Skip to content

Python: [Bug]: Responses-based clients (OpenAI, Azure OpenAI, Foundry) never populate finish_reason — follow-up to #6955 #7025

Description

@pashakamal080

Description

What happened?

ChatResponse.finish_reason and ChatResponseUpdate.finish_reason are always None for the Responses-based clients, on both the non-streaming and streaming paths. This affects OpenAIChatClient, the Azure OpenAI variant, and FoundryChatClient.

What did you expect to happen?

finish_reason populated on the terminal response, consistent with OpenAIChatCompletionClient and with the providers fixed in #6955.

Why this looks like an oversight rather than a design decision

PR #6955 ("Fix response metadata construction", merged as e26c8591a, 2026-07-08) swept exactly this bug class, closing #6929 (Claude), #6930 (GitHub Copilot) and #6943 (Ollama). Its stated motivation was that "several providers drop metadata that their lower-level SDKs already expose."

That PR touched 10 files, and none of them are under python/packages/openai/. The Responses-based clients appear to have simply been missed by the sweep. #6955's core changes propagate ChatResponse.finish_reasonAgentResponse.finish_reason, but since the Responses parser never sets it in the first place, the value stays None all the way through the new plumbing.

Evidence in the current tree (verified against main @ fbaa346)

  1. The string finish_reason does not appear anywhere in python/packages/openai/agent_framework_openai/_chat_client.py.

  2. The raw response's status field is read in exactly two places, and in both it is used only to build a continuation_token for the in-progress/queued background cases:

    • _chat_client.py:2642 (non-streaming, _parse_response_from_openai):
      # Set continuation_token when background operation is still in progress
      if response.status and response.status in ("in_progress", "queued"):
          args["continuation_token"] = OpenAIContinuationToken(response_id=response.id)
    • _chat_client.py:2831 (streaming, _parse_chunk_from_openai) — same in_progress/queued check.

    The terminal states (completed, incomplete, failed) and incomplete_details are never read.

  3. By contrast, the Chat Completions client does populate it — _chat_completion_client.py:709-710 and :748-749:

    if choice.finish_reason:
        finish_reason = choice.finish_reason  # type: ignore[assignment]
  4. Foundry inherits the gap rather than having its own: foundry/_chat_client.py:271 and foundry/_agent.py:405 both delegate via super()._parse_chunk_from_openai(...) / super()._parse_response_from_openai(...).

  5. git log --all -S'finish_reason' returns 0 commits for _chat_client.py and for every one of its pre-rename ancestors (core/agent_framework/openai/_responses_client.py, main/agent_framework/openai/_responses_client.py, and the two Azure _responses_client.py paths). So this was never added and later removed — it has simply never been mapped.

Impact

ChatResponse.finish_reason is the source for the OTel GenAI attribute gen_ai.response.finish_reasons, so telemetry for the Responses-based clients cannot distinguish a natural stop from a length truncation. This is the same downstream consequence described in #6943 for Ollama.

Prior art for the fix

#6943 was fixed in #6955 by mapping the provider's own terminal-state field onto FinishReason (ollama/_chat_client.py):

finish_reason = response.done_reason if response.done_reason in ("stop", "length") else None

The Responses API equivalent would map response.status / response.incomplete_details the same way — but the mapping is not 1:1, which is the reason I'm filing this as a question rather than opening a PR directly. See Additional Context below.

Regression? No. Per the pickaxe above, this has been the behavior since the Responses client was introduced.

Known workaround: read the raw response off ChatResponse.raw_representation and inspect .status / .incomplete_details yourself.

Code Sample

Minimal reproduction on a plain completion — no tools, no truncation, nothing unusual about the turn.

import asyncio

from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient


async def main() -> None:
    agent = Agent(
        client=OpenAIChatClient(model="gpt-4o"),
        name="FinishReasonRepro",
        instructions="You are a friendly assistant. Keep your answers brief.",
    )

    # Non-streaming
    result = await agent.run("What is the capital of France?")
    print("non-streaming finish_reason:", result.finish_reason)  # None  <- expected "stop"

    # Streaming
    final = None
    async for chunk in agent.run("What is the capital of France?", stream=True):
        if chunk.finish_reason is not None:
            final = chunk.finish_reason
    print("streaming finish_reason:    ", final)  # None  <- expected "stop"


if __name__ == "__main__":
    asyncio.run(main())

Observed on agent-framework-openai 1.10.0: finish_reason is None on both paths, where "stop" is expected. FoundryChatClient behaves identically, since it delegates to the same parser (see point 4 above).

For contrast, the Chat Completions client in the same package does populate the field — _chat_completion_client.py:709-710 and :748-749:

if choice.finish_reason:
    finish_reason = choice.finish_reason  # type: ignore[assignment]

so two clients shipped side by side in agent-framework-openai disagree on whether finish_reason is populated for an otherwise identical turn.

Error Messages / Stack Traces

None — this is a silent-None defect, not a crash. There is no exception or stack trace to attach.

Package Versions

agent-framework-core: 1.10.0, agent-framework-openai: 1.10.0, agent-framework-foundry: 1.10.0

Python Version

Python 3.14.5

Additional Context

The open design question — I'd like maintainer input before attempting a PR.

FinishReasonLiteral is stop | length | tool_calls | content_filter (core/_types.py:1707), and the Responses API's terminal states do not map onto it 1:1:

Responses API state Candidate FinishReason Problem
incomplete_details.reason == "max_output_tokens" length Clean — this one seems unambiguous.
status == "completed" stop? Conflict. A turn that ends in tool calls is also completed, but should surface as tool_calls, not stop. Distinguishing them requires inspecting the response's output items for function_call entries.
status == "failed" No obvious target in the literal set. Leave None, or widen the type?
content-filter / refusal outcomes content_filter Not clear which Responses-API signal should drive this, if any.

Concretely, before writing anything I'd like to know:

  1. For status == "completed", is inspecting output items for function_call entries to emit tool_calls the behavior you want, or would you rather always emit stop and let callers inspect the message contents?
  2. For failed (and for incomplete with a reason other than max_output_tokens), do you prefer finish_reason to stay None, or is extending FinishReason on the table? FinishReason is a NewType(str) so non-literal values are representable, but I don't want to introduce a value the rest of the framework doesn't recognize.
  3. Should the streaming path set finish_reason on the response.completed / response.incomplete event only, or on the final ChatResponseUpdate regardless of which event carries it?

Happy to implement this and add tests once there's agreement on the mapping — per CONTRIBUTING.md I'd rather agree on direction first than surprise you with a PR. Just say the word and I'll pick it up.

Related

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
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions