Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 19 additions & 2 deletions chatlas/_provider_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"]
):
Expand Down Expand Up @@ -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,
)

Expand Down
22 changes: 20 additions & 2 deletions chatlas/_provider_google.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -683,7 +701,7 @@ def _as_turn(

return AssistantTurn(
contents,
finish_reason=finish_reason,
finish_reason=normalize_finish_reason(finish_reason),
completion=message,
)

Expand Down
35 changes: 33 additions & 2 deletions chatlas/_provider_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)

Expand Down
19 changes: 17 additions & 2 deletions chatlas/_provider_openai_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)

Expand Down
22 changes: 19 additions & 3 deletions chatlas/_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions chatlas/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = (
Expand All @@ -44,6 +45,7 @@
"ContentToolResponseFetch",
"ContentToolRequestSearch",
"ContentToolResponseSearch",
"FinishReason",
"StructuredChatResult",
"ChatResponse",
"ChatResponseAsync",
Expand Down
35 changes: 32 additions & 3 deletions tests/test_provider_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_provider_bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/test_provider_cloudflare.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/test_provider_databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/test_provider_deepseek.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading