mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
Compare commits
4 Commits
v0.9.0
...
3c555278d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c555278d5 | |||
| f39ec5fb1a | |||
| 06a5bafab7 | |||
| 9156df2b02 |
@@ -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).
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"}}')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user