From ad4f39c0e74360c38450bf0cf47fe44dbdad1448 Mon Sep 17 00:00:00 2001 From: konsisumer Date: Fri, 17 Apr 2026 19:33:41 +0200 Subject: [PATCH] fix(anthropic): honor model.api_key from config.yaml (#7579) The anthropic provider paths in resolve_runtime_provider() hardcoded API key resolution to resolve_anthropic_token(), which only reads env vars and credential files. Users who configure model.api_key in config.yaml (common with self-hosted proxies like LiteLLM/OneAPI) were forced to also set ANTHROPIC_API_KEY as an environment variable, inconsistent with every other provider. Both the main anthropic branch and the explicit-override branch now read model.api_key (gated on model.provider == "anthropic" so unrelated keys don't leak) before falling back to resolve_anthropic_token(). --- hermes_cli/runtime_provider.py | 41 +++++--- .../test_runtime_provider_resolution.py | 98 +++++++++++++++++++ 2 files changed, 127 insertions(+), 12 deletions(-) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index a5c286fe011f..3532a1f0c22b 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -539,10 +539,16 @@ def _resolve_explicit_runtime( if provider == "anthropic": cfg_provider = str(model_cfg.get("provider") or "").strip().lower() cfg_base_url = "" + cfg_api_key = "" if cfg_provider == "anthropic": cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/") + for k in ("api_key", "api"): + v = model_cfg.get(k) + if isinstance(v, str) and has_usable_secret(v): + cfg_api_key = v.strip() + break base_url = explicit_base_url or cfg_base_url or "https://api.anthropic.com" - api_key = explicit_api_key + api_key = explicit_api_key or cfg_api_key if not api_key: from agent.anthropic_adapter import resolve_anthropic_token @@ -843,27 +849,38 @@ def resolve_runtime_provider( # Anthropic (native Messages API) if provider == "anthropic": - from agent.anthropic_adapter import resolve_anthropic_token - token = resolve_anthropic_token() - if not token: - raise AuthError( - "No Anthropic credentials found. Set ANTHROPIC_TOKEN or ANTHROPIC_API_KEY, " - "run 'claude setup-token', or authenticate with 'claude /login'." - ) - # Allow base URL override from config.yaml model.base_url, but only - # when the configured provider is anthropic — otherwise a non-Anthropic - # base_url (e.g. Codex endpoint) would leak into Anthropic requests. + # Allow base URL and api_key override from config.yaml, but only when + # the configured provider is anthropic — otherwise a non-Anthropic + # base_url (e.g. Codex endpoint) or unrelated api_key would leak into + # Anthropic requests. cfg_provider = str(model_cfg.get("provider") or "").strip().lower() cfg_base_url = "" + cfg_api_key = "" if cfg_provider == "anthropic": cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") + for k in ("api_key", "api"): + v = model_cfg.get(k) + if isinstance(v, str) and has_usable_secret(v): + cfg_api_key = v.strip() + break + token = cfg_api_key + source = "config" if token else "env" + if not token: + from agent.anthropic_adapter import resolve_anthropic_token + token = resolve_anthropic_token() + if not token: + raise AuthError( + "No Anthropic credentials found. Set model.api_key in config.yaml, " + "set ANTHROPIC_TOKEN or ANTHROPIC_API_KEY, " + "run 'claude setup-token', or authenticate with 'claude /login'." + ) base_url = cfg_base_url or "https://api.anthropic.com" return { "provider": "anthropic", "api_mode": "anthropic_messages", "base_url": base_url, "api_key": token, - "source": "env", + "source": source, "requested_provider": requested_provider, } diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index c7510a55b8f0..1be329aad201 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -93,6 +93,104 @@ def _unexpected_anthropic_token(): assert resolved.get("credential_pool") is None +def test_resolve_runtime_provider_anthropic_reads_config_api_key(monkeypatch): + """config.yaml model.api_key must be honored for provider: anthropic (#7579).""" + class _Pool: + def has_credentials(self): + return False + + def _unexpected_anthropic_token(): + raise AssertionError( + "resolve_anthropic_token should not be called when model.api_key is set" + ) + + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool()) + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "anthropic", + "base_url": "http://my-proxy:4000", + "api_key": "sk-my-api-key", + }, + ) + monkeypatch.setattr( + "agent.anthropic_adapter.resolve_anthropic_token", + _unexpected_anthropic_token, + ) + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + resolved = rp.resolve_runtime_provider(requested="anthropic") + + assert resolved["provider"] == "anthropic" + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["api_key"] == "sk-my-api-key" + assert resolved["base_url"] == "http://my-proxy:4000" + assert resolved["source"] == "config" + + +def test_resolve_runtime_provider_anthropic_config_api_key_ignored_when_provider_mismatch(monkeypatch): + """model.api_key must NOT leak to Anthropic when the configured provider is different.""" + class _Pool: + def has_credentials(self): + return False + + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool()) + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "openrouter", + "api_key": "sk-openrouter-key-not-for-anthropic", + }, + ) + monkeypatch.setattr( + "agent.anthropic_adapter.resolve_anthropic_token", + lambda: "env-anthropic-token", + ) + + resolved = rp.resolve_runtime_provider(requested="anthropic") + + assert resolved["api_key"] == "env-anthropic-token" + assert resolved["source"] == "env" + + +def test_resolve_runtime_provider_anthropic_explicit_base_url_uses_config_api_key(monkeypatch): + """When --base-url is passed but --api-key isn't, config.yaml api_key should be used.""" + def _unexpected_pool(provider): + raise AssertionError(f"load_pool should not be called for {provider}") + + def _unexpected_anthropic_token(): + raise AssertionError("resolve_anthropic_token should not be called") + + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "load_pool", _unexpected_pool) + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "anthropic", + "api_key": "sk-config-key", + }, + ) + monkeypatch.setattr( + "agent.anthropic_adapter.resolve_anthropic_token", + _unexpected_anthropic_token, + ) + + resolved = rp.resolve_runtime_provider( + requested="anthropic", + explicit_base_url="https://override.example.com/anthropic", + ) + + assert resolved["api_key"] == "sk-config-key" + assert resolved["base_url"] == "https://override.example.com/anthropic" + assert resolved["source"] == "explicit" + + def test_resolve_runtime_provider_falls_back_when_pool_empty(monkeypatch): class _Pool: def has_credentials(self):