diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index a3a26b268d06..d040b87efd5e 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1866,9 +1866,9 @@ def resolve_runtime_provider( # Anthropic (native Messages API) if provider == "anthropic": - # 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 / api_key overrides from config.yaml model.* — but + # only when the configured provider is anthropic, otherwise a + # non-Anthropic base_url or key would leak into Anthropic requests. cfg_provider = str(model_cfg.get("provider") or "").strip().lower() cfg_base_url = "" if cfg_provider == "anthropic": @@ -1894,6 +1894,7 @@ def resolve_runtime_provider( # Azure Foundry guide and read by most Hermes-compatible importers). # Matches the config.yaml examples in website/docs/guides/azure-foundry.md. token = "" + source = "env" for hint_key in ("key_env", "api_key_env"): env_var = str(model_cfg.get(hint_key) or "").strip() if env_var: @@ -1904,6 +1905,8 @@ def resolve_runtime_provider( # setups that want to avoid env-var juggling). if not token: token = str(model_cfg.get("api_key") or "").strip() + if token: + source = "config" # Finally fall back to the historical fixed names. if not token: token = ( @@ -1917,8 +1920,18 @@ def resolve_runtime_provider( "config.yaml model section at a custom env var." ) else: - from agent.anthropic_adapter import resolve_anthropic_token - token = resolve_anthropic_token() + cfg_api_key = ( + str(model_cfg.get("api_key") or "").strip() + if cfg_provider == "anthropic" + else "" + ) + if cfg_api_key: + token = cfg_api_key + source = "config" + else: + from agent.anthropic_adapter import resolve_anthropic_token + token = resolve_anthropic_token() + source = "env" if not token: raise AuthError( "No Anthropic credentials found. Set ANTHROPIC_TOKEN or ANTHROPIC_API_KEY, " @@ -1929,7 +1942,7 @@ def resolve_runtime_provider( "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 198fd0488bda..3bb45494ab7c 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -3416,3 +3416,45 @@ def test_resolve_named_custom_runtime_pool_result_includes_extra_headers(monkeyp } assert resolved["api_key"] == "pooled-key" assert resolved["source"] == "pool:lmstudio-pool" + + +# --- Anthropic config.yaml api_key tests (#9105) --- + +def _patch_anthropic_short_circuit(monkeypatch): + """Bypass everything before the provider-specific anthropic branch.""" + monkeypatch.setattr(rp, "resolve_requested_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "_resolve_named_custom_runtime", lambda **k: None) + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "_resolve_explicit_runtime", lambda **k: None) + # Force the pool path to yield nothing so we fall through to the + # anthropic-specific code block. + monkeypatch.setattr(rp, "load_pool", lambda *a: (_ for _ in ()).throw(Exception("no pool"))) + + +def test_anthropic_config_api_key_is_used(monkeypatch): + """Non-Azure branch: config.yaml model.api_key must be honoured.""" + _patch_anthropic_short_circuit(monkeypatch) + monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "anthropic", "api_key": "***"}) + resolved = rp.resolve_runtime_provider() + assert resolved["api_key"] == "***" + assert resolved["source"] == "config" + + +def test_anthropic_config_api_key_falls_back_to_env(monkeypatch): + """When no config api_key, fall back to resolve_anthropic_token().""" + _patch_anthropic_short_circuit(monkeypatch) + monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "anthropic"}) + monkeypatch.setattr("agent.anthropic_adapter.resolve_anthropic_token", lambda: "sk-ant-env") + resolved = rp.resolve_runtime_provider() + assert resolved["api_key"] == "sk-ant-env" + assert resolved["source"] == "env" + + +def test_anthropic_config_api_key_no_cross_provider_leak(monkeypatch): + """A config api_key from a *different* provider must not leak in.""" + _patch_anthropic_short_circuit(monkeypatch) + monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "openrouter", "api_key": "sk-or-leak"}) + monkeypatch.setattr("agent.anthropic_adapter.resolve_anthropic_token", lambda: "sk-ant-env") + resolved = rp.resolve_runtime_provider() + assert resolved["api_key"] == "sk-ant-env" + assert resolved["api_key"] != "sk-or-leak"