e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
5354 lines
192 KiB
Python
5354 lines
192 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Unit tests for the native Gemini API translation layer.
|
|
|
|
Gemini does NOT speak OpenAI Chat Completions on its primary endpoint
|
|
(`streamGenerateContent`). `_stream_gemini` in
|
|
`core/inference/external_provider.py` translates between the two shapes:
|
|
|
|
Request:
|
|
OpenAI messages [{role, content}]
|
|
-> Gemini contents [{role, parts: [{text}|{inlineData}|{functionCall}|...]}]
|
|
+ systemInstruction.parts[].text for role=system messages
|
|
+ generationConfig.{temperature,topP,topK,maxOutputTokens}
|
|
+ tools[{googleSearch:{}}] for web_search
|
|
+ tools[{codeExecution:{}}] for code_execution
|
|
+ responseModalities=[TEXT,IMAGE] for Nano Banana (gemini-2.5-flash-image)
|
|
+ cachedContent for prompt caching
|
|
|
|
Response:
|
|
Gemini SSE chunks { candidates:[{content:{parts:[...]}, finishReason}],
|
|
usageMetadata:{promptTokenCount, candidatesTokenCount} }
|
|
-> OpenAI chat.completion.chunk frames
|
|
(delta.content for text, delta.tool_calls for functionCall,
|
|
_toolEvent for image_b64/web_search, usage block before [DONE])
|
|
|
|
These tests pin the outbound body shape AND the inbound translation via
|
|
httpx.MockTransport (no live network). Mirrors test_anthropic_cache_ttl.py
|
|
and test_openai_image_generation.py.
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from core.inference import external_provider as ep_mod
|
|
from core.inference.external_provider import ExternalProviderClient
|
|
|
|
|
|
_active_mock_clients: list[httpx.AsyncClient] = []
|
|
|
|
|
|
def _drive(coro):
|
|
# Fresh loop per drive so tests don't share asyncio state. Close mocked
|
|
# clients + shutdown async-generators inside this loop so Python 3.13
|
|
# doesn't emit `Response.aiter_*.aclose was never awaited` on GC.
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
result = loop.run_until_complete(coro)
|
|
while _active_mock_clients:
|
|
mc = _active_mock_clients.pop()
|
|
loop.run_until_complete(mc.aclose())
|
|
return result
|
|
finally:
|
|
try:
|
|
loop.run_until_complete(loop.shutdown_asyncgens())
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
def _make_gemini_client(
|
|
base_url: str = "https://generativelanguage.googleapis.com/v1beta",
|
|
) -> ExternalProviderClient:
|
|
return ExternalProviderClient(
|
|
provider_type = "gemini",
|
|
base_url = base_url,
|
|
api_key = "AIza-test-key",
|
|
)
|
|
|
|
|
|
def _mock_http(monkeypatch, handler):
|
|
mock_client = httpx.AsyncClient(transport = httpx.MockTransport(handler))
|
|
monkeypatch.setattr(ep_mod, "_http_client", mock_client)
|
|
# `_drive` acloses this at end of run inside the same event loop, so we
|
|
# don't leak an unawaited aclose() coroutine.
|
|
_active_mock_clients.append(mock_client)
|
|
|
|
|
|
def _gemini_sse(events: list[dict]) -> bytes:
|
|
"""Encode a list of dicts as Gemini-style SSE frames (`data:` lines)."""
|
|
chunks: list[str] = []
|
|
for event in events:
|
|
chunks.append(f"data: {json.dumps(event)}")
|
|
chunks.append("")
|
|
return ("\n".join(chunks) + "\n").encode("utf-8")
|
|
|
|
|
|
def _capture_body(monkeypatch, **kwargs) -> dict:
|
|
"""Drive a single stream and return the captured outbound request body."""
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["body"] = json.loads(request.content.decode("utf-8"))
|
|
captured["headers"] = dict(request.headers)
|
|
captured["url"] = str(request.url)
|
|
captured["method"] = request.method
|
|
# Minimal valid Gemini stream so the helper completes.
|
|
return httpx.Response(
|
|
200,
|
|
content = _gemini_sse(
|
|
[
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [{"text": "ok"}],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 1,
|
|
"candidatesTokenCount": 1,
|
|
},
|
|
}
|
|
]
|
|
),
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
messages = kwargs.pop("messages", [{"role": "user", "content": "hi"}])
|
|
model = kwargs.pop("model", "gemini-2.5-flash")
|
|
temperature = kwargs.pop("temperature", 0.7)
|
|
top_p = kwargs.pop("top_p", 0.95)
|
|
max_tokens = kwargs.pop("max_tokens", 64)
|
|
|
|
async def run():
|
|
client = _make_gemini_client()
|
|
async for _ in client.stream_chat_completion(
|
|
messages = messages,
|
|
model = model,
|
|
temperature = temperature,
|
|
top_p = top_p,
|
|
max_tokens = max_tokens,
|
|
**kwargs,
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
return captured
|
|
|
|
|
|
def _collect(monkeypatch, sse_events, **kwargs) -> list[str]:
|
|
"""Drive a stream with a custom set of SSE events and return raw lines."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
content = _gemini_sse(sse_events),
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
messages = kwargs.pop("messages", [{"role": "user", "content": "hi"}])
|
|
model = kwargs.pop("model", "gemini-2.5-flash")
|
|
temperature = kwargs.pop("temperature", 0.7)
|
|
top_p = kwargs.pop("top_p", 0.95)
|
|
max_tokens = kwargs.pop("max_tokens", 64)
|
|
|
|
out: list[str] = []
|
|
|
|
async def run():
|
|
client = _make_gemini_client()
|
|
async for line in client.stream_chat_completion(
|
|
messages = messages,
|
|
model = model,
|
|
temperature = temperature,
|
|
top_p = top_p,
|
|
max_tokens = max_tokens,
|
|
**kwargs,
|
|
):
|
|
out.append(line)
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
return out
|
|
|
|
|
|
def _parse_chunks(lines: list[str]) -> list[dict]:
|
|
out: list[dict] = []
|
|
for raw in lines:
|
|
if not raw.startswith("data:"):
|
|
continue
|
|
payload = raw[len("data:") :].strip()
|
|
if not payload or payload == "[DONE]":
|
|
continue
|
|
try:
|
|
out.append(json.loads(payload))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
return out
|
|
|
|
|
|
# ── request body translation ─────────────────────────────────────────
|
|
|
|
|
|
def test_request_body_uses_contents_and_parts_shape(monkeypatch):
|
|
"""OpenAI messages must be translated to Gemini's `contents` shape."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
messages = [
|
|
{"role": "system", "content": "Be brief."},
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi there"},
|
|
{"role": "user", "content": "Follow up"},
|
|
],
|
|
)
|
|
body = captured["body"]
|
|
# system -> systemInstruction
|
|
assert body["systemInstruction"] == {"parts": [{"text": "Be brief."}]}, body
|
|
# user/assistant -> contents with role user/model
|
|
assert body["contents"] == [
|
|
{"role": "user", "parts": [{"text": "Hello"}]},
|
|
{"role": "model", "parts": [{"text": "Hi there"}]},
|
|
{"role": "user", "parts": [{"text": "Follow up"}]},
|
|
], body["contents"]
|
|
# generationConfig fields map across with Google's casing.
|
|
gc = body["generationConfig"]
|
|
assert gc["temperature"] == 0.7
|
|
assert gc["topP"] == 0.95
|
|
assert gc["maxOutputTokens"] == 64
|
|
|
|
|
|
def test_request_url_targets_stream_generate_content(monkeypatch):
|
|
"""Helper must POST to /v1beta/models/{model}:streamGenerateContent?alt=sse."""
|
|
captured = _capture_body(monkeypatch, model = "gemini-2.5-pro")
|
|
url = captured["url"]
|
|
assert ":streamGenerateContent" in url, url
|
|
assert "alt=sse" in url, url
|
|
assert "/v1beta/models/gemini-2.5-pro" in url, url
|
|
assert captured["method"] == "POST"
|
|
|
|
|
|
def test_request_auth_header_uses_x_goog_api_key(monkeypatch):
|
|
"""API key must be sent on `x-goog-api-key`, not Authorization."""
|
|
captured = _capture_body(monkeypatch)
|
|
hdrs = captured["headers"]
|
|
assert hdrs.get("x-goog-api-key") == "AIza-test-key", hdrs
|
|
assert "authorization" not in {k.lower() for k in hdrs}, hdrs
|
|
|
|
|
|
def test_top_k_forwarded_only_when_positive(monkeypatch):
|
|
"""top_k is opt-in; only positive integers reach the wire."""
|
|
captured = _capture_body(monkeypatch, top_k = 40)
|
|
assert captured["body"]["generationConfig"]["topK"] == 40
|
|
|
|
captured = _capture_body(monkeypatch, top_k = 0)
|
|
assert "topK" not in captured["body"]["generationConfig"]
|
|
|
|
|
|
def test_presence_penalty_forwarded_to_generation_config(monkeypatch):
|
|
"""A non-zero presence_penalty reaches generationConfig.presencePenalty."""
|
|
captured = _capture_body(monkeypatch, presence_penalty = 0.7)
|
|
assert captured["body"]["generationConfig"]["presencePenalty"] == 0.7
|
|
|
|
# Default zero is omitted, matching top_k semantics.
|
|
captured = _capture_body(monkeypatch, presence_penalty = 0.0)
|
|
assert "presencePenalty" not in captured["body"]["generationConfig"]
|
|
|
|
|
|
# ── thinkingConfig translation ────────────────────────────────────────
|
|
|
|
|
|
def test_gemini25_flash_thinking_disabled_sets_budget_zero(monkeypatch):
|
|
"""Gemini 2.5 Flash still uses thinkingBudget; 0 = off."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-2.5-flash",
|
|
enable_thinking = False,
|
|
)
|
|
tc = captured["body"]["generationConfig"].get("thinkingConfig")
|
|
assert tc == {"thinkingBudget": 0}, tc
|
|
|
|
|
|
def test_gemini3_flash_thinking_disabled_uses_minimal_level(monkeypatch):
|
|
"""Gemini 3 Flash uses thinkingLevel; "off" maps to minimal
|
|
(Gemini 3 cannot turn thinking fully off)."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-3.5-flash",
|
|
enable_thinking = False,
|
|
)
|
|
tc = captured["body"]["generationConfig"].get("thinkingConfig")
|
|
assert tc == {"thinkingLevel": "minimal"}, tc
|
|
|
|
|
|
def test_gemini25_pro_thinking_disabled_uses_small_budget(monkeypatch):
|
|
"""Gemini 2.5 Pro 400s on thinkingBudget=0 ("only works in thinking
|
|
mode"); coerce to a small positive budget."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-2.5-pro",
|
|
enable_thinking = False,
|
|
)
|
|
tc = captured["body"]["generationConfig"].get("thinkingConfig")
|
|
assert tc is not None and tc.get("thinkingBudget", 0) > 0, tc
|
|
|
|
|
|
def test_gemini3_pro_thinking_disabled_uses_low_level(monkeypatch):
|
|
"""Gemini 3 Pro uses thinkingLevel and rejects 'minimal' (Pro tier), so
|
|
'off' coerces to 'low' (lowest the API accepts)."""
|
|
for model in (
|
|
"gemini-3.1-pro-preview",
|
|
"gemini-3-pro-preview",
|
|
"gemini-3.5-pro",
|
|
"gemini-pro-latest",
|
|
):
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = model,
|
|
enable_thinking = False,
|
|
)
|
|
tc = captured["body"]["generationConfig"].get("thinkingConfig")
|
|
assert tc == {"thinkingLevel": "low"}, (model, tc)
|
|
|
|
|
|
def test_gemini25_flash_effort_levels_map_to_budgets(monkeypatch):
|
|
"""Gemini 2.5 Flash retains the integer thinkingBudget ladder."""
|
|
cases = {
|
|
"minimal": 512,
|
|
"low": 2048,
|
|
"medium": 8192,
|
|
"high": 24576,
|
|
"max": -1,
|
|
"xhigh": -1,
|
|
}
|
|
for effort, expected in cases.items():
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-2.5-flash",
|
|
reasoning_effort = effort,
|
|
)
|
|
tc = captured["body"]["generationConfig"].get("thinkingConfig")
|
|
assert tc == {"thinkingBudget": expected}, (effort, tc)
|
|
|
|
|
|
def test_gemini3_flash_effort_levels_map_to_thinking_level(monkeypatch):
|
|
"""Gemini 3 Flash thinkingLevel ladder: minimal/low/medium/high."""
|
|
cases = {
|
|
"minimal": "minimal",
|
|
"low": "low",
|
|
"medium": "medium",
|
|
"high": "high",
|
|
"max": "high",
|
|
}
|
|
for effort, expected in cases.items():
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-3.5-flash",
|
|
reasoning_effort = effort,
|
|
)
|
|
tc = captured["body"]["generationConfig"].get("thinkingConfig")
|
|
assert tc == {"thinkingLevel": expected}, (effort, tc)
|
|
|
|
|
|
def test_gemini3_pro_passes_medium_through(monkeypatch):
|
|
"""Gemini 3.1+ Pro accepts thinkingLevel="medium" per
|
|
https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-1-pro;
|
|
forward as-is (medium is the documented mid-tier on Gemini 3.1)."""
|
|
for model in (
|
|
"gemini-3.1-pro-preview",
|
|
"gemini-pro-latest",
|
|
):
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = model,
|
|
reasoning_effort = "medium",
|
|
)
|
|
tc = captured["body"]["generationConfig"].get("thinkingConfig")
|
|
assert tc == {"thinkingLevel": "medium"}, (model, tc)
|
|
|
|
|
|
def test_gemini3_pro_minimal_effort_coerces_to_low(monkeypatch):
|
|
"""Gemini 3 Pro rejects thinkingLevel="minimal"; coerce to "low"."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-3.1-pro-preview",
|
|
reasoning_effort = "minimal",
|
|
)
|
|
tc = captured["body"]["generationConfig"].get("thinkingConfig")
|
|
assert tc == {"thinkingLevel": "low"}, tc
|
|
|
|
|
|
def test_gemini3_flash_effort_none_maps_to_minimal(monkeypatch):
|
|
"""reasoning_effort='none' on Gemini 3 Flash -> thinkingLevel=minimal."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-3.5-flash",
|
|
reasoning_effort = "none",
|
|
)
|
|
tc = captured["body"]["generationConfig"].get("thinkingConfig")
|
|
assert tc == {"thinkingLevel": "minimal"}, tc
|
|
|
|
|
|
def test_thinking_default_omits_thinking_config(monkeypatch):
|
|
"""When neither knob is supplied, thinkingConfig is omitted (Google's
|
|
server-side default applies)."""
|
|
captured = _capture_body(monkeypatch, model = "gemini-3.5-flash")
|
|
gc = captured["body"]["generationConfig"]
|
|
assert "thinkingConfig" not in gc, gc
|
|
|
|
|
|
def test_nano_banana_alias_routes_through_image_modalities(monkeypatch):
|
|
"""`nano-banana-pro-preview` aliases the Pro image model; must set
|
|
responseModalities=[TEXT,IMAGE] when the Images pill is on
|
|
(enabled_tools includes "image_generation")."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "nano-banana-pro-preview",
|
|
enabled_tools = ["image_generation"],
|
|
)
|
|
gc = captured["body"]["generationConfig"]
|
|
assert gc.get("responseModalities") == ["TEXT", "IMAGE"], gc
|
|
|
|
|
|
def test_image_capable_model_without_image_pill_stays_text_only(monkeypatch):
|
|
"""When the Images pill is off (no image_generation in enabled_tools), an
|
|
image-capable model id (gemini-2.5-flash-image) must force
|
|
responseModalities=["TEXT"]. Google's image models default to text+image
|
|
when responseModalities is omitted, so omitting it would silently bill
|
|
image output the UI says is disabled."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-2.5-flash-image",
|
|
enabled_tools = [],
|
|
)
|
|
gc = captured["body"]["generationConfig"]
|
|
assert gc.get("responseModalities") == ["TEXT"], gc
|
|
|
|
|
|
def test_image_models_skip_thinking_config(monkeypatch):
|
|
"""Image-tier ids have no visible thinking knob and must NOT forward
|
|
thinkingConfig even when stale UI state still sends `reasoning_effort` or
|
|
`enable_thinking=False`."""
|
|
for model in (
|
|
"gemini-2.5-flash-image",
|
|
"gemini-3.1-flash-image-preview",
|
|
"gemini-3-pro-image-preview",
|
|
"nano-banana-pro-preview",
|
|
):
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = model,
|
|
reasoning_effort = "high",
|
|
enable_thinking = False,
|
|
enabled_tools = ["image_generation"],
|
|
)
|
|
gc = captured["body"]["generationConfig"]
|
|
assert "thinkingConfig" not in gc, (model, gc)
|
|
|
|
|
|
def test_image_models_drop_code_execution(monkeypatch):
|
|
"""All image-tier ids reject `tools: [{codeExecution: {}}]`; drop
|
|
silently. (Gemini 3 image models DO accept googleSearch -- see
|
|
test_gemini3_image_models_allow_google_search; older ones drop
|
|
everything.)"""
|
|
for model in (
|
|
"gemini-2.5-flash-image",
|
|
"gemini-3.1-flash-image-preview",
|
|
"gemini-3-pro-image-preview",
|
|
"nano-banana-pro-preview",
|
|
):
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = model,
|
|
enabled_tools = ["image_generation", "code_execution"],
|
|
)
|
|
tools_arr = captured["body"].get("tools") or []
|
|
names = [list(t.keys())[0] for t in tools_arr]
|
|
assert "codeExecution" not in names, (model, tools_arr)
|
|
|
|
|
|
def test_gemini_35_pro_uses_thinking_level(monkeypatch):
|
|
"""`gemini-3.5-pro` is Gemini 3 family and uses thinkingLevel (not
|
|
thinkingBudget). "Off" maps to "low" since Pro tier rejects "minimal"."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-3.5-pro",
|
|
enable_thinking = False,
|
|
)
|
|
tc = captured["body"]["generationConfig"].get("thinkingConfig")
|
|
assert tc == {"thinkingLevel": "low"}, tc
|
|
|
|
|
|
def test_gemini3_image_models_allow_google_search(monkeypatch):
|
|
"""Google documents Search grounding on the Gemini 3 image family
|
|
(gemini-3-pro-image-preview, gemini-3.1-flash-image-preview,
|
|
nano-banana-pro). codeExecution stays blocked on image mode."""
|
|
for model in (
|
|
"gemini-3-pro-image-preview",
|
|
"gemini-3.1-flash-image-preview",
|
|
"nano-banana-pro-preview",
|
|
):
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = model,
|
|
enabled_tools = ["image_generation", "web_search", "code_execution"],
|
|
)
|
|
tools_arr = captured["body"].get("tools") or []
|
|
names = [list(t.keys())[0] for t in tools_arr]
|
|
assert "googleSearch" in names, (model, tools_arr)
|
|
assert "codeExecution" not in names, (model, tools_arr)
|
|
|
|
|
|
def test_legacy_image_models_block_google_search(monkeypatch):
|
|
"""Older Gemini image ids (gemini-2.5-flash-image) still 400 on
|
|
`tools: [{googleSearch: {}}]`; backend keeps stripping it."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-2.5-flash-image",
|
|
enabled_tools = ["image_generation", "web_search", "code_execution"],
|
|
)
|
|
assert "tools" not in captured["body"], captured["body"].get("tools")
|
|
|
|
|
|
def test_legacy_openai_base_url_normalized(monkeypatch):
|
|
"""Saved Gemini providers with the legacy `/v1beta/openai` base (from
|
|
pre-PR OpenAI-compat plumbing) now point at the native endpoint without
|
|
the user re-saving the connection."""
|
|
client = ExternalProviderClient(
|
|
provider_type = "gemini",
|
|
base_url = "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
api_key = "AIza-test-key",
|
|
)
|
|
assert client.base_url == "https://generativelanguage.googleapis.com/v1beta"
|
|
|
|
|
|
def test_finish_reason_swaps_to_tool_calls_when_function_call_emitted(monkeypatch):
|
|
"""Gemini emits finishReason="STOP" even for pure functionCall turns;
|
|
surface as `tool_calls` so OAI clients run the tool."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [{"functionCall": {"name": "lookup", "args": {"k": "v"}}}],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
]
|
|
}
|
|
]
|
|
lines = _collect(monkeypatch, sse)
|
|
chunks = _parse_chunks(lines)
|
|
finish_chunks = [
|
|
c for c in chunks if c.get("choices", [{}])[0].get("finish_reason") is not None
|
|
]
|
|
assert finish_chunks, chunks
|
|
assert finish_chunks[-1]["choices"][0]["finish_reason"] == "tool_calls", chunks
|
|
|
|
|
|
def test_thought_signature_round_trips_into_gemini_function_call(monkeypatch):
|
|
"""An assistant tool_call carrying `extra_content.google.thought_signature`
|
|
must echo it back as a sibling of the Gemini functionCall part."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
messages = [
|
|
{"role": "user", "content": "lookup x"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_0",
|
|
"type": "function",
|
|
"function": {"name": "lookup", "arguments": "{}"},
|
|
"extra_content": {"google": {"thought_signature": "SIG-ABC"}},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_0",
|
|
"name": "lookup",
|
|
"content": "{}",
|
|
},
|
|
],
|
|
)
|
|
contents = captured["body"]["contents"]
|
|
fc_turn = next((c for c in contents if c["role"] == "model"), None)
|
|
assert fc_turn is not None, contents
|
|
fc_part = next(
|
|
(p for p in fc_turn["parts"] if "functionCall" in p),
|
|
None,
|
|
)
|
|
assert fc_part is not None, fc_turn
|
|
assert fc_part.get("thoughtSignature") == "SIG-ABC", fc_part
|
|
|
|
|
|
def test_thought_signature_emitted_in_tool_call_delta(monkeypatch):
|
|
"""A Gemini functionCall part with `thoughtSignature` must surface it on
|
|
the outbound OpenAI tool_calls delta via
|
|
`extra_content.google.thought_signature`."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [
|
|
{
|
|
"functionCall": {
|
|
"name": "lookup",
|
|
"args": {"k": "v"},
|
|
"id": "call_xyz",
|
|
},
|
|
"thoughtSignature": "SIG-FROM-GEMINI",
|
|
}
|
|
],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
]
|
|
}
|
|
]
|
|
chunks = _parse_chunks(_collect(monkeypatch, sse))
|
|
deltas = [
|
|
tc
|
|
for c in chunks
|
|
for tc in (c.get("choices", [{}])[0].get("delta", {}) or {}).get("tool_calls", [])
|
|
]
|
|
assert deltas, chunks
|
|
sig = deltas[0].get("extra_content", {}).get("google", {}).get("thought_signature")
|
|
assert sig == "SIG-FROM-GEMINI", deltas
|
|
|
|
|
|
def test_image_models_suppress_phantom_web_search_card(monkeypatch):
|
|
"""When the image guard filters googleSearch out of the request, the
|
|
inbound stream must NOT emit web_search tool_start / tool_end (else the UI
|
|
shows a misleading 'Search complete' card on a turn Gemini never
|
|
searched)."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {"role": "model", "parts": [{"text": "drawn"}]},
|
|
"finishReason": "STOP",
|
|
}
|
|
]
|
|
}
|
|
]
|
|
lines = _collect(
|
|
monkeypatch,
|
|
sse,
|
|
model = "gemini-2.5-flash-image",
|
|
enabled_tools = ["image_generation", "web_search", "code_execution"],
|
|
)
|
|
chunks = _parse_chunks(lines)
|
|
tool_evs = [
|
|
ev
|
|
for c in chunks
|
|
for ev in [c.get("_toolEvent")]
|
|
if isinstance(ev, dict) and ev.get("tool_name") == "web_search"
|
|
]
|
|
assert tool_evs == [], tool_evs
|
|
|
|
|
|
def test_image_generation_tool_on_image_model_drops_text_tools(monkeypatch):
|
|
"""`enabled_tools=["image_generation", "web_search", "code_execution"]`
|
|
on a Gemini IMAGE model flips responseModalities to TEXT+IMAGE; in that
|
|
mode codeExecution must NOT be forwarded (Gemini rejects text code tools
|
|
alongside image responseModalities). Older image families also drop
|
|
googleSearch."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-2.5-flash-image",
|
|
enabled_tools = [
|
|
"image_generation",
|
|
"web_search",
|
|
"code_execution",
|
|
],
|
|
)
|
|
assert "tools" not in captured["body"], captured["body"]
|
|
assert captured["body"]["generationConfig"].get("responseModalities") == ["TEXT", "IMAGE"]
|
|
|
|
|
|
def test_prompt_feedback_block_reason_surfaces_as_error(monkeypatch):
|
|
"""`promptFeedback.blockReason` with zero candidates must produce an error
|
|
chunk, not a silent empty assistant reply."""
|
|
sse = [
|
|
{
|
|
"promptFeedback": {"blockReason": "SAFETY"},
|
|
}
|
|
]
|
|
chunks = _parse_chunks(_collect(monkeypatch, sse))
|
|
error_chunks = [c for c in chunks if "error" in c]
|
|
assert error_chunks, chunks
|
|
assert "SAFETY" in (error_chunks[0].get("error", {}).get("message") or ""), error_chunks
|
|
|
|
|
|
def test_usage_chunk_includes_thoughts_tokens(monkeypatch):
|
|
"""`thoughtsTokenCount` is the hidden-reasoning slice of output; roll it
|
|
into `output_tokens` AND surface it on
|
|
`output_tokens_details.reasoning_tokens` so total_tokens reflects the full
|
|
billable spend."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {"role": "model", "parts": [{"text": "ok"}]},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 10,
|
|
"candidatesTokenCount": 5,
|
|
"thoughtsTokenCount": 20,
|
|
"totalTokenCount": 35,
|
|
},
|
|
}
|
|
]
|
|
chunks = _parse_chunks(_collect(monkeypatch, sse))
|
|
usage_chunk = next((c for c in chunks if isinstance(c.get("usage"), dict)), None)
|
|
assert usage_chunk is not None, chunks
|
|
usage = usage_chunk["usage"]
|
|
assert usage.get("prompt_tokens") == 10, usage
|
|
# candidates 5 + thoughts 20 = 25 output tokens; total = 35.
|
|
assert usage.get("completion_tokens") == 25, usage
|
|
assert usage.get("total_tokens") == 35, usage
|
|
|
|
|
|
# ── web_search forwarded as googleSearch tool ────────────────────────
|
|
|
|
|
|
def test_web_search_forwarded_as_google_search_tool(monkeypatch):
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
enabled_tools = ["web_search"],
|
|
)
|
|
tools = captured["body"].get("tools") or []
|
|
assert {"googleSearch": {}} in tools, tools
|
|
|
|
|
|
def test_code_execution_forwarded_as_code_execution_tool(monkeypatch):
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
enabled_tools = ["code_execution"],
|
|
)
|
|
tools = captured["body"].get("tools") or []
|
|
assert {"codeExecution": {}} in tools, tools
|
|
|
|
|
|
def test_omitted_tools_leaves_body_untouched(monkeypatch):
|
|
captured = _capture_body(monkeypatch, enabled_tools = [])
|
|
assert "tools" not in captured["body"], captured["body"]
|
|
|
|
|
|
# ── prompt caching passthrough ───────────────────────────────────────
|
|
|
|
|
|
def test_cached_content_pass_through(monkeypatch):
|
|
"""A string cache id on enable_prompt_caching is forwarded verbatim."""
|
|
cache_name = "cachedContents/abc123"
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
enable_prompt_caching = cache_name,
|
|
)
|
|
assert captured["body"].get("cachedContent") == cache_name
|
|
|
|
|
|
def test_boolean_caching_does_not_set_cached_content(monkeypatch):
|
|
"""Studio's existing True/False signals shouldn't fabricate a cache id."""
|
|
captured = _capture_body(monkeypatch, enable_prompt_caching = True)
|
|
assert "cachedContent" not in captured["body"]
|
|
|
|
|
|
# ── image generation: request modalities + response translation ──────
|
|
|
|
|
|
def test_image_model_sets_response_modalities(monkeypatch):
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-2.5-flash-image",
|
|
enabled_tools = ["image_generation"],
|
|
)
|
|
assert captured["body"]["generationConfig"]["responseModalities"] == ["TEXT", "IMAGE"]
|
|
|
|
|
|
def test_image_generation_tool_sets_response_modalities_on_image_model(monkeypatch):
|
|
"""`enabled_tools=["image_generation"]` flips responseModalities
|
|
only when the selected model is image-capable; otherwise the
|
|
request stays plain text (text-only models 400 on
|
|
responseModalities)."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-2.5-flash-image",
|
|
enabled_tools = ["image_generation"],
|
|
)
|
|
assert captured["body"]["generationConfig"]["responseModalities"] == ["TEXT", "IMAGE"]
|
|
|
|
|
|
def test_image_response_emits_image_b64_tool_event(monkeypatch):
|
|
"""`inlineData` parts become a tool_end with image_b64 + image_mime."""
|
|
fake_b64 = base64.b64encode(b"PNG-BYTES").decode()
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [
|
|
{
|
|
"inlineData": {
|
|
"mimeType": "image/png",
|
|
"data": fake_b64,
|
|
}
|
|
}
|
|
],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 5,
|
|
"candidatesTokenCount": 0,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(
|
|
monkeypatch,
|
|
sse,
|
|
model = "gemini-2.5-flash-image",
|
|
)
|
|
chunks = _parse_chunks(lines)
|
|
tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
|
|
starts = [e for e in tool_events if e.get("type") == "tool_start"]
|
|
ends = [e for e in tool_events if e.get("type") == "tool_end"]
|
|
image_starts = [e for e in starts if e.get("tool_name") == "image_generation"]
|
|
image_ends = [e for e in ends if e.get("image_b64")]
|
|
assert len(image_starts) == 1, tool_events
|
|
assert len(image_ends) == 1, tool_events
|
|
assert image_ends[0]["image_b64"] == fake_b64
|
|
assert image_ends[0]["image_mime"] == "image/png"
|
|
|
|
|
|
# ── function calling round-trips both directions ─────────────────────
|
|
|
|
|
|
def test_function_call_response_translates_to_tool_calls_delta(monkeypatch):
|
|
"""Gemini `functionCall` parts become OpenAI `tool_calls` delta chunks."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [
|
|
{
|
|
"functionCall": {
|
|
"name": "get_weather",
|
|
"args": {"location": "Paris"},
|
|
}
|
|
}
|
|
],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 12,
|
|
"candidatesTokenCount": 4,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(monkeypatch, sse)
|
|
chunks = _parse_chunks(lines)
|
|
tool_call_chunks = [
|
|
c
|
|
for c in chunks
|
|
if "_toolEvent" not in c
|
|
and any(
|
|
(isinstance(ch.get("delta"), dict) and "tool_calls" in ch["delta"])
|
|
for ch in c.get("choices", [])
|
|
)
|
|
]
|
|
assert len(tool_call_chunks) == 1, chunks
|
|
tc = tool_call_chunks[0]["choices"][0]["delta"]["tool_calls"][0]
|
|
assert tc["function"]["name"] == "get_weather"
|
|
args = json.loads(tc["function"]["arguments"])
|
|
assert args == {"location": "Paris"}
|
|
|
|
|
|
def test_tool_message_translates_to_function_response_part(monkeypatch):
|
|
"""role=tool follow-ups are rewritten to functionResponse parts."""
|
|
messages = [
|
|
{"role": "user", "content": "Weather?"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"arguments": json.dumps({"location": "Paris"}),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"name": "get_weather",
|
|
"content": json.dumps({"temp_c": 18, "summary": "Sunny"}),
|
|
},
|
|
]
|
|
captured = _capture_body(monkeypatch, messages = messages)
|
|
contents = captured["body"]["contents"]
|
|
# Last turn must be a functionResponse part (Gemini wraps it as a role=user
|
|
# turn carrying the result).
|
|
last = contents[-1]
|
|
assert last["role"] == "user", last
|
|
fr = last["parts"][0].get("functionResponse")
|
|
assert fr is not None, last
|
|
assert fr["name"] == "get_weather"
|
|
assert fr["response"] == {"temp_c": 18, "summary": "Sunny"}
|
|
# And the assistant turn carries the original functionCall so the model
|
|
# sees the round-trip context.
|
|
assistant_turn = [c for c in contents if c["role"] == "model"][0]
|
|
fc_part = next(
|
|
(p for p in assistant_turn["parts"] if "functionCall" in p),
|
|
None,
|
|
)
|
|
assert fc_part is not None, assistant_turn
|
|
assert fc_part["functionCall"]["name"] == "get_weather"
|
|
assert fc_part["functionCall"]["args"] == {"location": "Paris"}
|
|
|
|
|
|
def test_parallel_function_calls_get_distinct_tool_call_indices(monkeypatch):
|
|
"""Each emitted functionCall in one assistant turn needs its own
|
|
tool_calls[*].index. Hardcoding index=0 collapses parallel calls onto one
|
|
slot in OpenAI-style reassemblers."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [
|
|
{
|
|
"functionCall": {
|
|
"id": "call_alpha",
|
|
"name": "search",
|
|
"args": {"q": "alpha"},
|
|
}
|
|
},
|
|
{
|
|
"functionCall": {
|
|
"id": "call_beta",
|
|
"name": "search",
|
|
"args": {"q": "beta"},
|
|
}
|
|
},
|
|
],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 8,
|
|
"candidatesTokenCount": 4,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(monkeypatch, sse)
|
|
chunks = _parse_chunks(lines)
|
|
tool_call_chunks = [
|
|
c
|
|
for c in chunks
|
|
if "_toolEvent" not in c
|
|
and any(
|
|
(isinstance(ch.get("delta"), dict) and "tool_calls" in ch["delta"])
|
|
for ch in c.get("choices", [])
|
|
)
|
|
]
|
|
assert len(tool_call_chunks) == 2, tool_call_chunks
|
|
indices = [c["choices"][0]["delta"]["tool_calls"][0]["index"] for c in tool_call_chunks]
|
|
assert indices == [0, 1], indices
|
|
|
|
|
|
def test_function_call_ids_forwarded_into_gemini_function_call_part(monkeypatch):
|
|
"""OpenAI tool_call id rides functionCall.id so parallel calls disambiguate."""
|
|
messages = [
|
|
{"role": "user", "content": "x"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_alpha",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search",
|
|
"arguments": json.dumps({"q": "a"}),
|
|
},
|
|
},
|
|
{
|
|
"id": "call_beta",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search",
|
|
"arguments": json.dumps({"q": "b"}),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_alpha",
|
|
"content": json.dumps({"hits": ["A"]}),
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_beta",
|
|
"content": json.dumps({"hits": ["B"]}),
|
|
},
|
|
]
|
|
captured = _capture_body(monkeypatch, messages = messages)
|
|
contents = captured["body"]["contents"]
|
|
assistant_parts = next(c for c in contents if c["role"] == "model")["parts"]
|
|
call_ids = [p["functionCall"]["id"] for p in assistant_parts if "functionCall" in p]
|
|
assert call_ids == ["call_alpha", "call_beta"], assistant_parts
|
|
response_ids = [
|
|
p["functionResponse"]["id"] for c in contents for p in c["parts"] if "functionResponse" in p
|
|
]
|
|
assert response_ids == ["call_alpha", "call_beta"], contents
|
|
|
|
|
|
def test_parse_gemini_models_translates_native_catalog():
|
|
"""Gemini's native /v1beta/models payload becomes OpenAI-shape entries."""
|
|
payload = {
|
|
"models": [
|
|
{
|
|
"name": "models/gemini-2.5-flash",
|
|
"baseModelId": "gemini-2.5-flash",
|
|
"displayName": "Gemini 2.5 Flash",
|
|
"supportedGenerationMethods": [
|
|
"generateContent",
|
|
"streamGenerateContent",
|
|
],
|
|
},
|
|
{
|
|
"name": "models/embedding-001",
|
|
"supportedGenerationMethods": ["embedContent"],
|
|
},
|
|
{
|
|
"name": "models/gemini-2.5-pro",
|
|
},
|
|
]
|
|
}
|
|
out = ExternalProviderClient._parse_gemini_models(payload)
|
|
ids = [m["id"] for m in out]
|
|
assert "gemini-2.5-flash" in ids
|
|
assert "gemini-2.5-pro" in ids
|
|
assert "embedding-001" not in ids
|
|
flash = next(m for m in out if m["id"] == "gemini-2.5-flash")
|
|
assert flash["display_name"] == "Gemini 2.5 Flash"
|
|
assert flash["owned_by"] == "google"
|
|
|
|
|
|
def test_code_execution_parts_translate_to_code_execution_tool_events(monkeypatch):
|
|
"""executableCode + codeExecutionResult parts emit code_execution events."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [
|
|
{
|
|
"executableCode": {
|
|
"language": "PYTHON",
|
|
"code": "print(2+2)",
|
|
}
|
|
},
|
|
{
|
|
"codeExecutionResult": {
|
|
"outcome": "OUTCOME_OK",
|
|
"output": "4\n",
|
|
}
|
|
},
|
|
],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 8,
|
|
"candidatesTokenCount": 4,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(monkeypatch, sse, enabled_tools = ["code_execution"])
|
|
chunks = _parse_chunks(lines)
|
|
tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
|
|
code_starts = [
|
|
e
|
|
for e in tool_events
|
|
if e.get("type") == "tool_start" and e.get("tool_name") == "code_execution"
|
|
]
|
|
code_ends = [
|
|
e for e in tool_events if e.get("type") == "tool_end" and "4" in str(e.get("result", ""))
|
|
]
|
|
assert len(code_starts) == 1, tool_events
|
|
assert code_starts[0]["arguments"]["code"] == "print(2+2)"
|
|
assert code_starts[0]["arguments"]["language"] == "python"
|
|
assert len(code_ends) == 1, tool_events
|
|
# tool_start and tool_end must share a tool_call_id so the frontend pairs
|
|
# them onto one CodeExecutionToolUI block.
|
|
assert code_starts[0]["tool_call_id"] == code_ends[0]["tool_call_id"]
|
|
|
|
|
|
def test_code_execution_failure_outcome_surfaces_in_result(monkeypatch):
|
|
"""OUTCOME_FAILED is prefixed onto the result text so the UI shows it."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [
|
|
{
|
|
"executableCode": {
|
|
"language": "PYTHON",
|
|
"code": "1/0",
|
|
}
|
|
},
|
|
{
|
|
"codeExecutionResult": {
|
|
"outcome": "OUTCOME_FAILED",
|
|
"output": "ZeroDivisionError",
|
|
}
|
|
},
|
|
],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 5,
|
|
"candidatesTokenCount": 2,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(monkeypatch, sse, enabled_tools = ["code_execution"])
|
|
chunks = _parse_chunks(lines)
|
|
tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
|
|
result_text = next(
|
|
(e["result"] for e in tool_events if e.get("type") == "tool_end"),
|
|
"",
|
|
)
|
|
assert "OUTCOME_FAILED" in result_text
|
|
assert "ZeroDivisionError" in result_text
|
|
|
|
|
|
def test_tool_message_recovers_name_from_tool_call_id(monkeypatch):
|
|
"""When name is omitted, recover it from the matching tool_call_id."""
|
|
messages = [
|
|
{"role": "user", "content": "Weather?"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_xyz",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"arguments": json.dumps({"location": "Paris"}),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_xyz",
|
|
"content": json.dumps({"temp_c": 18}),
|
|
},
|
|
]
|
|
captured = _capture_body(monkeypatch, messages = messages)
|
|
contents = captured["body"]["contents"]
|
|
last = contents[-1]
|
|
fr = last["parts"][0].get("functionResponse")
|
|
assert fr is not None, last
|
|
assert (
|
|
fr["name"] == "get_weather"
|
|
), "name should fall back to the prior tool_call's function name"
|
|
|
|
|
|
# ── usage chunk surfaces promptTokenCount / candidatesTokenCount ─────
|
|
|
|
|
|
def test_usage_chunk_translates_gemini_token_counts(monkeypatch):
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [{"text": "ok"}],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 1234,
|
|
"candidatesTokenCount": 56,
|
|
"cachedContentTokenCount": 1000,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(monkeypatch, sse)
|
|
chunks = _parse_chunks(lines)
|
|
usage_chunks = [c for c in chunks if c.get("choices") == [] and "usage" in c]
|
|
assert len(usage_chunks) == 1, chunks
|
|
usage = usage_chunks[0]["usage"]
|
|
assert usage["prompt_tokens"] == 1234
|
|
assert usage["completion_tokens"] == 56
|
|
assert usage["total_tokens"] == 1290
|
|
assert usage["prompt_tokens_details"]["cached_tokens"] == 1000
|
|
|
|
|
|
# ── multimodal: vision image -> inlineData ───────────────────────────
|
|
|
|
|
|
def test_vision_data_url_translates_to_inline_data(monkeypatch):
|
|
fake = base64.b64encode(b"JPGBYTES").decode()
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "What is this?"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": f"data:image/jpeg;base64,{fake}",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
]
|
|
captured = _capture_body(monkeypatch, messages = messages)
|
|
parts = captured["body"]["contents"][0]["parts"]
|
|
inline_parts = [p for p in parts if "inlineData" in p]
|
|
assert len(inline_parts) == 1, parts
|
|
assert inline_parts[0]["inlineData"] == {"mimeType": "image/jpeg", "data": fake}
|
|
|
|
|
|
# ── finish reason mapping ────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"gemini_reason, openai_reason",
|
|
[
|
|
("STOP", "stop"),
|
|
("MAX_TOKENS", "length"),
|
|
("SAFETY", "content_filter"),
|
|
("PROHIBITED_CONTENT", "content_filter"),
|
|
],
|
|
)
|
|
def test_finish_reason_translation(monkeypatch, gemini_reason, openai_reason):
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [{"text": "x"}],
|
|
},
|
|
"finishReason": gemini_reason,
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 1,
|
|
"candidatesTokenCount": 1,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(monkeypatch, sse)
|
|
chunks = _parse_chunks(lines)
|
|
finish_chunks = [
|
|
c for c in chunks if any(ch.get("finish_reason") for ch in c.get("choices", []))
|
|
]
|
|
assert any(
|
|
ch["choices"][0]["finish_reason"] == openai_reason for ch in finish_chunks
|
|
), finish_chunks
|
|
|
|
|
|
# ── grounding citations surface as web_search tool_end ───────────────
|
|
|
|
|
|
def test_grounding_metadata_surfaces_as_tool_end_citations(monkeypatch):
|
|
"""`groundingMetadata.groundingChunks[].web` -> tool_end result block."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [{"text": "Answer with sources."}],
|
|
},
|
|
"groundingMetadata": {
|
|
"groundingChunks": [
|
|
{
|
|
"web": {
|
|
"uri": "https://example.com/a",
|
|
"title": "Example A",
|
|
}
|
|
},
|
|
{
|
|
"web": {
|
|
"uri": "https://example.com/b",
|
|
"title": "Example B",
|
|
}
|
|
},
|
|
]
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 7,
|
|
"candidatesTokenCount": 3,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(
|
|
monkeypatch,
|
|
sse,
|
|
enabled_tools = ["web_search"],
|
|
)
|
|
chunks = _parse_chunks(lines)
|
|
tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
|
|
web_search_ends = [
|
|
e
|
|
for e in tool_events
|
|
if e.get("type") == "tool_end" and e.get("tool_call_id") == "gemini_web_search"
|
|
]
|
|
assert len(web_search_ends) == 1, tool_events
|
|
result = web_search_ends[0]["result"]
|
|
assert "https://example.com/a" in result
|
|
assert "https://example.com/b" in result
|
|
assert "Example A" in result
|
|
assert "Example B" in result
|
|
|
|
|
|
# ── round 3 review follow-ups ─────────────────────────────────────────
|
|
|
|
|
|
def test_custom_gemini_proxy_base_url_not_rewritten():
|
|
"""Only the Google-hosted /v1beta/openai base is normalized; a custom
|
|
gateway whose path ends in /openai must be left alone."""
|
|
client = ExternalProviderClient(
|
|
provider_type = "gemini",
|
|
base_url = "https://proxy.example.com/team/openai",
|
|
api_key = "AIza-test-key",
|
|
)
|
|
assert client.base_url == "https://proxy.example.com/team/openai"
|
|
|
|
|
|
def test_custom_gemini_proxy_uses_openai_dispatch():
|
|
"""Any non-Google Gemini base (LiteLLM, custom OpenAI-compat routers) must
|
|
route through the OpenAI-compatible forwarder, not the native translator.
|
|
Auth uses Authorization: Bearer ..., not x-goog-api-key."""
|
|
for base in (
|
|
"https://proxy.example.com/team/openai",
|
|
"https://proxy.example.com/v1",
|
|
"https://litellm.internal.example/v1",
|
|
):
|
|
client = ExternalProviderClient(
|
|
provider_type = "gemini",
|
|
base_url = base,
|
|
api_key = "AIza-test-key",
|
|
)
|
|
assert client._is_openai_compatible() is True, base
|
|
headers = client._auth_headers()
|
|
assert "x-goog-api-key" not in {k.lower() for k in headers}, (base, headers)
|
|
assert headers["Authorization"] == "Bearer AIza-test-key", (base, headers)
|
|
|
|
|
|
def test_google_hosted_gemini_still_uses_native_dispatch():
|
|
"""Google-hosted Gemini keeps native dispatch + x-goog-api-key auth."""
|
|
client = ExternalProviderClient(
|
|
provider_type = "gemini",
|
|
base_url = "https://generativelanguage.googleapis.com/v1beta",
|
|
api_key = "AIza-test-key",
|
|
)
|
|
assert client._is_openai_compatible() is False
|
|
headers = client._auth_headers()
|
|
assert headers.get("x-goog-api-key") == "AIza-test-key", headers
|
|
|
|
|
|
def test_invalid_gemini_model_id_rejected_before_request(monkeypatch):
|
|
"""Path-traversal model ids must be rejected before the URL is
|
|
interpolated, so the configured API key isn't sent to unintended Gemini
|
|
endpoints."""
|
|
|
|
captured: list[httpx.Request] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured.append(request)
|
|
return httpx.Response(
|
|
200,
|
|
content = _gemini_sse([]),
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
out: list[str] = []
|
|
|
|
async def run():
|
|
client = _make_gemini_client()
|
|
async for line in client.stream_chat_completion(
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
model = "../cachedContents/leak",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 16,
|
|
):
|
|
out.append(line)
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
# No outbound request should have been issued.
|
|
assert captured == [], captured
|
|
error_lines = [line for line in out if '"error"' in line]
|
|
assert error_lines, out
|
|
|
|
|
|
def test_top_k_omitted_when_not_explicit_default_for_gemini(monkeypatch):
|
|
"""top_k=None means "use provider default"; helper must not emit `topK` in
|
|
generationConfig when the caller didn't pass it."""
|
|
captured = _capture_body(monkeypatch, top_k = None)
|
|
assert "topK" not in captured["body"]["generationConfig"], captured["body"]
|
|
|
|
|
|
def test_text_model_image_generation_tool_silently_dropped(monkeypatch):
|
|
"""A stale `enabled_tools=["image_generation"]` on a text-only Gemini
|
|
model (e.g. gemini-2.5-flash) must NOT switch the request into image mode
|
|
-- Google's API 400s on responseModalities for text models."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-2.5-flash",
|
|
enabled_tools = ["image_generation"],
|
|
)
|
|
gc = captured["body"]["generationConfig"]
|
|
assert "responseModalities" not in gc, gc
|
|
|
|
|
|
def test_empty_text_part_with_thought_signature_emits_extra_content(monkeypatch):
|
|
"""Gemini 3 can ship a content-free fragment whose only payload is
|
|
`thoughtSignature`. The translator must still surface it on a
|
|
delta.extra_content envelope so the next turn can replay it."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [
|
|
{"text": "answer"},
|
|
{"thoughtSignature": "SIG-FINAL"},
|
|
],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 2,
|
|
"candidatesTokenCount": 1,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(monkeypatch, sse)
|
|
chunks = _parse_chunks(lines)
|
|
extra_carriers = [
|
|
c
|
|
for c in chunks
|
|
if c.get("choices")
|
|
and c["choices"][0]["delta"].get("extra_content")
|
|
== {"google": {"thought_signature": "SIG-FINAL"}}
|
|
]
|
|
assert extra_carriers, chunks
|
|
|
|
|
|
def test_enable_prompt_caching_false_string_coerces_to_bool():
|
|
"""Pre-PR the field was Optional[bool]; widening to Union[bool,str] must
|
|
preserve historical coercion so callers sending `"false"` still opt out of
|
|
caching."""
|
|
from models.inference import ChatCompletionRequest
|
|
|
|
msg = {"role": "user", "content": "hi"}
|
|
req = ChatCompletionRequest.model_validate(
|
|
{
|
|
"model": "gemini-2.5-flash",
|
|
"messages": [msg],
|
|
"enable_prompt_caching": "false",
|
|
}
|
|
)
|
|
assert req.enable_prompt_caching is False, req.enable_prompt_caching
|
|
|
|
req = ChatCompletionRequest.model_validate(
|
|
{
|
|
"model": "gemini-2.5-flash",
|
|
"messages": [msg],
|
|
"enable_prompt_caching": "true",
|
|
}
|
|
)
|
|
assert req.enable_prompt_caching is True
|
|
|
|
# An actual cache resource name passes through untouched.
|
|
req = ChatCompletionRequest.model_validate(
|
|
{
|
|
"model": "gemini-2.5-flash",
|
|
"messages": [msg],
|
|
"enable_prompt_caching": "cachedContents/abc123",
|
|
}
|
|
)
|
|
assert req.enable_prompt_caching == "cachedContents/abc123"
|
|
|
|
|
|
def test_legacy_google_openai_base_url_is_rewritten():
|
|
"""The Google-hosted /v1beta/openai legacy base IS still rewritten."""
|
|
client = ExternalProviderClient(
|
|
provider_type = "gemini",
|
|
base_url = "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
api_key = "AIza-test-key",
|
|
)
|
|
assert client.base_url == "https://generativelanguage.googleapis.com/v1beta"
|
|
|
|
|
|
def test_remote_image_url_downloads_and_inlines_as_base64(monkeypatch):
|
|
"""Round 14: arbitrary public HTTPS image URLs cannot be sent as Gemini
|
|
fileData (reserved for Files API URIs and YouTube). The translator must
|
|
fetch the bytes server-side and inline them as base64 inlineData."""
|
|
image_bytes = b"FAKEPNGBYTES"
|
|
|
|
async def fake_fetch(
|
|
url,
|
|
fallback_mime,
|
|
max_bytes = None,
|
|
):
|
|
assert url == "https://cdn.example.com/diagram.png"
|
|
return ("image/png", base64.b64encode(image_bytes).decode("ascii"))
|
|
|
|
monkeypatch.setattr(ep_mod, "_safe_fetch_image_for_gemini", fake_fetch)
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "what is this?"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": "https://cdn.example.com/diagram.png",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
],
|
|
)
|
|
parts = captured["body"]["contents"][-1]["parts"]
|
|
inline = next((p for p in parts if "inlineData" in p), None)
|
|
assert inline is not None, parts
|
|
assert inline["inlineData"]["mimeType"] == "image/png"
|
|
assert inline["inlineData"]["data"] == base64.b64encode(image_bytes).decode()
|
|
assert not any("fileData" in p for p in parts), parts
|
|
|
|
|
|
def test_remote_image_url_dropped_when_fetch_returns_none(monkeypatch):
|
|
"""Round 15: if the SSRF guard rejects the URL (private host, non-https,
|
|
oversize, non-image), the helper returns None and the image part is
|
|
silently dropped, not forwarded as raw bytes or a fileData fallback."""
|
|
|
|
async def fake_fetch_reject(
|
|
url,
|
|
fallback_mime,
|
|
max_bytes = None,
|
|
):
|
|
return None
|
|
|
|
monkeypatch.setattr(ep_mod, "_safe_fetch_image_for_gemini", fake_fetch_reject)
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "what is this?"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {"url": "http://10.0.0.5/private.png"},
|
|
},
|
|
],
|
|
}
|
|
],
|
|
)
|
|
parts = captured["body"]["contents"][-1]["parts"]
|
|
assert not any("inlineData" in p for p in parts), parts
|
|
assert not any("fileData" in p for p in parts), parts
|
|
|
|
|
|
def test_safe_fetch_image_rejects_non_https():
|
|
"""SSRF guard: only https URLs may be fetched."""
|
|
res = asyncio.new_event_loop().run_until_complete(
|
|
ep_mod._safe_fetch_image_for_gemini("http://cdn.example.com/x.png", "image/png")
|
|
)
|
|
assert res is None
|
|
|
|
|
|
def test_safe_fetch_image_rejects_loopback_ip_literal():
|
|
"""SSRF guard: refuse loopback / private IP literals before any network
|
|
call."""
|
|
for url in (
|
|
"https://127.0.0.1/x.png",
|
|
"https://[::1]/x.png",
|
|
"https://169.254.169.254/latest/meta-data",
|
|
"https://10.0.0.5/x.png",
|
|
"https://192.168.1.1/x.png",
|
|
):
|
|
res = asyncio.new_event_loop().run_until_complete(
|
|
ep_mod._safe_fetch_image_for_gemini(url, "image/png")
|
|
)
|
|
assert res is None, url
|
|
|
|
|
|
def test_safe_fetch_image_rejects_resolved_private_host(monkeypatch):
|
|
"""SSRF guard: if a hostname resolves to a private IP, refuse."""
|
|
import socket
|
|
|
|
def fake_getaddrinfo(host, *_args, **_kwargs):
|
|
return [(socket.AF_INET, None, None, "", ("10.0.0.5", 0))]
|
|
|
|
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
|
res = asyncio.new_event_loop().run_until_complete(
|
|
ep_mod._safe_fetch_image_for_gemini("https://internal.example/x.png", "image/png")
|
|
)
|
|
assert res is None
|
|
|
|
|
|
def test_youtube_and_files_api_uris_stay_as_file_data(monkeypatch):
|
|
"""Round 14: YouTube URLs and generativelanguage.googleapis.com Files API
|
|
URIs are the documented `fileData.fileUri` paths and must NOT be
|
|
downloaded; arbitrary public URLs do get fetched."""
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["body"] = json.loads(request.content.decode("utf-8"))
|
|
return httpx.Response(
|
|
200,
|
|
content = _gemini_sse(
|
|
[
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [{"text": "ok"}],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 1,
|
|
"candidatesTokenCount": 1,
|
|
},
|
|
}
|
|
]
|
|
),
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = _make_gemini_client()
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "explain"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": "https://www.youtube.com/watch?v=abc123",
|
|
},
|
|
},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": "https://generativelanguage.googleapis.com/v1beta/files/abc",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
],
|
|
model = "gemini-2.5-flash",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 64,
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
parts = captured["body"]["contents"][-1]["parts"]
|
|
file_uris = [p["fileData"]["fileUri"] for p in parts if "fileData" in p]
|
|
assert "https://www.youtube.com/watch?v=abc123" in file_uris, parts
|
|
assert "https://generativelanguage.googleapis.com/v1beta/files/abc" in file_uris, parts
|
|
|
|
|
|
def test_tool_use_prompt_tokens_added_to_input_tokens(monkeypatch):
|
|
"""`toolUsePromptTokenCount` must roll into the OpenAI prompt total --
|
|
else tool turns silently undercount input tokens."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [{"text": "result"}],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 10,
|
|
"toolUsePromptTokenCount": 100,
|
|
"candidatesTokenCount": 5,
|
|
"thoughtsTokenCount": 2,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(monkeypatch, sse)
|
|
chunks = _parse_chunks(lines)
|
|
usage_chunks = [c for c in chunks if c.get("usage")]
|
|
assert len(usage_chunks) == 1, chunks
|
|
usage = usage_chunks[0]["usage"]
|
|
assert usage["prompt_tokens"] == 110, usage
|
|
assert usage["completion_tokens"] == 7, usage
|
|
assert usage["total_tokens"] == 117, usage
|
|
assert usage["completion_tokens_details"]["reasoning_tokens"] == 2, usage
|
|
|
|
|
|
def test_usage_chunk_reasoning_tokens_surfaced(monkeypatch):
|
|
"""thoughtsTokenCount must surface as
|
|
completion_tokens_details.reasoning_tokens in the emitted OpenAI usage
|
|
chunk."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [{"text": "ok"}],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 8,
|
|
"candidatesTokenCount": 5,
|
|
"thoughtsTokenCount": 20,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(monkeypatch, sse)
|
|
chunks = _parse_chunks(lines)
|
|
usage_chunks = [c for c in chunks if c.get("usage")]
|
|
assert len(usage_chunks) == 1, chunks
|
|
usage = usage_chunks[0]["usage"]
|
|
assert usage["completion_tokens"] == 25, usage
|
|
assert usage["completion_tokens_details"]["reasoning_tokens"] == 20, usage
|
|
|
|
|
|
def test_prompt_block_pairs_web_search_tool_end(monkeypatch):
|
|
"""When `promptFeedback.blockReason` triggers after the synthetic
|
|
web_search tool_start, the helper must emit a matching tool_end so the UI
|
|
doesn't leave a "searching..." spinner stuck on screen."""
|
|
sse = [
|
|
{"promptFeedback": {"blockReason": "SAFETY"}},
|
|
]
|
|
lines = _collect(
|
|
monkeypatch,
|
|
sse,
|
|
enabled_tools = ["web_search"],
|
|
)
|
|
chunks = _parse_chunks(lines)
|
|
tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
|
|
starts = [e for e in tool_events if e.get("type") == "tool_start"]
|
|
ends = [e for e in tool_events if e.get("type") == "tool_end"]
|
|
assert len(starts) == 1, tool_events
|
|
assert len(ends) == 1, tool_events
|
|
assert ends[0]["tool_call_id"] == "gemini_web_search"
|
|
assert "aborted" in ends[0]["result"]
|
|
error_chunks = [c for c in chunks if c.get("error")]
|
|
assert error_chunks, chunks
|
|
|
|
|
|
def test_code_execution_tool_events_stow_native_part(monkeypatch):
|
|
"""executableCode / codeExecutionResult must round-trip native ids and
|
|
thoughtSignature in google.native_part so follow-up turns can replay
|
|
Gemini's required history shape."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [
|
|
{
|
|
"executableCode": {
|
|
"id": "code_a",
|
|
"language": "PYTHON",
|
|
"code": "print(1+1)",
|
|
},
|
|
"thoughtSignature": "SIG-CODE",
|
|
},
|
|
{
|
|
"codeExecutionResult": {
|
|
"id": "result_a",
|
|
"outcome": "OUTCOME_OK",
|
|
"output": "2\n",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 5,
|
|
"candidatesTokenCount": 4,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(
|
|
monkeypatch,
|
|
sse,
|
|
enabled_tools = ["code_execution"],
|
|
)
|
|
chunks = _parse_chunks(lines)
|
|
tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
|
|
starts = [e for e in tool_events if e.get("type") == "tool_start"]
|
|
ends = [e for e in tool_events if e.get("type") == "tool_end"]
|
|
code_start = next(
|
|
(e for e in starts if e.get("tool_name") == "code_execution"),
|
|
None,
|
|
)
|
|
code_end = next(iter(ends), None)
|
|
assert code_start is not None, starts
|
|
assert code_start["tool_call_id"] == "code_a", code_start
|
|
native = code_start["arguments"]["google"]["native_part"]
|
|
# Round 21: native_part uses an ordered `parts` list so per-part
|
|
# `thoughtSignature` survives a frontend merge of executableCode +
|
|
# codeExecutionResult into one tool-call card.
|
|
start_parts = native["parts"]
|
|
assert start_parts[0]["executableCode"]["id"] == "code_a"
|
|
assert start_parts[0]["thoughtSignature"] == "SIG-CODE"
|
|
assert code_end is not None, ends
|
|
assert code_end["tool_call_id"] == "code_a", code_end
|
|
native_end = code_end["google"]["native_part"]
|
|
end_parts = native_end["parts"]
|
|
assert end_parts[0]["codeExecutionResult"]["id"] == "result_a"
|
|
|
|
|
|
def test_inline_image_tool_end_carries_thought_signature(monkeypatch):
|
|
"""Inline image parts with thoughtSignature must persist it on the emitted
|
|
tool_end so Gemini 3 image editing can echo it back."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [
|
|
{
|
|
"inlineData": {
|
|
"mimeType": "image/png",
|
|
"data": base64.b64encode(b"PNG").decode(),
|
|
},
|
|
"thoughtSignature": "SIG-IMG",
|
|
}
|
|
],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 4,
|
|
"candidatesTokenCount": 1,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(
|
|
monkeypatch,
|
|
sse,
|
|
model = "gemini-2.5-flash-image",
|
|
)
|
|
chunks = _parse_chunks(lines)
|
|
tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
|
|
image_ends = [e for e in tool_events if e.get("type") == "tool_end" and e.get("image_b64")]
|
|
assert image_ends, tool_events
|
|
assert image_ends[0]["google"]["thought_signature"] == "SIG-IMG"
|
|
# Multi-turn image edit must replay the original inlineData part with its
|
|
# thoughtSignature; the outbound translator reads
|
|
# google.native_part.parts[].inlineData, so stow it on the tool_end too.
|
|
# Round 21 made native_part an ordered parts list so a per-part signature
|
|
# stays attached to inlineData only.
|
|
native = image_ends[0]["google"]["native_part"]
|
|
image_parts = native["parts"]
|
|
assert image_parts[0]["inlineData"]["mimeType"] == "image/png"
|
|
assert image_parts[0]["inlineData"]["data"] == base64.b64encode(b"PNG").decode()
|
|
assert image_parts[0]["thoughtSignature"] == "SIG-IMG"
|
|
|
|
|
|
def test_code_execution_plot_attaches_inline_image_native_part(monkeypatch):
|
|
"""A code_execution turn that returns a matplotlib plot must stow the
|
|
plot's inlineData on the secondary tool_end so the follow-up turn can
|
|
replay the image alongside executableCode and codeExecutionResult."""
|
|
plot_data = base64.b64encode(b"PLOT").decode()
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [
|
|
{
|
|
"executableCode": {
|
|
"id": "code_a",
|
|
"language": "PYTHON",
|
|
"code": "plt.plot([0,1])",
|
|
},
|
|
},
|
|
{
|
|
"codeExecutionResult": {
|
|
"id": "result_a",
|
|
"outcome": "OUTCOME_OK",
|
|
"output": "",
|
|
},
|
|
},
|
|
{
|
|
"inlineData": {
|
|
"mimeType": "image/png",
|
|
"data": plot_data,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 5,
|
|
"candidatesTokenCount": 4,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(
|
|
monkeypatch,
|
|
sse,
|
|
enabled_tools = ["code_execution"],
|
|
)
|
|
chunks = _parse_chunks(lines)
|
|
tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
|
|
code_ends = [
|
|
e for e in tool_events if e.get("type") == "tool_end" and e.get("tool_call_id") == "code_a"
|
|
]
|
|
# Two tool_end events on the same id: one for codeExecutionResult, one
|
|
# merging in the inlineData plot. The plot one must carry the native
|
|
# inlineData under google.native_part so the frontend tool_end merge union
|
|
# joins it with the prior executableCode and codeExecutionResult parts on
|
|
# the same card.
|
|
assert len(code_ends) == 2, code_ends
|
|
image_end = next(
|
|
(e for e in code_ends if "__IMAGES__:" in (e.get("result") or "")),
|
|
None,
|
|
)
|
|
assert image_end is not None, code_ends
|
|
native = image_end["google"]["native_part"]
|
|
plot_parts = native["parts"]
|
|
assert plot_parts[0]["inlineData"]["mimeType"] == "image/png"
|
|
assert plot_parts[0]["inlineData"]["data"] == plot_data
|
|
|
|
|
|
def test_text_chunk_carries_thought_signature(monkeypatch):
|
|
"""Text parts with thoughtSignature surface it on delta.extra_content so
|
|
frontend persistence can replay it on the follow-up turn."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [
|
|
{
|
|
"text": "hello",
|
|
"thoughtSignature": "SIG-TEXT",
|
|
}
|
|
],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 2,
|
|
"candidatesTokenCount": 1,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(monkeypatch, sse)
|
|
chunks = _parse_chunks(lines)
|
|
text_chunks = [
|
|
c for c in chunks if c.get("choices") and c["choices"][0]["delta"].get("content") == "hello"
|
|
]
|
|
assert text_chunks, chunks
|
|
extra = text_chunks[0]["choices"][0]["delta"].get("extra_content")
|
|
assert extra == {"google": {"thought_signature": "SIG-TEXT"}}, text_chunks
|
|
|
|
|
|
def test_openai_tools_translated_into_function_declarations(monkeypatch):
|
|
"""Standard ChatCompletionRequest.tools must be forwarded into Gemini's
|
|
tools[].functionDeclarations envelope."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"description": "Look up the weather for a city.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"city": {"type": "string"},
|
|
},
|
|
"required": ["city"],
|
|
},
|
|
},
|
|
}
|
|
],
|
|
tool_choice = {"type": "function", "function": {"name": "get_weather"}},
|
|
)
|
|
tools_arr = captured["body"].get("tools") or []
|
|
fn_decls = [t for t in tools_arr if "functionDeclarations" in t]
|
|
assert fn_decls, captured["body"]
|
|
decls = fn_decls[0]["functionDeclarations"]
|
|
assert decls[0]["name"] == "get_weather"
|
|
assert decls[0]["parameters"]["properties"]["city"]["type"] == "string"
|
|
tool_config = captured["body"].get("toolConfig")
|
|
assert tool_config is not None, captured["body"]
|
|
fcc = tool_config["functionCallingConfig"]
|
|
assert fcc["mode"] == "ANY"
|
|
assert fcc["allowedFunctionNames"] == ["get_weather"]
|
|
|
|
|
|
def test_tool_choice_auto_maps_to_function_calling_mode_auto(monkeypatch):
|
|
"""tool_choice="auto" maps to toolConfig.functionCallingConfig.mode."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {"name": "noop", "parameters": {"type": "object"}},
|
|
}
|
|
],
|
|
tool_choice = "auto",
|
|
)
|
|
fcc = captured["body"]["toolConfig"]["functionCallingConfig"]
|
|
assert fcc["mode"] == "AUTO"
|
|
assert "allowedFunctionNames" not in fcc
|
|
|
|
|
|
def test_code_exec_inline_image_attaches_to_code_execution_card(monkeypatch):
|
|
"""A codeExecution sandbox plot (matplotlib) ships as an inline image part
|
|
right after the codeExecutionResult. Instead of a separate empty
|
|
image_generation card, attach to the same code_execution tool_end via the
|
|
`__IMAGES__:` marker the chat adapter already understands."""
|
|
sse = [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [
|
|
{
|
|
"executableCode": {
|
|
"id": "code_plot",
|
|
"language": "PYTHON",
|
|
"code": "import matplotlib.pyplot as plt; plt.plot([1,2,3]); plt.savefig('out.png')",
|
|
},
|
|
},
|
|
{
|
|
"codeExecutionResult": {
|
|
"outcome": "OUTCOME_OK",
|
|
"output": "saved",
|
|
},
|
|
},
|
|
{
|
|
"inlineData": {
|
|
"mimeType": "image/png",
|
|
"data": base64.b64encode(b"PNGDATA").decode(),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 5,
|
|
"candidatesTokenCount": 4,
|
|
},
|
|
}
|
|
]
|
|
lines = _collect(
|
|
monkeypatch,
|
|
sse,
|
|
enabled_tools = ["code_execution"],
|
|
)
|
|
chunks = _parse_chunks(lines)
|
|
tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c]
|
|
# No standalone image_generation card should have been emitted.
|
|
image_starts = [
|
|
e
|
|
for e in tool_events
|
|
if e.get("type") == "tool_start" and e.get("tool_name") == "image_generation"
|
|
]
|
|
assert not image_starts, tool_events
|
|
# The code_execution tool_end should now carry the inline image
|
|
# via the `__IMAGES__:` marker.
|
|
code_ends = [
|
|
e
|
|
for e in tool_events
|
|
if e.get("type") == "tool_end" and e.get("tool_call_id") == "code_plot"
|
|
]
|
|
assert code_ends, tool_events
|
|
final_result = code_ends[-1]["result"]
|
|
assert "__IMAGES__:" in final_result, code_ends
|
|
assert "data:image/png;base64," in final_result, code_ends
|
|
|
|
|
|
def test_code_execution_tool_call_replays_native_executable_code(monkeypatch):
|
|
"""An assistant tool_call with toolName=code_execution and
|
|
extra_content.google.native_part holding the originally-emitted
|
|
`executableCode` + `codeExecutionResult` must round-trip as native Gemini
|
|
parts (not a generic functionCall) on the next turn."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
messages = [
|
|
{"role": "user", "content": "compute 2+2"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "code_a",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "code_execution",
|
|
"arguments": "{}",
|
|
},
|
|
"extra_content": {
|
|
"google": {
|
|
"native_part": {
|
|
"executableCode": {
|
|
"id": "code_a",
|
|
"language": "PYTHON",
|
|
"code": "print(2+2)",
|
|
},
|
|
"codeExecutionResult": {
|
|
"outcome": "OUTCOME_OK",
|
|
"output": "4\n",
|
|
},
|
|
"thoughtSignature": "SIG-CODE",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{"role": "user", "content": "what was that result"},
|
|
],
|
|
)
|
|
assistant_turn = captured["body"]["contents"][1]
|
|
assert assistant_turn["role"] == "model"
|
|
parts = assistant_turn["parts"]
|
|
native_keys = [list(p.keys())[0] for p in parts if isinstance(p, dict)]
|
|
assert "executableCode" in native_keys, parts
|
|
assert "codeExecutionResult" in native_keys, parts
|
|
assert not any(
|
|
"functionCall" in p and (p["functionCall"] or {}).get("name") == "code_execution"
|
|
for p in parts
|
|
), parts
|
|
exec_part = next(p for p in parts if "executableCode" in p)
|
|
assert exec_part.get("thoughtSignature") == "SIG-CODE", exec_part
|
|
|
|
|
|
def test_image_generation_tool_call_replays_native_inline_data(monkeypatch):
|
|
"""An assistant tool_call with toolName=image_generation and
|
|
extra_content.google.native_part.inlineData must replay the prior image as
|
|
a native Gemini inlineData part (not a generic functionCall) so multi-turn
|
|
image editing keeps the image context."""
|
|
pixel = base64.b64encode(b"PNG").decode()
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-2.5-flash-image",
|
|
messages = [
|
|
{"role": "user", "content": "make a circle"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "img_a",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "image_generation",
|
|
"arguments": "{}",
|
|
},
|
|
"extra_content": {
|
|
"google": {
|
|
"native_part": {
|
|
"inlineData": {
|
|
"mimeType": "image/png",
|
|
"data": pixel,
|
|
},
|
|
"thoughtSignature": "SIG-IMG",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{"role": "user", "content": "now make it blue"},
|
|
],
|
|
)
|
|
assistant_turn = captured["body"]["contents"][1]
|
|
assert assistant_turn["role"] == "model"
|
|
parts = assistant_turn["parts"]
|
|
inline_parts = [p for p in parts if "inlineData" in p]
|
|
assert inline_parts, parts
|
|
assert inline_parts[0]["inlineData"]["mimeType"] == "image/png"
|
|
assert inline_parts[0]["inlineData"]["data"] == pixel
|
|
assert inline_parts[0].get("thoughtSignature") == "SIG-IMG", inline_parts
|
|
assert not any(
|
|
"functionCall" in p and (p["functionCall"] or {}).get("name") == "image_generation"
|
|
for p in parts
|
|
), parts
|
|
|
|
|
|
def test_assistant_text_thought_signature_replays_on_outbound_text_part(monkeypatch):
|
|
"""Assistant text with extra_content.google.thought_signature must attach
|
|
`thoughtSignature` to the LAST text part of the replayed Gemini history.
|
|
Gemini 3 strict function-calling rejects history that drops returned
|
|
signatures, so the frontend stows the latest signed-text signature and the
|
|
backend pins it on the next turn."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
messages = [
|
|
{"role": "user", "content": "hi"},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "hello"},
|
|
],
|
|
"extra_content": {
|
|
"google": {"thought_signature": "SIG-TEXT"},
|
|
},
|
|
},
|
|
{"role": "user", "content": "again"},
|
|
],
|
|
)
|
|
assistant_turn = captured["body"]["contents"][1]
|
|
assert assistant_turn["role"] == "model"
|
|
parts = assistant_turn["parts"]
|
|
text_parts = [p for p in parts if "text" in p]
|
|
assert text_parts, parts
|
|
assert text_parts[-1].get("thoughtSignature") == "SIG-TEXT", text_parts
|
|
|
|
|
|
def test_function_declarations_strip_openai_only_schema_keys(monkeypatch):
|
|
"""OpenAI strict tools commonly include `additionalProperties`, `$schema`,
|
|
`$defs`, `strict`, etc. Gemini's Schema rejects those with
|
|
INVALID_ARGUMENT, so the translator must strip them while keeping
|
|
properties.<field>.type intact."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "lookup",
|
|
"description": "Look up a value.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
"additionalProperties": False,
|
|
"strict": True,
|
|
"properties": {
|
|
"key": {
|
|
"type": "string",
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
"required": ["key"],
|
|
},
|
|
},
|
|
}
|
|
],
|
|
)
|
|
tools_arr = captured["body"].get("tools") or []
|
|
decls = next(
|
|
(t.get("functionDeclarations") for t in tools_arr if "functionDeclarations" in t),
|
|
None,
|
|
)
|
|
assert decls is not None, captured["body"]
|
|
params = decls[0]["parameters"]
|
|
assert "additionalProperties" not in params
|
|
assert "$schema" not in params
|
|
assert "strict" not in params
|
|
assert params["type"] == "object"
|
|
assert params["properties"]["key"]["type"] == "string"
|
|
assert "additionalProperties" not in params["properties"]["key"]
|
|
assert params["required"] == ["key"]
|
|
|
|
|
|
def test_function_declarations_inline_local_refs_into_gemini_schema(monkeypatch):
|
|
"""Round 25: Pydantic-generated tool schemas hoist nested object shapes
|
|
into `$defs` and reference them with `{"$ref": "#/$defs/..."}`. Gemini's
|
|
OpenAPI subset has no $ref, so a naive allowlist sanitizer drops the
|
|
reference and reduces the nested property to `{}`, losing its type, fields,
|
|
and required keys. The sanitizer must resolve local `#/...` pointers and
|
|
inline the referenced schema."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "set_user",
|
|
"description": "Persist a user.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"$defs": {
|
|
"Address": {
|
|
"type": "object",
|
|
"properties": {
|
|
"street": {"type": "string"},
|
|
"zip": {"type": "string"},
|
|
},
|
|
"required": ["street", "zip"],
|
|
},
|
|
},
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"address": {"$ref": "#/$defs/Address"},
|
|
},
|
|
"required": ["name", "address"],
|
|
},
|
|
},
|
|
}
|
|
],
|
|
)
|
|
tools_arr = captured["body"].get("tools") or []
|
|
decls = next(
|
|
(t.get("functionDeclarations") for t in tools_arr if "functionDeclarations" in t),
|
|
None,
|
|
)
|
|
assert decls is not None, captured["body"]
|
|
params = decls[0]["parameters"]
|
|
assert "$defs" not in params
|
|
address = params["properties"]["address"]
|
|
assert address.get("type") == "object", address
|
|
assert address.get("properties", {}).get("street", {}).get("type") == "string"
|
|
assert address.get("properties", {}).get("zip", {}).get("type") == "string"
|
|
assert address.get("required") == ["street", "zip"]
|
|
|
|
|
|
def test_function_declarations_inline_local_refs_in_anyof_and_items(monkeypatch):
|
|
"""The recursive inliner must reach through `anyOf` branches and `items`
|
|
(array element schemas), not just top-level property refs."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "bulk_set",
|
|
"parameters": {
|
|
"type": "object",
|
|
"$defs": {
|
|
"Address": {
|
|
"type": "object",
|
|
"properties": {"zip": {"type": "string"}},
|
|
"required": ["zip"],
|
|
},
|
|
},
|
|
"properties": {
|
|
"primary": {
|
|
"anyOf": [
|
|
{"$ref": "#/$defs/Address"},
|
|
{"type": "null"},
|
|
],
|
|
},
|
|
"extras": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/$defs/Address"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
],
|
|
)
|
|
tools_arr = captured["body"].get("tools") or []
|
|
decls = next(
|
|
(t.get("functionDeclarations") for t in tools_arr if "functionDeclarations" in t),
|
|
None,
|
|
)
|
|
assert decls is not None
|
|
params = decls[0]["parameters"]
|
|
primary = params["properties"]["primary"]
|
|
# anyOf with single non-null branch + null collapses to inline +
|
|
# nullable: true; the inlined branch must contain the resolved Address
|
|
# shape.
|
|
assert primary.get("nullable") is True
|
|
assert primary.get("type") == "object"
|
|
assert primary.get("properties", {}).get("zip", {}).get("type") == "string"
|
|
extras = params["properties"]["extras"]
|
|
assert extras.get("type") == "array"
|
|
assert extras.get("items", {}).get("type") == "object"
|
|
assert extras.get("items", {}).get("properties", {}).get("zip", {}).get("type") == "string"
|
|
|
|
|
|
def test_function_declarations_self_referential_schema_terminates(monkeypatch):
|
|
"""Self-referential / cyclic JSON Schemas (a `Node` with `children:
|
|
[Node]`) must not infinite-loop. The inliner tracks the set of refs in
|
|
flight and short-circuits to `{}` on a cycle."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "set_tree",
|
|
"parameters": {
|
|
"type": "object",
|
|
"$defs": {
|
|
"Node": {
|
|
"type": "object",
|
|
"properties": {
|
|
"value": {"type": "string"},
|
|
"children": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/$defs/Node"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"properties": {
|
|
"root": {"$ref": "#/$defs/Node"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
],
|
|
)
|
|
tools_arr = captured["body"].get("tools") or []
|
|
decls = next(
|
|
(t.get("functionDeclarations") for t in tools_arr if "functionDeclarations" in t),
|
|
None,
|
|
)
|
|
assert decls is not None
|
|
root = decls[0]["parameters"]["properties"]["root"]
|
|
assert root.get("type") == "object"
|
|
assert root.get("properties", {}).get("value", {}).get("type") == "string"
|
|
|
|
|
|
def test_gemini_native_skips_orphan_function_response_for_dropped_builtin(monkeypatch):
|
|
"""Round 26: when the assistant-side synthetic web_search/web_fetch
|
|
tool_call is dropped from native Gemini history, the matching role="tool"
|
|
follow-up must also be dropped. Otherwise the outbound body carries an
|
|
orphan functionResponse with no preceding functionCall, which 400s the
|
|
Gemini turn."""
|
|
from models.inference import ChatCompletionRequest
|
|
from routes.inference import _build_external_messages
|
|
|
|
req = ChatCompletionRequest.model_validate(
|
|
{
|
|
"model": "gemini-2.5-flash",
|
|
"messages": [
|
|
{"role": "user", "content": "search please"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_s",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "web_search",
|
|
"arguments": ('{"_server_tool": true, "query": "x"}'),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_s",
|
|
"content": "[search result]",
|
|
},
|
|
{"role": "user", "content": "again"},
|
|
],
|
|
"max_tokens": 64,
|
|
"stream": True,
|
|
}
|
|
)
|
|
built = _build_external_messages(
|
|
req.messages,
|
|
supports_vision = True,
|
|
provider_type = "gemini",
|
|
base_url = "https://generativelanguage.googleapis.com/v1beta",
|
|
)
|
|
captured = _capture_body(monkeypatch, messages = built)
|
|
contents = captured["body"].get("contents") or []
|
|
for entry in contents:
|
|
for part in entry.get("parts", []):
|
|
fr = part.get("functionResponse")
|
|
if isinstance(fr, dict):
|
|
assert fr.get("name") != "web_search", contents
|
|
|
|
|
|
def test_gemini_native_skips_orphan_function_response_for_native_part_replay(monkeypatch):
|
|
"""Round 26: code_execution / image_generation tool_calls are replayed as
|
|
Gemini-native executableCode / codeExecutionResult / inlineData parts. The
|
|
matching role="tool" follow-up must NOT then be emitted as a
|
|
functionResponse named code_execution -- there is no declared user
|
|
function with that name, and Gemini's history rules already attribute the
|
|
result to the native parts above."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
messages = [
|
|
{"role": "user", "content": "plot something"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_a",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "code_execution",
|
|
"arguments": "{}",
|
|
},
|
|
"extra_content": {
|
|
"google": {
|
|
"native_part": {
|
|
"parts": [
|
|
{
|
|
"executableCode": {
|
|
"language": "PYTHON",
|
|
"code": "print(2)",
|
|
}
|
|
},
|
|
{
|
|
"codeExecutionResult": {
|
|
"outcome": "OUTCOME_OK",
|
|
"output": "2\n",
|
|
}
|
|
},
|
|
]
|
|
}
|
|
}
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_a",
|
|
"name": "code_execution",
|
|
"content": "2",
|
|
},
|
|
{"role": "user", "content": "next"},
|
|
],
|
|
)
|
|
contents = captured["body"].get("contents") or []
|
|
saw_native = False
|
|
for entry in contents:
|
|
for part in entry.get("parts", []):
|
|
if "executableCode" in part or "codeExecutionResult" in part:
|
|
saw_native = True
|
|
fr = part.get("functionResponse")
|
|
if isinstance(fr, dict):
|
|
assert fr.get("name") != "code_execution", contents
|
|
assert saw_native, contents
|
|
|
|
|
|
def test_gemini_native_part_falls_back_to_args_google(monkeypatch):
|
|
"""Round 27: a direct OpenAI-compat API caller (or imported third-party
|
|
thread) cannot use Studio's non-standard `tool_calls[].extra_content`
|
|
field, so the native_part payload round-trips through `function.arguments`
|
|
as `{"google": {"native_part": {...}}}`. The synthetic-builtin detector
|
|
recognizes that location, but the replay branch was only reading from
|
|
`tc.extra_content.google.native_part`. Result: the round-25 guard saw a
|
|
synthetic builtin with no _native_part and dropped the entire assistant
|
|
turn, losing the prior code/image context. The translator must fall back
|
|
to args.google.native_part and still emit the native executableCode /
|
|
inlineData parts."""
|
|
import json as _json
|
|
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
messages = [
|
|
{"role": "user", "content": "draw a cat"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_img",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "image_generation",
|
|
"arguments": _json.dumps(
|
|
{
|
|
"google": {
|
|
"native_part": {
|
|
"parts": [
|
|
{
|
|
"inlineData": {
|
|
"mimeType": "image/png",
|
|
"data": "AAAA",
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{"role": "user", "content": "now make it a dog"},
|
|
],
|
|
)
|
|
contents = captured["body"].get("contents") or []
|
|
saw_inline = False
|
|
for entry in contents:
|
|
for part in entry.get("parts", []):
|
|
if "inlineData" in part:
|
|
saw_inline = True
|
|
assert saw_inline, contents
|
|
|
|
|
|
def test_gemini_native_skips_synthetic_server_builtin_replay(monkeypatch):
|
|
"""Round 25: Marked server-side builtin tool_calls (web_search /
|
|
web_fetch with `_server_tool` or `args.google.native_part`) must not fall
|
|
through to the generic Gemini `functionCall` replay path when no replayable
|
|
native part exists. Without this guard the outbound body contains a fake
|
|
`functionCall` whose name isn't a declared user function, and the Gemini
|
|
turn 400s."""
|
|
from models.inference import ChatCompletionRequest
|
|
from routes.inference import _build_external_messages
|
|
|
|
req = ChatCompletionRequest.model_validate(
|
|
{
|
|
"model": "gemini-2.5-flash",
|
|
"messages": [
|
|
{"role": "user", "content": "search please"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_s",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "web_search",
|
|
"arguments": ('{"_server_tool": true, "query": "x"}'),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_s",
|
|
"content": "[search result]",
|
|
},
|
|
{"role": "user", "content": "again"},
|
|
],
|
|
"max_tokens": 64,
|
|
"stream": True,
|
|
}
|
|
)
|
|
built = _build_external_messages(
|
|
req.messages,
|
|
supports_vision = True,
|
|
provider_type = "gemini",
|
|
base_url = "https://generativelanguage.googleapis.com/v1beta",
|
|
)
|
|
captured = _capture_body(monkeypatch, messages = built)
|
|
contents = captured["body"].get("contents") or []
|
|
for entry in contents:
|
|
for part in entry.get("parts", []):
|
|
fc = part.get("functionCall")
|
|
if isinstance(fc, dict):
|
|
assert fc.get("name") != "web_search", contents
|
|
|
|
|
|
def test_chat_message_extra_content_round_trips_through_validation():
|
|
"""Round 9: ChatMessage was missing `extra_content`, so Pydantic discarded
|
|
it during request validation and the text-part signature replay path read
|
|
nothing. The field must survive model_validate and pass through
|
|
_build_external_messages."""
|
|
from models.inference import ChatCompletionRequest
|
|
from routes.inference import _build_external_messages
|
|
|
|
req = ChatCompletionRequest.model_validate(
|
|
{
|
|
"model": "gemini-2.5-flash",
|
|
"messages": [
|
|
{"role": "user", "content": "hi"},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "hello"},
|
|
],
|
|
"extra_content": {
|
|
"google": {"thought_signature": "SIG-TEXT"},
|
|
},
|
|
},
|
|
{"role": "user", "content": "again"},
|
|
],
|
|
"max_tokens": 64,
|
|
"stream": True,
|
|
}
|
|
)
|
|
assistant_msg = req.messages[1]
|
|
assert assistant_msg.extra_content == {"google": {"thought_signature": "SIG-TEXT"}}
|
|
built = _build_external_messages(
|
|
req.messages,
|
|
supports_vision = True,
|
|
provider_type = "gemini",
|
|
base_url = "https://generativelanguage.googleapis.com/v1beta",
|
|
)
|
|
assistant_out = built[1]
|
|
assert assistant_out["extra_content"] == {"google": {"thought_signature": "SIG-TEXT"}}
|
|
# Non-Gemini providers must NOT receive extra_content; Google's
|
|
# thought_signature is unknown to OpenAI / Mistral / etc.
|
|
built_openai = _build_external_messages(
|
|
req.messages,
|
|
supports_vision = True,
|
|
provider_type = "openai",
|
|
)
|
|
assert "extra_content" not in built_openai[1], built_openai[1]
|
|
# Custom non-Google Gemini bases (LiteLLM / OAI-compat gateways) also must
|
|
# not receive Gemini-only extra_content -- the backend dispatches them
|
|
# through /chat/completions.
|
|
built_custom = _build_external_messages(
|
|
req.messages,
|
|
supports_vision = True,
|
|
provider_type = "gemini",
|
|
base_url = "https://litellm.example/v1",
|
|
)
|
|
assert "extra_content" not in built_custom[1], built_custom[1]
|
|
|
|
|
|
def test_parallel_tool_results_group_into_one_user_block(monkeypatch):
|
|
"""Round 14: Gemini docs group parallel functionResponses in a single
|
|
subsequent user content with multiple functionResponse parts. Consecutive
|
|
OpenAI role="tool" messages must merge into one Gemini user block, not
|
|
split into separate user turns."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
messages = [
|
|
{"role": "user", "content": "compute"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_a",
|
|
"type": "function",
|
|
"function": {"name": "add", "arguments": '{"x":1}'},
|
|
},
|
|
{
|
|
"id": "call_b",
|
|
"type": "function",
|
|
"function": {"name": "mul", "arguments": '{"x":2}'},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_a",
|
|
"name": "add",
|
|
"content": "2",
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_b",
|
|
"name": "mul",
|
|
"content": "4",
|
|
},
|
|
],
|
|
)
|
|
contents = captured["body"]["contents"]
|
|
# Initial user, model with two functionCalls, ONE user with two
|
|
# functionResponses.
|
|
tool_result_users = [
|
|
c
|
|
for c in contents
|
|
if c.get("role") == "user"
|
|
and all(isinstance(p, dict) and "functionResponse" in p for p in (c.get("parts") or []))
|
|
]
|
|
assert len(tool_result_users) == 1, contents
|
|
fr_parts = tool_result_users[0]["parts"]
|
|
assert len(fr_parts) == 2, fr_parts
|
|
names = [p["functionResponse"]["name"] for p in fr_parts]
|
|
assert names == ["add", "mul"], names
|
|
|
|
|
|
def test_function_schema_nullable_type_array_flattens(monkeypatch):
|
|
"""Round 14: OpenAI strict tools commonly use `"type": ["string", "null"]`
|
|
for optional fields. Gemini's OpenAPI-style Schema rejects union types and
|
|
expects `"type": "string"` with `"nullable": true`. The sanitizer must
|
|
translate the union form."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "lookup",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"city": {"type": ["string", "null"]},
|
|
"score": {"type": ["number", "null"]},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
],
|
|
)
|
|
decls = next(
|
|
t["functionDeclarations"]
|
|
for t in captured["body"].get("tools") or []
|
|
if "functionDeclarations" in t
|
|
)
|
|
params = decls[0]["parameters"]["properties"]
|
|
assert params["city"]["type"] == "string"
|
|
assert params["city"]["nullable"] is True
|
|
assert params["score"]["type"] == "number"
|
|
assert params["score"]["nullable"] is True
|
|
|
|
|
|
def test_image_picker_model_with_search_off_pill_strips_text_tools(monkeypatch):
|
|
"""Round 11: image-tier model ids reject text-only tools and
|
|
thinkingConfig at the model level regardless of the Images pill. Selecting
|
|
gemini-2.5-flash-image + enabled_tools=["web_search"] with no
|
|
image_generation must NOT forward googleSearch or thinkingConfig (Gemini
|
|
400s on text tools for legacy image ids)."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-2.5-flash-image",
|
|
enabled_tools = ["web_search"],
|
|
reasoning_effort = "high",
|
|
)
|
|
body = captured["body"]
|
|
assert "tools" not in body, body.get("tools")
|
|
assert "thinkingConfig" not in body.get("generationConfig", {}), body["generationConfig"]
|
|
|
|
|
|
def test_image_models_drop_function_declarations(monkeypatch):
|
|
"""Image-mode requests cannot mix tools with responseModalities, so
|
|
user-supplied function declarations must be dropped."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-2.5-flash-image",
|
|
enabled_tools = ["image_generation"],
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {"name": "noop", "parameters": {"type": "object"}},
|
|
}
|
|
],
|
|
)
|
|
assert captured["body"].get("tools") is None
|
|
assert captured["body"]["generationConfig"]["responseModalities"] == ["TEXT", "IMAGE"]
|
|
|
|
|
|
def test_safe_fetch_image_rejects_malformed_bracketed_url():
|
|
"""Round 17: bracketed IPv6 garbage like `https://[bad/x.png` makes
|
|
urlparse raise ValueError. The fetch helper must catch it and drop the
|
|
image rather than crashing the request mid-build."""
|
|
res = _drive(ep_mod._safe_fetch_image_for_gemini("https://[bad/x.png", "image/png"))
|
|
assert res is None
|
|
|
|
|
|
def test_safe_fetch_image_pins_validated_ip_no_hostname_in_request(monkeypatch):
|
|
"""Round 17: the fetch helper must pin the validated IP into the outgoing
|
|
request URL (with a Host header carrying the original hostname). A second
|
|
hostname-style getaddrinfo after validate would be a DNS-rebinding gap, so
|
|
we assert the urllib opener is called with an IP-rewritten URL."""
|
|
import socket
|
|
|
|
captured: dict = {"requests": []}
|
|
|
|
# Public IP during validate; record every getaddrinfo call.
|
|
original_getaddrinfo = socket.getaddrinfo
|
|
|
|
def fake_getaddrinfo(host, *args, **kwargs):
|
|
captured.setdefault("dns", []).append(host)
|
|
if host == "cdn.example.com":
|
|
return [
|
|
(
|
|
socket.AF_INET,
|
|
socket.SOCK_STREAM,
|
|
0,
|
|
"",
|
|
("8.8.8.8", 0),
|
|
)
|
|
]
|
|
return original_getaddrinfo(host, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
|
|
|
class _StubResp:
|
|
status = 200
|
|
headers = {"content-type": "image/png", "content-length": "3"}
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def read(self, _n = None):
|
|
return b"PNG"
|
|
|
|
class _StubOpener:
|
|
def open(
|
|
self,
|
|
req,
|
|
timeout = None,
|
|
):
|
|
captured["requests"].append(
|
|
{
|
|
"url": req.full_url,
|
|
"host_header": req.get_header("Host"),
|
|
}
|
|
)
|
|
return _StubResp()
|
|
|
|
monkeypatch.setattr("urllib.request.build_opener", lambda *_args, **_kw: _StubOpener())
|
|
|
|
res = _drive(ep_mod._safe_fetch_image_for_gemini("https://cdn.example.com/x.png", "image/png"))
|
|
assert res is not None
|
|
assert res[0] == "image/png"
|
|
# Outgoing URL must use the pinned IP literal, not the hostname.
|
|
assert any("8.8.8.8" in r["url"] for r in captured["requests"]), captured
|
|
assert all("cdn.example.com" not in r["url"] for r in captured["requests"]), captured
|
|
# Host header still carries the original hostname for vhost/SNI.
|
|
assert captured["requests"][0]["host_header"] == "cdn.example.com"
|
|
|
|
|
|
def test_safe_fetch_image_redirect_to_private_host_rejected(monkeypatch):
|
|
"""Round 17: each redirect hop must re-validate the new host. A public hop
|
|
that redirects to an internal address must be dropped."""
|
|
import socket
|
|
import urllib.error
|
|
|
|
original_getaddrinfo = socket.getaddrinfo
|
|
|
|
def fake_getaddrinfo(host, *args, **kwargs):
|
|
if host == "cdn.example.com":
|
|
return [
|
|
(
|
|
socket.AF_INET,
|
|
socket.SOCK_STREAM,
|
|
0,
|
|
"",
|
|
("1.1.1.1", 0),
|
|
)
|
|
]
|
|
if host == "internal.bad":
|
|
return [
|
|
(
|
|
socket.AF_INET,
|
|
socket.SOCK_STREAM,
|
|
0,
|
|
"",
|
|
("10.0.0.5", 0),
|
|
)
|
|
]
|
|
return original_getaddrinfo(host, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
|
|
|
class _StubOpener:
|
|
def open(
|
|
self,
|
|
req,
|
|
timeout = None,
|
|
):
|
|
# Simulate a 302 to a private host.
|
|
raise urllib.error.HTTPError(
|
|
req.full_url,
|
|
302,
|
|
"Found",
|
|
{"Location": "https://internal.bad/secret.png"},
|
|
None,
|
|
)
|
|
|
|
monkeypatch.setattr("urllib.request.build_opener", lambda *_args, **_kw: _StubOpener())
|
|
|
|
res = _drive(ep_mod._safe_fetch_image_for_gemini("https://cdn.example.com/x.png", "image/png"))
|
|
assert res is None
|
|
|
|
|
|
def test_files_api_substring_url_not_misclassified_as_filedata(monkeypatch):
|
|
"""Round 17: a CDN URL whose path/query merely contains the Files API
|
|
substring must NOT be sent as `fileData.fileUri`; route it through the
|
|
safe-fetch path. The old substring check
|
|
`"generativelanguage.googleapis.com/" in url.lower()` matched any URL
|
|
carrying that text anywhere."""
|
|
captured_outbound: dict = {}
|
|
fetch_calls: list[str] = []
|
|
|
|
async def fake_fetch(
|
|
url,
|
|
fallback_mime,
|
|
max_bytes = None,
|
|
):
|
|
fetch_calls.append(url)
|
|
return "image/png", base64.b64encode(b"DATA").decode("ascii")
|
|
|
|
monkeypatch.setattr(ep_mod, "_safe_fetch_image_for_gemini", fake_fetch)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_outbound["body"] = json.loads(request.content.decode("utf-8"))
|
|
return httpx.Response(
|
|
200,
|
|
content = _gemini_sse(
|
|
[
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [{"text": "ok"}],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 1,
|
|
"candidatesTokenCount": 1,
|
|
},
|
|
}
|
|
]
|
|
),
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = _make_gemini_client()
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "describe"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
# Files-API-looking path, but host is an
|
|
# attacker CDN.
|
|
"url": "https://evil.example/path/generativelanguage.googleapis.com/v1beta/files/abc.png",
|
|
},
|
|
},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
# Looks YouTube-ish in the path.
|
|
"url": "https://cdn.example.com/youtube.com/cat.png",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
],
|
|
model = "gemini-2.5-flash",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 64,
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
|
|
parts = captured_outbound["body"]["contents"][-1]["parts"]
|
|
assert not any("fileData" in p for p in parts), parts
|
|
inline_count = sum(1 for p in parts if "inlineData" in p)
|
|
assert inline_count == 2, parts
|
|
assert len(fetch_calls) == 2, fetch_calls
|
|
|
|
|
|
def test_function_schema_anyof_null_variant_flattens_to_nullable(monkeypatch):
|
|
"""Round 17: OpenAI/Pydantic emit `anyOf: [{X}, {"type":"null"}]` for
|
|
Optional[X]. Gemini's OpenAPI subset rejects `"type":"null"` inside anyOf.
|
|
The sanitizer must collapse a singleton-plus-null union back to the
|
|
non-null branch with `nullable: true`."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "lookup",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"label": {
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{"type": "null"},
|
|
]
|
|
},
|
|
"count": {
|
|
"anyOf": [
|
|
{"type": "integer"},
|
|
{"type": "null"},
|
|
]
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
],
|
|
)
|
|
decls = next(
|
|
t["functionDeclarations"]
|
|
for t in captured["body"].get("tools") or []
|
|
if "functionDeclarations" in t
|
|
)
|
|
params = decls[0]["parameters"]["properties"]
|
|
assert params["label"]["type"] == "string"
|
|
assert params["label"]["nullable"] is True
|
|
assert "anyOf" not in params["label"]
|
|
assert params["count"]["type"] == "integer"
|
|
assert params["count"]["nullable"] is True
|
|
|
|
|
|
def test_legacy_gemini3_pro_medium_coerced_to_high(monkeypatch):
|
|
"""Round 17: legacy `gemini-3-pro*` (incl. `-preview`, shut down
|
|
2026-03-09) only accepted low/high. 3.1+ Pro added medium. The backend
|
|
must coerce medium → high for the legacy model so stale UI state doesn't
|
|
400 the request."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-3-pro-preview",
|
|
reasoning_effort = "medium",
|
|
)
|
|
assert captured["body"]["generationConfig"]["thinkingConfig"] == {"thinkingLevel": "high"}
|
|
|
|
|
|
def test_gemini_3_1_pro_medium_passes_through(monkeypatch):
|
|
"""Round 17 regression: 3.1+ Pro accepts medium; coercion must NOT apply
|
|
when the model id is gemini-3.1-pro*."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-3.1-pro-preview",
|
|
reasoning_effort = "medium",
|
|
)
|
|
assert captured["body"]["generationConfig"]["thinkingConfig"] == {"thinkingLevel": "medium"}
|
|
|
|
|
|
def test_tool_calls_extra_content_stripped_for_non_native_gemini():
|
|
"""Round 17: per-tool-call `extra_content` (Gemini thoughtSignature
|
|
carrier) must not leak through `_build_external_messages` to
|
|
non-native-Gemini providers; OpenAI / Anthropic / custom Gemini OAI-compat
|
|
gateways would 400 on the unknown key."""
|
|
from models.inference import ChatCompletionRequest
|
|
from routes.inference import _build_external_messages
|
|
|
|
payload = {
|
|
"model": "gpt-5.5",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {"name": "lookup", "arguments": "{}"},
|
|
"extra_content": {
|
|
"google": {"thought_signature": "SIG"},
|
|
},
|
|
}
|
|
],
|
|
}
|
|
],
|
|
"stream": True,
|
|
}
|
|
req = ChatCompletionRequest.model_validate(payload)
|
|
|
|
# Non-native providers (openai, custom Gemini OAI-compat proxy) must have
|
|
# extra_content stripped from the tool_call entry.
|
|
for provider_type, base_url in [
|
|
("openai", None),
|
|
("gemini", "https://litellm.example/v1"),
|
|
]:
|
|
result = _build_external_messages(
|
|
req.messages,
|
|
supports_vision = True,
|
|
provider_type = provider_type,
|
|
base_url = base_url,
|
|
)
|
|
assert len(result) == 1
|
|
tc = result[0]["tool_calls"][0]
|
|
assert "extra_content" not in tc, (provider_type, tc)
|
|
|
|
# Native Gemini still receives extra_content for the round-trip.
|
|
result_native = _build_external_messages(
|
|
req.messages,
|
|
supports_vision = True,
|
|
provider_type = "gemini",
|
|
base_url = "https://generativelanguage.googleapis.com/v1beta",
|
|
)
|
|
tc_native = result_native[0]["tool_calls"][0]
|
|
assert tc_native["extra_content"]["google"]["thought_signature"] == "SIG"
|
|
|
|
|
|
def test_user_function_named_with_server_tool_arg_not_dropped(monkeypatch):
|
|
"""Round 17: the OpenAI Responses translator must NOT drop a user function
|
|
whose JSON arguments contain `_server_tool: true` UNLESS the function name
|
|
is also a canonical builtin name. Otherwise a user schema with an
|
|
`_server_tool` field becomes invisible to the model."""
|
|
captured: dict = {"input_items": None}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
body = json.loads(request.content.decode("utf-8"))
|
|
captured["input_items"] = body.get("input")
|
|
return httpx.Response(
|
|
200,
|
|
content = b'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "openai",
|
|
base_url = "https://api.openai.com/v1",
|
|
api_key = "sk-test",
|
|
)
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [
|
|
{"role": "user", "content": "hi"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_user",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "user_function",
|
|
"arguments": json.dumps({"_server_tool": True, "q": "x"}),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"content": "result",
|
|
"tool_call_id": "call_user",
|
|
"name": "user_function",
|
|
},
|
|
{"role": "user", "content": "continue"},
|
|
],
|
|
model = "gpt-5.5",
|
|
temperature = 0.7,
|
|
top_p = 1.0,
|
|
max_tokens = 16,
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
|
|
items = captured["input_items"] or []
|
|
fn_calls = [i for i in items if i.get("type") == "function_call"]
|
|
fn_outs = [i for i in items if i.get("type") == "function_call_output"]
|
|
# User function call must survive (call + output).
|
|
assert any(c.get("name") == "user_function" for c in fn_calls), items
|
|
assert len(fn_outs) == 1, items
|
|
|
|
|
|
def test_builtin_named_with_server_tool_marker_dropped(monkeypatch):
|
|
"""Round 17 control: a builtin (web_search) tagged with `_server_tool:
|
|
true` continues to be filtered from outbound history."""
|
|
captured: dict = {"input_items": None}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
body = json.loads(request.content.decode("utf-8"))
|
|
captured["input_items"] = body.get("input")
|
|
return httpx.Response(
|
|
200,
|
|
content = b'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "openai",
|
|
base_url = "https://api.openai.com/v1",
|
|
api_key = "sk-test",
|
|
)
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [
|
|
{"role": "user", "content": "search please"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_b",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "web_search",
|
|
"arguments": json.dumps({"_server_tool": True, "query": "x"}),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{"role": "user", "content": "continue"},
|
|
],
|
|
model = "gpt-5.5",
|
|
temperature = 0.7,
|
|
top_p = 1.0,
|
|
max_tokens = 16,
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
|
|
items = captured["input_items"] or []
|
|
fn_calls = [i for i in items if i.get("type") == "function_call"]
|
|
# Builtin server-side tool call must be filtered out.
|
|
assert all(c.get("name") != "web_search" for c in fn_calls), items
|
|
|
|
|
|
def test_gemini_tool_choice_none_disables_hosted_builtins(monkeypatch):
|
|
"""Round 18: `tool_choice="none"` must drop hosted Google Search / code
|
|
execution from the Gemini body, not just user function declarations.
|
|
Otherwise an API client that opted out of tool use still triggers grounded
|
|
search (privacy + billing)."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
enabled_tools = ["web_search", "code_execution"],
|
|
tool_choice = "none",
|
|
)
|
|
assert captured["body"].get("tools") is None, captured["body"]
|
|
|
|
|
|
def test_gemini_tool_choice_none_disables_function_declarations(monkeypatch):
|
|
"""Round 18: `tool_choice="none"` must drop user function declarations as
|
|
well as hosted builtins from the Gemini body."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
tool_choice = "none",
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {"name": "lookup", "parameters": {"type": "object"}},
|
|
}
|
|
],
|
|
)
|
|
assert captured["body"].get("tools") is None, captured["body"]
|
|
|
|
|
|
def test_schema_anyof_multitype_with_null_keeps_anyof_and_nullable(monkeypatch):
|
|
"""Round 18: multi-branch unions with null (e.g. `Union[str, int, None]`)
|
|
must keep the slim anyOf without the null branch and add `nullable: true`;
|
|
Gemini rejects `{"type":"null"}` inside anyOf."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "lookup",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"either": {
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{"type": "integer"},
|
|
{"type": "null"},
|
|
]
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
],
|
|
)
|
|
decls = next(
|
|
t["functionDeclarations"]
|
|
for t in captured["body"].get("tools") or []
|
|
if "functionDeclarations" in t
|
|
)
|
|
either = decls[0]["parameters"]["properties"]["either"]
|
|
assert either.get("nullable") is True
|
|
inner = either.get("anyOf")
|
|
assert isinstance(inner, list) and len(inner) == 2, either
|
|
assert all(not (isinstance(b, dict) and b.get("type") == "null") for b in inner), inner
|
|
|
|
|
|
def test_safe_fetch_image_redirect_malformed_url_no_crash(monkeypatch):
|
|
"""Round 18: when the upstream 302 Location is a malformed bracketed-IPv6
|
|
URL, the helper must return None instead of letting a urlparse ValueError
|
|
abort the chat stream."""
|
|
import socket
|
|
import urllib.error
|
|
|
|
original_getaddrinfo = socket.getaddrinfo
|
|
|
|
def fake_getaddrinfo(host, *args, **kwargs):
|
|
if host == "cdn.example.com":
|
|
return [
|
|
(
|
|
socket.AF_INET,
|
|
socket.SOCK_STREAM,
|
|
0,
|
|
"",
|
|
("1.1.1.1", 0),
|
|
)
|
|
]
|
|
return original_getaddrinfo(host, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
|
|
|
class _StubOpener:
|
|
def open(
|
|
self,
|
|
req,
|
|
timeout = None,
|
|
):
|
|
raise urllib.error.HTTPError(
|
|
req.full_url,
|
|
302,
|
|
"Found",
|
|
{"Location": "https://[bad/x.png"},
|
|
None,
|
|
)
|
|
|
|
monkeypatch.setattr("urllib.request.build_opener", lambda *_args, **_kw: _StubOpener())
|
|
|
|
res = _drive(ep_mod._safe_fetch_image_for_gemini("https://cdn.example.com/x.png", "image/png"))
|
|
assert res is None
|
|
|
|
|
|
def test_safe_fetch_image_malformed_port_no_crash():
|
|
"""Round 18: a URL with a non-numeric port (`https://h:bad/x.png`) must
|
|
not raise; urlparse's port property lazily ValueErrors."""
|
|
res = _drive(ep_mod._safe_fetch_image_for_gemini("https://example.com:bad/x.png", "image/png"))
|
|
assert res is None
|
|
|
|
|
|
def test_safe_fetch_image_missing_content_type_uses_fallback(monkeypatch):
|
|
"""Round 18: when the server returns image bytes but no Content-Type
|
|
header, the helper must use the caller-provided fallback MIME (guessed from
|
|
URL extension) instead of dropping the image as `non-image
|
|
content-type=<none>`."""
|
|
import socket
|
|
|
|
original_getaddrinfo = socket.getaddrinfo
|
|
|
|
def fake_getaddrinfo(host, *args, **kwargs):
|
|
if host == "cdn.example.com":
|
|
return [
|
|
(
|
|
socket.AF_INET,
|
|
socket.SOCK_STREAM,
|
|
0,
|
|
"",
|
|
("1.1.1.1", 0),
|
|
)
|
|
]
|
|
return original_getaddrinfo(host, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
|
|
|
class _StubResp:
|
|
status = 200
|
|
headers = {"content-length": "3"}
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def read(self, _n = None):
|
|
return b"PNG"
|
|
|
|
class _StubOpener:
|
|
def open(
|
|
self,
|
|
req,
|
|
timeout = None,
|
|
):
|
|
return _StubResp()
|
|
|
|
monkeypatch.setattr("urllib.request.build_opener", lambda *_args, **_kw: _StubOpener())
|
|
|
|
res = _drive(
|
|
ep_mod._safe_fetch_image_for_gemini("https://cdn.example.com/cat.png", "image/png")
|
|
)
|
|
assert res is not None
|
|
assert res[0] == "image/png"
|
|
|
|
|
|
def test_anthropic_translates_openai_tool_calls_into_tool_use_blocks(monkeypatch):
|
|
"""Round 18: an assistant turn with OpenAI-style top-level `tool_calls`
|
|
must be translated into Anthropic native `{type:"tool_use", id, name,
|
|
input}` content blocks before forwarding. The OpenAI `role="tool"`
|
|
follow-up must become a `role:"user"` message with a `tool_result`
|
|
block."""
|
|
captured: dict = {"messages": None}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
body = json.loads(request.content.decode("utf-8"))
|
|
captured["messages"] = body.get("messages")
|
|
return httpx.Response(
|
|
200,
|
|
content = b'event: message_stop\ndata: {"type":"message_stop"}\n\n',
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "anthropic",
|
|
base_url = "https://api.anthropic.com",
|
|
api_key = "sk-ant-test",
|
|
)
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [
|
|
{"role": "user", "content": "look up X"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "let me check",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_a",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "lookup",
|
|
"arguments": '{"q":"x"}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"content": "result_text",
|
|
"tool_call_id": "call_a",
|
|
"name": "lookup",
|
|
},
|
|
{"role": "user", "content": "summarise"},
|
|
],
|
|
model = "claude-sonnet-4-5",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 64,
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
|
|
msgs = captured["messages"] or []
|
|
# No top-level tool_calls should remain.
|
|
assert all("tool_calls" not in m for m in msgs), msgs
|
|
# The assistant turn must now have content blocks including a tool_use
|
|
# block.
|
|
asst = [m for m in msgs if m.get("role") == "assistant"]
|
|
assert asst and isinstance(asst[0]["content"], list), asst
|
|
tool_uses = [b for b in asst[0]["content"] if b.get("type") == "tool_use"]
|
|
assert len(tool_uses) == 1, asst[0]
|
|
assert tool_uses[0]["name"] == "lookup"
|
|
assert tool_uses[0]["input"] == {"q": "x"}
|
|
# The role="tool" message must become a user/tool_result message.
|
|
tool_results: list[dict] = []
|
|
for m in msgs:
|
|
if m.get("role") == "user" and isinstance(m.get("content"), list):
|
|
tool_results.extend(b for b in m["content"] if b.get("type") == "tool_result")
|
|
assert any(
|
|
tr.get("tool_use_id") == "call_a" and tr.get("content") == "result_text"
|
|
for tr in tool_results
|
|
), msgs
|
|
|
|
|
|
def test_unmarked_user_web_search_function_survives_serialization():
|
|
"""Round 18: a user-defined function literally named `web_search` with NO
|
|
`_server_tool` marker must survive `_build_external_messages` when
|
|
forwarded to a non-native provider; only marked synthetic builtin cards may
|
|
be dropped."""
|
|
from models.inference import ChatCompletionRequest
|
|
from routes.inference import _build_external_messages
|
|
|
|
payload = {
|
|
"model": "gpt-5.5",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_user",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "web_search",
|
|
"arguments": '{"query": "x"}',
|
|
},
|
|
}
|
|
],
|
|
}
|
|
],
|
|
"stream": True,
|
|
}
|
|
req = ChatCompletionRequest.model_validate(payload)
|
|
result = _build_external_messages(
|
|
req.messages,
|
|
supports_vision = True,
|
|
provider_type = "openai",
|
|
base_url = None,
|
|
)
|
|
assert len(result) == 1, result
|
|
tcs = result[0].get("tool_calls") or []
|
|
assert len(tcs) == 1, result
|
|
assert tcs[0]["function"]["name"] == "web_search"
|
|
|
|
|
|
def test_marked_server_builtin_dropped_from_build_external_messages():
|
|
"""Round 18: when a Gemini-native turn carrying a marked `image_generation`
|
|
server-tool card is forwarded to OpenAI / a custom Gemini OAI-compat proxy,
|
|
the tool_call must be dropped, not just have its extra_content stripped.
|
|
Forwarding an orphan `image_generation` tool_call would 400 the receiving
|
|
API."""
|
|
from models.inference import ChatCompletionRequest
|
|
from routes.inference import _build_external_messages
|
|
|
|
marked_args = json.dumps({"_server_tool": True, "kind": "image"})
|
|
payload = {
|
|
"model": "gpt-5.5",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_b",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "image_generation",
|
|
"arguments": marked_args,
|
|
},
|
|
}
|
|
],
|
|
}
|
|
],
|
|
"stream": True,
|
|
}
|
|
req = ChatCompletionRequest.model_validate(payload)
|
|
# Non-native providers: marked builtin tool_call must be dropped, and if it
|
|
# was the only payload, the whole message disappears.
|
|
for provider_type, base_url in [
|
|
("openai", None),
|
|
("gemini", "https://litellm.example/v1"),
|
|
]:
|
|
result = _build_external_messages(
|
|
req.messages,
|
|
supports_vision = True,
|
|
provider_type = provider_type,
|
|
base_url = base_url,
|
|
)
|
|
# Empty assistant turn with only synthetic tool_call dropped.
|
|
assert result == [] or all(not (m.get("tool_calls") or []) for m in result), (
|
|
provider_type,
|
|
result,
|
|
)
|
|
|
|
# Native Gemini preserves it (round-trips via extra_content).
|
|
result_native = _build_external_messages(
|
|
req.messages,
|
|
supports_vision = True,
|
|
provider_type = "gemini",
|
|
base_url = "https://generativelanguage.googleapis.com/v1beta",
|
|
)
|
|
assert len(result_native) == 1
|
|
assert result_native[0]["tool_calls"][0]["function"]["name"] == "image_generation"
|
|
|
|
|
|
def test_openai_responses_tool_choice_none_drops_hosted_tools(monkeypatch):
|
|
"""Round 18: `tool_choice="none"` must also drop hosted OpenAI Responses
|
|
builtins (web_search, code execution shell, image generation), not just
|
|
user function tools."""
|
|
captured: dict = {"body": None}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["body"] = json.loads(request.content.decode("utf-8"))
|
|
return httpx.Response(
|
|
200,
|
|
content = b'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "openai",
|
|
base_url = "https://api.openai.com/v1",
|
|
api_key = "sk-test",
|
|
)
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
model = "gpt-5.5",
|
|
temperature = 0.7,
|
|
top_p = 1.0,
|
|
max_tokens = 16,
|
|
enabled_tools = ["web_search", "code_execution", "image_generation"],
|
|
tool_choice = "none",
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
body = captured["body"] or {}
|
|
assert body.get("tools") in (None, []), body
|
|
|
|
|
|
def test_anthropic_tool_choice_none_drops_hosted_tools(monkeypatch):
|
|
"""Round 19: tool_choice="none" must opt out of Anthropic hosted builtins
|
|
(web_search, web_fetch, code_execution) like it does for Gemini and OpenAI
|
|
Responses."""
|
|
captured: dict = {"body": None}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["body"] = json.loads(request.content.decode("utf-8"))
|
|
return httpx.Response(
|
|
200,
|
|
content = b'event: message_stop\ndata: {"type":"message_stop"}\n\n',
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "anthropic",
|
|
base_url = "https://api.anthropic.com",
|
|
api_key = "sk-ant-test",
|
|
)
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
model = "claude-sonnet-4-5",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 16,
|
|
enabled_tools = ["web_search", "web_fetch", "code_execution"],
|
|
tool_choice = "none",
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
body = captured["body"] or {}
|
|
assert body.get("tools") in (None, []), body
|
|
|
|
|
|
def test_openrouter_tool_choice_none_drops_web_plugin(monkeypatch):
|
|
"""Round 19: tool_choice="none" must drop the OpenRouter web plugin so a
|
|
request that opted out of tool use doesn't still trigger hosted web
|
|
search."""
|
|
captured: dict = {"body": None}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["body"] = json.loads(request.content.decode("utf-8"))
|
|
return httpx.Response(
|
|
200,
|
|
content = b"data: [DONE]\n\n",
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "openrouter",
|
|
base_url = "https://openrouter.ai/api/v1",
|
|
api_key = "sk-or-test",
|
|
)
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
model = "openai/gpt-5.5",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 16,
|
|
enabled_tools = ["web_search"],
|
|
tool_choice = "none",
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
body = captured["body"] or {}
|
|
assert body.get("plugins") in (None, []), body
|
|
|
|
|
|
def test_kimi_tool_choice_none_skips_web_search_helper(monkeypatch):
|
|
"""Round 19: when tool_choice="none" plus enabled_tools=["web_search"] on
|
|
Kimi, the dispatcher must NOT route into `_stream_kimi_web_search`. Falling
|
|
through to the generic OAI-compat path is expected."""
|
|
routed_to_helper = {"called": False}
|
|
|
|
real_helper = ExternalProviderClient._stream_kimi_web_search
|
|
|
|
async def fake_helper(self, *args, **kwargs): # noqa: ARG001
|
|
routed_to_helper["called"] = True
|
|
if False:
|
|
yield "" # pragma: no cover
|
|
|
|
monkeypatch.setattr(
|
|
ExternalProviderClient,
|
|
"_stream_kimi_web_search",
|
|
fake_helper,
|
|
)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
content = b"data: [DONE]\n\n",
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "kimi",
|
|
base_url = "https://api.moonshot.ai/v1",
|
|
api_key = "sk-kimi-test",
|
|
)
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
model = "kimi-k2.6",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 16,
|
|
enabled_tools = ["web_search"],
|
|
tool_choice = "none",
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
assert routed_to_helper["called"] is False
|
|
|
|
monkeypatch.setattr(
|
|
ExternalProviderClient,
|
|
"_stream_kimi_web_search",
|
|
real_helper,
|
|
)
|
|
|
|
|
|
def test_user_code_execution_function_not_dropped():
|
|
"""Round 19: a user-declared function literally named `code_execution` with
|
|
normal `code` arguments must survive `_build_external_messages` -- round
|
|
17's shape heuristic dropped it, breaking function-calling round-trips."""
|
|
from models.inference import ChatCompletionRequest
|
|
from routes.inference import _build_external_messages
|
|
|
|
payload = {
|
|
"model": "gpt-5.5",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_user",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "code_execution",
|
|
"arguments": '{"code": "print(1)"}',
|
|
},
|
|
}
|
|
],
|
|
}
|
|
],
|
|
"stream": True,
|
|
}
|
|
req = ChatCompletionRequest.model_validate(payload)
|
|
result = _build_external_messages(
|
|
req.messages,
|
|
supports_vision = True,
|
|
provider_type = "openai",
|
|
base_url = None,
|
|
)
|
|
assert len(result) == 1, result
|
|
tcs = result[0].get("tool_calls") or []
|
|
assert len(tcs) == 1, result
|
|
assert tcs[0]["function"]["name"] == "code_execution"
|
|
|
|
|
|
def test_native_part_code_execution_treated_as_server_side():
|
|
"""Round 19: a Gemini `code_execution` card persists its replay payload at
|
|
`args.google.native_part` (no `_server_tool` marker on pre-PR cards). The
|
|
backend filter must still drop it for non-native providers because it's a
|
|
synthetic card, not a real user function."""
|
|
from models.inference import ChatCompletionRequest
|
|
from routes.inference import _build_external_messages
|
|
|
|
args_with_native_part = json.dumps(
|
|
{
|
|
"google": {
|
|
"native_part": {
|
|
"executableCode": {
|
|
"language": "PYTHON",
|
|
"code": "print(1)",
|
|
}
|
|
}
|
|
}
|
|
}
|
|
)
|
|
payload = {
|
|
"model": "gpt-5.5",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_x",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "code_execution",
|
|
"arguments": args_with_native_part,
|
|
},
|
|
}
|
|
],
|
|
}
|
|
],
|
|
"stream": True,
|
|
}
|
|
req = ChatCompletionRequest.model_validate(payload)
|
|
result = _build_external_messages(
|
|
req.messages,
|
|
supports_vision = True,
|
|
provider_type = "openai",
|
|
base_url = None,
|
|
)
|
|
assert result == [] or all(not (m.get("tool_calls") or []) for m in result), result
|
|
|
|
|
|
def test_remote_image_fetch_attempt_cap_includes_failures(monkeypatch):
|
|
"""Round 19: the per-request image fetch count cap must count ATTEMPTS,
|
|
not just successes. Otherwise a request with 100 failing/slow URLs runs 100
|
|
fetches each up to the 15s timeout."""
|
|
fetch_calls: list[str] = []
|
|
|
|
async def fake_fetch(
|
|
url,
|
|
fallback_mime,
|
|
max_bytes = None,
|
|
):
|
|
fetch_calls.append(url)
|
|
return None
|
|
|
|
monkeypatch.setattr(ep_mod, "_safe_fetch_image_for_gemini", fake_fetch)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
content = _gemini_sse(
|
|
[
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"role": "model",
|
|
"parts": [{"text": "ok"}],
|
|
},
|
|
"finishReason": "STOP",
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 1,
|
|
"candidatesTokenCount": 1,
|
|
},
|
|
}
|
|
]
|
|
),
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = _make_gemini_client()
|
|
image_parts = [
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {"url": f"https://cdn.example.com/img{idx}.png"},
|
|
}
|
|
for idx in range(20)
|
|
]
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "describe"},
|
|
*image_parts,
|
|
],
|
|
}
|
|
],
|
|
model = "gemini-2.5-flash",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 64,
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
assert len(fetch_calls) <= 8, len(fetch_calls)
|
|
|
|
|
|
def test_orphan_function_call_output_dropped_when_call_skipped(monkeypatch):
|
|
"""Round 19: when a marked server-side builtin `function_call` is dropped
|
|
from OpenAI Responses input items, the matching role=tool follow-up must
|
|
also be dropped to avoid an orphan `function_call_output`."""
|
|
captured: dict = {"input_items": None}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
body = json.loads(request.content.decode("utf-8"))
|
|
captured["input_items"] = body.get("input")
|
|
return httpx.Response(
|
|
200,
|
|
content = b'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "openai",
|
|
base_url = "https://api.openai.com/v1",
|
|
api_key = "sk-test",
|
|
)
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [
|
|
{"role": "user", "content": "search please"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_b",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "web_search",
|
|
"arguments": json.dumps({"_server_tool": True, "query": "x"}),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"content": "result_text",
|
|
"tool_call_id": "call_b",
|
|
"name": "web_search",
|
|
},
|
|
{"role": "user", "content": "continue"},
|
|
],
|
|
model = "gpt-5.5",
|
|
temperature = 0.7,
|
|
top_p = 1.0,
|
|
max_tokens = 16,
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
|
|
items = captured["input_items"] or []
|
|
fn_calls = [i for i in items if i.get("type") == "function_call"]
|
|
fn_outs = [i for i in items if i.get("type") == "function_call_output"]
|
|
assert all(c.get("call_id") != "call_b" for c in fn_calls), items
|
|
assert all(o.get("call_id") != "call_b" for o in fn_outs), items
|
|
|
|
|
|
def test_schema_multitype_union_with_null_preserves_anyof(monkeypatch):
|
|
"""Round 19: a JSON Schema `"type": ["string","integer","null"]` must be
|
|
sanitized to anyOf:[{string},{integer}] + nullable:true. Flattening to just
|
|
`{"type":"string"}` silently drops the integer branch and changes the
|
|
function contract."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "lookup",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"either": {"type": ["string", "integer", "null"]},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
],
|
|
)
|
|
decls = next(
|
|
t["functionDeclarations"]
|
|
for t in captured["body"].get("tools") or []
|
|
if "functionDeclarations" in t
|
|
)
|
|
either = decls[0]["parameters"]["properties"]["either"]
|
|
assert either.get("nullable") is True
|
|
inner = either.get("anyOf")
|
|
assert isinstance(inner, list) and len(inner) == 2, either
|
|
types = sorted(b.get("type") for b in inner if isinstance(b, dict) and b.get("type"))
|
|
assert types == ["integer", "string"], inner
|
|
|
|
|
|
def test_invalid_gemini_model_rejected_before_image_fetch(monkeypatch):
|
|
"""Round 19: invalid Gemini model IDs are rejected at the top of
|
|
`_stream_gemini`, BEFORE any user-controlled remote image fetch runs."""
|
|
fetch_calls: list[str] = []
|
|
|
|
async def fake_fetch(
|
|
url,
|
|
fallback_mime,
|
|
max_bytes = None,
|
|
):
|
|
fetch_calls.append(url)
|
|
return None
|
|
|
|
monkeypatch.setattr(ep_mod, "_safe_fetch_image_for_gemini", fake_fetch)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
content = b"",
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = _make_gemini_client()
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "hi"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {"url": "https://cdn.example.com/x.png"},
|
|
},
|
|
],
|
|
}
|
|
],
|
|
model = "../cachedContents/leak",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 64,
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
assert fetch_calls == [], fetch_calls
|
|
|
|
|
|
def test_empty_assistant_turn_skipped_after_synthetic_tool_calls_dropped():
|
|
"""Round 20: when `_filter_tool_calls` drops every synthetic server-builtin
|
|
tool_call on an empty-content assistant turn, the whole message must be
|
|
skipped. Several providers reject `{"role":"assistant","content":""}` as an
|
|
empty assistant turn."""
|
|
from models.inference import ChatCompletionRequest
|
|
from routes.inference import _build_external_messages
|
|
|
|
marked_args = json.dumps({"_server_tool": True, "kind": "image"})
|
|
payload = {
|
|
"model": "gpt-5.5",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_b",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "image_generation",
|
|
"arguments": marked_args,
|
|
},
|
|
}
|
|
],
|
|
}
|
|
],
|
|
"stream": True,
|
|
}
|
|
req = ChatCompletionRequest.model_validate(payload)
|
|
for provider_type, base_url in [
|
|
("openai", None),
|
|
("gemini", "https://litellm.example/v1"),
|
|
]:
|
|
result = _build_external_messages(
|
|
req.messages,
|
|
supports_vision = True,
|
|
provider_type = provider_type,
|
|
base_url = base_url,
|
|
)
|
|
# The empty assistant turn (only a synthetic builtin) must NOT appear
|
|
# in the output at all.
|
|
assert result == [], (provider_type, result)
|
|
|
|
|
|
def test_role_tool_dropped_when_matching_synthetic_call_filtered():
|
|
"""Round 20: `_build_external_messages` drops the matching role=tool
|
|
follow-up when its tool_call was a synthetic builtin that
|
|
`_filter_tool_calls` removed. Otherwise the receiving provider sees an
|
|
orphan tool_result with no tool_call."""
|
|
from models.inference import ChatCompletionRequest
|
|
from routes.inference import _build_external_messages
|
|
|
|
marked_args = json.dumps({"_server_tool": True, "query": "x"})
|
|
payload = {
|
|
"model": "gpt-5.5",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_b",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "web_search",
|
|
"arguments": marked_args,
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"content": "result_text",
|
|
"tool_call_id": "call_b",
|
|
"name": "web_search",
|
|
},
|
|
{"role": "user", "content": "continue"},
|
|
],
|
|
"stream": True,
|
|
}
|
|
req = ChatCompletionRequest.model_validate(payload)
|
|
result = _build_external_messages(
|
|
req.messages,
|
|
supports_vision = True,
|
|
provider_type = "openai",
|
|
base_url = None,
|
|
)
|
|
# Only the user "continue" message survives.
|
|
roles = [m.get("role") for m in result]
|
|
assert roles == ["user"], result
|
|
|
|
|
|
def test_openrouter_no_synthetic_web_search_event_on_tool_choice_none(monkeypatch):
|
|
"""Round 20: OpenRouter dispatcher must not emit synthetic web_search
|
|
tool_start / tool_end events when tool_choice="none"; otherwise the chat UI
|
|
shows a search card for a search that never happened."""
|
|
captured_events: list[dict] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
content = b"data: [DONE]\n\n",
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "openrouter",
|
|
base_url = "https://openrouter.ai/api/v1",
|
|
api_key = "sk-or-test",
|
|
)
|
|
async for line in client.stream_chat_completion(
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
model = "openai/gpt-5.5",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 16,
|
|
enabled_tools = ["web_search"],
|
|
tool_choice = "none",
|
|
):
|
|
if not line.startswith("data: "):
|
|
continue
|
|
payload = line[len("data: ") :].strip()
|
|
if not payload or payload == "[DONE]":
|
|
continue
|
|
try:
|
|
obj = json.loads(payload)
|
|
except Exception:
|
|
continue
|
|
# Backend emits synthetic tool events as a top-level `_toolEvent`
|
|
# on the SSE payload (not nested inside `delta`). Read both shapes
|
|
# so a future format change can't mask this regression.
|
|
evt = obj.get("_toolEvent")
|
|
if isinstance(evt, dict):
|
|
captured_events.append(evt)
|
|
for ch in obj.get("choices") or []:
|
|
delta = ch.get("delta") or {}
|
|
nested = delta.get("_toolEvent") if isinstance(delta, dict) else None
|
|
if isinstance(nested, dict):
|
|
captured_events.append(nested)
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
# No synthetic web_search tool_start / tool_end emitted.
|
|
assert all(e.get("tool_name") != "web_search" for e in captured_events), captured_events
|
|
|
|
|
|
def test_anthropic_role_tool_list_content_translates_to_tool_result(monkeypatch):
|
|
"""Round 20: an OpenAI-shape role=tool message with list content
|
|
(`content=[{"type":"text","text":"result"}]`) must be translated into
|
|
Anthropic's native tool_result block, not forwarded as an invalid role=tool
|
|
message."""
|
|
captured: dict = {"messages": None}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
body = json.loads(request.content.decode("utf-8"))
|
|
captured["messages"] = body.get("messages")
|
|
return httpx.Response(
|
|
200,
|
|
content = b'event: message_stop\ndata: {"type":"message_stop"}\n\n',
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "anthropic",
|
|
base_url = "https://api.anthropic.com",
|
|
api_key = "sk-ant-test",
|
|
)
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [
|
|
{"role": "user", "content": "look up X"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "let me check",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_a",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "lookup",
|
|
"arguments": '{"q":"x"}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"content": [{"type": "text", "text": "result_text"}],
|
|
"tool_call_id": "call_a",
|
|
"name": "lookup",
|
|
},
|
|
{"role": "user", "content": "summarise"},
|
|
],
|
|
model = "claude-sonnet-4-5",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 64,
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
|
|
msgs = captured["messages"] or []
|
|
assert all(m.get("role") != "tool" for m in msgs), msgs
|
|
tool_results: list[dict] = []
|
|
for m in msgs:
|
|
if m.get("role") == "user" and isinstance(m.get("content"), list):
|
|
tool_results.extend(b for b in m["content"] if b.get("type") == "tool_result")
|
|
assert any(
|
|
tr.get("tool_use_id") == "call_a" and tr.get("content") == "result_text"
|
|
for tr in tool_results
|
|
), msgs
|
|
|
|
|
|
def test_data_url_non_image_mime_dropped(monkeypatch):
|
|
"""Round 20: a `data:text/html;base64,...` image_url must be dropped from
|
|
the Gemini body, not forwarded as `inlineData.mimeType="text/html"` which
|
|
Gemini rejects."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "look"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": "data:text/html;base64,PGgxPmhpPC9oMT4=",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
],
|
|
)
|
|
parts = captured["body"]["contents"][-1]["parts"]
|
|
assert not any("inlineData" in p for p in parts), parts
|
|
|
|
|
|
def test_youtube_filedata_uses_video_mime(monkeypatch):
|
|
"""Round 20: YouTube `fileData.fileUri` must declare a video mimeType, not
|
|
`image/jpeg` guessed from the URL path."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "summarise"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": "https://www.youtube.com/watch?v=abc",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
],
|
|
)
|
|
parts = captured["body"]["contents"][-1]["parts"]
|
|
yt = next((p for p in parts if "fileData" in p), None)
|
|
assert yt is not None, parts
|
|
assert yt["fileData"]["mimeType"].startswith("video/"), yt
|
|
|
|
|
|
def test_openai_responses_assistant_text_serialized_before_function_call(monkeypatch):
|
|
"""Round 20: in OpenAI Responses history, the assistant's visible text for
|
|
a turn that ALSO emitted a function_call must serialize BEFORE the
|
|
function_call item, matching the prior response.output sequence. Otherwise
|
|
function_call_output (the role=tool follow-up) appears to follow an
|
|
unrelated assistant message."""
|
|
captured: dict = {"input_items": None}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
body = json.loads(request.content.decode("utf-8"))
|
|
captured["input_items"] = body.get("input")
|
|
return httpx.Response(
|
|
200,
|
|
content = b'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "openai",
|
|
base_url = "https://api.openai.com/v1",
|
|
api_key = "sk-test",
|
|
)
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [
|
|
{"role": "user", "content": "weather?"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "Let me check that.",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_w",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"arguments": "{}",
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"content": "sunny",
|
|
"tool_call_id": "call_w",
|
|
"name": "get_weather",
|
|
},
|
|
{"role": "user", "content": "thanks"},
|
|
],
|
|
model = "gpt-5.5",
|
|
temperature = 0.7,
|
|
top_p = 1.0,
|
|
max_tokens = 16,
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
|
|
items = captured["input_items"] or []
|
|
types = [i.get("type") or i.get("role") for i in items]
|
|
# Expected order:
|
|
# user ("weather?")
|
|
# assistant ("Let me check that.")
|
|
# function_call (get_weather)
|
|
# function_call_output (sunny)
|
|
# user ("thanks")
|
|
assert types == ["user", "assistant", "function_call", "function_call_output", "user"], items
|
|
|
|
|
|
def test_gemini_tool_choice_none_disables_image_generation(monkeypatch):
|
|
"""Round 21: `tool_choice="none"` must also flip the implicit
|
|
image-generation hosted tool off on image-tier models. Otherwise
|
|
`responseModalities=["TEXT","IMAGE"]` still rides on the body and the
|
|
provider can generate (and bill for) image output despite the explicit
|
|
OpenAI tool opt-out."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-2.5-flash-image",
|
|
enabled_tools = ["image_generation"],
|
|
tool_choice = "none",
|
|
)
|
|
body = captured["body"]
|
|
assert body["generationConfig"].get("responseModalities") == ["TEXT"], body
|
|
|
|
|
|
def test_gemini_forced_function_tool_choice_drops_hosted_builtins(monkeypatch):
|
|
"""Round 21: forced-function `tool_choice` (e.g.
|
|
`{"type":"function","function":{"name":"lookup"}}`) must suppress hosted
|
|
Google Search / code execution. Gemini's toolConfig only constrains
|
|
function declarations, not hosted tools, so leaving
|
|
`googleSearch`/`codeExecution` in `tools[]` lets them fire despite the
|
|
caller pinning a specific user function."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
enabled_tools = ["web_search", "code_execution"],
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {"name": "lookup", "parameters": {"type": "object"}},
|
|
}
|
|
],
|
|
tool_choice = {
|
|
"type": "function",
|
|
"function": {"name": "lookup"},
|
|
},
|
|
)
|
|
body = captured["body"]
|
|
tool_kinds = [list(t.keys())[0] for t in (body.get("tools") or [])]
|
|
assert "googleSearch" not in tool_kinds, body
|
|
assert "codeExecution" not in tool_kinds, body
|
|
# User function declaration still survives.
|
|
assert "functionDeclarations" in tool_kinds, body
|
|
|
|
|
|
def test_gemini_forced_function_tool_choice_drops_image_generation(monkeypatch):
|
|
"""Round 21: forced-function `tool_choice` must also flip the implicit
|
|
image-generation hosted tool off on image-tier models."""
|
|
captured = _capture_body(
|
|
monkeypatch,
|
|
model = "gemini-2.5-flash-image",
|
|
enabled_tools = ["image_generation"],
|
|
tool_choice = {
|
|
"type": "function",
|
|
"function": {"name": "lookup"},
|
|
},
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {"name": "lookup", "parameters": {"type": "object"}},
|
|
}
|
|
],
|
|
)
|
|
body = captured["body"]
|
|
assert body["generationConfig"].get("responseModalities") == ["TEXT"], body
|
|
|
|
|
|
def test_gemini_code_execution_native_part_list_replays_per_part_signatures(monkeypatch):
|
|
"""Round 21: merged code-execution history must replay per-part
|
|
`thoughtSignature`s, not fan one top-level signature across every native
|
|
subpart. Gemini 3 strict validators reject a signature on the wrong
|
|
part."""
|
|
history = [
|
|
{"role": "user", "content": "plot 1+1"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_a",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "code_execution",
|
|
"arguments": "{}",
|
|
},
|
|
"extra_content": {
|
|
"google": {
|
|
"native_part": {
|
|
"parts": [
|
|
{
|
|
"executableCode": {
|
|
"id": "code_a",
|
|
"language": "PYTHON",
|
|
"code": "print(1+1)",
|
|
},
|
|
"thoughtSignature": "SIG-EXEC",
|
|
},
|
|
{
|
|
"codeExecutionResult": {
|
|
"id": "res_a",
|
|
"outcome": "OUTCOME_OK",
|
|
"output": "2\n",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_a",
|
|
"name": "code_execution",
|
|
"content": "2",
|
|
},
|
|
{"role": "user", "content": "next"},
|
|
]
|
|
captured = _capture_body(monkeypatch, messages = history)
|
|
contents = captured["body"]["contents"]
|
|
# Find the assistant turn replayed as native code-exec parts.
|
|
assistant_turn = next(c for c in contents if c["role"] == "model")
|
|
parts = assistant_turn["parts"]
|
|
exec_parts = [p for p in parts if "executableCode" in p]
|
|
result_parts = [p for p in parts if "codeExecutionResult" in p]
|
|
assert exec_parts and result_parts, parts
|
|
assert exec_parts[0].get("thoughtSignature") == "SIG-EXEC", exec_parts[0]
|
|
# codeExecutionResult had no signature -- must NOT inherit one.
|
|
assert "thoughtSignature" not in result_parts[0], result_parts[0]
|
|
|
|
|
|
def test_gemini_code_execution_legacy_merged_signature_only_on_executable(monkeypatch):
|
|
"""Round 21: backward compat for pre-round-21 persisted history that stored
|
|
merged `native_part` as a single object plus a top-level
|
|
`thoughtSignature`. The replay branch must attach that signature only to
|
|
`executableCode` (where Gemini 3 emits it), not fan it across
|
|
`codeExecutionResult` / `inlineData`."""
|
|
history = [
|
|
{"role": "user", "content": "plot 1+1"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_b",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "code_execution",
|
|
"arguments": "{}",
|
|
},
|
|
"extra_content": {
|
|
"google": {
|
|
"native_part": {
|
|
"executableCode": {
|
|
"id": "code_b",
|
|
"language": "PYTHON",
|
|
"code": "print(1+1)",
|
|
},
|
|
"codeExecutionResult": {
|
|
"id": "res_b",
|
|
"outcome": "OUTCOME_OK",
|
|
"output": "2\n",
|
|
},
|
|
"thoughtSignature": "LEGACY-SIG",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_b",
|
|
"name": "code_execution",
|
|
"content": "2",
|
|
},
|
|
{"role": "user", "content": "next"},
|
|
]
|
|
captured = _capture_body(monkeypatch, messages = history)
|
|
contents = captured["body"]["contents"]
|
|
assistant_turn = next(c for c in contents if c["role"] == "model")
|
|
exec_parts = [p for p in assistant_turn["parts"] if "executableCode" in p]
|
|
result_parts = [p for p in assistant_turn["parts"] if "codeExecutionResult" in p]
|
|
assert exec_parts[0].get("thoughtSignature") == "LEGACY-SIG", exec_parts[0]
|
|
assert "thoughtSignature" not in result_parts[0], result_parts[0]
|
|
|
|
|
|
def test_gemini_role_tool_list_content_flattens_to_result_text(monkeypatch):
|
|
"""Round 21: OpenAI-shape role=tool messages may carry list content like
|
|
`[{"type":"text","text":"result"}]`. Forwarding those parts verbatim into
|
|
`functionResponse.response.result` yields a list of content-part objects
|
|
instead of the actual tool output text."""
|
|
history = [
|
|
{"role": "user", "content": "look up"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "lookup",
|
|
"arguments": json.dumps({"q": "x"}),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_1",
|
|
"name": "lookup",
|
|
"content": [{"type": "text", "text": "answer-text"}],
|
|
},
|
|
{"role": "user", "content": "next"},
|
|
]
|
|
captured = _capture_body(monkeypatch, messages = history)
|
|
contents = captured["body"]["contents"]
|
|
fn_response = None
|
|
for c in contents:
|
|
for p in c.get("parts") or []:
|
|
if isinstance(p, dict) and "functionResponse" in p:
|
|
fn_response = p["functionResponse"]
|
|
break
|
|
if fn_response:
|
|
break
|
|
assert fn_response is not None, contents
|
|
assert fn_response["response"] == {"result": "answer-text"}, fn_response
|
|
|
|
|
|
def test_safe_fetch_image_threads_per_request_byte_budget(monkeypatch):
|
|
"""Round 21: the aggregate per-request byte cap must be passed into
|
|
`_safe_fetch_image_for_gemini` so an oversize URL is refused via
|
|
Content-Length (short-circuit) rather than fully downloaded then
|
|
discarded."""
|
|
import socket
|
|
|
|
captured: dict = {"reads": 0, "content_length_seen": None}
|
|
|
|
original_getaddrinfo = socket.getaddrinfo
|
|
|
|
def fake_getaddrinfo(host, *args, **kwargs):
|
|
if host == "cdn.example.com":
|
|
return [
|
|
(
|
|
socket.AF_INET,
|
|
socket.SOCK_STREAM,
|
|
0,
|
|
"",
|
|
("8.8.8.8", 0),
|
|
)
|
|
]
|
|
return original_getaddrinfo(host, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
|
|
|
class _StubResp:
|
|
status = 200
|
|
# Declared 5 MiB, but caller passes a 1 MiB remaining budget.
|
|
headers = {
|
|
"content-type": "image/png",
|
|
"content-length": str(5 * 1024 * 1024),
|
|
}
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def read(self, _n = None):
|
|
captured["reads"] += 1
|
|
return b"\x00" * (5 * 1024 * 1024)
|
|
|
|
class _StubOpener:
|
|
def open(
|
|
self,
|
|
req,
|
|
timeout = None,
|
|
):
|
|
return _StubResp()
|
|
|
|
monkeypatch.setattr("urllib.request.build_opener", lambda *_args, **_kw: _StubOpener())
|
|
|
|
res = _drive(
|
|
ep_mod._safe_fetch_image_for_gemini(
|
|
"https://cdn.example.com/big.png",
|
|
"image/png",
|
|
max_bytes = 1 * 1024 * 1024,
|
|
)
|
|
)
|
|
assert res is None
|
|
# Refused via Content-Length pre-check, never read.
|
|
assert captured["reads"] == 0
|
|
|
|
|
|
def test_openai_chat_delta_type_includes_tool_calls_and_extra_content():
|
|
"""Round 21: the frontend `OpenAIChatDelta` interface must expose
|
|
`tool_calls` and `extra_content` so TypeScript callers can consume the
|
|
Gemini-native stream fields without `any` casts. A static-string assertion
|
|
against the .ts source; mirrors how other frontend wire-contract tests are
|
|
pinned from the backend suite."""
|
|
import os
|
|
|
|
here = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
types_path = os.path.join(here, "frontend", "src", "features", "chat", "types", "api.ts")
|
|
with open(types_path, "r", encoding = "utf-8") as f:
|
|
src = f.read()
|
|
assert "tool_calls?: OpenAIToolCallPart[]" in src, src[:200]
|
|
assert "extra_content?: Record<string, unknown>" in src, src[:200]
|
|
assert "boolean | string | null" in src, src[:200]
|
|
|
|
|
|
def test_anthropic_forced_function_tool_choice_drops_hosted_tools(monkeypatch):
|
|
"""Round 22: forced-function tool_choice must suppress Anthropic hosted
|
|
builtins like it does for Gemini. Pinning a user function
|
|
(`tool_choice={"type":"function","function":{"name":...}}`) while passing
|
|
`enabled_tools=["web_search","web_fetch","code_execution"]` should not still
|
|
fire those server-side."""
|
|
captured: dict = {"body": None}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["body"] = json.loads(request.content.decode("utf-8"))
|
|
return httpx.Response(
|
|
200,
|
|
content = b'event: message_stop\ndata: {"type":"message_stop"}\n\n',
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "anthropic",
|
|
base_url = "https://api.anthropic.com",
|
|
api_key = "sk-ant-test",
|
|
)
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
model = "claude-sonnet-4-5",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 16,
|
|
enabled_tools = ["web_search", "web_fetch", "code_execution"],
|
|
tool_choice = {
|
|
"type": "function",
|
|
"function": {"name": "lookup_record"},
|
|
},
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
body = captured["body"] or {}
|
|
# No hosted tools in the body — only the caller's user-function
|
|
# declarations (none passed here).
|
|
tools = body.get("tools") or []
|
|
hosted_tool_names = {"web_search", "web_fetch", "code_execution"}
|
|
for tool in tools:
|
|
assert tool.get("name") not in hosted_tool_names, body
|
|
|
|
|
|
def test_openrouter_forced_function_tool_choice_drops_web_plugin(monkeypatch):
|
|
"""Round 22: forced-function tool_choice must drop the OpenRouter web
|
|
plugin too — caller pinned a user function, so OpenRouter must not attach
|
|
the hosted web-search plugin."""
|
|
captured: dict = {"body": None}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["body"] = json.loads(request.content.decode("utf-8"))
|
|
return httpx.Response(
|
|
200,
|
|
content = b"data: [DONE]\n\n",
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "openrouter",
|
|
base_url = "https://openrouter.ai/api/v1",
|
|
api_key = "sk-or-test",
|
|
)
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
model = "openai/gpt-5.5",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 16,
|
|
enabled_tools = ["web_search"],
|
|
tool_choice = {
|
|
"type": "function",
|
|
"function": {"name": "lookup_record"},
|
|
},
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
body = captured["body"] or {}
|
|
assert body.get("plugins") in (None, []), body
|
|
|
|
|
|
def test_kimi_forced_function_tool_choice_skips_web_search_helper(monkeypatch):
|
|
"""Round 22: forced-function tool_choice plus enabled_tools=["web_search"]
|
|
on Kimi must NOT route into `_stream_kimi_web_search`. Caller pinned a user
|
|
function; hosted $web_search should be suppressed for the same
|
|
privacy/billing reason."""
|
|
routed_to_helper = {"called": False}
|
|
|
|
async def fake_helper(self, *args, **kwargs): # noqa: ARG001
|
|
routed_to_helper["called"] = True
|
|
if False:
|
|
yield "" # pragma: no cover
|
|
|
|
monkeypatch.setattr(
|
|
ExternalProviderClient,
|
|
"_stream_kimi_web_search",
|
|
fake_helper,
|
|
)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
content = b"data: [DONE]\n\n",
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "kimi",
|
|
base_url = "https://api.moonshot.ai/v1",
|
|
api_key = "sk-kimi-test",
|
|
)
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
model = "kimi-k2.6",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 16,
|
|
enabled_tools = ["web_search"],
|
|
tool_choice = {
|
|
"type": "function",
|
|
"function": {"name": "lookup_record"},
|
|
},
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
assert not routed_to_helper["called"]
|
|
|
|
|
|
def test_openai_responses_forced_function_tool_choice_drops_hosted_tools(monkeypatch):
|
|
"""Round 23: forced-function tool_choice on the OpenAI Responses path must
|
|
suppress hosted builtins (web_search, shell, image_generation) like it does
|
|
for Gemini / Anthropic / OpenRouter / Kimi. User-defined function tools
|
|
still flow through so the pinned function can resolve."""
|
|
captured: dict = {"body": None}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["body"] = json.loads(request.content.decode("utf-8"))
|
|
return httpx.Response(
|
|
200,
|
|
content = b"event: response.completed\ndata: {}\n\n",
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "openai",
|
|
base_url = "https://api.openai.com/v1",
|
|
api_key = "sk-openai-test",
|
|
)
|
|
async for _ in client.stream_chat_completion(
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
model = "gpt-5",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 16,
|
|
enabled_tools = ["web_search", "code_execution", "image_generation"],
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "lookup_record",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
],
|
|
tool_choice = {
|
|
"type": "function",
|
|
"function": {"name": "lookup_record"},
|
|
},
|
|
):
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
body = captured["body"] or {}
|
|
tools = body.get("tools") or []
|
|
hosted_types = {"web_search", "shell", "image_generation"}
|
|
hosted_seen = {t.get("type") for t in tools if isinstance(t, dict)}
|
|
assert not (hosted_seen & hosted_types), body
|
|
# The user function declaration must still be present so the pin has a
|
|
# target.
|
|
user_function_seen = any(isinstance(t, dict) and t.get("type") == "function" for t in tools)
|
|
assert user_function_seen, body
|
|
# And the forced-function tool_choice must be forwarded in Responses shape:
|
|
# `{type:"function", name:"..."}`.
|
|
tc = body.get("tool_choice")
|
|
assert isinstance(tc, dict) and tc.get("type") == "function", body
|
|
assert tc.get("name") == "lookup_record", body
|
|
|
|
|
|
def test_strip_provider_synthetic_tool_history_drops_text_only_extra_content():
|
|
"""Round 24: a plain text Gemini reply (no tool_calls) carrying
|
|
`extra_content.google.thought_signature` must still have that metadata
|
|
stripped before being forwarded to a local llama-server backend. Without
|
|
it, switching a Gemini thread mid-stream to a local GGUF model leaks
|
|
Gemini-only fields to llama-server."""
|
|
from routes.inference import _strip_provider_synthetic_tool_history
|
|
|
|
messages = [
|
|
{"role": "user", "content": "hi"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "Hello!",
|
|
"extra_content": {"google": {"thought_signature": "SIG_ABC"}},
|
|
},
|
|
{"role": "user", "content": "now in pirate voice"},
|
|
]
|
|
out = _strip_provider_synthetic_tool_history(messages)
|
|
# Same three turns, but the assistant's `extra_content` is gone.
|
|
assert [m["role"] for m in out] == ["user", "assistant", "user"]
|
|
assistant = out[1]
|
|
assert "extra_content" not in assistant, assistant
|
|
assert assistant["content"] == "Hello!"
|
|
|
|
|
|
def test_validate_and_resolve_host_blocks_shared_address_space():
|
|
"""Round 24 SSRF P1: 100.64.0.0/10 carrier-grade NAT addresses are
|
|
`is_private=False` AND `is_global=False` per Python's ipaddress docs. The
|
|
old denylist (is_private/loopback/link_local/etc.) missed them. Adding `not
|
|
ip.is_global` as the primary gate covers all non-public ranges, current and
|
|
future."""
|
|
import socket as _socket
|
|
from core.inference import tools as _tools
|
|
|
|
orig_getaddrinfo = _socket.getaddrinfo
|
|
|
|
def fake_getaddrinfo(hostname, port, *args, **kwargs):
|
|
if hostname == "shared.example":
|
|
return [
|
|
(
|
|
_socket.AF_INET,
|
|
_socket.SOCK_STREAM,
|
|
0,
|
|
"",
|
|
("100.64.0.1", port),
|
|
),
|
|
]
|
|
return orig_getaddrinfo(hostname, port, *args, **kwargs)
|
|
|
|
_socket.getaddrinfo = fake_getaddrinfo
|
|
try:
|
|
ok, reason, _ip = _tools._validate_and_resolve_host("shared.example", 443)
|
|
finally:
|
|
_socket.getaddrinfo = orig_getaddrinfo
|
|
assert ok is False, (ok, reason)
|
|
assert "non-public" in reason.lower() or "100.64.0.1" in reason
|
|
|
|
|
|
def test_gemini_custom_oai_compat_base_skips_native_allowlist():
|
|
"""Round 24: a custom Gemini OAI-compatible base (LiteLLM/proxy) must NOT
|
|
have its model list filtered through the native Gemini allowlist regex. A
|
|
LiteLLM gateway returning
|
|
`["google/gemini-2.5-flash", "my-team/gemini", "gemini-2.5-flash"]` should
|
|
pass through; the native filter would strip the prefixed IDs even though
|
|
chat dispatch routes them via the OpenAI-compatible client."""
|
|
import asyncio as _asyncio
|
|
|
|
from routes import providers as _providers
|
|
from routes.providers import (
|
|
ProviderModelsRequest,
|
|
list_provider_models,
|
|
)
|
|
|
|
captured: dict = {"base": None}
|
|
|
|
class _FakeClient:
|
|
def __init__(self, *, base_url, **kwargs):
|
|
captured["base"] = base_url
|
|
|
|
async def list_models(self):
|
|
return [
|
|
{"id": "google/gemini-2.5-flash"},
|
|
{"id": "my-team/gemini"},
|
|
{"id": "gemini-2.5-flash"},
|
|
]
|
|
|
|
async def close(self):
|
|
return None
|
|
|
|
orig = _providers.ExternalProviderClient
|
|
_providers.ExternalProviderClient = _FakeClient
|
|
try:
|
|
req = ProviderModelsRequest(
|
|
provider_type = "gemini",
|
|
base_url = "https://litellm.example/v1",
|
|
)
|
|
result = _asyncio.run(list_provider_models(req, current_subject = "unsloth"))
|
|
finally:
|
|
_providers.ExternalProviderClient = orig
|
|
ids = {m.id for m in result}
|
|
# All three IDs survive — native allowlist bypassed.
|
|
assert "google/gemini-2.5-flash" in ids, ids
|
|
assert "my-team/gemini" in ids, ids
|
|
assert "gemini-2.5-flash" in ids, ids
|
|
|
|
|
|
def test_strip_provider_synthetic_tool_history_drops_synthetic_only():
|
|
"""Round 22: switching a thread from native Gemini (code_execution /
|
|
image_generation tool_cards in history) to a local GGUF backend must strip
|
|
the synthetic tool_calls + matching role=tool replies before llama-server
|
|
sees them. Real user-function tool_calls and their matching tool replies
|
|
must survive."""
|
|
from routes.inference import _strip_provider_synthetic_tool_history
|
|
|
|
messages = [
|
|
{"role": "user", "content": "hi"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "let me run it",
|
|
"tool_calls": [
|
|
{
|
|
"id": "synth_ce_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "code_execution",
|
|
"arguments": json.dumps(
|
|
{
|
|
"_server_tool": True,
|
|
"google": {"native_part": {"parts": []}},
|
|
}
|
|
),
|
|
},
|
|
"extra_content": {"google": {"thought_signature": "abc"}},
|
|
},
|
|
{
|
|
"id": "real_lookup",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "lookup_user",
|
|
"arguments": json.dumps({"id": 42}),
|
|
},
|
|
},
|
|
],
|
|
"extra_content": {"google": {"thought_signature": "msglevel"}},
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "synth_ce_1",
|
|
"content": "Gemini-only result text",
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "real_lookup",
|
|
"content": '{"name": "alice"}',
|
|
},
|
|
]
|
|
out = _strip_provider_synthetic_tool_history(messages)
|
|
assistant = next(m for m in out if m.get("role") == "assistant")
|
|
tcs = assistant["tool_calls"]
|
|
assert len(tcs) == 1, tcs
|
|
assert tcs[0]["id"] == "real_lookup"
|
|
assert "extra_content" not in tcs[0]
|
|
assert "extra_content" not in assistant
|
|
tool_msgs = [m for m in out if m.get("role") == "tool"]
|
|
assert len(tool_msgs) == 1
|
|
assert tool_msgs[0]["tool_call_id"] == "real_lookup"
|
|
|
|
|
|
def test_strip_provider_synthetic_tool_history_drops_empty_assistant():
|
|
"""If every tool_call was synthetic and the assistant turn had no content,
|
|
the entire turn must be dropped (llama-server rejects empty assistant
|
|
messages with no tool_calls)."""
|
|
from routes.inference import _strip_provider_synthetic_tool_history
|
|
|
|
messages = [
|
|
{"role": "user", "content": "draw a sloth"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "synth_imggen",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "image_generation",
|
|
"arguments": json.dumps(
|
|
{
|
|
"google": {
|
|
"native_part": {
|
|
"parts": [
|
|
{
|
|
"inlineData": {
|
|
"mimeType": "image/png",
|
|
"data": "Zm9v",
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "synth_imggen", "content": "(image)"},
|
|
{"role": "user", "content": "now try in pirate voice"},
|
|
]
|
|
out = _strip_provider_synthetic_tool_history(messages)
|
|
roles = [m.get("role") for m in out]
|
|
# Synthetic assistant + its tool reply are both gone; only the two user
|
|
# turns survive.
|
|
assert roles == ["user", "user"], out
|
|
|
|
|
|
def test_openrouter_no_synthetic_web_search_event_on_forced_function_tool_choice(monkeypatch):
|
|
"""Round 22 sibling of the round-20 `tool_choice='none'` test: when the
|
|
caller forces a specific function via `tool_choice={"type":"function", ...}`
|
|
AND passes `enabled_tools=["web_search"]`, the OpenRouter path must NOT
|
|
synthesize a fake `web_search` tool card. The plugin wasn't attached
|
|
upstream, so the UI must not see a server-tool card."""
|
|
captured_events: list[dict] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
content = (b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n' b"data: [DONE]\n\n"),
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
_mock_http(monkeypatch, handler)
|
|
|
|
async def run():
|
|
client = ExternalProviderClient(
|
|
provider_type = "openrouter",
|
|
base_url = "https://openrouter.ai/api/v1",
|
|
api_key = "sk-or-test",
|
|
)
|
|
async for line in client.stream_chat_completion(
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
model = "openai/gpt-5.5",
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 16,
|
|
enabled_tools = ["web_search"],
|
|
tool_choice = {
|
|
"type": "function",
|
|
"function": {"name": "lookup_record"},
|
|
},
|
|
):
|
|
payload = line.strip().removeprefix("data: ")
|
|
if payload and payload != "[DONE]":
|
|
try:
|
|
captured_events.append(json.loads(payload))
|
|
except Exception:
|
|
pass
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
for evt in captured_events:
|
|
for choice in evt.get("choices") or []:
|
|
delta = choice.get("delta") or {}
|
|
extra = delta.get("extra_content") or {}
|
|
tool_event = extra.get("toolEvent") if isinstance(extra, dict) else None
|
|
if isinstance(tool_event, dict):
|
|
assert tool_event.get("tool_name") != "web_search", evt
|