core/agent+anthropic: drop the thinking block a model refuses to disable

This commit is contained in:
2026-10-16 22:16:25 +08:00
parent 06a5bafab7
commit f39ec5fb1a
5 changed files with 111 additions and 66 deletions
+8 -7
View File
@@ -457,14 +457,15 @@ def _adaptive_thinking_from_param(
return AnthropicThinkingConfig(type="adaptive"), AnthropicOutputConfig(effort=level) return AnthropicThinkingConfig(type="adaptive"), AnthropicOutputConfig(effort=level)
def extended_thinking_rejected(message: str) -> bool: def thinking_config_refused(message: str) -> bool:
"""Whether a ``/messages`` failure is the API refusing extended thinking. """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 The documented wordings are ``"thinking.type.enabled" is not supported for
model. Use "thinking.type.adaptive" and "output_config.effort" …``; gateways this model. Use "thinking.type.adaptive" and "output_config.effort" …`` and
paraphrase it (``claude-opus-5-5 requires adaptive thinking; omit thinking or the same sentence for ``"thinking.type.disabled"``; gateways paraphrase them
use thinking.type=adaptive …``), so match the one word they all carry — (``claude-opus-5-5 requires adaptive thinking; omit thinking or use
callers only get here after a request that sent extended thinking. 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() return "adaptive" in message.lower()
+34 -20
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json import json
from typing import TYPE_CHECKING, Any, Final, cast from typing import TYPE_CHECKING, Any, Final, cast
import msgspec
from msgspec import UNSET from msgspec import UNSET
from plyngent.agent.messages_bridge import ( from plyngent.agent.messages_bridge import (
@@ -18,7 +19,7 @@ from plyngent.agent.messages_bridge import (
anthropic_stop_to_finish_reason, anthropic_stop_to_finish_reason,
anthropic_usage_to_dict, anthropic_usage_to_dict,
chat_param_to_anthropic_param, chat_param_to_anthropic_param,
extended_thinking_rejected, thinking_config_refused,
) )
from plyngent.agent.stream_chunks import ( from plyngent.agent.stream_chunks import (
finish_reason_chunk, finish_reason_chunk,
@@ -56,46 +57,59 @@ def _thinking_budget(client: AnthropicClient) -> int:
return max(0, int(getattr(client, "thinking_budget_tokens", 0))) 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( def _create_param(
client: AnthropicClient, client: AnthropicClient,
param: ChatCompletionsParam, param: ChatCompletionsParam,
provider_tools: Sequence[dict[str, Any]] | None, provider_tools: Sequence[dict[str, Any]] | None,
*,
adaptive: bool | None = None,
) -> AnthropicMessagesParam: ) -> 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 A model this session found refusing extended thinking gets adaptive thinking;
session already found *param*'s model rejecting extended 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 model = param.model
return chat_param_to_anthropic_param( create = chat_param_to_anthropic_param(
param, param,
thinking_budget_tokens=_thinking_budget(client), thinking_budget_tokens=_thinking_budget(client),
provider_tools=provider_tools, 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, client: AnthropicClient,
param: ChatCompletionsParam, param: ChatCompletionsParam,
provider_tools: Sequence[dict[str, Any]] | None, provider_tools: Sequence[dict[str, Any]] | None,
error: Exception, error: Exception,
) -> AnthropicMessagesParam | None: ) -> 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 ``None`` when the failure is not that refusal, or when there is nothing left
extended thinking. The model is remembered on the client, so only its first to try. The refusal is remembered on the client, so only the model's first
request pays the extra round trip. request pays the extra round trip.
""" """
if not extended_thinking_rejected(str(error)): if not thinking_config_refused(str(error)):
return None return None
create = _create_param(client, param, provider_tools) 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 return None
client.note_adaptive_thinking(param.model) client.note_refused_thinking_type(param.model, create.thinking.type)
return _create_param(client, param, provider_tools, adaptive=True) return _create_param(client, param, provider_tools)
class _ToolBlockState: class _ToolBlockState:
@@ -416,7 +430,7 @@ async def _stream_events(
*, *,
provider_tools: Sequence[dict[str, Any]] | None = None, provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AsyncIterator[AnthropicStreamEvent]: ) -> 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 A refusal arrives before the stream yields anything, so the retry cannot
duplicate events the caller has already seen. duplicate events the caller has already seen.
@@ -430,7 +444,7 @@ async def _stream_events(
except RuntimeError as exc: except RuntimeError as exc:
if saw_event: if saw_event:
raise 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: if retry is None:
raise raise
async for event in await client.messages(retry, stream=True): async for event in await client.messages(retry, stream=True):
@@ -491,7 +505,7 @@ async def dispatch_messages(
try: try:
response = await client.messages(create, stream=False) response = await client.messages(create, stream=False)
except RuntimeError as exc: 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: if retry is None:
raise raise
response = await client.messages(retry, stream=False) response = await client.messages(retry, stream=False)
+11 -10
View File
@@ -36,9 +36,10 @@ class AnthropicClient:
models_decoder: msgspec.json.Decoder[AnthropicModelsResponse] models_decoder: msgspec.json.Decoder[AnthropicModelsResponse]
# Explicit ``thinking`` budget for this client (0 = derive from the effort). # Explicit ``thinking`` budget for this client (0 = derive from the effort).
thinking_budget_tokens: int = 0 thinking_budget_tokens: int = 0
# Models this session found to reject extended thinking; the dispatch retries # Per model id, the ``thinking.type`` values the API refused this session; the
# their requests with adaptive thinking instead (see ``messages_dispatch``). # dispatch retries with the shape the model takes instead (see
adaptive_thinking_models: set[str] # ``agent.messages_dispatch``).
refused_thinking_types: dict[str, set[str]]
_api_key: str _api_key: str
_api_version: str _api_version: str
@@ -49,17 +50,17 @@ class AnthropicClient:
self.stream_decoder = msgspec.json.Decoder(AnthropicStreamEvent) self.stream_decoder = msgspec.json.Decoder(AnthropicStreamEvent)
self.models_decoder = msgspec.json.Decoder(AnthropicModelsResponse) self.models_decoder = msgspec.json.Decoder(AnthropicModelsResponse)
self.thinking_budget_tokens = max(0, config.thinking_budget_tokens) 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_key = config.api_key
self._api_version = config.anthropic_version self._api_version = config.anthropic_version
def needs_adaptive_thinking(self, model: str) -> bool: def refuses_thinking_type(self, model: str, thinking_type: str) -> bool:
"""Whether *model* already rejected extended thinking in this session.""" """Whether the API already refused *thinking_type* for *model* this session."""
return model in self.adaptive_thinking_models return thinking_type in self.refused_thinking_types.get(model, ())
def note_adaptive_thinking(self, model: str) -> None: def note_refused_thinking_type(self, model: str, thinking_type: str) -> None:
"""Remember that *model* requires adaptive thinking, saving the retry.""" """Remember that the API refuses *thinking_type* for *model*, saving the retry."""
self.adaptive_thinking_models.add(model) self.refused_thinking_types.setdefault(model, set()).add(thinking_type)
def _headers(self) -> dict[str, str]: def _headers(self) -> dict[str, str]:
return { return {
+11 -6
View File
@@ -14,8 +14,8 @@ from plyngent.agent.messages_bridge import (
anthropic_usage_to_dict, anthropic_usage_to_dict,
chat_messages_to_anthropic, chat_messages_to_anthropic,
chat_param_to_anthropic_param, chat_param_to_anthropic_param,
extended_thinking_rejected,
provider_tools_to_anthropic_tools, provider_tools_to_anthropic_tools,
thinking_config_refused,
tool_items_to_anthropic_tools, tool_items_to_anthropic_tools,
) )
from plyngent.lmproto.anthropic.model import ( 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 assert body.output_config is UNSET
def test_extended_thinking_rejected_matches_the_api_and_gateway_wording() -> None: def test_thinking_config_refused_matches_the_api_and_gateway_wording() -> None:
documented = ( documented_enabled = (
'messages HTTP 400: {"error":{"message":"\\"thinking.type.enabled\\" is not supported for this ' '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."}}' '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 = ( gateway = (
'messages HTTP 400: {"error":{"message":"claude-opus-5-5 requires adaptive thinking; omit thinking ' 'messages HTTP 400: {"error":{"message":"claude-opus-5-5 requires adaptive thinking; omit thinking '
'or use thinking.type=adaptive and output_config.effort"}}' 'or use thinking.type=adaptive and output_config.effort"}}'
) )
assert extended_thinking_rejected(documented) assert thinking_config_refused(documented_enabled)
assert extended_thinking_rejected(gateway) assert thinking_config_refused(documented_disabled)
assert not extended_thinking_rejected('messages HTTP 400: {"error":{"message":"max_tokens: required"}}') assert thinking_config_refused(gateway)
assert not thinking_config_refused('messages HTTP 400: {"error":{"message":"max_tokens: required"}}')
+45 -21
View File
@@ -46,12 +46,19 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence from collections.abc import AsyncIterator, Sequence
# What the API answers a request that sends extended thinking to a model that # What the API answers a request whose thinking block it does not take (a gateway
# takes adaptive thinking only (a gateway paraphrase of the documented wording). # paraphrase of the documented wording, which names adaptive thinking either way).
_ADAPTIVE_REQUIRED_400 = ( _THINKING_REFUSED_400: dict[str, str] = {
"enabled": (
'messages HTTP 400: {"error":{"type":"error","message":"claude-opus-5-5 requires adaptive ' '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"}}' '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( def _message_response(
@@ -83,12 +90,12 @@ class ScriptedMessagesClient:
kind: str = "messages" kind: str = "messages"
calls: list[AnthropicMessagesParam] calls: list[AnthropicMessagesParam]
# Mirrors AnthropicClient: explicit thinking budget (0 = derive from effort), # 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 thinking_budget_tokens: int
adaptive_thinking_models: set[str] refused_thinking_types: dict[str, set[str]]
_non_stream: list[AnthropicMessageResponse] _non_stream: list[AnthropicMessageResponse]
_stream_events: list[list[AnthropicStreamEvent]] _stream_events: list[list[AnthropicStreamEvent]]
_rejects_extended_thinking: bool _refusals: frozenset[str]
_error: str | None _error: str | None
def __init__( def __init__(
@@ -96,29 +103,29 @@ class ScriptedMessagesClient:
*, *,
non_stream: Sequence[AnthropicMessageResponse] | None = None, non_stream: Sequence[AnthropicMessageResponse] | None = None,
stream_events: Sequence[Sequence[AnthropicStreamEvent]] | None = None, stream_events: Sequence[Sequence[AnthropicStreamEvent]] | None = None,
rejects_extended_thinking: bool = False, refusals: Sequence[str] = (),
error: str | None = None, error: str | None = None,
) -> None: ) -> None:
self.calls = [] self.calls = []
self.thinking_budget_tokens = 0 self.thinking_budget_tokens = 0
self.adaptive_thinking_models = set() self.refused_thinking_types = {}
self._non_stream = list(non_stream or ()) self._non_stream = list(non_stream or ())
self._stream_events = [list(events) for events in (stream_events 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 self._error = error
def needs_adaptive_thinking(self, model: str) -> bool: def refuses_thinking_type(self, model: str, thinking_type: str) -> bool:
return model in self.adaptive_thinking_models return thinking_type in self.refused_thinking_types.get(model, ())
def note_adaptive_thinking(self, model: str) -> None: def note_refused_thinking_type(self, model: str, thinking_type: str) -> None:
self.adaptive_thinking_models.add(model) self.refused_thinking_types.setdefault(model, set()).add(thinking_type)
def _reject(self, param: AnthropicMessagesParam) -> None: def _reject(self, param: AnthropicMessagesParam) -> None:
"""Raise what the API raises for a request it refuses to run.""" """Raise what the API raises for a request it refuses to run."""
if self._error is not None: if self._error is not None:
raise RuntimeError(self._error) raise RuntimeError(self._error)
if self._rejects_extended_thinking and param.thinking is not UNSET and param.thinking.type == "enabled": if param.thinking is not UNSET and param.thinking.type in self._refusals:
raise RuntimeError(_ADAPTIVE_REQUIRED_400) raise RuntimeError(_THINKING_REFUSED_400[param.thinking.type])
@overload @overload
async def messages( 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: 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) result = await dispatch_messages(cast("Any", client), _thinking_param(), stream=False)
assert result.choices[0].message.content == "done" assert result.choices[0].message.content == "done"
assert len(client.calls) == 2 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: async def test_dispatch_messages_remembers_the_refusing_model() -> None:
client = ScriptedMessagesClient( client = ScriptedMessagesClient(
non_stream=[_message_response(text="one"), _message_response(text="two")], non_stream=[_message_response(text="one"), _message_response(text="two")],
rejects_extended_thinking=True, refusals=("enabled",),
) )
param = _thinking_param() param = _thinking_param()
_ = await dispatch_messages(cast("Any", client), param, stream=False) _ = 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"] 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: async def test_dispatch_messages_stream_retries_with_adaptive_thinking() -> None:
events: list[AnthropicStreamEvent] = [ events: list[AnthropicStreamEvent] = [
AnthropicContentBlockStart(index=0, content_block=AnthropicRawContentBlock(type="text", text="")), 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)), AnthropicMessageDelta(delta={"stop_reason": "end_turn"}, usage=AnthropicUsage(output_tokens=3)),
AnthropicMessageStop(), 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) stream = await dispatch_messages(cast("Any", client), _thinking_param(), stream=True)
chunks = [chunk async for chunk in cast("Any", stream)] 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)] 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 assert len(client.calls) == 1
async def test_dispatch_messages_does_not_retry_a_request_without_extended_thinking() -> None: async def test_dispatch_messages_does_not_retry_a_request_without_thinking() -> None:
client = ScriptedMessagesClient(error=_ADAPTIVE_REQUIRED_400) client = ScriptedMessagesClient(error=_THINKING_REFUSED_400["enabled"])
param = ChatCompletionsParam(model="claude-opus-5-5", messages=[UserChatMessage(content="hi")]) param = ChatCompletionsParam(model="claude-opus-5-5", messages=[UserChatMessage(content="hi")])
with pytest.raises(RuntimeError, match="adaptive thinking"): with pytest.raises(RuntimeError, match="adaptive thinking"):
_ = await dispatch_messages(cast("Any", client), param, stream=False) _ = await dispatch_messages(cast("Any", client), param, stream=False)