Description
1. Summary
OllamaChatClient never propagates the response finish reason, and on the streaming path it also never propagates token usage. The Ollama SDK response object exposes both — done_reason ("stop", "length", …) and eval_count / prompt_eval_count — but the client discards done_reason entirely and only attaches usage on the non-streaming path.
Because MAF's own OTel GenAI instrumentation builds gen_ai.response.finish_reasons and gen_ai.usage.*_tokens from ChatResponse.finish_reason / ChatResponse.usage_details, those fields being unset means the attributes are simply absent from the chat span. This is a parity gap with the OpenAI-family clients, which populate finish_reason (and usage) on both the streaming and non-streaming paths.
2. Symptom — observed live
Full-stack run (harness agent + Microsoft Learn MCP over AG-UI/CopilotKit), provider ollama, model minimax-m3:cloud, ENABLE_INSTRUMENTATION=true exporting to the Aspire dashboard. Inspecting the trace (aspire otel traces ats-web, then the chat span attributes) shows only:
gen_ai.operation.name = chat
gen_ai.provider.name = ollama
gen_ai.request.choice.count = 1
gen_ai.request.model = minimax-m3:cloud
gen_ai.response.model = minimax-m3
server.address = Unknown
Missing, relative to an OpenAI-backed run of the same agent:
gen_ai.response.finish_reasons — so stop vs length is unknowable from telemetry.
gen_ai.usage.input_tokens / gen_ai.usage.output_tokens — so per-call token cost and the token-usage histogram/metric are never recorded for Ollama.
The parent invoke_agent span does carry gen_ai.request.max_tokens (e.g. 32768), so you can see the configured output cap but not whether a response actually hit it — exactly the fact needed to diagnose a truncated answer.
3. Root cause — done_reason discarded; streaming usage dropped
In agent_framework_ollama/_chat_client.py:
Streaming — _parse_streaming_response_from_ollama (≈ line 541) builds the update with neither finish reason nor usage:
def _parse_streaming_response_from_ollama(self, response: OllamaChatResponse) -> ChatResponseUpdate:
contents = self._parse_contents_from_ollama(response)
return ChatResponseUpdate(
contents=contents,
role="assistant",
model=response.model,
created_at=response.created_at,
# no finish_reason=…, no usage=… ← response.done_reason / eval_count are dropped
)
Non-streaming — _parse_response_from_ollama (≈ line 550) attaches usage but still no finish reason:
return ChatResponse(
messages=[Message(role="assistant", contents=contents)],
model=response.model,
created_at=response.created_at,
usage_details=UsageDetails(
input_token_count=response.prompt_eval_count,
output_token_count=response.eval_count,
),
response_format=response_format,
# no finish_reason=… ← response.done_reason is dropped
)
The final Ollama chunk carries done_reason (and done=True, eval_count, prompt_eval_count, total_duration); none of it reaches finish_reason, and in streaming the counts are lost too.
Why that erases the telemetry
MAF's instrumentation reads those exact fields:
Consumed by agent_framework/observability.py |
Reads |
Emitted attribute/metric |
≈:1579, ≈:1647–1655 |
response.finish_reason (only if in FINISH_REASON_MAP) |
gen_ai.response.finish_reasons |
≈:1720–1721 |
result.usage → input_token_count / output_token_count |
gen_ai.usage.input_tokens / .output_tokens, token-usage histogram |
With finish_reason=None and (streaming) no usage, both are skipped — no error, just silently absent.
Parity reference — the OpenAI client does it on both paths
agent_framework_openai/_chat_completion_client.py maps choice.finish_reason → ChatResponse(finish_reason=…) in the non-streaming path (≈:689–709) and the streaming path (≈:720–751), so OpenAI-backed runs populate gen_ai.response.finish_reasons and usage. Swapping the provider to Ollama silently drops both from telemetry even though create_harness_agent(client=…) is identical.
ChatResponse and ChatResponseUpdate already accept finish_reason (agent-framework-core _types.py), so this is purely a mapping the Ollama client doesn't perform.
4. Proposed feature
Map the Ollama response's done_reason to finish_reason, and attach usage on the streaming path too.
# _parse_streaming_response_from_ollama
return ChatResponseUpdate(
contents=contents,
role="assistant",
model=response.model,
created_at=response.created_at,
finish_reason=_map_done_reason(response.done_reason), # only on the final (done=True) chunk
usage=UsageDetails( # attach on the final chunk
input_token_count=response.prompt_eval_count,
output_token_count=response.eval_count,
) if response.done else None,
)
# _parse_response_from_ollama — add the same finish_reason=… it already omits
with a small mapping to the framework's FinishReason enum (the values MAF's FINISH_REASON_MAP recognizes):
Ollama done_reason |
MAF finish_reason |
stop |
stop |
length |
length |
load / other / None |
leave unset (or stop) |
This changes nothing on the wire, mirrors the OpenAI client, and makes stop vs length (and token usage) first-class in telemetry for every Ollama model.
5. Impact
- Truncation is undiagnosable from telemetry. A reasoning model that exhausts the output budget mid-answer produces
done_reason="length", but with the field dropped the trace looks identical to a clean stop. Operators can't tell "raise max_tokens" from "model chose to stop".
- No token-cost visibility for Ollama in the streaming path — the GenAI token-usage metric/histogram is never populated, so dashboards under-report Ollama usage as zero.
- Silent, provider-specific, and inconsistent with OpenAI/Foundry, so it's easy to mistake for a collector or config problem.
6. Workaround
Application-side, read done_reason / eval_count off the final streaming chunk in an OllamaChatClient subclass (we already subclass it for other gaps — see ollama-tool-call-id-collision.md, ollama-structured-output.md) and log it, since the field is otherwise lost before it can reach any span or log. This only recovers the value for local logging; it does not restore the missing gen_ai.* span attributes, which are built inside the framework from the fields the client must set.
Code Sample
Language/SDK
Python
Description
1. Summary
OllamaChatClientnever propagates the response finish reason, and on the streaming path it also never propagates token usage. The Ollama SDK response object exposes both —done_reason("stop","length", …) andeval_count/prompt_eval_count— but the client discardsdone_reasonentirely and only attaches usage on the non-streaming path.Because MAF's own OTel GenAI instrumentation builds
gen_ai.response.finish_reasonsandgen_ai.usage.*_tokensfromChatResponse.finish_reason/ChatResponse.usage_details, those fields being unset means the attributes are simply absent from thechatspan. This is a parity gap with the OpenAI-family clients, which populatefinish_reason(and usage) on both the streaming and non-streaming paths.2. Symptom — observed live
Full-stack run (harness agent + Microsoft Learn MCP over AG-UI/CopilotKit), provider
ollama, modelminimax-m3:cloud,ENABLE_INSTRUMENTATION=trueexporting to the Aspire dashboard. Inspecting the trace (aspire otel traces ats-web, then thechatspan attributes) shows only:Missing, relative to an OpenAI-backed run of the same agent:
gen_ai.response.finish_reasons— sostopvslengthis unknowable from telemetry.gen_ai.usage.input_tokens/gen_ai.usage.output_tokens— so per-call token cost and the token-usage histogram/metric are never recorded for Ollama.The parent
invoke_agentspan does carrygen_ai.request.max_tokens(e.g.32768), so you can see the configured output cap but not whether a response actually hit it — exactly the fact needed to diagnose a truncated answer.3. Root cause —
done_reasondiscarded; streaming usage droppedIn
agent_framework_ollama/_chat_client.py:Streaming —
_parse_streaming_response_from_ollama(≈ line 541) builds the update with neither finish reason nor usage:Non-streaming —
_parse_response_from_ollama(≈ line 550) attaches usage but still no finish reason:The final Ollama chunk carries
done_reason(anddone=True,eval_count,prompt_eval_count,total_duration); none of it reachesfinish_reason, and in streaming the counts are lost too.Why that erases the telemetry
MAF's instrumentation reads those exact fields:
agent_framework/observability.py≈:1579,≈:1647–1655response.finish_reason(only if inFINISH_REASON_MAP)gen_ai.response.finish_reasons≈:1720–1721result.usage→input_token_count/output_token_countgen_ai.usage.input_tokens/.output_tokens, token-usage histogramWith
finish_reason=Noneand (streaming) no usage, both are skipped — no error, just silently absent.Parity reference — the OpenAI client does it on both paths
agent_framework_openai/_chat_completion_client.pymapschoice.finish_reason → ChatResponse(finish_reason=…)in the non-streaming path (≈:689–709) and the streaming path (≈:720–751), so OpenAI-backed runs populategen_ai.response.finish_reasonsand usage. Swapping the provider to Ollama silently drops both from telemetry even thoughcreate_harness_agent(client=…)is identical.ChatResponseandChatResponseUpdatealready acceptfinish_reason(agent-framework-core_types.py), so this is purely a mapping the Ollama client doesn't perform.4. Proposed feature
Map the Ollama response's
done_reasontofinish_reason, and attach usage on the streaming path too.with a small mapping to the framework's
FinishReasonenum (the values MAF'sFINISH_REASON_MAPrecognizes):done_reasonfinish_reasonstopstoplengthlengthload/ other /Nonestop)This changes nothing on the wire, mirrors the OpenAI client, and makes
stopvslength(and token usage) first-class in telemetry for every Ollama model.5. Impact
done_reason="length", but with the field dropped the trace looks identical to a cleanstop. Operators can't tell "raisemax_tokens" from "model chose to stop".6. Workaround
Application-side, read
done_reason/eval_countoff the final streaming chunk in anOllamaChatClientsubclass (we already subclass it for other gaps — see ollama-tool-call-id-collision.md, ollama-structured-output.md) and log it, since the field is otherwise lost before it can reach any span or log. This only recovers the value for local logging; it does not restore the missinggen_ai.*span attributes, which are built inside the framework from the fields the client must set.Code Sample
Language/SDK
Python