From d4381f0e391c1df8fe757f6c362dc24b9d5bc35e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:11:18 -0700 Subject: [PATCH] fix(gemini): explain legacy Standard-key 401 rejections with migration guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent/gemini_native_adapter.py | 42 ++++ run_agent.py | 7 + .../test_gemini_standard_key_guidance.py | 191 ++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 tests/agent/test_gemini_standard_key_guidance.py diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index b4f6e6386e7d..bb53e32b2cec 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -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, diff --git a/run_agent.py b/run_agent.py index 166ce7dfb97e..40a1cdfe700f 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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): diff --git a/tests/agent/test_gemini_standard_key_guidance.py b/tests/agent/test_gemini_standard_key_guidance.py new file mode 100644 index 000000000000..1f50f1af079b --- /dev/null +++ b/tests/agent/test_gemini_standard_key_guidance.py @@ -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