{"slug": "llm-function-calling-in-python-across-9-providers-without-rewriting-your-tool", "title": "LLM function calling in Python across 9 providers without rewriting your tool loop", "summary": "A developer released llm-api-adapter, an open-source Python library that provides a unified interface for function calling across 57 model IDs from nine LLM providers, including OpenAI, Anthropic, Google, Mistral, xAI, Qwen, Kimi, DeepSeek, and Z.ai. The library lets developers define a tool once with a ToolSpec and keep the same tool-call loop when switching providers, with core providers installed via pip and others through optional extras.", "body_md": "I built [`llm-api-adapter`](https://github.com/Inozem/llm_api_adapter) as a multi-provider Python interface for 57 registered model IDs from 9 LLM providers: OpenAI, Anthropic, Google, Mistral, xAI, Qwen, Kimi, DeepSeek, and Z.ai.\n\nFor function calling, the goal is simple: define the tool once and keep the same Python tool loop when the provider changes.\n\nHere is a minimal one-round example using OpenAI.\n\nFor OpenAI, Anthropic, and Google:\n\n```\npip install llm-api-adapter\n```\n\nThe other provider integrations are installed through optional extras, for example:\n\n```\npip install \"llm-api-adapter[mistral,xai,qwen,kimi,deepseek,zai]\"\n```\n\nLet's use an inventory lookup. The inventory value lives in application state, so the model cannot know the answer without calling the function.\n\n``` python\nfrom llm_api_adapter.models.tools import ToolSpec\n\ninventory_tool = ToolSpec(\n    name=\"lookup_inventory\",\n    description=\"Return the current inventory for a SKU in a warehouse region.\",\n    json_schema={\n        \"type\": \"object\",\n        \"properties\": {\n            \"sku\": {\"type\": \"string\"},\n            \"region\": {\n                \"type\": \"string\",\n                \"enum\": [\"us-east\", \"eu-central\"],\n            },\n        },\n        \"required\": [\"sku\", \"region\"],\n        \"additionalProperties\": False,\n    },\n)\n```\n\nThe function itself belongs to the application:\n\n```\nINVENTORY = {\n    (\"SKU-1042\", \"eu-central\"): 37,\n    (\"SKU-1042\", \"us-east\"): 12,\n}\n\ndef run_tool(name: str, arguments: dict) -> dict:\n    if name != \"lookup_inventory\":\n        raise ValueError(f\"Unknown tool: {name}\")\n\n    key = (arguments[\"sku\"], arguments[\"region\"])\n\n    return {\n        \"sku\": arguments[\"sku\"],\n        \"region\": arguments[\"region\"],\n        \"available_units\": INVENTORY.get(key, 0),\n    }\npython\nimport json\nimport os\n\nfrom llm_api_adapter.models.messages.chat_message import (\n    AIMessage,\n    ToolMessage,\n    UserMessage,\n)\nfrom llm_api_adapter.universal_adapter import UniversalLLMAPIAdapter\n\nadapter = UniversalLLMAPIAdapter(\n    organization=\"openai\",\n    model=\"gpt-5.6-sol\",\n    api_key=os.environ[\"OPENAI_API_KEY\"],\n)\n\nmessages = [\n    UserMessage(\n        \"How many units of SKU-1042 are available in eu-central? \"\n        \"Use lookup_inventory and do not guess.\"\n    )\n]\n\nfirst = adapter.chat(\n    messages=messages,\n    max_tokens=4096,\n    tools=[inventory_tool],\n    tool_choice=\"auto\",\n)\n\nif not first.tool_calls:\n    print(first.content)\n\nelse:\n    messages.append(\n        AIMessage(\n            content=first.content or \"\",\n            tool_calls=first.tool_calls,\n        )\n    )\n\n    for tool_call in first.tool_calls:\n        result = run_tool(\n            tool_call.name,\n            tool_call.arguments,\n        )\n\n        messages.append(\n            ToolMessage(\n                tool_call_id=tool_call.call_id,\n                content=json.dumps(result),\n            )\n        )\n\n    final = adapter.chat(\n        messages=messages,\n        max_tokens=4096,\n        previous_response=first,\n    )\n\n    print(final.content)\n```\n\nWhen the tool is called, the final answer should report that `SKU-1042` has **37 units** available in `eu-central`.\n\nThe application-level flow is:\n\n```\nToolSpec\n   ↓\nmodel\n   ↓\nToolCall\n   ↓\nPython function\n   ↓\nToolMessage\n   ↓\nmodel\n   ↓\nfinal response\n```\n\nWith `tool_choice=\"auto\"`, the model is allowed to answer without calling a tool. That is why the example handles both cases: if no `ToolCall` is returned, the application simply uses the model response; otherwise it executes the tool loop.\n\nThe `ToolSpec`, `ToolCall`, `ToolMessage`, and execution logic above do not depend on OpenAI.\n\nFor Anthropic, for example, the adapter configuration becomes:\n\n```\nadapter = UniversalLLMAPIAdapter(\n    organization=\"anthropic\",\n    model=\"claude-sonnet-5\",\n    api_key=os.environ[\"ANTHROPIC_API_KEY\"],\n)\n```\n\nFor Google:\n\n```\nadapter = UniversalLLMAPIAdapter(\n    organization=\"google\",\n    model=\"gemini-3.8-flash\",\n    api_key=os.environ[\"GOOGLE_API_KEY\"],\n)\n```\n\nThe same application-level tool loop is used for Mistral, xAI, Qwen, Kimi, DeepSeek, and Z.ai as well.\n\nThe provider setup itself is not always identical. External integrations require their corresponding package extra, and some APIs have additional connection parameters. Qwen Model Studio, for example, requires a `workspace_id` on every request.\n\nThose differences stay in provider configuration rather than changing the tool contract.\n\n`tool_choice` stops being portable\nAll 57 model IDs currently registered in the adapter support:\n\n```\ntool_choice=\"auto\"\n```\n\nBut `auto` does **not** guarantee that a tool will be called. It means the model decides whether a tool is needed.\n\nForced tool selection is where provider and model differences start to appear.\n\n| Provider | `auto` | `any` | Named tool | \n|---|---|---|---|\n| OpenAI | Yes | Yes | Yes | \n| Anthropic | Yes | Yes* | Yes* | \n|  | Yes | Yes | Yes | \n| Mistral | Yes | Yes | Yes | \n| xAI | Yes | Yes | Yes | \n| Qwen | Yes | Yes* | Yes* | \n| Kimi | Yes | Yes* | No | \n| DeepSeek | Yes | Yes | Yes* | \n| Z.ai | Yes | No | No | \n\n* Model-specific details:\n\n`claude-fable-5-1` and `claude-opus-5-5` currently support only `auto` and `none`; the other registered Claude models support forced tool selection.`any` and named tools, but thinking must be disabled for those calls. The adapter handles this and warns unless `reasoning_level=\"none\"` was already explicit.`kimi-k3` supports `kimi-k2.6` supports neither forced `deepseek-flash` supports named tool selection, but named selection and tool-result continuation require reasoning to be disabled for that tool loop.\nZ.ai's currently registered `glm-5.3-flash` supports application tools with `tool_choice=\"auto\"` only.\n\nThat is why I use `auto` as the common baseline.\n\nThe shared application contract is deliberately small:\n\n| Part of the tool flow | Support | \n|---|---|\n| Define an application tool | All 57 registered models | \n| Tool name and description | All 57 registered models | \n| JSON Schema tool arguments | All 57 registered models* | \n| Receive a normalized `ToolCall` | All 57 registered models | \n| Parsed argument dictionary | All 57 registered models | \n| Tool call ID | All 57 registered models | \n| Execute the tool in application code | Application code | \n| Return a `ToolMessage` | All 57 registered models | \n| Continue after a tool result | All 57 registered models | \n| `tool_choice=\"auto\"` | All 57 registered models | \n\n* All 57 registered models accept JSON Schema for application-tool arguments. The exact schema vocabulary accepted by the underlying APIs can still differ; this does not remove any registered model from the basic tool-calling contract.\n\nA normalized tool request gives the application the same fields:\n\n```\ntool_call.name\ntool_call.arguments\ntool_call.call_id\n```\n\n`tool_call.arguments` is already a Python dictionary.\n\nThe adapter also deliberately does not execute the tool:\n\n```\nresult = run_tool(\n    tool_call.name,\n    tool_call.arguments,\n)\n```\n\nThat stays under application control.\n\nThe point of the abstraction is not to make 9 APIs appear identical. It is to keep the part the application actually depends on stable.\n\nAfter a tool runs, application code adds the result in the same form:\n\n```\nmessages.append(\n    ToolMessage(\n        tool_call_id=tool_call.call_id,\n        content=json.dumps(result),\n    )\n)\n```\n\nWhat happens underneath can differ.\n\nFor OpenAI models using the Responses API, `previous_response` can map to a provider response ID and use server-side continuation.\n\nOther providers continue from explicit message history.\n\nDeepSeek keeps conversation history explicit as well, while `previous_response` can carry matching reasoning-replay metadata rather than a server-side conversation ID.\n\nThe application still asks for the same thing:\n\n```\ncontinue after this tool result\n```\n\nThe provider-specific continuation mechanism stays below the adapter boundary.\n\nI deliberately stop the abstraction before tool execution.\n\nConsider these tools:\n\n```\nget_weather()\nsearch_documents()\ncreate_support_ticket()\ncharge_customer()\ndelete_resource()\n```\n\nReceiving a tool request is an LLM API concern.\n\nActually running that function is an application concern.\n\nThe distinction becomes important when a tool has side effects.\n\nImagine:\n\n```\nmodel requests create_support_ticket\n        ↓\napplication creates the ticket\n        ↓\nLLM continuation request fails\n```\n\nBlindly retrying the whole operation could create the ticket twice.\n\nAt that point the problem is no longer just function calling. It involves idempotency, retries, failover, checkpoints, and recovery after side effects.\n\nI keep those concerns outside `llm-api-adapter`. I built a separate [` llm-api-resilience`](https://github.com/Inozem/llm-api-resilience) layer for retries, failover, circuit breakers, and checkpoint-based recovery instead of turning the provider adapter into an agent runtime.\n\nThe repository also includes cross-provider tests for the shared tool-calling contract.\n\nAsync does not require a different tool abstraction.\n\nInstall the async extra:\n\n```\npip install \"llm-api-adapter[async]\"\n```\n\nThe async example below starts with a fresh message history rather than reusing the `messages` list modified by the synchronous example:\n\n```\nasync_messages = [\n    UserMessage(\n        \"How many units of SKU-1042 are available in eu-central? \"\n        \"Use lookup_inventory and do not guess.\"\n    )\n]\n\nfirst = await adapter.achat(\n    messages=async_messages,\n    max_tokens=4096,\n    tools=[inventory_tool],\n    tool_choice=\"auto\",\n)\n\nif not first.tool_calls:\n    print(first.content)\n\nelse:\n    async_messages.append(\n        AIMessage(\n            content=first.content or \"\",\n            tool_calls=first.tool_calls,\n        )\n    )\n\n    for tool_call in first.tool_calls:\n        result = run_tool(\n            tool_call.name,\n            tool_call.arguments,\n        )\n\n        async_messages.append(\n            ToolMessage(\n                tool_call_id=tool_call.call_id,\n                content=json.dumps(result),\n            )\n        )\n\n    final = await adapter.achat(\n        messages=async_messages,\n        max_tokens=4096,\n        previous_response=first,\n    )\n\n    print(final.content)\n```\n\n`ToolSpec`, `ToolCall`, and `ToolMessage` stay unchanged. `astream_chat()` follows the same contract for streaming.\n\nThe stable part of function calling ended up being:\n\n```\nToolSpec\n    ↓\nToolCall\n    ↓\napplication execution\n    ↓\nToolMessage\n    ↓\ncontinuation\n```\n\nThe provider adapter handles the different API formats and continuation mechanisms underneath it.\n\nSo the result is not 9 identical APIs.\n\nIt is one Python function-calling loop that does not need to be rewritten every time the provider changes.\n\nThe implementation and cross-provider tests are available in [`llm-api-adapter`](https://github.com/Inozem/llm_api_adapter).", "url": "https://wpnews.pro/news/llm-function-calling-in-python-across-9-providers-without-rewriting-your-tool", "canonical_source": "https://dev.to/inozem/llm-function-calling-in-python-across-9-providers-without-rewriting-your-tool-loop-15bn", "published_at": "2026-10-03 12:28:59+00:00", "updated_at": "2026-10-03 12:37:54.614857+00:00", "lang": "en", "topics": ["ai-tools", "large-language-models", "developer-tools", "ai-agents", "ai-products"], "entities": ["llm-api-adapter", "OpenAI", "Anthropic", "Google", "Mistral", "xAI", "Qwen", "DeepSeek"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/llm-function-calling-in-python-across-9-providers-without-rewriting-your-tool", "markdown": "https://wpnews.pro/news/llm-function-calling-in-python-across-9-providers-without-rewriting-your-tool.md", "text": "https://wpnews.pro/news/llm-function-calling-in-python-across-9-providers-without-rewriting-your-tool.txt", "jsonld": "https://wpnews.pro/news/llm-function-calling-in-python-across-9-providers-without-rewriting-your-tool.jsonld"}}