fix(anthropic): accept dict-shape reasoning_effort from Responses bridge (#28201)

* fix(anthropic): accept dict-shape reasoning_effort from Responses bridge

Issue #28196 — the Responses->Chat parser (transformation.py:184-200) keeps the full dict as reasoning_effort when summary is set; that branch was added in #25359. But the Anthropic transformation here still guarded on isinstance(value, str), silently dropping the param. Result: callers using the standard Reasoning(effort, summary) OpenAI-shaped object on Anthropic lose thinking entirely (0 reasoning_tokens, no thinking_blocks).

Coerce dict -> string before mapping. Same shape tolerance that gpt_5_transformation._normalize_reasoning_effort_for_chat_completion already implements. summary is irrelevant for Anthropic's thinking_blocks.

Adds two regression tests: one parametrized over string + dict shapes (with and without summary), one covering unparseable dict inputs (drops silently, no crash).

* test(anthropic): add non-adaptive model coverage for dict-shape reasoning_effort

Per Greptile feedback on PR #28198: the original regression test only exercised the adaptive (4.6+) path. Add a parametrized test for the non-adaptive branch (claude-sonnet-4-5) verifying that dict-shape reasoning_effort still maps to thinking.type='enabled' + budget_tokens, and that output_config is NOT set on pre-4.6 models.

* test(anthropic): convert unparseable-dict test to @pytest.mark.parametrize

Per @greptile-apps inline review on PR #28201 — matches the parametrize style of the two adjacent dict-shape tests and produces clearer failure messages (test ID per case instead of one collapsing for-loop).
This commit is contained in:
cwang-otto 2026-05-19 13:21:00 +08:00 committed by GitHub
parent 249ec01f4a
commit d9af172d27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 130 additions and 4 deletions

View file

@ -1506,9 +1506,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
optional_params["metadata"] = {"user_id": value}
elif param == "thinking":
optional_params["thinking"] = value
elif param == "reasoning_effort" and isinstance(value, str):
elif param == "reasoning_effort":
# Accept both string ("low") and dict ({"effort": "low",
# "summary": "concise"}). The Responses->Chat parser keeps the
# full dict when `summary` is set (see #25359), so a dict here
# is the standard shape Otto/OpenAI-Responses-Bridge callers
# send. Coerce to the effort string before mapping — same
# shape-tolerance the GPT-5 path already implements in
# `_normalize_reasoning_effort_for_chat_completion`.
effort_value = value
if isinstance(effort_value, dict):
effort_value = effort_value.get("effort")
if not isinstance(effort_value, str):
continue
mapped_thinking = AnthropicConfig._map_reasoning_effort(
reasoning_effort=value,
reasoning_effort=effort_value,
model=model,
llm_provider=self.custom_llm_provider or "anthropic",
)
@ -1519,12 +1531,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
optional_params["thinking"] = mapped_thinking
if AnthropicConfig._is_adaptive_thinking_model(model):
mapped_effort = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get(
value
effort_value
)
if mapped_effort is None:
AnthropicConfig._raise_invalid_reasoning_effort(
model=model,
value=value,
value=effort_value,
llm_provider=self.custom_llm_provider or "anthropic",
)
optional_params["output_config"] = {"effort": mapped_effort}

View file

@ -2476,6 +2476,120 @@ def test_reasoning_effort_does_not_set_output_config_for_older_models():
), f"output_config should not be set for {model}"
@pytest.mark.parametrize(
"reasoning_effort_value",
[
# String shape — what callers send when using `reasoning_effort="low"` directly.
"low",
# Dict shape with `effort` only — what the Responses->Chat parser produces
# when `reasoning={"effort": "low"}` is set without `summary`.
{"effort": "low"},
# Dict shape with `effort` AND `summary` — what the Responses->Chat parser
# produces when callers send `Reasoning(effort="low", summary="concise")`.
# PR #25359 added the dict-keeping branch for this case, but the Anthropic
# transformation must coerce the dict back to a string before mapping.
{"effort": "low", "summary": "concise"},
{"effort": "low", "summary": "detailed"},
],
)
def test_reasoning_effort_accepts_dict_shape_for_adaptive_model(reasoning_effort_value):
"""
Adaptive-thinking (Claude 4.6+) branch: dict-shape reasoning_effort must
map to ``thinking.type='adaptive'`` + ``output_config.effort``.
Regression test for the dict-shape ``reasoning_effort`` produced by the
Responses->Chat parser when ``summary`` is set on the request's
``reasoning`` field. Before this fix, the Anthropic transformation guarded
on ``isinstance(value, str)`` and silently dropped the param disabling
extended thinking entirely.
"""
config = AnthropicConfig()
result = config.map_openai_params(
non_default_params={"reasoning_effort": reasoning_effort_value},
optional_params={},
model="claude-sonnet-4-6-20260219",
drop_params=False,
)
# thinking must be set (adaptive for 4.6+)
assert "thinking" in result, (
f"thinking missing for reasoning_effort={reasoning_effort_value!r}"
)
assert result["thinking"]["type"] == "adaptive"
# output_config must carry the mapped effort
assert "output_config" in result, (
f"output_config missing for reasoning_effort={reasoning_effort_value!r}"
)
assert result["output_config"]["effort"] == "low"
@pytest.mark.parametrize(
"reasoning_effort_value",
[
"low",
{"effort": "low"},
{"effort": "low", "summary": "concise"},
],
)
def test_reasoning_effort_accepts_dict_shape_for_non_adaptive_model(reasoning_effort_value):
"""
Non-adaptive (pre-4.6) branch: dict-shape reasoning_effort must still map
to ``thinking.type='enabled'`` + ``budget_tokens``. ``output_config`` must
NOT be set on these models.
"""
config = AnthropicConfig()
result = config.map_openai_params(
non_default_params={"reasoning_effort": reasoning_effort_value},
optional_params={},
model="claude-sonnet-4-5-20250929",
drop_params=False,
)
assert "thinking" in result, (
f"thinking missing for reasoning_effort={reasoning_effort_value!r}"
)
assert result["thinking"]["type"] == "enabled"
assert "budget_tokens" in result["thinking"]
assert result["thinking"]["budget_tokens"] > 0
# Older models must not get adaptive-thinking output_config
assert "output_config" not in result, (
f"output_config should not be set for non-adaptive model "
f"(reasoning_effort={reasoning_effort_value!r})"
)
@pytest.mark.parametrize(
"bad_value",
[
{"summary": "concise"}, # missing effort
{"effort": None}, # explicit None effort
{"effort": 123}, # non-string effort
],
)
def test_reasoning_effort_unparseable_dict_is_dropped(bad_value):
"""
A dict shape that doesn't carry a usable ``effort`` key (e.g. only
``summary`` is set, or the value is some other unexpected type) should be
silently dropped not crash, not partially apply.
"""
config = AnthropicConfig()
result = config.map_openai_params(
non_default_params={"reasoning_effort": bad_value},
optional_params={},
model="claude-sonnet-4-6-20260219",
drop_params=False,
)
assert "thinking" not in result, (
f"thinking should not be set for bad value {bad_value!r}"
)
assert "output_config" not in result, (
f"output_config should not be set for bad value {bad_value!r}"
)
@pytest.mark.parametrize(
"model",
[