Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 29 additions & 12 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
}

Expand Down
98 changes: 98 additions & 0 deletions tests/hermes_cli/test_runtime_provider_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading