From f39ec5fb1a49a4cef7e6ce4b8ec3bbf1fa732b62 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Fri, 16 Oct 2026 22:16:25 +0800 Subject: [PATCH] core/agent+anthropic: drop the thinking block a model refuses to disable --- src/plyngent/agent/messages_bridge.py | 15 ++--- src/plyngent/agent/messages_dispatch.py | 54 ++++++++++------- src/plyngent/lmproto/anthropic/client.py | 21 +++---- tests/test_agent/test_messages_bridge.py | 17 ++++-- tests/test_agent/test_messages_dispatch.py | 70 +++++++++++++++------- 5 files changed, 111 insertions(+), 66 deletions(-) diff --git a/src/plyngent/agent/messages_bridge.py b/src/plyngent/agent/messages_bridge.py index 582b459..ef5a792 100644 --- a/src/plyngent/agent/messages_bridge.py +++ b/src/plyngent/agent/messages_bridge.py @@ -457,14 +457,15 @@ def _adaptive_thinking_from_param( return AnthropicThinkingConfig(type="adaptive"), AnthropicOutputConfig(effort=level) -def extended_thinking_rejected(message: str) -> bool: - """Whether a ``/messages`` failure is the API refusing extended thinking. +def thinking_config_refused(message: str) -> bool: + """Whether a ``/messages`` failure is the API refusing a request's thinking block. - The documented wording is ``"thinking.type.enabled" is not supported for this - model. Use "thinking.type.adaptive" and "output_config.effort" …``; gateways - paraphrase it (``claude-opus-5-5 requires adaptive thinking; omit thinking or - use thinking.type=adaptive …``), so match the one word they all carry — - callers only get here after a request that sent extended thinking. + The documented wordings are ``"thinking.type.enabled" is not supported for + this model. Use "thinking.type.adaptive" and "output_config.effort" …`` and + the same sentence for ``"thinking.type.disabled"``; gateways paraphrase them + (``claude-opus-5-5 requires adaptive thinking; omit thinking or use + thinking.type=adaptive …``), so match the one word they all carry — callers + only get here after a request that sent a thinking block. """ return "adaptive" in message.lower() diff --git a/src/plyngent/agent/messages_dispatch.py b/src/plyngent/agent/messages_dispatch.py index 343ed41..87237c9 100644 --- a/src/plyngent/agent/messages_dispatch.py +++ b/src/plyngent/agent/messages_dispatch.py @@ -11,6 +11,7 @@ from __future__ import annotations import json from typing import TYPE_CHECKING, Any, Final, cast +import msgspec from msgspec import UNSET from plyngent.agent.messages_bridge import ( @@ -18,7 +19,7 @@ from plyngent.agent.messages_bridge import ( anthropic_stop_to_finish_reason, anthropic_usage_to_dict, chat_param_to_anthropic_param, - extended_thinking_rejected, + thinking_config_refused, ) from plyngent.agent.stream_chunks import ( finish_reason_chunk, @@ -56,46 +57,59 @@ def _thinking_budget(client: AnthropicClient) -> int: return max(0, int(getattr(client, "thinking_budget_tokens", 0))) +# The thinking types a refusal has a fallback for: extended thinking becomes +# adaptive, and a block disabling thinking is dropped (such a model thinks +# whatever the request says). +_RETRYABLE_THINKING_TYPES: Final = frozenset({"enabled", "disabled"}) + + def _create_param( client: AnthropicClient, param: ChatCompletionsParam, provider_tools: Sequence[dict[str, Any]] | None, - *, - adaptive: bool | None = None, ) -> AnthropicMessagesParam: - """Build the ``/messages`` request for *param*. + """Build the ``/messages`` request for *param*, the way its model now takes it. - *adaptive* picks the thinking shape; ``None`` asks the client whether this - session already found *param*'s model rejecting extended thinking. + A model this session found refusing extended thinking gets adaptive thinking; + one that refuses being told to stop thinking is sent no block at all (its + thinking is always on, so the API answers a block that disables it with a + 400 — see :func:`_retry_with_thinking_fallback`). """ - use_adaptive = client.needs_adaptive_thinking(param.model) if adaptive is None else adaptive - return chat_param_to_anthropic_param( + model = param.model + create = chat_param_to_anthropic_param( param, thinking_budget_tokens=_thinking_budget(client), provider_tools=provider_tools, - adaptive_thinking=use_adaptive, + adaptive_thinking=client.refuses_thinking_type(model, "enabled"), ) + if ( + create.thinking is not UNSET + and create.thinking.type == "disabled" + and client.refuses_thinking_type(model, "disabled") + ): + return msgspec.structs.replace(create, thinking=UNSET) + return create -def _retry_with_adaptive_thinking( +def _retry_with_thinking_fallback( client: AnthropicClient, param: ChatCompletionsParam, provider_tools: Sequence[dict[str, Any]] | None, error: Exception, ) -> AnthropicMessagesParam | None: - """The request to retry with when the model refused extended thinking. + """The request to retry with when the model refused the thinking block. - ``None`` when the failure is not that refusal, or when the request never sent - extended thinking. The model is remembered on the client, so only its first + ``None`` when the failure is not that refusal, or when there is nothing left + to try. The refusal is remembered on the client, so only the model's first request pays the extra round trip. """ - if not extended_thinking_rejected(str(error)): + if not thinking_config_refused(str(error)): return None create = _create_param(client, param, provider_tools) - if create.thinking is UNSET or create.thinking.type != "enabled": + if create.thinking is UNSET or create.thinking.type not in _RETRYABLE_THINKING_TYPES: return None - client.note_adaptive_thinking(param.model) - return _create_param(client, param, provider_tools, adaptive=True) + client.note_refused_thinking_type(param.model, create.thinking.type) + return _create_param(client, param, provider_tools) class _ToolBlockState: @@ -416,7 +430,7 @@ async def _stream_events( *, provider_tools: Sequence[dict[str, Any]] | None = None, ) -> AsyncIterator[AnthropicStreamEvent]: - """Open the Messages stream, retrying once with adaptive thinking if refused. + """Open the Messages stream, retrying once with the shape the model takes. A refusal arrives before the stream yields anything, so the retry cannot duplicate events the caller has already seen. @@ -430,7 +444,7 @@ async def _stream_events( except RuntimeError as exc: if saw_event: raise - retry = _retry_with_adaptive_thinking(client, param, provider_tools, exc) + retry = _retry_with_thinking_fallback(client, param, provider_tools, exc) if retry is None: raise async for event in await client.messages(retry, stream=True): @@ -491,7 +505,7 @@ async def dispatch_messages( try: response = await client.messages(create, stream=False) except RuntimeError as exc: - retry = _retry_with_adaptive_thinking(client, param, provider_tools, exc) + retry = _retry_with_thinking_fallback(client, param, provider_tools, exc) if retry is None: raise response = await client.messages(retry, stream=False) diff --git a/src/plyngent/lmproto/anthropic/client.py b/src/plyngent/lmproto/anthropic/client.py index 6f1c890..e440cd2 100644 --- a/src/plyngent/lmproto/anthropic/client.py +++ b/src/plyngent/lmproto/anthropic/client.py @@ -36,9 +36,10 @@ class AnthropicClient: models_decoder: msgspec.json.Decoder[AnthropicModelsResponse] # Explicit ``thinking`` budget for this client (0 = derive from the effort). thinking_budget_tokens: int = 0 - # Models this session found to reject extended thinking; the dispatch retries - # their requests with adaptive thinking instead (see ``messages_dispatch``). - adaptive_thinking_models: set[str] + # Per model id, the ``thinking.type`` values the API refused this session; the + # dispatch retries with the shape the model takes instead (see + # ``agent.messages_dispatch``). + refused_thinking_types: dict[str, set[str]] _api_key: str _api_version: str @@ -49,17 +50,17 @@ class AnthropicClient: self.stream_decoder = msgspec.json.Decoder(AnthropicStreamEvent) self.models_decoder = msgspec.json.Decoder(AnthropicModelsResponse) self.thinking_budget_tokens = max(0, config.thinking_budget_tokens) - self.adaptive_thinking_models = set() + self.refused_thinking_types = {} self._api_key = config.api_key self._api_version = config.anthropic_version - def needs_adaptive_thinking(self, model: str) -> bool: - """Whether *model* already rejected extended thinking in this session.""" - return model in self.adaptive_thinking_models + def refuses_thinking_type(self, model: str, thinking_type: str) -> bool: + """Whether the API already refused *thinking_type* for *model* this session.""" + return thinking_type in self.refused_thinking_types.get(model, ()) - def note_adaptive_thinking(self, model: str) -> None: - """Remember that *model* requires adaptive thinking, saving the retry.""" - self.adaptive_thinking_models.add(model) + def note_refused_thinking_type(self, model: str, thinking_type: str) -> None: + """Remember that the API refuses *thinking_type* for *model*, saving the retry.""" + self.refused_thinking_types.setdefault(model, set()).add(thinking_type) def _headers(self) -> dict[str, str]: return { diff --git a/tests/test_agent/test_messages_bridge.py b/tests/test_agent/test_messages_bridge.py index b062310..a69a6e4 100644 --- a/tests/test_agent/test_messages_bridge.py +++ b/tests/test_agent/test_messages_bridge.py @@ -14,8 +14,8 @@ from plyngent.agent.messages_bridge import ( anthropic_usage_to_dict, chat_messages_to_anthropic, chat_param_to_anthropic_param, - extended_thinking_rejected, provider_tools_to_anthropic_tools, + thinking_config_refused, tool_items_to_anthropic_tools, ) from plyngent.lmproto.anthropic.model import ( @@ -432,15 +432,20 @@ def test_chat_param_to_anthropic_param_adaptive_without_effort_sends_nothing() - assert body.output_config is UNSET -def test_extended_thinking_rejected_matches_the_api_and_gateway_wording() -> None: - documented = ( +def test_thinking_config_refused_matches_the_api_and_gateway_wording() -> None: + documented_enabled = ( 'messages HTTP 400: {"error":{"message":"\\"thinking.type.enabled\\" is not supported for this ' 'model. Use \\"thinking.type.adaptive\\" and \\"output_config.effort\\" to control thinking behavior."}}' ) + documented_disabled = ( + 'messages HTTP 400: {"error":{"message":"\\"thinking.type.disabled\\" is not supported for this ' + 'model. Use \\"thinking.type.adaptive\\" and \\"output_config.effort\\" to control thinking behavior."}}' + ) gateway = ( 'messages HTTP 400: {"error":{"message":"claude-opus-5-5 requires adaptive thinking; omit thinking ' 'or use thinking.type=adaptive and output_config.effort"}}' ) - assert extended_thinking_rejected(documented) - assert extended_thinking_rejected(gateway) - assert not extended_thinking_rejected('messages HTTP 400: {"error":{"message":"max_tokens: required"}}') + assert thinking_config_refused(documented_enabled) + assert thinking_config_refused(documented_disabled) + assert thinking_config_refused(gateway) + assert not thinking_config_refused('messages HTTP 400: {"error":{"message":"max_tokens: required"}}') diff --git a/tests/test_agent/test_messages_dispatch.py b/tests/test_agent/test_messages_dispatch.py index 1b8d33c..7d3227b 100644 --- a/tests/test_agent/test_messages_dispatch.py +++ b/tests/test_agent/test_messages_dispatch.py @@ -46,12 +46,19 @@ if TYPE_CHECKING: from collections.abc import AsyncIterator, Sequence -# What the API answers a request that sends extended thinking to a model that -# takes adaptive thinking only (a gateway paraphrase of the documented wording). -_ADAPTIVE_REQUIRED_400 = ( - 'messages HTTP 400: {"error":{"type":"error","message":"claude-opus-5-5 requires adaptive ' - 'thinking; omit thinking or use thinking.type=adaptive and output_config.effort"}}' -) +# What the API answers a request whose thinking block it does not take (a gateway +# paraphrase of the documented wording, which names adaptive thinking either way). +_THINKING_REFUSED_400: dict[str, str] = { + "enabled": ( + 'messages HTTP 400: {"error":{"type":"error","message":"claude-opus-5-5 requires adaptive ' + 'thinking; omit thinking or use thinking.type=adaptive and output_config.effort"}}' + ), + "disabled": ( + 'messages HTTP 400: {"error":{"type":"error","message":"\\"thinking.type.disabled\\" is not ' + 'supported for this model. Use \\"thinking.type.adaptive\\" and \\"output_config.effort\\" to ' + 'control thinking behavior."}}' + ), +} def _message_response( @@ -83,12 +90,12 @@ class ScriptedMessagesClient: kind: str = "messages" calls: list[AnthropicMessagesParam] # Mirrors AnthropicClient: explicit thinking budget (0 = derive from effort), - # and the models the dispatch learned to send adaptive thinking to. + # and the thinking types the dispatch found the API refusing. thinking_budget_tokens: int - adaptive_thinking_models: set[str] + refused_thinking_types: dict[str, set[str]] _non_stream: list[AnthropicMessageResponse] _stream_events: list[list[AnthropicStreamEvent]] - _rejects_extended_thinking: bool + _refusals: frozenset[str] _error: str | None def __init__( @@ -96,29 +103,29 @@ class ScriptedMessagesClient: *, non_stream: Sequence[AnthropicMessageResponse] | None = None, stream_events: Sequence[Sequence[AnthropicStreamEvent]] | None = None, - rejects_extended_thinking: bool = False, + refusals: Sequence[str] = (), error: str | None = None, ) -> None: self.calls = [] self.thinking_budget_tokens = 0 - self.adaptive_thinking_models = set() + self.refused_thinking_types = {} self._non_stream = list(non_stream or ()) self._stream_events = [list(events) for events in (stream_events or ())] - self._rejects_extended_thinking = rejects_extended_thinking + self._refusals = frozenset(refusals) self._error = error - def needs_adaptive_thinking(self, model: str) -> bool: - return model in self.adaptive_thinking_models + def refuses_thinking_type(self, model: str, thinking_type: str) -> bool: + return thinking_type in self.refused_thinking_types.get(model, ()) - def note_adaptive_thinking(self, model: str) -> None: - self.adaptive_thinking_models.add(model) + def note_refused_thinking_type(self, model: str, thinking_type: str) -> None: + self.refused_thinking_types.setdefault(model, set()).add(thinking_type) def _reject(self, param: AnthropicMessagesParam) -> None: """Raise what the API raises for a request it refuses to run.""" if self._error is not None: raise RuntimeError(self._error) - if self._rejects_extended_thinking and param.thinking is not UNSET and param.thinking.type == "enabled": - raise RuntimeError(_ADAPTIVE_REQUIRED_400) + if param.thinking is not UNSET and param.thinking.type in self._refusals: + raise RuntimeError(_THINKING_REFUSED_400[param.thinking.type]) @overload async def messages( @@ -647,7 +654,7 @@ def _thinking_param(model: str = "claude-opus-5-5", effort: str = "high") -> Cha async def test_dispatch_messages_retries_with_adaptive_thinking() -> None: - client = ScriptedMessagesClient(non_stream=[_message_response(text="done")], rejects_extended_thinking=True) + client = ScriptedMessagesClient(non_stream=[_message_response(text="done")], refusals=("enabled",)) result = await dispatch_messages(cast("Any", client), _thinking_param(), stream=False) assert result.choices[0].message.content == "done" assert len(client.calls) == 2 @@ -663,7 +670,7 @@ async def test_dispatch_messages_retries_with_adaptive_thinking() -> None: async def test_dispatch_messages_remembers_the_refusing_model() -> None: client = ScriptedMessagesClient( non_stream=[_message_response(text="one"), _message_response(text="two")], - rejects_extended_thinking=True, + refusals=("enabled",), ) param = _thinking_param() _ = await dispatch_messages(cast("Any", client), param, stream=False) @@ -672,6 +679,23 @@ async def test_dispatch_messages_remembers_the_refusing_model() -> None: assert [call.thinking.type for call in client.calls] == ["enabled", "adaptive", "adaptive"] +async def test_dispatch_messages_drops_a_refused_disabled_block() -> None: + """A model whose thinking is always on answers ``disabled`` with a 400.""" + client = ScriptedMessagesClient( + non_stream=[_message_response(text="one"), _message_response(text="two")], + refusals=("enabled", "disabled"), + ) + param = _thinking_param(effort="none") + result = await dispatch_messages(cast("Any", client), param, stream=False) + assert result.choices[0].message.content == "one" + assert len(client.calls) == 2 + assert client.calls[0].thinking.type == "disabled" + # No block at all: there is nothing left to ask such a model for. + assert client.calls[1].thinking is UNSET + _ = await dispatch_messages(cast("Any", client), param, stream=False) + assert client.calls[2].thinking is UNSET + + async def test_dispatch_messages_stream_retries_with_adaptive_thinking() -> None: events: list[AnthropicStreamEvent] = [ AnthropicContentBlockStart(index=0, content_block=AnthropicRawContentBlock(type="text", text="")), @@ -679,7 +703,7 @@ async def test_dispatch_messages_stream_retries_with_adaptive_thinking() -> None AnthropicMessageDelta(delta={"stop_reason": "end_turn"}, usage=AnthropicUsage(output_tokens=3)), AnthropicMessageStop(), ] - client = ScriptedMessagesClient(stream_events=[events], rejects_extended_thinking=True) + client = ScriptedMessagesClient(stream_events=[events], refusals=("enabled",)) stream = await dispatch_messages(cast("Any", client), _thinking_param(), stream=True) chunks = [chunk async for chunk in cast("Any", stream)] texts = [c.choices[0].delta.content for c in chunks if c.choices and isinstance(c.choices[0].delta.content, str)] @@ -695,8 +719,8 @@ async def test_dispatch_messages_does_not_retry_other_failures() -> None: assert len(client.calls) == 1 -async def test_dispatch_messages_does_not_retry_a_request_without_extended_thinking() -> None: - client = ScriptedMessagesClient(error=_ADAPTIVE_REQUIRED_400) +async def test_dispatch_messages_does_not_retry_a_request_without_thinking() -> None: + client = ScriptedMessagesClient(error=_THINKING_REFUSED_400["enabled"]) param = ChatCompletionsParam(model="claude-opus-5-5", messages=[UserChatMessage(content="hi")]) with pytest.raises(RuntimeError, match="adaptive thinking"): _ = await dispatch_messages(cast("Any", client), param, stream=False)