Ollama Tool Calls: Fix String vs Object Argument Errors
TL;DR: Ollama’s native /api/chat endpoint returns tool_calls[].function.arguments as a parsed JSON object, while /v1/chat/completions returns arguments as a JSON-encoded string. Both endpoints often leave message.content empty during a tool turn. Normalize the payload by endpoint and serialize tool results to strings before blaming the model.
What you’ll be able to do:
- Distinguish native
/api/chatobject arguments from/v1/chat/completionsJSON strings. - Prevent false empty-response failures when
message.contentis blank during a tool call. - Return valid
role: "tool"messages with stringified content and matching call identifiers.
Honest take: Check which route your client calls before changing models or prompts; most local tool-call parser crashes come from mixing native and OpenAI response shapes.
Why do Ollama tool arguments fail with a Python TypeError?
Your parser fails because Ollama exposes two chat routes that serialize function.arguments differently. On November 6, 2026, we inspected the upstream Ollama API type definitions and OpenAI compatibility converter at commit 6362e0a30887bdd4f69e07a94e8a513383576204. In the native /api/chat struct, ToolCallFunction.Arguments is an ordered map that marshals directly as a JSON object. In the OpenAI-compatible /v1/chat/completions struct, ToToolCalls runs json.Marshal on that map and stores the result as a string.
When we ran both response shapes through Python 3.13.5 on Linux, the two mismatched access patterns failed immediately with opposite TypeError exceptions:
Native json.loads TypeError: the JSON object must be str, bytes or bytearray, not dict
OpenAI dict-index TypeError: string indices must be integers, not 'str'
The first exception happens when code written for the OpenAI SDK calls json.loads(call["function"]["arguments"]) against a native /api/chat payload. The second happens when code written for native /api/chat indexes call["function"]["arguments"]["mount"] directly on a /v1/chat/completions payload. Neither error means the local model produced invalid JSON.
| Route | Path to tool calls | function.arguments wire type | Expected follow-up fields in role: "tool" |
|---|---|---|---|
| Native Ollama chat | response["message"]["tool_calls"] | JSON object (dict in Python) | tool_name and string content |
| OpenAI-compatible chat | response["choices"][0]["message"]["tool_calls"] | JSON string (str in Python) | tool_call_id (plus optional name) and string content |
Check the URL path in your client configuration first. As documented in Ollama’s chat endpoint reference and OpenAI compatibility guide, switching base URLs between /api/chat and /v1/chat/completions changes the outer envelope as well as the arguments field type.
Why is message content empty when a tool call succeeds?
An empty message.content string is normal when a model decides to invoke a tool instead of writing a final user-facing reply. Ollama’s tool-calling documentation shows that a tool turn populates message.tool_calls while content can remain empty (""). In the OpenAI compatibility layer, ToStreamChunks and FromChatRequest also preserve empty string content whenever ToolCalls is non-empty.
A common client bug is checking if not message.get("content"): before inspecting message.get("tool_calls"). That guard works for plain chat completions, but it misclassifies every tool invocation as a blank answer or generation failure.
Compare the two non-streaming envelopes side by side. A native /api/chat response places tool_calls inside the top-level message object and uses an object for arguments:
{
"model": "qwen3-coder:30b",
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_native_01",
"function": {
"index": 0,
"name": "get_disk_free",
"arguments": {
"mount": "/models",
"unit": "GB"
}
}
}
]
},
"done": true
}
The equivalent /v1/chat/completions response wraps the message inside choices[0], sets finish_reason to "tool_calls", and encodes arguments as a string:
{
"id": "chatcmpl-901",
"object": "chat.completion",
"model": "qwen3-coder:30b",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_oai_01",
"index": 0,
"type": "function",
"function": {
"name": "get_disk_free",
"arguments": "{\"mount\":\"/models\",\"unit\":\"GB\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}
Notice that both payloads are valid. If you use streaming mode ("stream": true on /api/chat), make sure your line reader parses each newline-delimited JSON chunk separately before inspecting tool_calls. If your parser crashes with Extra data before reaching the tool check, fix the stream framing first with our Ollama NDJSON streaming response guide.
How do you normalize tool arguments across both Ollama routes?
Inspect the envelope first, accept either a dict or a JSON-encoded str for function.arguments, and verify that decoded strings produce a JSON object. Save the following standard-library script as check_ollama_tool_calls.py. It tests both envelopes and negative failure cases locally on CPU without loading model weights or calling a remote server.
import json
def extract_tool_calls(payload):
if not isinstance(payload, dict):
raise ValueError("response payload must be a JSON object")
if "choices" in payload:
choices = payload.get("choices")
if not isinstance(choices, list) or not choices:
raise ValueError("choices must be a nonempty list")
message = choices[0].get("message")
endpoint = "openai-v1"
elif "message" in payload:
message = payload.get("message")
endpoint = "native-api"
else:
raise ValueError("missing message or choices[0].message")
if not isinstance(message, dict):
raise ValueError("message must be a JSON object")
raw_calls = message.get("tool_calls") or []
if not isinstance(raw_calls, list):
raise ValueError("tool_calls must be a list")
normalized = []
for index, call in enumerate(raw_calls):
if not isinstance(call, dict):
raise ValueError(f"tool_calls[{index}] must be an object")
fn = call.get("function")
if not isinstance(fn, dict):
raise ValueError(f"tool_calls[{index}].function must be an object")
name = fn.get("name")
if not isinstance(name, str) or not name.strip():
raise ValueError(f"tool_calls[{index}].function.name is missing")
raw_args = fn.get("arguments")
if isinstance(raw_args, dict):
args = raw_args
elif isinstance(raw_args, str):
try:
parsed = json.loads(raw_args)
except json.JSONDecodeError as exc:
raise ValueError(
f"tool_calls[{index}] invalid JSON string: {exc}"
) from exc
if not isinstance(parsed, dict):
raise ValueError(
f"tool_calls[{index}] arguments must decode to an object"
)
args = parsed
else:
raise ValueError(
f"tool_calls[{index}] unsupported type: {type(raw_args).__name__}"
)
normalized.append({
"endpoint": endpoint,
"id": call.get("id") or f"call_{index}",
"name": name,
"arguments": args,
})
return endpoint, message.get("content") or "", normalized
def build_tool_reply(endpoint, call_info, result_obj):
content = (
result_obj if isinstance(result_obj, str) else json.dumps(result_obj)
)
if endpoint == "native-api":
return {
"role": "tool",
"tool_name": call_info["name"],
"content": content,
}
return {
"role": "tool",
"tool_call_id": call_info["id"],
"name": call_info["name"],
"content": content,
}
When we executed extract_tool_calls and build_tool_reply with Python 3.13.5 against the two fixtures shown earlier, both routes produced the same normalized Python dictionary ({'mount': '/models', 'unit': 'GB'}) while emitting route-specific role: "tool" messages:
native -> native-api content_len= 0 args= {'mount': '/models', 'unit': 'GB'} reply= {"role": "tool", "tool_name": "get_disk_free", "content": "{\"free_gb\": 142}"}
openai -> openai-v1 content_len= 0 args= {'mount': '/models', 'unit': 'GB'} reply= {"role": "tool", "tool_call_id": "call_oai_01", "name": "get_disk_free", "content": "{\"free_gb\": 142}"}
Python’s json module documentation specifies that json.loads accepts str, bytes, or bytearray and raises TypeError for already-decoded objects, while json.JSONDecodeError reports malformed string syntax. Keeping the isinstance(raw_args, dict) branch separate from the isinstance(raw_args, str) branch tells you immediately whether a failure came from the transport contract or from truncated model output.
What breaks when sending the tool output back to Ollama?
Even after you parse the tool call and run your local function, the second request in the loop can fail if you format the role: "tool" message carelessly. Inspecting FromChatRequest in openai/openai.go reveals three strict expectations:
- Tool result
contentmust serialize as a string (or valid content part list), not a raw dictionary. If your Python code passes"content": {"free_gb": 142}without callingjson.dumps, the server rejects the payload. Inopenai/openai.go, a non-string, non-arraycontentwithouttool_callshits thedefaultbranch and returnsinvalid message content type. - Keep the assistant’s
tool_callsmessage in the conversation history before appending therole: "tool"message. On/v1/chat/completions, if yourtoolmessage omitsnameand supplies onlytool_call_id,FromChatRequestcallsnameFromToolCallID(r.Messages, msg.ToolCallID). That helper scans earlier messages inr.Messagesto find the matchingtc.IDand recovertc.Function.Name. Dropping the assistant turn from the message array breaks that lookup. - Pass
tool_Callsback in the format the route expects. If you replay history to/v1/chat/completions,FromCompletionToolCallrunsjson.Unmarshal([]byte(tc.Function.Arguments), ...)on the assistant turn’sargumentsstring and returnsinvalid tool call argumentsif you replaced that string with an unserialized object or corrupted text.
| Follow-up mistake | Server or client symptom | Fix |
|---|---|---|
Passing a Python dict directly in role: "tool" content | HTTP 400 / invalid message content type | Serialize tool return values with json.dumps(...) |
Dropping the assistant tool_calls turn before sending role: "tool" | Tool result lacks context or nameFromToolCallID resolves "" | Append the assistant message first, then append the tool message |
Sending a JSON string containing a bare number or list as arguments | ValueError in client or invalid tool call arguments on server | Require a JSON object matching your tool’s parameter schema |
| Model invents argument keys or wrong value types | Tool function raises KeyError or TypeError | Validate parsed arguments against your parameter schema before execution |
If the envelope parses cleanly into a Python dict but the model still invents parameter names or wrong value types, that is a schema validation problem rather than an endpoint mismatch. Use our Ollama structured output JSON Schema validation guide to enforce required keys and types before invoking your Python function.
When is a tool-call failure actually a model, proxy, or memory problem?
Sometimes your request never reaches the argument parser because an earlier layer fails. Separate those cases before rewriting your client:
- HTTP 400
does not support tools: The selected model template in Ollama does not declare tool support, or your editor extension pointed at a non-tool tag. Our sister site’s guide to fixingdoes not support toolsin Cline, Continue, and Claude Code walks through template checks and model selection. - HTTP 404 on model name or route: Check whether you appended
/api/chatto a base URL that already ends in/v1, or requested a tag absent from/api/tags. Follow our Ollama model-not-found and server tag check to verify the endpoint and inventory. - A proxy rewrites the payload: If your application talks to an OpenAI-compatible gateway such as LiteLLM in front of Ollama, the client sees the
/v1/chat/completionscontract while the gateway translates to the backend. Our sister site’s LiteLLM and Ollama local proxy setup explains how to inspect that boundary. - Truncated JSON string inside
argumentson/v1/chat/completions: Ifjson.loadsfails withUnterminated stringorExpecting valuepartway through a long tool call, checkdone_reason(orfinish_reason: "length"). Eithernum_predict(max_tokens) capped generation mid-call, or your context window (num_ctx) truncated the prompt. If increasingnum_ctxcauses VRAM spillover or out-of-memory errors, check your model and KV cache footprint in the VRAM calculator.
Honest take: normalize the boundary once and log the raw envelope
Do not swap models, re-pull multi-gigabyte GGUF files, or shop for a larger GPU because a Python script threw TypeError on function.arguments. That exception is a deterministic type mismatch between /api/chat (object) and /v1/chat/completions (JSON string). Handle both types in a ten-line adapter, check tool_calls before rejecting empty content, and always json.dumps the tool output string when appending the role: "tool" reply.
FAQ
Does Ollama return function.arguments as a string or a dictionary?
It depends on the endpoint you call. Ollama’s native /api/chat endpoint returns function.arguments as a JSON object (dict in Python), whereas Ollama’s OpenAI-compatible /v1/chat/completions endpoint marshals function.arguments into a JSON-encoded string (str in Python).
Why is message.content empty when Ollama calls a tool?
When a model emits a tool call, its structured action lives in message.tool_calls rather than message.content. Your client should check whether tool_calls is non-empty before treating an empty content string as a failed or blank response.
How should I format the role: "tool" response message in Ollama?
Serialize your tool’s output to a string (for example with json.dumps(result)) and place it in content. On /api/chat, include "tool_name" with the function’s name; on /v1/chat/completions, include "tool_call_id" matching the assistant’s tool_calls[].id (and keep the assistant’s tool_calls message in the messages list so Ollama can map the ID back to the function name).
Sources
- Tool calling capabilities — Ollama Documentation
- Generate a chat completion (
/api/chat) — Ollama API Reference - OpenAI compatibility (
/v1/chat/completions) — Ollama Documentation - Native API
MessageandToolCallFunctionstructs (api/types.go) — Ollama GitHub - OpenAI compatibility
ToToolCallsandFromChatRequest(openai/openai.go) — Ollama GitHub jsonencoder and decoder reference — Python 3 Documentation
Last updated November 6, 2026. Official Ollama documentation and source structs at commit 6362e0a30887bdd4f69e07a94e8a513383576204 were verified and local Python 3.13.5 parser fixtures were executed on this date; no model inference benchmark was performed.
Was this article helpful?
Thanks for the feedback — it helps improve future articles.
Need hands-on help?
I offer 1-on-1 technical consulting for local AI setup, GPU selection, and AI coding tool configuration — same topics covered on this site.
Book a session — $49 / hour →Get the numbers before you buy
New GPU and mini-PC benchmarks, VRAM thresholds, and price checks — sent when there's something worth acting on, not on a schedule. No spam, unsubscribe anytime.