Ollama API Tool Calling Example with the OpenAI SDK
Tool calling lets the model ask your code to run a function and then use the result in its answer. Below is a complete example with the OpenAI SDK against a hosted endpoint, plus streaming and the mistakes that cause most failures.
Updated

Key points
- Send your function as a JSON schema in tools; the model replies with tool_calls instead of text.
- You run the function, append the result as a role tool message, and call the API again.
- Always parse arguments defensively, because a model can return malformed or unexpected JSON.
- Streamed tool calls arrive in fragments that you must stitch together by index.
What the loop looks like
If you are looking for an ollama api tool calling example that works against a hosted endpoint, the shape is the standard OpenAI one. You describe your functions in a tools array. The model either answers in text or returns a tool_calls list naming the function and its arguments. The model never runs anything. Your code runs the function, sends the output back, and the model writes the final reply.
That makes one round trip of tool use two API calls:
- Send the user message and the tool definitions.
- Receive an assistant message with
tool_calls. Run each call locally. - Send the original messages, the assistant message, and one
role: "tool"message per call with its result. - Receive the final text answer.
The examples use base URL https://api.ollamaapi.com/v1 and model ID uncensored. The endpoint is a hosted service, so the key comes from the API key page. The same code also runs against a local Ollama server if you change the base URL, the key and the model name, as long as the local model supports tools.
A complete Python example
The example gives the model one function, get_order_status, backed by a small dictionary. A real application would query a database or an internal service at that point.
import json
from openai import OpenAI
client = OpenAI(base_url="https://api.ollamaapi.com/v1", api_key="YOUR_KEY")
ORDERS = {"A1001": "shipped", "A1002": "processing", "A1003": "delivered"}
def get_order_status(order_id: str) -> str:
return ORDERS.get(order_id, "not found")
tools = [{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Look up the status of an order by its ID.",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "Order ID such as A1001"}
},
"required": ["order_id"],
},
},
}]
messages = [{"role": "user", "content": "Where is order A1002?"}]
first = client.chat.completions.create(
model="uncensored",
messages=messages,
tools=tools,
tool_choice="auto",
)
msg = first.choices[0].message
if msg.tool_calls:
messages.append(msg) # keep the assistant turn with its tool_calls
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = get_order_status(**args)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})
final = client.chat.completions.create(
model="uncensored",
messages=messages,
tools=tools,
)
print(final.choices[0].message.content)
else:
print(msg.content)Three details matter here. The assistant message that contains tool_calls must go back into the history before the tool results, or the next request will be rejected or confused. Each tool message must carry the tool_call_id of the call it answers. And the second request should still include tools, because the model may want to call another function after seeing the first result.
The same request with curl
Testing with curl removes the SDK from the picture, which helps when you are unsure whether a problem is in your code or in the request.
curl https://api.ollamaapi.com/v1/chat/completions \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "uncensored",
"messages": [{"role": "user", "content": "Where is order A1002?"}],
"tools": [{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Look up the status of an order by its ID.",
"parameters": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"]
}
}
}],
"tool_choice": "auto"
}'A successful first reply has finish_reason set to tool_calls, an empty or null content, and a tool_calls array. The arguments field is a string holding JSON, not a nested object, which is why the Python example calls json.loads.
The tool_choice field controls how forceful the request is. auto lets the model decide. Passing an object that names a function asks for that specific function. Use auto unless you have a reason, because forcing a call on a message that does not need one produces invented arguments.
The same loop in Node
The Node version uses the official openai package and follows the same steps. This one handles more than one call per turn with Promise.all.
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.ollamaapi.com/v1",
apiKey: process.env.API_KEY,
});
const handlers = {
get_order_status: async ({ order_id }) =>
({ A1001: "shipped", A1002: "processing" })[order_id] ?? "not found",
};
const tools = [{
type: "function",
function: {
name: "get_order_status",
description: "Look up the status of an order by its ID.",
parameters: {
type: "object",
properties: { order_id: { type: "string" } },
required: ["order_id"],
},
},
}];
const messages = [{ role: "user", content: "Where is order A1001?" }];
const first = await client.chat.completions.create({
model: "uncensored", messages, tools,
});
const msg = first.choices[0].message;
if (msg.tool_calls?.length) {
messages.push(msg);
const results = await Promise.all(msg.tool_calls.map(async (call) => {
const args = JSON.parse(call.function.arguments);
const out = await handlers[call.function.name](args);
return { role: "tool", tool_call_id: call.id, content: String(out) };
}));
messages.push(...results);
const final = await client.chat.completions.create({
model: "uncensored", messages, tools,
});
console.log(final.choices[0].message.content);
} else {
console.log(msg.content);
}Streaming tool calls
With stream: true the reply arrives over SSE, and tool calls arrive in pieces. Each chunk carries a delta.tool_calls entry with an index. The first fragment for an index holds the call ID and function name. Later fragments add slices of the arguments string. You concatenate the slices per index, and only parse the JSON after the stream ends.
stream = client.chat.completions.create(
model="uncensored",
messages=[{"role": "user", "content": "Where is order A1003?"}],
tools=tools,
stream=True,
)
calls = {}
for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
for part in (delta.tool_calls or []):
slot = calls.setdefault(part.index, {"id": "", "name": "", "args": ""})
if part.id:
slot["id"] = part.id
if part.function and part.function.name:
slot["name"] = part.function.name
if part.function and part.function.arguments:
slot["args"] += part.function.arguments
for slot in calls.values():
print(slot["name"], json.loads(slot["args"]))Remember that a streamed request keeps its concurrency slot until the stream finishes. A full key allows 8 requests at once and 300 per minute, a trial key 2 and 60. If you run tool loops for many users in parallel, count each open stream against the first number.
Pitfalls and how to fix them
Malformed or missing arguments
The model is an open-weight model, and open models sometimes return arguments that are not valid JSON, leave out a required field, or invent an extra one. Wrap json.loads in a try block, validate against your schema, and when it fails, send the error back as a tool message. The model usually corrects itself on the next turn. Cap the loop at a few iterations so a stubborn failure cannot run forever.
Descriptions that are too vague
The model picks a function by reading its name and description. A description such as "gets data" gives it nothing to work with. Say what the function does, what the inputs mean, and include an example value in the parameter description.
Too many tools at once
Every tool definition counts as input tokens on every request. At $0.15 per 1M input tokens, ten tools of 150 tokens each add 1,500 tokens, which is $0.000225 per request, small but not zero at volume. More important, a long menu makes selection less reliable. Offer only the tools relevant to the current step.
Tool results that are too large
Whatever you return is fed back as input. Trim big database rows or web pages to the fields the model needs. The context window is 100,000 tokens in total, so a very large result can crowd out the conversation.
Mixing tools with JSON mode
JSON mode (response_format with json_object) is available, but use it for the final answer, not for turns where you want the model to call a function. Test the combination on your own prompts before relying on it.
Moving from local Ollama and what to check
If you built your first tool loop against a local Ollama server, almost nothing changes. Replace the base URL with https://api.ollamaapi.com/v1, use a real key, and set the model to uncensored. Native Ollama routes like /api/chat do not exist here. Only POST /v1/chat/completions and GET /v1/models are available, so code written for the native API needs the OpenAI-style call instead.
Before you ship, run this short checklist:
- Confirm that the key works with
GET /v1/models. - Log the
X-Request-Idheader for every call, so a bad run can be traced. - Watch
X-Balance-USDin the response headers if you run tool loops unattended. - Handle HTTP 429: wait on
rate_limitedorconcurrency, and honorRetry-After: 5on overload. - Set a hard cap on loop iterations.
A tool loop makes at least two requests per user question, so costs scale with that. For example, 2,000 loops with 1,200 input tokens and 150 output tokens per request, two requests each, come to 4,000 requests, 4.8M input tokens and 0.6M output tokens. That is $0.72 plus $0.30, or $1.02 in total. The pricing page has the top-up options, and the docs list the supported request fields. New users can try all of this with the $0.50 trial credit, which needs no card.
More guides
Questions and answers
Does the endpoint support function calling?
Yes. It accepts tools and tool_choice, returns tool_calls, streams them as deltas, and takes results back as messages with role tool.
Why does the second request fail after a tool call?
The usual cause is a missing assistant message or a tool message without the matching tool_call_id. Append the assistant turn that contains tool_calls first, then one tool message per call.
Can the model call several tools in one turn?
It can return a list with more than one entry, so loop over all of them and send one result for each. Do not assume the list has a single item.
Do I need the Ollama library for this?
No. The official OpenAI SDKs are enough. Point the base URL at the endpoint and use the model uncensored.
Your key is one form away
Create an account, copy the key, change the base URL. That is the whole setup.