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)
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()
+34 -20
View File
@@ -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)
+11 -10
View File
@@ -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 {
+11 -6
View File
@@ -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"}}')
+47 -23
View File
@@ -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)