From b9b9dd4b7c0466733c9a8a035a82b7722a2eb29a Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 22 Jun 2026 09:48:58 -0500 Subject: [PATCH 1/7] feat: normalize finish_reason across providers Maps provider-specific stop reasons to a common vocabulary: "success", "max_tokens", "content_filter", "stop_sequence", "context_window". Unknown reasons pass through as-is. Also fixes OpenAI streaming to handle `response.incomplete` events so truncated responses are returned rather than silently dropped. Mirrors tidyverse/ellmer#1015. --- chatlas/_provider_anthropic.py | 18 ++++++++- chatlas/_provider_google.py | 20 +++++++++- chatlas/_provider_openai.py | 29 ++++++++++++++- chatlas/_provider_openai_completions.py | 16 +++++++- tests/test_provider_anthropic.py | 27 +++++++++++++- tests/test_provider_bedrock.py | 2 +- tests/test_provider_cloudflare.py | 4 +- tests/test_provider_databricks.py | 4 +- tests/test_provider_deepseek.py | 4 +- tests/test_provider_github.py | 4 +- tests/test_provider_google.py | 26 +++++++++++-- tests/test_provider_huggingface.py | 4 +- tests/test_provider_lmstudio.py | 2 +- tests/test_provider_mistral.py | 4 +- tests/test_provider_openai.py | 45 +++++++++++++++++++++-- tests/test_provider_openai_completions.py | 26 ++++++++++--- tests/test_provider_openrouter.py | 4 +- 17 files changed, 206 insertions(+), 33 deletions(-) diff --git a/chatlas/_provider_anthropic.py b/chatlas/_provider_anthropic.py index d734e82d..0dfa480a 100644 --- a/chatlas/_provider_anthropic.py +++ b/chatlas/_provider_anthropic.py @@ -296,6 +296,22 @@ def ChatAnthropic( ) +# https://docs.anthropic.com/en/api/handling-stop-reasons +_ANTHROPIC_FINISH_REASON_MAP = { + "end_turn": "success", + "max_tokens": "max_tokens", + "model_context_window_exceeded": "context_window", + "stop_sequence": "stop_sequence", + "refusal": "content_filter", +} + + +def normalize_finish_reason(reason: str | None) -> str | None: + if reason is None: + return None + return _ANTHROPIC_FINISH_REASON_MAP.get(reason, reason) + + class AnthropicProvider( Provider[Message, RawMessageStreamEvent, Message, "SubmitInputArgs"] ): @@ -940,7 +956,7 @@ def _as_turn(self, completion: Message, has_data_model=False) -> AssistantTurn: return AssistantTurn( contents, - finish_reason=completion.stop_reason, + finish_reason=normalize_finish_reason(completion.stop_reason), completion=completion, ) diff --git a/chatlas/_provider_google.py b/chatlas/_provider_google.py index c3d7f499..f30b1b99 100644 --- a/chatlas/_provider_google.py +++ b/chatlas/_provider_google.py @@ -190,6 +190,24 @@ def ChatGoogle( ) +# https://ai.google.dev/api/generate-content +_GOOGLE_FINISH_REASON_MAP = { + "STOP": "success", + "MAX_TOKENS": "max_tokens", + "SAFETY": "content_filter", + "RECITATION": "content_filter", + "BLOCKLIST": "content_filter", + "PROHIBITED_CONTENT": "content_filter", + "SPII": "content_filter", +} + + +def normalize_finish_reason(reason: str | None) -> str | None: + if reason is None: + return None + return _GOOGLE_FINISH_REASON_MAP.get(reason, reason) + + class GoogleProvider( Provider[ GenerateContentResponse, @@ -650,7 +668,7 @@ def _as_turn( return AssistantTurn( contents, - finish_reason=finish_reason, + finish_reason=normalize_finish_reason(finish_reason), completion=message, ) diff --git a/chatlas/_provider_openai.py b/chatlas/_provider_openai.py index 40077444..0bdc847c 100644 --- a/chatlas/_provider_openai.py +++ b/chatlas/_provider_openai.py @@ -194,6 +194,26 @@ def ChatOpenAI( ) +# https://platform.openai.com/docs/api-reference/responses/get +_OPENAI_INCOMPLETE_REASON_MAP = { + "max_output_tokens": "max_tokens", + "content_filter": "content_filter", +} + + +def normalize_finish_reason( + status: str | None, incomplete_reason: str | None = None +) -> str | None: + if status is None: + return None + if status == "completed": + return "success" + if status != "incomplete": + return status + reason = incomplete_reason or status + return _OPENAI_INCOMPLETE_REASON_MAP.get(reason, reason) + + class OpenAIProvider( OpenAIAbstractProvider[ Response, @@ -313,7 +333,7 @@ def stream_content(self, chunk) -> Optional[Content]: return None def stream_merge_chunks(self, completion, chunk): - if chunk.type == "response.completed": + if chunk.type == "response.completed" or chunk.type == "response.incomplete": return chunk.response elif chunk.type == "response.failed": error = chunk.response.error @@ -448,8 +468,15 @@ def _response_as_turn(completion: Response, has_data_model: bool) -> AssistantTu else: raise ValueError(f"Unknown output type: {output.type}") + incomplete_reason = None + if getattr(completion, "incomplete_details", None) is not None: + # getattr() check above confirms non-None, but pyright can't narrow through getattr + incomplete_reason = completion.incomplete_details.reason # type: ignore[union-attr] return AssistantTurn( contents, + finish_reason=normalize_finish_reason( + getattr(completion, "status", None), incomplete_reason + ), completion=completion, ) diff --git a/chatlas/_provider_openai_completions.py b/chatlas/_provider_openai_completions.py index 9dac3bb5..5666eed1 100644 --- a/chatlas/_provider_openai_completions.py +++ b/chatlas/_provider_openai_completions.py @@ -117,6 +117,20 @@ def ChatOpenAICompletions( ) +# https://platform.openai.com/docs/api-reference/chat/create +_OPENAI_COMPLETIONS_FINISH_REASON_MAP = { + "stop": "success", + "length": "max_tokens", + "content_filter": "content_filter", +} + + +def normalize_finish_reason(reason: str | None) -> str | None: + if reason is None: + return None + return _OPENAI_COMPLETIONS_FINISH_REASON_MAP.get(reason, reason) + + class OpenAICompletionsProvider( OpenAIAbstractProvider[ ChatCompletion, @@ -444,7 +458,7 @@ def _response_as_turn( return AssistantTurn( contents, - finish_reason=completion.choices[0].finish_reason, + finish_reason=normalize_finish_reason(completion.choices[0].finish_reason), completion=completion, ) diff --git a/tests/test_provider_anthropic.py b/tests/test_provider_anthropic.py index 55b34dd8..54d596c6 100644 --- a/tests/test_provider_anthropic.py +++ b/tests/test_provider_anthropic.py @@ -11,6 +11,9 @@ tool_web_search, ) from chatlas._provider_anthropic import AnthropicProvider +from chatlas._provider_anthropic import ( + normalize_finish_reason as anthropic_normalize_finish_reason, +) from pydantic import BaseModel, Field from .conftest import ( @@ -32,6 +35,26 @@ ) +def test_normalize_finish_reason_maps_known_reasons(): + assert anthropic_normalize_finish_reason("end_turn") == "success" + assert anthropic_normalize_finish_reason("max_tokens") == "max_tokens" + assert anthropic_normalize_finish_reason("stop_sequence") == "stop_sequence" + assert ( + anthropic_normalize_finish_reason("model_context_window_exceeded") + == "context_window" + ) + assert anthropic_normalize_finish_reason("refusal") == "content_filter" + + +def test_normalize_finish_reason_passes_through_unknown(): + assert anthropic_normalize_finish_reason("tool_use") == "tool_use" + assert anthropic_normalize_finish_reason("some_new_reason") == "some_new_reason" + + +def test_normalize_finish_reason_handles_none(): + assert anthropic_normalize_finish_reason(None) is None + + def chat_func(system_prompt: str = "", **kwargs): return ChatAnthropic( system_prompt=system_prompt, @@ -49,7 +72,7 @@ def test_anthropic_simple_request(): turn = chat.get_last_turn() assert turn is not None assert turn.tokens == (26, 5, 0) - assert turn.finish_reason == "end_turn" + assert turn.finish_reason == "success" @pytest.mark.vcr @@ -65,7 +88,7 @@ async def test_anthropic_simple_streaming_request(): assert "2" in "".join(res) turn = chat.get_last_turn() assert turn is not None - assert turn.finish_reason == "end_turn" + assert turn.finish_reason == "success" @pytest.mark.vcr diff --git a/tests/test_provider_bedrock.py b/tests/test_provider_bedrock.py index 8cd7d5be..61a7e171 100644 --- a/tests/test_provider_bedrock.py +++ b/tests/test_provider_bedrock.py @@ -90,7 +90,7 @@ async def test_anthropic_simple_streaming_request(): assert "2" in "".join(res) turn = chat.get_last_turn() assert turn is not None - assert turn.finish_reason == "end_turn" + assert turn.finish_reason == "success" @requires_bedrock diff --git a/tests/test_provider_cloudflare.py b/tests/test_provider_cloudflare.py index 19e8fd66..25a67f6c 100644 --- a/tests/test_provider_cloudflare.py +++ b/tests/test_provider_cloudflare.py @@ -27,7 +27,7 @@ def test_cloudflare_simple_request(): assert len(turn.tokens) == 3 assert turn.tokens[0] > 0 # input tokens assert turn.tokens[1] > 0 # output tokens - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr @@ -40,7 +40,7 @@ async def test_cloudflare_simple_streaming_request(): assert "2" in "".join(res) turn = chat.get_last_turn() assert turn is not None - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr diff --git a/tests/test_provider_databricks.py b/tests/test_provider_databricks.py index 17e35448..3dca22c0 100644 --- a/tests/test_provider_databricks.py +++ b/tests/test_provider_databricks.py @@ -38,7 +38,7 @@ def test_databricks_simple_request(): assert len(turn.tokens) == 3 assert turn.tokens[0] == 26 # Not testing turn.tokens[1] because it's not deterministic. Typically 1 or 2. - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr @@ -53,7 +53,7 @@ async def test_databricks_simple_streaming_request(): assert "2" in "".join(res) turn = chat.get_last_turn() assert turn is not None - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr diff --git a/tests/test_provider_deepseek.py b/tests/test_provider_deepseek.py index d0791599..7852beb2 100644 --- a/tests/test_provider_deepseek.py +++ b/tests/test_provider_deepseek.py @@ -21,7 +21,7 @@ def test_deepseek_simple_request(): assert turn.tokens is not None assert len(turn.tokens) == 3 assert turn.tokens[0] >= 10 # More lenient assertion for DeepSeek - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr @@ -36,7 +36,7 @@ async def test_deepseek_simple_streaming_request(): assert "2" in "".join(res) turn = chat.get_last_turn() assert turn is not None - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr diff --git a/tests/test_provider_github.py b/tests/test_provider_github.py index 5eb87d4c..686417b0 100644 --- a/tests/test_provider_github.py +++ b/tests/test_provider_github.py @@ -33,7 +33,7 @@ def test_github_simple_request(): assert len(turn.tokens) == 3 assert turn.tokens[0] == 27 # Not testing turn.tokens[1] because it's not deterministic. Typically 1 or 2. - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr @@ -48,7 +48,7 @@ async def test_github_simple_streaming_request(): assert "2" in "".join(res) turn = chat.get_last_turn() assert turn is not None - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr diff --git a/tests/test_provider_google.py b/tests/test_provider_google.py index 0390b4a6..e3b22ad9 100644 --- a/tests/test_provider_google.py +++ b/tests/test_provider_google.py @@ -1,6 +1,7 @@ import pytest import requests from chatlas import ChatGoogle, ChatVertex, tool_web_fetch, tool_web_search +from chatlas._provider_google import normalize_finish_reason as google_normalize_finish_reason from google.genai.errors import APIError from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential @@ -27,6 +28,25 @@ def chat_func(vertex: bool = False, **kwargs): return chat +def test_normalize_finish_reason_maps_known_reasons(): + assert google_normalize_finish_reason("STOP") == "success" + assert google_normalize_finish_reason("MAX_TOKENS") == "max_tokens" + assert google_normalize_finish_reason("SAFETY") == "content_filter" + assert google_normalize_finish_reason("RECITATION") == "content_filter" + assert google_normalize_finish_reason("BLOCKLIST") == "content_filter" + assert google_normalize_finish_reason("PROHIBITED_CONTENT") == "content_filter" + assert google_normalize_finish_reason("SPII") == "content_filter" + + +def test_normalize_finish_reason_passes_through_unknown(): + assert google_normalize_finish_reason("OTHER") == "OTHER" + assert google_normalize_finish_reason("NEW_REASON") == "NEW_REASON" + + +def test_normalize_finish_reason_handles_none(): + assert google_normalize_finish_reason(None) is None + + def test_google_reasoning_effort_string(): """A string `reasoning` maps to a `thinking_level` enum (#998).""" from google.genai.types import ThinkingLevel @@ -80,7 +100,7 @@ def test_google_simple_request(): assert turn.tokens[0] == 18 # input tokens # Output tokens can vary (1-29), so just check it's positive assert turn.tokens[1] > 0 - assert turn.finish_reason == "STOP" + assert turn.finish_reason == "success" assert chat.provider.name == "Google/Gemini" @@ -94,7 +114,7 @@ def test_google_simple_request(): # turn = chat.get_last_turn() # assert turn is not None # assert turn.tokens == (16, 2) -# assert turn.finish_reason == "STOP" +# assert turn.finish_reason == "success" # assert chat.provider.name == "Google/Vertex" @@ -125,7 +145,7 @@ def test_name_setting(): # assert "2" in "".join(res) # turn = chat.get_last_turn() # assert turn is not None -# assert turn.finish_reason == "STOP" +# assert turn.finish_reason == "success" @pytest.mark.vcr diff --git a/tests/test_provider_huggingface.py b/tests/test_provider_huggingface.py index ffb5aa5a..2628e4cf 100644 --- a/tests/test_provider_huggingface.py +++ b/tests/test_provider_huggingface.py @@ -24,7 +24,7 @@ def test_huggingface_simple_request(): assert len(turn.tokens) == 3 assert turn.tokens[0] > 0 # input tokens assert turn.tokens[1] > 0 # output tokens - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr @@ -40,7 +40,7 @@ async def test_huggingface_simple_streaming_request(): assert "2" in "".join(res) turn = chat.get_last_turn() assert turn is not None - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr diff --git a/tests/test_provider_lmstudio.py b/tests/test_provider_lmstudio.py index 8ec4415e..13100183 100644 --- a/tests/test_provider_lmstudio.py +++ b/tests/test_provider_lmstudio.py @@ -31,7 +31,7 @@ def test_lmstudio_simple_request(): assert turn.tokens is not None assert turn.tokens[0] > 0 assert turn.tokens[1] > 0 - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" def test_lmstudio_simple_streaming_request(): diff --git a/tests/test_provider_mistral.py b/tests/test_provider_mistral.py index 90fbf13f..bb3c4a41 100644 --- a/tests/test_provider_mistral.py +++ b/tests/test_provider_mistral.py @@ -23,7 +23,7 @@ def test_mistral_simple_request(): assert len(turn.tokens) == 3 assert turn.tokens[0] > 0 # prompt tokens assert turn.tokens[1] > 0 # completion tokens - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr @@ -38,7 +38,7 @@ async def test_mistral_simple_streaming_request(): assert "2" in "".join(res) turn = chat.get_last_turn() assert turn is not None - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr diff --git a/tests/test_provider_openai.py b/tests/test_provider_openai.py index 75c9b9e3..2a3b4964 100644 --- a/tests/test_provider_openai.py +++ b/tests/test_provider_openai.py @@ -3,6 +3,9 @@ import httpx import pytest from chatlas import ChatOpenAI, tool_web_search +from chatlas._provider_openai import ( + normalize_finish_reason as openai_normalize_finish_reason, +) from openai.types.responses import ResponseOutputMessage, ResponseOutputText from .conftest import ( @@ -22,6 +25,44 @@ ) +def test_normalize_finish_reason_completed(): + assert openai_normalize_finish_reason("completed") == "success" + + +def test_normalize_finish_reason_incomplete_max_tokens(): + assert ( + openai_normalize_finish_reason("incomplete", "max_output_tokens") + == "max_tokens" + ) + + +def test_normalize_finish_reason_incomplete_content_filter(): + assert ( + openai_normalize_finish_reason("incomplete", "content_filter") + == "content_filter" + ) + + +def test_normalize_finish_reason_incomplete_unknown_reason(): + assert ( + openai_normalize_finish_reason("incomplete", "some_other_reason") + == "some_other_reason" + ) + + +def test_normalize_finish_reason_incomplete_no_reason(): + assert openai_normalize_finish_reason("incomplete", None) == "incomplete" + + +def test_normalize_finish_reason_passes_through_unknown_status(): + assert openai_normalize_finish_reason("failed") == "failed" + assert openai_normalize_finish_reason("cancelled") == "cancelled" + + +def test_normalize_finish_reason_handles_none(): + assert openai_normalize_finish_reason(None) is None + + @pytest.mark.vcr def test_openai_simple_request(): chat = ChatOpenAI( @@ -236,9 +277,7 @@ def make_response(action: dict): assert turn.contents[0].query == "find this" # search action without query but with queries - resp = make_response( - {"type": "search", "query": "", "queries": ["first query"]} - ) + resp = make_response({"type": "search", "query": "", "queries": ["first query"]}) turn = provider._response_as_turn(resp, has_data_model=False) assert isinstance(turn.contents[0], ContentToolRequestSearch) assert turn.contents[0].query == "first query" diff --git a/tests/test_provider_openai_completions.py b/tests/test_provider_openai_completions.py index 32f64e6e..e5509c19 100644 --- a/tests/test_provider_openai_completions.py +++ b/tests/test_provider_openai_completions.py @@ -8,6 +8,9 @@ ContentToolRequest, ) from chatlas._provider_openai_completions import OpenAICompletionsProvider +from chatlas._provider_openai_completions import ( + normalize_finish_reason as completions_normalize_finish_reason, +) from chatlas._turn import AssistantTurn from .conftest import ( @@ -26,6 +29,21 @@ ) +def test_normalize_finish_reason_maps_known_reasons(): + assert completions_normalize_finish_reason("stop") == "success" + assert completions_normalize_finish_reason("length") == "max_tokens" + assert completions_normalize_finish_reason("content_filter") == "content_filter" + + +def test_normalize_finish_reason_passes_through_unknown(): + assert completions_normalize_finish_reason("tool_calls") == "tool_calls" + assert completions_normalize_finish_reason("function_call") == "function_call" + + +def test_normalize_finish_reason_handles_none(): + assert completions_normalize_finish_reason(None) is None + + @pytest.mark.vcr def test_openai_simple_request(): chat = ChatOpenAICompletions( @@ -38,7 +56,7 @@ def test_openai_simple_request(): assert len(turn.tokens) == 3 assert turn.tokens[0] == 26 # Not testing turn.tokens[1] because it's not deterministic. Typically 1 or 2. - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr @@ -53,7 +71,7 @@ async def test_openai_simple_streaming_request(): assert "2" in "".join(res) turn = chat.get_last_turn() assert turn is not None - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr @@ -256,9 +274,7 @@ def test_response_as_turn_treats_empty_content_as_none(): message.reasoning = None message.reasoning_content = None message.content = "" - message.tool_calls = [ - Mock(type="function", id="call_1", function=mock_func) - ] + message.tool_calls = [Mock(type="function", id="call_1", function=mock_func)] completion.choices = [Mock(message=message, finish_reason="stop")] turn = OpenAICompletionsProvider._response_as_turn(completion, has_data_model=False) diff --git a/tests/test_provider_openrouter.py b/tests/test_provider_openrouter.py index e1dec0c9..639e3945 100644 --- a/tests/test_provider_openrouter.py +++ b/tests/test_provider_openrouter.py @@ -22,7 +22,7 @@ def test_openrouter_simple_request(): assert turn.tokens is not None assert len(turn.tokens) == 3 assert turn.tokens[0] >= 1 - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr @@ -38,7 +38,7 @@ async def test_openrouter_simple_streaming_request(): assert "2" in "".join(res) turn = chat.get_last_turn() assert turn is not None - assert turn.finish_reason == "stop" + assert turn.finish_reason == "success" @pytest.mark.vcr From dd9db66461259c99257e3e4e6b47f5314761e15c Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 21 Jul 2026 13:26:43 -0500 Subject: [PATCH 2/7] fix: normalize tool-call finish reasons to "tool_use" Anthropic's stop_reason and OpenAI completions' finish_reason both use provider-specific spellings for "the model wants to call a tool" (tool_use / tool_calls). Map both explicitly to "tool_use" so callers get a consistent value instead of a provider-specific string leaking through unnormalized. Mirrors tidyverse/ellmer#1039. --- chatlas/_provider_anthropic.py | 1 + chatlas/_provider_openai_completions.py | 1 + tests/test_provider_anthropic.py | 10 ++++++++-- tests/test_provider_openai_completions.py | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/chatlas/_provider_anthropic.py b/chatlas/_provider_anthropic.py index 0dfa480a..6c084d18 100644 --- a/chatlas/_provider_anthropic.py +++ b/chatlas/_provider_anthropic.py @@ -299,6 +299,7 @@ def ChatAnthropic( # https://docs.anthropic.com/en/api/handling-stop-reasons _ANTHROPIC_FINISH_REASON_MAP = { "end_turn": "success", + "tool_use": "tool_use", "max_tokens": "max_tokens", "model_context_window_exceeded": "context_window", "stop_sequence": "stop_sequence", diff --git a/chatlas/_provider_openai_completions.py b/chatlas/_provider_openai_completions.py index 5666eed1..7e535d2a 100644 --- a/chatlas/_provider_openai_completions.py +++ b/chatlas/_provider_openai_completions.py @@ -120,6 +120,7 @@ def ChatOpenAICompletions( # https://platform.openai.com/docs/api-reference/chat/create _OPENAI_COMPLETIONS_FINISH_REASON_MAP = { "stop": "success", + "tool_calls": "tool_use", "length": "max_tokens", "content_filter": "content_filter", } diff --git a/tests/test_provider_anthropic.py b/tests/test_provider_anthropic.py index 54d596c6..208e8508 100644 --- a/tests/test_provider_anthropic.py +++ b/tests/test_provider_anthropic.py @@ -10,7 +10,7 @@ tool_web_fetch, tool_web_search, ) -from chatlas._provider_anthropic import AnthropicProvider +from chatlas._provider_anthropic import _ANTHROPIC_FINISH_REASON_MAP, AnthropicProvider from chatlas._provider_anthropic import ( normalize_finish_reason as anthropic_normalize_finish_reason, ) @@ -44,10 +44,16 @@ def test_normalize_finish_reason_maps_known_reasons(): == "context_window" ) assert anthropic_normalize_finish_reason("refusal") == "content_filter" + assert anthropic_normalize_finish_reason("tool_use") == "tool_use" + + +def test_normalize_finish_reason_maps_tool_use_explicitly(): + # tool_use must be an explicit mapping, not an incidental passthrough of an + # unknown reason, so it isn't confused with a truly unrecognized reason. + assert "tool_use" in _ANTHROPIC_FINISH_REASON_MAP def test_normalize_finish_reason_passes_through_unknown(): - assert anthropic_normalize_finish_reason("tool_use") == "tool_use" assert anthropic_normalize_finish_reason("some_new_reason") == "some_new_reason" diff --git a/tests/test_provider_openai_completions.py b/tests/test_provider_openai_completions.py index e5509c19..627b74cc 100644 --- a/tests/test_provider_openai_completions.py +++ b/tests/test_provider_openai_completions.py @@ -33,10 +33,10 @@ def test_normalize_finish_reason_maps_known_reasons(): assert completions_normalize_finish_reason("stop") == "success" assert completions_normalize_finish_reason("length") == "max_tokens" assert completions_normalize_finish_reason("content_filter") == "content_filter" + assert completions_normalize_finish_reason("tool_calls") == "tool_use" def test_normalize_finish_reason_passes_through_unknown(): - assert completions_normalize_finish_reason("tool_calls") == "tool_calls" assert completions_normalize_finish_reason("function_call") == "function_call" From 9b455617421d6ed189ec798ca22665ca6cbda4c1 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 21 Jul 2026 13:45:08 -0500 Subject: [PATCH 3/7] docs: add changelog entry for finish_reason normalization --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d2ffbe..ab730836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Improvements * `ChatGoogle()` and `ChatVertex()` now default to `gemini-3.5-flash` instead of the older `gemini-2.5-flash`. +* `Turn.finish_reason` is now normalized to a consistent set of values (`"success"`, `"tool_use"`, `"max_tokens"`, `"content_filter"`, `"context_window"`, `"stop_sequence"`) across all providers, so you no longer need provider-specific logic to check why a turn ended. Previously each provider surfaced its own raw string (e.g. Anthropic's `"end_turn"`/`"tool_use"` vs. OpenAI Completions' `"stop"`/`"tool_calls"` vs. Google's `"STOP"`/`"SAFETY"`), so the same outcome could require different checks depending on which `Chat*()` you used. Reasons chatlas doesn't yet recognize still pass through unchanged. ## [0.19.2] - 2026-07-08 From 3a8be2e0c708c22007ca04d048ff2a693c1311d6 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 21 Jul 2026 13:52:26 -0500 Subject: [PATCH 4/7] feat: add FinishReason literal type for AssistantTurn.finish_reason Now that finish_reason is normalized to a shared vocabulary across providers, type it as a Literal (with a str fallback for reasons chatlas doesn't yet recognize) instead of a bare str. --- chatlas/_provider_anthropic.py | 4 ++-- chatlas/_provider_google.py | 4 ++-- chatlas/_provider_openai.py | 4 ++-- chatlas/_provider_openai_completions.py | 4 ++-- chatlas/_turn.py | 23 ++++++++++++++++++++--- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/chatlas/_provider_anthropic.py b/chatlas/_provider_anthropic.py index 6c084d18..4bf7061a 100644 --- a/chatlas/_provider_anthropic.py +++ b/chatlas/_provider_anthropic.py @@ -45,7 +45,7 @@ from ._tokens import get_price_info from ._tools import Tool, ToolBuiltIn, basemodel_to_param_schema from ._tools_builtin import ToolWebFetch, ToolWebSearch -from ._turn import AssistantTurn, SystemTurn, Turn, UserTurn, user_turn +from ._turn import AssistantTurn, FinishReason, SystemTurn, Turn, UserTurn, user_turn from ._utils import split_http_client_kwargs if TYPE_CHECKING: @@ -307,7 +307,7 @@ def ChatAnthropic( } -def normalize_finish_reason(reason: str | None) -> str | None: +def normalize_finish_reason(reason: str | None) -> FinishReason | str | None: if reason is None: return None return _ANTHROPIC_FINISH_REASON_MAP.get(reason, reason) diff --git a/chatlas/_provider_google.py b/chatlas/_provider_google.py index f30b1b99..919749fc 100644 --- a/chatlas/_provider_google.py +++ b/chatlas/_provider_google.py @@ -25,7 +25,7 @@ from ._tokens import get_price_info from ._tools import Tool, ToolBuiltIn from ._tools_builtin import ToolWebFetch, ToolWebSearch -from ._turn import AssistantTurn, SystemTurn, Turn, UserTurn, user_turn +from ._turn import AssistantTurn, FinishReason, SystemTurn, Turn, UserTurn, user_turn if TYPE_CHECKING: from google.genai.types import Content as GoogleContent @@ -202,7 +202,7 @@ def ChatGoogle( } -def normalize_finish_reason(reason: str | None) -> str | None: +def normalize_finish_reason(reason: str | None) -> FinishReason | str | None: if reason is None: return None return _GOOGLE_FINISH_REASON_MAP.get(reason, reason) diff --git a/chatlas/_provider_openai.py b/chatlas/_provider_openai.py index 0bdc847c..34f2b963 100644 --- a/chatlas/_provider_openai.py +++ b/chatlas/_provider_openai.py @@ -29,7 +29,7 @@ from ._provider_openai_generic import BatchResult, OpenAIAbstractProvider from ._tools import Tool, ToolBuiltIn, basemodel_to_param_schema from ._tools_builtin import ToolWebFetch, ToolWebSearch -from ._turn import AssistantTurn, Turn +from ._turn import AssistantTurn, FinishReason, Turn if TYPE_CHECKING: from openai.types.responses import ( @@ -203,7 +203,7 @@ def ChatOpenAI( def normalize_finish_reason( status: str | None, incomplete_reason: str | None = None -) -> str | None: +) -> FinishReason | str | None: if status is None: return None if status == "completed": diff --git a/chatlas/_provider_openai_completions.py b/chatlas/_provider_openai_completions.py index 7e535d2a..7eeb7fd7 100644 --- a/chatlas/_provider_openai_completions.py +++ b/chatlas/_provider_openai_completions.py @@ -34,7 +34,7 @@ from ._provider import StandardModelParamNames, StandardModelParams from ._provider_openai_generic import BatchResult, OpenAIAbstractProvider from ._tools import Tool, ToolBuiltIn, basemodel_to_param_schema -from ._turn import AssistantTurn, SystemTurn, Turn, UserTurn +from ._turn import AssistantTurn, FinishReason, SystemTurn, Turn, UserTurn from ._utils import MISSING, MISSING_TYPE, is_testing if TYPE_CHECKING: @@ -126,7 +126,7 @@ def ChatOpenAICompletions( } -def normalize_finish_reason(reason: str | None) -> str | None: +def normalize_finish_reason(reason: str | None) -> FinishReason | str | None: if reason is None: return None return _OPENAI_COMPLETIONS_FINISH_REASON_MAP.get(reason, reason) diff --git a/chatlas/_turn.py b/chatlas/_turn.py index 58246c59..90ff38eb 100644 --- a/chatlas/_turn.py +++ b/chatlas/_turn.py @@ -21,6 +21,19 @@ CompletionT = TypeVar("CompletionT") Role = Literal["user", "assistant", "system"] +# The reasons chatlas normalizes provider-specific finish/stop reasons to. +# Providers may still surface a reason chatlas doesn't yet recognize, so +# consumers should treat this as open-ended (hence the trailing `| str`) +# rather than exhaustive. +FinishReason = Literal[ + "success", + "tool_use", + "max_tokens", + "content_filter", + "context_window", + "stop_sequence", +] + class Turn(BaseModel): """ @@ -301,7 +314,11 @@ class AssistantTurn(Turn, Generic[CompletionT]): A numeric vector of length 3 representing the number of input, output, and cached tokens (respectively) used in this turn. finish_reason - A string indicating the reason why the conversation ended. + The (normalized) reason why the turn ended, e.g. `"success"`, + `"tool_use"`, `"max_tokens"`, `"content_filter"`, `"context_window"`, + or `"stop_sequence"`. Providers may also surface a reason chatlas + doesn't yet recognize, in which case the raw string is passed through + unchanged. completion The completion object returned by the provider. This is useful if there's information returned by the provider that chatlas doesn't otherwise expose. @@ -322,7 +339,7 @@ class AssistantTurn(Turn, Generic[CompletionT]): frozen=True, ) tokens: Optional[tuple[int, int, int]] = None - finish_reason: Optional[str] = None + finish_reason: Optional[FinishReason | str] = None completion: Optional[CompletionT] = Field(default=None, exclude=True) cost: Optional[float] = None partial_reason: Optional[str] = None @@ -345,7 +362,7 @@ def __init__( contents: str | Sequence[Content | str], *, tokens: Optional[tuple[int, int, int] | list[int]] = None, - finish_reason: Optional[str] = None, + finish_reason: Optional[FinishReason | str] = None, completion: Optional[CompletionT] = None, cost: Optional[float] = None, partial_reason: Optional[str] = None, From 3628cbae4a9633195bd020500dacd08264aeb7cc Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 21 Jul 2026 13:54:22 -0500 Subject: [PATCH 5/7] feat: re-export FinishReason from chatlas.types --- chatlas/types/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/chatlas/types/__init__.py b/chatlas/types/__init__.py index 1c7d0b29..a6c5e580 100644 --- a/chatlas/types/__init__.py +++ b/chatlas/types/__init__.py @@ -26,6 +26,7 @@ from .._parallel import StructuredChatResult from .._provider import ModelInfo from .._tokens import TokenUsage +from .._turn import FinishReason from .._utils import MISSING, MISSING_TYPE __all__ = ( @@ -44,6 +45,7 @@ "ContentToolResponseFetch", "ContentToolRequestSearch", "ContentToolResponseSearch", + "FinishReason", "StructuredChatResult", "ChatResponse", "ChatResponseAsync", From 6e470711ab500d34693c5a92f508d16495a85a9d Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 21 Jul 2026 14:15:55 -0500 Subject: [PATCH 6/7] refactor: type finish-reason maps as dict[str, FinishReason] Each provider's _*_FINISH_REASON_MAP now declares its value type as FinishReason, documenting that every mapped value is one of the known reasons. normalize_finish_reason()'s return type drops the redundant `FinishReason |` prefix (pyright collapses `Literal[...] | str` down to plain `str` anyway) since these functions pass unrecognized reasons through unchanged, matching ellmer's value_finish_reason() behavior. --- chatlas/_provider_anthropic.py | 4 ++-- chatlas/_provider_google.py | 4 ++-- chatlas/_provider_openai.py | 4 ++-- chatlas/_provider_openai_completions.py | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/chatlas/_provider_anthropic.py b/chatlas/_provider_anthropic.py index 4bf7061a..1892c230 100644 --- a/chatlas/_provider_anthropic.py +++ b/chatlas/_provider_anthropic.py @@ -297,7 +297,7 @@ def ChatAnthropic( # https://docs.anthropic.com/en/api/handling-stop-reasons -_ANTHROPIC_FINISH_REASON_MAP = { +_ANTHROPIC_FINISH_REASON_MAP: dict[str, FinishReason] = { "end_turn": "success", "tool_use": "tool_use", "max_tokens": "max_tokens", @@ -307,7 +307,7 @@ def ChatAnthropic( } -def normalize_finish_reason(reason: str | None) -> FinishReason | str | None: +def normalize_finish_reason(reason: str | None) -> str | None: if reason is None: return None return _ANTHROPIC_FINISH_REASON_MAP.get(reason, reason) diff --git a/chatlas/_provider_google.py b/chatlas/_provider_google.py index 919749fc..a97e939e 100644 --- a/chatlas/_provider_google.py +++ b/chatlas/_provider_google.py @@ -191,7 +191,7 @@ def ChatGoogle( # https://ai.google.dev/api/generate-content -_GOOGLE_FINISH_REASON_MAP = { +_GOOGLE_FINISH_REASON_MAP: dict[str, FinishReason] = { "STOP": "success", "MAX_TOKENS": "max_tokens", "SAFETY": "content_filter", @@ -202,7 +202,7 @@ def ChatGoogle( } -def normalize_finish_reason(reason: str | None) -> FinishReason | str | None: +def normalize_finish_reason(reason: str | None) -> str | None: if reason is None: return None return _GOOGLE_FINISH_REASON_MAP.get(reason, reason) diff --git a/chatlas/_provider_openai.py b/chatlas/_provider_openai.py index 34f2b963..7e6ff125 100644 --- a/chatlas/_provider_openai.py +++ b/chatlas/_provider_openai.py @@ -195,7 +195,7 @@ def ChatOpenAI( # https://platform.openai.com/docs/api-reference/responses/get -_OPENAI_INCOMPLETE_REASON_MAP = { +_OPENAI_INCOMPLETE_REASON_MAP: dict[str, FinishReason] = { "max_output_tokens": "max_tokens", "content_filter": "content_filter", } @@ -203,7 +203,7 @@ def ChatOpenAI( def normalize_finish_reason( status: str | None, incomplete_reason: str | None = None -) -> FinishReason | str | None: +) -> str | None: if status is None: return None if status == "completed": diff --git a/chatlas/_provider_openai_completions.py b/chatlas/_provider_openai_completions.py index 7eeb7fd7..61521101 100644 --- a/chatlas/_provider_openai_completions.py +++ b/chatlas/_provider_openai_completions.py @@ -118,7 +118,7 @@ def ChatOpenAICompletions( # https://platform.openai.com/docs/api-reference/chat/create -_OPENAI_COMPLETIONS_FINISH_REASON_MAP = { +_OPENAI_COMPLETIONS_FINISH_REASON_MAP: dict[str, FinishReason] = { "stop": "success", "tool_calls": "tool_use", "length": "max_tokens", @@ -126,7 +126,7 @@ def ChatOpenAICompletions( } -def normalize_finish_reason(reason: str | None) -> FinishReason | str | None: +def normalize_finish_reason(reason: str | None) -> str | None: if reason is None: return None return _OPENAI_COMPLETIONS_FINISH_REASON_MAP.get(reason, reason) From b8c0f60874c2d4fd0d87137e2a72dc821891e399 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 21 Jul 2026 17:06:30 -0500 Subject: [PATCH 7/7] fix: address Copilot review feedback on finish_reason normalization - Normalize OpenAI Responses API finish_reason to "tool_use" when the output contains a function_call, since status alone is "completed" even for tool-calling turns. - Fix line length in test_provider_google.py import. - Correct misleading comment on FinishReason type alias. - Soften changelog wording since SnowflakeProvider doesn't yet set finish_reason. --- CHANGELOG.md | 2 +- chatlas/_provider_openai.py | 16 ++++++++++------ chatlas/_turn.py | 5 ++--- tests/test_provider_google.py | 10 ++++++---- tests/test_provider_openai.py | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 53 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab730836..61a9b49c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Improvements * `ChatGoogle()` and `ChatVertex()` now default to `gemini-3.5-flash` instead of the older `gemini-2.5-flash`. -* `Turn.finish_reason` is now normalized to a consistent set of values (`"success"`, `"tool_use"`, `"max_tokens"`, `"content_filter"`, `"context_window"`, `"stop_sequence"`) across all providers, so you no longer need provider-specific logic to check why a turn ended. Previously each provider surfaced its own raw string (e.g. Anthropic's `"end_turn"`/`"tool_use"` vs. OpenAI Completions' `"stop"`/`"tool_calls"` vs. Google's `"STOP"`/`"SAFETY"`), so the same outcome could require different checks depending on which `Chat*()` you used. Reasons chatlas doesn't yet recognize still pass through unchanged. +* `Turn.finish_reason` is now normalized to a consistent set of values (`"success"`, `"tool_use"`, `"max_tokens"`, `"content_filter"`, `"context_window"`, `"stop_sequence"`) across most providers, so you no longer need provider-specific logic to check why a turn ended. Previously each provider surfaced its own raw string (e.g. Anthropic's `"end_turn"`/`"tool_use"` vs. OpenAI Completions' `"stop"`/`"tool_calls"` vs. Google's `"STOP"`/`"SAFETY"`), so the same outcome could require different checks depending on which `Chat*()` you used. Reasons chatlas doesn't yet recognize still pass through unchanged. (`ChatSnowflake()` does not yet set `finish_reason`.) ## [0.19.2] - 2026-07-08 diff --git a/chatlas/_provider_openai.py b/chatlas/_provider_openai.py index 7e6ff125..28c402cb 100644 --- a/chatlas/_provider_openai.py +++ b/chatlas/_provider_openai.py @@ -469,14 +469,18 @@ def _response_as_turn(completion: Response, has_data_model: bool) -> AssistantTu raise ValueError(f"Unknown output type: {output.type}") incomplete_reason = None - if getattr(completion, "incomplete_details", None) is not None: - # getattr() check above confirms non-None, but pyright can't narrow through getattr - incomplete_reason = completion.incomplete_details.reason # type: ignore[union-attr] + if completion.incomplete_details is not None: + incomplete_reason = completion.incomplete_details.reason + + finish_reason = normalize_finish_reason(completion.status, incomplete_reason) + if finish_reason == "success" and any( + isinstance(x, ContentToolRequest) for x in contents + ): + finish_reason = "tool_use" + return AssistantTurn( contents, - finish_reason=normalize_finish_reason( - getattr(completion, "status", None), incomplete_reason - ), + finish_reason=finish_reason, completion=completion, ) diff --git a/chatlas/_turn.py b/chatlas/_turn.py index 90ff38eb..082747be 100644 --- a/chatlas/_turn.py +++ b/chatlas/_turn.py @@ -22,9 +22,8 @@ Role = Literal["user", "assistant", "system"] # The reasons chatlas normalizes provider-specific finish/stop reasons to. -# Providers may still surface a reason chatlas doesn't yet recognize, so -# consumers should treat this as open-ended (hence the trailing `| str`) -# rather than exhaustive. +# Not exhaustive: providers may surface a reason chatlas doesn't yet +# recognize, so usages pair this with `| str` (see AssistantTurn.finish_reason). FinishReason = Literal[ "success", "tool_use", diff --git a/tests/test_provider_google.py b/tests/test_provider_google.py index e3b22ad9..adb959d6 100644 --- a/tests/test_provider_google.py +++ b/tests/test_provider_google.py @@ -1,7 +1,9 @@ import pytest import requests from chatlas import ChatGoogle, ChatVertex, tool_web_fetch, tool_web_search -from chatlas._provider_google import normalize_finish_reason as google_normalize_finish_reason +from chatlas._provider_google import ( + normalize_finish_reason as google_normalize_finish_reason, +) from google.genai.errors import APIError from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential @@ -133,9 +135,9 @@ def test_name_setting(): # TODO: this test runs fine in isolation, but fails for some reason when run with the other tests # Seems google isn't handling async 100% correctly -#@pytest.mark.vcr -#@pytest.mark.asyncio -#async def test_google_simple_streaming_request(): +# @pytest.mark.vcr +# @pytest.mark.asyncio +# async def test_google_simple_streaming_request(): # chat = chat_func( # system_prompt="Be as terse as possible; no punctuation. Do not spell out numbers.", # ) diff --git a/tests/test_provider_openai.py b/tests/test_provider_openai.py index 2a3b4964..544986ba 100644 --- a/tests/test_provider_openai.py +++ b/tests/test_provider_openai.py @@ -289,6 +289,40 @@ def make_response(action: dict): assert turn.contents[0].query == "web search" +def test_openai_function_call_finish_reason_is_tool_use(): + """A completed response containing a function_call should normalize to tool_use.""" + from chatlas._provider_openai import OpenAIProvider + from openai.types.responses import Response + + chat = ChatOpenAI() + provider = chat.provider + assert isinstance(provider, OpenAIProvider) + + resp = Response.model_validate( + { + "id": "resp_1", + "created_at": 0, + "model": "gpt-4.1", + "object": "response", + "status": "completed", + "output": [ + { + "id": "fc_1", + "type": "function_call", + "call_id": "call_1", + "name": "get_date", + "arguments": "{}", + } + ], + "parallel_tool_calls": True, + "tool_choice": "auto", + "tools": [], + } + ) + turn = provider._response_as_turn(resp, has_data_model=False) + assert turn.finish_reason == "tool_use" + + def test_openai_custom_base_url_warning(): from chatlas._provider_openai import check_base_url