Repository navigation
Description
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_reason → AgentResponse.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)
-
The string
finish_reasondoes not appear anywhere inpython/packages/openai/agent_framework_openai/_chat_client.py. -
The raw response's
statusfield is read in exactly two places, and in both it is used only to build acontinuation_tokenfor 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) — samein_progress/queuedcheck.
The terminal states (
completed,incomplete,failed) andincomplete_detailsare never read. -
By contrast, the Chat Completions client does populate it —
_chat_completion_client.py:709-710and:748-749:if choice.finish_reason: finish_reason = choice.finish_reason # type: ignore[assignment]
-
Foundry inherits the gap rather than having its own:
foundry/_chat_client.py:271andfoundry/_agent.py:405both delegate viasuper()._parse_chunk_from_openai(...)/super()._parse_response_from_openai(...). -
git log --all -S'finish_reason'returns 0 commits for_chat_client.pyand 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.pypaths). 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 NoneThe 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:
- For
status == "completed", is inspecting output items forfunction_callentries to emittool_callsthe behavior you want, or would you rather always emitstopand let callers inspect the message contents? - For
failed(and forincompletewith a reason other thanmax_output_tokens), do you preferfinish_reasonto stayNone, or is extendingFinishReasonon the table?FinishReasonis aNewType(str)so non-literal values are representable, but I don't want to introduce a value the rest of the framework doesn't recognize. - Should the streaming path set
finish_reasonon theresponse.completed/response.incompleteevent only, or on the finalChatResponseUpdateregardless 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
- Python: Fix response metadata construction #6955 — the metadata sweep this follows up on (merged
e26c8591a); closed Python: [Bug]: usage_details not available in ContextProvider / HistoryProvider #6842, Python: bug(claude): AgentResponse.usage_details and finish_reason always None when using ClaudeAgent #6929, Python: bug(copilot): AgentResponse.usage_details and finish_reason always None when using GitHubCopilotAgent #6930, Python: the Ollama chat provider dropsfinish_reason(and streaming token usage), so GenAI telemetry can't tellstopfromlength#6943 - Python: the Ollama chat provider drops
finish_reason(and streaming token usage), so GenAI telemetry can't tellstopfromlength#6943 — the Ollama instance of this bug; itsdone_reasonmapping is the structural template - Python: bug(claude): AgentResponse.usage_details and finish_reason always None when using ClaudeAgent #6929, Python: bug(copilot): AgentResponse.usage_details and finish_reason always None when using GitHubCopilotAgent #6930 — the Claude / Copilot instances
- Python: [Bug]: OpenAIResponsesClient._parse_response_from_openai drops encrypted_content when reasoning summaries are present #4644 — a previously accepted bug in this same
_parse_response_from_openaifunction (droppedencrypted_content), for precedent that parser-drops-a-field is in scope - Python: [Feature]: Python: add finish reason to AgentResponse and AgentResponseUpdate #4622 — adds
finish_reasontoAgentResponse/AgentResponseUpdate; complementary, one layer up. Note it is still open even though Python: Feat: Add finish_reason support to AgentResponse and AgentResponseUpdate #5211 landed with "Fixes Python: [Feature]: Python: add finish reason to AgentResponse and AgentResponseUpdate #4622".
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
github-actions commentedon Jul 10, 2026
🤖 Automated triage reproduction notes (agent-authored — trust but verify)
Agent analysis
Repro:
_parse_response_from_openai()inpython/packages/openai/agent_framework_openai/_chat_client.py(non-streaming, line ~2622-2644) and_parse_chunk_from_openai()(streaming, line ~3180-3190) both construct ChatResponse/ChatResponseUpdate without afinish_reasonkwarg. Theresponse.statusfield (completed/incomplete/failed) andincomplete_details.reasonare never mapped toFinishReason. Minimal repro: callclient._parse_response_from_openai(response_with_status_completed, options={})and assertresult.finish_reason is not None— it is alwaysNone.python/packages/openai/tests/openai/test_finish_reason_repro_7025.py1.10.01.10.1