Compare commits

...

1 Commits

Author SHA1 Message Date
Teknium
7fd7ec0059 fix: stale OAuth credentials block OpenRouter users on auto-detect
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.
2026-04-06 22:56:27 -07:00
2 changed files with 122 additions and 23 deletions

View File

@@ -639,31 +639,47 @@ def resolve_runtime_provider(
)
if provider == "nous":
creds = resolve_nous_runtime_credentials(
min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))),
timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
)
return {
"provider": "nous",
"api_mode": "chat_completions",
"base_url": creds.get("base_url", "").rstrip("/"),
"api_key": creds.get("api_key", ""),
"source": creds.get("source", "portal"),
"expires_at": creds.get("expires_at"),
"requested_provider": requested_provider,
}
try:
creds = resolve_nous_runtime_credentials(
min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))),
timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
)
return {
"provider": "nous",
"api_mode": "chat_completions",
"base_url": creds.get("base_url", "").rstrip("/"),
"api_key": creds.get("api_key", ""),
"source": creds.get("source", "portal"),
"expires_at": creds.get("expires_at"),
"requested_provider": requested_provider,
}
except AuthError:
if requested_provider != "auto":
raise
# Auto-detected Nous but credentials are stale/revoked —
# fall through to env-var providers (e.g. OpenRouter).
logger.info("Auto-detected Nous provider but credentials failed; "
"falling through to next provider.")
if provider == "openai-codex":
creds = resolve_codex_runtime_credentials()
return {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": creds.get("base_url", "").rstrip("/"),
"api_key": creds.get("api_key", ""),
"source": creds.get("source", "hermes-auth-store"),
"last_refresh": creds.get("last_refresh"),
"requested_provider": requested_provider,
}
try:
creds = resolve_codex_runtime_credentials()
return {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": creds.get("base_url", "").rstrip("/"),
"api_key": creds.get("api_key", ""),
"source": creds.get("source", "hermes-auth-store"),
"last_refresh": creds.get("last_refresh"),
"requested_provider": requested_provider,
}
except AuthError:
if requested_provider != "auto":
raise
# Auto-detected Codex but credentials are stale/revoked —
# fall through to env-var providers (e.g. OpenRouter).
logger.info("Auto-detected Codex provider but credentials failed; "
"falling through to next provider.")
if provider == "copilot-acp":
creds = resolve_external_process_provider_credentials(provider)

View File

@@ -996,6 +996,89 @@ def test_custom_provider_no_key_gets_placeholder(monkeypatch):
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)