Environment setup
Use the same local-lab folder from lesson 01 (uv, Python 3.11+, and uv add numpy). If you skipped it, do the setup in lesson 01 first.
Install Ollama from ollama.com/download. Check it with ollama --version.
Install llama.cpp. On macOS: brew install llama.cpp. On Linux or Windows, download a release from github.com/ggml-org/llama.cpp/releases. Check it with llama-server --version.
The Ollama case
Ollama #18944 (Ollama 0.40.3): with "format": "json" on /api/chat, the model cannot call tools. It writes the call as JSON text in content instead:
{
"message": {
"role": "assistant",
"content": "{\"name\": \"time\", \"parameters\": {}}"
}
}
No tool_calls field. If your code only checks tool_calls, the tool never runs and the user sees raw JSON.
The llama.cpp case
llama.cpp #30321: when a JSON schema lists an optional field before a required one, the grammar only allows required fields first. Many models then skip the optional field. The JSON is still valid, so nothing warns you. llama.cpp #30256 (lesson 02) is another JSON schema problem: thinking leaks in.
The rule
- In a step that may call a tool, send
toolsand noformatorresponse_format. - In a step that must return data, send a schema and no
tools. - In schemas, list required fields first. Check optional fields after parsing and log when they are missing.
- If you need both, make the tool return the data. Then the tool call is the structured output.
Check it with a test
import json
import urllib.request
def chat(body):
req = urllib.request.Request("http://127.0.0.1:11434/api/chat", json.dumps(body).encode(),
{"Content-Type": "application/json"})
return json.load(urllib.request.urlopen(req))
tool = {"type": "function", "function": {"name": "get_time", "description": "Get the current time.",
"parameters": {"type": "object", "properties": {"zone": {"type": "string"}}, "required": ["zone"]}}}
base = {"model": "qwen3:1.7b", "stream": False, "tools": [tool],
"messages": [{"role": "user", "content": "Use your tool to get the time in UTC."}]}
for fmt in (None, "json"):
body = dict(base, **({"format": fmt} if fmt else {}))
msg = chat(body)["message"]
print("format =", fmt, "| tool_calls:", bool(msg.get("tool_calls")), "| content:", msg.get("content", "")[:60])
Output on the test box, Ollama 0.40.4, Qwen3 1.7B Q8_0:
format = None | tool_calls: True | content:
format = json | tool_calls: False | content: {"name": "get_time", "arguments": {"zone": "UTC"}}
So the problem from #18944 is still there in 0.40.4. Pull the model first with ollama pull qwen3:1.7b. If tool_calls is false when format is json, your build has the problem from #18944. Keep this test in CI.
Key concepts
- JSON mode and tool calling both constrain the output.
- Ollama #18944: with
format: json, tool calls come back as text incontent. - llama.cpp #30321: an optional field before a required one can be dropped silently.
- Valid JSON is not the same as complete JSON.
Takeaways
- Send either tools or a schema in one request, not both.
- Put required fields first and log missing optional fields.
- Keep a CI test that checks
tool_callswith and without JSON mode.