mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
1319 lines
57 KiB
Python
1319 lines
57 KiB
Python
"""Anthropic wire-format utilities — core module, no SDK dependency.
|
||
|
||
Contains all code for converting between OpenAI-format and Anthropic Messages
|
||
API format: message conversion, tool schema conversion, model normalization,
|
||
max_tokens resolution, beta header management, and response normalization helpers.
|
||
|
||
Nothing in this file imports the anthropic SDK. Functions that create SDK clients
|
||
(build_anthropic_client, etc.) live in hermes_agent_anthropic.adapter.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
from urllib.parse import urlparse
|
||
|
||
from hermes_constants import get_hermes_home
|
||
from utils import base_url_host_matches, normalize_proxy_env_vars
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
THINKING_BUDGET = {"xhigh": 32000, "high": 16000, "medium": 8000, "low": 4000}
|
||
# Hermes effort → Anthropic adaptive-thinking effort (output_config.effort).
|
||
# Anthropic exposes 5 levels on 4.7+: low, medium, high, xhigh, max.
|
||
# Opus/Sonnet 4.6 only expose 4 levels: low, medium, high, max — no xhigh.
|
||
# We preserve xhigh as xhigh on 4.7+ (the recommended default for coding/
|
||
# agentic work) and downgrade it to max on pre-4.7 adaptive models (which
|
||
# is the strongest level they accept). "minimal" is a legacy alias that
|
||
# maps to low on every model. See:
|
||
# https://platform.claude.com/docs/en/about-claude/models/migration-guide
|
||
ADAPTIVE_EFFORT_MAP = {
|
||
"max": "max",
|
||
"xhigh": "xhigh",
|
||
"high": "high",
|
||
"medium": "medium",
|
||
"low": "low",
|
||
"minimal": "low",
|
||
}
|
||
|
||
# Models that accept the "xhigh" output_config.effort level. Opus 4.7 added
|
||
# xhigh as a distinct level between high and max; older adaptive-thinking
|
||
# models (4.6) reject it with a 400. Keep this substring list in sync with
|
||
# the Anthropic migration guide as new model families ship.
|
||
_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7")
|
||
|
||
# Models where extended thinking is deprecated/removed (4.6+ behavior: adaptive
|
||
# is the only supported mode; 4.7 additionally forbids manual thinking entirely
|
||
# and drops temperature/top_p/top_k).
|
||
_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7")
|
||
|
||
# Models where temperature/top_p/top_k return 400 if set to non-default values.
|
||
# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it.
|
||
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7")
|
||
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
|
||
|
||
# ── Max output token limits per Anthropic model ───────────────────────
|
||
# Source: Anthropic docs + Cline model catalog. Anthropic's API requires
|
||
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
|
||
# starves thinking-enabled models (thinking tokens count toward the limit).
|
||
_ANTHROPIC_OUTPUT_LIMITS = {
|
||
# Claude 4.7
|
||
"claude-opus-4-7": 128_000,
|
||
# Claude 4.6
|
||
"claude-opus-4-6": 128_000,
|
||
"claude-sonnet-4-6": 64_000,
|
||
# Claude 4.5
|
||
"claude-opus-4-5": 64_000,
|
||
"claude-sonnet-4-5": 64_000,
|
||
"claude-haiku-4-5": 64_000,
|
||
# Claude 4
|
||
"claude-opus-4": 32_000,
|
||
"claude-sonnet-4": 64_000,
|
||
# Claude 3.7
|
||
"claude-3-7-sonnet": 128_000,
|
||
# Claude 3.5
|
||
"claude-3-5-sonnet": 8_192,
|
||
"claude-3-5-haiku": 8_192,
|
||
# Claude 3
|
||
"claude-3-opus": 4_096,
|
||
"claude-3-sonnet": 4_096,
|
||
"claude-3-haiku": 4_096,
|
||
# Third-party Anthropic-compatible providers
|
||
"minimax": 131_072,
|
||
# Qwen models via DashScope Anthropic-compatible endpoint
|
||
# DashScope enforces max_tokens ∈ [1, 65536]
|
||
"qwen3": 65_536,
|
||
}
|
||
|
||
# For any model not in the table, assume the highest current limit.
|
||
# Future Anthropic models are unlikely to have *less* output capacity.
|
||
_ANTHROPIC_DEFAULT_OUTPUT_LIMIT = 128_000
|
||
|
||
def _get_anthropic_max_output(model: str) -> int:
|
||
"""Look up the max output token limit for an Anthropic model.
|
||
|
||
Uses substring matching against _ANTHROPIC_OUTPUT_LIMITS so date-stamped
|
||
model IDs (claude-sonnet-4-5-20250929) and variant suffixes (:1m, :fast)
|
||
resolve correctly. Longest-prefix match wins to avoid e.g. "claude-3-5"
|
||
matching before "claude-3-5-sonnet".
|
||
|
||
Normalizes dots to hyphens so that model names like
|
||
``anthropic/claude-opus-4.6`` match the ``claude-opus-4-6`` table key.
|
||
"""
|
||
m = model.lower().replace(".", "-")
|
||
best_key = ""
|
||
best_val = _ANTHROPIC_DEFAULT_OUTPUT_LIMIT
|
||
for key, val in _ANTHROPIC_OUTPUT_LIMITS.items():
|
||
if key in m and len(key) > len(best_key):
|
||
best_key = key
|
||
best_val = val
|
||
return best_val
|
||
|
||
def _resolve_positive_anthropic_max_tokens(value) -> Optional[int]:
|
||
"""Return ``value`` floored to a positive int, or ``None`` if it is not a
|
||
finite positive number. Ported from openclaw/openclaw#66664.
|
||
|
||
Anthropic's Messages API rejects ``max_tokens`` values that are 0,
|
||
negative, non-integer, or non-finite with HTTP 400. Python's ``or``
|
||
idiom (``max_tokens or fallback``) correctly catches ``0`` but lets
|
||
negative ints and fractional floats (``-1``, ``0.5``) through to the
|
||
API, producing a user-visible failure instead of a local error.
|
||
"""
|
||
# Booleans are a subclass of int — exclude explicitly so ``True`` doesn't
|
||
# silently become 1 and ``False`` doesn't become 0.
|
||
if isinstance(value, bool):
|
||
return None
|
||
if not isinstance(value, (int, float)):
|
||
return None
|
||
try:
|
||
import math
|
||
if not math.isfinite(value):
|
||
return None
|
||
except Exception:
|
||
return None
|
||
floored = int(value) # truncates toward zero for floats
|
||
return floored if floored > 0 else None
|
||
|
||
def _resolve_anthropic_messages_max_tokens(
|
||
requested,
|
||
model: str,
|
||
context_length: Optional[int] = None,
|
||
) -> int:
|
||
"""Resolve the ``max_tokens`` budget for an Anthropic Messages call.
|
||
|
||
Prefers ``requested`` when it is a positive finite number; otherwise
|
||
falls back to the model's output ceiling. Raises ``ValueError`` if no
|
||
positive budget can be resolved (should not happen with current model
|
||
table defaults, but guards against a future regression where
|
||
``_get_anthropic_max_output`` could return ``0``).
|
||
|
||
Separately, callers apply a context-window clamp — this resolver does
|
||
not, to keep the positive-value contract independent of endpoint
|
||
specifics.
|
||
|
||
Ported from openclaw/openclaw#66664 (resolveAnthropicMessagesMaxTokens).
|
||
"""
|
||
resolved = _resolve_positive_anthropic_max_tokens(requested)
|
||
if resolved is not None:
|
||
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."
|
||
)
|
||
|
||
def _supports_adaptive_thinking(model: str) -> bool:
|
||
"""Return True for Claude 4.6+ models that support adaptive thinking."""
|
||
return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS)
|
||
|
||
def _supports_xhigh_effort(model: str) -> bool:
|
||
"""Return True for models that accept the 'xhigh' adaptive effort level.
|
||
|
||
Opus 4.7 introduced xhigh as a distinct level between high and max.
|
||
Pre-4.7 adaptive models (Opus/Sonnet 4.6) only accept low/medium/high/max
|
||
and reject xhigh with an HTTP 400. Callers should downgrade xhigh→max
|
||
when this returns False.
|
||
"""
|
||
return any(v in model for v in _XHIGH_EFFORT_SUBSTRINGS)
|
||
|
||
def _forbids_sampling_params(model: str) -> bool:
|
||
"""Return True for models that 400 on any non-default temperature/top_p/top_k.
|
||
|
||
Opus 4.7 explicitly rejects sampling parameters; later Claude releases are
|
||
expected to follow suit. Callers should omit these fields entirely rather
|
||
than passing zero/default values (the API rejects anything non-null).
|
||
"""
|
||
return any(v in model for v in _NO_SAMPLING_PARAMS_SUBSTRINGS)
|
||
|
||
def _supports_fast_mode(model: str) -> bool:
|
||
"""Return True for models that support Anthropic Fast Mode (speed=fast).
|
||
|
||
Per Anthropic docs, fast mode is currently supported on Opus 4.6 only.
|
||
Sending ``speed: "fast"`` to any other Claude model (including Opus 4.7)
|
||
returns HTTP 400. This guard prevents silently 400'ing when stale config
|
||
or older callers leave fast mode enabled across a model upgrade.
|
||
"""
|
||
return any(v in model for v in _FAST_MODE_SUPPORTED_SUBSTRINGS)
|
||
|
||
# Beta headers for enhanced features that are safe on ordinary/native Anthropic
|
||
# requests. As of Opus 4.7 (2026-04-16), these are GA on Claude 4.6+ — the
|
||
# beta headers are still accepted (harmless no-op) but not required. Kept
|
||
# here so older Claude (4.5, 4.1) + compatible endpoints that still gate on
|
||
# the headers continue to get the enhanced features.
|
||
#
|
||
# Do NOT include ``context-1m-2025-08-07`` here. Anthropic returns HTTP 400
|
||
# ("long context beta is not yet available for this subscription") for
|
||
# accounts without the long-context beta, which breaks normal short auxiliary
|
||
# calls like title generation/session summarization.
|
||
#
|
||
# ``context-1m-2025-08-07`` is still required to unlock the 1M context window
|
||
# on Claude Opus 4.6/4.7 and Sonnet 4.6 when served via AWS Bedrock or Azure
|
||
# AI Foundry. Add it only for those endpoint-specific paths below.
|
||
_COMMON_BETAS = [
|
||
"interleaved-thinking-2025-05-14",
|
||
"fine-grained-tool-streaming-2025-05-14",
|
||
]
|
||
# MiniMax's Anthropic-compatible endpoints fail tool-use requests when
|
||
# the fine-grained tool streaming beta is present. Omit it so tool calls
|
||
# fall back to the provider's default response path.
|
||
_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"
|
||
# 1M context beta. Native Anthropic does not get this by default because some
|
||
# subscriptions reject it, but Bedrock/Azure still need it for 1M context.
|
||
_CONTEXT_1M_BETA = "context-1m-2025-08-07"
|
||
|
||
# Fast mode beta — enables the ``speed: "fast"`` request parameter for
|
||
# significantly higher output token throughput on Opus 4.6 (~2.5x).
|
||
# See https://platform.claude.com/docs/en/build-with-claude/fast-mode
|
||
_FAST_MODE_BETA = "fast-mode-2026-02-01"
|
||
|
||
# Additional beta headers required for OAuth/subscription auth.
|
||
# Matches what Claude Code (and pi-ai / OpenCode) send.
|
||
_OAUTH_ONLY_BETAS = [
|
||
"claude-code-20250219",
|
||
"oauth-2025-04-20",
|
||
]
|
||
|
||
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
|
||
_MCP_TOOL_PREFIX = "mcp_"
|
||
|
||
def _normalize_base_url_text(base_url) -> str:
|
||
"""Normalize SDK/base transport URL values to a plain string for inspection.
|
||
|
||
Some client objects expose ``base_url`` as an ``httpx.URL`` instead of a raw
|
||
string. Provider/auth detection should accept either shape.
|
||
"""
|
||
if not base_url:
|
||
return ""
|
||
return str(base_url).strip()
|
||
|
||
def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
|
||
"""Return True for non-Anthropic endpoints using the Anthropic Messages API.
|
||
|
||
Third-party proxies (Microsoft Foundry, AWS Bedrock, self-hosted) authenticate
|
||
with their own API keys via x-api-key, not Anthropic OAuth tokens. OAuth
|
||
detection should be skipped for these endpoints.
|
||
"""
|
||
normalized = _normalize_base_url_text(base_url)
|
||
if not normalized:
|
||
return False # No base_url = direct Anthropic API
|
||
normalized = normalized.rstrip("/").lower()
|
||
if "anthropic.com" in normalized:
|
||
return False # Direct Anthropic API — OAuth applies
|
||
return True # Any other endpoint is a third-party proxy
|
||
|
||
def _is_kimi_coding_endpoint(base_url: str | None) -> bool:
|
||
"""Return True for Kimi's /coding endpoint that requires claude-code UA."""
|
||
normalized = _normalize_base_url_text(base_url)
|
||
if not normalized:
|
||
return False
|
||
return normalized.rstrip("/").lower().startswith("https://api.kimi.com/coding")
|
||
|
||
# Model-name prefixes that identify the Kimi / Moonshot family. Covers
|
||
# - official slugs: ``kimi-k2.5``, ``kimi_thinking``, ``moonshot-v1-8k``
|
||
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``
|
||
# Matched case-insensitively against the post-``normalize_model_name`` form,
|
||
# so a caller's ``provider/vendor/model`` slug is handled the same as a
|
||
# bare name.
|
||
_KIMI_FAMILY_MODEL_PREFIXES = (
|
||
"kimi-", "kimi_",
|
||
"moonshot-", "moonshot_",
|
||
"k1.", "k1-",
|
||
"k2.", "k2-",
|
||
"k25", "k2.5",
|
||
)
|
||
|
||
def _model_name_is_kimi_family(model: str | None) -> bool:
|
||
if not isinstance(model, str):
|
||
return False
|
||
m = model.strip().lower()
|
||
if not m:
|
||
return False
|
||
# Strip vendor prefix (e.g. ``moonshotai/kimi-k2.5`` → ``kimi-k2.5``)
|
||
if "/" in m:
|
||
m = m.rsplit("/", 1)[-1]
|
||
return m.startswith(_KIMI_FAMILY_MODEL_PREFIXES)
|
||
|
||
def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) -> bool:
|
||
"""Return True for any Kimi / Moonshot Anthropic-Messages-speaking endpoint.
|
||
|
||
Broader than ``_is_kimi_coding_endpoint`` — matches:
|
||
|
||
- Kimi's official ``/coding`` URL (legacy check, preserved)
|
||
- Any ``api.kimi.com`` / ``moonshot.ai`` / ``moonshot.cn`` host
|
||
- Custom or proxied endpoints whose *model* name is in the Kimi / Moonshot
|
||
family (``kimi-*``, ``moonshot-*``, ``k1.*``, ``k2.*``, …). Users with
|
||
``api_mode: anthropic_messages`` on a private gateway fronting Kimi
|
||
fall into this branch — the upstream still enforces Kimi's thinking
|
||
semantics (reasoning_content required on every replayed tool-call
|
||
message) regardless of the gateway's hostname.
|
||
|
||
Used to decide whether to drop Anthropic's ``thinking`` kwarg and to
|
||
preserve unsigned reasoning_content-derived thinking blocks on replay.
|
||
See hermes-agent#13848, #17057.
|
||
"""
|
||
if _is_kimi_coding_endpoint(base_url):
|
||
return True
|
||
for _domain in ("api.kimi.com", "moonshot.ai", "moonshot.cn"):
|
||
if base_url_host_matches(base_url or "", _domain):
|
||
return True
|
||
if _model_name_is_kimi_family(model):
|
||
return True
|
||
return False
|
||
|
||
def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool:
|
||
"""Return True for DeepSeek's Anthropic-compatible endpoint.
|
||
|
||
DeepSeek's ``/anthropic`` route speaks the Anthropic Messages protocol
|
||
but, when thinking mode is enabled, requires the ``thinking`` blocks
|
||
from prior assistant turns to round-trip on subsequent requests — the
|
||
generic third-party path strips them and triggers HTTP 400::
|
||
|
||
The content[].thinking in the thinking mode must be passed back
|
||
to the API.
|
||
|
||
Per DeepSeek's published compatibility matrix the blocks are unsigned
|
||
(no Anthropic-proprietary signature, no ``redacted_thinking`` support),
|
||
so this endpoint is handled with the same strip-signed / keep-unsigned
|
||
policy used for Kimi's ``/coding`` endpoint. The match is pinned to
|
||
the ``/anthropic`` path so the OpenAI-compatible ``api.deepseek.com``
|
||
base URL (which never reaches this adapter) is not misclassified.
|
||
See hermes-agent#16748.
|
||
"""
|
||
if not base_url_host_matches(base_url or "", "api.deepseek.com"):
|
||
return False
|
||
normalized = _normalize_base_url_text(base_url)
|
||
if not normalized:
|
||
return False
|
||
return "/anthropic" in normalized.rstrip("/").lower()
|
||
|
||
def _base_url_needs_context_1m_beta(base_url: str | None) -> bool:
|
||
"""Return True for endpoints that still gate 1M context behind a beta."""
|
||
normalized = _normalize_base_url_text(base_url).lower()
|
||
if not normalized:
|
||
return False
|
||
return "azure.com" in normalized
|
||
|
||
def _is_minimax_anthropic_endpoint(base_url: str | None) -> bool:
|
||
"""Return True for MiniMax's Anthropic-compatible endpoints.
|
||
|
||
MiniMax rejects the fine-grained-tool-streaming and context-1m betas;
|
||
those need to be stripped even though MiniMax also uses Bearer auth.
|
||
"""
|
||
normalized = _normalize_base_url_text(base_url)
|
||
if not normalized:
|
||
return False
|
||
normalized = normalized.rstrip("/").lower()
|
||
return normalized.startswith(
|
||
("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic")
|
||
)
|
||
|
||
def _common_betas_for_base_url(
|
||
base_url: str | None,
|
||
*,
|
||
drop_context_1m_beta: bool = False,
|
||
) -> list[str]:
|
||
"""Return the beta headers that are safe for the configured endpoint.
|
||
|
||
MiniMax's Anthropic-compatible endpoints (Bearer-auth) reject requests
|
||
that include Anthropic's ``fine-grained-tool-streaming`` beta — every
|
||
tool-use message triggers a connection error. They also reject the
|
||
1M-context beta. Azure AI Foundry's Anthropic endpoint also uses
|
||
Bearer auth but keeps both betas (it needs the 1M beta for 1M context).
|
||
|
||
The ``context-1m-2025-08-07`` beta is not sent to native Anthropic by
|
||
default because some subscriptions reject it. Add it only for endpoint
|
||
families that still require it for 1M context, currently Microsoft Foundry.
|
||
Bedrock uses its own client helper below and opts in explicitly.
|
||
|
||
``drop_context_1m_beta=True`` strips the 1M-context beta from any path that
|
||
would otherwise include it after a subscription/endpoint rejects the beta.
|
||
"""
|
||
betas = list(_COMMON_BETAS)
|
||
if _base_url_needs_context_1m_beta(base_url) and not drop_context_1m_beta:
|
||
betas.append(_CONTEXT_1M_BETA)
|
||
if _is_minimax_anthropic_endpoint(base_url):
|
||
_stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA}
|
||
return [b for b in betas if b not in _stripped]
|
||
if drop_context_1m_beta:
|
||
return [b for b in betas if b != _CONTEXT_1M_BETA]
|
||
return betas
|
||
|
||
def _is_bedrock_model_id(model: str) -> bool:
|
||
"""Detect AWS Bedrock model IDs that use dots as namespace separators.
|
||
|
||
Bedrock model IDs come in two forms:
|
||
- Bare: ``anthropic.claude-opus-4-7``
|
||
- Regional (inference profiles): ``us.anthropic.claude-sonnet-4-5-v1:0``
|
||
|
||
In both cases the dots separate namespace components, not version
|
||
numbers, and must be preserved verbatim for the Bedrock API.
|
||
"""
|
||
lower = model.lower()
|
||
# Regional inference-profile prefixes
|
||
if any(lower.startswith(p) for p in ("global.", "us.", "eu.", "ap.", "jp.")):
|
||
return True
|
||
# Bare Bedrock model IDs: provider.model-family
|
||
if lower.startswith("anthropic."):
|
||
return True
|
||
return False
|
||
|
||
def normalize_model_name(model: str, preserve_dots: bool = False) -> str:
|
||
"""Normalize a model name for the Anthropic API.
|
||
|
||
- Strips 'anthropic/' prefix (OpenRouter format, case-insensitive)
|
||
- Converts dots to hyphens in version numbers (OpenRouter uses dots,
|
||
Anthropic uses hyphens: claude-opus-4.6 → claude-opus-4-6), unless
|
||
preserve_dots is True (e.g. for Alibaba/DashScope: qwen3.5-plus).
|
||
- Preserves Bedrock model IDs (``anthropic.claude-opus-4-7``) and
|
||
regional inference profiles (``us.anthropic.claude-*``) whose dots
|
||
are namespace separators, not version separators.
|
||
"""
|
||
lower = model.lower()
|
||
if lower.startswith("anthropic/"):
|
||
model = model[len("anthropic/"):]
|
||
if not preserve_dots:
|
||
# Bedrock model IDs use dots as namespace separators
|
||
# (e.g. "anthropic.claude-opus-4-7", "us.anthropic.claude-*").
|
||
# These must not be converted to hyphens. See issue #12295.
|
||
if _is_bedrock_model_id(model):
|
||
return model
|
||
# Only convert dots to hyphens for Anthropic/Claude models.
|
||
# Non-Anthropic models (gpt-5.4, gemini-2.5, etc.) use dots
|
||
# as part of their canonical names. See issue #17171.
|
||
_lower = model.lower()
|
||
if _lower.startswith("claude-") or _lower.startswith("anthropic/"):
|
||
model = model.replace(".", "-")
|
||
return model
|
||
|
||
def _sanitize_tool_id(tool_id: str) -> str:
|
||
"""Sanitize a tool call ID for the Anthropic API.
|
||
|
||
Anthropic requires IDs matching [a-zA-Z0-9_-]. Replace invalid
|
||
characters with underscores and ensure non-empty.
|
||
"""
|
||
import re
|
||
if not tool_id:
|
||
return "tool_0"
|
||
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id)
|
||
return sanitized or "tool_0"
|
||
|
||
def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]:
|
||
"""Normalize tool schemas before sending them to Anthropic.
|
||
|
||
Anthropic's tool schema validator rejects nullable unions such as
|
||
``anyOf: [{"type": "string"}, {"type": "null"}]`` that Pydantic/MCP
|
||
commonly emits for optional fields. Tool optionality is represented by
|
||
the parent ``required`` array, so we delegate to the shared
|
||
``strip_nullable_unions`` helper to collapse nullable unions to the
|
||
non-null branch while preserving metadata like description/default.
|
||
|
||
``keep_nullable_hint=False`` because the Anthropic validator does not
|
||
recognize the OpenAPI-style ``nullable: true`` extension and strict
|
||
schema-to-grammar converters may reject unknown keywords.
|
||
|
||
Top-level ``oneOf``/``allOf``/``anyOf`` are also stripped here: the
|
||
Anthropic API rejects union keywords at the schema root with a generic
|
||
HTTP 400. Several upstream and plugin tools ship schemas with one of
|
||
these keywords at the top level (commonly for Pydantic discriminated
|
||
unions). If we land here with those keywords still present after
|
||
nullable-union stripping, drop them and fall back to a plain object
|
||
schema so the tool still validates at the Anthropic boundary.
|
||
"""
|
||
if not schema:
|
||
return {"type": "object", "properties": {}}
|
||
|
||
from tools.schema_sanitizer import strip_nullable_unions
|
||
|
||
normalized = strip_nullable_unions(schema, keep_nullable_hint=False)
|
||
if not isinstance(normalized, dict):
|
||
return {"type": "object", "properties": {}}
|
||
# Strip top-level union keywords that Anthropic's validator rejects.
|
||
banned = {"oneOf", "allOf", "anyOf"}
|
||
if banned & normalized.keys():
|
||
normalized = {k: v for k, v in normalized.items() if k not in banned}
|
||
if "type" not in normalized:
|
||
normalized["type"] = "object"
|
||
if normalized.get("type") == "object" and not isinstance(normalized.get("properties"), dict):
|
||
normalized = {**normalized, "properties": {}}
|
||
return normalized
|
||
|
||
def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
|
||
"""Convert OpenAI tool definitions to Anthropic format."""
|
||
if not tools:
|
||
return []
|
||
result = []
|
||
seen_names: set = set()
|
||
for t in tools:
|
||
fn = t.get("function", {})
|
||
name = fn.get("name", "")
|
||
# Defensive dedup: Anthropic rejects requests with duplicate tool
|
||
# names. Upstream injection paths already dedup, but this guard
|
||
# converts a hard API failure into a warning. See: #18478
|
||
if name and name in seen_names:
|
||
logger.warning(
|
||
"convert_tools_to_anthropic: duplicate tool name '%s' "
|
||
"— dropping second occurrence",
|
||
name,
|
||
)
|
||
continue
|
||
if name:
|
||
seen_names.add(name)
|
||
anthropic_tool: Dict[str, Any] = {
|
||
"name": name,
|
||
"description": fn.get("description", ""),
|
||
"input_schema": _normalize_tool_input_schema(
|
||
fn.get("parameters", {"type": "object", "properties": {}})
|
||
),
|
||
}
|
||
# Forward cache_control marker when present on the OpenAI-format
|
||
# tool dict. Anthropic's tools array supports cache_control on the
|
||
# last tool to cache the entire schema cross-session.
|
||
cache_control = t.get("cache_control")
|
||
if isinstance(cache_control, dict):
|
||
anthropic_tool["cache_control"] = dict(cache_control)
|
||
result.append(anthropic_tool)
|
||
return result
|
||
|
||
def _image_source_from_openai_url(url: str) -> Dict[str, str]:
|
||
"""Convert an OpenAI-style image URL/data URL into Anthropic image source."""
|
||
url = str(url or "").strip()
|
||
if not url:
|
||
return {"type": "url", "url": ""}
|
||
|
||
if url.startswith("data:"):
|
||
header, _, data = url.partition(",")
|
||
media_type = "image/jpeg"
|
||
if header.startswith("data:"):
|
||
mime_part = header[len("data:"):].split(";", 1)[0].strip()
|
||
if mime_part.startswith("image/"):
|
||
media_type = mime_part
|
||
return {
|
||
"type": "base64",
|
||
"media_type": media_type,
|
||
"data": data,
|
||
}
|
||
|
||
return {"type": "url", "url": url}
|
||
|
||
def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
|
||
"""Convert a single OpenAI-style content part to Anthropic format."""
|
||
if part is None:
|
||
return None
|
||
if isinstance(part, str):
|
||
return {"type": "text", "text": part}
|
||
if not isinstance(part, dict):
|
||
return {"type": "text", "text": str(part)}
|
||
|
||
ptype = part.get("type")
|
||
|
||
if ptype == "input_text":
|
||
block: Dict[str, Any] = {"type": "text", "text": part.get("text", "")}
|
||
elif ptype in {"image_url", "input_image"}:
|
||
image_value = part.get("image_url", {})
|
||
url = image_value.get("url", "") if isinstance(image_value, dict) else str(image_value or "")
|
||
block = {"type": "image", "source": _image_source_from_openai_url(url)}
|
||
else:
|
||
block = dict(part)
|
||
|
||
if isinstance(part.get("cache_control"), dict) and "cache_control" not in block:
|
||
block["cache_control"] = dict(part["cache_control"])
|
||
return block
|
||
|
||
def _to_plain_data(value: Any, *, _depth: int = 0, _path: Optional[set] = None) -> Any:
|
||
"""Recursively convert SDK objects to plain Python data structures.
|
||
|
||
Guards against circular references (``_path`` tracks ``id()`` of objects
|
||
on the *current* recursion path) and runaway depth (capped at 20 levels).
|
||
Uses path-based tracking so shared (but non-cyclic) objects referenced by
|
||
multiple siblings are converted correctly rather than being stringified.
|
||
"""
|
||
_MAX_DEPTH = 20
|
||
if _depth > _MAX_DEPTH:
|
||
return str(value)
|
||
|
||
if _path is None:
|
||
_path = set()
|
||
|
||
obj_id = id(value)
|
||
if obj_id in _path:
|
||
return str(value)
|
||
|
||
if hasattr(value, "model_dump"):
|
||
_path.add(obj_id)
|
||
result = _to_plain_data(value.model_dump(), _depth=_depth + 1, _path=_path)
|
||
_path.discard(obj_id)
|
||
return result
|
||
if isinstance(value, dict):
|
||
_path.add(obj_id)
|
||
result = {k: _to_plain_data(v, _depth=_depth + 1, _path=_path) for k, v in value.items()}
|
||
_path.discard(obj_id)
|
||
return result
|
||
if isinstance(value, (list, tuple)):
|
||
_path.add(obj_id)
|
||
result = [_to_plain_data(v, _depth=_depth + 1, _path=_path) for v in value]
|
||
_path.discard(obj_id)
|
||
return result
|
||
if hasattr(value, "__dict__"):
|
||
_path.add(obj_id)
|
||
result = {
|
||
k: _to_plain_data(v, _depth=_depth + 1, _path=_path)
|
||
for k, v in vars(value).items()
|
||
if not k.startswith("_")
|
||
}
|
||
_path.discard(obj_id)
|
||
return result
|
||
return value
|
||
|
||
def _extract_preserved_thinking_blocks(message: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||
"""Return Anthropic thinking blocks previously preserved on the message."""
|
||
raw_details = message.get("reasoning_details")
|
||
if not isinstance(raw_details, list):
|
||
return []
|
||
|
||
preserved: List[Dict[str, Any]] = []
|
||
for detail in raw_details:
|
||
if not isinstance(detail, dict):
|
||
continue
|
||
block_type = str(detail.get("type", "") or "").strip().lower()
|
||
if block_type not in {"thinking", "redacted_thinking"}:
|
||
continue
|
||
preserved.append(copy.deepcopy(detail))
|
||
return preserved
|
||
|
||
def _convert_content_to_anthropic(content: Any) -> Any:
|
||
"""Convert OpenAI-style multimodal content arrays to Anthropic blocks."""
|
||
if not isinstance(content, list):
|
||
return content
|
||
|
||
converted = []
|
||
for part in content:
|
||
block = _convert_content_part_to_anthropic(part)
|
||
if block is not None:
|
||
converted.append(block)
|
||
return converted
|
||
|
||
def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
|
||
"""Convert OpenAI-style tool-message content parts → Anthropic tool_result inner blocks.
|
||
|
||
Used for multimodal tool results (e.g. computer_use screenshots). Each
|
||
part is normalized via `_convert_content_part_to_anthropic`, then
|
||
filtered to the block types Anthropic tool_result accepts (text + image).
|
||
"""
|
||
if not isinstance(parts, list):
|
||
return []
|
||
out: List[Dict[str, Any]] = []
|
||
for part in parts:
|
||
block = _convert_content_part_to_anthropic(part)
|
||
if not block:
|
||
continue
|
||
btype = block.get("type")
|
||
if btype == "text":
|
||
text_val = block.get("text")
|
||
if isinstance(text_val, str) and text_val:
|
||
out.append({"type": "text", "text": text_val})
|
||
elif btype == "image":
|
||
src = block.get("source")
|
||
if isinstance(src, dict) and src:
|
||
out.append({"type": "image", "source": src})
|
||
return out
|
||
|
||
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Convert an assistant message to Anthropic content blocks.
|
||
|
||
Handles thinking blocks, regular content, tool calls, and
|
||
reasoning_content injection for Kimi/DeepSeek endpoints.
|
||
"""
|
||
content = m.get("content", "")
|
||
blocks = _extract_preserved_thinking_blocks(m)
|
||
if content:
|
||
if isinstance(content, list):
|
||
converted_content = _convert_content_to_anthropic(content)
|
||
if isinstance(converted_content, list):
|
||
blocks.extend(converted_content)
|
||
else:
|
||
blocks.append({"type": "text", "text": str(content)})
|
||
for tc in m.get("tool_calls", []):
|
||
if not tc or not isinstance(tc, dict):
|
||
continue
|
||
fn = tc.get("function", {})
|
||
args = fn.get("arguments", "{}")
|
||
try:
|
||
parsed_args = json.loads(args) if isinstance(args, str) else args
|
||
except (json.JSONDecodeError, ValueError):
|
||
parsed_args = {}
|
||
blocks.append({
|
||
"type": "tool_use",
|
||
"id": _sanitize_tool_id(tc.get("id", "")),
|
||
"name": fn.get("name", ""),
|
||
"input": parsed_args,
|
||
})
|
||
# Kimi's /coding endpoint (Anthropic protocol) requires assistant
|
||
# tool-call messages to carry reasoning_content when thinking is
|
||
# enabled server-side. Preserve it as a thinking block so Kimi
|
||
# can validate the message history. See hermes-agent#13848.
|
||
#
|
||
# Accept empty string "" — _copy_reasoning_content_for_api()
|
||
# injects "" as a tier-3 fallback for Kimi tool-call messages
|
||
# that had no reasoning. Kimi requires the field to exist, even
|
||
# if empty.
|
||
#
|
||
# Prepend (not append): Anthropic protocol requires thinking
|
||
# blocks before text and tool_use blocks.
|
||
#
|
||
# Guard: only add when reasoning_details didn't already contribute
|
||
# thinking blocks. On native Anthropic, reasoning_details produces
|
||
# signed thinking blocks — adding another unsigned one from
|
||
# reasoning_content would create a duplicate (same text) that gets
|
||
# downgraded to a spurious text block on the last assistant message.
|
||
reasoning_content = m.get("reasoning_content")
|
||
_already_has_thinking = any(
|
||
isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
|
||
for b in blocks
|
||
)
|
||
if isinstance(reasoning_content, str) and not _already_has_thinking:
|
||
blocks.insert(0, {"type": "thinking", "thinking": reasoning_content})
|
||
# Anthropic rejects empty assistant content
|
||
effective = blocks or content
|
||
if not effective or effective == "":
|
||
effective = [{"type": "text", "text": "(empty)"}]
|
||
return {"role": "assistant", "content": effective}
|
||
|
||
def _convert_tool_message_to_result(
|
||
result: List[Dict[str, Any]], m: Dict[str, Any]
|
||
) -> None:
|
||
"""Convert a tool message to an Anthropic tool_result, merging consecutive
|
||
results into one user message.
|
||
|
||
Mutates ``result`` in place — either appends a new user message or extends
|
||
the trailing user message's tool_result list.
|
||
"""
|
||
content = m.get("content", "")
|
||
multimodal_blocks: Optional[List[Dict[str, Any]]] = None
|
||
if isinstance(content, dict) and content.get("_multimodal"):
|
||
multimodal_blocks = _content_parts_to_anthropic_blocks(
|
||
content.get("content") or []
|
||
)
|
||
# Fallback text if the conversion produced nothing usable.
|
||
if not multimodal_blocks and content.get("text_summary"):
|
||
multimodal_blocks = [
|
||
{"type": "text", "text": str(content["text_summary"])}
|
||
]
|
||
elif isinstance(content, list):
|
||
converted = _content_parts_to_anthropic_blocks(content)
|
||
if any(b.get("type") == "image" for b in converted):
|
||
multimodal_blocks = converted
|
||
# Back-compat: some callers stash blocks under a private key.
|
||
if multimodal_blocks is None:
|
||
stashed = m.get("_anthropic_content_blocks")
|
||
if isinstance(stashed, list) and stashed:
|
||
text_content = content if isinstance(content, str) and content.strip() else None
|
||
multimodal_blocks = (
|
||
[{"type": "text", "text": text_content}] + stashed
|
||
if text_content else list(stashed)
|
||
)
|
||
|
||
if multimodal_blocks:
|
||
result_content: Any = multimodal_blocks
|
||
elif isinstance(content, str):
|
||
result_content = content
|
||
else:
|
||
result_content = json.dumps(content) if content else "(no output)"
|
||
if not result_content:
|
||
result_content = "(no output)"
|
||
tool_result = {
|
||
"type": "tool_result",
|
||
"tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")),
|
||
"content": result_content,
|
||
}
|
||
if isinstance(m.get("cache_control"), dict):
|
||
tool_result["cache_control"] = dict(m["cache_control"])
|
||
# Merge consecutive tool results into one user message
|
||
if (
|
||
result
|
||
and result[-1]["role"] == "user"
|
||
and isinstance(result[-1]["content"], list)
|
||
and result[-1]["content"]
|
||
and result[-1]["content"][0].get("type") == "tool_result"
|
||
):
|
||
result[-1]["content"].append(tool_result)
|
||
else:
|
||
result.append({"role": "user", "content": [tool_result]})
|
||
|
||
def _convert_user_message(content: Any) -> Dict[str, Any]:
|
||
"""Validate and convert a user message to anthropic format."""
|
||
if isinstance(content, list):
|
||
converted_blocks = _convert_content_to_anthropic(content)
|
||
if not converted_blocks or all(
|
||
b.get("text", "").strip() == ""
|
||
for b in converted_blocks
|
||
if isinstance(b, dict) and b.get("type") == "text"
|
||
):
|
||
converted_blocks = [{"type": "text", "text": "(empty message)"}]
|
||
return {"role": "user", "content": converted_blocks}
|
||
else:
|
||
if 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.
|
||
|
||
Context compression or session truncation can remove either side of a
|
||
tool-call pair. Anthropic rejects both orphans with HTTP 400.
|
||
|
||
Mutates ``result`` in place.
|
||
"""
|
||
# Strip orphaned tool_use blocks (no matching tool_result follows)
|
||
tool_result_ids = set()
|
||
for m in result:
|
||
if m["role"] == "user" and isinstance(m["content"], list):
|
||
for block in m["content"]:
|
||
if block.get("type") == "tool_result":
|
||
tool_result_ids.add(block.get("tool_use_id"))
|
||
for m in result:
|
||
if m["role"] == "assistant" and isinstance(m["content"], list):
|
||
m["content"] = [
|
||
b
|
||
for b in m["content"]
|
||
if b.get("type") != "tool_use" or b.get("id") in tool_result_ids
|
||
]
|
||
if not m["content"]:
|
||
m["content"] = [{"type": "text", "text": "(tool call removed)"}]
|
||
|
||
# Strip orphaned tool_result blocks (no matching tool_use precedes them)
|
||
tool_use_ids = set()
|
||
for m in result:
|
||
if m["role"] == "assistant" and isinstance(m["content"], list):
|
||
for block in m["content"]:
|
||
if block.get("type") == "tool_use":
|
||
tool_use_ids.add(block.get("id"))
|
||
for m in result:
|
||
if m["role"] == "user" and isinstance(m["content"], list):
|
||
m["content"] = [
|
||
b
|
||
for b in m["content"]
|
||
if b.get("type") != "tool_result" or b.get("tool_use_id") in tool_use_ids
|
||
]
|
||
if not m["content"]:
|
||
m["content"] = [{"type": "text", "text": "(tool result removed)"}]
|
||
|
||
def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""Merge consecutive same-role messages to enforce Anthropic alternation.
|
||
|
||
Returns a new list (caller must rebind ``result``).
|
||
"""
|
||
fixed = []
|
||
for m in result:
|
||
if fixed and fixed[-1]["role"] == m["role"]:
|
||
if m["role"] == "user":
|
||
prev_content = fixed[-1]["content"]
|
||
curr_content = m["content"]
|
||
if isinstance(prev_content, str) and isinstance(curr_content, str):
|
||
fixed[-1]["content"] = prev_content + "\n" + curr_content
|
||
elif isinstance(prev_content, list) and isinstance(curr_content, list):
|
||
fixed[-1]["content"] = prev_content + curr_content
|
||
else:
|
||
if isinstance(prev_content, str):
|
||
prev_content = [{"type": "text", "text": prev_content}]
|
||
if isinstance(curr_content, str):
|
||
curr_content = [{"type": "text", "text": curr_content}]
|
||
fixed[-1]["content"] = prev_content + curr_content
|
||
else:
|
||
# Consecutive assistant messages — merge text content.
|
||
# Drop thinking blocks from the *second* message: their
|
||
# signature was computed against a different turn boundary
|
||
# and becomes invalid once merged.
|
||
if isinstance(m["content"], list):
|
||
m["content"] = [
|
||
b for b in m["content"]
|
||
if not (isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"})
|
||
]
|
||
prev_blocks = fixed[-1]["content"]
|
||
curr_blocks = m["content"]
|
||
if isinstance(prev_blocks, list) and isinstance(curr_blocks, list):
|
||
fixed[-1]["content"] = prev_blocks + curr_blocks
|
||
elif isinstance(prev_blocks, str) and isinstance(curr_blocks, str):
|
||
fixed[-1]["content"] = prev_blocks + "\n" + curr_blocks
|
||
else:
|
||
if isinstance(prev_blocks, str):
|
||
prev_blocks = [{"type": "text", "text": prev_blocks}]
|
||
if isinstance(curr_blocks, str):
|
||
curr_blocks = [{"type": "text", "text": curr_blocks}]
|
||
fixed[-1]["content"] = prev_blocks + curr_blocks
|
||
else:
|
||
fixed.append(m)
|
||
return fixed
|
||
|
||
def _manage_thinking_signatures(
|
||
result: List[Dict[str, Any]], base_url: str | None, model: str | None
|
||
) -> None:
|
||
"""Strip or preserve thinking blocks based on endpoint type.
|
||
|
||
Anthropic signs thinking blocks against the full turn content.
|
||
Any upstream mutation (context compression, session truncation, orphan
|
||
stripping, message merging) invalidates the signature, causing HTTP 400
|
||
"Invalid signature in thinking block".
|
||
|
||
Signatures are Anthropic-proprietary. Third-party endpoints (MiniMax,
|
||
Azure AI Foundry, AWS Bedrock, self-hosted proxies) cannot validate them
|
||
and will reject them outright. Kimi's /coding and DeepSeek's /anthropic
|
||
endpoints speak the Anthropic protocol upstream but require unsigned
|
||
thinking blocks (synthesised from ``reasoning_content``) to round-trip on
|
||
replayed assistant tool-call messages. See hermes-agent#13848 (Kimi) and
|
||
hermes-agent#16748 (DeepSeek).
|
||
|
||
Mutates ``result`` in place.
|
||
"""
|
||
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
|
||
_is_third_party = _is_third_party_anthropic_endpoint(base_url)
|
||
# Kimi / DeepSeek share a contract: strip signed Anthropic blocks
|
||
# (neither upstream can validate Anthropic signatures), preserve unsigned
|
||
# ones synthesised from reasoning_content. See #13848, #16748.
|
||
_preserve_unsigned_thinking = (
|
||
_is_kimi_family_endpoint(base_url, model)
|
||
or _is_deepseek_anthropic_endpoint(base_url)
|
||
)
|
||
|
||
last_assistant_idx = None
|
||
for i in range(len(result) - 1, -1, -1):
|
||
if result[i].get("role") == "assistant":
|
||
last_assistant_idx = i
|
||
break
|
||
|
||
for idx, m in enumerate(result):
|
||
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
|
||
continue
|
||
|
||
if _preserve_unsigned_thinking:
|
||
# Kimi / DeepSeek: strip signed, preserve unsigned.
|
||
new_content = []
|
||
for b in m["content"]:
|
||
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
|
||
new_content.append(b)
|
||
continue
|
||
if b.get("signature") or b.get("data"):
|
||
# Signed (or redacted-with-data) — upstream can't validate, strip.
|
||
continue
|
||
new_content.append(b)
|
||
m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
|
||
elif _is_third_party or idx != last_assistant_idx:
|
||
# Third-party: strip ALL thinking blocks (signatures are proprietary).
|
||
# Direct Anthropic: strip from non-latest assistant messages only.
|
||
stripped = [
|
||
b for b in m["content"]
|
||
if not (isinstance(b, dict) and b.get("type") in _THINKING_TYPES)
|
||
]
|
||
m["content"] = stripped or [{"type": "text", "text": "(thinking elided)"}]
|
||
else:
|
||
# Latest assistant on direct Anthropic: keep signed, downgrade unsigned
|
||
# to text so the reasoning isn't lost.
|
||
new_content = []
|
||
for b in m["content"]:
|
||
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
|
||
new_content.append(b)
|
||
continue
|
||
if b.get("type") == "redacted_thinking":
|
||
# Redacted blocks use 'data' for the signature payload —
|
||
# drop the block when 'data' is missing (can't be validated).
|
||
if b.get("data"):
|
||
new_content.append(b)
|
||
elif b.get("signature"):
|
||
new_content.append(b)
|
||
else:
|
||
thinking_text = b.get("thinking", "")
|
||
if thinking_text:
|
||
new_content.append({"type": "text", "text": thinking_text})
|
||
m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
|
||
|
||
# Strip cache_control from any remaining thinking/redacted_thinking
|
||
# blocks — cache markers interfere with signature validation.
|
||
for b in m["content"]:
|
||
if isinstance(b, dict) and b.get("type") in _THINKING_TYPES:
|
||
b.pop("cache_control", None)
|
||
|
||
def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
|
||
"""Keep only the most recent ``_MAX_KEEP_IMAGES`` computer-use screenshots.
|
||
|
||
Base64 images cost ~1,465 tokens each and accumulate across tool calls.
|
||
Walk backward, keep the most recent N, replace older ones with a placeholder.
|
||
|
||
Mutates ``result`` in place.
|
||
"""
|
||
_MAX_KEEP_IMAGES = 3
|
||
_image_count = 0
|
||
for msg in reversed(result):
|
||
content = msg.get("content")
|
||
if not isinstance(content, list):
|
||
continue
|
||
for block in content:
|
||
if not isinstance(block, dict) or block.get("type") != "tool_result":
|
||
continue
|
||
inner = block.get("content")
|
||
if not isinstance(inner, list):
|
||
continue
|
||
has_image = any(
|
||
isinstance(b, dict) and b.get("type") == "image"
|
||
for b in inner
|
||
)
|
||
if not has_image:
|
||
continue
|
||
_image_count += 1
|
||
if _image_count > _MAX_KEEP_IMAGES:
|
||
block["content"] = [
|
||
b if b.get("type") != "image"
|
||
else {"type": "text", "text": "[screenshot removed to save context]"}
|
||
for b in inner
|
||
]
|
||
|
||
def convert_messages_to_anthropic(
|
||
messages: List[Dict],
|
||
base_url: str | None = None,
|
||
model: str | None = None,
|
||
) -> Tuple[Optional[Any], List[Dict]]:
|
||
"""Convert OpenAI-format messages to Anthropic format.
|
||
|
||
Returns (system_prompt, anthropic_messages).
|
||
System messages are extracted since Anthropic takes them as a separate param.
|
||
system_prompt is a string or list of content blocks (when cache_control present).
|
||
|
||
When *base_url* is provided and points to a third-party Anthropic-compatible
|
||
endpoint, all thinking block signatures are stripped. Signatures are
|
||
Anthropic-proprietary — third-party endpoints cannot validate them and will
|
||
reject them with HTTP 400 "Invalid signature in thinking block".
|
||
|
||
When *model* is provided and matches the Kimi / Moonshot family (or
|
||
*base_url* is a Kimi / Moonshot host), unsigned thinking blocks
|
||
synthesised from ``reasoning_content`` are preserved on replayed
|
||
assistant tool-call messages — Kimi requires the field to exist, even
|
||
if empty.
|
||
"""
|
||
system = None
|
||
result: List[Dict[str, Any]] = []
|
||
|
||
for m in messages:
|
||
role = m.get("role", "user")
|
||
content = m.get("content", "")
|
||
|
||
if role == "system":
|
||
if isinstance(content, list):
|
||
# Preserve cache_control markers on content blocks
|
||
has_cache = any(
|
||
p.get("cache_control") for p in content if isinstance(p, dict)
|
||
)
|
||
if has_cache:
|
||
system = [p for p in content if isinstance(p, dict)]
|
||
else:
|
||
system = "\n".join(
|
||
p["text"] for p in content if p.get("type") == "text"
|
||
)
|
||
else:
|
||
system = content
|
||
continue
|
||
|
||
if role == "assistant":
|
||
result.append(_convert_assistant_message(m))
|
||
continue
|
||
|
||
if role == "tool":
|
||
_convert_tool_message_to_result(result, m)
|
||
continue
|
||
|
||
# Regular user message
|
||
result.append(_convert_user_message(content))
|
||
|
||
_strip_orphaned_tool_blocks(result)
|
||
result = _merge_consecutive_roles(result)
|
||
_manage_thinking_signatures(result, base_url, model)
|
||
_evict_old_screenshots(result)
|
||
|
||
return system, result
|
||
|
||
def build_anthropic_kwargs(
|
||
model: str,
|
||
messages: List[Dict],
|
||
tools: Optional[List[Dict]],
|
||
max_tokens: Optional[int],
|
||
reasoning_config: Optional[Dict[str, Any]],
|
||
tool_choice: Optional[str] = None,
|
||
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().
|
||
|
||
Naming note — two distinct concepts, easily confused:
|
||
max_tokens = OUTPUT token cap for a single response.
|
||
Anthropic's API calls this "max_tokens" but it only
|
||
limits the *output*. Anthropic's own native SDK
|
||
renamed it "max_output_tokens" for clarity.
|
||
context_length = TOTAL context window (input tokens + output tokens).
|
||
The API enforces: input_tokens + max_tokens ≤ context_length.
|
||
Stored on the ContextCompressor; reduced on overflow errors.
|
||
|
||
When *max_tokens* is None the model's native output ceiling is used
|
||
(e.g. 128K for Opus 4.6, 64K for Sonnet 4.6).
|
||
|
||
When *context_length* is provided and the model's native output ceiling
|
||
exceeds it (e.g. a local endpoint with an 8K window), the output cap is
|
||
clamped to context_length − 1. This only kicks in for unusually small
|
||
context windows; for full-size models the native output cap is always
|
||
smaller than the context window so no clamping happens.
|
||
NOTE: this clamping does not account for prompt size — if the prompt is
|
||
large, Anthropic may still reject the request. The caller must detect
|
||
"max_tokens too large given prompt" errors and retry with a smaller cap
|
||
(see parse_available_output_tokens_from_error + _ephemeral_max_output_tokens).
|
||
|
||
When *is_oauth* is True, applies Claude Code compatibility transforms:
|
||
system prompt prefix, tool name prefixing, and prompt sanitization.
|
||
|
||
When *preserve_dots* is True, model name dots are not converted to hyphens
|
||
(for Alibaba/DashScope anthropic-compatible endpoints: qwen3.5-plus).
|
||
|
||
When *base_url* points to a third-party Anthropic-compatible endpoint,
|
||
thinking block signatures are stripped (they are Anthropic-proprietary).
|
||
|
||
When *fast_mode* is True, adds ``extra_body["speed"] = "fast"`` and the
|
||
fast-mode beta header for ~2.5x faster output throughput on Opus 4.6.
|
||
Currently only supported on native Anthropic endpoints (not third-party
|
||
compatible ones).
|
||
"""
|
||
system, anthropic_messages = convert_messages_to_anthropic(
|
||
messages, base_url=base_url, model=model
|
||
)
|
||
anthropic_tools = convert_tools_to_anthropic(tools) if tools else []
|
||
|
||
model = normalize_model_name(model, preserve_dots=preserve_dots)
|
||
# effective_max_tokens = output cap for this call (≠ total context window)
|
||
# Use the resolver helper so non-positive values (negative ints,
|
||
# fractional floats, NaN, non-numeric) fail locally with a clear error
|
||
# rather than 400-ing at the Anthropic API. See openclaw/openclaw#66664.
|
||
effective_max_tokens = _resolve_anthropic_messages_max_tokens(
|
||
max_tokens, model, context_length=context_length
|
||
)
|
||
|
||
# Clamp output cap to fit inside the total context window.
|
||
# Only matters for small custom endpoints where context_length < native
|
||
# output ceiling. For standard Anthropic models context_length (e.g.
|
||
# 200K) is always larger than the output ceiling (e.g. 128K), so this
|
||
# branch is not taken.
|
||
if context_length and effective_max_tokens > context_length:
|
||
effective_max_tokens = max(context_length - 1, 1)
|
||
|
||
# ── OAuth: Claude Code identity ──────────────────────────────────
|
||
if is_oauth:
|
||
# 1. Prepend Claude Code system prompt identity
|
||
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]
|
||
|
||
# 2. Sanitize system prompt — replace product name references
|
||
# to avoid Anthropic's server-side content filters.
|
||
for block in system:
|
||
if isinstance(block, dict) and block.get("type") == "text":
|
||
text = block.get("text", "")
|
||
text = text.replace("Hermes Agent", "Claude Code")
|
||
text = text.replace("Hermes agent", "Claude Code")
|
||
text = text.replace("hermes-agent", "claude-code")
|
||
text = text.replace("Nous Research", "Anthropic")
|
||
block["text"] = text
|
||
|
||
# 3. Prefix tool names with mcp_ (Claude Code convention)
|
||
# Skip names that already begin with the marker — native MCP server
|
||
# tools (from mcp_servers: in config.yaml) are registered under their
|
||
# full mcp_<server>_<tool> name and would double-prefix otherwise,
|
||
# breaking round-trip registry lookup in normalize_response. GH-25255.
|
||
if anthropic_tools:
|
||
for tool in anthropic_tools:
|
||
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX):
|
||
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
|
||
|
||
# 4. Prefix tool names in message history (tool_use and tool_result blocks)
|
||
for msg in anthropic_messages:
|
||
content = msg.get("content")
|
||
if isinstance(content, list):
|
||
for block in content:
|
||
if isinstance(block, dict):
|
||
if block.get("type") == "tool_use" and "name" in block:
|
||
if not block["name"].startswith(_MCP_TOOL_PREFIX):
|
||
block["name"] = _MCP_TOOL_PREFIX + block["name"]
|
||
elif block.get("type") == "tool_result" and "tool_use_id" in block:
|
||
pass # tool_result uses ID, not name
|
||
|
||
kwargs: Dict[str, Any] = {
|
||
"model": model,
|
||
"messages": anthropic_messages,
|
||
"max_tokens": effective_max_tokens,
|
||
}
|
||
|
||
if system:
|
||
kwargs["system"] = system
|
||
|
||
if anthropic_tools:
|
||
kwargs["tools"] = anthropic_tools
|
||
# Map OpenAI tool_choice to Anthropic format
|
||
if tool_choice == "auto" or tool_choice is None:
|
||
kwargs["tool_choice"] = {"type": "auto"}
|
||
elif tool_choice == "required":
|
||
kwargs["tool_choice"] = {"type": "any"}
|
||
elif tool_choice == "none":
|
||
# Anthropic has no tool_choice "none" — omit tools entirely to prevent use
|
||
kwargs.pop("tools", None)
|
||
elif isinstance(tool_choice, str):
|
||
# Specific tool name
|
||
kwargs["tool_choice"] = {"type": "tool", "name": tool_choice}
|
||
|
||
# Map reasoning_config to Anthropic's thinking parameter.
|
||
# Claude 4.6+ models use adaptive thinking + output_config.effort.
|
||
# Older models use manual thinking with budget_tokens.
|
||
# MiniMax Anthropic-compat endpoints support thinking (manual mode only,
|
||
# not adaptive). Haiku does NOT support extended thinking — skip entirely.
|
||
#
|
||
# Kimi's /coding endpoint speaks the Anthropic Messages protocol but has
|
||
# its own thinking semantics: when ``thinking.enabled`` is sent, Kimi
|
||
# validates the message history and requires every prior assistant
|
||
# tool-call message to carry OpenAI-style ``reasoning_content``. The
|
||
# Anthropic path never populates that field, and
|
||
# ``convert_messages_to_anthropic`` strips all Anthropic thinking blocks
|
||
# on third-party endpoints — so the request fails with HTTP 400
|
||
# "thinking is enabled but reasoning_content is missing in assistant
|
||
# tool call message at index N". Kimi's reasoning is driven server-side
|
||
# on the /coding route, so skip Anthropic's thinking parameter entirely
|
||
# for that host. (Kimi on chat_completions enables thinking via
|
||
# extra_body in the ChatCompletionsTransport — see #13503.)
|
||
#
|
||
# On 4.7+ the `thinking.display` field defaults to "omitted", which
|
||
# silently hides reasoning text that Hermes surfaces in its CLI. We
|
||
# request "summarized" so the reasoning blocks stay populated — matching
|
||
# 4.6 behavior and preserving the activity-feed UX during long tool runs.
|
||
_is_kimi_coding = _is_kimi_family_endpoint(base_url, model)
|
||
if reasoning_config and isinstance(reasoning_config, dict) and not _is_kimi_coding:
|
||
if reasoning_config.get("enabled") is not False and "haiku" not in model.lower():
|
||
effort = str(reasoning_config.get("effort", "medium")).lower()
|
||
budget = THINKING_BUDGET.get(effort, 8000)
|
||
if _supports_adaptive_thinking(model):
|
||
kwargs["thinking"] = {
|
||
"type": "adaptive",
|
||
"display": "summarized",
|
||
}
|
||
adaptive_effort = ADAPTIVE_EFFORT_MAP.get(effort, "medium")
|
||
# Downgrade xhigh→max on models that don't list xhigh as a
|
||
# supported level (Opus/Sonnet 4.6). Opus 4.7+ keeps xhigh.
|
||
if adaptive_effort == "xhigh" and not _supports_xhigh_effort(model):
|
||
adaptive_effort = "max"
|
||
kwargs["output_config"] = {
|
||
"effort": adaptive_effort,
|
||
}
|
||
else:
|
||
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
||
# Anthropic requires temperature=1 when thinking is enabled on older models
|
||
kwargs["temperature"] = 1
|
||
kwargs["max_tokens"] = max(effective_max_tokens, budget + 4096)
|
||
|
||
# ── Strip sampling params on 4.7+ ─────────────────────────────────
|
||
# Opus 4.7 rejects any non-default temperature/top_p/top_k with a 400.
|
||
# Callers (auxiliary_client, etc.) may set these for older models;
|
||
# drop them here as a safety net so upstream 4.6 → 4.7 migrations
|
||
# don't require coordinated edits everywhere.
|
||
if _forbids_sampling_params(model):
|
||
for _sampling_key in ("temperature", "top_p", "top_k"):
|
||
kwargs.pop(_sampling_key, None)
|
||
|
||
# ── Fast mode (Opus 4.6 only) ────────────────────────────────────
|
||
# Adds extra_body.speed="fast" + the fast-mode beta header for ~2.5x
|
||
# output speed. Per Anthropic docs, fast mode is only supported on
|
||
# Opus 4.6 — Opus 4.7 and other models 400 on the speed parameter.
|
||
# Only for native Anthropic endpoints — third-party providers would
|
||
# reject the unknown beta header and speed parameter.
|
||
if (
|
||
fast_mode
|
||
and not _is_third_party_anthropic_endpoint(base_url)
|
||
and _supports_fast_mode(model)
|
||
):
|
||
kwargs.setdefault("extra_body", {})["speed"] = "fast"
|
||
# Build extra_headers with ALL applicable betas (the per-request
|
||
# extra_headers override the client-level anthropic-beta header).
|
||
betas = list(_common_betas_for_base_url(
|
||
base_url,
|
||
drop_context_1m_beta=drop_context_1m_beta,
|
||
))
|
||
if is_oauth:
|
||
betas.extend(_OAUTH_ONLY_BETAS)
|
||
betas.append(_FAST_MODE_BETA)
|
||
kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)}
|
||
|
||
return kwargs
|