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:
@@ -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))
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user