fix(doctor): suppress stale direct-key issues when oauth is healthy

Fixes #26693

`hermes doctor` currently promotes invalid direct API keys into the final
summary even when the matching OAuth path is already healthy. That makes
the setup look more broken than it really is.

This change keeps the failed API Connectivity row visible but stops
treating it as a blocking summary issue when a healthy OAuth fallback
already exists for the same provider family.

Covered cases:
- Gemini OAuth + invalid direct Gemini key
- MiniMax OAuth + invalid direct MiniMax key

Based on #26704 by @worlldz.
This commit is contained in:
worlldz 2026-05-16 14:46:34 +05:30 committed by kshitij
parent 5f91b1a48b
commit d0a183cadd
2 changed files with 133 additions and 1 deletions

View file

@ -152,6 +152,30 @@ def _apply_doctor_tool_availability_overrides(available: list[str], unavailable:
return updated_available, updated_unavailable
def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:
"""Return True when a direct API-key probe failure is non-blocking.
Some provider families support both a direct API-key path and a separate
OAuth runtime path. When the OAuth path is already healthy, doctor should
still show a failed API-key connectivity row, but it should not promote
that direct-key problem into the final blocking summary.
"""
try:
from hermes_cli.auth import (
get_gemini_oauth_auth_status,
get_minimax_oauth_auth_status,
)
except Exception:
return False
normalized = (provider_label or "").strip().lower()
if normalized in {"google / gemini", "gemini"}:
return bool((get_gemini_oauth_auth_status() or {}).get("logged_in"))
if normalized == "minimax":
return bool((get_minimax_oauth_auth_status() or {}).get("logged_in"))
return False
def check_ok(text: str, detail: str = ""):
print(f" {color('', Colors.GREEN)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else ""))
@ -1594,7 +1618,10 @@ def run_doctor(args):
print(f" {_glyph} {_label} {_detail}")
else:
print(f" {_glyph} {_label}")
for _issue in _r.issues:
_issues_to_add = list(_r.issues)
if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):
_issues_to_add = []
for _issue in _issues_to_add:
issues.append(_issue)
# =========================================================================

View file

@ -839,3 +839,108 @@ class TestGitHubTokenCheck:
assert "gh auth" in str(call_log) or any(c[0] == "gh" for c in call_log), f"gh not called: {call_log}"
assert "GitHub authenticated via gh CLI" in out or "token configured" in out
def _run_doctor_with_healthy_oauth_fallback(
monkeypatch,
tmp_path,
*,
env_key: str,
bad_key: str,
failing_host: str,
gemini_oauth_status: dict,
minimax_oauth_status: dict,
) -> str:
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: nous\n"
" default: moonshotai/kimi-k2.6\n",
encoding="utf-8",
)
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setenv(env_key, bad_key)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
monkeypatch.delenv("MINIMAX_CN_API_KEY", raising=False)
monkeypatch.setenv(env_key, bad_key)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: gemini_oauth_status)
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: minimax_oauth_status)
def fake_get(url, headers=None, timeout=None):
status = 401 if failing_host in url else 200
return types.SimpleNamespace(status_code=status)
import httpx
monkeypatch.setattr(httpx, "get", fake_get)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
return buf.getvalue()
@pytest.mark.parametrize(
("env_key", "bad_key", "failing_host", "gemini_oauth_status", "minimax_oauth_status", "unexpected_issue"),
[
(
"GOOGLE_API_KEY",
"bad-gemini-key",
"googleapis.com",
{"logged_in": True, "email": "user@example.com"},
{},
"Check GOOGLE_API_KEY in .env",
),
(
"MINIMAX_API_KEY",
"bad-minimax-key",
"minimax.io",
{},
{"logged_in": True, "region": "global"},
"Check MINIMAX_API_KEY in .env",
),
],
)
def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy(
monkeypatch,
tmp_path,
env_key,
bad_key,
failing_host,
gemini_oauth_status,
minimax_oauth_status,
unexpected_issue,
):
out = _run_doctor_with_healthy_oauth_fallback(
monkeypatch,
tmp_path,
env_key=env_key,
bad_key=bad_key,
failing_host=failing_host,
gemini_oauth_status=gemini_oauth_status,
minimax_oauth_status=minimax_oauth_status,
)
assert "invalid API key" in out
assert unexpected_issue not in out