fix(core): preserve unmapped finish_reason and add 'refusal' mapping

Previously, map_finish_reason() would default to 'stop' for any
unmapped finish_reason, which is lossy. This restores the previous
behavior of passing through unmapped values as-is, so callers can
handle provider-specific finish reasons correctly.

Also adds 'refusal' to the finish reason map and type, as Anthropic
can return this stop reason (see https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons#refusal).

Fixes #23793
This commit is contained in:
Haoran Shu 2026-03-16 18:34:54 -07:00
parent 278c9babc6
commit 017461627e
3 changed files with 12 additions and 8 deletions

View file

@ -98,16 +98,18 @@ _FINISH_REASON_MAP: dict[str, OpenAIChatCompletionFinishReason] = {
"content_filter": "content_filter",
# Anthropic Sonnet 4
"content_filtered": "content_filter",
# Anthropic refusal
"refusal": "refusal",
}
def map_finish_reason(finish_reason: str) -> OpenAIChatCompletionFinishReason:
def map_finish_reason(finish_reason: str) -> str:
mapped = _FINISH_REASON_MAP.get(finish_reason)
if mapped is None:
verbose_logger.warning(
"Unmapped finish_reason '%s', defaulting to 'stop'", finish_reason
"Unmapped finish_reason '%s', passing through as-is", finish_reason
)
return "stop"
return finish_reason
return mapped

View file

@ -2131,6 +2131,7 @@ OpenAIChatCompletionFinishReason = Literal[
"function_call",
"tool_calls",
"length",
"refusal",
"guardrail_intervened",
"eos",
"finish_reason_unspecified",

View file

@ -55,7 +55,7 @@ def test_reconstruct_model_name_returns_original_for_other_providers():
# map_finish_reason tests
# ---------------------------------------------------------------------------
VALID_OPENAI_FINISH_REASONS = {"stop", "length", "tool_calls", "function_call", "content_filter"}
VALID_OPENAI_FINISH_REASONS = {"stop", "length", "tool_calls", "function_call", "content_filter", "refusal"}
class TestMapFinishReasonAnthropic:
@ -68,6 +68,7 @@ class TestMapFinishReasonAnthropic:
("tool_use", "tool_calls"),
("compaction", "length"),
("content_filtered", "content_filter"),
("refusal", "refusal"),
],
)
def test_anthropic_finish_reasons(self, provider_reason: str, expected: str) -> None:
@ -132,11 +133,11 @@ class TestMapFinishReasonOpenAIPassthrough:
class TestMapFinishReasonUnknown:
def test_unknown_value_defaults_to_stop(self):
assert map_finish_reason("some_unknown_value") == "stop"
def test_unknown_value_passes_through(self):
assert map_finish_reason("some_unknown_value") == "some_unknown_value"
def test_empty_string_defaults_to_stop(self):
assert map_finish_reason("") == "stop"
def test_empty_string_passes_through(self):
assert map_finish_reason("") == ""
class TestFinishReasonMapOutputsAreValid: