From f030c039702e122425c314dfb34e91637d879890 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:10:03 -0700 Subject: [PATCH] Port from cline/cline#13329: normalize host-root Gemini base URLs to /v1beta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GEMINI_BASE_URL (or tts.gemini.base_url / providers.gemini base_url) set to a host root — https://generativelanguage.googleapis.com or a proxy root like http://localhost:4000/gemini — produced native requests to {base}/models/{model}:generateContent with no API version segment, a guaranteed 404. Google's own google-genai client treats the base URL as a host root and appends the version itself, so users reasonably configure it that way. normalize_gemini_base_url() appends /v1beta unless the URL already ends with a version segment (v1, v1beta, v1alpha, ...). Applied at every native request builder: GeminiNativeClient, probe_gemini_tier, Gemini TTS (tts_tool.py), and streaming TTS (tts_streaming.py). /openai-suffixed URLs are untouched (OpenAI-compat path). Port of cline/cline#13329, which fixed the same bug class after their ai-sdk migration. --- agent/gemini_native_adapter.py | 24 ++++++++++++++++--- tests/agent/test_gemini_native_adapter.py | 28 +++++++++++++++++++++++ tools/tts_streaming.py | 5 ++-- tools/tts_tool_providers.py | 6 +++-- website/docs/guides/google-gemini.md | 6 +++++ 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 084522191c..df3bef3a12 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -96,6 +96,25 @@ def gemini_requires_tool_call_ids(model: str) -> bool: return match is not None and int(match.group(1)) >= 3 +_API_VERSION_SEGMENT = re.compile(r"^v\d+(?:alpha|beta)?\d*$", re.IGNORECASE) + + +def normalize_gemini_base_url(base_url: Optional[str]) -> str: + """Gemini native base URL with the API version segment guaranteed. Google's own client treats the + base as a host root and appends the version itself, so users configure ``GEMINI_BASE_URL`` (or a + proxy root like ``http://localhost:4000/gemini``) that way; our request builders expect + ``{base}/models/{model}:generateContent`` — without ``/v1beta`` that is a guaranteed 404. Trailing + slashes and an ``/openai`` suffix are stripped; an existing version segment (``v1``, ``v1beta``, + ``v1alpha``, ...) is kept; empty input returns ``DEFAULT_GEMINI_BASE_URL``.""" + trimmed = str(base_url or "").strip().rstrip("/") + trimmed = re.sub(r"/openai\Z", "", trimmed, flags=re.IGNORECASE).rstrip("/") + if not trimmed: + return DEFAULT_GEMINI_BASE_URL + if _API_VERSION_SEGMENT.match(trimmed.rsplit("/", 1)[-1]): + return trimmed + return f"{trimmed}/v1beta" + + def is_native_gemini_base_url(base_url: str) -> bool: """True when the endpoint speaks Gemini's native REST API (not ``/openai``).""" normalized = str(base_url or "").strip().rstrip("/").lower() @@ -116,8 +135,7 @@ def probe_gemini_tier( key = (api_key or "").strip() if not key: return "unknown" - base = str(base_url or DEFAULT_GEMINI_BASE_URL).strip().rstrip("/") or DEFAULT_GEMINI_BASE_URL - base = re.sub(r"/openai\Z", "", base, flags=re.IGNORECASE) + base = normalize_gemini_base_url(base_url) payload = {"contents": [{"role": "user", "parts": [{"text": "hi"}]}], "generationConfig": {"maxOutputTokens": 1}} headers = {"Content-Type": "application/json", "X-Goog-Api-Client": _API_CLIENT} try: @@ -682,7 +700,7 @@ class GeminiNativeClient: if not (api_key or "").strip(): raise RuntimeError(_MISSING_KEY_ERROR) self.api_key, self.is_closed = api_key, False - self.base_url = (base_url or DEFAULT_GEMINI_BASE_URL).rstrip("/").removesuffix("/openai") + self.base_url = normalize_gemini_base_url(base_url) self._default_headers = dict(default_headers or {}) self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create_chat_completion)) self._http = http_client or httpx.Client(timeout=timeout or httpx.Timeout(connect=15.0, read=600.0, write=30.0, pool=30.0)) diff --git a/tests/agent/test_gemini_native_adapter.py b/tests/agent/test_gemini_native_adapter.py index cbb3be0e89..7d378e7075 100644 --- a/tests/agent/test_gemini_native_adapter.py +++ b/tests/agent/test_gemini_native_adapter.py @@ -330,6 +330,34 @@ def test_native_client_accepts_injected_http_client(): assert client._http is injected +@pytest.mark.parametrize( + ("configured", "expected"), + [ + (None, "https://generativelanguage.googleapis.com/v1beta"), + ("https://generativelanguage.googleapis.com", "https://generativelanguage.googleapis.com/v1beta"), + ("http://localhost:4000/gemini///", "http://localhost:4000/gemini/v1beta"), + ("https://proxy.example/gemini/v1alpha/", "https://proxy.example/gemini/v1alpha"), + ("https://proxy.example/v1", "https://proxy.example/v1"), + ("https://generativelanguage.googleapis.com/v1beta/openai", "https://generativelanguage.googleapis.com/v1beta"), + ], +) +def test_normalize_gemini_base_url_guarantees_version_segment(configured, expected): + """Host roots (how Google's own client is configured) gain ``/v1beta``; versioned URLs and the + ``/openai`` strip are unchanged, so ``{base}/models/{model}:generateContent`` never 404s.""" + from agent.gemini_native_adapter import normalize_gemini_base_url + + assert normalize_gemini_base_url(configured) == expected + + +def test_native_client_appends_v1beta_to_host_root_base_url(): + from agent.gemini_native_adapter import GeminiNativeClient + + client = GeminiNativeClient( + api_key="AIza-test", base_url="https://generativelanguage.googleapis.com", http_client=SimpleNamespace(close=lambda: None) + ) + assert client.base_url == "https://generativelanguage.googleapis.com/v1beta" + + def test_native_client_rejects_empty_api_key_with_actionable_message(): """Empty/whitespace api_key must raise at construction, not produce a cryptic Google GFE 'Error 400 (Bad Request)!!1' HTML page on the first request.""" diff --git a/tools/tts_streaming.py b/tools/tts_streaming.py index e325d62123..0316720663 100644 --- a/tools/tts_streaming.py +++ b/tools/tts_streaming.py @@ -244,9 +244,10 @@ class GeminiStreamer(StreamingTTSProvider): api_key = _gemini_key() model = str(self.section.get("model", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL voice = str(self.section.get("voice", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE - base_url = str( + from agent.gemini_native_adapter import normalize_gemini_base_url + base_url = normalize_gemini_base_url( self.section.get("base_url") or get_env_value("GEMINI_BASE_URL") or DEFAULT_GEMINI_TTS_BASE_URL - ).strip().rstrip("/") + ) payload = { "contents": [{"parts": [{"text": text}]}], "generationConfig": { diff --git a/tools/tts_tool_providers.py b/tools/tts_tool_providers.py index 4af368defa..1804ccd10b 100644 --- a/tools/tts_tool_providers.py +++ b/tools/tts_tool_providers.py @@ -568,8 +568,10 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any] model = str(gemini_config.get("model", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL voice = str(gemini_config.get("voice", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE from hermes_cli.config import get_env_value - base_url = str(gemini_config.get("base_url") or get_env_value("GEMINI_BASE_URL") - or DEFAULT_GEMINI_TTS_BASE_URL).strip().rstrip("/") + from agent.gemini_native_adapter import normalize_gemini_base_url + base_url = normalize_gemini_base_url( + gemini_config.get("base_url") or get_env_value("GEMINI_BASE_URL") or DEFAULT_GEMINI_TTS_BASE_URL + ) persona_prompt = _read_gemini_persona_prompt(gemini_config) tts_script = text if _gemini_audio_tags_enabled(gemini_config, model): diff --git a/website/docs/guides/google-gemini.md b/website/docs/guides/google-gemini.md index d047916b7d..4783c5a28f 100644 --- a/website/docs/guides/google-gemini.md +++ b/website/docs/guides/google-gemini.md @@ -103,6 +103,12 @@ If you previously set `GEMINI_BASE_URL` to the `/openai` URL, remove it or chang GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta ``` +Host-root base URLs are normalized automatically: if the URL doesn't end with +an API version segment (`v1beta`, `v1alpha`, `v1`, ...), Hermes appends +`/v1beta` for you. Setting `GEMINI_BASE_URL=https://generativelanguage.googleapis.com` +or pointing at a proxy root like `http://localhost:4000/gemini` works the same +as spelling out the `/v1beta` suffix. + ## Available Models The `hermes model` picker shows Gemini models maintained in Hermes' provider registry. Common choices include: