refactor(agent/anthropic_adapter,message_convert): compact tables and docstrings, _assistant_block_lists iterator, _carry_cache_control copy mode, collapse predicate ladders

This commit is contained in:
Teknium
2026-09-02 20:01:36 -07:00
parent 429b7895e6
commit 5dc9c223e1
2 changed files with 118 additions and 198 deletions
+89 -149
View File
@@ -1,12 +1,8 @@
"""Anthropic Messages API adapter for Hermes Agent.
Client construction and the Messages API call for Hermes's OpenAI-style internals. Auth:
regular API keys (``sk-ant-api*``) -> x-api-key; OAuth setup-tokens (``sk-ant-oat*``) -> Bearer +
beta header; Claude Code credentials (``~/.claude.json`` / ``~/.claude/.credentials.json``) ->
Bearer. Endpoint predicates, payload conversion and credential handling live in
``agent/anthropic_endpoints.py``, ``agent/anthropic_message_convert.py`` and
``agent/anthropic_credentials.py`` and are re-exported here for long-standing imports.
"""
"""Anthropic Messages API adapter: client construction + the Messages call for Hermes's
OpenAI-style internals. Auth: API keys (``sk-ant-api*``) -> x-api-key; OAuth setup-tokens
(``sk-ant-oat*``) and Claude Code credentials -> Bearer + beta header. Endpoint predicates,
payload conversion and credentials live in ``agent/anthropic_{endpoints,message_convert,
credentials}.py`` and are re-exported here for long-standing imports."""
import logging
import math
@@ -85,23 +81,20 @@ ADAPTIVE_EFFORT_MAP = {
"minimal": "low",
}
# Thinking-mode classification. Claude 4.6 replaced budget-based extended thinking with
# *adaptive* thinking; 4.7 additionally forbids the manual ``thinking`` block and drops
# temperature/top_p/top_k. Newer releases share no common version substring, so an allowlist of
# "modern" versions would go stale and silently route a new model down the legacy path: unknown
# Claude models DEFAULT to the modern contract and only explicit *legacy* lists are kept
# (mirroring _get_anthropic_max_output's default-to-newest). Non-Claude Anthropic-Messages models
# (minimax, qwen3, GLM, ...) fall through to the legacy manual-thinking path, which they need.
# Older Claude families that need manual thinking (budget_tokens only).
# Thinking-mode classification. Claude 4.6 replaced budget-based extended thinking with *adaptive*
# thinking; 4.7 additionally forbids the manual ``thinking`` block and drops temperature/top_p/
# top_k. Newer releases share no common version substring, so an allowlist of "modern" versions
# would go stale and silently route a new model down the legacy path: unknown Claude models
# DEFAULT to the modern contract and only explicit *legacy* lists are kept (mirroring
# _get_anthropic_max_output's default-to-newest). Non-Claude Anthropic-Messages models (minimax,
# qwen3, GLM, ...) fall through to the legacy manual-thinking path, which they need.
# Older Claude families that need manual thinking (budget_tokens only); ``claude-3`` covers
# 3/3.5/3.7 and the ``-2025`` entries are date-stamped 4.0 ids.
_LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS = (
"claude-3", # 3, 3.5, 3.7
"claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
"claude-sonnet-4-0", "claude-sonnet-4.0",
"claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs
"claude-opus-4-5", "claude-opus-4.5",
"claude-sonnet-4-5", "claude-sonnet-4.5",
"claude-haiku-4-5", "claude-haiku-4.5",
"claude-3", "claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
"claude-sonnet-4-0", "claude-sonnet-4.0", "claude-opus-4-2025", "claude-sonnet-4-2025",
"claude-opus-4-5", "claude-opus-4.5", "claude-sonnet-4-5", "claude-sonnet-4.5", "claude-haiku-4-5",
"claude-haiku-4.5",
)
# Adaptive families that reject the "xhigh" effort (arrived with Opus 4.7) and still accept
# sampling params.
@@ -110,7 +103,6 @@ _NO_XHIGH_CLAUDE_SUBSTRINGS = ("claude-opus-4-6", "claude-opus-4.6", "claude-son
# 400 (Portal flags them ``reasoning.mandatory``). The failure is asymmetric — a missing entry
# 400s the turn, a spurious one only leaves thinking on — so when in doubt, add the family.
_MANDATORY_THINKING_CLAUDE_SUBSTRINGS = ("claude-fable",)
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-8", "opus-4.8", "opus-5")
@@ -124,28 +116,17 @@ def _model_matches(model: str, substrings) -> bool:
return any(v in m for v in substrings)
# Max output tokens per model. Anthropic requires max_tokens; a fixed 16384 starved
# thinking-enabled models (thinking tokens count toward the limit). Source: Anthropic docs + Cline.
# Max output tokens per model (Anthropic docs + Cline catalog). Anthropic requires max_tokens; a
# fixed 16384 starved thinking-enabled models (thinking tokens count toward the limit).
# ``claude-fable`` = Mythos-class named models (1M context); ``minimax`` is a third-party
# Anthropic-compatible endpoint; DashScope enforces ``qwen3`` max_tokens in [1, 65536].
_ANTHROPIC_OUTPUT_LIMITS = {
"claude-fable": 128_000, # Mythos-class named models — 1M context, reasoning
"claude-sonnet-5": 128_000,
"claude-opus-4-8": 128_000,
"claude-opus-4-7": 128_000,
"claude-opus-4-6": 128_000,
"claude-sonnet-4-6": 64_000,
"claude-opus-4-5": 64_000,
"claude-sonnet-4-5": 64_000,
"claude-haiku-4-5": 64_000,
"claude-opus-4": 32_000,
"claude-sonnet-4": 64_000,
"claude-3-7-sonnet": 128_000,
"claude-3-5-sonnet": 8_192,
"claude-3-5-haiku": 8_192,
"claude-3-opus": 4_096,
"claude-3-sonnet": 4_096,
"claude-3-haiku": 4_096,
"minimax": 131_072, # third-party Anthropic-compatible
"qwen3": 65_536, # DashScope enforces max_tokens in [1, 65536]
"claude-fable": 128_000, "claude-sonnet-5": 128_000, "claude-opus-4-8": 128_000,
"claude-opus-4-7": 128_000, "claude-opus-4-6": 128_000, "claude-sonnet-4-6": 64_000,
"claude-opus-4-5": 64_000, "claude-sonnet-4-5": 64_000, "claude-haiku-4-5": 64_000,
"claude-opus-4": 32_000, "claude-sonnet-4": 64_000, "claude-3-7-sonnet": 128_000,
"claude-3-5-sonnet": 8_192, "claude-3-5-haiku": 8_192, "claude-3-opus": 4_096,
"claude-3-sonnet": 4_096, "claude-3-haiku": 4_096, "minimax": 131_072, "qwen3": 65_536,
}
# Unknown models get the highest current limit: future models are unlikely to have *less*.
_ANTHROPIC_DEFAULT_OUTPUT_LIMIT = 128_000
@@ -172,20 +153,16 @@ def _resolve_positive_anthropic_max_tokens(value) -> Optional[int]:
return None
except Exception: # e.g. OverflowError for ints too large for float
return None
floored = int(value) # truncates toward zero for floats
return floored if floored > 0 else None
return int(value) if int(value) > 0 else None # int() truncates toward zero for floats
def _resolve_anthropic_messages_max_tokens(requested, model: str, context_length: Optional[int] = None) -> int:
"""``requested`` when it is a positive finite number, else the model's output ceiling. Raises
ValueError if neither is positive. The context-window clamp is the caller's job so the
positive-value contract stays endpoint-agnostic."""
resolved = _resolve_positive_anthropic_max_tokens(requested)
if resolved is not None:
resolved = _resolve_positive_anthropic_max_tokens(requested) or _get_anthropic_max_output(model)
if resolved > 0:
return resolved
fallback = _get_anthropic_max_output(model)
if fallback > 0:
return fallback
raise ValueError(
f"Anthropic Messages adapter requires a positive max_tokens value for "
f"model {model!r}; got {requested!r} and no model default resolved."
@@ -196,11 +173,9 @@ def _supports_adaptive_thinking(model: str) -> bool:
"""True for Claude models using adaptive thinking (4.6+): unknown Claude models default to
adaptive, the explicit legacy list stays manual, and non-Claude models return False — except
Kimi/Moonshot, whose Anthropic-compatible endpoints implement the adaptive contract."""
if _model_name_is_kimi_family(model):
return True
if not _is_claude_model(model):
return False
return not _model_matches(model, _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
return _model_name_is_kimi_family(model) or (
_is_claude_model(model) and not _model_matches(model, _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
)
def _supports_xhigh_effort(model: str) -> bool:
@@ -243,18 +218,14 @@ def _supports_fast_mode(model: str) -> bool:
# there) but older Claude and compatible endpoints still gate on them. Do NOT add
# ``context-1m-2025-08-07``: accounts without the long-context beta get HTTP 400, breaking short
# auxiliary calls. Bedrock/Azure still need it for 1M context and opt in on their own paths.
_COMMON_BETAS = [
"interleaved-thinking-2025-05-14", "fine-grained-tool-streaming-2025-05-14"
]
# MiniMax's Anthropic-compatible endpoints fail tool-use requests when this beta is present.
# MiniMax's Anthropic-compatible endpoints fail tool-use requests when the tool-streaming beta is
# present. ``_FAST_MODE_BETA`` enables the ``speed: "fast"`` request parameter.
_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"
_COMMON_BETAS = ["interleaved-thinking-2025-05-14", _TOOL_STREAMING_BETA]
_CONTEXT_1M_BETA = "context-1m-2025-08-07"
# Enables the ``speed: "fast"`` request parameter.
_FAST_MODE_BETA = "fast-mode-2026-02-01"
# Required for OAuth/subscription auth; matches Claude Code / pi-ai / OpenCode.
_OAUTH_ONLY_BETAS = [
"claude-code-20250219", "oauth-2025-04-20"
]
_OAUTH_ONLY_BETAS = ["claude-code-20250219", "oauth-2025-04-20"]
# Claude Code identity — OAuth requests without it intermittently 500. Anthropic rejects OAuth
# requests whose user-agent version is too far behind the actual release, so the installed
@@ -296,9 +267,7 @@ _MCP_TOOL_PREFIX = "mcp__"
# usage" on a valid subscription). Live A/B repros isolated two independent triggers — the
# ``session_search`` tool (schema/name/prose) and the ``memory`` tool (schema/name) — so both are
# aliased on the OAuth wire only; normalize_response reverses the mapping.
_OAUTH_TOOL_NAME_ALIASES = {
"session_search": "chat_history_lookup", "memory": "context_notes"
}
_OAUTH_TOOL_NAME_ALIASES = {"session_search": "chat_history_lookup", "memory": "context_notes"}
_OAUTH_TOOL_NAME_REVERSE_ALIASES = {wire_name: name for name, wire_name in _OAUTH_TOOL_NAME_ALIASES.items()}
# Aliases ALSO safe to substitute in free-form prose (system prompt, tool descriptions). "memory"
@@ -342,14 +311,12 @@ def _beta_header(betas: list) -> Dict[str, str]:
return {"anthropic-beta": ",".join(betas)} if betas else {}
_ATTRIBUTION_HEADERS = {
"HTTP-Referer": "https://hermes-agent.nousresearch.com", "X-Title": "Hermes Agent"
}
def _attribution_headers() -> Dict[str, str]:
"""Same client-attribution set sent to OpenRouter / Vercel AI Gateway / Fireworks."""
return {**_ATTRIBUTION_HEADERS, "User-Agent": f"HermesAgent/{_HERMES_VERSION}"}
return {
"HTTP-Referer": "https://hermes-agent.nousresearch.com", "X-Title": "Hermes Agent",
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
}
def _client_timeout(timeout):
@@ -361,15 +328,11 @@ def _client_timeout(timeout):
def _base_client_kwargs(base_url, timeout) -> tuple[str, Dict[str, Any]]:
"""Shared SDK constructor kwargs; returns ``(normalized_base_url, kwargs)``.
Retry is delegated to hermes's outer loop (``max_retries=0``): the SDK default of 2 uses its
own backoff that ignores Retry-After and double-retries inside our loop, burning request slots
against a bucket that won't refill for minutes. Any trailing ``/v1`` is stripped because the
SDK appends ``/v1/messages``. Azure endpoints need an ``api-version`` query param; it goes
through ``default_query`` so the base_url is not corrupted into
``/anthropic?api-version=.../v1/messages``.
"""
"""Shared SDK constructor kwargs -> ``(normalized_base_url, kwargs)``. Retry is delegated to
hermes's outer loop (``max_retries=0``): the SDK default of 2 uses its own backoff that ignores
Retry-After and double-retries inside our loop. Any trailing ``/v1`` is stripped because the
SDK appends ``/v1/messages``. Azure's ``api-version`` goes through ``default_query`` so the
base_url is not corrupted into ``/anthropic?api-version=.../v1/messages``."""
kwargs: Dict[str, Any] = {"timeout": _client_timeout(timeout), "max_retries": 0}
normalized = _normalize_base_url_text(base_url)
if normalized:
@@ -383,14 +346,11 @@ def _base_client_kwargs(base_url, timeout) -> tuple[str, Dict[str, Any]]:
def _build_anthropic_client_with_bearer_hook(
token_provider, base_url: str = None, timeout: float = None, *, drop_context_1m_beta: bool = False
):
"""Anthropic-on-Foundry Entra ID variant of :func:`build_anthropic_client`.
The SDK stores ``api_key``/``auth_token`` as static strings, so per-request bearer refresh
(Microsoft's documented Foundry pattern) is done with a custom ``httpx.Client`` whose request
hook mints a fresh JWT and rewrites ``Authorization`` on every request; the SDK skips its own
auth when ``http_client`` is given. A placeholder ``auth_token`` is still required at
construction — the hook overrides it, and the sentinel makes any accidental leak diagnosable.
"""
"""Anthropic-on-Foundry Entra ID variant of :func:`build_anthropic_client`. The SDK stores
``api_key``/``auth_token`` as static strings, so per-request bearer refresh (Microsoft's
documented Foundry pattern) uses a custom ``httpx.Client`` whose request hook mints a fresh JWT
and rewrites ``Authorization``; the SDK skips its own auth when ``http_client`` is given. The
placeholder ``auth_token`` is still required at construction and makes any leak diagnosable."""
sdk = _require_sdk("Azure Foundry Anthropic-style endpoints with Entra ID auth", verb="Install with")
normalize_proxy_env_vars()
@@ -417,16 +377,12 @@ def _new_sdk_client(sdk, kwargs: Dict[str, Any], headers: Dict[str, str]):
def _auth_style(api_key, base_url, normalized_base_url) -> str:
"""Order-sensitive endpoint/key classification for :func:`build_anthropic_client`.
``kimi``: Kimi's /coding endpoint 403s without a User-Agent (the Kimi team asked for proper
attribution instead of the ``claude-code/0.1.0`` minimum). ``bearer``: MiniMax & co. want the
key in Authorization: Bearer — checked before the OAuth shape test because their secrets lack
the sk-ant-api prefix and would be misread as Anthropic OAuth/setup tokens. ``api_key``:
third-party proxies use their own x-api-key keys (skip OAuth detection). ``oauth``: OAuth /
setup-token -> Bearer auth + Claude Code identity (Anthropic routes OAuth by
user-agent/headers; without the fingerprint, 500s).
"""
"""Order-sensitive endpoint/key classification for :func:`build_anthropic_client`. ``kimi``:
Kimi's /coding endpoint 403s without a User-Agent (the Kimi team asked for proper attribution).
``bearer``: MiniMax & co. want Authorization: Bearer — checked before the OAuth shape test
because their secrets lack the sk-ant-api prefix and would be misread as OAuth/setup tokens.
``api_key``: third-party proxies use their own x-api-key keys (skip OAuth detection). ``oauth``:
Bearer auth + Claude Code identity (Anthropic routes OAuth by user-agent; without it, 500s)."""
if _is_kimi_coding_endpoint(base_url):
return "kimi"
if _requires_bearer_auth(normalized_base_url):
@@ -439,16 +395,12 @@ def _auth_style(api_key, base_url, normalized_base_url) -> str:
def build_anthropic_client(api_key, base_url: str = None, timeout: float = None, *, drop_context_1m_beta: bool = False):
"""Create an Anthropic client, auto-detecting setup-tokens vs API keys.
``api_key`` is a static ``str`` (all key-based and OAuth flows) or a ``Callable[[], str]``
Entra ID bearer provider, routed through :func:`_build_anthropic_client_with_bearer_hook`.
``timeout`` overrides the 900s read timeout (connect stays 10s) from the per-provider /
per-model ``request_timeout_seconds`` config. ``drop_context_1m_beta`` strips
``context-1m-2025-08-07`` from the client-level beta header — used by the reactive OAuth retry
in run_agent when a subscription rejects it; fresh clients keep the default so 1M-capable
subscriptions keep the capability.
"""
"""Create an Anthropic client, auto-detecting setup-tokens vs API keys. ``api_key`` is a static
``str`` or a ``Callable[[], str]`` Entra ID bearer provider (routed through
:func:`_build_anthropic_client_with_bearer_hook`). ``timeout`` overrides the 900s read timeout
(connect stays 10s). ``drop_context_1m_beta`` strips ``context-1m-2025-08-07`` from the
client-level beta header — the reactive OAuth retry in run_agent uses it after a subscription
rejects it; fresh clients keep the default so 1M-capable subscriptions keep the capability."""
sdk = _require_sdk("the Anthropic provider")
if callable(api_key) and not isinstance(api_key, str):
return _build_anthropic_client_with_bearer_hook(
@@ -522,10 +474,9 @@ def _oauth_wire_namer(anthropic_tools: List[Dict[str, Any]]):
}
def to_wire(name: str) -> str:
if name in _OAUTH_TOOL_NAME_ALIASES:
aliased = _OAUTH_TOOL_NAME_ALIASES[name]
if _MCP_TOOL_PREFIX + aliased not in claimed:
name = aliased
aliased = _OAUTH_TOOL_NAME_ALIASES.get(name)
if aliased and _MCP_TOOL_PREFIX + aliased not in claimed:
name = aliased
return _normalize_to_mcp_wire(name)
return to_wire
@@ -543,12 +494,9 @@ def _apply_claude_code_identity(system, anthropic_tools, anthropic_messages, to_
blocks so history matches ``tools[]``. Returns the new ``system``; tools and messages are
mutated in place."""
cc_block = {"type": "text", "text": _CLAUDE_CODE_SYSTEM_PREFIX}
if isinstance(system, list):
system = [cc_block] + system
elif isinstance(system, str) and system:
system = [cc_block, {"type": "text", "text": system}]
else:
system = [cc_block]
if isinstance(system, str) and system:
system = [{"type": "text", "text": system}]
system = [cc_block] + (system if isinstance(system, list) else [])
for block in system:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
@@ -612,19 +560,14 @@ def build_anthropic_kwargs(
is_oauth: bool = False, preserve_dots: bool = False, context_length: Optional[int] = None,
base_url: str | None = None, fast_mode: bool = False, drop_context_1m_beta: bool = False,
) -> Dict[str, Any]:
"""Build kwargs for anthropic.messages.create().
Two easily confused concepts: ``max_tokens`` is the OUTPUT cap for one response;
``context_length`` is the TOTAL window (input + output), enforced as ``input_tokens +
max_tokens <= context_length``. ``max_tokens=None`` uses the model's native output ceiling; if
that exceeds ``context_length`` (small local endpoints) it is clamped to ``context_length -
1``. The clamp ignores prompt size — callers must catch "max_tokens too large given prompt"
and retry smaller (parse_available_output_tokens_from_error).
"""Build kwargs for anthropic.messages.create(). ``max_tokens`` is the OUTPUT cap for one
response; ``context_length`` is the TOTAL window (input + output). ``max_tokens=None`` uses the
model's native output ceiling; if that exceeds ``context_length`` (small local endpoints) it is
clamped to ``context_length - 1``. The clamp ignores prompt size — callers must catch
"max_tokens too large given prompt" and retry smaller (parse_available_output_tokens_from_error).
``is_oauth`` applies Claude Code compatibility transforms; ``preserve_dots`` keeps model-name
dots (DashScope: qwen3.5-plus); a third-party ``base_url`` strips thinking signatures;
``fast_mode`` adds ``extra_body.speed="fast"`` plus the fast-mode beta on native Anthropic only.
"""
``fast_mode`` adds ``extra_body.speed="fast"`` plus the fast-mode beta on native Anthropic only."""
system, anthropic_messages = convert_messages_to_anthropic(messages, base_url=base_url, model=model)
anthropic_tools = convert_tools_to_anthropic(tools) if tools else []
@@ -662,8 +605,8 @@ def build_anthropic_kwargs(
# Safety net so upstream 4.6 -> 4.7 migrations don't need coordinated edits everywhere callers
# (auxiliary_client, ...) set sampling params.
if _forbids_sampling_params(model):
for _sampling_key in ("temperature", "top_p", "top_k"):
kwargs.pop(_sampling_key, None)
for key in ("temperature", "top_p", "top_k"):
kwargs.pop(key, None)
# Fast mode: native Anthropic only — third-party providers reject the unknown beta/param and
# Anthropic scopes it to the Claude API (not Bedrock/Vertex/Foundry). Per-request extra_headers
@@ -694,8 +637,8 @@ def sanitize_anthropic_kwargs(api_kwargs: Any, *, log_prefix: str = "") -> Any:
return api_kwargs
leaked = _RESPONSES_ONLY_KWARGS.intersection(api_kwargs)
if leaked:
for _key in leaked:
del api_kwargs[_key]
for key in leaked:
del api_kwargs[key]
logger.warning(
"%sStripped Responses-only kwarg(s) %s from an Anthropic Messages "
"call (api_mode flip race — see #31673). The call will proceed; "
@@ -748,17 +691,14 @@ def create_anthropic_message(
client: Any, api_kwargs: dict, *, log_prefix: str = "", prefer_stream: bool = True,
on_stream_event=None, on_response=None,
) -> Any:
"""Create an Anthropic message, aggregating via stream when available.
Some Anthropic-compatible gateways are SSE-only and answer ``create()`` with
``text/event-stream``, which the SDK surfaces as raw text (callers then crash on
``.content``). So prefer ``messages.stream().get_final_message()`` like the main turn path,
falling back to ``create()`` only for providers that explicitly don't support streaming
(restricted Bedrock roles). Both callbacks are best-effort (exceptions swallowed) and fire
only on the streaming path: ``on_stream_event(event)`` lets liveness watchdogs see forward
progress; ``on_response(httpx_response)`` exposes headers the parsed Message drops (Nous
Portal's ``x-nous-credits-*`` balance family).
"""
"""Create an Anthropic message, aggregating via stream when available. Some Anthropic-compatible
gateways are SSE-only and answer ``create()`` with ``text/event-stream``, which the SDK surfaces
as raw text (callers then crash on ``.content``), so prefer ``messages.stream()`` like the main
turn path and fall back to ``create()`` only for providers that explicitly don't support
streaming (restricted Bedrock roles). Both callbacks are best-effort and fire only on the
streaming path: ``on_stream_event(event)`` lets liveness watchdogs see forward progress;
``on_response(httpx_response)`` exposes headers the parsed Message drops (Nous Portal's
``x-nous-credits-*`` balance family)."""
sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix)
messages_api = getattr(client, "messages", None)
+29 -49
View File
@@ -29,7 +29,6 @@ _BEDROCK_REGION_PREFIXES = (
)
def _block_type(b: Any) -> Any:
"""``type`` of a dict block, None for non-dicts."""
return b.get("type") if isinstance(b, dict) else None
@@ -81,10 +80,17 @@ def _block_ids(blocks: List[Any], btype: str, key: str) -> set:
return {b.get(key) for b in blocks if _block_type(b) == btype}
def _carry_cache_control(out: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
"""Copy a dict-valued ``cache_control`` marker from ``b`` onto ``out`` (returned)."""
if _cache_control_of(b) is not None:
out["cache_control"] = b["cache_control"]
def _assistant_block_lists(result: List[Dict[str, Any]]):
"""``(index, message)`` for every assistant message whose content is a block list."""
return ((i, m) for i, m in enumerate(result) if m.get("role") == "assistant" and isinstance(m.get("content"), list))
def _carry_cache_control(out: Dict[str, Any], b: Any, *, copy: bool = False) -> Dict[str, Any]:
"""Carry a dict-valued ``cache_control`` marker from ``b`` onto ``out`` (returned); ``copy``
shallow-copies it so the caller's dict is never shared with the wire payload."""
cc = _cache_control_of(b)
if cc is not None:
out["cache_control"] = dict(cc) if copy else cc
return out
@@ -104,7 +110,6 @@ def _split_blank_text_blocks(blocks: List[Any]) -> Tuple[List[Any], Any, List[in
return kept, relocated_cc, dropped
def _is_bedrock_model_id(model: str) -> bool:
"""Bedrock ids (``anthropic.claude-opus-4-7``, ``us.anthropic.claude-*``) use dots as namespace
separators that must be preserved verbatim."""
@@ -177,14 +182,10 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
"description": fn.get("description", ""),
"input_schema": _normalize_tool_input_schema(fn.get("parameters") or {}),
}
cache_control = _cache_control_of(t)
if cache_control is not None:
anthropic_tool["cache_control"] = dict(cache_control)
result.append(anthropic_tool)
result.append(_carry_cache_control(anthropic_tool, t, copy=True))
return result
def _image_source_from_openai_url(url: str) -> Dict[str, str]:
"""OpenAI image URL / data URL -> Anthropic image ``source``."""
url = str(url or "").strip()
@@ -303,7 +304,6 @@ def _safe_text(text: Any) -> str:
return text if text.strip() else _EMPTY_TEXT_PLACEHOLDER
def _replay_text(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
# Drop blank blocks rather than coerce in place: the caller relocates any cache_control and
# falls back to a placeholder only when nothing survives, so "(empty)" never sits as
@@ -360,7 +360,6 @@ def _apply_assistant_cache_control_to_last_cacheable_block(blocks: List[Dict[str
break
def _replay_ordered_blocks(m: Dict[str, Any], ordered_blocks: List[Any]) -> Optional[List[Dict[str, Any]]]:
"""Interleaved-thinking replay: rebuild the assistant turn from the verbatim block list
normalize_response stored (only for turns interleaving SIGNED thinking with tool_use).
@@ -479,9 +478,7 @@ def _convert_tool_message_to_result(result: List[Dict[str, Any]], m: Dict[str, A
"type": "tool_result", "tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")),
"content": _tool_result_content(m),
}
cache_control = _cache_control_of(m)
if cache_control is not None:
tool_result["cache_control"] = dict(cache_control)
_carry_cache_control(tool_result, m, copy=True)
last = result[-1] if result else {}
if last.get("role") == "user" and isinstance(last.get("content"), list) and last["content"] \
and last["content"][0].get("type") == "tool_result":
@@ -493,26 +490,22 @@ def _convert_tool_message_to_result(result: List[Dict[str, Any]], m: Dict[str, A
def _convert_user_message(content: Any) -> Dict[str, Any]:
"""Validate and convert a user message to Anthropic format."""
if isinstance(content, list):
kept_blocks = _fix_blank_text_blocks_in_list(
content = _fix_blank_text_blocks_in_list(
_convert_content_to_anthropic(content), placeholder_text="(empty message)",
msg_index=-1, role="user", location="_convert_user_message",
)
return {"role": "user", "content": kept_blocks}
if not content or (isinstance(content, str) and not content.strip()):
elif not content or (isinstance(content, str) and not content.strip()):
content = "(empty message)"
return {"role": "user", "content": content}
def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None:
"""Strip tool_use blocks with no matching tool_result, and vice versa. Compression/truncation
can remove either side of a pair or insert messages between them. Anthropic requires the
tool_result in the IMMEDIATELY FOLLOWING user message — a global id match is not enough.
Mutates ``result`` in place."""
# Pass 1: tool_use without an adjacent result.
for i, m in enumerate(result):
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
continue
for i, m in _assistant_block_lists(result):
tool_use_ids_in_turn = _block_ids(m["content"], "tool_use", "id")
if not tool_use_ids_in_turn:
continue
@@ -534,9 +527,8 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None:
# Pass 2: tool_result whose tool_use no longer exists anywhere.
surviving_tool_use_ids: set = set()
for m in result:
if m.get("role") == "assistant" and isinstance(m.get("content"), list):
surviving_tool_use_ids |= _block_ids(m["content"], "tool_use", "id")
for _, m in _assistant_block_lists(result):
surviving_tool_use_ids |= _block_ids(m["content"], "tool_use", "id")
for m in result:
if m.get("role") != "user" or not isinstance(m.get("content"), list):
continue
@@ -612,9 +604,7 @@ def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | No
is_deepseek = _is_deepseek_anthropic_endpoint(base_url)
last_assistant_idx = next((i for i in range(len(result) - 1, -1, -1) if result[i].get("role") == "assistant"), None)
for idx, m in enumerate(result):
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
continue
for idx, m in _assistant_block_lists(result):
if is_kimi:
pass # shared cleanup below still strips cache markers + the flag
elif is_deepseek:
@@ -643,12 +633,8 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
image_count = 0
for msg in reversed(result):
content = msg.get("content")
if not isinstance(content, list):
continue
for block in content:
if _block_type(block) != "tool_result":
continue
inner = block.get("content")
for block in content if isinstance(content, list) else []:
inner = block.get("content") if _block_type(block) == "tool_result" else None
if not isinstance(inner, list) or not _has_block_type(inner, {"image"}):
continue
image_count += 1
@@ -697,19 +683,16 @@ def _scrub_blank_text_blocks(result: List[Dict[str, Any]]) -> None:
content = msg.get("content")
if not isinstance(content, list) or not content:
continue
new_content = _fix_blank_text_blocks_in_list(
msg["content"] = _fix_blank_text_blocks_in_list(
content, placeholder_text=_EMPTY_TEXT_PLACEHOLDER if role == "assistant" else "(empty message)",
msg_index=msg_index, role=role, location="content",
)
for blk in new_content:
if _block_type(blk) != "tool_result":
continue
inner = blk.get("content")
for blk in msg["content"]:
inner = blk.get("content") if _block_type(blk) == "tool_result" else None
if isinstance(inner, list) and inner:
blk["content"] = _fix_blank_text_blocks_in_list(
inner, placeholder_text="(no output)", msg_index=msg_index, role=role, location="tool_result",
)
msg["content"] = new_content
def _convert_system_content(content: Any) -> Any:
@@ -721,14 +704,11 @@ def _convert_system_content(content: Any) -> Any:
return content
if not any(p.get("cache_control") for p in content if isinstance(p, dict)):
return "\n".join(p["text"] for p in content if p.get("type") == "text")
system = []
for p in content:
if not isinstance(p, dict):
continue
if p.get("type") == "text" and isinstance(p.get("text"), str) and not p["text"].strip():
p = {**p, "text": _EMPTY_TEXT_PLACEHOLDER}
system.append(p)
return system
return [
{**p, "text": _EMPTY_TEXT_PLACEHOLDER} if _is_blank_text_block(p) and isinstance(p.get("text"), str) else p
for p in content
if isinstance(p, dict)
]
def convert_messages_to_anthropic(