diff --git a/CHANGELOG.md b/CHANGELOG.md index f96efc5f..c742370d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `ChatGoogle()` and `ChatVertex()` now default to `gemini-3.5-flash` instead of the older `gemini-2.5-flash`. * `ChatGroq()` now defaults to `openai/gpt-oss-20b` instead of `llama-3.1-8b-instant`. +### Changes + +* `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. + ### Bug fixes * `ChatGoogle()` no longer errors when mixing custom tools and built-in tools (e.g. `tool_web_search()`) on Gemini 3+ models. diff --git a/chatlas/_provider_anthropic.py b/chatlas/_provider_anthropic.py index d734e82d..1892c230 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: @@ -296,6 +296,23 @@ def ChatAnthropic( ) +# https://docs.anthropic.com/en/api/handling-stop-reasons +_ANTHROPIC_FINISH_REASON_MAP: dict[str, FinishReason] = { + "end_turn": "success", + "tool_use": "tool_use", + "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 +957,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 d9d40505..bdafdc15 100644 --- a/chatlas/_provider_google.py +++ b/chatlas/_provider_google.py @@ -33,7 +33,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 @@ -198,6 +198,24 @@ def ChatGoogle( ) +# https://ai.google.dev/api/generate-content +_GOOGLE_FINISH_REASON_MAP: dict[str, FinishReason] = { + "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, @@ -683,7 +701,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..28c402cb 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 ( @@ -194,6 +194,26 @@ def ChatOpenAI( ) +# https://platform.openai.com/docs/api-reference/responses/get +_OPENAI_INCOMPLETE_REASON_MAP: dict[str, FinishReason] = { + "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,19 @@ def _response_as_turn(completion: Response, has_data_model: bool) -> AssistantTu else: raise ValueError(f"Unknown output type: {output.type}") + incomplete_reason = None + 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=finish_reason, completion=completion, ) diff --git a/chatlas/_provider_openai_completions.py b/chatlas/_provider_openai_completions.py index 9dac3bb5..61521101 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: @@ -117,6 +117,21 @@ def ChatOpenAICompletions( ) +# https://platform.openai.com/docs/api-reference/chat/create +_OPENAI_COMPLETIONS_FINISH_REASON_MAP: dict[str, FinishReason] = { + "stop": "success", + "tool_calls": "tool_use", + "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 +459,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/chatlas/_turn.py b/chatlas/_turn.py index 58246c59..082747be 100644 --- a/chatlas/_turn.py +++ b/chatlas/_turn.py @@ -21,6 +21,18 @@ CompletionT = TypeVar("CompletionT") Role = Literal["user", "assistant", "system"] +# The reasons chatlas normalizes provider-specific finish/stop reasons to. +# 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", + "max_tokens", + "content_filter", + "context_window", + "stop_sequence", +] + class Turn(BaseModel): """ @@ -301,7 +313,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 +338,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 +361,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, 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", diff --git a/tests/test_provider_anthropic.py b/tests/test_provider_anthropic.py index 55b34dd8..208e8508 100644 --- a/tests/test_provider_anthropic.py +++ b/tests/test_provider_anthropic.py @@ -10,7 +10,10 @@ 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, +) from pydantic import BaseModel, Field from .conftest import ( @@ -32,6 +35,32 @@ ) +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" + 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("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 +78,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 +94,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 1c38b38b..4d12fcab 100644 --- a/tests/test_provider_google.py +++ b/tests/test_provider_google.py @@ -1,6 +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 google.genai.errors import APIError from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential @@ -27,6 +30,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 +102,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 +116,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 +147,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..544986ba 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" @@ -250,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 diff --git a/tests/test_provider_openai_completions.py b/tests/test_provider_openai_completions.py index 32f64e6e..627b74cc 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" + assert completions_normalize_finish_reason("tool_calls") == "tool_use" + + +def test_normalize_finish_reason_passes_through_unknown(): + 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