fix(gemini): tighten native routing and streaming replay
- only use the native adapter for the canonical Gemini native endpoint - keep custom and /openai base URLs on the OpenAI-compatible path - preserve Hermes keepalive transport injection for native Gemini clients - stabilize streaming tool-call replay across repeated SSE events - add follow-up tests for base_url precedence, async streaming, and duplicate tool-call chunks
This commit is contained in:
@@ -186,6 +186,43 @@ def test_native_http_error_keeps_status_and_retry_after():
|
||||
assert "quota exhausted" in str(err)
|
||||
|
||||
|
||||
def test_native_client_accepts_injected_http_client():
|
||||
from agent.gemini_native_adapter import GeminiNativeClient
|
||||
|
||||
injected = SimpleNamespace(close=lambda: None)
|
||||
client = GeminiNativeClient(api_key="AIza-test", http_client=injected)
|
||||
assert client._http is injected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_native_client_streams_without_requiring_async_iterator_from_sync_client():
|
||||
from agent.gemini_native_adapter import AsyncGeminiNativeClient
|
||||
|
||||
chunk = SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content="hi"), finish_reason=None)])
|
||||
sync_stream = iter([chunk])
|
||||
|
||||
def _advance(iterator):
|
||||
try:
|
||||
return False, next(iterator)
|
||||
except StopIteration:
|
||||
return True, None
|
||||
|
||||
sync_client = SimpleNamespace(
|
||||
api_key="AIza-test",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta",
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **kwargs: sync_stream)),
|
||||
_advance_stream_iterator=_advance,
|
||||
close=lambda: None,
|
||||
)
|
||||
|
||||
async_client = AsyncGeminiNativeClient(sync_client)
|
||||
stream = await async_client.chat.completions.create(stream=True)
|
||||
collected = []
|
||||
async for item in stream:
|
||||
collected.append(item)
|
||||
assert collected == [chunk]
|
||||
|
||||
|
||||
def test_stream_event_translation_emits_tool_call_delta_with_stable_index():
|
||||
from agent.gemini_native_adapter import translate_stream_event
|
||||
|
||||
@@ -209,4 +246,30 @@ def test_stream_event_translation_emits_tool_call_delta_with_stable_index():
|
||||
assert first[0].choices[0].delta.tool_calls[0].index == 0
|
||||
assert second[0].choices[0].delta.tool_calls[0].index == 0
|
||||
assert first[0].choices[0].delta.tool_calls[0].id == second[0].choices[0].delta.tool_calls[0].id
|
||||
assert first[0].choices[0].delta.tool_calls[0].function.arguments == '{"q": "abc"}'
|
||||
assert second[0].choices[0].delta.tool_calls[0].function.arguments == ""
|
||||
assert first[-1].choices[0].finish_reason == "tool_calls"
|
||||
|
||||
|
||||
def test_stream_event_translation_keeps_identical_calls_in_distinct_parts():
|
||||
from agent.gemini_native_adapter import translate_stream_event
|
||||
|
||||
event = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"functionCall": {"name": "search", "args": {"q": "abc"}}},
|
||||
{"functionCall": {"name": "search", "args": {"q": "abc"}}},
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
chunks = translate_stream_event(event, model="gemini-2.5-flash", tool_call_indices={})
|
||||
tool_chunks = [chunk for chunk in chunks if chunk.choices[0].delta.tool_calls]
|
||||
assert tool_chunks[0].choices[0].delta.tool_calls[0].index == 0
|
||||
assert tool_chunks[1].choices[0].delta.tool_calls[0].index == 1
|
||||
assert tool_chunks[0].choices[0].delta.tool_calls[0].id != tool_chunks[1].choices[0].delta.tool_calls[0].id
|
||||
|
||||
@@ -13,7 +13,7 @@ class TestCustomProvidersValidation:
|
||||
issues = validate_config_structure({
|
||||
"custom_providers": {
|
||||
"name": "Generativelanguage.googleapis.com",
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
||||
"api_key": "xxx",
|
||||
"model": "models/gemini-2.5-flash",
|
||||
"rate_limit_delay": 2.0,
|
||||
|
||||
@@ -210,8 +210,10 @@ class TestGeminiAgentInit:
|
||||
def test_gemini_agent_uses_native_client(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "AIzaSy_REAL_KEY")
|
||||
with patch("agent.gemini_native_adapter.GeminiNativeClient") as mock_client, \
|
||||
patch("run_agent.OpenAI") as mock_openai:
|
||||
patch("run_agent.OpenAI") as mock_openai, \
|
||||
patch("run_agent.ContextCompressor") as mock_compressor:
|
||||
mock_client.return_value = MagicMock()
|
||||
mock_compressor.return_value = MagicMock(context_length=1048576, threshold_tokens=524288)
|
||||
from run_agent import AIAgent
|
||||
AIAgent(
|
||||
model="gemini-2.5-flash",
|
||||
@@ -222,6 +224,38 @@ class TestGeminiAgentInit:
|
||||
assert mock_client.called
|
||||
mock_openai.assert_not_called()
|
||||
|
||||
def test_gemini_custom_base_url_keeps_openai_client(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "AIzaSy_REAL_KEY")
|
||||
with patch("agent.gemini_native_adapter.GeminiNativeClient") as mock_client, \
|
||||
patch("run_agent.OpenAI") as mock_openai, \
|
||||
patch("run_agent.ContextCompressor") as mock_compressor:
|
||||
mock_openai.return_value = MagicMock()
|
||||
mock_compressor.return_value = MagicMock(context_length=128000, threshold_tokens=64000)
|
||||
from run_agent import AIAgent
|
||||
AIAgent(
|
||||
model="gemini-2.5-flash",
|
||||
provider="gemini",
|
||||
api_key="AIzaSy_REAL_KEY",
|
||||
base_url="https://proxy.example.com/v1",
|
||||
)
|
||||
mock_openai.assert_called_once()
|
||||
|
||||
def test_gemini_openai_compat_base_url_keeps_openai_client(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "AIzaSy_REAL_KEY")
|
||||
with patch("agent.gemini_native_adapter.GeminiNativeClient") as mock_client, \
|
||||
patch("run_agent.OpenAI") as mock_openai, \
|
||||
patch("run_agent.ContextCompressor") as mock_compressor:
|
||||
mock_openai.return_value = MagicMock()
|
||||
mock_compressor.return_value = MagicMock(context_length=1048576, threshold_tokens=524288)
|
||||
from run_agent import AIAgent
|
||||
AIAgent(
|
||||
model="gemini-2.5-flash",
|
||||
provider="gemini",
|
||||
api_key="AIzaSy_REAL_KEY",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
)
|
||||
mock_openai.assert_called_once()
|
||||
|
||||
def test_gemini_resolve_provider_client_uses_native_client(self, monkeypatch):
|
||||
"""resolve_provider_client('gemini') should build GeminiNativeClient."""
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "AIzaSy_TEST_KEY")
|
||||
@@ -233,6 +267,16 @@ class TestGeminiAgentInit:
|
||||
assert mock_client.called
|
||||
mock_openai.assert_not_called()
|
||||
|
||||
def test_gemini_resolve_provider_client_keeps_openai_for_non_native_base_url(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "AIzaSy_TEST_KEY")
|
||||
monkeypatch.setenv("GEMINI_BASE_URL", "https://proxy.example.com/v1")
|
||||
with patch("agent.gemini_native_adapter.GeminiNativeClient") as mock_client, \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
mock_openai.return_value = MagicMock()
|
||||
from agent.auxiliary_client import resolve_provider_client
|
||||
resolve_provider_client("gemini")
|
||||
mock_openai.assert_called_once()
|
||||
|
||||
|
||||
# ── models.dev Integration ──
|
||||
|
||||
|
||||
Reference in New Issue
Block a user