Skip to content

Python: the Ollama chat provider drops finish_reason (and streaming token usage), so GenAI telemetry can't tell stop from length #6943

Description

@antsok

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.usageinput_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

Metadata

Metadata

Labels

agentsUsage: [Issues, PRs], Target: Single agentpythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow

Type

Fields

No fields configured for Bug.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions