refactor(agent/adapters): simplify anthropic adapter, message convert, endpoints (-857 LOC)

Table-driven model capability checks and beta-header assembly, shared
_cache_control_of/_block_type/_image_block_from_data_url helpers in the message
converter, extracted _apply_claude_code_identity/_base_client_kwargs.
convert_messages_to_anthropic / convert_tools_to_anthropic and request kwargs
verified byte-identical against merge-base.
This commit is contained in:
Teknium
2026-09-02 11:21:11 -07:00
parent 841d9ff4a8
commit 267cdbecf3
3 changed files with 1016 additions and 1873 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +1,58 @@
"""Endpoint-family detection for Anthropic-compatible base URLs.
Hermes talks to a dozen services that speak the Anthropic Messages API but
differ in auth style, accepted beta headers, and request quirks: MiniMax,
Kimi/Moonshot, DeepSeek, OpenCode, Azure AI Foundry, the Nous portal, Bedrock.
Every one of those differences is decided by inspecting the configured base
URL, so the predicates live together here instead of being scattered through
client construction and message conversion.
A dozen services speak the Anthropic Messages API but differ in auth style,
accepted beta headers, and request quirks (MiniMax, Kimi/Moonshot, DeepSeek,
OpenCode, Azure AI Foundry, Nous Portal, Bedrock). Every such difference is
decided from the configured base URL, so the predicates live together here.
Pure functions over a base-URL string - no I/O, no SDK, no credentials - which
is what lets both ``agent/anthropic_adapter.py`` and
``agent/anthropic_message_convert.py`` depend on this module without a cycle.
``agent.anthropic_adapter`` re-exports every name below.
Pure functions over a base-URL string - no I/O, no SDK, no credentials - so
both ``agent/anthropic_adapter.py`` and ``agent/anthropic_message_convert.py``
can depend on this module without a cycle. ``agent.anthropic_adapter``
re-exports every name below.
"""
from urllib.parse import urlparse
from utils import base_url_host_matches, base_url_hostname
_MINIMAX_ANTHROPIC_PREFIXES = ("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic")
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.
"""
"""Coerce a base URL (str or ``httpx.URL``) to a stripped string; "" when falsy."""
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.
def _normalized_lower(base_url) -> str:
"""``_normalize_base_url_text`` + rstrip("/") + lower(), the shape most predicates match on."""
return _normalize_base_url_text(base_url).rstrip("/").lower()
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.
def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
"""True for any non-anthropic.com endpoint (own API keys via x-api-key; skip OAuth detection).
No base_url means the direct Anthropic API.
"""
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
normalized = _normalized_lower(base_url)
return bool(normalized) and "anthropic.com" not in normalized
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")
"""True for Kimi's /coding endpoint, which requires a claude-code User-Agent."""
return _normalized_lower(base_url).startswith("https://api.kimi.com/coding")
def _is_opencode_endpoint(base_url: str | None) -> bool:
"""Return True for OpenCode's Zen/Go relay (opencode.ai)."""
"""True for OpenCode's Zen/Go relay (opencode.ai)."""
return base_url_host_matches(base_url or "", "opencode.ai")
# 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-...``,
# and the bare Coding Plan slug ``k3`` (plus ``k3.x``/``k3-...`` variants)
# 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.
# Model-name prefixes identifying the Kimi / Moonshot family: official slugs
# (``kimi-k2.5``, ``kimi_thinking``, ``moonshot-v1-8k``) and release lines
# (``k1.5-…``, ``k2-thinking``, ``k25-…``, ``k3.x``/``k3-…``). Matched
# case-insensitively after stripping any ``vendor/`` prefix.
_KIMI_FAMILY_MODEL_PREFIXES = (
"kimi-", "kimi_",
"moonshot-", "moonshot_",
@@ -75,9 +62,8 @@ _KIMI_FAMILY_MODEL_PREFIXES = (
"k3.", "k3-",
)
# Bare release slugs with no separator suffix (Kimi Coding Plan serves K3
# as the exact slug ``k3``). Kept exact-match so unrelated model names that
# merely start with the same characters don't get misclassified.
# Bare release slugs with no separator suffix (Kimi Coding Plan serves K3 as
# exactly ``k3``). Exact-match so unrelated names sharing the prefix don't match.
_KIMI_FAMILY_EXACT_SLUGS = frozenset({"k3"})
@@ -87,84 +73,48 @@ def _model_name_is_kimi_family(model: str | None) -> bool:
m = model.strip().lower()
if not m:
return False
# Strip vendor prefix (e.g. ``moonshotai/kimi-k2.5`` → ``kimi-k2.5``)
if "/" in m:
if "/" in m: # ``moonshotai/kimi-k2.5`` -> ``kimi-k2.5``
m = m.rsplit("/", 1)[-1]
if m in _KIMI_FAMILY_EXACT_SLUGS:
return True
return m.startswith(_KIMI_FAMILY_MODEL_PREFIXES)
return m in _KIMI_FAMILY_EXACT_SLUGS or 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.
"""True for any Kimi / Moonshot Anthropic-Messages 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.
Broader than ``_is_kimi_coding_endpoint``: also matches any api.kimi.com /
moonshot.ai / moonshot.cn host, and any endpoint (e.g. a private gateway)
whose *model* is in the Kimi family — the upstream still enforces Kimi's
thinking semantics regardless of hostname. Decides whether unsigned
reasoning_content-derived thinking blocks are preserved on replay.
"""
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):
if any(base_url_host_matches(base_url or "", d) for d in ("api.kimi.com", "moonshot.ai", "moonshot.cn")):
return True
return False
return _model_name_is_kimi_family(model)
def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool:
"""Return True for DeepSeek's Anthropic-compatible endpoint.
"""True for DeepSeek's ``/anthropic`` route.
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.
In thinking mode DeepSeek requires prior-turn ``thinking`` blocks to round-trip
("The content[].thinking in the thinking mode must be passed back to the API"),
while the generic third-party path strips them. Its blocks are unsigned, so it
gets the same strip-signed / keep-unsigned policy as Kimi. Pinned to the
``/anthropic`` path so the OpenAI-compatible base URL is not misclassified.
"""
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()
return "/anthropic" in _normalized_lower(base_url)
def _is_nous_portal_endpoint(base_url: str | None) -> bool:
"""Return True for Nous Portal's Anthropic Messages route.
"""True for Nous Portal's Anthropic Messages route (Bearer JWT, verbatim catalog
ids, native thinking-signature replay).
Portal serves its ``anthropic/*`` catalog natively at
``https://inference-api.nousresearch.com/v1/messages``. Portal-specific
behaviours key off this: Bearer JWT auth, verbatim catalog model ids,
and native thinking-signature replay.
Trusted hosts only:
1. Prod hostname ``inference-api.nousresearch.com``
2. The operator-set ``NOUS_INFERENCE_BASE_URL`` hostname (staging/preview)
Lookalikes such as ``inference-api.nousresearch.com.attacker.test`` are
rejected (hostname match, not substring).
Trusted hosts only: prod ``inference-api.nousresearch.com`` or the operator-set
``NOUS_INFERENCE_BASE_URL`` host (exact hostname equality, so neither lookalike
domains nor sibling hosts of the override match).
"""
if base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
return True
@@ -176,83 +126,54 @@ def _is_nous_portal_endpoint(base_url: str | None) -> bool:
return False
if not override:
return False
# Exact host equality (not subdomain) so the env override can't broaden
# into sibling hosts the operator did not set.
override_host = base_url_hostname(override)
return bool(override_host) and base_url_hostname(base_url or "") == override_host
def _requires_bearer_auth(base_url: str | None) -> bool:
"""Return True for Anthropic-compatible providers that require Bearer auth.
"""True for Anthropic-compatible providers that need ``Authorization: Bearer``
instead of ``x-api-key``: MiniMax, Azure AI Foundry, Palantir Foundry's LLM
proxy, CommandCode, and Nous Portal.
Some third-party /anthropic endpoints implement Anthropic's Messages API but
require Authorization: Bearer instead of Anthropic's native x-api-key header.
MiniMax's global and China Anthropic-compatible endpoints, Azure AI
Foundry's Anthropic-style endpoint, Palantir Foundry's LLM proxy, and Nous
Portal's Messages route follow this pattern.
Palantir/CommandCode use hostname matching (not substring) so e.g.
``evil.com/palantirfoundry`` paths don't trigger Bearer auth.
"""
if _is_nous_portal_endpoint(base_url):
return True
normalized = _normalize_base_url_text(base_url)
normalized = _normalized_lower(base_url)
if not normalized:
return False
normalized = normalized.rstrip("/").lower()
return (
normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic"))
normalized.startswith(_MINIMAX_ANTHROPIC_PREFIXES)
or "azure.com" in normalized
# Palantir Foundry LLM proxy (<org>.palantirfoundry.com/api/v2/llm/proxy/anthropic)
# rejects x-api-key with 401 and requires Authorization: Bearer.
# Hostname match (not substring) so e.g. evil.com/palantirfoundry
# paths don't trigger Bearer auth.
or base_url_host_matches(normalized, "palantirfoundry.com")
# CommandCode's /provider/v1/messages endpoint uses Bearer auth,
# not Anthropic's native x-api-key header. Hostname match for the
# same reason as above.
or base_url_host_matches(normalized, "api.commandcode.ai")
)
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
"""True for endpoints that still gate 1M context behind a beta (Azure)."""
return "azure.com" in _normalize_base_url_text(base_url).lower()
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")
)
"""True for MiniMax's Anthropic-compatible endpoints, which reject the
fine-grained-tool-streaming and context-1m betas (stripped even though MiniMax
also uses Bearer auth)."""
return _normalized_lower(base_url).startswith(_MINIMAX_ANTHROPIC_PREFIXES)
def _is_azure_anthropic_endpoint(base_url: str | None) -> bool:
"""Return True for Azure-hosted Anthropic Messages endpoints.
"""True for Azure-hosted Anthropic Messages endpoints serving ``/anthropic``:
modern Foundry (``*.services.ai.azure.*``) and legacy Azure OpenAI
(``*.openai.azure.*``) hosts. Opts them into ``api-version`` query plumbing.
Covers both the modern Foundry host family (``*.services.ai.azure.*``)
and the legacy Azure OpenAI host family (``*.openai.azure.*``) when
serving Anthropic's ``/anthropic`` route. Used to opt-in those hosts
to the ``api-version`` query-param plumbing required by Azure.
Intentionally avoids a finite allow-list of TLD suffixes so it works
across sovereign / private Azure clouds.
Deliberately no finite TLD allow-list, so sovereign/private clouds work.
"""
normalized = _normalize_base_url_text(base_url)
if not normalized:
return False
parsed = urlparse(normalized)
host = (parsed.hostname or "").lower().rstrip(".")
path = (parsed.path or "").lower()
host_padded = f".{host}."
is_foundry_host = ".services.ai.azure." in host_padded
is_legacy_azoai_host = ".openai.azure." in host_padded
return (is_foundry_host or is_legacy_azoai_host) and "/anthropic" in path
host_padded = f".{(parsed.hostname or '').lower().rstrip('.')}."
is_azure_host = ".services.ai.azure." in host_padded or ".openai.azure." in host_padded
return is_azure_host and "/anthropic" in (parsed.path or "").lower()

File diff suppressed because it is too large Load Diff