diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index bb1b33fcc827c..868f91857b37c 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -246,7 +246,12 @@ def _supports_fast_mode(model: str) -> bool: # # Migration guide: remove these if you no longer support ≤4.5 models or once # Bedrock/Azure promote 1M to GA. -_COMMON_BETAS = [ +# +# These are DEFAULTS — overridable via config under ``anthropic.protocol.*`` +# (see ``_load_anthropic_protocol_config``). Direct reads of these constants +# inside this module remain valid; the config-aware resolvers ``_resolve_*_betas`` +# at the bottom of this section consult the config first. +_COMMON_BETAS_DEFAULT = [ "interleaved-thinking-2025-05-14", "fine-grained-tool-streaming-2025-05-14", "context-1m-2025-08-07", @@ -267,19 +272,125 @@ def _supports_fast_mode(model: str) -> bool: # Additional beta headers required for OAuth/subscription auth. # Matches what Claude Code (and pi-ai / OpenCode) send. -_OAUTH_ONLY_BETAS = [ +_OAUTH_ONLY_BETAS_DEFAULT = [ "claude-code-20250219", "oauth-2025-04-20", ] +# Backward-compatibility aliases — older code reads these names directly. +# Tests, in particular, patch ``_COMMON_BETAS`` and ``_OAUTH_ONLY_BETAS``. +# Keep them in sync with the defaults; new call sites should prefer the +# resolver functions. +_COMMON_BETAS = _COMMON_BETAS_DEFAULT +_OAUTH_ONLY_BETAS = _OAUTH_ONLY_BETAS_DEFAULT + # 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_USER_AGENT_TEMPLATE_DEFAULT = "claude-cli/{version} (external, cli)" _claude_code_version_cache: Optional[str] = None +def _load_anthropic_protocol_config() -> dict: + """Read ``anthropic.protocol`` config knobs with safe fallbacks. + + Centralises overrides for beta headers, Claude Code version, and user-agent + template so users can adapt to upstream Anthropic header changes without + editing source. Returns an empty dict (== all defaults) on any failure + (config missing, malformed, etc.) so this never breaks the import-time + or request-time path for users who haven't opted in. + """ + try: + from hermes_cli.config import load_config + cfg = load_config().get("anthropic") or {} + proto = cfg.get("protocol") or {} + return proto if isinstance(proto, dict) else {} + except Exception: + return {} + + +def _resolve_common_betas() -> list[str]: + """Return effective common-betas list (config override + default + extend).""" + cfg = _load_anthropic_protocol_config() + base = cfg.get("common_betas") + base = list(base) if isinstance(base, list) else list(_COMMON_BETAS_DEFAULT) + extend = cfg.get("extend_betas") + if isinstance(extend, list): + base = base + list(extend) + return base + + +def _resolve_oauth_only_betas() -> list[str]: + """Return effective OAuth-only-betas list (config override or default).""" + cfg = _load_anthropic_protocol_config() + override = cfg.get("oauth_only_betas") + return list(override) if isinstance(override, list) else list(_OAUTH_ONLY_BETAS_DEFAULT) + + +def resolve_passthrough_llm_headers(provider_name: str | None = None) -> bool: + """Read the ``passthrough_llm_headers`` config flag — narrowest scope wins. + + Resolution order: + + 1. ``providers..passthrough_llm_headers`` — per named-custom + provider entry (matches LiteLLM-style multi-deployment configs). + 2. ``model.passthrough_llm_headers`` — top-level default for the active + ``model:`` config. + 3. ``False`` — built-in default. Preserves the pre-existing safety gate + against leaking Anthropic OAuth tokens to MiniMax / Alibaba / other + Anthropic-compatible providers that don't honour them. + + When True and the resolved API key is an Anthropic OAuth token, callers + should pass ``passthrough_oauth=True`` to ``build_anthropic_client`` and + set ``is_oauth=True`` on subsequent ``build_anthropic_messages_kwargs`` + invocations so the request body, auth header, and identity headers + consistently carry the Claude Code OAuth fingerprint upstream. + """ + try: + from hermes_cli.config import load_config + cfg = load_config() + if provider_name: + providers = cfg.get("providers") or {} + if isinstance(providers, dict): + entry = providers.get(provider_name) + if isinstance(entry, dict) and "passthrough_llm_headers" in entry: + return bool(entry["passthrough_llm_headers"]) + model_cfg = cfg.get("model") or {} + if isinstance(model_cfg, dict) and "passthrough_llm_headers" in model_cfg: + return bool(model_cfg["passthrough_llm_headers"]) + except Exception: + pass + return False + + +def _resolve_claude_code_user_agent() -> str: + """Return the Claude Code user-agent string with version interpolated. + + Template is ``anthropic.protocol.user_agent`` (must contain ``{version}``) + or the built-in default. Version comes from + ``anthropic.protocol.claude_code_version`` (overrides dynamic detection). + """ + cfg = _load_anthropic_protocol_config() + version_override = cfg.get("claude_code_version") + version = ( + str(version_override) + if isinstance(version_override, str) and version_override.strip() + else _get_claude_code_version() + ) + template = cfg.get("user_agent") + if not isinstance(template, str) or not template.strip() or "{version}" not in template: + # Missing / blank / no {version} placeholder — fall back. The placeholder + # check guards against silent breakage when a misconfigured template + # would otherwise emit a static UA that drifts from the spoofed version. + template = _CLAUDE_CODE_USER_AGENT_TEMPLATE_DEFAULT + try: + return template.format(version=version) + except (KeyError, IndexError, ValueError): + return _CLAUDE_CODE_USER_AGENT_TEMPLATE_DEFAULT.format(version=version) + + def _detect_claude_code_version() -> str: """Detect the installed Claude Code version, fall back to a static constant. @@ -361,7 +472,10 @@ def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool: Third-party proxies (Azure AI 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. + detection should be skipped for these endpoints UNLESS the caller has + opted into OAuth passthrough — see ``passthrough_oauth`` in + ``build_anthropic_client`` and the ``_forces_x_api_key_auth`` carve-out + for endpoints that genuinely cannot accept OAuth (Azure, Bedrock). """ normalized = _normalize_base_url_text(base_url) if not normalized: @@ -372,6 +486,25 @@ def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool: return True # Any other endpoint is a third-party proxy +def _forces_x_api_key_auth(base_url: str | None) -> bool: + """Return True for third-party endpoints that genuinely cannot accept Anthropic OAuth. + + Azure AI Foundry and AWS Bedrock host their own Claude deployments with + their own auth schemes (Azure key + ``api-version`` query, AWS SigV4 + via boto3). They do not honour ``Authorization: Bearer `` + upstream and would 401 every request. ``passthrough_oauth=True`` does + NOT override this — these are technical incompatibilities, not policy. + + Distinguished from generic third-party proxies (LiteLLM, OpenRouter + passthrough mode, custom Anthropic-compatible gateways) which CAN + forward OAuth tokens upstream when configured to do so. + """ + normalized = (_normalize_base_url_text(base_url) or "").lower() + if not normalized: + return False + return ("azure.com" in normalized) or ("bedrock-runtime" 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) @@ -500,12 +633,13 @@ def _common_betas_for_base_url( reactive recovery loop in ``run_agent.py`` and issue-comment history on PR #17680 for the full rationale. """ + common = _resolve_common_betas() if _requires_bearer_auth(base_url): _stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA} - return [b for b in _COMMON_BETAS if b not in _stripped] + return [b for b in common if b not in _stripped] if drop_context_1m_beta: - return [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] - return _COMMON_BETAS + return [b for b in common if b != _CONTEXT_1M_BETA] + return common def build_anthropic_client( @@ -514,6 +648,7 @@ def build_anthropic_client( timeout: float = None, *, drop_context_1m_beta: bool = False, + passthrough_oauth: bool = False, ): """Create an Anthropic client, auto-detecting setup-tokens vs API keys. @@ -529,6 +664,14 @@ def build_anthropic_client( its default on fresh clients so 1M-capable subscriptions keep the capability. + ``passthrough_oauth=True`` opts a non-``anthropic.com`` URL into the + OAuth client-construction branch (Bearer auth + Claude Code identity + headers) when the supplied ``api_key`` looks like an Anthropic OAuth + token. Use when proxying Claude Code OAuth through LiteLLM, OpenRouter + passthrough, or another transparent Anthropic-compatible gateway that + forwards OAuth identity headers upstream. Azure / Bedrock are + auto-excluded regardless of this flag (see ``_forces_x_api_key_auth``). + Returns an anthropic.Anthropic instance. """ _anthropic_sdk = _get_anthropic_sdk() @@ -582,25 +725,33 @@ def build_anthropic_client( 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 (Azure AI 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): + elif _is_oauth_token(api_key) and ( + not _is_third_party_anthropic_endpoint(base_url) + or (passthrough_oauth and not _forces_x_api_key_auth(base_url)) + ): # 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 + # + # Fires for native Anthropic AND for any non-anthropic.com URL when + # the caller opts in via passthrough_oauth=True (e.g. LiteLLM proxy + # forwarding Claude Code OAuth upstream). Azure / Bedrock are + # excluded by ``_forces_x_api_key_auth`` regardless of opt-in: + # they technically cannot accept OAuth tokens. + all_betas = list(common_betas) + _resolve_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)", + "user-agent": _resolve_claude_code_user_agent(), "x-app": "cli", } + elif _is_third_party_anthropic_endpoint(base_url): + # Third-party proxies (Azure AI Foundry, AWS Bedrock, MiniMax, etc.) + # using a non-OAuth key. Use x-api-key auth — their keys don't follow + # Anthropic's sk-ant-* prefix convention. + kwargs["api_key"] = api_key + if common_betas: + kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} else: # Regular API key → x-api-key header + common betas kwargs["api_key"] = api_key @@ -642,7 +793,7 @@ def build_anthropic_bedrock_client(region: str): return _anthropic_sdk.AnthropicBedrock( aws_region=region, timeout=Timeout(timeout=900.0, connect=10.0), - default_headers={"anthropic-beta": ",".join(_COMMON_BETAS)}, + default_headers={"anthropic-beta": ",".join(_resolve_common_betas())}, ) @@ -808,7 +959,7 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False) data=data, headers={ "Content-Type": content_type, - "User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)", + "User-Agent": _resolve_claude_code_user_agent(), }, method="POST", ) @@ -1118,7 +1269,7 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]: data=exchange_data, headers={ "Content-Type": "application/json", - "User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)", + "User-Agent": _resolve_claude_code_user_agent(), }, method="POST", ) @@ -1961,7 +2112,7 @@ def build_anthropic_kwargs( drop_context_1m_beta=drop_context_1m_beta, )) if is_oauth: - betas.extend(_OAUTH_ONLY_BETAS) + betas.extend(_resolve_oauth_only_betas()) betas.append(_FAST_MODE_BETA) kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)} diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 1e3d39c7ba5e4..b88da26d7248a 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1022,7 +1022,10 @@ def _maybe_wrap_anthropic( return client_obj try: - from agent.anthropic_adapter import build_anthropic_client + from agent.anthropic_adapter import ( + build_anthropic_client, + resolve_passthrough_llm_headers, + ) except ImportError: logger.warning( "Endpoint %s speaks Anthropic Messages but the anthropic SDK is " @@ -1032,7 +1035,10 @@ def _maybe_wrap_anthropic( return client_obj try: - real_client = build_anthropic_client(api_key, base_url) + real_client = build_anthropic_client( + api_key, base_url, + passthrough_oauth=resolve_passthrough_llm_headers(), + ) except Exception as exc: logger.warning( "Failed to build Anthropic client for %s (%s) — falling back to " @@ -1533,11 +1539,20 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]: return CodexAuxiliaryClient(real_client, model), model if custom_mode == "anthropic_messages": # Third-party Anthropic-compatible gateway (MiniMax, Zhipu GLM, - # LiteLLM proxies, etc.). Must NEVER be treated as OAuth — - # Anthropic OAuth claims only apply to api.anthropic.com. + # LiteLLM proxies, etc.). OAuth-passthrough opts in for proxies + # that genuinely forward Claude Code identity headers upstream + # (e.g. LiteLLM in claude-code passthrough mode); otherwise the + # request goes out with x-api-key auth as before, preserving the + # MiniMax / Alibaba safety gate. try: - from agent.anthropic_adapter import build_anthropic_client - real_client = build_anthropic_client(custom_key, custom_base) + from agent.anthropic_adapter import ( + build_anthropic_client, + resolve_passthrough_llm_headers, + ) + real_client = build_anthropic_client( + custom_key, custom_base, + passthrough_oauth=resolve_passthrough_llm_headers(), + ) except ImportError: logger.warning( "Custom endpoint declares api_mode=anthropic_messages but the " @@ -1637,7 +1652,11 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona model = _get_aux_model_for_provider("anthropic") or "claude-haiku-4-5-20251001" logger.debug("Auxiliary client: Anthropic native (%s) at %s (oauth=%s)", model, base_url, is_oauth) try: - real_client = build_anthropic_client(token, base_url) + from agent.anthropic_adapter import resolve_passthrough_llm_headers + real_client = build_anthropic_client( + token, base_url, + passthrough_oauth=resolve_passthrough_llm_headers("anthropic"), + ) except ImportError: # The anthropic_adapter module imports fine but the SDK itself is # missing — build_anthropic_client raises ImportError at call time @@ -2377,8 +2396,14 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", # branch in _try_custom_endpoint(). See #15033. if entry_api_mode == "anthropic_messages": try: - from agent.anthropic_adapter import build_anthropic_client - real_client = build_anthropic_client(custom_key, custom_base) + from agent.anthropic_adapter import ( + build_anthropic_client, + resolve_passthrough_llm_headers, + ) + real_client = build_anthropic_client( + custom_key, custom_base, + passthrough_oauth=resolve_passthrough_llm_headers(provider), + ) except ImportError: logger.warning( "Named custom provider %r declares api_mode=" diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 963268d4ba680..5c74db8858545 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -371,6 +371,65 @@ compression: prompt_caching: cache_ttl: "5m" # use "1h" for long sessions with pauses between turns +# ============================================================================= +# OAuth passthrough through Anthropic-compatible proxies +# ============================================================================= +# Forward Claude Code OAuth identity (Authorization: Bearer + claude-cli +# user-agent + OAuth betas + x-app: cli) through ANY non-anthropic.com URL. +# Required when proxying Claude Code OAuth tokens via LiteLLM, OpenRouter +# Anthropic-passthrough, or another transparent Anthropic-compatible router +# that genuinely forwards OAuth headers upstream to api.anthropic.com. +# +# Default: false. Preserves the pre-existing safety gate against leaking +# OAuth tokens to MiniMax / Alibaba / other third-party providers that +# wouldn't honour them. Azure AI Foundry and AWS Bedrock are auto-excluded +# even with the flag enabled — they technically cannot accept OAuth Bearer. +# +# Resolution: provider-entry wins over model-section. +# model.passthrough_llm_headers — top-level default +# providers..passthrough_llm_headers — per named-custom-provider +# +# model: +# default: claude-opus-4-7 +# provider: anthropic +# base_url: https://litellm.example.com/v1/anthropic +# passthrough_llm_headers: true # forward OAuth identity to LiteLLM + +# providers: +# litellm-claude: +# api: https://litellm.example.com/v1/anthropic +# api_mode: anthropic_messages +# key_env: CLAUDE_CODE_OAUTH_TOKEN +# passthrough_llm_headers: true # narrowest scope; overrides model. + +# ============================================================================= +# Anthropic protocol overrides (advanced — rarely needed) +# ============================================================================= +# Override Anthropic beta header set, Claude Code version, or user-agent +# template. Useful when Anthropic rotates beta names, your proxy injects +# additional betas, or you need to spoof a different Claude Code release. +# +# anthropic: +# protocol: +# # Replaces the default common-betas list entirely (omit to keep defaults). +# common_betas: +# - interleaved-thinking-2025-05-14 +# - fine-grained-tool-streaming-2025-05-14 +# - context-1m-2025-08-07 +# # Replaces the default OAuth-only betas list entirely. +# oauth_only_betas: +# - claude-code-20250219 +# - oauth-2025-04-20 +# # Additive: merged into common_betas for every request. Useful when a +# # proxy requires an extra beta without disturbing the default set. +# extend_betas: [] +# # Override the Claude Code version Hermes spoofs in the user-agent. +# # Defaults to whatever `claude --version` returns, falling back to +# # the static value baked into agent/anthropic_adapter.py. +# claude_code_version: "2.1.74" +# # User-agent template ({version} expands to claude_code_version above). +# user_agent: "claude-cli/{version} (external, cli)" + # ============================================================================= # Auxiliary Models (Advanced — Experimental) # ============================================================================= diff --git a/run_agent.py b/run_agent.py index 919a5875b65ad..971a6df5b8a9e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1376,16 +1376,38 @@ def __init__( self.api_key = effective_key self._anthropic_api_key = effective_key self._anthropic_base_url = base_url - # Only mark the session as OAuth-authenticated when the token - # genuinely belongs to native Anthropic. Third-party providers - # (MiniMax, Kimi, GLM, LiteLLM proxies) that accept the - # Anthropic protocol must never trip OAuth code paths — doing - # so injects Claude-Code identity headers and system prompts - # that cause 401/403 on their endpoints. Guards #1739 and - # the third-party identity-injection bug. - from agent.anthropic_adapter import _is_oauth_token as _is_oat - self._is_anthropic_oauth = _is_oat(effective_key) if _is_native_anthropic else False - self._anthropic_client = build_anthropic_client(effective_key, base_url, timeout=_provider_timeout) + # OAuth-authentication gate. By default, only mark the session + # as OAuth-authenticated when the token genuinely belongs to + # native Anthropic — third-party providers (MiniMax, Kimi, GLM) + # that accept the Anthropic protocol must never trip OAuth code + # paths because doing so injects Claude-Code identity headers + # and system prompts that cause 401/403 on their endpoints. + # Guards #1739 and the third-party identity-injection bug. + # + # Opt-in escape hatch: ``passthrough_llm_headers`` (read from + # providers..passthrough_llm_headers OR model.passthrough_llm_headers, + # narrowest wins) tells us the proxy DOES forward OAuth identity + # headers upstream (e.g. LiteLLM in claude-code passthrough + # mode). When set, OAuth detection runs even on non-anthropic.com + # URLs. Azure / Bedrock are still excluded inside + # build_anthropic_client via _forces_x_api_key_auth. + from agent.anthropic_adapter import ( + _is_oauth_token as _is_oat, + resolve_passthrough_llm_headers, + ) + _passthrough = resolve_passthrough_llm_headers(self.provider) + self._passthrough_llm_headers = _passthrough + self._is_anthropic_oauth = ( + _is_oat(effective_key) + if (_is_native_anthropic or _passthrough) + else False + ) + self._anthropic_client = build_anthropic_client( + effective_key, + base_url, + timeout=_provider_timeout, + passthrough_oauth=_passthrough, + ) # No OpenAI client needed for Anthropic mode self.client = None self._client_kwargs = {} @@ -2224,6 +2246,7 @@ def __init__( "anthropic_api_key": self._anthropic_api_key, "anthropic_base_url": self._anthropic_base_url, "is_anthropic_oauth": self._is_anthropic_oauth, + "passthrough_llm_headers": getattr(self, "_passthrough_llm_headers", False), }) def _ensure_db_session(self) -> None: @@ -2373,6 +2396,7 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod build_anthropic_client, resolve_anthropic_token, _is_oauth_token, + resolve_passthrough_llm_headers, ) # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic. # Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own @@ -2382,11 +2406,18 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod self.api_key = effective_key self._anthropic_api_key = effective_key self._anthropic_base_url = base_url or getattr(self, "_anthropic_base_url", None) + _passthrough = resolve_passthrough_llm_headers(new_provider) + self._passthrough_llm_headers = _passthrough self._anthropic_client = build_anthropic_client( effective_key, self._anthropic_base_url, timeout=get_provider_request_timeout(self.provider, self.model), + passthrough_oauth=_passthrough, + ) + self._is_anthropic_oauth = ( + _is_oauth_token(effective_key) + if (_is_native_anthropic or _passthrough) + else False ) - self._is_anthropic_oauth = _is_oauth_token(effective_key) if _is_native_anthropic else False self.client = None self._client_kwargs = {} else: @@ -2474,6 +2505,7 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod "anthropic_api_key": self._anthropic_api_key, "anthropic_base_url": self._anthropic_base_url, "is_anthropic_oauth": self._is_anthropic_oauth, + "passthrough_llm_headers": getattr(self, "_passthrough_llm_headers", False), }) # ── Reset fallback state ── @@ -6241,11 +6273,13 @@ def _try_refresh_anthropic_client_credentials(self) -> bool: except Exception: pass + _passthrough = bool(getattr(self, "_passthrough_llm_headers", False)) try: self._anthropic_client = build_anthropic_client( new_token, getattr(self, "_anthropic_base_url", None), timeout=get_provider_request_timeout(self.provider, self.model), + passthrough_oauth=_passthrough, ) except Exception as exc: logger.warning("Failed to rebuild Anthropic client after credential refresh: %s", exc) @@ -6253,11 +6287,15 @@ def _try_refresh_anthropic_client_credentials(self) -> bool: self._anthropic_api_key = new_token # Update OAuth flag — token type may have changed (API key ↔ OAuth). - # Only treat as OAuth on native Anthropic; third-party endpoints using - # the Anthropic protocol must not trip OAuth paths (#1739 & third-party - # identity-injection guard). + # OAuth is honoured on native Anthropic OR when passthrough_llm_headers + # opts a proxy in. Other endpoints (MiniMax, Alibaba) must not trip + # OAuth paths (#1739 & third-party identity-injection guard). from agent.anthropic_adapter import _is_oauth_token - self._is_anthropic_oauth = _is_oauth_token(new_token) if self.provider == "anthropic" else False + self._is_anthropic_oauth = ( + _is_oauth_token(new_token) + if (self.provider == "anthropic" or _passthrough) + else False + ) return True def _apply_client_headers_for_base_url(self, base_url: str) -> None: @@ -6309,13 +6347,19 @@ def _swap_credential(self, entry) -> None: except Exception: pass + _passthrough = bool(getattr(self, "_passthrough_llm_headers", False)) self._anthropic_api_key = runtime_key self._anthropic_base_url = runtime_base self._anthropic_client = build_anthropic_client( runtime_key, runtime_base, timeout=get_provider_request_timeout(self.provider, self.model), + passthrough_oauth=_passthrough, + ) + self._is_anthropic_oauth = ( + _is_oauth_token(runtime_key) + if (self.provider == "anthropic" or _passthrough) + else False ) - self._is_anthropic_oauth = _is_oauth_token(runtime_key) if self.provider == "anthropic" else False self.api_key = runtime_key self.base_url = runtime_base return @@ -6444,6 +6488,7 @@ def _rebuild_anthropic_client(self) -> None: rebuilt client carries the reduced beta set. """ _drop_1m = bool(getattr(self, "_oauth_1m_beta_disabled", False)) + _passthrough = bool(getattr(self, "_passthrough_llm_headers", False)) if getattr(self, "provider", None) == "bedrock": from agent.anthropic_adapter import build_anthropic_bedrock_client region = getattr(self, "_bedrock_region", "us-east-1") or "us-east-1" @@ -6455,6 +6500,7 @@ def _rebuild_anthropic_client(self) -> None: getattr(self, "_anthropic_base_url", None), timeout=get_provider_request_timeout(self.provider, self.model), drop_context_1m_beta=_drop_1m, + passthrough_oauth=_passthrough, ) def _interruptible_api_call(self, api_kwargs: dict): @@ -7740,15 +7786,28 @@ def _try_activate_fallback(self, reason: "FailoverReason | None" = None) -> bool if fb_api_mode == "anthropic_messages": # Build native Anthropic client instead of using OpenAI client - from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token, _is_oauth_token + from agent.anthropic_adapter import ( + build_anthropic_client, + resolve_anthropic_token, + _is_oauth_token, + resolve_passthrough_llm_headers, + ) effective_key = (fb_client.api_key or resolve_anthropic_token() or "") if fb_provider == "anthropic" else (fb_client.api_key or "") self.api_key = effective_key self._anthropic_api_key = effective_key self._anthropic_base_url = fb_base_url + _passthrough = resolve_passthrough_llm_headers(fb_provider) + self._passthrough_llm_headers = _passthrough self._anthropic_client = build_anthropic_client( - effective_key, self._anthropic_base_url, timeout=_fb_timeout, + effective_key, self._anthropic_base_url, + timeout=_fb_timeout, + passthrough_oauth=_passthrough, + ) + self._is_anthropic_oauth = ( + _is_oauth_token(effective_key) + if (fb_provider == "anthropic" or _passthrough) + else False ) - self._is_anthropic_oauth = _is_oauth_token(effective_key) if fb_provider == "anthropic" else False self.client = None self._client_kwargs = {} else: @@ -7869,9 +7928,12 @@ def _restore_primary_runtime(self) -> bool: from agent.anthropic_adapter import build_anthropic_client self._anthropic_api_key = rt["anthropic_api_key"] self._anthropic_base_url = rt["anthropic_base_url"] + _passthrough = bool(rt.get("passthrough_llm_headers", getattr(self, "_passthrough_llm_headers", False))) + self._passthrough_llm_headers = _passthrough self._anthropic_client = build_anthropic_client( rt["anthropic_api_key"], rt["anthropic_base_url"], timeout=get_provider_request_timeout(self.provider, self.model), + passthrough_oauth=_passthrough, ) self._is_anthropic_oauth = rt["is_anthropic_oauth"] self.client = None @@ -7968,9 +8030,12 @@ def _try_recover_primary_transport( from agent.anthropic_adapter import build_anthropic_client self._anthropic_api_key = rt["anthropic_api_key"] self._anthropic_base_url = rt["anthropic_base_url"] + _passthrough = bool(rt.get("passthrough_llm_headers", getattr(self, "_passthrough_llm_headers", False))) + self._passthrough_llm_headers = _passthrough self._anthropic_client = build_anthropic_client( rt["anthropic_api_key"], rt["anthropic_base_url"], timeout=get_provider_request_timeout(self.provider, self.model), + passthrough_oauth=_passthrough, ) self._is_anthropic_oauth = rt["is_anthropic_oauth"] self.client = None diff --git a/tests/agent/test_anthropic_passthrough.py b/tests/agent/test_anthropic_passthrough.py new file mode 100644 index 0000000000000..70ee450e1d480 --- /dev/null +++ b/tests/agent/test_anthropic_passthrough.py @@ -0,0 +1,252 @@ +"""Tests for the OAuth-passthrough escape hatch in build_anthropic_client. + +Covers the 8-cell auth matrix: + + URL | token | passthrough | expected + ----------------------------+-------------+-------------+-------------------- + api.anthropic.com | api-key | n/a | api_key=, x-api-key + api.anthropic.com | OAuth | n/a | auth_token=, Bearer + identity + litellm/v1/anthropic | api-key | False | api_key=, x-api-key + litellm/v1/anthropic | api-key | True | api_key=, x-api-key (flag is OAuth-only) + litellm/v1/anthropic | OAuth | False | api_key=, x-api-key (current behaviour preserved) + litellm/v1/anthropic | OAuth | True | auth_token=, Bearer + identity ← THE FIX + foo.azure.com | OAuth | True | api_key=, x-api-key (Azure carve-out) + bedrock-runtime.us-east-1.. | OAuth | True | api_key=, x-api-key (Bedrock carve-out) + +Plus tests for the config-driven beta header / claude_code_version / user_agent +overrides reachable via ``anthropic.protocol.*``. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from agent.anthropic_adapter import ( + _forces_x_api_key_auth, + _resolve_claude_code_user_agent, + _resolve_common_betas, + _resolve_oauth_only_betas, + build_anthropic_client, + resolve_passthrough_llm_headers, +) + + +# Token fixtures — match the shape detection in _is_oauth_token. +_API_KEY = "sk-ant-api03-" + "x" * 60 +_OAUTH = "sk-ant-oat01-" + "x" * 60 + + +# ---------------------------------------------------------------------------- +# 8-cell auth matrix +# ---------------------------------------------------------------------------- + +class TestAuthMatrix: + """Verify the auth-construction branch picked for each (URL, token, flag) cell.""" + + @staticmethod + def _build(api_key, base_url=None, passthrough=False): + with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + mock_sdk.Anthropic = MagicMock(return_value="client") + build_anthropic_client( + api_key, base_url=base_url, passthrough_oauth=passthrough, + ) + assert mock_sdk.Anthropic.call_count == 1 + return mock_sdk.Anthropic.call_args.kwargs + + def test_native_anthropic_api_key_uses_x_api_key(self): + kwargs = self._build(_API_KEY) + assert "api_key" in kwargs and "auth_token" not in kwargs + assert kwargs["api_key"] == _API_KEY + + def test_native_anthropic_oauth_uses_bearer_with_identity(self): + kwargs = self._build(_OAUTH) + assert "auth_token" in kwargs and "api_key" not in kwargs + assert kwargs["auth_token"] == _OAUTH + headers = kwargs["default_headers"] + assert "claude-code-20250219" in headers["anthropic-beta"] + assert "oauth-2025-04-20" in headers["anthropic-beta"] + assert headers["user-agent"].startswith("claude-cli/") + assert headers["x-app"] == "cli" + + def test_litellm_api_key_passthrough_off(self): + kwargs = self._build( + _API_KEY, base_url="https://litellm.example.com/v1/anthropic", + passthrough=False, + ) + assert kwargs.get("api_key") == _API_KEY + assert "auth_token" not in kwargs + + def test_litellm_api_key_passthrough_on_unaffected(self): + # Flag is OAuth-only — non-OAuth tokens stay on x-api-key. + kwargs = self._build( + _API_KEY, base_url="https://litellm.example.com/v1/anthropic", + passthrough=True, + ) + assert kwargs.get("api_key") == _API_KEY + assert "auth_token" not in kwargs + + def test_litellm_oauth_passthrough_off_preserves_current_behaviour(self): + # This is the pre-patch "broken" behaviour — kept as default for safety. + kwargs = self._build( + _OAUTH, base_url="https://litellm.example.com/v1/anthropic", + passthrough=False, + ) + assert kwargs.get("api_key") == _OAUTH + assert "auth_token" not in kwargs + + def test_litellm_oauth_passthrough_on_emits_oauth_headers(self): + # The fix: OAuth identity flows through the proxy when opt-in is set. + kwargs = self._build( + _OAUTH, base_url="https://litellm.example.com/v1/anthropic", + passthrough=True, + ) + assert kwargs.get("auth_token") == _OAUTH + assert "api_key" not in kwargs + headers = kwargs["default_headers"] + assert "claude-code-20250219" in headers["anthropic-beta"] + assert "oauth-2025-04-20" in headers["anthropic-beta"] + assert headers["user-agent"].startswith("claude-cli/") + assert headers["x-app"] == "cli" + + def test_azure_oauth_passthrough_blocked(self): + # Azure cannot accept OAuth Bearer — carve-out wins over flag. + kwargs = self._build( + _OAUTH, base_url="https://my-deploy.azure.com/anthropic", + passthrough=True, + ) + assert kwargs.get("api_key") == _OAUTH + assert "auth_token" not in kwargs + + def test_bedrock_oauth_passthrough_blocked(self): + kwargs = self._build( + _OAUTH, + base_url="https://bedrock-runtime.us-east-1.amazonaws.com", + passthrough=True, + ) + assert kwargs.get("api_key") == _OAUTH + assert "auth_token" not in kwargs + + +# ---------------------------------------------------------------------------- +# Carve-out helper +# ---------------------------------------------------------------------------- + +class TestForcesXApiKeyAuth: + @pytest.mark.parametrize("url,expected", [ + ("https://my.azure.com/foo", True), + ("https://bedrock-runtime.us-east-1.amazonaws.com", True), + ("https://litellm.example.com/v1/anthropic", False), + ("https://api.anthropic.com", False), + ("", False), + (None, False), + ]) + def test_carveout(self, url, expected): + assert _forces_x_api_key_auth(url) is expected + + +# ---------------------------------------------------------------------------- +# Config-driven overrides +# ---------------------------------------------------------------------------- + +class TestConfigOverrides: + @staticmethod + def _patch_cfg(proto): + return patch( + "agent.anthropic_adapter._load_anthropic_protocol_config", + return_value=proto, + ) + + def test_extend_betas_appends_to_common(self): + with self._patch_cfg({"extend_betas": ["custom-beta-2026-05-01"]}): + betas = _resolve_common_betas() + assert "custom-beta-2026-05-01" in betas + # Default betas still present + assert "interleaved-thinking-2025-05-14" in betas + + def test_common_betas_override_replaces_defaults(self): + with self._patch_cfg({"common_betas": ["only-this-2030"]}): + betas = _resolve_common_betas() + assert betas == ["only-this-2030"] + + def test_oauth_only_betas_override(self): + with self._patch_cfg({"oauth_only_betas": ["custom-oauth-beta"]}): + betas = _resolve_oauth_only_betas() + assert betas == ["custom-oauth-beta"] + + def test_claude_code_version_override(self): + with self._patch_cfg({"claude_code_version": "9.9.9"}): + ua = _resolve_claude_code_user_agent() + assert "claude-cli/9.9.9" in ua + + def test_user_agent_template_override(self): + proto = { + "claude_code_version": "1.0.0", + "user_agent": "custom/{version}", + } + with self._patch_cfg(proto): + ua = _resolve_claude_code_user_agent() + assert ua == "custom/1.0.0" + + def test_user_agent_template_bad_format_falls_back(self): + # Template missing {version} placeholder — falls back to default template. + with self._patch_cfg({"user_agent": "no_placeholder"}): + ua = _resolve_claude_code_user_agent() + assert "claude-cli/" in ua + + def test_no_config_returns_defaults(self): + with self._patch_cfg({}): + common = _resolve_common_betas() + oauth = _resolve_oauth_only_betas() + assert "interleaved-thinking-2025-05-14" in common + assert "claude-code-20250219" in oauth + + +# ---------------------------------------------------------------------------- +# resolve_passthrough_llm_headers: per-provider vs model-section, narrowest wins +# ---------------------------------------------------------------------------- + +class TestResolvePassthroughLlmHeaders: + @staticmethod + def _patch_load_config(cfg): + return patch("hermes_cli.config.load_config", return_value=cfg) + + def test_default_false_when_no_config(self): + with self._patch_load_config({}): + assert resolve_passthrough_llm_headers() is False + + def test_model_section_true(self): + with self._patch_load_config({"model": {"passthrough_llm_headers": True}}): + assert resolve_passthrough_llm_headers() is True + + def test_provider_entry_true_overrides_model_false(self): + cfg = { + "model": {"passthrough_llm_headers": False}, + "providers": {"my-litellm": {"passthrough_llm_headers": True}}, + } + with self._patch_load_config(cfg): + assert resolve_passthrough_llm_headers("my-litellm") is True + # Other providers fall back to model.passthrough_llm_headers + assert resolve_passthrough_llm_headers("anthropic") is False + + def test_provider_entry_false_overrides_model_true(self): + cfg = { + "model": {"passthrough_llm_headers": True}, + "providers": {"strict-byok": {"passthrough_llm_headers": False}}, + } + with self._patch_load_config(cfg): + assert resolve_passthrough_llm_headers("strict-byok") is False + assert resolve_passthrough_llm_headers() is True + + def test_no_provider_arg_uses_model_section(self): + cfg = { + "model": {"passthrough_llm_headers": True}, + "providers": {"anthropic": {"passthrough_llm_headers": False}}, + } + with self._patch_load_config(cfg): + # No provider name passed — model-section wins. + assert resolve_passthrough_llm_headers() is True + + def test_malformed_config_returns_false(self): + # Config load raises — defensive default kicks in. + with patch("hermes_cli.config.load_config", side_effect=Exception("boom")): + assert resolve_passthrough_llm_headers() is False diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 42f1902db8613..4592f1d83c9cb 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -4261,7 +4261,8 @@ def test_try_refresh_anthropic_client_credentials_rebuilds_client(self): old_client.close.assert_called_once() rebuild.assert_called_once_with( - "sk-ant-oat01-fresh-token", "https://api.anthropic.com", timeout=None, + "sk-ant-oat01-fresh-token", "https://api.anthropic.com", + timeout=None, passthrough_oauth=False, ) assert agent._anthropic_client is new_client assert agent._anthropic_api_key == "sk-ant-oat01-fresh-token"