Ollama Tool Calls: Fix String vs Object Argument Errors

ollamalocal-llmtool-callingtroubleshootingpython

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/chat object arguments from /v1/chat/completions JSON strings.
  • Prevent false empty-response failures when message.content is 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.

RoutePath to tool callsfunction.arguments wire typeExpected follow-up fields in role: "tool"
Native Ollama chatresponse["message"]["tool_calls"]JSON object (dict in Python)tool_name and string content
OpenAI-compatible chatresponse["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:

  1. Tool result content must serialize as a string (or valid content part list), not a raw dictionary. If your Python code passes "content": {"free_gb": 142} without calling json.dumps, the server rejects the payload. In openai/openai.go, a non-string, non-array content without tool_calls hits the default branch and returns invalid message content type.
  2. Keep the assistant’s tool_calls message in the conversation history before appending the role: "tool" message. On /v1/chat/completions, if your tool message omits name and supplies only tool_call_id, FromChatRequest calls nameFromToolCallID(r.Messages, msg.ToolCallID). That helper scans earlier messages in r.Messages to find the matching tc.ID and recover tc.Function.Name. Dropping the assistant turn from the message array breaks that lookup.
  3. Pass tool_Calls back in the format the route expects. If you replay history to /v1/chat/completions, FromCompletionToolCall runs json.Unmarshal([]byte(tc.Function.Arguments), ...) on the assistant turn’s arguments string and returns invalid tool call arguments if you replaced that string with an unserialized object or corrupted text.
Follow-up mistakeServer or client symptomFix
Passing a Python dict directly in role: "tool" contentHTTP 400 / invalid message content typeSerialize 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 argumentsValueError in client or invalid tool call arguments on serverRequire a JSON object matching your tool’s parameter schema
Model invents argument keys or wrong value typesTool function raises KeyError or TypeErrorValidate 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 fixing does not support tools in Cline, Continue, and Claude Code walks through template checks and model selection.
  • HTTP 404 on model name or route: Check whether you appended /api/chat to 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/completions contract 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 arguments on /v1/chat/completions: If json.loads fails with Unterminated string or Expecting value partway through a long tool call, check done_reason (or finish_reason: "length"). Either num_predict (max_tokens) capped generation mid-call, or your context window (num_ctx) truncated the prompt. If increasing num_ctx causes 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

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?

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.