Port from cline/cline#13329: normalize host-root Gemini base URLs to /v1beta

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.
This commit is contained in:
Teknium
2026-08-19 17:10:03 -07:00
parent aa75d3724f
commit f030c03970
5 changed files with 62 additions and 7 deletions

View File

@@ -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))

View File

@@ -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."""

View File

@@ -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": {

View File

@@ -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):

View File

@@ -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: