Compare commits

...

2 Commits

Author SHA1 Message Date
teknium1
e289dbf163 test(gemini): update stale passthrough assertion in test_gemini_provider
test_strip_vendor_prefix asserted the OLD (buggy) behavior where a self-prefix
survived to the native API. Now asserts the prefix is stripped, matching the
adapter/normalizer fix and the method's own name.
2026-05-31 17:13:30 -07:00
teknium1
0f37e051da fix(gemini): strip self provider prefix before native generateContent
Port from openclaw/openclaw#88781: a model id carrying its own provider's
prefix (google/gemini-2.0-flash, gemini/gemini-3-pro, xai/grok-4) must have
that prefix stripped before a native API call. Google's native endpoint builds
models/{model}:generateContent — a self-prefixed value produced the malformed
resource path models/google/gemini-2.0-flash:generateContent and 404'd.

- gemini_native_adapter: add bare_gemini_model_id(), strip google//gemini/ self
  prefix at URL construction (covers sync + async + stream paths).
- model_normalize: route gemini and xai through matching-prefix stripping so
  config.yaml values pasted from aggregator slugs resolve to bare native ids.
  HuggingFace stays authoritative passthrough (org/model is legitimate).
- tests: adapter helper + transport-level URL assertion; update the stale
  #6211 case that asserted gemini passthrough (now strips).
2026-05-31 17:08:30 -07:00
5 changed files with 118 additions and 3 deletions

View File

@@ -33,6 +33,39 @@ logger = logging.getLogger(__name__)
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
# Self prefixes that may ride along on a model id when a user copies an
# aggregator-style slug (``google/gemini-2.0-flash``) into config.yaml, or
# when an upstream resolver leaves the provider prefix attached. Google's
# native ``models/{model}:generateContent`` endpoint expects a bare model id
# (``gemini-2.0-flash``); a self-prefixed value produces a malformed URL like
# ``models/google/gemini-2.0-flash:generateContent`` and a 404. We strip only
# prefixes that name THIS provider so genuine slash-bearing ids are untouched.
_GEMINI_SELF_PREFIXES = ("google/", "gemini/")
def bare_gemini_model_id(model: str) -> str:
"""Strip a leading ``google/`` or ``gemini/`` self prefix from a model id.
Only the provider's own prefix is removed, case-insensitively. Any other
slash-bearing value (or a bare id) passes through unchanged so we never
mangle a legitimately namespaced model name.
Examples::
>>> bare_gemini_model_id("google/gemini-2.0-flash")
'gemini-2.0-flash'
>>> bare_gemini_model_id("gemini/gemini-3-pro-preview")
'gemini-3-pro-preview'
>>> bare_gemini_model_id("gemini-2.5-flash")
'gemini-2.5-flash'
"""
name = (model or "").strip()
lowered = name.lower()
for prefix in _GEMINI_SELF_PREFIXES:
if lowered.startswith(prefix):
return name[len(prefix):].strip() or name
return name
def is_native_gemini_base_url(base_url: str) -> bool:
"""Return True when the endpoint speaks Gemini's native REST API."""
@@ -895,6 +928,7 @@ class GeminiNativeClient:
thinking_config=thinking_config,
)
model = bare_gemini_model_id(model)
if stream:
return self._stream_completion(model=model, request=request, timeout=timeout)

View File

@@ -83,8 +83,9 @@ _STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({
})
# Providers whose native naming is authoritative -- pass through unchanged.
# HuggingFace ids are legitimately ``org/model`` (e.g. ``Qwen/Qwen3.5-397B``),
# so we must NOT strip the leading segment for it.
_AUTHORITATIVE_NATIVE_PROVIDERS: frozenset[str] = frozenset({
"gemini",
"huggingface",
})
@@ -103,6 +104,13 @@ _MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({
"arcee",
"ollama-cloud",
"custom",
# Native Google (Gemini) and xAI APIs take bare model ids. A self-prefixed
# value like ``google/gemini-2.0-flash`` or ``xai/grok-4`` — easily pasted
# from an aggregator slug into config.yaml — must have the matching prefix
# stripped before the native call, or the request 400/404s. HuggingFace is
# deliberately NOT here: its ids are legitimately ``org/model``.
"gemini",
"xai",
})
# Providers whose APIs require lowercase model IDs. Xiaomi's

View File

@@ -198,6 +198,66 @@ def test_native_client_uses_x_goog_api_key_and_native_models_endpoint(monkeypatc
assert response.choices[0].message.content == "hello"
@pytest.mark.parametrize("model_in,expected", [
("google/gemini-2.0-flash", "gemini-2.0-flash"),
("gemini/gemini-3-pro-preview", "gemini-3-pro-preview"),
("Google/Gemini-2.5-Pro", "Gemini-2.5-Pro"), # prefix match is case-insensitive; model casing preserved
("gemini-2.5-flash", "gemini-2.5-flash"), # bare id unchanged
("models/gemini-x", "models/gemini-x"), # non-self prefix unchanged
("tunedModels/my-tune", "tunedModels/my-tune"), # legit Gemini namespaced id unchanged
("", ""),
])
def test_bare_gemini_model_id_strips_only_self_prefix(model_in, expected):
from agent.gemini_native_adapter import bare_gemini_model_id
assert bare_gemini_model_id(model_in) == expected
def test_native_client_strips_self_prefix_from_model_url(monkeypatch):
"""A self-prefixed model id must resolve to a bare native resource path.
Port of openclaw/openclaw#88781. Before the fix, ``google/gemini-2.0-flash``
built ``models/google/gemini-2.0-flash:generateContent`` — an invalid
Google resource name that 404s. The adapter now strips the matching
self-prefix at URL construction so the request reaches the real model.
"""
from agent.gemini_native_adapter import GeminiNativeClient
recorded = {}
class DummyHTTP:
def post(self, url, json=None, headers=None, timeout=None):
recorded["url"] = url
return DummyResponse(
payload={
"candidates": [
{"content": {"parts": [{"text": "ok"}]}, "finishReason": "STOP"}
],
"usageMetadata": {
"promptTokenCount": 1,
"candidatesTokenCount": 1,
"totalTokenCount": 2,
},
}
)
def close(self):
return None
monkeypatch.setattr("agent.gemini_native_adapter.httpx.Client", lambda *a, **k: DummyHTTP())
client = GeminiNativeClient(api_key="AIza-test", base_url="https://generativelanguage.googleapis.com/v1beta")
client.chat.completions.create(
model="google/gemini-2.0-flash",
messages=[{"role": "user", "content": "Hello"}],
)
assert recorded["url"] == (
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
)
assert "models/google/" not in recorded["url"]
def test_native_http_error_keeps_status_and_retry_after():
from agent.gemini_native_adapter import gemini_http_error

View File

@@ -143,7 +143,11 @@ class TestGeminiModelNormalization:
assert normalize_model_for_provider("gemini-2.5-flash", "gemini") == "gemini-2.5-flash"
def test_strip_vendor_prefix(self):
assert normalize_model_for_provider("google/gemini-2.5-flash", "gemini") == "google/gemini-2.5-flash"
# Native Gemini takes a bare model id; a self-prefix (``google/`` or
# ``gemini/``) must be stripped before the generateContent call, or the
# request 404s on a malformed ``models/google/...`` resource path.
assert normalize_model_for_provider("google/gemini-2.5-flash", "gemini") == "gemini-2.5-flash"
assert normalize_model_for_provider("gemini/gemini-2.5-flash", "gemini") == "gemini-2.5-flash"
def test_gemma_vendor_detection(self):
assert detect_vendor("gemma-4-31b-it") == "google"

View File

@@ -167,10 +167,19 @@ class TestAggregatorProviders:
class TestIssue6211NativeProviderPrefixNormalization:
@pytest.mark.parametrize("model,target_provider,expected", [
("zai/glm-5.1", "zai", "glm-5.1"),
("google/gemini-2.5-pro", "gemini", "google/gemini-2.5-pro"),
# Native Gemini/xAI now strip the matching self-prefix before the
# native API call (port of openclaw/openclaw#88781). Sending
# ``google/gemini-2.5-pro`` to generativelanguage.googleapis.com builds
# a malformed ``models/google/...`` resource path and 404s.
("google/gemini-2.5-pro", "gemini", "gemini-2.5-pro"),
("gemini/gemini-2.5-pro", "gemini", "gemini-2.5-pro"),
("xai/grok-4-fast-reasoning", "xai", "grok-4-fast-reasoning"),
("moonshot/kimi-k2.5", "kimi-coding", "kimi-k2.5"),
("anthropic/claude-sonnet-4.6", "openrouter", "anthropic/claude-sonnet-4.6"),
# HuggingFace ids are legitimately ``org/model`` — never stripped.
("Qwen/Qwen3.5-397B-A17B", "huggingface", "Qwen/Qwen3.5-397B-A17B"),
# Cross-vendor prefixes on a native provider stay untouched.
("openai/gpt-5.4", "xai", "openai/gpt-5.4"),
("modal/zai-org/GLM-5-FP8", "custom", "modal/zai-org/GLM-5-FP8"),
])
def test_native_provider_prefixes_are_only_stripped_on_matching_provider(