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_reason does not appear anywhere in python/packages/openai/agent_framework_openai/_chat_client.py.
-
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.
-
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]
-
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(...).
-
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:
- 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?
- 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.
- 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
Description
What happened?
ChatResponse.finish_reasonandChatResponseUpdate.finish_reasonare alwaysNonefor the Responses-based clients, on both the non-streaming and streaming paths. This affectsOpenAIChatClient, the Azure OpenAI variant, andFoundryChatClient.What did you expect to happen?
finish_reasonpopulated on the terminal response, consistent withOpenAIChatCompletionClientand 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 propagateChatResponse.finish_reason→AgentResponse.finish_reason, but since the Responses parser never sets it in the first place, the value staysNoneall 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):_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: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_reasonis the source for the OTel GenAI attributegen_ai.response.finish_reasons, so telemetry for the Responses-based clients cannot distinguish a naturalstopfrom alengthtruncation. 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):The Responses API equivalent would map
response.status/response.incomplete_detailsthe 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_representationand inspect.status/.incomplete_detailsyourself.Code Sample
Minimal reproduction on a plain completion — no tools, no truncation, nothing unusual about the turn.
Observed on
agent-framework-openai1.10.0:finish_reasonisNoneon both paths, where"stop"is expected.FoundryChatClientbehaves 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-710and:748-749:so two clients shipped side by side in
agent-framework-openaidisagree on whetherfinish_reasonis populated for an otherwise identical turn.Error Messages / Stack Traces
None — this is a silent-
Nonedefect, 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.
FinishReasonLiteralisstop | length | tool_calls | content_filter(core/_types.py:1707), and the Responses API's terminal states do not map onto it 1:1:FinishReasonincomplete_details.reason == "max_output_tokens"lengthstatus == "completed"stop?completed, but should surface astool_calls, notstop. Distinguishing them requires inspecting the response's output items forfunction_callentries.status == "failed"None, or widen the type?content_filterConcretely, before writing anything I'd like to know:
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?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.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
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#6943finish_reason(and streaming token usage), so GenAI telemetry can't tellstopfromlength#6943 — the Ollama instance of this bug; itsdone_reasonmapping is the structural template_parse_response_from_openaifunction (droppedencrypted_content), for precedent that parser-drops-a-field is in scopefinish_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".