From a9054169d47c9b3b57cf20d686433cdb349af477 Mon Sep 17 00:00:00 2001 From: DinoChiesa Date: Sun, 30 Aug 2026 15:16:06 -0700 Subject: [PATCH] feat(gemini): authenticate using x-goog-api-key header Switch Gemini provider authentication from query parameters (`?key=...`) to the `x-goog-api-key` HTTP header in alignment with Google Gemini API guidelines. - Override `build_headers` in `GeminiProvider` to populate `x-goog-api-key` and suppress `Authorization: Bearer`. - Remove `key` query parameter construction in `gemini_complete` and `gemini_stream` domain adapters. - Add test coverage in `tests/helpers/test_llms_gemini_auth.py`. Co-authored-by: cecli (gemini/gemini-3.6-flash) --- cecli/helpers/llms/domains/gemini.py | 4 +- cecli/helpers/llms/providers/gemini.py | 17 +++--- tests/helpers/test_llms_gemini_auth.py | 80 ++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 tests/helpers/test_llms_gemini_auth.py diff --git a/cecli/helpers/llms/domains/gemini.py b/cecli/helpers/llms/domains/gemini.py index b267ced2dcc..2e2aed1c07d 100644 --- a/cecli/helpers/llms/domains/gemini.py +++ b/cecli/helpers/llms/domains/gemini.py @@ -194,7 +194,7 @@ async def gemini_complete( url = f"{resolved['api_base']}/v1beta/models/{resolved['route']}:generateContent" payload = gemini_payload(resolved, messages, tools, kwargs) hdrs = {"Content-Type": "application/json", **headers} - params = {"key": key} if key else {} + params: Dict[str, str] = {} async with make_client(timeout=DEFAULT_TIMEOUT, verify=VERIFY_SSL) as client: resp = await client.post(url, json=payload, headers=hdrs, params=params) @@ -215,7 +215,7 @@ async def gemini_stream( url = f"{resolved['api_base']}/v1beta/models/{resolved['route']}:streamGenerateContent" payload = gemini_payload(resolved, messages, tools, kwargs) hdrs = {"Content-Type": "application/json", **headers} - params = {"key": key, "alt": "sse"} if key else {"alt": "sse"} + params = {"alt": "sse"} has_seen_tool_calls = False _stream_state["tool_indices"] = {} diff --git a/cecli/helpers/llms/providers/gemini.py b/cecli/helpers/llms/providers/gemini.py index a1515153b0c..cbe49c2a4b5 100644 --- a/cecli/helpers/llms/providers/gemini.py +++ b/cecli/helpers/llms/providers/gemini.py @@ -1,11 +1,10 @@ """Gemini provider adapter for the llms package. -Gemini authenticates via the ``key`` query parameter (or ``X-Goog-Api-Key`` -header), NOT via an ``Authorization: Bearer`` header. The base -:class:`ProviderAdapter` adds a Bearer header whenever a key is present, which -Google rejects with 401 for API keys, so this adapter overrides -:meth:`build_headers` to skip it (the domain adapter passes ``key`` as a query -param itself). +Gemini authenticates via the ``x-goog-api-key`` header, NOT via an +``Authorization: Bearer`` header. The base :class:`ProviderAdapter` adds a +Bearer header whenever a key is present, which Google rejects with 401 for API +keys, so this adapter overrides :meth:`build_headers` to set ``x-goog-api-key`` +instead. """ from __future__ import annotations @@ -16,7 +15,7 @@ class GeminiProvider(ProviderAdapter): - """Gemini: key via query param; no Authorization header.""" + """Gemini: key via x-goog-api-key header; no Authorization header.""" provider: str = "gemini" @@ -27,10 +26,12 @@ def build_headers( family: str, headers: Dict[str, str], ) -> Dict[str, str]: - """Return headers without an Authorization header (key is a query param).""" + """Return headers with x-goog-api-key set instead of Authorization.""" merged = dict(headers) merged.setdefault("Content-Type", "application/json") + if key: + merged["x-goog-api-key"] = key return merged diff --git a/tests/helpers/test_llms_gemini_auth.py b/tests/helpers/test_llms_gemini_auth.py new file mode 100644 index 00000000000..53d5436bfcf --- /dev/null +++ b/tests/helpers/test_llms_gemini_auth.py @@ -0,0 +1,80 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from cecli.helpers.llms.domains.gemini import gemini_complete +from cecli.helpers.llms.providers.gemini import GeminiProvider + + +def test_gemini_provider_build_headers(): + provider = GeminiProvider() + headers = provider.build_headers( + resolved={"provider": "gemini"}, + key="my-secret-gemini-key", + family="gemini", + headers={"Custom-Header": "value"}, + ) + + assert headers["x-goog-api-key"] == "my-secret-gemini-key" + assert headers["Content-Type"] == "application/json" + assert headers["Custom-Header"] == "value" + assert "Authorization" not in headers + + +def test_gemini_provider_build_headers_no_key(): + provider = GeminiProvider() + headers = provider.build_headers( + resolved={"provider": "gemini"}, + key=None, + family="gemini", + headers={}, + ) + + assert "x-goog-api-key" not in headers + assert "Authorization" not in headers + + +@pytest.mark.asyncio +async def test_gemini_complete_sends_x_goog_api_key_header(): + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = { + "responseId": "resp-123", + "candidates": [ + { + "content": {"parts": [{"text": "Hello world"}]}, + "finishReason": "STOP", + } + ], + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + mock_make_client = MagicMock() + mock_make_client.return_value.__aenter__.return_value = mock_client + + with patch("cecli.helpers.llms.domains.gemini.make_client", mock_make_client): + resolved = { + "api_base": "https://generativelanguage.googleapis.com", + "route": "gemini-2.0-flash", + "model": "gemini/gemini-2.0-flash", + } + messages = [{"role": "user", "content": "Hi"}] + headers = {"x-goog-api-key": "my-secret-gemini-key"} + + resp = await gemini_complete( + resolved=resolved, + messages=messages, + tools=None, + key="my-secret-gemini-key", + headers=headers, + kwargs={}, + ) + + assert resp.choices[0].message.content == "Hello world" + mock_client.post.assert_called_once() + _, kwargs = mock_client.post.call_args + assert kwargs["headers"]["x-goog-api-key"] == "my-secret-gemini-key" + assert "Authorization" not in kwargs["headers"] + assert kwargs["params"] == {}