4 Commits

9 changed files with 404 additions and 28 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
- **Kind-based dispatch**: clients expose `kind` (`chat_completions` | `responses` | `messages`). Agent history stays chat-completions-shaped; transport adapters return synthetic `ChatCompletionResponse` / stream chunks.
- **`responses_bridge` / `responses_dispatch`**: chat ↔ OpenAI Responses; stream text/reasoning deltas (`response.reasoning_text.done` repeats what its fragments spelled out, so the loop streams each part once and still keeps the whole text in the round's message); tool calls + usage on `response.completed`; a request carrying `tools` returns every round's reasoning as a `reasoning` item of `reasoning_text` parts — "必须完整回传 `reasoning_content` … 即使该轮模型未实际进行工具调用" (a missing item and an empty part are both a 400, so a round that kept none sends a blank line) — and a round's `function_call` items stay contiguous ahead of their outputs (`call, output, call, output` was a 400: the second call reads as a round of its own without reasoning); reasoning is read back from the item's `content` parts.
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call and nothing is executed; `thinking` and the two search blocks are kept as `AssistantChatMessage.provider_blocks` (the thinking text also lands in `reasoning_content`, so it streams as reasoning) and go back with the round, which is also why a round that kept only those blocks is not an empty completion; a chat-completions request drops the field (`compat.coerce_chat_messages`).
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a thinking block the model refuses (from Claude 4.7 on, `thinking.type.enabled` is a 400 asking for adaptive thinking + `output_config.effort`; a model whose thinking is always on refuses `thinking.type.disabled`) gets one retry with the shape it takes — adaptive thinking with the level as the effort, or no block at all — and the refusal is remembered per model on the client, so only its first request pays the round trip (only a refusal that arrives before the stream yields anything is retried; a later failure has already been seen); a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call and nothing is executed; `thinking` and the two search blocks are kept as `AssistantChatMessage.provider_blocks` (the thinking text also lands in `reasoning_content`, so it streams as reasoning) and go back with the round, which is also why a round that kept only those blocks is not an empty completion; a chat-completions request drops the field (`compat.coerce_chat_messages`).
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results go back verbatim with the round — `AssistantChatMessage.provider_blocks`) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet). `@tool(writes=("path",))` names a handler's file arguments: one batch applies calls that name the same file one at a time, in call order — the lock is taken before the confirm prompt, so a pending prompt cannot be overtaken — while different files keep running in parallel.
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config).
+1 -1
View File
@@ -175,7 +175,7 @@ max_context_tokens = 200000
Per-provider **`timeout`** is passed to the HTTP session for chat/completions, Responses, and `GET /models`. A single number sets one timeout; `{ connect, read }` splits TCP/TLS setup vs idle wait between response bytes (SSE can run longer than `read` while chunks keep arriving). Tool/process timeouts (`run_argv`, PTY, policy confirm) are separate.
**Thinking strength** (`reasoning_effort`) can be set on `[agent]`, on a provider, or on a single model: the most specific layer wins, per field, so a model may override only the effort or only the budget. `""` sends nothing and lets the provider apply its own default. Each API surface maps the value onto its own wire field — chat completions send `reasoning_effort`, Responses send `reasoning.effort`, and Anthropic has no effort level at all, so it sends `thinking` with the token budget that level maps onto (`minimal` 1024 … `xhigh`/`max` 32768; `none` disables thinking). `max` is DeepSeek's own top level on chat completions and clamps to `xhigh` everywhere else. `thinking_budget_tokens` overrides that mapping; either way the request stays valid: the budget never drops below 1024, and when it would swallow the output limit `max_tokens` grows so the answer still has room. DeepSeek chat keeps `thinking.type = "enabled"` unless the effort is `none`.
**Thinking strength** (`reasoning_effort`) can be set on `[agent]`, on a provider, or on a single model: the most specific layer wins, per field, so a model may override only the effort or only the budget. `""` sends nothing and lets the provider apply its own default. Each API surface maps the value onto its own wire field — chat completions send `reasoning_effort`, Responses send `reasoning.effort`, and Anthropic has no effort level at all, so it sends `thinking` with the token budget that level maps onto (`minimal` 1024 … `xhigh`/`max` 32768; `none` disables thinking); a thinking block the model refuses is retried once with the shape it takes — adaptive thinking with the level itself as `output_config.effort` (from Claude 4.7 on), or no block at all where thinking cannot be turned off — and the refusal is remembered for the session, so `thinking_budget_tokens` is ignored on such a model. `max` is DeepSeek's own top level on chat completions and clamps to `xhigh` everywhere else. `thinking_budget_tokens` overrides that mapping; either way the request stays valid: the budget never drops below 1024, and when it would swallow the output limit `max_tokens` grows so the answer still has room. DeepSeek chat keeps `thinking.type = "enabled"` unless the effort is `none`.
```toml
[providers.deepseek]
+6 -2
View File
@@ -105,7 +105,8 @@ max_context_tokens = 200000
# reasoning_effort = "medium"
# Explicit Anthropic thinking budget in tokens, overriding that mapping; the
# request stays valid either way (floor 1024, and ``max_tokens`` grows when the
# budget would leave no room for the answer).
# budget would leave no room for the answer). A model that only takes adaptive
# thinking ignores it (see the anthropic provider below).
# thinking_budget_tokens = 8000
# Optional: custom compact/summarisation prompts (empty = built-in defaults).
@@ -160,7 +161,10 @@ access_key_or_token = "sk-replace-me"
# # url defaults to https://api.anthropic.com/v1 when empty
# timeout = { connect = 10, read = 600 }
# # Anthropic has no effort level: an effort becomes a thinking token budget
# # (see the [agent] section above). Set the budget explicitly to pin it:
# # (see the [agent] section above). A model that no longer takes one (from
# # Claude 4.7 on) is retried with adaptive thinking, whose depth is the effort
# # level itself, and a model that cannot stop thinking gets no thinking block
# # at all. Set the budget explicitly to pin it:
# reasoning_effort = "high"
# thinking_budget_tokens = 16000
#
+80 -2
View File
@@ -17,6 +17,7 @@ from plyngent.lmproto.anthropic.model import (
AnthropicAssistantMessage,
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicOutputConfig,
AnthropicResponseText,
AnthropicServerToolUseContent,
AnthropicTextContent,
@@ -384,7 +385,7 @@ def _thinking_from_param(
*,
thinking_budget_tokens: int,
) -> AnthropicThinkingConfig | Unset:
"""Build the ``thinking`` block for *param* (UNSET = send nothing).
"""Build the extended-thinking block for *param* (UNSET = send nothing).
``reasoning_effort = "none"`` disables thinking; any other level enables it
with the explicit *thinking_budget_tokens*, else with the budget that level
@@ -423,11 +424,77 @@ def _thinking_and_max_tokens(
return max_tokens, thinking
# Effort level → Anthropic ``output_config.effort`` on an adaptive-thinking
# model. ``minimal`` has no API level of its own and lands on the lowest one;
# unknown levels fall back to "medium" (the budgets above do the same).
_ANTHROPIC_ADAPTIVE_EFFORTS: dict[str, str] = {
"minimal": "low",
"low": "low",
"medium": "medium",
"high": "high",
"xhigh": "xhigh",
"max": "max",
}
_ANTHROPIC_FALLBACK_ADAPTIVE_EFFORT = "medium"
def _adaptive_thinking_from_param(
param: ChatCompletionsParam,
) -> tuple[AnthropicThinkingConfig | Unset, AnthropicOutputConfig | Unset]:
"""Build the adaptive-thinking blocks for *param* (UNSET = send nothing).
The shape for a model that rejects ``thinking.type.enabled``: adaptive
thinking, steered by ``output_config.effort`` rather than a token budget.
``reasoning_effort = "none"`` still disables thinking; a configured
``thinking_budget_tokens`` has no adaptive equivalent and is left out.
"""
effort = param.reasoning_effort
if effort is UNSET:
return UNSET, UNSET
if effort == "none":
return AnthropicThinkingConfig(type="disabled"), UNSET
level = _ANTHROPIC_ADAPTIVE_EFFORTS.get(str(effort), _ANTHROPIC_FALLBACK_ADAPTIVE_EFFORT)
return AnthropicThinkingConfig(type="adaptive"), AnthropicOutputConfig(effort=level)
def thinking_config_refused(message: str) -> bool:
"""Whether a ``/messages`` failure is the API refusing a request's thinking block.
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()
def _thinking_for_param(
param: ChatCompletionsParam,
*,
thinking_budget_tokens: int,
adaptive: bool,
) -> tuple[int, AnthropicThinkingConfig | Unset, AnthropicOutputConfig | Unset]:
"""Resolve *param*'s ``max_tokens`` and thinking blocks for one shape.
Extended thinking needs ``max_tokens`` raised above its budget (see
:func:`_thinking_and_max_tokens`); adaptive thinking carries no budget, so
the caller's ``max_tokens`` stands.
"""
if adaptive:
thinking, output_config = _adaptive_thinking_from_param(param)
return _max_tokens_from_param(param), thinking, output_config
max_tokens, thinking = _thinking_and_max_tokens(param, thinking_budget_tokens=thinking_budget_tokens)
return max_tokens, thinking, UNSET
def chat_param_to_anthropic_param(
param: ChatCompletionsParam,
*,
thinking_budget_tokens: int = 0,
provider_tools: Sequence[dict[str, Any]] | None = None,
adaptive_thinking: bool = False,
) -> AnthropicMessagesParam:
"""Build :class:`AnthropicMessagesParam` from a chat completions param.
@@ -435,13 +502,22 @@ def chat_param_to_anthropic_param(
(0 = derive it from the param's ``reasoning_effort``); *provider_tools* are
hosted tools (web_search, …) as opaque dicts, merged after local function
tools and never executed by the registry.
*adaptive_thinking* asks for the thinking shape of a model that rejects
extended thinking: ``thinking.type = "adaptive"`` with the effort in
``output_config`` instead of a ``budget_tokens`` budget (see
:func:`_adaptive_thinking_from_param`).
"""
system, messages = chat_messages_to_anthropic(param.messages)
tools: list[AnthropicToolDefinition | dict[str, Any]] = list(
tool_items_to_anthropic_tools(param.tools if param.tools is not UNSET else None)
)
tools.extend(provider_tools_to_anthropic_tools(provider_tools))
max_tokens, thinking = _thinking_and_max_tokens(param, thinking_budget_tokens=thinking_budget_tokens)
max_tokens, thinking, output_config = _thinking_for_param(
param,
thinking_budget_tokens=thinking_budget_tokens,
adaptive=adaptive_thinking,
)
kwargs: dict[str, Any] = {
"model": param.model,
@@ -450,6 +526,8 @@ def chat_param_to_anthropic_param(
}
if thinking is not UNSET:
kwargs["thinking"] = thinking
if output_config is not UNSET:
kwargs["output_config"] = output_config
if system:
kwargs["system"] = system
if tools:
+96 -14
View File
@@ -11,11 +11,15 @@ 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 (
anthropic_response_to_chat_completion,
anthropic_stop_to_finish_reason,
anthropic_usage_to_dict,
chat_param_to_anthropic_param,
thinking_config_refused,
)
from plyngent.agent.stream_chunks import (
finish_reason_chunk,
@@ -40,7 +44,7 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
from plyngent.lmproto.anthropic.client import AnthropicClient
from plyngent.lmproto.anthropic.model import AnthropicStreamEvent
from plyngent.lmproto.anthropic.model import AnthropicMessagesParam, AnthropicStreamEvent
from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk,
ChatCompletionResponse,
@@ -53,6 +57,61 @@ 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,
) -> AnthropicMessagesParam:
"""Build the ``/messages`` request for *param*, the way its model now takes it.
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`).
"""
model = param.model
create = chat_param_to_anthropic_param(
param,
thinking_budget_tokens=_thinking_budget(client),
provider_tools=provider_tools,
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_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 the thinking block.
``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 thinking_config_refused(str(error)):
return None
create = _create_param(client, param, provider_tools)
if create.thinking is UNSET or create.thinking.type not in _RETRYABLE_THINKING_TYPES:
return None
client.note_refused_thinking_type(param.model, create.thinking.type)
return _create_param(client, param, provider_tools)
class _ToolBlockState:
"""Tracks an in-flight tool_use content block during SSE streaming."""
@@ -365,6 +424,33 @@ def _chunks_for_content_delta(
]
async def _stream_events(
client: AnthropicClient,
param: ChatCompletionsParam,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AsyncIterator[AnthropicStreamEvent]:
"""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.
"""
create = _create_param(client, param, provider_tools)
saw_event = False
try:
async for event in await client.messages(create, stream=True):
saw_event = True
yield event
except RuntimeError as exc:
if saw_event:
raise
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):
yield event
async def _stream_as_chat_chunks(
client: AnthropicClient,
param: ChatCompletionsParam,
@@ -379,15 +465,9 @@ async def _stream_as_chat_chunks(
``message_stop``. Errors raise ``RuntimeError`` so the agent loop can
surface them as retryable failures.
"""
create = chat_param_to_anthropic_param(
param,
thinking_budget_tokens=_thinking_budget(client),
provider_tools=provider_tools,
)
stream = await client.messages(create, stream=True)
state = _StreamState(param.model)
async for event in stream:
async for event in _stream_events(client, param, provider_tools=provider_tools):
for chunk in _chunks_for_event(event, state):
yield chunk
if state.saw_message_stop:
@@ -419,14 +499,16 @@ async def dispatch_messages(
surface's own server-tool definition is built from them and the results
arrive inside the same response, so nothing is executed locally.
"""
create = chat_param_to_anthropic_param(
param,
thinking_budget_tokens=_thinking_budget(client),
provider_tools=provider_tools,
)
if stream:
return _stream_as_chat_chunks(client, param, provider_tools=provider_tools)
response = await client.messages(create, stream=False)
create = _create_param(client, param, provider_tools)
try:
response = await client.messages(create, stream=False)
except RuntimeError as exc:
retry = _retry_with_thinking_fallback(client, param, provider_tools, exc)
if retry is None:
raise
response = await client.messages(retry, stream=False)
return anthropic_response_to_chat_completion(response)
+13
View File
@@ -36,6 +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
# 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
@@ -46,9 +50,18 @@ 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.refused_thinking_types = {}
self._api_key = config.api_key
self._api_version = config.anthropic_version
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_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 {
"Content-Type": "application/json",
+19 -5
View File
@@ -66,21 +66,34 @@ class AnthropicMetadata(Struct, omit_defaults=True):
class AnthropicThinkingConfig(Struct, omit_defaults=True):
"""``thinking`` request block (extended thinking).
"""``thinking`` request block (extended or adaptive thinking).
``budget_tokens`` is the reasoning budget the model may spend before it
answers; the API requires it to be at least 1024 and smaller than
``max_tokens`` (see ``agent.messages_bridge``, which keeps both valid).
``enabled`` is extended thinking: ``budget_tokens`` is the reasoning budget
the model may spend before it answers; the API requires it to be at least
1024 and smaller than ``max_tokens`` (see ``agent.messages_bridge``, which
keeps both valid). Models from Claude 4.7 on reject ``enabled`` and take
``adaptive`` instead, where the depth comes from ``output_config.effort``;
``disabled`` turns thinking off where the model allows it.
``type`` carries no default: ``omit_defaults`` would drop a defaulted one,
and the server reads the block as malformed without it — DeepSeek answers
``thinking: missing field ``type``.
"""
type: Literal["enabled", "disabled"]
type: Literal["enabled", "disabled", "adaptive"]
budget_tokens: int | Unset = UNSET
class AnthropicOutputConfig(Struct, omit_defaults=True):
"""``output_config`` request block: how much the model spends on a turn.
``effort`` is the adaptive-thinking knob (``low`` … ``max``); it replaces a
``thinking.budget_tokens`` budget on the models that no longer take one.
"""
effort: str | Unset = UNSET
class AnthropicMessagesParam(Struct, omit_defaults=True):
model: str
max_tokens: int = 8192
@@ -97,6 +110,7 @@ class AnthropicMessagesParam(Struct, omit_defaults=True):
top_p: float | Unset = UNSET
top_k: int | Unset = UNSET
thinking: AnthropicThinkingConfig | Unset = UNSET
output_config: AnthropicOutputConfig | Unset = UNSET
class AnthropicUsage(Struct, omit_defaults=True):
+62
View File
@@ -15,6 +15,7 @@ from plyngent.agent.messages_bridge import (
chat_messages_to_anthropic,
chat_param_to_anthropic_param,
provider_tools_to_anthropic_tools,
thinking_config_refused,
tool_items_to_anthropic_tools,
)
from plyngent.lmproto.anthropic.model import (
@@ -387,3 +388,64 @@ def test_chat_param_to_anthropic_param_thinking_disabled() -> None:
assert body.thinking is not UNSET
assert body.thinking.type == "disabled"
assert body.thinking.budget_tokens is UNSET
def test_chat_param_to_anthropic_param_adaptive_thinking_from_effort() -> None:
body = chat_param_to_anthropic_param(_effort_param("high"), adaptive_thinking=True)
assert body.thinking is not UNSET
assert body.thinking.type == "adaptive"
assert body.thinking.budget_tokens is UNSET
assert body.output_config is not UNSET
assert body.output_config.effort == "high"
# Adaptive thinking spends no fixed budget, so max_tokens stays as asked.
assert body.max_tokens == 8192
def test_chat_param_to_anthropic_param_adaptive_effort_levels() -> None:
def effort_for(level: ReasoningEffort) -> str:
body = chat_param_to_anthropic_param(_effort_param(level), adaptive_thinking=True)
assert body.output_config is not UNSET
return str(body.output_config.effort)
assert effort_for("minimal") == "low" # no such API level; the lowest one
assert effort_for("xhigh") == "xhigh"
def test_chat_param_to_anthropic_param_adaptive_ignores_a_configured_budget() -> None:
body = chat_param_to_anthropic_param(_effort_param("medium"), thinking_budget_tokens=20000, adaptive_thinking=True)
assert body.thinking is not UNSET
assert body.thinking.type == "adaptive"
assert body.thinking.budget_tokens is UNSET
assert body.max_tokens == 8192
def test_chat_param_to_anthropic_param_adaptive_disabled_carries_no_effort() -> None:
body = chat_param_to_anthropic_param(_effort_param("none"), adaptive_thinking=True)
assert body.thinking is not UNSET
assert body.thinking.type == "disabled"
assert body.output_config is UNSET
def test_chat_param_to_anthropic_param_adaptive_without_effort_sends_nothing() -> None:
body = chat_param_to_anthropic_param(_effort_param(), adaptive_thinking=True)
assert body.thinking is UNSET
assert body.output_config is UNSET
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 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"}}')
+126 -3
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, cast, overload
import pytest
from msgspec import UNSET
from plyngent.agent import (
@@ -45,6 +46,21 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
# 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(
*,
text: str = "hello",
@@ -73,21 +89,43 @@ class ScriptedMessagesClient:
kind: str = "messages"
calls: list[AnthropicMessagesParam]
# Mirrors AnthropicClient: explicit thinking budget (0 = derive from effort).
# Mirrors AnthropicClient: explicit thinking budget (0 = derive from effort),
# and the thinking types the dispatch found the API refusing.
thinking_budget_tokens: int
refused_thinking_types: dict[str, set[str]]
_non_stream: list[AnthropicMessageResponse]
_stream_events: list[list[AnthropicStreamEvent]]
_refusals: frozenset[str]
_error: str | None
def __init__(
self,
*,
non_stream: Sequence[AnthropicMessageResponse] | None = None,
stream_events: Sequence[Sequence[AnthropicStreamEvent]] | None = None,
refusals: Sequence[str] = (),
error: str | None = None,
) -> None:
self.calls = []
self.thinking_budget_tokens = 0
self.refused_thinking_types = {}
self._non_stream = list(non_stream or ())
self._stream_events = [list(events) for events in (stream_events or ())]
self._refusals = frozenset(refusals)
self._error = error
def refuses_thinking_type(self, model: str, thinking_type: str) -> bool:
return thinking_type in self.refused_thinking_types.get(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 param.thinking is not UNSET and param.thinking.type in self._refusals:
raise RuntimeError(_THINKING_REFUSED_400[param.thinking.type])
@overload
async def messages(
@@ -107,13 +145,16 @@ class ScriptedMessagesClient:
if not self._stream_events:
msg = "no more scripted stream events"
raise RuntimeError(msg)
events = self._stream_events.pop(0)
async def _gen() -> AsyncIterator[AnthropicStreamEvent]:
for event in events:
# The real client reads the error body on the first iteration,
# so a refused request consumes no scripted events.
self._reject(param)
for event in self._stream_events.pop(0):
yield event
return _gen()
self._reject(param)
if not self._non_stream:
msg = "no more scripted messages"
raise RuntimeError(msg)
@@ -602,3 +643,85 @@ async def test_search_turn_reports_the_prompt_the_delta_counts() -> None:
assert isinstance(usage, dict)
assert usage["prompt_tokens"] == 12468
assert usage["completion_tokens"] == 198
def _thinking_param(model: str = "claude-opus-5-5", effort: str = "high") -> ChatCompletionsParam:
return ChatCompletionsParam(
model=model,
messages=[UserChatMessage(content="hi")],
reasoning_effort=cast("Any", effort),
)
async def test_dispatch_messages_retries_with_adaptive_thinking() -> None:
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
refused, retried = client.calls
assert refused.thinking is not UNSET
assert refused.thinking.type == "enabled"
assert retried.thinking is not UNSET
assert retried.thinking.type == "adaptive"
assert retried.output_config is not UNSET
assert retried.output_config.effort == "high"
async def test_dispatch_messages_remembers_the_refusing_model() -> None:
client = ScriptedMessagesClient(
non_stream=[_message_response(text="one"), _message_response(text="two")],
refusals=("enabled",),
)
param = _thinking_param()
_ = await dispatch_messages(cast("Any", client), param, stream=False)
_ = await dispatch_messages(cast("Any", client), param, stream=False)
# Only the model's first request pays the refused attempt.
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="")),
AnthropicContentBlockDelta(index=0, delta=AnthropicRawContentBlock(type="text_delta", text="streamed")),
AnthropicMessageDelta(delta={"stop_reason": "end_turn"}, usage=AnthropicUsage(output_tokens=3)),
AnthropicMessageStop(),
]
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)]
assert texts == ["streamed"]
assert len(client.calls) == 2
assert client.calls[1].thinking.type == "adaptive"
async def test_dispatch_messages_does_not_retry_other_failures() -> None:
client = ScriptedMessagesClient(error='messages HTTP 400: {"error":{"message":"max_tokens: required"}}')
with pytest.raises(RuntimeError, match="max_tokens"):
_ = await dispatch_messages(cast("Any", client), _thinking_param(), stream=False)
assert len(client.calls) == 1
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)
assert len(client.calls) == 1