diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index f8e58872ac..5e373c75d4 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -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) diff --git a/agent/anthropic_message_convert.py b/agent/anthropic_message_convert.py index f84e78d917..f0c76238e8 100644 --- a/agent/anthropic_message_convert.py +++ b/agent/anthropic_message_convert.py @@ -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(