Developer guide
Tool calling with an OpenAI-compatible LLM API
Describe each function with a JSON Schema in the tools array. When the model wants one, the reply has finish_reason tool_calls and a list of calls with JSON arguments. Run the function yourself, append the result as a tool message with the matching tool_call_id, and call the API again until the model answers in text. Validate arguments and cap the number of rounds.
Test status, 14 November 2026: The Python and Node.js loops were run unchanged against the mock OpenAI-compatible server in this site's repository, which returns a tool call and then a final answer. Tool-calling quality of real models was not tested. Environment: Python 3.12.4 with openai 3.51.0; Node.js 22.19.0 with openai 7.57.0. Not yet run against a live provider endpoint.
How the loop works
Tool calling, also called function calling, lets a model ask your code to do something it cannot do itself, such as look up data or run a calculation. The model never executes anything. A round trip looks like this:
- You send messages plus a
toolsarray, each tool with a name, a description and a JSON Schema for its parameters. - The model either answers in text, or returns
finish_reason: "tool_calls"with one or more calls, each with anid, a function name and arguments as a JSON string. - Your code validates the arguments, runs the function, and appends two things to the conversation: the assistant message with the calls, and a
toolmessage per call containing the result and the matchingtool_call_id. - You call the API again with the longer conversation. Repeat until the model answers in text.
Python
"""A complete tool-calling loop: the model asks for a function, we run it,
send the result back, and the model writes the final answer."""
import json
import os
from openai import OpenAI
client = OpenAI(base_url=os.environ["LLM_BASE_URL"], api_key=os.environ["LLM_API_KEY"], timeout=60.0)
MODEL = os.environ["LLM_MODEL"]
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current weather for a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "City name, e.g. Lisbon"}},
"required": ["city"],
"additionalProperties": False,
},
},
}
]
def get_weather(city: str) -> dict:
# Replace with a real lookup. Never execute code or shell commands that a model writes.
return {"city": city, "temperature_c": 19, "conditions": "clear"}
messages = [{"role": "user", "content": "Should I take an umbrella in Lisbon today?"}]
for _ in range(5): # hard limit on round trips
reply = client.chat.completions.create(model=MODEL, messages=messages, tools=tools, tool_choice="auto")
message = reply.choices[0].message
if not message.tool_calls:
print(message.content)
break
messages.append(message.model_dump(exclude_none=True))
for call in message.tool_calls:
if call.function.name != "get_weather":
result = {"error": f"unknown tool {call.function.name}"}
else:
try:
args = json.loads(call.function.arguments or "{}")
result = get_weather(str(args["city"]))
except (json.JSONDecodeError, KeyError) as e:
result = {"error": f"bad arguments: {e}"}
messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})
else:
print("Stopped after 5 rounds without a final answer.")
Node.js
// A complete tool-calling loop with the OpenAI Node SDK.
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: process.env.LLM_BASE_URL, apiKey: process.env.LLM_API_KEY })
const model = process.env.LLM_MODEL
const tools = [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Current weather for a city.',
parameters: {
type: 'object',
properties: { city: { type: 'string', description: 'City name, e.g. Lisbon' } },
required: ['city'],
additionalProperties: false,
},
},
},
]
// Replace with a real lookup. Never execute code or shell commands that a model writes.
const handlers = {
get_weather: ({ city }) => ({ city, temperature_c: 19, conditions: 'clear' }),
}
const messages = [{ role: 'user', content: 'Should I take an umbrella in Lisbon today?' }]
let answered = false
for (let round = 0; round < 5 && !answered; round++) {
const reply = await client.chat.completions.create({ model, messages, tools, tool_choice: 'auto' })
const message = reply.choices[0].message
if (!message.tool_calls?.length) {
console.log(message.content)
answered = true
break
}
messages.push(message)
for (const call of message.tool_calls) {
let result
try {
const handler = handlers[call.function.name]
if (!handler) throw new Error(`unknown tool ${call.function.name}`)
result = handler(JSON.parse(call.function.arguments || '{}'))
} catch (err) {
result = { error: err.message }
}
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) })
}
}
if (!answered) console.log('Stopped after 5 rounds without a final answer.')
Both versions do the same things on purpose:
- Cap the rounds. A model can keep calling tools; five rounds is a reasonable ceiling for most tasks.
- Parse arguments defensively. The arguments are a string the model wrote. It can be invalid JSON or miss a required field. Return an error message as the tool result instead of crashing, so the model can try again.
- Allow-list functions. Only names you defined are run; anything else gets an error result.
- Never run model-written code or shell commands without a sandbox and a human decision. A refusal-reduced model will follow instructions it finds in documents or web pages, which makes prompt injection through tool results a real risk.
Writing good tool definitions
- Give each tool one job and a description that says when to use it.
- Use
requiredandadditionalProperties: falsein the schema. Some servers enforce the schema; many open-weight stacks do not, which is why validation in your code still matters. - Keep the result small and factual. Large tool results use context and cost input tokens on every later round.
Provider and model differences
Whether tool calling works depends on three layers: the model’s training, the chat template, and the server’s parser that turns the model’s text into structured tool_calls. Model cards often document the parser: GLM-4.7-Flash’s card, for example, serves it with a dedicated GLM tool-call parser in vLLM and SGLang. A provider can accept the tools parameter while the model behind it handles tools poorly.
Documented differences we found:
- MolochAI supports
toolsandtool_choice, says quality can vary by server, and sends streamed tool arguments whole rather than in fragments. - Featherless, Venice and OpenRouter flag tool support per model in their model lists, so check the specific model.
- In the OpenAI streaming format, arguments can arrive in fragments to concatenate by
index; see the streaming guide.
Test tool calls with your own tools before committing to a model. Our coding model page lists which catalogued models’ cards mention tool use, and how to evaluate an LLM for coding covers testing tool calls as part of a task set.
Questions
Does a refusal-reduced model call tools better?
There is no evidence that it does. Tool calling depends on the base model's training and on the server's parser; reducing refusals does not add either.
Can the model run my functions directly?
No. The model only proposes a call with arguments. Your code decides whether to run it, which is where you enforce limits and permissions.
Sources
- Function calling guide, OpenAI
- Chat Completions API reference, OpenAI
- OpenAI compatibility (tool notes), MolochAI
- zai-org/GLM-4.7-Flash (serving examples with a tool-call parser), Hugging Face
Found an error? Report it. We log material corrections publicly.