LLM function calling in Python across 9 providers without rewriting your tool loop 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. 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. For function calling, the goal is simple: define the tool once and keep the same Python tool loop when the provider changes. Here is a minimal one-round example using OpenAI. For OpenAI, Anthropic, and Google: pip install llm-api-adapter The other provider integrations are installed through optional extras, for example: pip install "llm-api-adapter mistral,xai,qwen,kimi,deepseek,zai " Let's use an inventory lookup. The inventory value lives in application state, so the model cannot know the answer without calling the function. python from llm api adapter.models.tools import ToolSpec inventory tool = ToolSpec name="lookup inventory", description="Return the current inventory for a SKU in a warehouse region.", json schema={ "type": "object", "properties": { "sku": {"type": "string"}, "region": { "type": "string", "enum": "us-east", "eu-central" , }, }, "required": "sku", "region" , "additionalProperties": False, }, The function itself belongs to the application: INVENTORY = { "SKU-1042", "eu-central" : 37, "SKU-1042", "us-east" : 12, } def run tool name: str, arguments: dict - dict: if name = "lookup inventory": raise ValueError f"Unknown tool: {name}" key = arguments "sku" , arguments "region" return { "sku": arguments "sku" , "region": arguments "region" , "available units": INVENTORY.get key, 0 , } python import json import os from llm api adapter.models.messages.chat message import AIMessage, ToolMessage, UserMessage, from llm api adapter.universal adapter import UniversalLLMAPIAdapter adapter = UniversalLLMAPIAdapter organization="openai", model="gpt-5.6-sol", api key=os.environ "OPENAI API KEY" , messages = UserMessage "How many units of SKU-1042 are available in eu-central? " "Use lookup inventory and do not guess." first = adapter.chat messages=messages, max tokens=4096, tools= inventory tool , tool choice="auto", if not first.tool calls: print first.content else: messages.append AIMessage content=first.content or "", tool calls=first.tool calls, for tool call in first.tool calls: result = run tool tool call.name, tool call.arguments, messages.append ToolMessage tool call id=tool call.call id, content=json.dumps result , final = adapter.chat messages=messages, max tokens=4096, previous response=first, print final.content When the tool is called, the final answer should report that SKU-1042 has 37 units available in eu-central . The application-level flow is: ToolSpec ↓ model ↓ ToolCall ↓ Python function ↓ ToolMessage ↓ model ↓ final response With 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. The ToolSpec , ToolCall , ToolMessage , and execution logic above do not depend on OpenAI. For Anthropic, for example, the adapter configuration becomes: adapter = UniversalLLMAPIAdapter organization="anthropic", model="claude-sonnet-5", api key=os.environ "ANTHROPIC API KEY" , For Google: adapter = UniversalLLMAPIAdapter organization="google", model="gemini-3.8-flash", api key=os.environ "GOOGLE API KEY" , The same application-level tool loop is used for Mistral, xAI, Qwen, Kimi, DeepSeek, and Z.ai as well. The 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. Those differences stay in provider configuration rather than changing the tool contract. tool choice stops being portable All 57 model IDs currently registered in the adapter support: tool choice="auto" But auto does not guarantee that a tool will be called. It means the model decides whether a tool is needed. Forced tool selection is where provider and model differences start to appear. | Provider | auto | any | Named tool | |---|---|---|---| | OpenAI | Yes | Yes | Yes | | Anthropic | Yes | Yes | Yes | | | Yes | Yes | Yes | | Mistral | Yes | Yes | Yes | | xAI | Yes | Yes | Yes | | Qwen | Yes | Yes | Yes | | Kimi | Yes | Yes | No | | DeepSeek | Yes | Yes | Yes | | Z.ai | Yes | No | No | Model-specific details: 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. Z.ai's currently registered glm-5.3-flash supports application tools with tool choice="auto" only. That is why I use auto as the common baseline. The shared application contract is deliberately small: | Part of the tool flow | Support | |---|---| | Define an application tool | All 57 registered models | | Tool name and description | All 57 registered models | | JSON Schema tool arguments | All 57 registered models | | Receive a normalized ToolCall | All 57 registered models | | Parsed argument dictionary | All 57 registered models | | Tool call ID | All 57 registered models | | Execute the tool in application code | Application code | | Return a ToolMessage | All 57 registered models | | Continue after a tool result | All 57 registered models | | tool choice="auto" | All 57 registered models | 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. A normalized tool request gives the application the same fields: tool call.name tool call.arguments tool call.call id tool call.arguments is already a Python dictionary. The adapter also deliberately does not execute the tool: result = run tool tool call.name, tool call.arguments, That stays under application control. The point of the abstraction is not to make 9 APIs appear identical. It is to keep the part the application actually depends on stable. After a tool runs, application code adds the result in the same form: messages.append ToolMessage tool call id=tool call.call id, content=json.dumps result , What happens underneath can differ. For OpenAI models using the Responses API, previous response can map to a provider response ID and use server-side continuation. Other providers continue from explicit message history. DeepSeek keeps conversation history explicit as well, while previous response can carry matching reasoning-replay metadata rather than a server-side conversation ID. The application still asks for the same thing: continue after this tool result The provider-specific continuation mechanism stays below the adapter boundary. I deliberately stop the abstraction before tool execution. Consider these tools: get weather search documents create support ticket charge customer delete resource Receiving a tool request is an LLM API concern. Actually running that function is an application concern. The distinction becomes important when a tool has side effects. Imagine: model requests create support ticket ↓ application creates the ticket ↓ LLM continuation request fails Blindly retrying the whole operation could create the ticket twice. At that point the problem is no longer just function calling. It involves idempotency, retries, failover, checkpoints, and recovery after side effects. I 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. The repository also includes cross-provider tests for the shared tool-calling contract. Async does not require a different tool abstraction. Install the async extra: pip install "llm-api-adapter async " The async example below starts with a fresh message history rather than reusing the messages list modified by the synchronous example: async messages = UserMessage "How many units of SKU-1042 are available in eu-central? " "Use lookup inventory and do not guess." first = await adapter.achat messages=async messages, max tokens=4096, tools= inventory tool , tool choice="auto", if not first.tool calls: print first.content else: async messages.append AIMessage content=first.content or "", tool calls=first.tool calls, for tool call in first.tool calls: result = run tool tool call.name, tool call.arguments, async messages.append ToolMessage tool call id=tool call.call id, content=json.dumps result , final = await adapter.achat messages=async messages, max tokens=4096, previous response=first, print final.content ToolSpec , ToolCall , and ToolMessage stay unchanged. astream chat follows the same contract for streaming. The stable part of function calling ended up being: ToolSpec ↓ ToolCall ↓ application execution ↓ ToolMessage ↓ continuation The provider adapter handles the different API formats and continuation mechanisms underneath it. So the result is not 9 identical APIs. It is one Python function-calling loop that does not need to be rewritten every time the provider changes. The implementation and cross-provider tests are available in llm-api-adapter https://github.com/Inozem/llm api adapter .