When resolve_runtime_provider is called with requested='auto' and
auth.json has a stale active_provider (nous or openai-codex) whose
OAuth refresh token has been revoked, the AuthError now falls through
to the next provider in the chain (e.g. OpenRouter via env vars)
instead of propagating to the user as a blocking error.
When the user explicitly requested the OAuth provider, the error
still propagates so they know to re-authenticate.
Root cause: resolve_provider('auto') checks auth.json for an active
OAuth provider before checking env vars. get_nous_auth_status()
reports logged_in=True if any access_token exists (even expired),
so the Nous path is taken. resolve_nous_runtime_credentials() then
tries to refresh the token, fails with 'Refresh session has been
revoked', and the AuthError bubbles up to the CLI bold-red display.
Adds 3 tests: Nous fallthrough, Codex fallthrough, explicit-request
still raises.
1092 lines
42 KiB
Python
1092 lines
42 KiB
Python
from hermes_cli import runtime_provider as rp
|
|
|
|
|
|
def test_resolve_runtime_provider_uses_credential_pool(monkeypatch):
|
|
class _Entry:
|
|
access_token = "pool-token"
|
|
source = "manual"
|
|
base_url = "https://chatgpt.com/backend-api/codex"
|
|
|
|
class _Pool:
|
|
def has_credentials(self):
|
|
return True
|
|
|
|
def select(self):
|
|
return _Entry()
|
|
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openai-codex")
|
|
monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="openai-codex")
|
|
|
|
assert resolved["provider"] == "openai-codex"
|
|
assert resolved["api_key"] == "pool-token"
|
|
assert resolved["credential_pool"] is not None
|
|
assert resolved["source"] == "manual"
|
|
|
|
|
|
def test_resolve_runtime_provider_anthropic_pool_respects_config_base_url(monkeypatch):
|
|
class _Entry:
|
|
access_token = "pool-token"
|
|
source = "manual"
|
|
base_url = "https://api.anthropic.com"
|
|
|
|
class _Pool:
|
|
def has_credentials(self):
|
|
return True
|
|
|
|
def select(self):
|
|
return _Entry()
|
|
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic")
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"_get_model_config",
|
|
lambda: {
|
|
"provider": "anthropic",
|
|
"base_url": "https://proxy.example.com/anthropic",
|
|
},
|
|
)
|
|
monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="anthropic")
|
|
|
|
assert resolved["provider"] == "anthropic"
|
|
assert resolved["api_mode"] == "anthropic_messages"
|
|
assert resolved["api_key"] == "pool-token"
|
|
assert resolved["base_url"] == "https://proxy.example.com/anthropic"
|
|
|
|
|
|
def test_resolve_runtime_provider_anthropic_explicit_override_skips_pool(monkeypatch):
|
|
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,
|
|
"_get_model_config",
|
|
lambda: {
|
|
"provider": "anthropic",
|
|
"base_url": "https://config.example.com/anthropic",
|
|
},
|
|
)
|
|
monkeypatch.setattr(rp, "load_pool", _unexpected_pool)
|
|
monkeypatch.setattr(
|
|
"agent.anthropic_adapter.resolve_anthropic_token",
|
|
_unexpected_anthropic_token,
|
|
)
|
|
|
|
resolved = rp.resolve_runtime_provider(
|
|
requested="anthropic",
|
|
explicit_api_key="anthropic-explicit-token",
|
|
explicit_base_url="https://proxy.example.com/anthropic/",
|
|
)
|
|
|
|
assert resolved["provider"] == "anthropic"
|
|
assert resolved["api_mode"] == "anthropic_messages"
|
|
assert resolved["api_key"] == "anthropic-explicit-token"
|
|
assert resolved["base_url"] == "https://proxy.example.com/anthropic"
|
|
assert resolved["source"] == "explicit"
|
|
assert resolved.get("credential_pool") is None
|
|
|
|
|
|
def test_resolve_runtime_provider_falls_back_when_pool_empty(monkeypatch):
|
|
class _Pool:
|
|
def has_credentials(self):
|
|
return False
|
|
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openai-codex")
|
|
monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"resolve_codex_runtime_credentials",
|
|
lambda: {
|
|
"provider": "openai-codex",
|
|
"base_url": "https://chatgpt.com/backend-api/codex",
|
|
"api_key": "codex-token",
|
|
"source": "hermes-auth-store",
|
|
"last_refresh": "2026-02-26T00:00:00Z",
|
|
},
|
|
)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="openai-codex")
|
|
|
|
assert resolved["api_key"] == "codex-token"
|
|
assert resolved.get("credential_pool") is None
|
|
|
|
|
|
def test_resolve_runtime_provider_codex(monkeypatch):
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openai-codex")
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"resolve_codex_runtime_credentials",
|
|
lambda: {
|
|
"provider": "openai-codex",
|
|
"base_url": "https://chatgpt.com/backend-api/codex",
|
|
"api_key": "codex-token",
|
|
"source": "codex-auth-json",
|
|
"auth_file": "/tmp/auth.json",
|
|
"codex_home": "/tmp/codex",
|
|
"last_refresh": "2026-02-26T00:00:00Z",
|
|
},
|
|
)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="openai-codex")
|
|
|
|
assert resolved["provider"] == "openai-codex"
|
|
assert resolved["api_mode"] == "codex_responses"
|
|
assert resolved["base_url"] == "https://chatgpt.com/backend-api/codex"
|
|
assert resolved["api_key"] == "codex-token"
|
|
assert resolved["requested_provider"] == "openai-codex"
|
|
|
|
|
|
def test_resolve_runtime_provider_ai_gateway(monkeypatch):
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "ai-gateway")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.setenv("AI_GATEWAY_API_KEY", "test-ai-gw-key")
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="ai-gateway")
|
|
|
|
assert resolved["provider"] == "ai-gateway"
|
|
assert resolved["api_mode"] == "chat_completions"
|
|
assert resolved["base_url"] == "https://ai-gateway.vercel.sh/v1"
|
|
assert resolved["api_key"] == "test-ai-gw-key"
|
|
assert resolved["requested_provider"] == "ai-gateway"
|
|
|
|
|
|
def test_resolve_runtime_provider_ai_gateway_explicit_override_skips_pool(monkeypatch):
|
|
def _unexpected_pool(provider):
|
|
raise AssertionError(f"load_pool should not be called for {provider}")
|
|
|
|
def _unexpected_provider_resolution(provider):
|
|
raise AssertionError(f"resolve_api_key_provider_credentials should not be called for {provider}")
|
|
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "ai-gateway")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.setattr(rp, "load_pool", _unexpected_pool)
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"resolve_api_key_provider_credentials",
|
|
_unexpected_provider_resolution,
|
|
)
|
|
|
|
resolved = rp.resolve_runtime_provider(
|
|
requested="ai-gateway",
|
|
explicit_api_key="ai-gateway-explicit-token",
|
|
explicit_base_url="https://proxy.example.com/v1/",
|
|
)
|
|
|
|
assert resolved["provider"] == "ai-gateway"
|
|
assert resolved["api_mode"] == "chat_completions"
|
|
assert resolved["api_key"] == "ai-gateway-explicit-token"
|
|
assert resolved["base_url"] == "https://proxy.example.com/v1"
|
|
assert resolved["source"] == "explicit"
|
|
assert resolved.get("credential_pool") is None
|
|
|
|
|
|
def test_resolve_runtime_provider_openrouter_explicit(monkeypatch):
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(
|
|
requested="openrouter",
|
|
explicit_api_key="test-key",
|
|
explicit_base_url="https://example.com/v1/",
|
|
)
|
|
|
|
assert resolved["provider"] == "openrouter"
|
|
assert resolved["api_mode"] == "chat_completions"
|
|
assert resolved["api_key"] == "test-key"
|
|
assert resolved["base_url"] == "https://example.com/v1"
|
|
assert resolved["source"] == "explicit"
|
|
|
|
|
|
def test_resolve_runtime_provider_auto_uses_openrouter_pool(monkeypatch):
|
|
class _Entry:
|
|
access_token = "pool-key"
|
|
source = "manual"
|
|
base_url = "https://openrouter.ai/api/v1"
|
|
|
|
class _Pool:
|
|
def has_credentials(self):
|
|
return True
|
|
|
|
def select(self):
|
|
return _Entry()
|
|
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="auto")
|
|
|
|
assert resolved["provider"] == "openrouter"
|
|
assert resolved["api_key"] == "pool-key"
|
|
assert resolved["base_url"] == "https://openrouter.ai/api/v1"
|
|
assert resolved["source"] == "manual"
|
|
assert resolved.get("credential_pool") is not None
|
|
|
|
|
|
def test_resolve_runtime_provider_openrouter_explicit_api_key_skips_pool(monkeypatch):
|
|
class _Entry:
|
|
access_token = "pool-key"
|
|
source = "manual"
|
|
base_url = "https://openrouter.ai/api/v1"
|
|
|
|
class _Pool:
|
|
def has_credentials(self):
|
|
return True
|
|
|
|
def select(self):
|
|
return _Entry()
|
|
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(
|
|
requested="openrouter",
|
|
explicit_api_key="explicit-key",
|
|
)
|
|
|
|
assert resolved["provider"] == "openrouter"
|
|
assert resolved["api_key"] == "explicit-key"
|
|
assert resolved["base_url"] == rp.OPENROUTER_BASE_URL
|
|
assert resolved["source"] == "explicit"
|
|
assert resolved.get("credential_pool") is None
|
|
|
|
|
|
def test_resolve_runtime_provider_openrouter_ignores_codex_config_base_url(monkeypatch):
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"_get_model_config",
|
|
lambda: {
|
|
"provider": "openai-codex",
|
|
"base_url": "https://chatgpt.com/backend-api/codex",
|
|
},
|
|
)
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="openrouter")
|
|
|
|
assert resolved["provider"] == "openrouter"
|
|
assert resolved["base_url"] == rp.OPENROUTER_BASE_URL
|
|
|
|
|
|
def test_resolve_runtime_provider_auto_uses_custom_config_base_url(monkeypatch):
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"_get_model_config",
|
|
lambda: {
|
|
"provider": "auto",
|
|
"base_url": "https://custom.example/v1/",
|
|
},
|
|
)
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="auto")
|
|
|
|
assert resolved["provider"] == "openrouter"
|
|
assert resolved["base_url"] == "https://custom.example/v1"
|
|
|
|
|
|
def test_openrouter_key_takes_priority_over_openai_key(monkeypatch):
|
|
"""OPENROUTER_API_KEY should be used over OPENAI_API_KEY when both are set.
|
|
|
|
Regression test for #289: users with OPENAI_API_KEY in .bashrc had it
|
|
sent to OpenRouter instead of their OPENROUTER_API_KEY.
|
|
"""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-should-lose")
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-should-win")
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="openrouter")
|
|
|
|
assert resolved["api_key"] == "sk-or-should-win"
|
|
|
|
|
|
def test_openai_key_used_when_no_openrouter_key(monkeypatch):
|
|
"""OPENAI_API_KEY is used as fallback when OPENROUTER_API_KEY is not set."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-fallback")
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="openrouter")
|
|
|
|
assert resolved["api_key"] == "sk-openai-fallback"
|
|
|
|
|
|
def test_custom_endpoint_prefers_openai_key(monkeypatch):
|
|
"""Custom endpoint should use config api_key over OPENROUTER_API_KEY.
|
|
|
|
Updated for #4165: config.yaml is now the source of truth for endpoint URLs,
|
|
OPENAI_BASE_URL env var is no longer consulted.
|
|
"""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {
|
|
"provider": "custom",
|
|
"base_url": "https://api.z.ai/api/coding/paas/v4",
|
|
"api_key": "zai-key",
|
|
})
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "openrouter-key")
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="custom")
|
|
|
|
assert resolved["base_url"] == "https://api.z.ai/api/coding/paas/v4"
|
|
assert resolved["api_key"] == "zai-key"
|
|
|
|
|
|
def test_custom_endpoint_uses_saved_config_base_url_when_env_missing(monkeypatch):
|
|
"""Persisted custom endpoints in config.yaml must still resolve when
|
|
OPENAI_BASE_URL is absent from the current environment."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"_get_model_config",
|
|
lambda: {
|
|
"provider": "custom",
|
|
"base_url": "http://127.0.0.1:1234/v1",
|
|
},
|
|
)
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.setenv("OPENAI_API_KEY", "local-key")
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="custom")
|
|
|
|
assert resolved["base_url"] == "http://127.0.0.1:1234/v1"
|
|
assert resolved["api_key"] == "local-key"
|
|
|
|
|
|
def test_custom_endpoint_uses_config_api_key_over_env(monkeypatch):
|
|
"""provider: custom with base_url and api_key in config uses them (#1760)."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"_get_model_config",
|
|
lambda: {
|
|
"provider": "custom",
|
|
"base_url": "https://my-api.example.com/v1",
|
|
"api_key": "config-api-key",
|
|
},
|
|
)
|
|
monkeypatch.setenv("OPENAI_BASE_URL", "https://other.example.com/v1")
|
|
monkeypatch.setenv("OPENAI_API_KEY", "env-key")
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="custom")
|
|
|
|
assert resolved["base_url"] == "https://my-api.example.com/v1"
|
|
assert resolved["api_key"] == "config-api-key"
|
|
|
|
|
|
def test_custom_endpoint_uses_config_api_field_when_no_api_key(monkeypatch):
|
|
"""provider: custom with 'api' in config uses it as api_key (#1760)."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"_get_model_config",
|
|
lambda: {
|
|
"provider": "custom",
|
|
"base_url": "https://custom.example.com/v1",
|
|
"api": "config-api-field",
|
|
},
|
|
)
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="custom")
|
|
|
|
assert resolved["base_url"] == "https://custom.example.com/v1"
|
|
assert resolved["api_key"] == "config-api-field"
|
|
|
|
|
|
def test_custom_endpoint_explicit_custom_prefers_config_key(monkeypatch):
|
|
"""Explicit 'custom' provider with config base_url+api_key should use them.
|
|
|
|
Updated for #4165: config.yaml is the source of truth, not OPENAI_BASE_URL.
|
|
"""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {
|
|
"provider": "custom",
|
|
"base_url": "https://my-vllm-server.example.com/v1",
|
|
"api_key": "sk-vllm-key",
|
|
})
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-...leak")
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="custom")
|
|
|
|
assert resolved["base_url"] == "https://my-vllm-server.example.com/v1"
|
|
assert resolved["api_key"] == "sk-vllm-key"
|
|
|
|
|
|
def test_named_custom_provider_uses_saved_credentials(monkeypatch):
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"load_config",
|
|
lambda: {
|
|
"custom_providers": [
|
|
{
|
|
"name": "Local",
|
|
"base_url": "http://1.2.3.4:1234/v1",
|
|
"api_key": "local-provider-key",
|
|
}
|
|
]
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"resolve_provider",
|
|
lambda *a, **k: (_ for _ in ()).throw(
|
|
AssertionError(
|
|
"resolve_provider should not be called for named custom providers"
|
|
)
|
|
),
|
|
)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="local")
|
|
|
|
assert resolved["provider"] == "custom"
|
|
assert resolved["api_mode"] == "chat_completions"
|
|
assert resolved["base_url"] == "http://1.2.3.4:1234/v1"
|
|
assert resolved["api_key"] == "local-provider-key"
|
|
assert resolved["requested_provider"] == "local"
|
|
assert resolved["source"] == "custom_provider:Local"
|
|
|
|
|
|
def test_named_custom_provider_falls_back_to_openai_api_key(monkeypatch):
|
|
monkeypatch.setenv("OPENAI_API_KEY", "env-openai-key")
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"load_config",
|
|
lambda: {
|
|
"custom_providers": [
|
|
{
|
|
"name": "Local LLM",
|
|
"base_url": "http://localhost:1234/v1",
|
|
}
|
|
]
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"resolve_provider",
|
|
lambda *a, **k: (_ for _ in ()).throw(
|
|
AssertionError(
|
|
"resolve_provider should not be called for named custom providers"
|
|
)
|
|
),
|
|
)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="custom:local-llm")
|
|
|
|
assert resolved["base_url"] == "http://localhost:1234/v1"
|
|
assert resolved["api_key"] == "env-openai-key"
|
|
assert resolved["requested_provider"] == "custom:local-llm"
|
|
|
|
|
|
def test_named_custom_provider_does_not_shadow_builtin_provider(monkeypatch):
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"load_config",
|
|
lambda: {
|
|
"custom_providers": [
|
|
{
|
|
"name": "nous",
|
|
"base_url": "http://localhost:1234/v1",
|
|
"api_key": "shadow-key",
|
|
}
|
|
]
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"resolve_nous_runtime_credentials",
|
|
lambda **kwargs: {
|
|
"base_url": "https://inference-api.nousresearch.com/v1",
|
|
"api_key": "nous-runtime-key",
|
|
"source": "portal",
|
|
"expires_at": None,
|
|
},
|
|
)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="nous")
|
|
|
|
assert resolved["provider"] == "nous"
|
|
assert resolved["base_url"] == "https://inference-api.nousresearch.com/v1"
|
|
assert resolved["api_key"] == "nous-runtime-key"
|
|
assert resolved["requested_provider"] == "nous"
|
|
|
|
|
|
def test_explicit_openrouter_skips_openai_base_url(monkeypatch):
|
|
"""When the user explicitly requests openrouter, OPENAI_BASE_URL
|
|
(which may point to a custom endpoint) must not override the
|
|
OpenRouter base URL. Regression test for #874."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.setenv("OPENAI_BASE_URL", "https://my-custom-llm.example.com/v1")
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="openrouter")
|
|
|
|
assert resolved["provider"] == "openrouter"
|
|
assert "openrouter.ai" in resolved["base_url"]
|
|
assert "my-custom-llm" not in resolved["base_url"]
|
|
assert resolved["api_key"] == "or-test-key"
|
|
|
|
|
|
def test_explicit_openrouter_honors_openrouter_base_url_over_pool(monkeypatch):
|
|
class _Entry:
|
|
access_token = "pool-key"
|
|
source = "manual"
|
|
base_url = "https://openrouter.ai/api/v1"
|
|
|
|
class _Pool:
|
|
def has_credentials(self):
|
|
return True
|
|
|
|
def select(self):
|
|
return _Entry()
|
|
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
|
|
monkeypatch.setenv("OPENROUTER_BASE_URL", "https://mirror.example.com/v1")
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "mirror-key")
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="openrouter")
|
|
|
|
assert resolved["provider"] == "openrouter"
|
|
assert resolved["base_url"] == "https://mirror.example.com/v1"
|
|
assert resolved["api_key"] == "mirror-key"
|
|
assert resolved["source"] == "env/config"
|
|
assert resolved.get("credential_pool") is None
|
|
|
|
|
|
def test_resolve_requested_provider_precedence(monkeypatch):
|
|
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "nous")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "openai-codex"})
|
|
assert rp.resolve_requested_provider("openrouter") == "openrouter"
|
|
assert rp.resolve_requested_provider() == "openai-codex"
|
|
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
assert rp.resolve_requested_provider() == "nous"
|
|
|
|
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
|
|
assert rp.resolve_requested_provider() == "auto"
|
|
|
|
|
|
# ── api_mode config override tests ──────────────────────────────────────
|
|
|
|
|
|
def test_model_config_api_mode(monkeypatch):
|
|
"""model.api_mode in config.yaml should override the default chat_completions."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(
|
|
rp, "_get_model_config",
|
|
lambda: {
|
|
"provider": "custom",
|
|
"base_url": "http://127.0.0.1:9208/v1",
|
|
"api_mode": "codex_responses",
|
|
},
|
|
)
|
|
monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:9208/v1")
|
|
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="custom")
|
|
|
|
assert resolved["api_mode"] == "codex_responses"
|
|
assert resolved["base_url"] == "http://127.0.0.1:9208/v1"
|
|
|
|
|
|
def test_model_config_api_mode_ignored_when_provider_differs(monkeypatch):
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "zai")
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"_get_model_config",
|
|
lambda: {
|
|
"provider": "opencode-go",
|
|
"default": "minimax-m2.5",
|
|
"api_mode": "anthropic_messages",
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"resolve_api_key_provider_credentials",
|
|
lambda provider: {
|
|
"provider": provider,
|
|
"api_key": "test-key",
|
|
"base_url": "https://api.z.ai/api/paas/v4",
|
|
"source": "env",
|
|
},
|
|
)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="zai")
|
|
|
|
assert resolved["provider"] == "zai"
|
|
assert resolved["api_mode"] == "chat_completions"
|
|
|
|
|
|
def test_invalid_api_mode_ignored(monkeypatch):
|
|
"""Invalid api_mode values should fall back to chat_completions."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {"api_mode": "bogus_mode"})
|
|
monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:9208/v1")
|
|
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="custom")
|
|
|
|
assert resolved["api_mode"] == "chat_completions"
|
|
|
|
|
|
def test_named_custom_provider_api_mode(monkeypatch):
|
|
"""custom_providers entries with api_mode should use it."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-server")
|
|
monkeypatch.setattr(
|
|
rp, "_get_named_custom_provider",
|
|
lambda p: {
|
|
"name": "my-server",
|
|
"base_url": "http://localhost:8000/v1",
|
|
"api_key": "sk-test",
|
|
"api_mode": "codex_responses",
|
|
},
|
|
)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="my-server")
|
|
|
|
assert resolved["api_mode"] == "codex_responses"
|
|
assert resolved["base_url"] == "http://localhost:8000/v1"
|
|
|
|
|
|
def test_named_custom_provider_without_api_mode_defaults(monkeypatch):
|
|
"""custom_providers entries without api_mode should default to chat_completions."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-server")
|
|
monkeypatch.setattr(
|
|
rp, "_get_named_custom_provider",
|
|
lambda p: {
|
|
"name": "my-server",
|
|
"base_url": "http://localhost:8000/v1",
|
|
"api_key": "***",
|
|
},
|
|
)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="my-server")
|
|
|
|
assert resolved["api_mode"] == "chat_completions"
|
|
|
|
|
|
def test_anthropic_messages_in_valid_api_modes():
|
|
"""anthropic_messages should be accepted by _parse_api_mode."""
|
|
assert rp._parse_api_mode("anthropic_messages") == "anthropic_messages"
|
|
|
|
|
|
def test_api_key_provider_anthropic_url_auto_detection(monkeypatch):
|
|
"""API-key providers with /anthropic base URL should auto-detect anthropic_messages mode."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
|
|
monkeypatch.setenv("MINIMAX_BASE_URL", "https://api.minimax.io/anthropic")
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="minimax")
|
|
|
|
assert resolved["provider"] == "minimax"
|
|
assert resolved["api_mode"] == "anthropic_messages"
|
|
assert resolved["base_url"] == "https://api.minimax.io/anthropic"
|
|
|
|
|
|
def test_api_key_provider_explicit_api_mode_config(monkeypatch):
|
|
"""API-key providers should respect api_mode from model config."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {"api_mode": "anthropic_messages"})
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
|
|
monkeypatch.delenv("MINIMAX_BASE_URL", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="minimax")
|
|
|
|
assert resolved["provider"] == "minimax"
|
|
assert resolved["api_mode"] == "anthropic_messages"
|
|
|
|
|
|
def test_minimax_default_url_uses_anthropic_messages(monkeypatch):
|
|
"""MiniMax with default /anthropic URL should auto-detect anthropic_messages mode."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
|
|
monkeypatch.delenv("MINIMAX_BASE_URL", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="minimax")
|
|
|
|
assert resolved["provider"] == "minimax"
|
|
assert resolved["api_mode"] == "anthropic_messages"
|
|
assert resolved["base_url"] == "https://api.minimax.io/anthropic"
|
|
|
|
|
|
def test_minimax_v1_url_uses_chat_completions(monkeypatch):
|
|
"""MiniMax with /v1 base URL should use chat_completions (user override for regions where /anthropic 404s)."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
|
|
monkeypatch.setenv("MINIMAX_BASE_URL", "https://api.minimax.chat/v1")
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="minimax")
|
|
|
|
assert resolved["provider"] == "minimax"
|
|
assert resolved["api_mode"] == "chat_completions"
|
|
assert resolved["base_url"] == "https://api.minimax.chat/v1"
|
|
|
|
|
|
def test_minimax_cn_v1_url_uses_chat_completions(monkeypatch):
|
|
"""MiniMax-CN with /v1 base URL should use chat_completions (user override)."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-cn")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.setenv("MINIMAX_CN_API_KEY", "test-minimax-cn-key")
|
|
monkeypatch.setenv("MINIMAX_CN_BASE_URL", "https://api.minimaxi.com/v1")
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="minimax-cn")
|
|
|
|
assert resolved["provider"] == "minimax-cn"
|
|
assert resolved["api_mode"] == "chat_completions"
|
|
assert resolved["base_url"] == "https://api.minimaxi.com/v1"
|
|
|
|
|
|
def test_minimax_explicit_api_mode_respected(monkeypatch):
|
|
"""Explicit api_mode config should override MiniMax auto-detection."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {"api_mode": "chat_completions"})
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
|
|
monkeypatch.delenv("MINIMAX_BASE_URL", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="minimax")
|
|
|
|
assert resolved["provider"] == "minimax"
|
|
assert resolved["api_mode"] == "chat_completions"
|
|
|
|
|
|
def test_alibaba_default_coding_intl_endpoint_uses_chat_completions(monkeypatch):
|
|
"""Alibaba default coding-intl /v1 URL should use chat_completions mode."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "alibaba")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.setenv("DASHSCOPE_API_KEY", "test-dashscope-key")
|
|
monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="alibaba")
|
|
|
|
assert resolved["provider"] == "alibaba"
|
|
assert resolved["api_mode"] == "chat_completions"
|
|
assert resolved["base_url"] == "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
|
|
|
|
|
def test_alibaba_anthropic_endpoint_override_uses_anthropic_messages(monkeypatch):
|
|
"""Alibaba with /apps/anthropic URL override should auto-detect anthropic_messages mode."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "alibaba")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
|
monkeypatch.setenv("DASHSCOPE_API_KEY", "test-dashscope-key")
|
|
monkeypatch.setenv("DASHSCOPE_BASE_URL", "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic")
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="alibaba")
|
|
|
|
assert resolved["provider"] == "alibaba"
|
|
assert resolved["api_mode"] == "anthropic_messages"
|
|
assert resolved["base_url"] == "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
|
|
|
|
|
def test_opencode_zen_gpt_defaults_to_responses(monkeypatch):
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-zen")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {"default": "gpt-5.4"})
|
|
monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "test-opencode-zen-key")
|
|
monkeypatch.delenv("OPENCODE_ZEN_BASE_URL", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="opencode-zen")
|
|
|
|
assert resolved["provider"] == "opencode-zen"
|
|
assert resolved["api_mode"] == "codex_responses"
|
|
assert resolved["base_url"] == "https://opencode.ai/zen/v1"
|
|
|
|
|
|
def test_opencode_zen_claude_defaults_to_messages(monkeypatch):
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-zen")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {"default": "claude-sonnet-4-6"})
|
|
monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "test-opencode-zen-key")
|
|
monkeypatch.delenv("OPENCODE_ZEN_BASE_URL", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="opencode-zen")
|
|
|
|
assert resolved["provider"] == "opencode-zen"
|
|
assert resolved["api_mode"] == "anthropic_messages"
|
|
# Trailing /v1 stripped for anthropic_messages mode — the Anthropic SDK
|
|
# appends its own /v1/messages to the base_url.
|
|
assert resolved["base_url"] == "https://opencode.ai/zen"
|
|
|
|
|
|
def test_opencode_go_minimax_defaults_to_messages(monkeypatch):
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-go")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {"default": "minimax-m2.5"})
|
|
monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-opencode-go-key")
|
|
monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="opencode-go")
|
|
|
|
assert resolved["provider"] == "opencode-go"
|
|
assert resolved["api_mode"] == "anthropic_messages"
|
|
# Trailing /v1 stripped — Anthropic SDK appends /v1/messages itself.
|
|
assert resolved["base_url"] == "https://opencode.ai/zen/go"
|
|
|
|
|
|
def test_opencode_go_glm_defaults_to_chat_completions(monkeypatch):
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-go")
|
|
monkeypatch.setattr(rp, "_get_model_config", lambda: {"default": "glm-5"})
|
|
monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-opencode-go-key")
|
|
monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="opencode-go")
|
|
|
|
assert resolved["provider"] == "opencode-go"
|
|
assert resolved["api_mode"] == "chat_completions"
|
|
assert resolved["base_url"] == "https://opencode.ai/zen/go/v1"
|
|
|
|
|
|
def test_opencode_go_configured_api_mode_still_overrides_default(monkeypatch):
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-go")
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"_get_model_config",
|
|
lambda: {
|
|
"provider": "opencode-go",
|
|
"default": "minimax-m2.5",
|
|
"api_mode": "chat_completions",
|
|
},
|
|
)
|
|
monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-opencode-go-key")
|
|
monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="opencode-go")
|
|
|
|
assert resolved["provider"] == "opencode-go"
|
|
assert resolved["api_mode"] == "chat_completions"
|
|
|
|
|
|
def test_named_custom_provider_anthropic_api_mode(monkeypatch):
|
|
"""Custom providers should accept api_mode: anthropic_messages."""
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-anthropic-proxy")
|
|
monkeypatch.setattr(
|
|
rp, "_get_named_custom_provider",
|
|
lambda p: {
|
|
"name": "my-anthropic-proxy",
|
|
"base_url": "https://proxy.example.com/anthropic",
|
|
"api_key": "test-key",
|
|
"api_mode": "anthropic_messages",
|
|
},
|
|
)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="my-anthropic-proxy")
|
|
|
|
assert resolved["api_mode"] == "anthropic_messages"
|
|
assert resolved["base_url"] == "https://proxy.example.com/anthropic"
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# fix #2562 — resolve_provider("custom") must not remap to "openrouter"
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def test_resolve_provider_custom_returns_custom():
|
|
"""resolve_provider('custom') must return 'custom', not 'openrouter'."""
|
|
from hermes_cli.auth import resolve_provider
|
|
assert resolve_provider("custom") == "custom"
|
|
|
|
|
|
def test_resolve_provider_openrouter_unchanged():
|
|
"""resolve_provider('openrouter') must still return 'openrouter'."""
|
|
from hermes_cli.auth import resolve_provider
|
|
assert resolve_provider("openrouter") == "openrouter"
|
|
|
|
|
|
def test_custom_provider_runtime_preserves_provider_name(monkeypatch):
|
|
"""resolve_runtime_provider with provider='custom' must return provider='custom'."""
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"load_config",
|
|
lambda: {
|
|
"model": {
|
|
"provider": "custom",
|
|
"base_url": "http://localhost:8080/v1",
|
|
"api_key": "test-key-123",
|
|
}
|
|
},
|
|
)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="custom")
|
|
assert resolved["provider"] == "custom", (
|
|
f"Expected provider='custom', got provider='{resolved['provider']}'"
|
|
)
|
|
assert resolved["base_url"] == "http://localhost:8080/v1"
|
|
assert resolved["api_key"] == "test-key-123"
|
|
|
|
|
|
def test_custom_provider_no_key_gets_placeholder(monkeypatch):
|
|
"""Local server with no API key should get 'no-key-required' placeholder."""
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.setattr(
|
|
rp,
|
|
"load_config",
|
|
lambda: {
|
|
"model": {
|
|
"provider": "custom",
|
|
"base_url": "http://localhost:8080/v1",
|
|
}
|
|
},
|
|
)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="custom")
|
|
assert resolved["provider"] == "custom"
|
|
assert resolved["api_key"] == "no-key-required"
|
|
assert resolved["base_url"] == "http://localhost:8080/v1"
|
|
|
|
|
|
def test_auto_detected_nous_auth_failure_falls_through_to_openrouter(monkeypatch):
|
|
"""When auto-detect picks Nous but credentials are revoked, fall through to OpenRouter."""
|
|
from hermes_cli.auth import AuthError
|
|
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "test-or-key")
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.setattr(rp, "load_config", lambda: {})
|
|
|
|
# resolve_provider returns "nous" (stale active_provider in auth.json)
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "nous")
|
|
# load_pool returns empty pool so we hit the direct credential resolution
|
|
monkeypatch.setattr(rp, "load_pool", lambda p: type("P", (), {
|
|
"has_credentials": lambda self: False,
|
|
})())
|
|
# Nous credential resolution fails with revoked token
|
|
monkeypatch.setattr(
|
|
rp, "resolve_nous_runtime_credentials",
|
|
lambda **kw: (_ for _ in ()).throw(
|
|
AuthError("Refresh session has been revoked",
|
|
provider="nous", code="invalid_grant", relogin_required=True)
|
|
),
|
|
)
|
|
|
|
# With requested="auto", should fall through to OpenRouter
|
|
resolved = rp.resolve_runtime_provider(requested="auto")
|
|
assert resolved["provider"] == "openrouter"
|
|
assert resolved["api_key"] == "test-or-key"
|
|
|
|
|
|
def test_auto_detected_codex_auth_failure_falls_through_to_openrouter(monkeypatch):
|
|
"""When auto-detect picks Codex but credentials are revoked, fall through to OpenRouter."""
|
|
from hermes_cli.auth import AuthError
|
|
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "test-or-key")
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.setattr(rp, "load_config", lambda: {})
|
|
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openai-codex")
|
|
monkeypatch.setattr(rp, "load_pool", lambda p: type("P", (), {
|
|
"has_credentials": lambda self: False,
|
|
})())
|
|
monkeypatch.setattr(
|
|
rp, "resolve_codex_runtime_credentials",
|
|
lambda **kw: (_ for _ in ()).throw(
|
|
AuthError("Codex token refresh failed: session revoked",
|
|
provider="openai-codex", code="invalid_grant", relogin_required=True)
|
|
),
|
|
)
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="auto")
|
|
assert resolved["provider"] == "openrouter"
|
|
assert resolved["api_key"] == "test-or-key"
|
|
|
|
|
|
def test_explicit_nous_auth_failure_still_raises(monkeypatch):
|
|
"""When user explicitly requests Nous and auth fails, the error should propagate."""
|
|
from hermes_cli.auth import AuthError
|
|
import pytest
|
|
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "test-or-key")
|
|
monkeypatch.setattr(rp, "load_config", lambda: {})
|
|
|
|
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "nous")
|
|
monkeypatch.setattr(rp, "load_pool", lambda p: type("P", (), {
|
|
"has_credentials": lambda self: False,
|
|
})())
|
|
monkeypatch.setattr(
|
|
rp, "resolve_nous_runtime_credentials",
|
|
lambda **kw: (_ for _ in ()).throw(
|
|
AuthError("Refresh session has been revoked",
|
|
provider="nous", code="invalid_grant", relogin_required=True)
|
|
),
|
|
)
|
|
|
|
# With explicit "nous", should raise — don't silently switch providers
|
|
with pytest.raises(AuthError, match="Refresh session has been revoked"):
|
|
rp.resolve_runtime_provider(requested="nous")
|
|
|
|
|
|
def test_openrouter_provider_not_affected_by_custom_fix(monkeypatch):
|
|
"""Fixing custom must not change openrouter behavior."""
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "test-or-key")
|
|
monkeypatch.setattr(rp, "load_config", lambda: {})
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="openrouter")
|
|
assert resolved["provider"] == "openrouter"
|