259 lines
11 KiB
Python
259 lines
11 KiB
Python
"""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.
|
|
|
|
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.
|
|
"""
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
from utils import base_url_host_matches, base_url_hostname
|
|
|
|
|
|
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")
|
|
|
|
|
|
def _is_opencode_endpoint(base_url: str | None) -> bool:
|
|
"""Return 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.
|
|
_KIMI_FAMILY_MODEL_PREFIXES = (
|
|
"kimi-", "kimi_",
|
|
"moonshot-", "moonshot_",
|
|
"k1.", "k1-",
|
|
"k2.", "k2-",
|
|
"k25", "k2.5",
|
|
"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.
|
|
_KIMI_FAMILY_EXACT_SLUGS = frozenset({"k3"})
|
|
|
|
|
|
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]
|
|
if m in _KIMI_FAMILY_EXACT_SLUGS:
|
|
return True
|
|
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 _is_nous_portal_endpoint(base_url: str | None) -> bool:
|
|
"""Return True for Nous Portal's Anthropic Messages route.
|
|
|
|
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).
|
|
"""
|
|
if base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
|
|
return True
|
|
try:
|
|
from hermes_cli.auth import _nous_inference_env_override
|
|
|
|
override = _nous_inference_env_override()
|
|
except Exception:
|
|
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.
|
|
|
|
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.
|
|
"""
|
|
if _is_nous_portal_endpoint(base_url):
|
|
return True
|
|
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"))
|
|
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
|
|
|
|
|
|
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 _is_azure_anthropic_endpoint(base_url: str | None) -> bool:
|
|
"""Return True for Azure-hosted Anthropic Messages endpoints.
|
|
|
|
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.
|
|
"""
|
|
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
|