OPENAI_API_KEY doubled as a legacy home for an OpenRouter key, so with no
provider pinned and OPENAI_BASE_URL unset, a genuine OpenAI key made
resolve_provider("auto") pick openrouter and _resolve_openrouter_runtime
send that key to openrouter.ai as the bearer. #120299 closed the case
where OPENAI_BASE_URL binds the key elsewhere; the unset case stayed open.
Gate both sites on the existing KNOWN_PROVIDER_KEY_PREFIXES["openrouter"]
("sk-or-"): an OpenRouter-shaped value in OPENAI_API_KEY still works as
before, anything else falls through to the openai-api registry row, which
is what the providers docs already describe. Explicit OPENAI_BASE_URL
binding and OpenRouter mirrors keep their current behaviour.
(cherry picked from commit 6f0b1f40806ab3b36e83d23e5563ed38fc2b3185)
137 lines
6.3 KiB
Python
137 lines
6.3 KiB
Python
"""Regression tests for #29285 — provider precedence in resolve_provider("auto").
|
|
|
|
Explicit user intent (config.yaml model.provider, env-var API keys) must win
|
|
over a stale logged-in OAuth `active_provider` in auth.json. Before the fix,
|
|
`active_provider` sat above the env/config checks and silently overrode an
|
|
explicit choice — e.g. a user OAuth-logged-into Anthropic but with
|
|
OPENAI_API_KEY exported (or model.provider set) got routed to Anthropic.
|
|
"""
|
|
import pytest
|
|
|
|
from hermes_cli.auth import resolve_provider, AuthError
|
|
|
|
|
|
def _login(monkeypatch, provider_id):
|
|
"""Simulate a logged-in OAuth active_provider in auth.json."""
|
|
monkeypatch.setattr("hermes_cli.auth._load_auth_store",
|
|
lambda: {"active_provider": provider_id})
|
|
monkeypatch.setattr("hermes_cli.auth.get_auth_status",
|
|
lambda p: {"logged_in": p == provider_id})
|
|
|
|
|
|
def _config(monkeypatch, model_cfg):
|
|
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"model": model_cfg})
|
|
|
|
|
|
def _no_aws(monkeypatch):
|
|
# Neutralize any ambient AWS creds so Bedrock auto-detect can't interfere.
|
|
monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: False)
|
|
|
|
|
|
def _clear_provider_env(monkeypatch):
|
|
for var in ("OPENAI_API_KEY", "OPENROUTER_API_KEY", "GLM_API_KEY", "ZAI_API_KEY",
|
|
"KIMI_API_KEY", "MINIMAX_API_KEY", "HERMES_INFERENCE_PROVIDER"):
|
|
monkeypatch.delenv(var, raising=False)
|
|
|
|
|
|
class TestProviderPrecedence:
|
|
def test_config_provider_beats_stale_oauth(self, monkeypatch):
|
|
"""config.yaml model.provider wins over a logged-in OAuth active_provider."""
|
|
_clear_provider_env(monkeypatch)
|
|
_no_aws(monkeypatch)
|
|
_login(monkeypatch, "anthropic") # stale OAuth login
|
|
_config(monkeypatch, {"provider": "zai", "default": "glm-4.6"})
|
|
assert resolve_provider("auto") == "zai"
|
|
|
|
def test_env_key_beats_stale_oauth(self, monkeypatch):
|
|
"""An exported provider API key wins over a logged-in OAuth active_provider."""
|
|
_clear_provider_env(monkeypatch)
|
|
_no_aws(monkeypatch)
|
|
_login(monkeypatch, "anthropic")
|
|
_config(monkeypatch, {"default": "some-model"}) # dict, NO provider key
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-key")
|
|
assert resolve_provider("auto") == "openai-api"
|
|
|
|
|
|
def test_oauth_used_as_last_resort(self, monkeypatch):
|
|
"""With NO config provider and NO env keys, the logged-in OAuth provider
|
|
is still used (it's the last-resort fallback, not removed)."""
|
|
_clear_provider_env(monkeypatch)
|
|
_no_aws(monkeypatch)
|
|
_login(monkeypatch, "anthropic")
|
|
_config(monkeypatch, {}) # empty model config, no provider
|
|
assert resolve_provider("auto") == "anthropic"
|
|
|
|
|
|
|
|
|
|
def test_openrouter_pool_beats_stale_oauth(self, monkeypatch):
|
|
"""An OpenRouter credential-pool entry (no env var) wins over a logged-in
|
|
OAuth provider — the pool rung sits above OAuth (#42130 + #29285)."""
|
|
_clear_provider_env(monkeypatch)
|
|
_no_aws(monkeypatch)
|
|
_login(monkeypatch, "anthropic")
|
|
_config(monkeypatch, {})
|
|
|
|
class _Pool:
|
|
def has_credentials(self):
|
|
return True
|
|
|
|
monkeypatch.setattr("agent.credential_pool.load_pool", lambda name: _Pool())
|
|
assert resolve_provider("auto") == "openrouter"
|
|
|
|
|
|
def _logged_out(monkeypatch):
|
|
monkeypatch.setattr("hermes_cli.auth._load_auth_store", lambda: {})
|
|
monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda p: {"logged_in": False})
|
|
|
|
|
|
def _free_tier(monkeypatch, *, on=True, identity=False):
|
|
"""Free tier switch + whether a free-tier identity already exists. The resolver is a READ: any
|
|
call into the creator from inside it is a bug, so the stub fails loudly."""
|
|
monkeypatch.setattr("hermes_cli.anon_auth.guest_enabled", lambda: on)
|
|
monkeypatch.setattr("hermes_cli.anon_auth.has_guest", lambda: identity)
|
|
monkeypatch.setattr("hermes_cli.anon_auth.ensure_portal_identity",
|
|
lambda **kw: (_ for _ in ()).throw(AssertionError("resolve_provider must not mint")))
|
|
|
|
|
|
class TestFreeTierBeatsImplicitHostCredentials:
|
|
"""NS-829: a leftover ~/.aws profile must not pre-empt the free tier on a fresh install.
|
|
|
|
The ladder: explicit intent still wins, an EXISTING free-tier identity sits above the implicit
|
|
Bedrock chain, the free tier off (or its identity absent) restores Bedrock. The resolver never
|
|
creates the identity; the boot bootstrap does, before any turn asks."""
|
|
|
|
@pytest.mark.parametrize("free_tier_on, identity, env_key, login, expected", [
|
|
(True, True, None, None, "nous"), # existing identity beats the AWS chain
|
|
(True, False, None, None, "bedrock"), # no identity yet: Bedrock, nothing minted
|
|
(False, True, None, None, "bedrock"), # free tier off: Bedrock as before
|
|
(True, True, "OPENAI_API_KEY", None, "openai-api"), # env key still wins
|
|
(True, True, None, "anthropic", "anthropic"), # a sign-in still wins
|
|
])
|
|
def test_free_tier_sits_above_the_bedrock_chain(self, monkeypatch, free_tier_on, identity,
|
|
env_key, login, expected):
|
|
_clear_provider_env(monkeypatch)
|
|
_config(monkeypatch, "")
|
|
if login:
|
|
_login(monkeypatch, login)
|
|
else:
|
|
_logged_out(monkeypatch)
|
|
if env_key:
|
|
monkeypatch.setenv(env_key, "sk-test-key")
|
|
monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: True)
|
|
_free_tier(monkeypatch, on=free_tier_on, identity=identity)
|
|
assert resolve_provider("auto") == expected
|
|
|
|
def test_skip_free_tier_answers_what_else_would_carry_inference(self, monkeypatch):
|
|
"""The bootstrap's question: with the free tier hidden, an existing identity is not an
|
|
answer and the ladder falls through to the next real rung."""
|
|
_clear_provider_env(monkeypatch)
|
|
_config(monkeypatch, "")
|
|
_logged_out(monkeypatch)
|
|
monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: False)
|
|
_free_tier(monkeypatch, on=True, identity=True)
|
|
assert resolve_provider("auto") == "nous"
|
|
with pytest.raises(AuthError):
|
|
resolve_provider("auto", skip_free_tier=True)
|