1354 lines
53 KiB
Python
1354 lines
53 KiB
Python
"""Anthropic Messages API adapter for Hermes Agent.
|
|
|
|
Translates between Hermes's internal OpenAI-style message format and
|
|
Anthropic's Messages API. Follows the same pattern as the codex_responses
|
|
adapter — all provider-specific logic is isolated here.
|
|
|
|
Auth supports:
|
|
- Regular API keys (sk-ant-api*) → x-api-key header
|
|
- OAuth setup-tokens (sk-ant-oat*) → Bearer auth + beta header
|
|
- Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json) → Bearer auth
|
|
"""
|
|
|
|
import copy
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import secrets
|
|
import stat
|
|
import subprocess
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from hermes_constants import get_hermes_home
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
from utils import base_url_host_matches, normalize_proxy_env_vars
|
|
|
|
# NOTE: `import anthropic` is deliberately NOT at module top — the SDK pulls
|
|
# ~220 ms of imports (anthropic.types, anthropic.lib.tools._beta_runner, etc.)
|
|
# and the 3 usage sites (build_anthropic_client, build_anthropic_bedrock_client,
|
|
# read_claude_code_credentials_from_keychain) are all on cold user-triggered
|
|
# paths. Access via the `_get_anthropic_sdk()` accessor below, which caches
|
|
# the module after the first call and returns None on ImportError.
|
|
_anthropic_sdk: Any = ... # sentinel — None means "tried and missing"
|
|
|
|
|
|
def _get_anthropic_sdk():
|
|
"""Return the ``anthropic`` SDK module, importing lazily. None if not installed."""
|
|
global _anthropic_sdk
|
|
if _anthropic_sdk is ...:
|
|
try:
|
|
import anthropic as _sdk
|
|
_anthropic_sdk = _sdk
|
|
except ImportError:
|
|
_anthropic_sdk = None
|
|
return _anthropic_sdk
|
|
|
|
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 identity — required for OAuth requests to be routed correctly.
|
|
# Without these, Anthropic's infrastructure intermittently 500s OAuth traffic.
|
|
# The version must stay reasonably current — Anthropic rejects OAuth requests
|
|
# when the spoofed user-agent version is too far behind the actual release.
|
|
_CLAUDE_CODE_VERSION_FALLBACK = "2.1.74"
|
|
_claude_code_version_cache: Optional[str] = None
|
|
|
|
|
|
def _detect_claude_code_version() -> str:
|
|
"""Detect the installed Claude Code version, fall back to a static constant.
|
|
|
|
Anthropic's OAuth infrastructure validates the user-agent version and may
|
|
reject requests with a version that's too old. Detecting dynamically means
|
|
users who keep Claude Code updated never hit stale-version 400s.
|
|
"""
|
|
import subprocess as _sp
|
|
|
|
for cmd in ("claude", "claude-code"):
|
|
try:
|
|
result = _sp.run(
|
|
[cmd, "--version"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
# Output is like "2.1.74 (Claude Code)" or just "2.1.74"
|
|
version = result.stdout.strip().split()[0]
|
|
if version and version[0].isdigit():
|
|
return version
|
|
except Exception:
|
|
pass
|
|
return _CLAUDE_CODE_VERSION_FALLBACK
|
|
|
|
|
|
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
|
|
_MCP_TOOL_PREFIX = "mcp_"
|
|
|
|
|
|
def _get_claude_code_version() -> str:
|
|
"""Lazily detect the installed Claude Code version when OAuth headers need it."""
|
|
global _claude_code_version_cache
|
|
if _claude_code_version_cache is None:
|
|
_claude_code_version_cache = _detect_claude_code_version()
|
|
return _claude_code_version_cache
|
|
|
|
|
|
def _is_oauth_token(key: str) -> bool:
|
|
"""Check if the key is an Anthropic OAuth/setup token.
|
|
|
|
Positively identifies Anthropic OAuth tokens by their key format:
|
|
- ``sk-ant-`` prefix (but NOT ``sk-ant-api``) → setup tokens, managed keys
|
|
- ``eyJ`` prefix → JWTs from the Anthropic OAuth flow
|
|
- ``cc-`` prefix → Claude Code OAuth access tokens (from CLAUDE_CODE_OAUTH_TOKEN)
|
|
|
|
Non-Anthropic keys (MiniMax, Alibaba, etc.) don't match any pattern
|
|
and correctly return False.
|
|
"""
|
|
if not key:
|
|
return False
|
|
# Regular Anthropic Console API keys — x-api-key auth, never OAuth
|
|
if key.startswith("sk-ant-api"):
|
|
return False
|
|
# Anthropic-issued tokens (setup-tokens sk-ant-oat-*, managed keys)
|
|
if key.startswith("sk-ant-"):
|
|
return True
|
|
# JWTs from Anthropic OAuth flow
|
|
if key.startswith("eyJ"):
|
|
return True
|
|
# Claude Code OAuth access tokens (opaque, from CLAUDE_CODE_OAUTH_TOKEN)
|
|
if key.startswith("cc-"):
|
|
return True
|
|
return False
|
|
|
|
|
|
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 _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, and Azure AI
|
|
Foundry's Anthropic-style endpoint follow this pattern.
|
|
"""
|
|
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
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
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 _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`.
|
|
|
|
Anthropic SDK 0.86.0 stores ``api_key`` / ``auth_token`` as static
|
|
strings; there is no callable-token contract. To get per-request
|
|
bearer refresh (Microsoft's documented Foundry pattern), we hand
|
|
the SDK a custom ``httpx.Client`` whose request event hook mints a
|
|
fresh JWT from the Entra credential chain and rewrites
|
|
``Authorization: Bearer <jwt>`` on every outbound request. The SDK
|
|
ignores its own auth logic when ``http_client`` is provided (the
|
|
hook strips any pre-set Authorization).
|
|
|
|
The placeholder ``auth_token`` is required because the SDK raises
|
|
``AnthropicError`` at construction if neither ``api_key`` nor
|
|
``auth_token`` is set — but the hook overrides it per-request so
|
|
the placeholder value never reaches Azure.
|
|
"""
|
|
_anthropic_sdk = _get_anthropic_sdk()
|
|
if _anthropic_sdk is None:
|
|
raise ImportError(
|
|
"The 'anthropic' package is required for Azure Foundry Anthropic-style "
|
|
"endpoints with Entra ID auth. Install with: pip install 'anthropic>=0.39.0'"
|
|
)
|
|
|
|
normalize_proxy_env_vars()
|
|
|
|
from httpx import Timeout
|
|
from hermes_agent_azure import build_bearer_http_client
|
|
|
|
_read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
|
|
timeout_obj = Timeout(timeout=float(_read_timeout), connect=10.0)
|
|
|
|
# Strip any trailing /v1 — the Anthropic SDK appends /v1/messages.
|
|
normalized_base_url = _normalize_base_url_text(base_url)
|
|
if normalized_base_url:
|
|
import re as _re
|
|
normalized_base_url = _re.sub(r"/v1/?$", "", normalized_base_url.rstrip("/"))
|
|
|
|
http_client = build_bearer_http_client(token_provider, timeout=timeout_obj)
|
|
|
|
kwargs = {
|
|
"timeout": timeout_obj,
|
|
"http_client": http_client,
|
|
# The SDK requires *something* for api_key/auth_token. Our
|
|
# event hook overrides Authorization per request so this value
|
|
# is never sent. The sentinel string makes accidental leaks
|
|
# diagnosable in logs.
|
|
"auth_token": "entra-id-bearer-via-http-hook",
|
|
}
|
|
|
|
if normalized_base_url:
|
|
if _is_azure_anthropic_endpoint(normalized_base_url) and "api-version" not in normalized_base_url:
|
|
kwargs["base_url"] = normalized_base_url
|
|
kwargs["default_query"] = {"api-version": "2025-04-15"}
|
|
else:
|
|
kwargs["base_url"] = normalized_base_url
|
|
|
|
common_betas = _common_betas_for_base_url(
|
|
normalized_base_url,
|
|
drop_context_1m_beta=drop_context_1m_beta,
|
|
)
|
|
if common_betas:
|
|
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
|
|
|
|
return _anthropic_sdk.Anthropic(**kwargs)
|
|
|
|
|
|
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`` accepts either:
|
|
|
|
* a static ``str`` — the historical contract for all key-based and
|
|
OAuth flows.
|
|
* a ``Callable[[], str]`` — an Entra ID bearer token provider from
|
|
:mod:`agent.azure_identity_adapter`. The Anthropic SDK itself
|
|
requires a static string, so when given a callable we construct
|
|
a custom ``httpx.Client`` with a request event hook that mints a
|
|
fresh JWT per outbound request and rewrites the ``Authorization``
|
|
header. The SDK never sees the callable directly.
|
|
|
|
If *timeout* is provided it overrides the default 900s read timeout. The
|
|
connect timeout stays at 10s. Callers pass this from the per-provider /
|
|
per-model ``request_timeout_seconds`` config so Anthropic-native and
|
|
Anthropic-compatible providers respect the same knob as OpenAI-wire
|
|
providers.
|
|
|
|
``drop_context_1m_beta=True`` strips ``context-1m-2025-08-07`` from the
|
|
client-level ``anthropic-beta`` header. Used by the reactive OAuth retry
|
|
path in ``run_agent.py`` when a subscription rejects the beta; leave at
|
|
its default on fresh clients so 1M-capable subscriptions keep the
|
|
capability.
|
|
|
|
Returns an anthropic.Anthropic instance.
|
|
"""
|
|
_anthropic_sdk = _get_anthropic_sdk()
|
|
if _anthropic_sdk is None:
|
|
raise ImportError(
|
|
"The 'anthropic' package is required for the Anthropic provider. "
|
|
"Install it with: pip install 'anthropic>=0.39.0'"
|
|
)
|
|
|
|
# Callable api_key → Entra ID bearer provider path. Delegated to a
|
|
# helper so the existing static-key code below stays unchanged.
|
|
if callable(api_key) and not isinstance(api_key, str):
|
|
return _build_anthropic_client_with_bearer_hook(
|
|
api_key, base_url, timeout,
|
|
drop_context_1m_beta=drop_context_1m_beta,
|
|
)
|
|
|
|
normalize_proxy_env_vars()
|
|
|
|
from httpx import Timeout
|
|
|
|
normalized_base_url = _normalize_base_url_text(base_url)
|
|
_read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
|
|
kwargs = {
|
|
"timeout": Timeout(timeout=float(_read_timeout), connect=10.0),
|
|
}
|
|
if normalized_base_url:
|
|
# Azure Anthropic endpoints require an ``api-version`` query parameter.
|
|
# Pass it via default_query so the SDK appends it to every request URL
|
|
# without corrupting the base_url (appending it directly produces
|
|
# malformed paths like /anthropic?api-version=.../v1/messages).
|
|
if _is_azure_anthropic_endpoint(normalized_base_url) and "api-version" not in normalized_base_url:
|
|
kwargs["base_url"] = normalized_base_url.rstrip("/")
|
|
kwargs["default_query"] = {"api-version": "2025-04-15"}
|
|
else:
|
|
kwargs["base_url"] = normalized_base_url
|
|
common_betas = _common_betas_for_base_url(
|
|
normalized_base_url,
|
|
drop_context_1m_beta=drop_context_1m_beta,
|
|
)
|
|
|
|
if _is_kimi_coding_endpoint(base_url):
|
|
# Kimi's /coding endpoint requires User-Agent: claude-code/0.1.0
|
|
# to be recognized as a valid Coding Agent. Without it, returns 403.
|
|
# Check this BEFORE _requires_bearer_auth since both match api.kimi.com/coding.
|
|
kwargs["api_key"] = api_key
|
|
kwargs["default_headers"] = {
|
|
"User-Agent": "claude-code/0.1.0",
|
|
**( {"anthropic-beta": ",".join(common_betas)} if common_betas else {} )
|
|
}
|
|
elif _requires_bearer_auth(normalized_base_url):
|
|
# Some Anthropic-compatible providers (e.g. MiniMax) expect the API key in
|
|
# Authorization: Bearer *** for regular API keys. Route those endpoints
|
|
# through auth_token so the SDK sends Bearer auth instead of x-api-key.
|
|
# Check this before OAuth token shape detection because MiniMax secrets do
|
|
# not use Anthropic's sk-ant-api prefix and would otherwise be misread as
|
|
# Anthropic OAuth/setup tokens.
|
|
kwargs["auth_token"] = api_key
|
|
if common_betas:
|
|
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
|
|
elif _is_third_party_anthropic_endpoint(base_url):
|
|
# Third-party proxies (Microsoft Foundry, AWS Bedrock, etc.) use their
|
|
# own API keys with x-api-key auth. Skip OAuth detection — their keys
|
|
# don't follow Anthropic's sk-ant-* prefix convention and would be
|
|
# misclassified as OAuth tokens.
|
|
kwargs["api_key"] = api_key
|
|
if common_betas:
|
|
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
|
|
elif _is_oauth_token(api_key):
|
|
# OAuth access token / setup-token → Bearer auth + Claude Code identity.
|
|
# Anthropic routes OAuth requests based on user-agent and headers;
|
|
# without Claude Code's fingerprint, requests get intermittent 500s.
|
|
all_betas = common_betas + _OAUTH_ONLY_BETAS
|
|
kwargs["auth_token"] = api_key
|
|
kwargs["default_headers"] = {
|
|
"anthropic-beta": ",".join(all_betas),
|
|
"user-agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
|
|
"x-app": "cli",
|
|
}
|
|
else:
|
|
# Regular API key → x-api-key header + common betas
|
|
kwargs["api_key"] = api_key
|
|
if common_betas:
|
|
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
|
|
|
|
return _anthropic_sdk.Anthropic(**kwargs)
|
|
|
|
|
|
def build_anthropic_bedrock_client(region: str):
|
|
"""Create an AnthropicBedrock client for Bedrock Claude models.
|
|
|
|
Uses the Anthropic SDK's native Bedrock adapter, which provides full
|
|
Claude feature parity: prompt caching, thinking budgets, adaptive
|
|
thinking, fast mode — features not available via the Converse API.
|
|
|
|
Attaches the common Anthropic beta headers as client-level defaults so
|
|
that Bedrock-hosted Claude models get the same enhanced features as
|
|
native Anthropic. The ``context-1m-2025-08-07`` beta in particular
|
|
unlocks the 1M context window for Opus 4.6/4.7 on Bedrock — without
|
|
it, Bedrock caps these models at 200K even though the Anthropic API
|
|
serves them with 1M natively.
|
|
|
|
Auth uses the boto3 default credential chain (IAM roles, SSO, env vars).
|
|
"""
|
|
_anthropic_sdk = _get_anthropic_sdk()
|
|
if _anthropic_sdk is None:
|
|
raise ImportError(
|
|
"The 'anthropic' package is required for the Bedrock provider. "
|
|
"Install it with: pip install 'anthropic>=0.39.0'"
|
|
)
|
|
if not hasattr(_anthropic_sdk, "AnthropicBedrock"):
|
|
raise ImportError(
|
|
"anthropic.AnthropicBedrock not available. "
|
|
"Upgrade with: pip install 'anthropic>=0.39.0'"
|
|
)
|
|
from httpx import Timeout
|
|
|
|
return _anthropic_sdk.AnthropicBedrock(
|
|
aws_region=region,
|
|
timeout=Timeout(timeout=900.0, connect=10.0),
|
|
default_headers={"anthropic-beta": ",".join([*_COMMON_BETAS, _CONTEXT_1M_BETA])},
|
|
)
|
|
|
|
|
|
def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
|
|
"""Read Claude Code OAuth credentials from the macOS Keychain.
|
|
|
|
Claude Code >=2.1.114 stores credentials in the macOS Keychain under the
|
|
service name "Claude Code-credentials" rather than (or in addition to)
|
|
the JSON file at ~/.claude/.credentials.json.
|
|
|
|
The password field contains a JSON string with the same claudeAiOauth
|
|
structure as the JSON file.
|
|
|
|
Returns dict with {accessToken, refreshToken?, expiresAt?} or None.
|
|
"""
|
|
if platform.system() != "Darwin":
|
|
return None
|
|
|
|
try:
|
|
# Read the "Claude Code-credentials" generic password entry
|
|
result = subprocess.run(
|
|
["security", "find-generic-password",
|
|
"-s", "Claude Code-credentials",
|
|
"-w"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
logger.debug("Keychain: security command not available or timed out")
|
|
return None
|
|
|
|
if result.returncode != 0:
|
|
logger.debug("Keychain: no entry found for 'Claude Code-credentials'")
|
|
return None
|
|
|
|
raw = result.stdout.strip()
|
|
if not raw:
|
|
return None
|
|
|
|
try:
|
|
data = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
logger.debug("Keychain: credentials payload is not valid JSON")
|
|
return None
|
|
|
|
oauth_data = data.get("claudeAiOauth")
|
|
if oauth_data and isinstance(oauth_data, dict):
|
|
access_token = oauth_data.get("accessToken", "")
|
|
if access_token:
|
|
return {
|
|
"accessToken": access_token,
|
|
"refreshToken": oauth_data.get("refreshToken", ""),
|
|
"expiresAt": oauth_data.get("expiresAt", 0),
|
|
"source": "macos_keychain",
|
|
}
|
|
|
|
return None
|
|
|
|
|
|
def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
|
|
"""Read refreshable Claude Code OAuth credentials.
|
|
|
|
Checks two sources in order:
|
|
1. macOS Keychain (Darwin only) — "Claude Code-credentials" entry
|
|
2. ~/.claude/.credentials.json file
|
|
|
|
This intentionally excludes ~/.claude.json primaryApiKey. Opencode's
|
|
subscription flow is OAuth/setup-token based with refreshable credentials,
|
|
and native direct Anthropic provider usage should follow that path rather
|
|
than auto-detecting Claude's first-party managed key.
|
|
|
|
Returns dict with {accessToken, refreshToken?, expiresAt?} or None.
|
|
"""
|
|
# Try macOS Keychain first (covers Claude Code >=2.1.114)
|
|
kc_creds = _read_claude_code_credentials_from_keychain()
|
|
if kc_creds:
|
|
return kc_creds
|
|
|
|
# Fall back to JSON file
|
|
cred_path = Path.home() / ".claude" / ".credentials.json"
|
|
if cred_path.exists():
|
|
try:
|
|
data = json.loads(cred_path.read_text(encoding="utf-8"))
|
|
oauth_data = data.get("claudeAiOauth")
|
|
if oauth_data and isinstance(oauth_data, dict):
|
|
access_token = oauth_data.get("accessToken", "")
|
|
if access_token:
|
|
return {
|
|
"accessToken": access_token,
|
|
"refreshToken": oauth_data.get("refreshToken", ""),
|
|
"expiresAt": oauth_data.get("expiresAt", 0),
|
|
"source": "claude_code_credentials_file",
|
|
}
|
|
except (json.JSONDecodeError, OSError, IOError) as e:
|
|
logger.debug("Failed to read ~/.claude/.credentials.json: %s", e)
|
|
|
|
return None
|
|
|
|
|
|
def read_claude_managed_key() -> Optional[str]:
|
|
"""Read Claude's native managed key from ~/.claude.json for diagnostics only."""
|
|
claude_json = Path.home() / ".claude.json"
|
|
if claude_json.exists():
|
|
try:
|
|
data = json.loads(claude_json.read_text(encoding="utf-8"))
|
|
primary_key = data.get("primaryApiKey", "")
|
|
if isinstance(primary_key, str) and primary_key.strip():
|
|
return primary_key.strip()
|
|
except (json.JSONDecodeError, OSError, IOError) as e:
|
|
logger.debug("Failed to read ~/.claude.json: %s", e)
|
|
return None
|
|
|
|
|
|
def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
|
|
"""Check if Claude Code credentials have a non-expired access token."""
|
|
import time
|
|
|
|
expires_at = creds.get("expiresAt", 0)
|
|
if not expires_at:
|
|
# No expiry set (managed keys) — valid if token is present
|
|
return bool(creds.get("accessToken"))
|
|
|
|
# expiresAt is in milliseconds since epoch
|
|
now_ms = int(time.time() * 1000)
|
|
# Allow 60 seconds of buffer
|
|
return now_ms < (expires_at - 60_000)
|
|
|
|
|
|
def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False) -> Dict[str, Any]:
|
|
"""Refresh an Anthropic OAuth token without mutating local credential files."""
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
if not refresh_token:
|
|
raise ValueError("refresh_token is required")
|
|
|
|
client_id = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
if use_json:
|
|
data = json.dumps({
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": refresh_token,
|
|
"client_id": client_id,
|
|
}).encode()
|
|
content_type = "application/json"
|
|
else:
|
|
data = urllib.parse.urlencode({
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": refresh_token,
|
|
"client_id": client_id,
|
|
}).encode()
|
|
content_type = "application/x-www-form-urlencoded"
|
|
|
|
token_endpoints = [
|
|
"https://platform.claude.com/v1/oauth/token",
|
|
"https://console.anthropic.com/v1/oauth/token",
|
|
]
|
|
last_error = None
|
|
for endpoint in token_endpoints:
|
|
req = urllib.request.Request(
|
|
endpoint,
|
|
data=data,
|
|
headers={
|
|
"Content-Type": content_type,
|
|
"User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
|
|
},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
result = json.loads(resp.read().decode())
|
|
except Exception as exc:
|
|
last_error = exc
|
|
logger.debug("Anthropic token refresh failed at %s: %s", endpoint, exc)
|
|
continue
|
|
|
|
access_token = result.get("access_token", "")
|
|
if not access_token:
|
|
raise ValueError("Anthropic refresh response was missing access_token")
|
|
next_refresh = result.get("refresh_token", refresh_token)
|
|
expires_in = result.get("expires_in", 3600)
|
|
return {
|
|
"access_token": access_token,
|
|
"refresh_token": next_refresh,
|
|
"expires_at_ms": int(time.time() * 1000) + (expires_in * 1000),
|
|
}
|
|
|
|
if last_error is not None:
|
|
raise last_error
|
|
raise ValueError("Anthropic token refresh failed")
|
|
|
|
|
|
def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
|
|
"""Attempt to refresh an expired Claude Code OAuth token."""
|
|
refresh_token = creds.get("refreshToken", "")
|
|
if not refresh_token:
|
|
logger.debug("No refresh token available — cannot refresh")
|
|
return None
|
|
|
|
try:
|
|
refreshed = refresh_anthropic_oauth_pure(refresh_token, use_json=False)
|
|
_write_claude_code_credentials(
|
|
refreshed["access_token"],
|
|
refreshed["refresh_token"],
|
|
refreshed["expires_at_ms"],
|
|
)
|
|
logger.debug("Successfully refreshed Claude Code OAuth token")
|
|
return refreshed["access_token"]
|
|
except Exception as e:
|
|
logger.debug("Failed to refresh Claude Code token: %s", e)
|
|
return None
|
|
|
|
|
|
def _write_claude_code_credentials(
|
|
access_token: str,
|
|
refresh_token: str,
|
|
expires_at_ms: int,
|
|
*,
|
|
scopes: Optional[list] = None,
|
|
) -> None:
|
|
"""Write refreshed credentials back to ~/.claude/.credentials.json.
|
|
|
|
The optional *scopes* list (e.g. ``["user:inference", "user:profile", ...]``)
|
|
is persisted so that Claude Code's own auth check recognises the credential
|
|
as valid. Claude Code >=2.1.81 gates on the presence of ``"user:inference"``
|
|
in the stored scopes before it will use the token.
|
|
"""
|
|
cred_path = Path.home() / ".claude" / ".credentials.json"
|
|
try:
|
|
# Read existing file to preserve other fields
|
|
existing = {}
|
|
if cred_path.exists():
|
|
existing = json.loads(cred_path.read_text(encoding="utf-8"))
|
|
|
|
oauth_data: Dict[str, Any] = {
|
|
"accessToken": access_token,
|
|
"refreshToken": refresh_token,
|
|
"expiresAt": expires_at_ms,
|
|
}
|
|
if scopes is not None:
|
|
oauth_data["scopes"] = scopes
|
|
elif "claudeAiOauth" in existing and "scopes" in existing["claudeAiOauth"]:
|
|
# Preserve previously-stored scopes when the refresh response
|
|
# does not include a scope field.
|
|
oauth_data["scopes"] = existing["claudeAiOauth"]["scopes"]
|
|
|
|
existing["claudeAiOauth"] = oauth_data
|
|
|
|
cred_path.parent.mkdir(parents=True, exist_ok=True)
|
|
# Per-process random suffix avoids collisions between concurrent
|
|
# writers and stale leftovers from a prior crashed write.
|
|
_tmp_cred = cred_path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
|
|
try:
|
|
# Create the temp file atomically at 0o600. The previous
|
|
# write_text + post-replace chmod opened a TOCTOU window where
|
|
# both the temp file and the destination briefly inherited the
|
|
# process umask (commonly 0o644 = world-readable), exposing
|
|
# Claude Code OAuth tokens to other local users between create
|
|
# and chmod. Mirrors agent/google_oauth.py (#19673) and
|
|
# tools/mcp_oauth.py (#21148). Parent dir (~/.claude/) is
|
|
# owned by Claude Code itself, so we leave its mode alone.
|
|
fd = os.open(
|
|
str(_tmp_cred),
|
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
stat.S_IRUSR | stat.S_IWUSR,
|
|
)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
json.dump(existing, fh, indent=2)
|
|
fh.flush()
|
|
os.fsync(fh.fileno())
|
|
os.replace(_tmp_cred, cred_path)
|
|
except OSError:
|
|
try:
|
|
_tmp_cred.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
except (OSError, IOError) as e:
|
|
logger.debug("Failed to write refreshed credentials: %s", e)
|
|
|
|
|
|
def _resolve_claude_code_token_from_credentials(creds: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
|
"""Resolve a token from Claude Code credential files, refreshing if needed."""
|
|
creds = creds or read_claude_code_credentials()
|
|
if creds and is_claude_code_token_valid(creds):
|
|
logger.debug("Using Claude Code credentials (auto-detected)")
|
|
return creds["accessToken"]
|
|
if creds:
|
|
logger.debug("Claude Code credentials expired — attempting refresh")
|
|
refreshed = _refresh_oauth_token(creds)
|
|
if refreshed:
|
|
return refreshed
|
|
logger.debug("Token refresh failed — re-run 'claude setup-token' to reauthenticate")
|
|
return None
|
|
|
|
|
|
def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
"""Prefer Claude Code creds when a persisted env OAuth token would shadow refresh.
|
|
|
|
Hermes historically persisted setup tokens into ANTHROPIC_TOKEN. That makes
|
|
later refresh impossible because the static env token wins before we ever
|
|
inspect Claude Code's refreshable credential file. If we have a refreshable
|
|
Claude Code credential record, prefer it over the static env OAuth token.
|
|
"""
|
|
if not env_token or not _is_oauth_token(env_token) or not isinstance(creds, dict):
|
|
return None
|
|
if not creds.get("refreshToken"):
|
|
return None
|
|
|
|
resolved = _resolve_claude_code_token_from_credentials(creds)
|
|
if resolved and resolved != env_token:
|
|
logger.debug(
|
|
"Preferring Claude Code credential file over static env OAuth token so refresh can proceed"
|
|
)
|
|
return resolved
|
|
return None
|
|
|
|
|
|
def resolve_anthropic_token() -> Optional[str]:
|
|
"""Resolve an Anthropic token from all available sources.
|
|
|
|
Priority:
|
|
1. ANTHROPIC_TOKEN env var (OAuth/setup token saved by Hermes)
|
|
2. CLAUDE_CODE_OAUTH_TOKEN env var
|
|
3. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)
|
|
— with automatic refresh if expired and a refresh token is available
|
|
4. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback)
|
|
|
|
Returns the token string or None.
|
|
"""
|
|
creds = read_claude_code_credentials()
|
|
|
|
# 1. Hermes-managed OAuth/setup token env var
|
|
token = os.getenv("ANTHROPIC_TOKEN", "").strip()
|
|
if token:
|
|
preferred = _prefer_refreshable_claude_code_token(token, creds)
|
|
if preferred:
|
|
return preferred
|
|
return token
|
|
|
|
# 2. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens)
|
|
cc_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
|
|
if cc_token:
|
|
preferred = _prefer_refreshable_claude_code_token(cc_token, creds)
|
|
if preferred:
|
|
return preferred
|
|
return cc_token
|
|
|
|
# 3. Claude Code credential file
|
|
resolved_claude_token = _resolve_claude_code_token_from_credentials(creds)
|
|
if resolved_claude_token:
|
|
return resolved_claude_token
|
|
|
|
# 4. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
|
|
# This remains as a compatibility fallback for pre-migration Hermes configs.
|
|
api_key = os.getenv("ANTHROPIC_API_KEY", "").strip()
|
|
if api_key:
|
|
return api_key
|
|
|
|
return None
|
|
|
|
|
|
def run_oauth_setup_token() -> Optional[str]:
|
|
"""Run 'claude setup-token' interactively and return the resulting token.
|
|
|
|
Checks multiple sources after the subprocess completes:
|
|
1. Claude Code credential files (may be written by the subprocess)
|
|
2. CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_TOKEN env vars
|
|
|
|
Returns the token string, or None if no credentials were obtained.
|
|
Raises FileNotFoundError if the 'claude' CLI is not installed.
|
|
"""
|
|
import shutil
|
|
import subprocess
|
|
|
|
claude_path = shutil.which("claude")
|
|
if not claude_path:
|
|
raise FileNotFoundError(
|
|
"The 'claude' CLI is not installed. "
|
|
"Install it with: npm install -g @anthropic-ai/claude-code"
|
|
)
|
|
|
|
# Run interactively — stdin/stdout/stderr inherited so user can interact
|
|
try:
|
|
subprocess.run([claude_path, "setup-token"])
|
|
except (KeyboardInterrupt, EOFError):
|
|
return None
|
|
|
|
# Check if credentials were saved to Claude Code's config files
|
|
creds = read_claude_code_credentials()
|
|
if creds and is_claude_code_token_valid(creds):
|
|
return creds["accessToken"]
|
|
|
|
# Check env vars that may have been set
|
|
for env_var in ("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_TOKEN"):
|
|
val = os.getenv(env_var, "").strip()
|
|
if val:
|
|
return val
|
|
|
|
return None
|
|
|
|
|
|
# ── Hermes-native PKCE OAuth flow ────────────────────────────────────────
|
|
# Mirrors the flow used by Claude Code, pi-ai, and OpenCode.
|
|
# Stores credentials in ~/.hermes/.anthropic_oauth.json (our own file).
|
|
|
|
_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
|
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
|
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
|
|
_HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json"
|
|
|
|
|
|
def _generate_pkce() -> tuple:
|
|
"""Generate PKCE code_verifier and code_challenge (S256)."""
|
|
import base64
|
|
import hashlib
|
|
import secrets
|
|
|
|
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
|
|
challenge = base64.urlsafe_b64encode(
|
|
hashlib.sha256(verifier.encode()).digest()
|
|
).rstrip(b"=").decode()
|
|
return verifier, challenge
|
|
|
|
|
|
def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
|
"""Run Hermes-native OAuth PKCE flow and return credential state."""
|
|
import secrets
|
|
import time
|
|
import webbrowser
|
|
|
|
verifier, challenge = _generate_pkce()
|
|
oauth_state = secrets.token_urlsafe(32)
|
|
|
|
params = {
|
|
"code": "true",
|
|
"client_id": _OAUTH_CLIENT_ID,
|
|
"response_type": "code",
|
|
"redirect_uri": _OAUTH_REDIRECT_URI,
|
|
"scope": _OAUTH_SCOPES,
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
"state": oauth_state,
|
|
}
|
|
from urllib.parse import urlencode
|
|
|
|
auth_url = f"https://claude.ai/oauth/authorize?{urlencode(params)}"
|
|
|
|
print()
|
|
print("Authorize Hermes with your Claude Pro/Max subscription.")
|
|
print()
|
|
print("╭─ Claude Pro/Max Authorization ────────────────────╮")
|
|
print("│ │")
|
|
print("│ Open this link in your browser: │")
|
|
print("╰───────────────────────────────────────────────────╯")
|
|
print()
|
|
print(f" {auth_url}")
|
|
print()
|
|
|
|
try:
|
|
webbrowser.open(auth_url)
|
|
print(" (Browser opened automatically)")
|
|
except Exception:
|
|
pass
|
|
|
|
print()
|
|
print("After authorizing, you'll see a code. Paste it below.")
|
|
print()
|
|
try:
|
|
auth_code = input("Authorization code: ").strip()
|
|
except (KeyboardInterrupt, EOFError):
|
|
return None
|
|
|
|
if not auth_code:
|
|
print("No code entered.")
|
|
return None
|
|
|
|
splits = auth_code.split("#")
|
|
code = splits[0]
|
|
received_state = splits[1] if len(splits) > 1 else ""
|
|
|
|
# Validate state to prevent CSRF (RFC 6749 §10.12)
|
|
if received_state != oauth_state:
|
|
logger.warning("OAuth state mismatch — possible CSRF, aborting")
|
|
return None
|
|
|
|
try:
|
|
import urllib.request
|
|
|
|
exchange_data = json.dumps({
|
|
"grant_type": "authorization_code",
|
|
"client_id": _OAUTH_CLIENT_ID,
|
|
"code": code,
|
|
"state": received_state,
|
|
"redirect_uri": _OAUTH_REDIRECT_URI,
|
|
"code_verifier": verifier,
|
|
}).encode()
|
|
|
|
req = urllib.request.Request(
|
|
_OAUTH_TOKEN_URL,
|
|
data=exchange_data,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
|
|
},
|
|
method="POST",
|
|
)
|
|
|
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
result = json.loads(resp.read().decode())
|
|
except Exception as e:
|
|
print(f"Token exchange failed: {e}")
|
|
return None
|
|
|
|
access_token = result.get("access_token", "")
|
|
refresh_token = result.get("refresh_token", "")
|
|
expires_in = result.get("expires_in", 3600)
|
|
|
|
if not access_token:
|
|
print("No access token in response.")
|
|
return None
|
|
|
|
expires_at_ms = int(time.time() * 1000) + (expires_in * 1000)
|
|
return {
|
|
"access_token": access_token,
|
|
"refresh_token": refresh_token,
|
|
"expires_at_ms": expires_at_ms,
|
|
}
|
|
|
|
|
|
def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
|
|
"""Read Hermes-managed OAuth credentials from ~/.hermes/.anthropic_oauth.json."""
|
|
if _HERMES_OAUTH_FILE.exists():
|
|
try:
|
|
data = json.loads(_HERMES_OAUTH_FILE.read_text(encoding="utf-8"))
|
|
if data.get("accessToken"):
|
|
return data
|
|
except (json.JSONDecodeError, OSError, IOError) as e:
|
|
logger.debug("Failed to read Hermes OAuth credentials: %s", e)
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Message / tool / response format conversion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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
|