unsloth/studio/backend/tests/test_gemini_provider.py
Michael Han 6d8c18cd1a
Replace standalone Studio wording with Unsloth (#7221)
* Replace standalone Studio wording with Unsloth

Replace the single word Studio with Unsloth wherever it is used as
shorthand for Unsloth Studio in docs, CLI output, UI strings, i18n
locales, workflow display names, comments and docstrings.

Kept unchanged: the full name Unsloth Studio, third party product
names (LM Studio, Visual Studio, Mac Studio), feature names
(Recipe Studio, Fine-tuning Studio and its translations), and all
identifiers such as env vars, commands, paths and filenames.

* Address review feedback on the Studio wording rename

Use "an" before Unsloth where the rename left the article as "a".
Restore the split brand where Unsloth and Studio render as two halves
of the full product name: the onboarding sidebar subtitle and the
IPv6 localhost warning. Scope two messages to the full name Unsloth
Studio where plain Unsloth was misleading: the AMD README bullet and
the CLI studio setup error.
2026-07-19 00:47:04 -07:00

5353 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):
"""Unsloth'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 Unsloth'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