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
25 changes: 21 additions & 4 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,26 @@ def _check_gateway_service_linger(issues: list[str]) -> None:
_APIKEY_PROVIDERS_CACHE: list | None = None


def _build_models_probe_request(url: str, key: str) -> tuple[str, dict]:
"""Build the URL and headers for a provider /models health check.

Most OpenAI-compatible providers accept ``Authorization: Bearer``.
Google's native Generative Language API validates API keys via the ``key``
query parameter instead; sending the key as Bearer returns 401 and makes
``hermes doctor`` report false negatives for valid Gemini keys.
"""
headers = {
"User-Agent": _HERMES_USER_AGENT,
}
if base_url_host_matches(url, "generativelanguage.googleapis.com"):
from urllib.parse import quote as _url_quote

sep = "&" if "?" in url else "?"
return f"{url}{sep}key={_url_quote(key, safe='')}", headers
headers["Authorization"] = f"Bearer {key}"
return url, headers


def _build_apikey_providers_list() -> list:
"""Build the API-key provider health-check list once and cache it.

Expand Down Expand Up @@ -1442,10 +1462,7 @@ def _probe_apikey_provider(pname, env_vars, default_url, base_env,
if base_url_host_matches(base, "api.kimi.com") and base.rstrip("/").endswith("/coding"):
base = base.rstrip("/") + "/v1"
url = (base.rstrip("/") + "/models") if base else default_url
headers = {
"Authorization": f"Bearer {key}",
"User-Agent": _HERMES_USER_AGENT,
}
url, headers = _build_models_probe_request(url, key)
if base_url_host_matches(base, "api.kimi.com"):
headers["User-Agent"] = "claude-code/0.1.0"
r = httpx.get(url, headers=headers, timeout=10)
Expand Down
40 changes: 40 additions & 0 deletions tests/hermes_cli/test_doctor_gemini_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Regression tests for Gemini API-key probing in hermes doctor."""


def test_gemini_models_probe_uses_key_query_param_not_bearer_header():
"""Regression for #25108: Google Generative Language /models uses ?key=."""
from hermes_cli.doctor import _build_models_probe_request

url, headers = _build_models_probe_request(
"https://generativelanguage.googleapis.com/v1beta/models",
"AIza-test-key",
)

assert url == "https://generativelanguage.googleapis.com/v1beta/models?key=AIza-test-key"
assert "Authorization" not in headers
assert headers["User-Agent"]


def test_non_gemini_models_probe_keeps_bearer_header():
from hermes_cli.doctor import _build_models_probe_request

url, headers = _build_models_probe_request(
"https://api.deepseek.com/v1/models",
"sk-test",
)

assert url == "https://api.deepseek.com/v1/models"
assert headers["Authorization"] == "Bearer sk-test"



def test_gemini_models_probe_preserves_existing_query_params_and_encodes_key():
from hermes_cli.doctor import _build_models_probe_request

url, headers = _build_models_probe_request(
"https://generativelanguage.googleapis.com/v1beta/models?alt=json",
"AIza-test+key&bad=value",
)

assert url == "https://generativelanguage.googleapis.com/v1beta/models?alt=json&key=AIza-test%2Bkey%26bad%3Dvalue"
assert "Authorization" not in headers