🧠 Reasoning, Tool Calls & Streaming Output
Every model writes its answer in its own markup. Qwen3 wraps its thinking in <think>…</think> and tool calls in <tool_call>…</tool_call>. Gemma4 thinks in a <|channel>thought channel and writes calls as call:f{…}. gpt-oss uses Harmony channels. LFM2 writes Python calls.
FLM’s /v1/chat/completions endpoint removes that markup for you. Whatever the model, you get the same OpenAI-shaped message:
| Field | What it holds |
|---|---|
reasoning_content |
The model’s thinking, without the tags. Left out when the model didn’t reason. |
content |
The answer the user should see. null when the turn is only tool calls. |
tool_calls |
The function calls the model asked for. Each one has arguments as a JSON string, whatever syntax the model used. |
This page covers how that output looks with and without streaming, how to turn thinking on and off, and how to read it from the OpenAI Python SDK.
📘 To force a tool call, ban one, or make the model answer in JSON that matches a schema, see Structured Output & tool_choice.
📑 Table of Contents
- Supported Model Formats
- Non-Streaming Responses
- Streaming Responses
- Turning Thinking On and Off
- Use Case: Show the Model’s Thinking Live
- Use Case: A Streaming Tool-Calling Agent Loop
- Use Case: Parallel Tool Calls
- Rules Worth Knowing
- Troubleshooting
🗂️ Supported Model Formats
FLM detects the format once, when the model loads, from its chat template and tokenizer. The server log names the format it detected:
[FLM] Chat format: hermes_json with reasoning
| Format (log name) | Model families | Reasoning | Tool call syntax the model writes |
|---|---|---|---|
hermes_json |
Qwen3, Qwen2.5, Nanbeige | <think>…</think> |
<tool_call>{"name": …, "arguments": {…}}</tool_call> |
qwen_xml |
Qwen3.5 and later | <think>…</think> |
<tool_call><function=f><parameter=k>v</parameter></function></tool_call> |
lfm2_pythonic |
LFM2, LFM2.5 | <think>…</think> (thinking variants) |
<|tool_call_start|>[f(a=1, b="x")]<|tool_call_end|> |
gemma4 |
Gemma4 | <|channel>thought … <channel|> |
<|tool_call>call:f{a:<|"|>x<|"|>}<tool_call|> |
harmony |
gpt-oss | analysis channel |
commentary to=functions.f channel |
content_only |
everything else | <think>…</think> if the model uses it (e.g. DeepSeek-R1) |
none: the model has no tool call syntax |
Your code doesn’t depend on any of this. The table explains why a model can or can’t call tools, and what its raw output looks like if you ever read the server log.
💡 Check the model card for which models are labelled
tool-callingandreasoning.
📦 Non-Streaming Responses
A request with tools:
from openai import OpenAI
import json
client = OpenAI(base_url="http://127.0.0.1:52625/v1", api_key="flm")
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["city"],
},
},
}]
resp = client.chat.completions.create(
model="qwen3:4b",
messages=[{"role": "user", "content": "What's the weather in Paris?"}],
tools=tools,
)
choice = resp.choices[0]
msg = choice.message
print("finish_reason:", choice.finish_reason) # "tool_calls"
print("reasoning:", getattr(msg, "reasoning_content", None)) # the <think> text, tags removed
print("content:", msg.content) # None: the turn is only a call
for call in msg.tool_calls or []:
print(call.id, call.function.name, json.loads(call.function.arguments))
The raw JSON body looks like this:
{
"object": "chat.completion",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"reasoning_content": "The user wants the weather in Paris, I should call get_weather…",
"content": null,
"tool_calls": [{
"index": 0,
"id": "call_QbsRMJjkvHumJZVPdR0Gkqsm",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 180, "completion_tokens": 64, "total_tokens": 244, "...": "..."}
}
How to read it:
reasoning_contentis a FLM extension that other OpenAI-compatible servers also use. The OpenAI SDK keeps unknown fields, so read it withgetattr(msg, "reasoning_content", None)ormsg.model_extra.contentisnullwhen the model only called tools, as with OpenAI. When the model writes text and calls tools, both are set.argumentsis always a JSON string, even when the model wrote XML (Qwen3.5), Python (f(a=1), LFM2) or Gemma4’s relaxed syntax. FLM converts it. When the model’s syntax doesn’t say whether a value is a string or a number (Qwen XML), FLM uses the type declared in your tool’sparameters.idis generated by FLM ascall_plus 24 random characters. Send it back astool_call_idin thetoolmessage.finish_reasonis:"tool_calls"when the model stopped normally after calling at least one tool,"stop"when it stopped normally without calls,"length"when it hitmax_tokensor the context limit. A truncated generation is never reported as"tool_calls", even if a call was started. Treat the call’s arguments as incomplete.
🌊 Streaming Responses
With stream=True the same message arrives in small deltas. FLM runs the same parser for both modes. When the stream ends, the deltas add up to exactly the message a non-streaming request would have returned.
Each chunk’s delta carries one of these:
| Delta | Meaning |
|---|---|
{"reasoning_content": "…"} |
More thinking text. Append it. |
{"content": "…"} |
More answer text. Append it. |
{"tool_calls": [{"index": 0, "id": "call_…", "type": "function", "function": {"name": "get_weather", "arguments": ""}}]} |
A new call starts. Only the first delta of a call has id, type and name. |
{"tool_calls": [{"index": 0, "function": {"arguments": "{\"city\": \"Par"}}]} |
More argument text for call index. Append it to that call’s arguments. |
Then a final chunk with "delta": {"content": null} and the finish_reason, then data: [DONE].
Here is a real stream from qwen3:0.6b (abridged) that calls get_weather twice:
data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_803aPw…","type":"function","function":{"name":"get_weather","arguments":""}}]}, …}]}
data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"arguments":"{\""}}]}, …}]}
data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"arguments":"city"}}]}, …}]}
data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"arguments":"\": \"Paris\"}"}}]}, …}]}
data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"index":1,"id":"call_gDVP0q…","type":"function","function":{"name":"get_weather","arguments":""}}]}, …}]}
data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"index":1,"function":{"arguments":"{\"city\": \"Tokyo\"}"}}]}, …}]}
data: {"choices":[{"delta":{"content":null},"finish_reason":"tool_calls"}], …}
data: [DONE]
✅ Accumulating a stream correctly
Merge tool call deltas by index. Don’t append every delta as a new call. Later deltas have no id and no name.
def collect_stream(stream):
"""Fold a chat.completion.chunk stream into one assistant message."""
reasoning, content = [], []
calls = {} # index -> {"id", "type", "function": {"name", "arguments"}}
finish_reason = None
usage = None
for chunk in stream:
if chunk.usage: # only with stream_options={"include_usage": True}
usage = chunk.usage
if not chunk.choices: # the usage chunk has no choices
continue
choice = chunk.choices[0]
delta = choice.delta
if choice.finish_reason:
finish_reason = choice.finish_reason
text = getattr(delta, "reasoning_content", None)
if text:
reasoning.append(text)
if delta.content:
content.append(delta.content)
for tc in delta.tool_calls or []:
call = calls.setdefault(tc.index, {
"id": None, "type": "function",
"function": {"name": "", "arguments": ""},
})
if tc.id:
call["id"] = tc.id
if tc.function and tc.function.name:
call["function"]["name"] = tc.function.name
if tc.function and tc.function.arguments:
call["function"]["arguments"] += tc.function.arguments
message = {"role": "assistant", "content": "".join(content) or None}
if calls:
message["tool_calls"] = [calls[i] for i in sorted(calls)]
return message, "".join(reasoning), finish_reason, usage
message can go straight back into messages for the next turn. Leave the reasoning out: FLM strips reasoning_content from earlier assistant turns before it builds the prompt anyway.
📊 Usage in streams
As with OpenAI, a stream reports token usage only when you ask for it:
stream = client.chat.completions.create(..., stream=True,
stream_options={"include_usage": True})
Every chunk then has "usage": null, and one extra chunk before [DONE] has "choices": [] and the full usage, including FLM’s timing fields (prefill_speed_tps, decoding_speed_tps, …). Check chunk.choices before you index it.
🔀 Turning Thinking On and Off
Reasoning models can be switched per request. These are FLM extensions, so the OpenAI SDK sends them through extra_body:
# Turn thinking off: answers come faster, reasoning_content is absent
client.chat.completions.create(model="qwen3:4b", messages=msgs, extra_body={"think": False})
# Turn it back on
client.chat.completions.create(model="qwen3:4b", messages=msgs, extra_body={"think": True})
# The OpenAI-style knob works too: "none" turns thinking off,
# "low" / "medium" / "high" turn it on (gpt-oss also uses it as its effort level)
client.chat.completions.create(model="gpt-oss:20b", messages=msgs, reasoning_effort="low")
⚠️ The setting sticks.
thinkandreasoning_effortare applied to the loaded model and stay in effect for later requests that don’t mention them. If your application mixes thinking and non-thinking calls, send the setting on every request.
What changes when thinking is off:
reasoning_contentis left out of the response.- With constrained decoding active (
tool_choice,response_format), the grammar leaves no room for a reasoning block either, so the model can’t slip into one. - Models that can’t switch thinking off (for example
qwen3-tk,lfm2.5-tk,deepseek-r1) keep reasoning. Their thinking still arrives inreasoning_content, never incontent.
💭 Use Case: Show the Model’s Thinking Live
A chat UI usually shows the thinking dimmed or folded away, and the answer normally:
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:52625/v1", api_key="flm")
DIM, RESET = "\033[2m", "\033[0m"
in_thinking = False
stream = client.chat.completions.create(
model="qwen3:4b",
messages=[{"role": "user", "content": "A bat and a ball cost $1.10 in total. "
"The bat costs $1.00 more than the ball. "
"How much is the ball?"}],
stream=True,
extra_body={"think": True},
)
for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
thinking = getattr(delta, "reasoning_content", None)
if thinking:
if not in_thinking:
print(f"{DIM}🤔 ", end="")
in_thinking = True
print(thinking, end="", flush=True)
if delta.content:
if in_thinking:
print(f"{RESET}\n\n", end="")
in_thinking = False
print(delta.content, end="", flush=True)
print(RESET)
You never see <think> or </think> in either field, even half-written. FLM holds back a few characters whenever the text might be the start of a tag, until it can tell.
🔁 Use Case: A Streaming Tool-Calling Agent Loop
This loop keeps calling the model until it answers without asking for a tool. It uses collect_stream() from above.
import json
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:52625/v1", api_key="flm")
MODEL = "qwen3:4b"
tools = [
{"type": "function", "function": {
"name": "get_weather",
"description": "Current weather for a city",
"parameters": {"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]}}},
{"type": "function", "function": {
"name": "convert_currency",
"description": "Convert an amount between two currencies",
"parameters": {"type": "object",
"properties": {"amount": {"type": "number"},
"from": {"type": "string"},
"to": {"type": "string"}},
"required": ["amount", "from", "to"]}}},
]
def get_weather(city):
return {"city": city, "temperature_c": 18, "sky": "cloudy"}
def convert_currency(amount, **kw):
return {"amount": round(amount * 1.08, 2), "currency": kw["to"]}
IMPLEMENTATIONS = {"get_weather": get_weather, "convert_currency": convert_currency}
messages = [{"role": "user",
"content": "I'm flying to Paris with 200 USD. What's the weather there, "
"and how many euros is that?"}]
for step in range(5): # never loop forever
stream = client.chat.completions.create(
model=MODEL, messages=messages, tools=tools, stream=True,
extra_body={"think": False},
)
message, _reasoning, finish_reason, _ = collect_stream(stream)
messages.append(message)
if finish_reason != "tool_calls":
print(message["content"])
break
for call in message["tool_calls"]:
fn = IMPLEMENTATIONS[call["function"]["name"]]
args = json.loads(call["function"]["arguments"])
result = fn(**args)
print(f"🔧 {call['function']['name']}({args}) -> {result}")
messages.append({
"role": "tool",
"tool_call_id": call["id"],
"content": json.dumps(result),
})
Things this loop relies on:
finish_reason == "tool_calls"is the signal to run tools. It’s only set when the model stopped normally, so a"length"cut-off falls through instead of running a half-written call.- The assistant message goes back with its
tool_calls, and each result goes back as atoolmessage with the matchingtool_call_id. FLM rebuilds the model’s native markup from these for the next prompt. json.loads(arguments)is safe once the stream has ended. While streaming,argumentsis a growing prefix of the final JSON. Don’t parse it before the call is complete.
🧵 Use Case: Parallel Tool Calls
Ask for two things and a capable model calls the same tool twice. Each call has its own index and id:
resp = client.chat.completions.create(
model="qwen3:4b",
messages=[{"role": "user", "content": "Weather in Paris and in Tokyo?"}],
tools=tools,
)
for call in resp.choices[0].message.tool_calls or []:
print(call.index, call.id, call.function.arguments)
# 0 call_Gce8tV… {"city": "Paris"}
# 1 call_sCOOJo… {"city": "Tokyo"}
Answer every call with its own tool message before the next request. To get at most one call per turn, send parallel_tool_calls=False (see Structured Output & tool_choice).
📏 Rules Worth Knowing
- Tools are only parsed when the request has
tools. Without them, any tool markup the model writes stays incontentas plain text. tool_choice: "none"hides the tools from the model entirely. They’re not put in the prompt, so the model answers in prose.- Text and calls can interleave. A model may write a sentence, call a tool, then write more. Both parts are returned: the text pieces are joined in
contentand the calls are intool_calls. When separate text pieces are joined, they’re separated by a blank line (\n\n). - Whitespace at the edges is trimmed.
contentandreasoning_contentnever start or end with blank lines. While streaming, trailing whitespace is held back until more text follows it. - Text after the model hands over to a tool is dropped. Gemma4 marks the end of its turn with
<|tool_response>, and gpt-oss with<|call|>. Generation stops there. - Only
/v1/chat/completionsdoes this parsing./v1/completionsreturns the raw text.
🩺 Troubleshooting
| Symptom | Likely cause |
|---|---|
Tool markup like <tool_call> shows up in content |
The request had no tools, or the model’s format is content_only (check the Chat format: log line). |
| My client creates one tool call per streamed chunk | Merge deltas by index, as in collect_stream(). |
json.loads(arguments) fails |
You parsed while still streaming, or finish_reason is "length": raise max_tokens. |
reasoning_content is empty but I asked for thinking |
A previous request sent think: false and the setting stuck. Send think: true explicitly. |
| Long pause before the first content token | The model is thinking. Stream it and read reasoning_content, or turn thinking off. |
usage is missing in streams |
Pass stream_options={"include_usage": True}. |