mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
fix(gemini): explain legacy Standard-key 401 rejections with migration guidance
Port from Kilo-Org/kilocode#12162. Google began rejecting unrestricted legacy 'Standard' Google Cloud API keys on the Gemini API on June 19, 2026 (all Standard keys stop working in September 2026). The rejection is a 401 whose message misleadingly tells the user to supply an OAuth 2 access token. gemini_http_error() now appends actionable guidance (check key type in AI Studio, mint a new Gemini API key, temporary restriction bridge) on that narrow shape — matched via google.rpc.ErrorInfo reason ACCESS_TOKEN_TYPE_UNSUPPORTED or the 'Expected OAuth 2 access token' signature. Plain invalid keys (API_KEY_INVALID) keep their existing message. Also fixes a latent sibling gap: _summarize_api_error() preferred re-extracting the raw response body for errors carrying .response, which stripped adapter- composed guidance (this one AND the existing free-tier 429 guidance) from the user-facing summary. GeminiAPIError now surfaces its composed message.
This commit is contained in:
parent
da26ff986b
commit
d4381f0e39
3 changed files with 240 additions and 0 deletions
|
|
@ -163,6 +163,42 @@ _FREE_TIER_GUIDANCE = (
|
|||
)
|
||||
|
||||
|
||||
def is_standard_key_auth_error(
|
||||
status: int, error_message: str, reason: str = ""
|
||||
) -> bool:
|
||||
"""Return True when a Gemini 401 indicates Google rejected the key TYPE.
|
||||
|
||||
Google began rejecting unrestricted legacy "Standard" Google Cloud API
|
||||
keys on the Gemini API on June 19, 2026, and ALL Standard keys stop
|
||||
working in September 2026. The rejection surfaces as a misleading 401
|
||||
telling the user to supply an OAuth 2 access token ("Request had invalid
|
||||
authentication credentials. Expected OAuth 2 access token, login cookie
|
||||
or other valid authentication credential."), optionally carrying
|
||||
``google.rpc.ErrorInfo`` reason ``ACCESS_TOKEN_TYPE_UNSUPPORTED``.
|
||||
|
||||
Scoped narrowly so a plain bad key (reason ``API_KEY_INVALID``,
|
||||
"API key not valid") keeps its existing message.
|
||||
"""
|
||||
if status != 401:
|
||||
return False
|
||||
if reason == "ACCESS_TOKEN_TYPE_UNSUPPORTED":
|
||||
return True
|
||||
return "expected oauth 2 access token" in (error_message or "").lower()
|
||||
|
||||
|
||||
_STANDARD_KEY_GUIDANCE = (
|
||||
"\n\nGoogle Gemini rejected this API key's type — you do NOT need OAuth. "
|
||||
"Google began rejecting legacy 'Standard' Google Cloud keys for the "
|
||||
"Gemini API on June 19, 2026, and all Standard keys stop working in "
|
||||
"September 2026. Open https://aistudio.google.com/api-keys, check the "
|
||||
"key's type and status, and create a replacement Gemini API key (or, as "
|
||||
"a temporary bridge, restrict the Standard key to "
|
||||
"generativelanguage.googleapis.com). Then update GEMINI_API_KEY / "
|
||||
"GOOGLE_API_KEY in ~/.hermes/.env and restart your session. "
|
||||
"Details: https://ai.google.dev/gemini-api/docs/api-key"
|
||||
)
|
||||
|
||||
|
||||
class GeminiAPIError(Exception):
|
||||
"""Error shape compatible with Hermes retry/error classification."""
|
||||
|
||||
|
|
@ -824,6 +860,12 @@ def gemini_http_error(
|
|||
if status == 429 and is_free_tier_quota_error(err_message or body_text):
|
||||
message = message + _FREE_TIER_GUIDANCE
|
||||
|
||||
# Legacy "Standard" Google Cloud key rejection (June 19, 2026 onward) ->
|
||||
# Google's raw 401 misleadingly tells the user to use OAuth. Append the
|
||||
# actual fix (mint a new Gemini API key in AI Studio).
|
||||
if is_standard_key_auth_error(status, err_message or body_text, reason):
|
||||
message = message + _STANDARD_KEY_GUIDANCE
|
||||
|
||||
return GeminiAPIError(
|
||||
message,
|
||||
code=code,
|
||||
|
|
|
|||
|
|
@ -2367,6 +2367,13 @@ class AIAgent:
|
|||
parts.append(f"Ray {ray_id}")
|
||||
return " — ".join(parts)
|
||||
|
||||
# GeminiAPIError (agent/gemini_native_adapter.py) already composes a
|
||||
# clean one-liner and may have appended actionable guidance (free-tier
|
||||
# 429, legacy Standard-key 401). Prefer its message over re-extracting
|
||||
# the raw response body below, which would strip that guidance.
|
||||
if type(error).__name__ == "GeminiAPIError":
|
||||
return redact_sensitive_text(raw[:1000])
|
||||
|
||||
# JSON body errors from OpenAI/Anthropic SDKs
|
||||
body = getattr(error, "body", None)
|
||||
if isinstance(body, dict):
|
||||
|
|
|
|||
191
tests/agent/test_gemini_standard_key_guidance.py
Normal file
191
tests/agent/test_gemini_standard_key_guidance.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"""Tests for Gemini legacy Standard-key 401 guidance.
|
||||
|
||||
Google began rejecting unrestricted legacy "Standard" Google Cloud API keys
|
||||
on the Gemini API on June 19, 2026 (all Standard keys stop working in
|
||||
September 2026). The rejection is a 401 whose message misleadingly tells the
|
||||
user to supply an OAuth 2 access token. ``gemini_http_error`` must append
|
||||
actionable key-migration guidance on that shape — and ONLY that shape.
|
||||
|
||||
Port of Kilo-Org/kilocode#12162, adapted to Hermes' GeminiAPIError surface.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from agent.gemini_native_adapter import (
|
||||
gemini_http_error,
|
||||
is_standard_key_auth_error,
|
||||
)
|
||||
|
||||
|
||||
GOOGLE_AUTH_MESSAGE = (
|
||||
"Request had invalid authentication credentials. Expected OAuth 2 access "
|
||||
"token, login cookie or other valid authentication credential. See "
|
||||
"https://developers.google.com/identity/sign-in/web/devconsole-project."
|
||||
)
|
||||
|
||||
GUIDANCE_MARKER = "rejected this API key's type"
|
||||
|
||||
|
||||
def _mock_response(status: int, body: str, headers: dict | None = None) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.status_code = status
|
||||
resp.headers = headers or {}
|
||||
resp.text = body
|
||||
return resp
|
||||
|
||||
|
||||
def _google_error_body(
|
||||
status_code: int,
|
||||
message: str,
|
||||
status: str = "UNAUTHENTICATED",
|
||||
reason: str | None = None,
|
||||
) -> str:
|
||||
err: dict = {"code": status_code, "message": message, "status": status}
|
||||
if reason is not None:
|
||||
err["details"] = [
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||
"reason": reason,
|
||||
"domain": "googleapis.com",
|
||||
"metadata": {"service": "generativelanguage.googleapis.com"},
|
||||
}
|
||||
]
|
||||
return json.dumps({"error": err})
|
||||
|
||||
|
||||
class TestIsStandardKeyAuthError:
|
||||
def test_matches_oauth_message_without_reason(self):
|
||||
assert is_standard_key_auth_error(401, GOOGLE_AUTH_MESSAGE)
|
||||
|
||||
def test_matches_error_info_reason_alone(self):
|
||||
assert is_standard_key_auth_error(
|
||||
401, "some other text", "ACCESS_TOKEN_TYPE_UNSUPPORTED"
|
||||
)
|
||||
|
||||
def test_rejects_non_401_status(self):
|
||||
assert not is_standard_key_auth_error(400, GOOGLE_AUTH_MESSAGE)
|
||||
assert not is_standard_key_auth_error(403, GOOGLE_AUTH_MESSAGE)
|
||||
|
||||
def test_rejects_plain_invalid_key(self):
|
||||
assert not is_standard_key_auth_error(
|
||||
401, "API key not valid. Please pass a valid API key.", "API_KEY_INVALID"
|
||||
)
|
||||
|
||||
def test_empty_message_is_safe(self):
|
||||
assert not is_standard_key_auth_error(401, "")
|
||||
assert not is_standard_key_auth_error(401, None) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestGeminiHttpErrorGuidance:
|
||||
def test_guidance_appended_on_oauth_401_with_reason(self):
|
||||
body = _google_error_body(
|
||||
401, GOOGLE_AUTH_MESSAGE, reason="ACCESS_TOKEN_TYPE_UNSUPPORTED"
|
||||
)
|
||||
err = gemini_http_error(_mock_response(401, body))
|
||||
text = str(err)
|
||||
assert GUIDANCE_MARKER in text
|
||||
assert "aistudio.google.com/api-keys" in text
|
||||
assert "ai.google.dev/gemini-api/docs/api-key" in text
|
||||
assert err.code == "gemini_unauthorized"
|
||||
|
||||
def test_guidance_appended_on_oauth_401_without_reason(self):
|
||||
body = _google_error_body(401, GOOGLE_AUTH_MESSAGE)
|
||||
err = gemini_http_error(_mock_response(401, body))
|
||||
assert GUIDANCE_MARKER in str(err)
|
||||
|
||||
def test_original_google_message_preserved(self):
|
||||
body = _google_error_body(401, GOOGLE_AUTH_MESSAGE)
|
||||
err = gemini_http_error(_mock_response(401, body))
|
||||
assert "Expected OAuth 2 access token" in str(err)
|
||||
|
||||
def test_plain_invalid_key_401_gets_no_guidance(self):
|
||||
body = _google_error_body(
|
||||
401,
|
||||
"API key not valid. Please pass a valid API key.",
|
||||
reason="API_KEY_INVALID",
|
||||
)
|
||||
err = gemini_http_error(_mock_response(401, body))
|
||||
assert GUIDANCE_MARKER not in str(err)
|
||||
|
||||
def test_403_with_oauth_message_gets_no_guidance(self):
|
||||
body = _google_error_body(403, GOOGLE_AUTH_MESSAGE, status="PERMISSION_DENIED")
|
||||
err = gemini_http_error(_mock_response(403, body))
|
||||
assert GUIDANCE_MARKER not in str(err)
|
||||
|
||||
def test_free_tier_429_unaffected(self):
|
||||
body = json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"code": 429,
|
||||
"message": (
|
||||
"Quota exceeded for metric: generativelanguage.googleapis.com/"
|
||||
"generate_content_free_tier_requests, limit: 20"
|
||||
),
|
||||
}
|
||||
}
|
||||
)
|
||||
err = gemini_http_error(_mock_response(429, body))
|
||||
text = str(err)
|
||||
assert "free tier" in text
|
||||
assert GUIDANCE_MARKER not in text
|
||||
|
||||
def test_unparseable_401_body_with_oauth_text_still_matches(self):
|
||||
# err_message empty -> falls back to raw body_text scan.
|
||||
err = gemini_http_error(_mock_response(401, GOOGLE_AUTH_MESSAGE))
|
||||
assert GUIDANCE_MARKER in str(err)
|
||||
|
||||
|
||||
class TestSummarizerPreservesGuidance:
|
||||
"""_summarize_api_error must not strip adapter-composed guidance.
|
||||
|
||||
GeminiAPIError carries ``.response``; without the GeminiAPIError branch,
|
||||
the summarizer re-extracts the raw body's error.message (capped at 300
|
||||
chars), silently discarding both the Standard-key 401 guidance and the
|
||||
pre-existing free-tier 429 guidance.
|
||||
"""
|
||||
|
||||
def test_standard_key_guidance_survives_summarizer(self):
|
||||
from run_agent import AIAgent
|
||||
|
||||
body = _google_error_body(
|
||||
401, GOOGLE_AUTH_MESSAGE, reason="ACCESS_TOKEN_TYPE_UNSUPPORTED"
|
||||
)
|
||||
err = gemini_http_error(_mock_response(401, body))
|
||||
summary = AIAgent._summarize_api_error(err)
|
||||
assert GUIDANCE_MARKER in summary
|
||||
assert "aistudio.google.com/api-keys" in summary
|
||||
|
||||
def test_free_tier_guidance_survives_summarizer(self):
|
||||
from run_agent import AIAgent
|
||||
|
||||
body = json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"code": 429,
|
||||
"message": (
|
||||
"Quota exceeded for metric: "
|
||||
"generativelanguage.googleapis.com/"
|
||||
"generate_content_free_tier_requests, limit: 20"
|
||||
),
|
||||
}
|
||||
}
|
||||
)
|
||||
err = gemini_http_error(_mock_response(429, body))
|
||||
summary = AIAgent._summarize_api_error(err)
|
||||
assert "free tier" in summary
|
||||
|
||||
def test_non_gemini_errors_keep_response_body_extraction(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from run_agent import AIAgent
|
||||
|
||||
err = Exception("")
|
||||
err.status_code = 400
|
||||
err.body = {}
|
||||
err.response = SimpleNamespace(
|
||||
text='{"error": {"message": "model `foo` does not exist"}}'
|
||||
)
|
||||
summary = AIAgent._summarize_api_error(err)
|
||||
assert "model `foo` does not exist" in summary
|
||||
Loading…
Add table
Add a link
Reference in a new issue