mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
core/agent+anthropic: drop the thinking block a model refuses to disable
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}}')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user