diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py index 8f34bb23fc0..8a1edd608b8 100644 --- a/studio/backend/core/inference/external_provider.py +++ b/studio/backend/core/inference/external_provider.py @@ -8,10 +8,12 @@ Anthropic uses native Messages API with translation in this client. """ +import base64 import json as _json +import mimetypes import re import time -from typing import Any, AsyncGenerator, Literal, NamedTuple, Optional +from typing import Any, AsyncGenerator, Literal, NamedTuple, Optional, Union from urllib.parse import urlparse import httpx @@ -506,6 +508,250 @@ def _apply_mistral_reasoning_controls( _http_client = httpx.AsyncClient() +# Cap per-image fetch well below Gemini's ~20 MB total request budget. +_GEMINI_REMOTE_IMAGE_MAX_BYTES = 10 * 1024 * 1024 +_GEMINI_REMOTE_IMAGE_TIMEOUT_S = 15.0 + + +def _safe_fetch_image_for_gemini_sync( + url: str, + fallback_mime: str, + max_bytes: int = _GEMINI_REMOTE_IMAGE_MAX_BYTES, +) -> Optional[tuple[str, str]]: + """Synchronous IP-pinned HTTPS image fetch with SSRF guards. + + Uses the same pinned-IP + SNI pattern as `tools._fetch_page_text` so + DNS rebinding between validation and the actual connection cannot + redirect us to a private/metadata address. Follows up to 4 hops, + re-validating each redirect target. Returns (mime, base64) or None. + + `max_bytes` is clamped to the per-image cap and additionally lets + the caller pass the remaining per-request budget so an over-budget + URL is rejected via Content-Length (or read short-circuit) instead + of being fully downloaded then discarded after the fact. + """ + import urllib.error + import urllib.request + from urllib.parse import urljoin, urlunparse + + # Refuse upfront if the per-request budget is already spent. + _byte_limit = min(max(0, int(max_bytes)), _GEMINI_REMOTE_IMAGE_MAX_BYTES) + if _byte_limit <= 0: + return None + + # Share tools.py's pinned-IP hardening: validate-once-then-pin. + from .tools import ( + _NoRedirect, + _SNIHTTPSHandler, + _validate_and_resolve_host, + ) + + def _safe_parse_https(raw_url: str) -> Optional[tuple[Any, str, int]]: + """Validate https + hostname + port. Returns (parsed, host, port) or + None. Handles malformed-port and malformed-bracketed-IPv6 URLs that + would otherwise raise ValueError mid-build. + """ + try: + parsed_url = urlparse(raw_url) + host_value = parsed_url.hostname + port_value = parsed_url.port or 443 + except (ValueError, UnicodeError) as _err: + logger.info( + "Gemini image fetch: refusing malformed url err=%s", + type(_err).__name__, + ) + return None + scheme_value = (parsed_url.scheme or "").lower() + if scheme_value != "https": + logger.info( + "Gemini image fetch: refusing non-https scheme=%s", + scheme_value, + ) + return None + if not host_value: + logger.info("Gemini image fetch: refusing url with no hostname") + return None + return parsed_url, host_value, port_value + + parsed_info = _safe_parse_https(url) + if parsed_info is None: + return None + parsed, current_host, current_port = parsed_info + current_url = url + ok, reason, pinned_ip = _validate_and_resolve_host(current_host, current_port) + if not ok: + logger.warning( + "Gemini image fetch: refusing host=%s reason=%s", + current_host, + reason, + ) + return None + + for _hop in range(4): + # Pin to validated IP; SNI + cert still use hostname via _SNIHTTPSHandler. + cp_info = _safe_parse_https(current_url) + if cp_info is None: + return None + cp, _cp_host, _cp_port = cp_info + ip_str = f"[{pinned_ip}]" if ":" in pinned_ip else pinned_ip + ip_netloc = f"{ip_str}:{cp.port}" if cp.port else ip_str + pinned_url = urlunparse(cp._replace(netloc = ip_netloc)) + + opener = urllib.request.build_opener( + _NoRedirect, + _SNIHTTPSHandler(current_host), + ) + req = urllib.request.Request( + pinned_url, + headers = {"Host": current_host}, + method = "GET", + ) + + try: + resp = opener.open(req, timeout = _GEMINI_REMOTE_IMAGE_TIMEOUT_S) + except urllib.error.HTTPError as e: + if e.code not in (301, 302, 303, 307, 308): + logger.info( + "Gemini image fetch: status=%d host=%s", + e.code, + current_host, + ) + return None + location = e.headers.get("Location") + if not location: + return None + try: + current_url = urljoin(current_url, location) + except (ValueError, UnicodeError) as _err: + logger.info( + "Gemini image fetch: refusing malformed redirect err=%s", + type(_err).__name__, + ) + return None + rp_info = _safe_parse_https(current_url) + if rp_info is None: + return None + _rp, current_host, current_port = rp_info + ok2, reason2, pinned_ip = _validate_and_resolve_host( + current_host, current_port + ) + if not ok2: + logger.warning( + "Gemini image fetch: refusing redirect host=%s reason=%s", + current_host, + reason2, + ) + return None + continue + except (urllib.error.URLError, OSError) as _err: + logger.warning( + "Gemini image fetch failed host=%s err=%s", + current_host, + type(_err).__name__, + ) + return None + + with resp: + status = getattr(resp, "status", None) or resp.getcode() + if status != 200: + logger.info( + "Gemini image fetch: status=%s host=%s", status, current_host + ) + return None + _hdr_mime = ( + (resp.headers.get("content-type") or "").split(";")[0].strip().lower() + ) + # Declared non-image MIME is a refusal; missing MIME falls back to caller's. + if _hdr_mime and not _hdr_mime.startswith("image/"): + logger.info( + "Gemini image fetch: non-image content-type=%s host=%s", + _hdr_mime, + current_host, + ) + return None + _final_mime_pre = _hdr_mime if _hdr_mime else fallback_mime + if not isinstance(_final_mime_pre, str) or not _final_mime_pre.startswith( + "image/" + ): + logger.info( + "Gemini image fetch: missing content-type and no image fallback host=%s", + current_host, + ) + return None + _hdr_len = resp.headers.get("content-length") + if _hdr_len and _hdr_len.isdigit() and int(_hdr_len) > _byte_limit: + logger.info( + "Gemini image fetch: declared %s bytes exceeds cap=%s host=%s", + _hdr_len, + _byte_limit, + current_host, + ) + return None + # Read cap+1 to detect oversize without buffering unbounded data. + raw = resp.read(_byte_limit + 1) + if len(raw) > _byte_limit: + logger.info( + "Gemini image fetch: streamed bytes exceed cap=%s host=%s", + _byte_limit, + current_host, + ) + return None + return _final_mime_pre, base64.b64encode(raw).decode("ascii") + + logger.info("Gemini image fetch: too many redirects host=%s", current_host) + return None + + +async def _safe_fetch_image_for_gemini( + url: str, + fallback_mime: str, + max_bytes: int = _GEMINI_REMOTE_IMAGE_MAX_BYTES, +) -> Optional[tuple[str, str]]: + """Async wrapper running the IP-pinned fetch on a worker thread. + + SSRF guards (https only, pinned IP, per-hop redirect re-check, size + cap, image/* content-type) live in the sync helper. `max_bytes` + carries the remaining per-request budget so over-budget URLs are + rejected up front. + """ + import asyncio + + return await asyncio.to_thread( + _safe_fetch_image_for_gemini_sync, url, fallback_mime, max_bytes + ) + + +# Synthetic-tool names stamped onto outbound _toolEvent.arguments so the +# frontend can distinguish provider-side cards from real user-declared +# tools of the same name. Mirrored on the TS side. +_SERVER_SIDE_BUILTIN_TOOL_NAMES = frozenset( + {"web_search", "web_fetch", "code_execution", "image_generation"} +) + + +def _stamp_server_tool_marker(payload: dict[str, Any]) -> None: + """Tag synthetic provider-side tool events so the frontend can + distinguish them from real user-declared / local function tools of + the same name. The marker rides on `arguments._server_tool` and is + only added for known server-side builtin names; user-supplied + tool calls echoed back through these helpers (e.g. Kimi + `$web_search`) keep their existing shape because we keep this scoped + to the canonical builtin names. + """ + if not isinstance(payload, dict): + return + if payload.get("type") != "tool_start": + return + name = payload.get("tool_name") + if not isinstance(name, str) or name not in _SERVER_SIDE_BUILTIN_TOOL_NAMES: + return + args = payload.get("arguments") + if not isinstance(args, dict): + args = {} + payload["arguments"] = args + args["_server_tool"] = True + + def _build_kimi_tool_end( synthetic_chunk_fn: Any, tool_call_id: str, @@ -546,16 +792,23 @@ def __init__( ): self.provider_type = provider_type self.base_url = base_url.rstrip("/") + # Strip a legacy `/openai` suffix from Google-hosted bases so + # configs saved before the native switch still route correctly. + # Custom proxy paths ending in `/openai` are left untouched. + if self.provider_type == "gemini": + _parsed_base = urlparse(self.base_url) + if ( + (_parsed_base.hostname or "").lower() + == "generativelanguage.googleapis.com" + and _parsed_base.path.rstrip("/") == "/v1beta/openai" + ): + self.base_url = self.base_url[: -len("/openai")] self.api_key = api_key self._timeout = httpx.Timeout(timeout, connect = 10.0) - # Separate timeout for SSE streams: reasoning-heavy providers - # (Anthropic Opus 4.7 with adaptive thinking, OpenAI gpt-5.x via - # /v1/responses) can pause for tens of seconds between bytes - # while the model is internally thinking. httpx's read timeout is - # the *gap* between successive reads, not a wall clock — so - # disabling it lets long thinks complete without cutting the - # stream prematurely. connect/write/pool keep the 10s / 120s - # bounds so genuine network failures still surface. + # Disable read timeout on SSE streams: reasoning-heavy models + # pause tens of seconds between bytes while thinking, and httpx's + # read timeout is the per-byte gap, not wall clock. connect/write + # bounds still surface real network failures. self._stream_timeout = httpx.Timeout(timeout, connect = 10.0, read = None) def _auth_headers(self) -> dict[str, str]: @@ -566,6 +819,14 @@ def _auth_headers(self) -> dict[str, str]: auth_header = provider_info.get("auth_header", "Authorization") auth_prefix = provider_info.get("auth_prefix", "Bearer ") + # Non-Google Gemini bases (LiteLLM, custom gateways) use OAI-compat + # Bearer auth, not Google's x-goog-api-key. Override the registry default. + if self.provider_type == "gemini": + _host = (urlparse(self.base_url).hostname or "").lower() + if _host != "generativelanguage.googleapis.com": + auth_header = "Authorization" + auth_prefix = "Bearer " + headers = {"Content-Type": "application/json"} # Skip auth header when api_key is empty (optional for local providers); # httpx rejects an empty `Bearer ` value as "Illegal header value". @@ -580,6 +841,12 @@ def _is_openai_compatible(self) -> bool: from core.inference.providers import get_provider_info info = get_provider_info(self.provider_type) or {} + # Google-hosted Gemini uses the native translator; non-Google + # bases stay on OAI-compat so LiteLLM / custom proxies still work. + if self.provider_type == "gemini": + _host = (urlparse(self.base_url).hostname or "").lower() + if _host != "generativelanguage.googleapis.com": + return True return info.get("openai_compatible", True) async def stream_chat_completion( @@ -594,11 +861,13 @@ async def stream_chat_completion( enable_thinking: Optional[bool] = None, reasoning_effort: Optional[str] = None, enabled_tools: Optional[list[str]] = None, - enable_prompt_caching: Optional[bool] = None, + enable_prompt_caching: Optional[Union[bool, str]] = None, openai_code_exec_container_id: Optional[str] = None, anthropic_code_exec_container_id: Optional[str] = None, prompt_cache_ttl: Optional[str] = None, compaction_threshold: Optional[int] = None, + tools: Optional[list[dict[str, Any]]] = None, + tool_choice: Optional[Any] = None, fast_mode: Optional[bool] = None, stream: bool = True, ) -> AsyncGenerator[str, None]: @@ -616,7 +885,35 @@ async def stream_chat_completion( ``fast_mode`` only applies to Anthropic Opus 4.6 / 4.7 (silently dropped elsewhere); adds the beta header and ``speed: "fast"``. """ + # tool_choice="none" hard-disables hosted/builtin tools across + # every provider so enabled_tools cannot accidentally bill or leak. + tool_choice_disabled = ( + isinstance(tool_choice, str) and tool_choice.strip().lower() == "none" + ) + if not self._is_openai_compatible(): + # Gemini speaks its own native REST shape (contents/parts); + # `_stream_gemini` translates request/response into the OpenAI + # Chat Completions chunk format the rest of Studio expects. + # API reference: https://ai.google.dev/gemini-api/docs + if self.provider_type == "gemini": + async for line in self._stream_gemini( + messages, + model, + temperature, + top_p, + max_tokens, + top_k, + presence_penalty, + enabled_tools, + enable_prompt_caching, + enable_thinking, + reasoning_effort, + tools, + tool_choice, + ): + yield line + return async for line in self._stream_anthropic( messages, model, @@ -631,6 +928,7 @@ async def stream_chat_completion( anthropic_code_exec_container_id, prompt_cache_ttl, compaction_threshold, + tool_choice, fast_mode = fast_mode, ): yield line @@ -654,20 +952,25 @@ async def stream_chat_completion( enable_prompt_caching, openai_code_exec_container_id, compaction_threshold, + tools, + tool_choice, ): yield line return - # Kimi's $web_search is a builtin_function that requires a client - # round-trip: the first call returns a tool_calls envelope with - # function.arguments populated; the caller echoes those arguments - # back as a role=tool message; the second call streams the final - # answer with the search incorporated. The doc also mandates - # disabling thinking while $web_search is active. Route to a - # dedicated helper so the default OAI-compat path stays single-pass. - # https://platform.kimi.ai/docs/guide/use-web-search + # Kimi $web_search needs a 2-call round-trip + thinking off; route + # to a helper. Forced-function tool_choice suppresses it. + # https://platform.kimi.ai/docs/guide/use-web-search + _kimi_tool_choice_forced_function = ( + isinstance(tool_choice, dict) + and tool_choice.get("type") == "function" + and isinstance(tool_choice.get("function"), dict) + and bool(tool_choice["function"].get("name")) + ) if ( self.provider_type == "kimi" + and not tool_choice_disabled + and not _kimi_tool_choice_forced_function and enabled_tools and "web_search" in enabled_tools ): @@ -694,28 +997,18 @@ async def stream_chat_completion( else: body["max_tokens"] = max_tokens - # Strip body fields a provider's registry entry declares unusable — - # reasoning-class models that lock these to fixed defaults (e.g. - # Kimi k2.5/k2.6 only accept temperature=1, top_p=1) 400 otherwise. - # The frontend capability map already hides the matching sliders; - # this is the matching guard for the pydantic default that the - # route layer would otherwise still fill in. + # Drop fields the registry flags as unusable so reasoning-class + # models with fixed defaults (Kimi k2.6 etc) don't 400 on pydantic + # default values that the route layer still fills in. from core.inference.providers import get_provider_info provider_info = get_provider_info(self.provider_type) or {} for field in provider_info.get("body_omit", ()): body.pop(field, None) - # Kimi (kimi-k2.6, kimi-k2-thinking) accepts a boolean thinking toggle - # via a top-level `thinking` field (the docs show it nested under - # extra_body, but that is an OpenAI Python SDK convention; on the - # wire it merges into the request body). - # - kimi-k2.6 defaults to thinking enabled; clients can pass - # {"type": "disabled"} to suppress it. - # - kimi-k2-thinking is always on; we never send disabled there. - # `keep: all` retains every thinking chunk through the stream, which - # is what we need so our frontend can wrap reasoning_content into - # the chat reasoning panel. + # Kimi thinking is a top-level body field. kimi-k2-thinking is + # always on (ignore the toggle); kimi-k2.6 defaults on, can be + # disabled. `keep: all` preserves every chunk for the UI panel. if self.provider_type == "kimi" and enable_thinking is not None: if model == "kimi-k2-thinking": # Always on; ignore client toggle to avoid an API-level reject. @@ -736,17 +1029,9 @@ async def stream_chat_completion( tpl_kw["enable_thinking"] = bool(enable_thinking) body["chat_template_kwargs"] = tpl_kw - # OpenRouter exposes a unified `reasoning` parameter on every - # chat-completion request — the gateway routes it to whichever - # underlying model actually supports reasoning, and silently - # no-ops for ones that don't. Documented at - # https://openrouter.ai/docs/guides/best-practices/reasoning-tokens - # Shape: `reasoning: {enabled?: bool, effort?: low|medium|high, - # max_tokens?: N, exclude?: bool}` with effort and max_tokens - # mutually exclusive. We forward either an effort level (when - # the user picked one) or a bare {enabled: true}. A small set of - # known routes rejects explicit disable with 400 ("Reasoning is - # mandatory for this endpoint ..."), so only those omit "off". + # OpenRouter's unified `reasoning` field gates per-model thinking. + # Some routes (`*_MANDATORY_REASONING_MODELS`) 400 on explicit off. + # https://openrouter.ai/docs/guides/best-practices/reasoning-tokens if self.provider_type == "openrouter": normalized_or_model = model.strip().lower() if reasoning_effort in ("low", "medium", "high"): @@ -759,17 +1044,22 @@ async def stream_chat_completion( else: body["reasoning"] = {"enabled": False} - # OpenRouter web-search plugin — universal shape that works - # for every model id, including the `openrouter/free` and - # `openrouter/auto` meta-routers. Documented at - # https://openrouter.ai/docs/guides/features/plugins/web-search - # The `:online` model-suffix shortcut is "exactly equivalent - # to" this plugin per the same doc, but only works on - # concrete model ids — meta-routers reject the suffix. - # `plugins: [{id: "web"}]` works everywhere, no model id - # rewrite needed, and idempotent if some future call site - # adds the entry first. - if enabled_tools and "web_search" in enabled_tools: + # OpenRouter web plugin works on every model id including + # meta-routers (unlike the `:online` suffix). Forced-function + # tool_choice suppresses it, matching Gemini/Anthropic. + # https://openrouter.ai/docs/guides/features/plugins/web-search + _or_tool_choice_forced_function = ( + isinstance(tool_choice, dict) + and tool_choice.get("type") == "function" + and isinstance(tool_choice.get("function"), dict) + and bool(tool_choice["function"].get("name")) + ) + if ( + not tool_choice_disabled + and not _or_tool_choice_forced_function + and enabled_tools + and "web_search" in enabled_tools + ): plugins = list(body.get("plugins") or []) if not any( isinstance(p, dict) and p.get("id") == "web" for p in plugins @@ -781,6 +1071,15 @@ async def stream_chat_completion( body.get("model"), ) + # Forward OpenAI-style function tools / tool_choice on every + # OAI-compat route (incl. custom Gemini OpenAI proxies like + # LiteLLM). Without this, callers that wire user-defined tools + # silently lose function-calling on non-native providers. + if tools: + body["tools"] = tools + if tool_choice is not None: + body["tool_choice"] = tool_choice + url = f"{self.base_url}/chat/completions" logger.info( "Proxying chat completion to %s (provider=%s, model=%s)", @@ -816,30 +1115,22 @@ async def stream_chat_completion( ) return - # NOTE: manual __anext__ loop instead of `async for` is intentional. - # On Python 3.13 + httpcore 1.0.x, `async for` auto-calls aclose() on - # early exit (break/return/GeneratorExit) BEFORE our finally block runs. - # That propagates GeneratorExit into PoolByteStream.__aiter__() while it - # calls `await self.aclose()` inside `with AsyncShieldCancellation()`, - # triggering "RuntimeError: async generator ignored GeneratorExit". - # Fix: call response.aclose() FIRST (sets PoolByteStream._closed=True), - # then lines_gen.aclose() is a no-op and GeneratorExit re-raises cleanly. + # Manual __anext__ (not `async for`) so we can close + # the response BEFORE lines_gen, avoiding the httpcore + # 1.0 GeneratorExit -> RuntimeError path on Python 3.13. lines_gen = response.aiter_lines().__aiter__() - # Best-effort diagnostics for the default OAI-compat path. Without - # this, OpenRouter mid-stream errors (200 OK + error event in the - # SSE body) and OpenRouter-router model selection were invisible - # in the backend logs — the user only saw "Provider returned - # error" in the UI with no trail on the server side. + # Diagnostic counters for the OAI-compat path; surfaces + # OpenRouter mid-stream errors that would otherwise be + # invisible server-side. event_counts: dict[str, int] = {} chosen_model: Optional[str] = None - # Web-search tool-card synthesis for OpenRouter. The gateway - # doesn't emit structured web_search_call events — citations - # come back as `annotations` of type=url_citation on delta / - # message objects. Mirror the OpenAI/Anthropic UX by yielding - # a synthetic tool_start at stream open and tool_end at - # stream close with the collected citation list. + # OpenRouter has no web_search_call events — citations + # arrive as url_citation annotations. Synthesise a + # tool_start/tool_end pair to match the OpenAI/Anthropic UX. web_search_active = ( self.provider_type == "openrouter" + and not tool_choice_disabled + and not _or_tool_choice_forced_function and bool(enabled_tools) and "web_search" in (enabled_tools or []) ) @@ -849,6 +1140,7 @@ async def stream_chat_completion( web_search_tool_ended = False def _emit_synthetic_tool_event(payload: dict[str, Any]) -> str: + _stamp_server_tool_marker(payload) chunk = { "id": f"chatcmpl-{self.provider_type}-synthetic", "object": "chat.completion.chunk", @@ -1104,6 +1396,7 @@ async def _stream_kimi_web_search( synthetic_id = f"chatcmpl-{self.provider_type}-synthetic" def _synthetic_chunk(payload: dict[str, Any]) -> str: + _stamp_server_tool_marker(payload) chunk = { "id": synthetic_id, "object": "chat.completion.chunk", @@ -1441,6 +1734,7 @@ async def _stream_anthropic( anthropic_code_exec_container_id: Optional[str] = None, prompt_cache_ttl: Optional[str] = None, compaction_threshold: Optional[int] = None, + tool_choice: Optional[Any] = None, *, fast_mode: Optional[bool] = None, ) -> AsyncGenerator[str, None]: @@ -1471,6 +1765,42 @@ async def _stream_anthropic( continue content = msg.get("content") + # OpenAI role="tool" with list content -> Anthropic native + # tool_result block on a user message. Translating in the + # string-content branch only (below) leaves the list-content + # form forwarded as an invalid `role:"tool"` message that + # Anthropic rejects. Handle both upfront. + if msg.get("role") == "tool": + _tr_id = msg.get("tool_call_id") or "" + if isinstance(content, list): + _flat_parts: list[str] = [] + for part in content: + if ( + isinstance(part, dict) + and part.get("type") == "text" + and part.get("text") + ): + _flat_parts.append(str(part["text"])) + _flat_result = "".join(_flat_parts) + elif content is None: + _flat_result = "" + elif isinstance(content, str): + _flat_result = content + else: + _flat_result = _json.dumps(content) + filtered.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": _tr_id, + "content": _flat_result, + } + ], + } + ) + continue if isinstance(content, list): # Translate OpenAI multimodal parts -> Anthropic native shapes. # - `image_url` -> `{type:"image", source:...}` @@ -1583,6 +1913,37 @@ async def _stream_anthropic( if title: doc_block["title"] = title anthropic_parts.append(doc_block) + # Assistant tool_calls -> Anthropic tool_use blocks + # appended to the same message. Anthropic native + # Messages API does not accept OpenAI's top-level + # `tool_calls` field; the call lives inside a content + # block with `{type:"tool_use", id, name, input}`. + if msg.get("role") == "assistant" and isinstance( + msg.get("tool_calls"), list + ): + for _tc in msg["tool_calls"]: + if not isinstance(_tc, dict): + continue + _fn = _tc.get("function") or {} + if not isinstance(_fn, dict) or not _fn.get("name"): + continue + _raw = _fn.get("arguments") or "{}" + try: + _input = ( + _json.loads(_raw) if isinstance(_raw, str) else _raw + ) + except Exception: + _input = {"_raw": _raw} + if not isinstance(_input, dict): + _input = {"value": _input} + anthropic_parts.append( + { + "type": "tool_use", + "id": _tc.get("id") or f"toolu_{time.time_ns()}", + "name": _fn["name"], + "input": _input, + } + ) # Skip whole-message append when nothing usable survived. # An empty content array (e.g. user dropped only an unparseable # `input_document`) would 400 the Anthropic API with @@ -1590,6 +1951,72 @@ async def _stream_anthropic( if anthropic_parts: filtered.append({"role": msg["role"], "content": anthropic_parts}) else: + # role="tool" follow-up -> Anthropic native tool_result + # block on a `user` message. The OpenAI shape + # (role=tool, content=string, tool_call_id) is not a + # valid Anthropic role. + if msg.get("role") == "tool": + _tr_id = msg.get("tool_call_id") or "" + _tr_content = msg.get("content") + if _tr_content is None: + _tr_content = "" + filtered.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": _tr_id, + "content": ( + _tr_content + if isinstance(_tr_content, str) + else _json.dumps(_tr_content) + ), + } + ], + } + ) + continue + # Assistant turn whose content is a plain string but + # also carries OpenAI `tool_calls`: convert into a + # content-array message with a text block + tool_use + # blocks. Without this, the top-level tool_calls leaks + # through unchanged. + if ( + msg.get("role") == "assistant" + and isinstance(msg.get("tool_calls"), list) + and msg["tool_calls"] + ): + _text_content = msg.get("content") + _blocks: list[dict[str, Any]] = [] + if isinstance(_text_content, str) and _text_content: + _blocks.append({"type": "text", "text": _text_content}) + for _tc in msg["tool_calls"]: + if not isinstance(_tc, dict): + continue + _fn = _tc.get("function") or {} + if not isinstance(_fn, dict) or not _fn.get("name"): + continue + _raw = _fn.get("arguments") or "{}" + try: + _input = ( + _json.loads(_raw) if isinstance(_raw, str) else _raw + ) + except Exception: + _input = {"_raw": _raw} + if not isinstance(_input, dict): + _input = {"value": _input} + _blocks.append( + { + "type": "tool_use", + "id": _tc.get("id") or f"toolu_{time.time_ns()}", + "name": _fn["name"], + "input": _input, + } + ) + if _blocks: + filtered.append({"role": "assistant", "content": _blocks}) + continue filtered.append(msg) # Claude 4.7 family removed temperature / top_p / top_k entirely. @@ -1616,34 +2043,16 @@ async def _stream_anthropic( # same as True here (callers that don't set the flag still get # caching). Pass False explicitly to opt out. prompt_caching_enabled = enable_prompt_caching is not False - # Anthropic accepts an optional `ttl` on each cache_control marker - # (default is the 5m ephemeral pool; set "1h" to land in the 1h - # pool instead). Per the prompt-caching docs, 1h cache writes are - # billed at 2x base input vs 1.25x for 5m, but reads are 0.1x for - # both. The 1h pool is the right pick when conversations span - # multiple short bursts more than 5 minutes apart -- the read - # discount makes up for the 1.6x write premium after a single - # additional hit. Anything other than the known TTL strings is - # dropped to avoid sending a malformed marker. - # - # The `extended-cache-ttl-2025-04-11` beta header that originally - # gated 1h TTL has been promoted to GA: as of 2026-05 the live - # API accepts `ttl: "1h"` without any beta opt-in. Verified - # against api.anthropic.com on claude-opus-4-7 (status 200 + - # `ephemeral_1h_input_tokens` populated). The test below pins - # the contract by asserting the header is NOT on the wire so a - # future regression that reintroduces the gate would surface - # before users see a 400. + # Optional 1h cache TTL is GA as of 2026-05 (no beta header). 1h + # writes are 2x vs 5m's 1.25x but reads are 0.1x for both, so 1h + # wins after a single extra hit. Unknown TTL strings drop. cache_marker: dict[str, Any] = {"type": "ephemeral"} if prompt_cache_ttl in ("5m", "1h"): cache_marker["ttl"] = prompt_cache_ttl if system: if prompt_caching_enabled: - # System block is the most stable prefix across turns, so - # it gets its own breakpoint. Skipped when system is - # empty — there's nothing to cache, and an empty marker - # is a no-op. + # System is the most stable cross-turn prefix; own breakpoint. body["system"] = [ { "type": "text", @@ -1749,19 +2158,29 @@ async def _stream_anthropic( if body.get("max_tokens", 0) <= budget_tokens: body["max_tokens"] = budget_tokens + 1024 - # Anthropic server-side web_search — see - # https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool - # The tool type is date-pinned per model family. Newer Opus / - # Sonnet 4.6 + 4.7 accept `web_search_20260209` with dynamic - # filtering (Claude writes code to filter results before they - # reach context); everything else uses `web_search_20250305`. - # `_anthropic_web_search_version` picks the right one. Anthropic - # dispatches search calls server-side, returning server_tool_use - # + web_search_tool_result blocks in the SSE stream, plus - # url-citation annotations on text deltas. We translate all of - # that into our local _toolEvent shape so the chat UI renders - # web_search exactly like OpenAI's path. - if enabled_tools and "web_search" in enabled_tools: + # tool_choice="none" or pinned-function suppresses hosted tools + # so a stale UI toggle can't fire server-side search/code-exec. + _anthropic_tool_choice_disabled = ( + isinstance(tool_choice, str) and tool_choice.strip().lower() == "none" + ) + _anthropic_tool_choice_forced_function = ( + isinstance(tool_choice, dict) + and tool_choice.get("type") == "function" + and isinstance(tool_choice.get("function"), dict) + and bool(tool_choice["function"].get("name")) + ) + _anthropic_hosted_builtins_allowed = ( + not _anthropic_tool_choice_disabled + and not _anthropic_tool_choice_forced_function + ) + + # Anthropic web_search (date-pinned per model family). + # https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool + if ( + _anthropic_hosted_builtins_allowed + and enabled_tools + and "web_search" in enabled_tools + ): anthropic_tools = list(body.get("tools") or []) anthropic_tools.append( { @@ -1772,14 +2191,13 @@ async def _stream_anthropic( ) body["tools"] = anthropic_tools - # Anthropic server-side web_fetch reads a single URL (text/PDF) - # and returns a `web_fetch_tool_result` document block. Opt in - # via `enabled_tools=["web_fetch"]`; no beta header required. - # `_anthropic_web_fetch_version` picks `web_fetch_20260209` - # (dynamic filtering) for Opus 4.6/4.7 + Sonnet 4.6, falling - # back to `web_fetch_20250910` elsewhere; mismatched variants - # return 400 so the per-model picker is required. - web_fetch_enabled = bool(enabled_tools and "web_fetch" in enabled_tools) + # Anthropic web_fetch: only URLs already in conversation. Date-pinned. + # https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-fetch-tool + web_fetch_enabled = bool( + _anthropic_hosted_builtins_allowed + and enabled_tools + and "web_fetch" in enabled_tools + ) if web_fetch_enabled: anthropic_tools = list(body.get("tools") or []) anthropic_tools.append( @@ -1792,24 +2210,13 @@ async def _stream_anthropic( body["tools"] = anthropic_tools # Anthropic server-side code execution — see - # https://platform.claude.com/docs/en/agents-and-tools/tool-use/code-execution-tool - # The tool type is date-pinned per model family. - # `_anthropic_code_execution_version` picks `code_execution_20260120` - # for Opus 4.5+ / Sonnet 4.5+ / Opus 4.7 / Sonnet 4.6 (adds REPL - # state persistence + programmatic tool calling) and falls back - # to `code_execution_20250825` everywhere else. Both versions - # run Python + bash + str_replace file edits inside a 5 GB - # sandboxed container per request, with no internet access, and - # both are unlocked by the same `code-execution-2025-08-25` - # `anthropic-beta` header set further down. On the SSE stream - # Anthropic emits two sub-tool names -- `bash_code_execution` - # and `text_editor_code_execution` -- wrapped in the standard - # server_tool_use / *_tool_result block shape. - # v1 wires the tool only; file uploads (container_upload - # content blocks and generated-file retrieval via the Files - # API) are a deliberate follow-up. + # https://platform.claude.com/docs/en/agents-and-tools/tool-use/code-execution-tool + # Date-pinned tool type per model; both unlock via the same + # `code-execution-2025-08-25` beta header set below. code_execution_enabled = bool( - enabled_tools and "code_execution" in enabled_tools + _anthropic_hosted_builtins_allowed + and enabled_tools + and "code_execution" in enabled_tools ) if code_execution_enabled: anthropic_tools = list(body.get("tools") or []) @@ -1820,32 +2227,14 @@ async def _stream_anthropic( } ) body["tools"] = anthropic_tools - # Reuse the prior turn's container so filesystem state - # (files written, packages installed, variables set) - # persists across turns of the same thread. Anthropic - # exposes the container id on the Message object's - # top-level `container.id`; on the SSE stream we latch it - # off `message_start.message.container.id` further down - # and emit a `container_ready` _toolEvent so the chat - # adapter persists it on the thread record. A stale id - # (container expired / not found) surfaces as a 4xx - # below, where we emit `container_invalidated` and let - # the next turn fall back to auto-create. + # Reuse the thread's prior container so filesystem state + # persists. Stale ids 4xx and clear via container_invalidated. if anthropic_code_exec_container_id: body["container"] = anthropic_code_exec_container_id - # Server-side context compaction — see - # https://platform.claude.com/docs/en/build-with-claude/compaction - # Beta as of `compact-2026-01-12`. When `compaction_threshold` is - # provided AND the model accepts compaction (Opus 4.6+ / 4.7, - # Sonnet 4.6, Mythos preview), attach - # `context_management.edits[{type:"compact_20260112", trigger: - # {type:"input_tokens", value:N}}]` to the body. Anthropic runs - # the compaction step server-side once the rendered prompt - # crosses the threshold and replies with a top-level - # `context_management` block plus `usage.iterations[]` so we can - # account per-iteration. Below-min thresholds get clamped up to - # 50K so the request doesn't 400. + # Server-side compaction (beta `compact-2026-01-12`). Clamps + # below-min thresholds to 50K so the request doesn't 400. + # https://platform.claude.com/docs/en/build-with-claude/compaction compaction_active = ( compaction_threshold is not None and compaction_threshold > 0 @@ -1878,10 +2267,8 @@ async def _stream_anthropic( url = f"{self.base_url}/messages" completion_id = f"chatcmpl-anthropic-{model.replace('/', '-')}" - # Log the outgoing config keys (not the messages themselves) so we - # can prove which thinking/effort fields actually reached the wire. - # If Anthropic skips reasoning despite a configured effort, this - # tells us whether we sent the field or dropped it on the floor. + # Log outgoing config keys (not messages) to prove which thinking / + # effort fields actually reached the wire. logger.info( "Anthropic request shape (model=%s, has_thinking=%s, thinking=%s, " "output_config=%s, temperature=%s, has_top_p=%s, has_top_k=%s, " @@ -1896,16 +2283,10 @@ async def _stream_anthropic( body.get("max_tokens"), ) - # Translate Anthropic stop reasons onto the OpenAI chat-completions - # `finish_reason` vocabulary. `pause_turn` maps to None so the - # adapter does NOT emit a finish_reason chunk: pause_turn means - # Claude paused a long server-tool turn (web_search / web_fetch) - # and will continue once the user (or our retry) sends back the - # partial assistant message. Forwarding it as "stop" makes the - # OpenAI client think the answer is done and truncates the - # rendered message. `refusal` maps to "content_filter" as the - # nearest semantic match. See - # https://platform.claude.com/docs/en/api/messages#response-stop-reason + # Anthropic stop_reason -> OpenAI finish_reason. `pause_turn` + # maps to None so the UI doesn't treat a paused server-tool turn + # as final. `refusal` -> "content_filter" (closest match). + # https://platform.claude.com/docs/en/api/messages#response-stop-reason _finish_reason_map: dict[str, Optional[str]] = { "end_turn": "stop", "max_tokens": "length", @@ -1918,11 +2299,7 @@ async def _stream_anthropic( logger.info("Proxying Anthropic Messages API to %s (model=%s)", url, model) request_headers = self._auth_headers() - # Anthropic accepts comma-separated beta features in a single - # `anthropic-beta` header. Merge our flags onto whatever the - # registry's extra_headers contributed (currently nothing on - # the beta axis, just anthropic-version) so future betas - # added at the registry level keep working. + # Merge new beta flags onto whatever the registry contributed. existing_beta = request_headers.get("anthropic-beta", "").strip() beta_parts = ( [p.strip() for p in existing_beta.split(",") if p.strip()] @@ -1988,27 +2365,14 @@ async def _stream_anthropic( # "no thinking content" — distinguishes "Anthropic never sent # thinking_delta" from "frontend didn't render the chunks". event_counts: dict[str, int] = {} - # web_search state. Anthropic emits the query inside an - # `input_json_delta` stream on a `server_tool_use` content - # block, then a separate `web_search_tool_result` block - # with the URL list. Unlike OpenAI we get per-call results - # directly, so each tool card carries its own citations. - # `current_server_tool_use`: {id, name, partial_json_buffer} - # `current_result_block`: {tool_use_id, results} - # Both go to None when the matching content_block_stop fires. + # web_search state. Query streams via input_json_delta + # on a server_tool_use block; results land in a separate + # web_search_tool_result block. Per-call citations. current_server_tool_use: Optional[dict[str, Any]] = None current_result_block: Optional[dict[str, Any]] = None web_search_calls: dict[str, dict[str, Any]] = {} - # code_execution state. Anthropic's - # `code_execution_20250825` tool emits the same - # server_tool_use → *_tool_result block shape as - # web_search, but the server_tool_use carries one of - # two sub-tool names (`bash_code_execution` or - # `text_editor_code_execution`) and the result block - # type matches (`bash_code_execution_tool_result` / - # `text_editor_code_execution_tool_result`). Kept - # parallel to web_search state so the two paths don't - # collide when both pills are on in the same turn. + # code_execution state (bash / text_editor sub-tools); + # kept parallel to web_search so concurrent pills don't collide. current_code_exec_use: Optional[dict[str, Any]] = None current_code_exec_result: Optional[dict[str, Any]] = None code_execution_calls: dict[str, dict[str, Any]] = {} @@ -2078,6 +2442,7 @@ def _content_chunk(text: str) -> str: return f"data: {_json.dumps(chunk)}" def _emit_tool_event(payload: dict[str, Any]) -> str: + _stamp_server_tool_marker(payload) chunk = { "id": completion_id, "object": "chat.completion.chunk", @@ -2335,18 +2700,8 @@ def _format_code_execution_result( "inner": inner if isinstance(inner, dict) else {}, } elif block_type == "compaction": - # Server-side compaction emits a `compaction` - # content block on the assistant message. - # Anthropic may include the summary text on - # this start event AND/OR stream it via - # text_delta events on the same block. See - # https://platform.claude.com/docs/en/build-with-claude/compaction - # Capture either form; finalize and emit - # on content_block_stop. The chat-adapter - # persists the block onto the assistant - # message so the next turn's request - # carries it back -- Anthropic then skips - # re-compaction from scratch. + # Summary may arrive on start AND/OR via + # text_delta. Capture both; emit on stop. seed = content_block.get("content") or "" current_compaction = { "content": seed if isinstance(seed, str) else "", @@ -2356,12 +2711,7 @@ def _format_code_execution_result( delta = event.get("delta", {}) delta_type = delta.get("type") if delta_type == "thinking_delta": - # Anthropic streams extended-thinking content as - # thinking_delta events on a separate content - # block. Wrap as inline ... so - # the frontend's parseAssistantContent lifts it - # into the reasoning panel — same pattern as - # the OpenAI Responses path. + # Wrap as ... for parseAssistantContent. thinking_text = delta.get("thinking", "") if thinking_text: if not thinking_open: @@ -2631,19 +2981,9 @@ def _format_code_execution_result( delta_usage = event.get("usage") if isinstance(delta_usage, dict): last_usage.update(delta_usage) - # When a fresh compaction has run, Anthropic - # publishes per-iteration token counts in - # `usage.iterations[]`. The top-level - # input_tokens / output_tokens only cover the - # `message` iteration, NOT the compaction - # passes — billing has to sum the whole - # array. See - # https://platform.claude.com/docs/en/build-with-claude/compaction - # Fold the compaction iterations into - # `compaction_input_tokens` / `compaction_output_tokens` - # so the cost surface can add them without - # re-walking the array (and so the closing - # log line names the figures). + # Compaction iterations aren't in top-level + # input/output_tokens; fold them into + # compaction_{input,output}_tokens for billing. iterations = delta_usage.get("iterations") if isinstance(iterations, list): c_in = 0 @@ -2880,133 +3220,1977 @@ def _format_code_execution_result( self.provider_type, ) - async def _stream_openai_responses( + async def _stream_gemini( self, messages: list[dict[str, Any]], model: str, temperature: float, top_p: float, max_tokens: Optional[int], - enable_thinking: Optional[bool], - reasoning_effort: Optional[str], + top_k: Optional[int] = None, + presence_penalty: float = 0.0, enabled_tools: Optional[list[str]] = None, - enable_prompt_caching: Optional[bool] = None, - openai_code_exec_container_id: Optional[str] = None, - compaction_threshold: Optional[int] = None, + enable_prompt_caching: Optional[Any] = None, + enable_thinking: Optional[bool] = None, + reasoning_effort: Optional[str] = None, + tools: Optional[list[dict[str, Any]]] = None, + tool_choice: Optional[Any] = None, ) -> AsyncGenerator[str, None]: """ - Call OpenAI's /v1/responses endpoint and translate its SSE stream back - into OpenAI Chat Completions chunk format. - - The Responses API uses a different request shape (``input`` instead of - ``messages``, ``instructions`` for system prompts, ``max_output_tokens`` - for the budget) and emits event-typed SSE frames (e.g. - ``response.output_text.delta``) rather than chat-completion chunks. - ``presence_penalty`` / ``top_k`` are not part of the Responses contract - and are dropped here intentionally. + Call Google's native Gemini API and translate its streaming + ``streamGenerateContent`` response into OpenAI Chat Completions + chunk format. + + Gemini does NOT speak the OpenAI Chat Completions contract on + its primary endpoint. The wire shape is: + + POST /v1beta/models/{model}:streamGenerateContent?alt=sse + { + "contents": [{"role": "user|model", "parts": [{"text": "..."}]}], + "systemInstruction": {"parts": [{"text": "..."}]}, + "generationConfig": {"temperature": 0.7, "topP": 0.95, "topK": 40, + "maxOutputTokens": 1024}, + "tools": [{"googleSearch": {}}, {"codeExecution": {}}], + "cachedContent": "" // optional, see caching docs + } + + Streamed responses are SSE frames carrying partial + ``GenerateContentResponse`` objects: + + {"candidates": [{"content": {"parts": [{"text": "Hello"}]}, + "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 7, "candidatesTokenCount": 3}} + + Image generation uses the same endpoint with model + ``gemini-2.5-flash-image`` (also called Nano Banana); the + response carries an ``inlineData`` part with the base64 PNG + bytes and a ``mimeType``. We surface that through the same + ``tool_start`` / ``tool_end`` ``image_b64`` envelope the OpenAI + image_generation path uses, so the chat UI renders the image + inline with no extra plumbing. + + References: + - https://ai.google.dev/gemini-api/docs/text-generation + - https://ai.google.dev/gemini-api/docs/function-calling + - https://ai.google.dev/gemini-api/docs/grounding + - https://ai.google.dev/gemini-api/docs/caching + - https://ai.google.dev/gemini-api/docs/image-generation """ import json as _json - is_openai_cloud = _is_openai_family_cloud(self.base_url) - image_generation_requested = bool( - enabled_tools and "image_generation" in enabled_tools and is_openai_cloud - ) + # Validate the user-controlled model id BEFORE any message + # translation. A model like `../cachedContents/x` is path- + # traversal that lands in `/v1beta/cachedContents/...`; rejecting + # it here also avoids triggering user-controlled outbound fetches + # (remote image_url inlining) on a request we'll error out + # anyway. Documented catalog ids match `[A-Za-z0-9._-]+`. + if not re.fullmatch(r"[A-Za-z0-9._-]+", model): + yield _error_sse_line( + 400, + f"Invalid Gemini model id: {model!r}", + self.provider_type, + ) + return - # Split system messages out into a single `instructions` string and - # translate user/assistant messages into the Responses input shape. - instructions_parts: list[str] = [] - input_items: list[dict[str, Any]] = [] - openai_replay_items: list[dict[str, Any]] = [] - previous_response_id: Optional[str] = None + # Translate OpenAI messages -> Gemini contents. system role + # promotes to top-level systemInstruction. + system_text_parts: list[str] = [] + contents: list[dict[str, Any]] = [] + # OpenAI may drop `name` from role="tool" follow-ups. Remember + # prior function names so functionResponse isn't sent name-less + # (Gemini 400s on empty names). + tool_call_names: dict[str, str] = {} + # tool_call_ids whose assistant card was dropped (synthetic + # builtin) or already replayed as native parts. Their role="tool" + # follow-up must be skipped to avoid orphan/duplicate responses. + _gemini_skip_tool_result_ids: set[str] = set() + # Per-request image caps. The byte cap counts DECODED bytes; we + # set it to ~14 MB because base64 expansion + prompt overhead + # must fit Gemini's ~20 MB request limit. + _GEMINI_REMOTE_IMAGE_MAX_COUNT = 8 + _GEMINI_REMOTE_IMAGE_MAX_TOTAL_BYTES = 14 * 1024 * 1024 + _remote_image_count = 0 + _remote_image_total_bytes = 0 for msg in messages: role = msg.get("role") content = msg.get("content", "") - if role == "system": if isinstance(content, str): if content: - instructions_parts.append(content) + system_text_parts.append(content) elif isinstance(content, list): for part in content: - if part.get("type") == "text" and part.get("text"): - instructions_parts.append(part["text"]) + if ( + isinstance(part, dict) + and part.get("type") == "text" + and part.get("text") + ): + system_text_parts.append(part["text"]) continue - + # Map OpenAI roles to Gemini's two-role contract. + gemini_role = "model" if role == "assistant" else "user" + parts: list[dict[str, Any]] = [] if isinstance(content, str): - input_items.append({"role": role, "content": content}) - continue - - if isinstance(content, list): - translated_parts: list[dict[str, Any]] = [] - used_previous_response_id = False + if content: + parts.append({"text": content}) + elif isinstance(content, list): for part in content: - part_type = part.get("type") - if part_type == "text": - translated_parts.append( - {"type": "input_text", "text": part.get("text", "")} - ) - elif part_type == "image_url": + if not isinstance(part, dict): + continue + ptype = part.get("type") + if ptype == "text": + text = part.get("text", "") + if text: + parts.append({"text": text}) + elif ptype == "image_url": url = part.get("image_url", {}).get("url", "") - if url: - # Responses takes image_url as a flat string (both - # https:// URLs and data: URLs are accepted). - translated_parts.append( - {"type": "input_image", "image_url": url} + if url.startswith("data:"): + header, _, b64data = url.partition(",") + media_type = ( + header.split(";")[0] + .replace("data:", "") + .strip() + .lower() + or "image/jpeg" ) - elif ( - part_type == "reasoning" - and role == "assistant" - and image_generation_requested - ): - replay_item = _sanitize_openai_reasoning_replay_item(part) - if replay_item: - openai_replay_items.append(replay_item) - elif ( - part_type == "image_generation_call" - and role == "assistant" - and image_generation_requested - ): - response_id = ( - part.get("response_id") - or part.get("openai_response_id") - or part.get("previous_response_id") - ) - call_id = part.get("id") or part.get("image_generation_call_id") - if isinstance(call_id, str) and call_id: - if isinstance(response_id, str) and response_id: - previous_response_id = response_id - input_items = [] - translated_parts = [] - used_previous_response_id = True + # Symmetry with the fetched remote image + # path, which already rejects non-image + # Content-Type. A `data:text/html;base64,...` + # URL otherwise lands as Gemini inlineData + # with mimeType="text/html" and 400s the + # whole request. + if not media_type.startswith("image/"): + logger.info( + "Gemini inlineData: refusing non-image data URL media_type=%s", + media_type, + ) + elif b64data: + # data: URLs share the same caps as fetched + # URLs so inline payloads don't bypass them. + _data_approx_bytes = (len(b64data) * 3) // 4 + if ( + _remote_image_count + >= _GEMINI_REMOTE_IMAGE_MAX_COUNT + ): + logger.info( + "Gemini inlineData: per-request count cap %d reached, dropping image", + _GEMINI_REMOTE_IMAGE_MAX_COUNT, + ) + elif ( + _remote_image_total_bytes + _data_approx_bytes + > _GEMINI_REMOTE_IMAGE_MAX_TOTAL_BYTES + ): + logger.info( + "Gemini inlineData: per-request byte cap reached, dropping image", + ) + else: + _remote_image_count += 1 + _remote_image_total_bytes += _data_approx_bytes + parts.append( + { + "inlineData": { + "mimeType": media_type, + "data": b64data, + } + } + ) + elif url: + # fileData.fileUri only accepts Files-API URIs + # and YouTube; everything else must be downloaded + # and inlined. Parse fields explicitly so + # attacker URLs like https://evil.com/youtube.com/x + # aren't misclassified as YouTube. + try: + _parsed_image_url = urlparse(url) + except (ValueError, UnicodeError): + _parsed_image_url = None + if _parsed_image_url is None: + _img_scheme = "" + _img_host = "" + _img_path = "" else: - previous_response_id = None - openai_replay_items.append( - {"type": "image_generation_call", "id": call_id} + _img_scheme = (_parsed_image_url.scheme or "").lower() + _img_host = (_parsed_image_url.hostname or "").lower() + _img_path = _parsed_image_url.path or "" + _is_native_uri = ( + _img_scheme == "https" + and _img_host == "generativelanguage.googleapis.com" + and _img_path.startswith("/v1beta/files/") ) - elif part_type == "input_document": - # OpenAI Responses accepts PDFs / docs as - # `{type:"input_file", file_data:"data:application/pdf;base64,..."}` - # or `{type:"input_file", file_url:"https://..."}`, - # with optional `filename`. See - # https://developers.openai.com/api/docs/guides/images-vision - # Map Studio's normalised `input_document` shape - # straight onto Responses' `input_file`. - file_url = part.get("file_url") - file_data = part.get("file_data") - filename = part.get("filename") - # Mirror the Anthropic-side guard: any "data:" URI - # without an actual base64 payload (`data:application/pdf;base64,` - # or whitespace-only) would otherwise be forwarded - # to OpenAI as `file_data=""`, which 400s the whole - # turn. Treat such payloads as missing AND fall - # back to file_url if one is also present, so a - # recoverable remote URL doesn't get discarded in - # favour of a malformed inline payload. - file_data_valid = bool( - isinstance(file_data, str) - and file_data - and ( - not file_data.startswith("data:") + _is_youtube = _img_scheme == "https" and ( + _img_host == "youtu.be" + or _img_host == "youtube.com" + or _img_host.endswith(".youtube.com") + ) + _guessed, _ = mimetypes.guess_type(_img_path) + _media_type = ( + _guessed + if isinstance(_guessed, str) + and _guessed.startswith("image/") + else "image/jpeg" + ) + if _is_youtube: + # YouTube URIs must use video/mp4; the + # default image/jpeg yields a 400. + parts.append( + { + "fileData": { + "fileUri": url, + "mimeType": "video/mp4", + } + } + ) + elif _is_native_uri: + parts.append( + { + "fileData": { + "fileUri": url, + "mimeType": _media_type, + } + } + ) + elif _remote_image_count >= _GEMINI_REMOTE_IMAGE_MAX_COUNT: + logger.info( + "Gemini image fetch: per-request count cap %d reached, dropping image", + _GEMINI_REMOTE_IMAGE_MAX_COUNT, + ) + else: + # Refuse pre-fetch when the per-request + # byte budget is spent; pass the remainder + # so over-budget URLs reject on Content-Length. + _remaining_bytes = ( + _GEMINI_REMOTE_IMAGE_MAX_TOTAL_BYTES + - _remote_image_total_bytes + ) + if _remaining_bytes <= 0: + logger.info( + "Gemini image fetch: per-request byte cap already reached, dropping image", + ) + else: + # Count attempts before awaiting so + # slow URLs don't each burn the timeout. + _remote_image_count += 1 + _fetched = await _safe_fetch_image_for_gemini( + url, + _media_type, + max_bytes = _remaining_bytes, + ) + if _fetched is not None: + _final_mime, _b64 = _fetched + # base64 expands ~4/3 — recover bytes from len(_b64). + _approx_bytes = (len(_b64) * 3) // 4 + if ( + _remote_image_total_bytes + _approx_bytes + > _GEMINI_REMOTE_IMAGE_MAX_TOTAL_BYTES + ): + logger.info( + "Gemini image fetch: per-request byte cap reached, dropping image", + ) + else: + _remote_image_total_bytes += _approx_bytes + parts.append( + { + "inlineData": { + "mimeType": _final_mime, + "data": _b64, + } + } + ) + # Gemini 3 strict function-calling requires text-part + # thoughtSignatures to be replayed on history; the frontend + # stows the latest one as + # extra_content.google.thought_signature on the assistant + # message and we pin it onto the last text part here. + if role == "assistant" and parts: + _msg_extra = msg.get("extra_content") if isinstance(msg, dict) else None + if isinstance(_msg_extra, dict): + _msg_g = _msg_extra.get("google") or {} + if isinstance(_msg_g, dict): + _msg_sig = _msg_g.get("thought_signature") or _msg_g.get( + "thoughtSignature" + ) + if isinstance(_msg_sig, str) and _msg_sig: + for _idx in range(len(parts) - 1, -1, -1): + if "text" in parts[_idx]: + parts[_idx] = { + **parts[_idx], + "thoughtSignature": _msg_sig, + } + break + # Translate OpenAI tool_calls into Gemini functionCall parts. + # code_execution / image_generation replay their native parts + # (executableCode / codeExecutionResult / inlineData) stowed + # on extra_content.google.native_part. + tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else None + if isinstance(tool_calls, list): + for tc in tool_calls: + if not isinstance(tc, dict): + continue + fn = tc.get("function") or {} + if not isinstance(fn, dict): + continue + args_raw = fn.get("arguments") or "{}" + if isinstance(args_raw, str): + try: + args = _json.loads(args_raw) + except Exception: + args = {"_raw": args_raw} + elif isinstance(args_raw, dict): + args = args_raw + else: + args = {} + fn_name = fn.get("name", "") + tc_id = tc.get("id") + if fn_name and isinstance(tc_id, str) and tc_id: + tool_call_names[tc_id] = fn_name + + # Replay native Gemini code_execution / image_generation parts + # from extra_content.google.native_part, with fallback to + # args.google.native_part for OAI-compat round-trips. + _extra = tc.get("extra_content") + _native_part = None + _google_extra: dict[str, Any] = {} + if isinstance(_extra, dict): + _ge = _extra.get("google") or {} + if isinstance(_ge, dict): + _google_extra = _ge + _native_part = _ge.get("native_part") + if _native_part is None and isinstance(args, dict): + _args_google = args.get("google") + if isinstance(_args_google, dict): + _args_np = _args_google.get("native_part") + if isinstance(_args_np, dict): + _native_part = _args_np + if not _google_extra: + _google_extra = _args_google + + # Synthetic builtin cards (web_search/web_fetch) must + # not become fake functionCalls; drop them. Native + # code_execution / image_generation replay below. + _name_lc = fn_name.lower() if isinstance(fn_name, str) else "" + _is_synthetic_server_builtin = ( + _name_lc + in ( + "web_search", + "web_fetch", + "code_execution", + "image_generation", + ) + and isinstance(args, dict) + and ( + args.get("_server_tool") is True + or isinstance( + (args.get("google") or {}).get("native_part"), dict + ) + ) + ) + if _is_synthetic_server_builtin and not ( + _name_lc in ("code_execution", "image_generation") + and isinstance(_native_part, dict) + ): + # No replayable Gemini native part -- skip + # entirely rather than send a fake functionCall. + # Also remember this tool_call_id so a matching + # role="tool" follow-up does not become an + # orphan functionResponse below. + if isinstance(tc_id, str) and tc_id: + _gemini_skip_tool_result_ids.add(tc_id) + tool_call_names.pop(tc_id, None) + continue + if fn_name in ("code_execution", "image_generation") and isinstance( + _native_part, dict + ): + # code_execution/image_generation history is + # replayed as native parts; the matching + # role="tool" must be skipped or Gemini sees a + # functionResponse with no declared function + # name and 400s the turn. + if isinstance(tc_id, str) and tc_id: + _gemini_skip_tool_result_ids.add(tc_id) + # New shape: `native_part.parts` is an ordered list + # of full part wrappers, each carrying its own + # `thoughtSignature`. This preserves Gemini 3's + # strict per-part replay requirement when the + # frontend has merged executableCode + + # codeExecutionResult + inlineData into the same + # tool-call card. + _native_parts_list = _native_part.get("parts") + if isinstance(_native_parts_list, list): + for _entry in _native_parts_list: + if isinstance(_entry, dict): + parts.append(_entry) + continue + # Legacy single-object native_part: fan the shared + # thoughtSignature only when one subpart exists; + # for code+result, prefer executableCode and drop + # the signature elsewhere. + _legacy_sig = _native_part.get( + "thoughtSignature" + ) or _native_part.get("thought_signature") + _legacy_subparts = [ + _k + for _k in ( + "executableCode", + "codeExecutionResult", + "inlineData", + ) + if isinstance(_native_part.get(_k), dict) + ] + for _native_key in ( + "executableCode", + "codeExecutionResult", + "inlineData", + ): + _sub = _native_part.get(_native_key) + if not isinstance(_sub, dict): + continue + _replay_part: dict[str, Any] = {_native_key: _sub} + if isinstance(_legacy_sig, str) and _legacy_sig: + if len(_legacy_subparts) == 1: + _replay_part["thoughtSignature"] = _legacy_sig + elif _native_key == "executableCode": + _replay_part["thoughtSignature"] = _legacy_sig + parts.append(_replay_part) + continue + + # Forward the OpenAI tool_call id into Gemini's + # functionCall.id so a follow-up turn that issues + # multiple calls to the same function (different + # args, same name) can be disambiguated on the + # response side. Gemini accepts the field per + # https://ai.google.dev/gemini-api/docs/function-calling. + function_call_part: dict[str, Any] = { + "name": fn_name, + "args": args, + } + if isinstance(tc_id, str) and tc_id: + function_call_part["id"] = tc_id + # Gemini 3 function-calling requires the prior + # thoughtSignature to be echoed back as a sibling + # of the functionCall part. The translator stows + # it on the assistant tool_call via + # `extra_content.google.thought_signature` (see + # the inbound emit below). + fc_part: dict[str, Any] = {"functionCall": function_call_part} + sig = _google_extra.get("thought_signature") or _google_extra.get( + "thoughtSignature" + ) + if isinstance(sig, str) and sig: + fc_part["thoughtSignature"] = sig + parts.append(fc_part) + if role == "tool": + # If the matching assistant-side tool_call was either + # dropped (synthetic server-tool with no native part) + # or already replayed as Gemini-native parts + # (code_execution/image_generation native_part), drop + # the follow-up too. Emitting it as a functionResponse + # would be orphaned or duplicate the native result. + _tc_id_for_skip = msg.get("tool_call_id") + if ( + isinstance(_tc_id_for_skip, str) + and _tc_id_for_skip in _gemini_skip_tool_result_ids + ): + continue + # OpenAI's role="tool" follow-up carries the function + # result. Gemini's matching shape is a role="user" turn + # with a functionResponse part. When the caller dropped + # ``name``, recover it from the matching assistant + # tool_call so Gemini doesn't 400 on an empty name. + tool_name = msg.get("name") or msg.get("tool_name") or "" + if not tool_name: + tc_id = msg.get("tool_call_id") + if isinstance(tc_id, str) and tc_id in tool_call_names: + tool_name = tool_call_names[tc_id] + response_payload: Any + if isinstance(content, list): + # OpenAI tool messages may carry list-form content + # (`[{"type":"text","text":"..."}]`). Forwarding the + # content-part objects verbatim into Gemini's + # `functionResponse.response.result` yields + # `result:[{"type":"text","text":"..."}]` instead of + # the actual tool output text; flatten text parts so + # the result mirrors the string-content path. + _flat_parts: list[str] = [] + for _cpart in content: + if ( + isinstance(_cpart, dict) + and _cpart.get("type") == "text" + and isinstance(_cpart.get("text"), str) + ): + _flat_parts.append(_cpart["text"]) + _flat_text = "".join(_flat_parts) + try: + response_payload = _json.loads(_flat_text) + except Exception: + response_payload = {"result": _flat_text} + elif isinstance(content, str): + try: + response_payload = _json.loads(content) + except Exception: + response_payload = {"result": content} + else: + response_payload = content or {} + function_response_part: dict[str, Any] = { + "name": tool_name, + "response": ( + response_payload + if isinstance(response_payload, dict) + else {"result": response_payload} + ), + } + # Mirror tool_call_id onto functionResponse.id so + # Gemini can match the result to the originating + # functionCall when multiple parallel calls were made. + tc_id = msg.get("tool_call_id") + if isinstance(tc_id, str) and tc_id: + function_response_part["id"] = tc_id + parts = [{"functionResponse": function_response_part}] + gemini_role = "user" + if parts: + # Gemini expects parallel functionResponses (multiple + # OpenAI role="tool" messages in a row) to ride on a + # single user content with multiple functionResponse + # parts -- the docs show parallel responses grouped + # together in the next turn. Merge consecutive + # functionResponse-only user blocks so realistic + # parallel tool loops round-trip correctly. + if ( + role == "tool" + and contents + and contents[-1].get("role") == "user" + and all( + isinstance(p, dict) and "functionResponse" in p + for p in (contents[-1].get("parts") or []) + ) + ): + contents[-1]["parts"].extend(parts) + else: + contents.append({"role": gemini_role, "parts": parts}) + + body: dict[str, Any] = {"contents": contents} + if system_text_parts: + body["systemInstruction"] = { + "parts": [{"text": "\n\n".join(system_text_parts)}] + } + + # Generation config -- temperature / topP / topK / maxOutputTokens + # map straight across. The frontend capability matrix restricts + # the sliders the UI exposes for Gemini to this set. + gen_config: dict[str, Any] = {} + if temperature is not None: + gen_config["temperature"] = temperature + if top_p is not None: + gen_config["topP"] = top_p + if top_k is not None and top_k > 0: + gen_config["topK"] = top_k + # Gemini accepts ``presencePenalty`` on generationConfig with the + # same sign convention as the OpenAI knob (positive discourages + # repetition). Forward when the caller bothers to set it. + if presence_penalty: + gen_config["presencePenalty"] = presence_penalty + if max_tokens is not None: + gen_config["maxOutputTokens"] = max_tokens + + # Nano Banana image generation. Gemini only accepts + # `responseModalities: ["TEXT","IMAGE"]` on the image-capable + # model family (id contains `-image` or `nano-banana`). Text- + # only models such as `gemini-2.5-flash` 400 on the same body, + # so only force image mode when the selected model actually + # supports it -- a stale `enabled_tools=["image_generation"]` + # on a text model is silently treated as a regular turn. + # https://ai.google.dev/gemini-api/docs/image-generation + model_lc = model.lower() + is_image_picker_model = "-image" in model_lc or "nano-banana" in model_lc + # tool_choice="none" / forced-function tool_choice must also + # suppress the implicit image-generation hosted tool. Otherwise + # an explicit OpenAI-style opt-out (or an explicit user-function + # pin) still flips `responseModalities=["TEXT","IMAGE"]` on + # image-tier models and bills for image output. + _tool_choice_disabled = ( + isinstance(tool_choice, str) and tool_choice.strip().lower() == "none" + ) + _tool_choice_forced_function = ( + isinstance(tool_choice, dict) + and tool_choice.get("type") == "function" + and isinstance(tool_choice.get("function"), dict) + and bool(tool_choice["function"].get("name")) + ) + _hosted_builtins_allowed = ( + not _tool_choice_disabled and not _tool_choice_forced_function + ) + # Image-tier model IDs reject text-only tools (code_execution, + # user functions) and thinkingConfig regardless of whether the + # Images pill is on -- those are model-level constraints + # documented by Google. The pill only controls whether we ask + # Gemini to actually emit image output via + # `responseModalities: ["TEXT","IMAGE"]`. Decoupling the two + # avoids the case where Images is off + Code/Search is on + # forwards `tools: [{codeExecution: {}}]` plus + # `thinkingConfig` to an image model and 400s. + image_tool_requested = bool( + _hosted_builtins_allowed + and enabled_tools + and "image_generation" in enabled_tools + ) + # Strict tool / thinking strip uses the model-id check. + is_image_model_strict = is_image_picker_model + # The actual modality flip only happens when the user opted in. + is_image_model = is_image_picker_model and image_tool_requested + if is_image_model: + gen_config["responseModalities"] = ["TEXT", "IMAGE"] + elif is_image_picker_model: + # Force TEXT-only so an image-capable model with Images OFF + # doesn't still bill for image output. + gen_config["responseModalities"] = ["TEXT"] + + # Thinking control. Gemini 3 uses thinkingLevel (str), 2.5 uses + # thinkingBudget (int). Gemini 3 has no full-off; minimum is + # "minimal" on Flash, "low" on Pro. + # https://ai.google.dev/gemini-api/docs/thinking + _GEMINI3_THINKING_PREFIXES = ( + "gemini-3.5-", + "gemini-3.1-", + "gemini-3-", + "gemini-pro-latest", + "gemini-flash-latest", + "gemini-flash-lite-latest", + ) + _GEMINI3_PRO_PREFIXES = ( + "gemini-3.5-pro", + "gemini-3.1-pro", + "gemini-3-pro", + "gemini-pro-latest", + ) + _PRO_THINKING_PREFIXES = ("gemini-2.5-pro",) + is_gemini3_thinking = any( + model_lc.startswith(p) for p in _GEMINI3_THINKING_PREFIXES + ) + is_gemini3_pro = any(model_lc.startswith(p) for p in _GEMINI3_PRO_PREFIXES) + _is_pro_thinking_only = any( + model_lc == p or model_lc.startswith(p + "-") + for p in _PRO_THINKING_PREFIXES + ) + effort_lc = (reasoning_effort or "").strip().lower() + if not is_image_model_strict and is_gemini3_thinking: + # Gemini 3.x thinkingLevel matrix: + # 3.1+ Pro: low/medium/high + # 3 Pro: low/high (deprecated 2026-03-09) + # 3.x Flash*: minimal/low/medium/high + # Coerce minimal->low on Pro; medium->high on legacy 3-Pro. + _G3_LEVELS = {"minimal", "low", "medium", "high"} + level: Optional[str] = None + if effort_lc in ("none", "off"): + level = "low" if is_gemini3_pro else "minimal" + elif effort_lc == "max": + level = "high" + elif effort_lc in _G3_LEVELS: + # Coerce legacy 3-Pro (low/high only) inputs. + _is_legacy_gemini3_pro = model_lc.startswith( + ("gemini-3-pro-preview", "gemini-3-pro") + ) and not model_lc.startswith(("gemini-3.1-pro", "gemini-3.5-pro")) + if is_gemini3_pro and effort_lc == "minimal": + level = "low" + elif _is_legacy_gemini3_pro and effort_lc == "medium": + level = "high" + else: + level = effort_lc + elif enable_thinking is True: + level = "high" + elif enable_thinking is False: + level = "low" if is_gemini3_pro else "minimal" + if level is not None: + gen_config["thinkingConfig"] = {"thinkingLevel": level} + elif not is_image_model_strict: + # Gemini 2.5 / older: thinkingBudget int. Effort -> budget + # mirrors the OpenAI minimal/low/medium/high ladder so the + # existing frontend picker maps cleanly. + # NOTE: gemini-2.5-flash-lite rejects positive budgets below + # 512 with HTTP 400, so minimal=512 sits at that floor. + _EFFORT_TO_BUDGET: dict[str, int] = { + "minimal": 512, + "low": 2048, + "medium": 8192, + "high": 24576, + "xhigh": -1, + "max": -1, + } + thinking_budget: Optional[int] = None + if effort_lc == "none" or enable_thinking is False: + # Pro-tier 2.5 rejects budget=0 (400 "only works in + # thinking mode"), so coerce to a small positive value. + thinking_budget = 128 if _is_pro_thinking_only else 0 + elif effort_lc in _EFFORT_TO_BUDGET: + thinking_budget = _EFFORT_TO_BUDGET[effort_lc] + elif enable_thinking is True: + thinking_budget = -1 + if thinking_budget is not None: + gen_config["thinkingConfig"] = { + "thinkingBudget": thinking_budget, + } + + if gen_config: + body["generationConfig"] = gen_config + + # Hosted tools: googleSearch (grounding) and codeExecution. + # Image-mode rejects codeExecution; only Gemini 3 image models + # accept googleSearch. + # https://ai.google.dev/gemini-api/docs/grounding + # https://ai.google.dev/gemini-api/docs/code-execution + def _gemini_image_model_allows_google_search(_m: str) -> bool: + return ( + _m.startswith("gemini-3-pro-image") + or _m.startswith("gemini-3.1-flash-image") + or _m.startswith("nano-banana-pro") + or _m.startswith("nano-banana-2") + ) + + google_search_allowed = ( + not is_image_model_strict + or _gemini_image_model_allows_google_search(model_lc) + ) + code_execution_allowed = not is_image_model_strict + text_tools_allowed = not is_image_model_strict + # tool_choice="none" / forced-function suppresses hosted builtins + # too, matching the Anthropic / OpenRouter gates. + tools_array: list[dict[str, Any]] = [] + if ( + _hosted_builtins_allowed + and enabled_tools + and "web_search" in enabled_tools + and google_search_allowed + ): + tools_array.append({"googleSearch": {}}) + if ( + _hosted_builtins_allowed + and enabled_tools + and "code_execution" in enabled_tools + and code_execution_allowed + ): + tools_array.append({"codeExecution": {}}) + # OpenAI-style function declarations -> Gemini functionDeclarations. + # https://ai.google.dev/gemini-api/docs/function-calling#step_1 + # Gemini's Schema accepts only the OpenAPI 3.0 subset documented + # at https://ai.google.dev/api/caching#Schema; OpenAI's strict + # tool definitions routinely include `additionalProperties`, + # `$schema`, `$defs`, `strict`, `examples`, and similar keys + # which 400 the request as INVALID_ARGUMENT. Strip them + # recursively before forwarding. + _GEMINI_ALLOWED_SCHEMA_KEYS = frozenset( + { + "type", + "format", + "title", + "description", + "nullable", + "enum", + "maxItems", + "minItems", + "properties", + "required", + "minProperties", + "maxProperties", + "items", + "minimum", + "maximum", + "minLength", + "maxLength", + "pattern", + "default", + "anyOf", + "propertyOrdering", + } + ) + + def _resolve_local_schema_ref( + root: Optional[dict[str, Any]], ref: str + ) -> Optional[Any]: + # Walk a `#/foo/bar` JSON pointer against the schema root. + # Returns None if the pointer doesn't resolve to a dict, so + # the caller can fall back to the unresolved node. + if not isinstance(root, dict) or not isinstance(ref, str): + return None + if not ref.startswith("#/"): + return None + node: Any = root + for raw_part in ref[2:].split("/"): + if not raw_part: + continue + part = raw_part.replace("~1", "/").replace("~0", "~") + if not isinstance(node, dict) or part not in node: + return None + node = node[part] + return node + + def _sanitize_gemini_schema( + node: Any, + root: Optional[dict[str, Any]] = None, + _seen_refs: Optional[frozenset[str]] = None, + ) -> Any: + # Recursively filter to Gemini's OpenAPI 3.0 subset. At a + # Schema-keyword dict layer we drop keys not in the + # allowlist; under `properties` the keys are user-defined + # field names and the values are themselves Schemas; under + # `items` / `anyOf` the values are also Schemas. + # OpenAI strict tools commonly use JSON Schema's + # `"type": ["string", "null"]` form for nullable fields; + # Gemini's OpenAPI Schema uses `"type": "string"` plus + # `"nullable": true`. Translate that here. + if root is None and isinstance(node, dict): + root = node + if _seen_refs is None: + _seen_refs = frozenset() + if isinstance(node, dict): + # Pydantic / OpenAI strict tools commonly hoist nested + # object schemas into `$defs` and reference them via + # `{"$ref": "#/$defs/Address"}`. Gemini's OpenAPI subset + # has no $ref and drops anything not in the allowlist, + # so the referenced shape would vanish if we didn't + # inline it here. Recurse into the resolved target with + # local siblings overriding the reference (normal JSON + # Schema composition), guarding against ref cycles. + _ref = node.get("$ref") + if isinstance(_ref, str): + if _ref in _seen_refs: + return {} + _target = _resolve_local_schema_ref(root, _ref) + if isinstance(_target, dict): + _merged = { + **_target, + **{k: v for k, v in node.items() if k != "$ref"}, + } + return _sanitize_gemini_schema( + _merged, root, _seen_refs | {_ref} + ) + cleaned: dict[str, Any] = {} + _nullable_from_union = False + _flattened_type: Optional[str] = None + _union_any_of: Optional[list[dict[str, Any]]] = None + _raw_type = node.get("type") + if isinstance(_raw_type, list): + _non_null = [t for t in _raw_type if t != "null"] + if len(_non_null) < len(_raw_type): + _nullable_from_union = True + if len(_non_null) == 1: + _flattened_type = _non_null[0] + elif len(_non_null) > 1: + # Preserve multi-type unions as anyOf; flattening + # to the first non-null type silently drops the + # other branches and changes the tool contract. + _union_any_of = [ + {"type": _t} for _t in _non_null if isinstance(_t, str) + ] + for _k, _v in node.items(): + if _k == "type" and isinstance(_v, list): + # Handled below via _flattened_type. + continue + if _k not in _GEMINI_ALLOWED_SCHEMA_KEYS: + continue + if _k == "properties" and isinstance(_v, dict): + cleaned[_k] = { + _name: _sanitize_gemini_schema(_subschema, root, _seen_refs) + for _name, _subschema in _v.items() + } + elif _k == "items": + cleaned[_k] = _sanitize_gemini_schema(_v, root, _seen_refs) + elif _k == "anyOf" and isinstance(_v, list): + # Optional[X] / Union[A, B, None]: Pydantic emits + # `anyOf: [..., {"type":"null"}]`. Gemini's + # OpenAPI subset rejects `"type": "null"` inside + # anyOf, so drop the null variant and surface it + # via `nullable: true`. If exactly one non-null + # branch remains, collapse it inline; otherwise + # keep the slim anyOf and mark the field + # nullable. + _saw_null = any( + isinstance(_entry, dict) and _entry.get("type") == "null" + for _entry in _v + ) + _non_null_entries = [ + _entry + for _entry in _v + if not ( + isinstance(_entry, dict) + and _entry.get("type") == "null" + ) + ] + if len(_non_null_entries) == 1 and _saw_null: + _inner = _sanitize_gemini_schema( + _non_null_entries[0], root, _seen_refs + ) + if isinstance(_inner, dict): + for _ik, _iv in _inner.items(): + cleaned.setdefault(_ik, _iv) + cleaned.setdefault("nullable", True) + else: + cleaned[_k] = [ + _sanitize_gemini_schema(_entry, root, _seen_refs) + for _entry in _non_null_entries + ] + if _saw_null: + cleaned.setdefault("nullable", True) + elif _k in ("required", "enum", "propertyOrdering"): + # Lists of plain strings; copy verbatim. + cleaned[_k] = _v + else: + cleaned[_k] = _v + if _union_any_of is not None and "anyOf" not in cleaned: + cleaned["anyOf"] = [ + _sanitize_gemini_schema(_s, root, _seen_refs) + for _s in _union_any_of + ] + elif _flattened_type is not None: + cleaned["type"] = _flattened_type + if _nullable_from_union and "nullable" not in cleaned: + cleaned["nullable"] = True + return cleaned + return node + + function_declarations: list[dict[str, Any]] = [] + if tools and text_tools_allowed and not _tool_choice_disabled: + for _tool in tools: + if not isinstance(_tool, dict) or _tool.get("type") != "function": + continue + _fn = _tool.get("function") + if not isinstance(_fn, dict) or not _fn.get("name"): + continue + _decl: dict[str, Any] = { + "name": _fn["name"], + "description": _fn.get("description") or "", + } + _params = _fn.get("parameters") + if isinstance(_params, dict): + _decl["parameters"] = _sanitize_gemini_schema(_params) + function_declarations.append(_decl) + if function_declarations: + tools_array.append({"functionDeclarations": function_declarations}) + if tools_array: + body["tools"] = tools_array + # Tool-choice mapping: OpenAI "auto"/"none"/"required"/{name=...} + # -> Gemini toolConfig.functionCallingConfig.mode + allowedFunctionNames. + if tool_choice is not None and function_declarations and text_tools_allowed: + _mode: Optional[str] = None + _allowed: Optional[list[str]] = None + if isinstance(tool_choice, str): + _tc_lc = tool_choice.strip().lower() + if _tc_lc == "auto": + _mode = "AUTO" + elif _tc_lc == "none": + _mode = "NONE" + elif _tc_lc in ("required", "any"): + _mode = "ANY" + elif ( + isinstance(tool_choice, dict) and tool_choice.get("type") == "function" + ): + _fn_pick = tool_choice.get("function") or {} + _name = _fn_pick.get("name") if isinstance(_fn_pick, dict) else None + if isinstance(_name, str) and _name: + _mode = "ANY" + _allowed = [_name] + if _mode is not None: + _fcc: dict[str, Any] = {"mode": _mode} + if _allowed: + _fcc["allowedFunctionNames"] = _allowed + body["toolConfig"] = {"functionCallingConfig": _fcc} + + # Prompt caching. The Gemini caching contract is "create a + # CachedContent resource, then pass its name on + # `cachedContent`". The cache itself is created out of band by + # the caller via POST /cachedContents; here we forward an + # explicit cache id when the dispatcher hands us one (a string + # value on enable_prompt_caching means "use this cache name"). + # https://ai.google.dev/gemini-api/docs/caching + if isinstance(enable_prompt_caching, str) and enable_prompt_caching: + body["cachedContent"] = enable_prompt_caching + + # Model id is already validated at the top of _stream_gemini so + # we never reach a path-traversed URL segment here. + url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse" + completion_id = f"chatcmpl-gemini-{model.replace('/', '-')}" + + logger.info( + "Proxying Gemini streamGenerateContent to %s (model=%s, " + "tools=%s, image=%s)", + url, + model, + [list(t.keys())[0] for t in tools_array] if tools_array else [], + is_image_model, + ) + + def _emit_tool_event(payload: dict[str, Any]) -> str: + _stamp_server_tool_marker(payload) + chunk = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": None, + } + ], + "_toolEvent": payload, + } + return f"data: {_json.dumps(chunk)}" + + def _text_chunk( + text: str, extra_content: Optional[dict[str, Any]] = None + ) -> str: + delta: dict[str, Any] = {"content": text} + if extra_content: + delta["extra_content"] = extra_content + chunk = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": delta, + "finish_reason": None, + } + ], + } + return f"data: {_json.dumps(chunk)}" + + def _gemini_part_extra(part: dict[str, Any]) -> Optional[dict[str, Any]]: + """Return ``{"google": {"thought_signature": ...}}`` when the + Gemini stream part carries a `thoughtSignature` we need to + replay on a follow-up turn (Gemini 3 image editing + tool + contexts both require an exact signature echo).""" + sig = part.get("thoughtSignature") or part.get("thought_signature") + if isinstance(sig, str) and sig: + return {"google": {"thought_signature": sig}} + return None + + # Gemini finish reasons -> OpenAI vocabulary. Reference: + # https://ai.google.dev/api/rest/v1beta/Candidate#FinishReason + _finish_reason_map: dict[str, Optional[str]] = { + "STOP": "stop", + "MAX_TOKENS": "length", + "SAFETY": "content_filter", + "RECITATION": "content_filter", + "PROHIBITED_CONTENT": "content_filter", + "BLOCKLIST": "content_filter", + "MALFORMED_FUNCTION_CALL": "stop", + "OTHER": "stop", + "FINISH_REASON_UNSPECIFIED": None, + } + + last_usage: Optional[dict[str, Any]] = None + emitted_function_call_ids: set[str] = set() + # True once any Gemini functionCall part has been emitted so the + # final finish_reason swaps STOP -> tool_calls (matches the + # OpenAI Chat Completions contract; an OAI client that sees a + # tool_calls delta followed by finish_reason="stop" never + # executes the tool). + emitted_any_function_call = False + # web_search_active drives the tool_start / tool_end envelope. + # Track on whether `googleSearch` was actually forwarded above, + # not the raw caller intent -- image-mode requests filter the + # tool out, and emitting a phantom "search complete" card on a + # turn where Gemini was never told to search confuses the UI. + web_search_active = any("googleSearch" in t for t in tools_array) + web_search_tool_id = "gemini_web_search" + web_search_tool_started = False + web_search_tool_ended = False + web_search_citations: list[dict[str, str]] = [] + # Tracks the tool_call_id minted on the most recent + # executableCode part so the matching codeExecutionResult can + # close out the same envelope. None between rounds. + gemini_code_exec_pending_id: Optional[str] = None + # The most recently emitted code_execution id + result text. Kept + # *after* the tool_end so a following inline image (matplotlib + # plot rendered by codeExecution) can attach to the same card + # via a `__IMAGES__:` marker instead of spawning a separate + # image_generation event. + last_code_exec_tool_id: Optional[str] = None + last_code_exec_result_text: str = "" + + try: + async with _http_client.stream( + "POST", + url, + json = body, + headers = self._auth_headers(), + timeout = self._stream_timeout, + ) as response: + if response.status_code != 200: + error_body = await response.aread() + error_text = error_body.decode("utf-8", errors = "replace") + logger.error( + "Gemini returned %d: %s", + response.status_code, + error_text[:500], + ) + yield _error_sse_line( + response.status_code, error_text, self.provider_type + ) + return + + if web_search_active: + yield _emit_tool_event( + { + "type": "tool_start", + "tool_name": "web_search", + "tool_call_id": web_search_tool_id, + "arguments": {}, + } + ) + web_search_tool_started = True + + # NOTE: same manual __anext__ loop pattern as the other + # streaming helpers (see stream_chat_completion for the + # Python 3.13 + httpcore 1.0.x GeneratorExit ordering). + lines_gen = response.aiter_lines().__aiter__() + final_finish_reason: Optional[str] = None + try: + while True: + try: + line = await lines_gen.__anext__() + except StopAsyncIteration: + break + if not line.strip(): + continue + if not line.startswith("data:"): + continue + data_str = line[len("data:") :].strip() + if not data_str or data_str == "[DONE]": + continue + try: + event = _json.loads(data_str) + except Exception: + logger.warning( + "Gemini: failed to parse SSE chunk: %s", + data_str[:200], + ) + continue + if not isinstance(event, dict): + continue + + # Latch usageMetadata across deltas -- the final + # fragment carries the complete totals. + usage_meta = event.get("usageMetadata") + if isinstance(usage_meta, dict): + last_usage = usage_meta + + # Prompt-level safety block: Gemini ships zero + # candidates plus a `promptFeedback.blockReason` + # (e.g. SAFETY). The downstream OAI client would + # otherwise see an empty successful assistant + # response. Surface as a content_filter error + # event so the UI can render the block reason. + prompt_feedback = event.get("promptFeedback") + if isinstance(prompt_feedback, dict) and prompt_feedback.get( + "blockReason" + ): + block_reason = str(prompt_feedback.get("blockReason")) + # Close out the synthetic web_search start so + # the UI does not show a spinner stuck on + # "searching..." after the error toast lands. + if ( + web_search_active + and web_search_tool_started + and not web_search_tool_ended + ): + yield _emit_tool_event( + { + "type": "tool_end", + "tool_call_id": web_search_tool_id, + "result": ( + "(search aborted: Gemini blocked " + f"prompt: {block_reason})" + ), + } + ) + web_search_tool_ended = True + yield _error_sse_line( + 400, + f"Gemini blocked prompt: {block_reason}", + self.provider_type, + ) + return + + candidates = event.get("candidates") or [] + if not isinstance(candidates, list): + continue + for cand in candidates: + if not isinstance(cand, dict): + continue + # Citations / grounding metadata. + # `groundingMetadata.groundingChunks[].web` + # carries `uri` + `title`. Collect for the + # tool_end emission at stream close. + gm = cand.get("groundingMetadata") + if isinstance(gm, dict) and web_search_active: + chunks_list = gm.get("groundingChunks") or [] + if isinstance(chunks_list, list): + for ch in chunks_list: + if not isinstance(ch, dict): + continue + web = ch.get("web") or {} + if not isinstance(web, dict): + continue + u = web.get("uri") or "" + if not u or not isinstance(u, str): + continue + if any( + c["url"] == u for c in web_search_citations + ): + continue + web_search_citations.append( + { + "url": u, + "title": (web.get("title") or u), + "snippet": "", + } + ) + + content_obj = cand.get("content") or {} + parts = ( + content_obj.get("parts") + if isinstance(content_obj, dict) + else None + ) + if isinstance(parts, list): + for part in parts: + if not isinstance(part, dict): + continue + # Text delta. Stow part-level + # `thoughtSignature` on the delta so + # Gemini 3 turns that need an exact + # signature echo round-trip cleanly. + text = part.get("text") + _part_extra = _gemini_part_extra(part) + if isinstance(text, str) and text: + yield _text_chunk( + text, + extra_content = _part_extra, + ) + elif _part_extra is not None and not any( + k in part + for k in ( + "functionCall", + "executableCode", + "codeExecutionResult", + "inlineData", + ) + ): + # Empty-content part carrying a + # thoughtSignature: emit an empty delta + # so the signature is preserved. + yield _text_chunk( + "", + extra_content = _part_extra, + ) + # functionCall -> OpenAI tool_calls + # delta envelope. + fc = part.get("functionCall") + if isinstance(fc, dict): + fc_name = fc.get("name") or "" + fc_args = fc.get("args") or {} + fc_id = ( + fc.get("id") + or f"call_{fc_name}_{time.time_ns()}" + ) + if fc_id in emitted_function_call_ids: + continue + emitted_function_call_ids.add(fc_id) + # Each distinct functionCall in an + # assistant turn needs its own + # tool_calls[*].index. Consumers + # that reassemble tool_calls by + # index collapse all calls onto + # the same slot when this is + # hardcoded to 0, breaking + # parallel/multi-tool turns. + tc_index = len(emitted_function_call_ids) - 1 + tool_call_delta: dict[str, Any] = { + "index": tc_index, + "id": fc_id, + "type": "function", + "function": { + "name": fc_name, + "arguments": _json.dumps(fc_args), + }, + } + # Gemini 3 function-calling: the + # part-level `thoughtSignature` + # must be echoed back on the + # next turn or the model rejects + # the tool-result envelope. Stow + # it on `extra_content.google` + # so the frontend can persist it + # and our outbound translator + # (below) can replay it. + thought_sig = part.get( + "thoughtSignature" + ) or part.get("thought_signature") + if isinstance(thought_sig, str) and thought_sig: + tool_call_delta["extra_content"] = { + "google": { + "thought_signature": thought_sig, + } + } + emitted_any_function_call = True + tool_chunk = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [tool_call_delta] + }, + "finish_reason": None, + } + ], + } + yield f"data: {_json.dumps(tool_chunk)}" + # executableCode + codeExecutionResult + # parts surface as the standard + # code_execution tool_start/tool_end + # envelope (same shape OpenAI and + # Anthropic emit) so the chat + # adapter can render Gemini sandbox + # output through CodeExecutionToolUI. + # https://ai.google.dev/gemini-api/docs/code-execution + exec_code = part.get("executableCode") + if isinstance(exec_code, dict): + code_str = exec_code.get("code") or "" + if code_str: + code_tool_id = ( + exec_code.get("id") + or f"gemini_code_exec_{time.time_ns()}" + ) + gemini_code_exec_pending_id = code_tool_id + # Stow the raw Gemini part so + # follow-up turns can replay + # the native `executableCode` + # (Gemini rejects a generic + # functionCall echo for code + # execution history). + _exec_thought_sig = part.get( + "thoughtSignature" + ) or part.get("thought_signature") + # Per-part thoughtSignature stays + # bound to its own part (Gemini 3 + # rejects shared signatures). + _exec_part_entry: dict[str, Any] = { + "executableCode": exec_code, + } + if ( + isinstance(_exec_thought_sig, str) + and _exec_thought_sig + ): + _exec_part_entry["thoughtSignature"] = ( + _exec_thought_sig + ) + _exec_native: dict[str, Any] = { + "parts": [_exec_part_entry], + } + yield _emit_tool_event( + { + "type": "tool_start", + "tool_name": "code_execution", + "tool_call_id": code_tool_id, + "arguments": { + "kind": "code_execution", + "language": ( + ( + exec_code.get( + "language" + ) + or "PYTHON" + ).lower() + ), + "code": code_str, + "google": { + "native_part": _exec_native, + }, + }, + } + ) + exec_result = part.get("codeExecutionResult") + if isinstance(exec_result, dict): + outcome = exec_result.get("outcome") or "" + output = exec_result.get("output") or "" + # Gemini returns + # OUTCOME_OK / OUTCOME_FAILED / + # OUTCOME_DEADLINE_EXCEEDED. Treat + # non-OK outcomes as stderr so the + # UI surfaces the error. + if outcome and outcome != "OUTCOME_OK": + result_text = ( + f"[{outcome}]\n{output}".rstrip() + ) + else: + result_text = output + # Pair tool_end with the most recent + # executableCode tool_start; fall back + # to exec_result.id then a fresh id. + pair_id = ( + gemini_code_exec_pending_id + or exec_result.get("id") + or f"gemini_code_exec_{time.time_ns()}" + ) + if gemini_code_exec_pending_id is None: + yield _emit_tool_event( + { + "type": "tool_start", + "tool_name": "code_execution", + "tool_call_id": pair_id, + "arguments": { + "kind": "code_execution", + "code": "", + }, + } + ) + _result_thought_sig = part.get( + "thoughtSignature" + ) or part.get("thought_signature") + _result_part_entry: dict[str, Any] = { + "codeExecutionResult": exec_result, + } + if ( + isinstance(_result_thought_sig, str) + and _result_thought_sig + ): + _result_part_entry["thoughtSignature"] = ( + _result_thought_sig + ) + _result_native: dict[str, Any] = { + "parts": [_result_part_entry], + } + yield _emit_tool_event( + { + "type": "tool_end", + "tool_call_id": pair_id, + "result": result_text, + "google": { + "native_part": _result_native, + }, + } + ) + last_code_exec_tool_id = pair_id + last_code_exec_result_text = result_text + gemini_code_exec_pending_id = None + # inlineData: either a Nano Banana + # generation (own card) or a sandbox + # plot attached to the code_execution + # card via the __IMAGES__: marker. + inline = part.get("inlineData") + if isinstance(inline, dict): + b64 = inline.get("data") or "" + mime = inline.get("mimeType") or "image/png" + if b64: + image_uri = f"data:{mime};base64,{b64}" + attached_to_code_exec = ( + not is_image_model + and last_code_exec_tool_id is not None + and bool(enabled_tools) + and "code_execution" + in (enabled_tools or []) + ) + if attached_to_code_exec: + updated_result = ( + last_code_exec_result_text + + "\n__IMAGES__:" + + _json.dumps([image_uri]) + ) + # Stow inlineData so a follow-up + # turn can replay the plot with + # its per-part thoughtSignature. + _plot_thought_sig = part.get( + "thoughtSignature" + ) or part.get("thought_signature") + _plot_part_entry: dict[str, Any] = { + "inlineData": { + "mimeType": mime, + "data": b64, + }, + } + if ( + isinstance(_plot_thought_sig, str) + and _plot_thought_sig + ): + _plot_part_entry[ + "thoughtSignature" + ] = _plot_thought_sig + yield _emit_tool_event( + { + "type": "tool_end", + "tool_call_id": ( + last_code_exec_tool_id + ), + "result": updated_result, + "google": { + "native_part": { + "parts": [ + _plot_part_entry + ], + }, + }, + } + ) + last_code_exec_result_text = ( + updated_result + ) + else: + img_id = f"img_{time.time_ns()}" + yield _emit_tool_event( + { + "type": "tool_start", + "tool_name": "image_generation", + "tool_call_id": img_id, + "arguments": { + "kind": "image", + "prompt": "", + }, + } + ) + # Gemini 3 image edit needs + # the prior thoughtSignature + # echoed on the inline image part. + _img_thought_sig = part.get( + "thoughtSignature" + ) or part.get("thought_signature") + _img_tool_end: dict[str, Any] = { + "type": "tool_end", + "tool_call_id": img_id, + "result": "", + "image_b64": b64, + "image_mime": mime, + } + # Stow inlineData so multi-turn + # edits replay the original + # image as native history. + _img_part_entry: dict[str, Any] = { + "inlineData": { + "mimeType": mime, + "data": b64, + }, + } + if ( + isinstance(_img_thought_sig, str) + and _img_thought_sig + ): + _img_part_entry[ + "thoughtSignature" + ] = _img_thought_sig + _img_native: dict[str, Any] = { + "parts": [_img_part_entry], + } + _img_google: dict[str, Any] = { + "native_part": _img_native, + } + if ( + isinstance(_img_thought_sig, str) + and _img_thought_sig + ): + _img_google["thought_signature"] = ( + _img_thought_sig + ) + _img_tool_end["google"] = _img_google + yield _emit_tool_event(_img_tool_end) + finish_reason = cand.get("finishReason") + if isinstance(finish_reason, str): + mapped = _finish_reason_map.get(finish_reason, "stop") + if mapped is not None: + final_finish_reason = mapped + + # End-of-stream emission order: web_search tool_end + # (with citations) -> finish_reason chunk -> usage + # chunk -> [DONE]. Matches the Anthropic / OpenAI + # helpers' contract so the frontend handler does + # not need provider-specific ordering knowledge. + if ( + web_search_active + and web_search_tool_started + and not web_search_tool_ended + ): + blocks: list[str] = [] + for cit in web_search_citations: + line_out = f"Title: {cit['title']}\nURL: {cit['url']}" + if cit.get("snippet"): + line_out += f"\nSnippet: {cit['snippet']}" + blocks.append(line_out) + yield _emit_tool_event( + { + "type": "tool_end", + "tool_call_id": web_search_tool_id, + "result": ( + "\n---\n".join(blocks) + if blocks + else "(search complete)" + ), + } + ) + web_search_tool_ended = True + + if final_finish_reason: + # OpenAI clients trigger tool execution when + # finish_reason="tool_calls". Gemini emits + # "STOP" even when the turn was a pure + # functionCall request, so override after the + # fact to match the OAI contract. + if emitted_any_function_call and final_finish_reason == "stop": + final_finish_reason = "tool_calls" + finish_chunk = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": final_finish_reason, + } + ], + } + yield f"data: {_json.dumps(finish_chunk)}" + + # Map Gemini usageMetadata onto OpenAI include_usage. + # thoughtsTokenCount is billed output too — fold it in + # so cost calculators don't undercount. + if isinstance(last_usage, dict): + thought_tokens = last_usage.get("thoughtsTokenCount") or 0 + candidate_tokens = last_usage.get("candidatesTokenCount") or 0 + prompt_tokens = last_usage.get("promptTokenCount") or 0 + # Gemini bills tool-call prompt slices separately + # via `toolUsePromptTokenCount`. Fold into input + # so total_tokens does not undercount tool turns. + tool_use_prompt_tokens = ( + last_usage.get("toolUsePromptTokenCount") or 0 + ) + translated_usage = { + "input_tokens": prompt_tokens + tool_use_prompt_tokens, + "output_tokens": candidate_tokens + thought_tokens, + "input_tokens_details": { + "cached_tokens": ( + last_usage.get("cachedContentTokenCount") or 0 + ), + "tool_use_prompt_tokens": tool_use_prompt_tokens, + }, + "output_tokens_details": { + "reasoning_tokens": thought_tokens, + }, + } + usage_line = _build_usage_chunk( + completion_id, "openai", translated_usage + ) + if usage_line: + yield usage_line + + yield "data: [DONE]" + finally: + # Close response first so lines_gen.aclose() becomes + # a no-op (avoids the httpcore 1.0 GeneratorExit + # path and the aclose-never-awaited RuntimeWarning). + await response.aclose() + await lines_gen.aclose() + + except httpx.ConnectError as exc: + logger.error("Connection error to %s: %s", self.provider_type, exc) + if web_search_tool_started and not web_search_tool_ended: + yield _emit_tool_event( + { + "type": "tool_end", + "tool_call_id": web_search_tool_id, + "result": f"(search aborted: connection error: {exc})", + } + ) + web_search_tool_ended = True + yield _error_sse_line( + 502, + f"Failed to connect to {self.provider_type}: {exc}", + self.provider_type, + ) + except httpx.ReadTimeout as exc: + logger.error("Read timeout from %s: %s", self.provider_type, exc) + if web_search_tool_started and not web_search_tool_ended: + yield _emit_tool_event( + { + "type": "tool_end", + "tool_call_id": web_search_tool_id, + "result": "(search aborted: read timeout)", + } + ) + web_search_tool_ended = True + yield _error_sse_line( + 504, + f"Timeout waiting for {self.provider_type} response", + self.provider_type, + ) + except httpx.HTTPError as exc: + logger.error("HTTP error from %s: %s", self.provider_type, exc) + if web_search_tool_started and not web_search_tool_ended: + yield _emit_tool_event( + { + "type": "tool_end", + "tool_call_id": web_search_tool_id, + "result": f"(search aborted: transport error: {exc})", + } + ) + web_search_tool_ended = True + yield _error_sse_line( + 502, + f"Error communicating with {self.provider_type}: {exc}", + self.provider_type, + ) + + async def _stream_openai_responses( + self, + messages: list[dict[str, Any]], + model: str, + temperature: float, + top_p: float, + max_tokens: Optional[int], + enable_thinking: Optional[bool], + reasoning_effort: Optional[str], + enabled_tools: Optional[list[str]] = None, + enable_prompt_caching: Optional[bool] = None, + openai_code_exec_container_id: Optional[str] = None, + compaction_threshold: Optional[int] = None, + tools: Optional[list[dict[str, Any]]] = None, + tool_choice: Optional[Any] = None, + ) -> AsyncGenerator[str, None]: + """ + Call OpenAI's /v1/responses endpoint and translate its SSE stream back + into OpenAI Chat Completions chunk format. + + The Responses API uses a different request shape (``input`` instead of + ``messages``, ``instructions`` for system prompts, ``max_output_tokens`` + for the budget) and emits event-typed SSE frames (e.g. + ``response.output_text.delta``) rather than chat-completion chunks. + ``presence_penalty`` / ``top_k`` are not part of the Responses contract + and are dropped here intentionally. + """ + import json as _json + + is_openai_cloud = _is_openai_family_cloud(self.base_url) + image_generation_requested = bool( + enabled_tools and "image_generation" in enabled_tools and is_openai_cloud + ) + + # Split system messages out into a single `instructions` string and + # translate user/assistant messages into the Responses input shape. + instructions_parts: list[str] = [] + input_items: list[dict[str, Any]] = [] + # When we drop a server-side builtin `function_call` here, the + # matching `role="tool"` follow-up must also be dropped -- + # otherwise the outbound body contains an orphan + # `function_call_output` with no matching `function_call`, which + # OpenAI Responses can reject or mis-associate. + skipped_server_builtin_call_ids: set[str] = set() + openai_replay_items: list[dict[str, Any]] = [] + previous_response_id: Optional[str] = None + for msg in messages: + role = msg.get("role") + content = msg.get("content", "") + + if role == "system": + if isinstance(content, str): + if content: + instructions_parts.append(content) + elif isinstance(content, list): + for part in content: + if part.get("type") == "text" and part.get("text"): + instructions_parts.append(part["text"]) + continue + + # OpenAI Responses uses item-shape history for function + # calling: assistant turns that invoked user tools must + # serialize each call as a `function_call` input item, and + # each role="tool" follow-up as a `function_call_output` + # item keyed by the matching `call_id`. Without this the + # second turn after a function call sends Chat Completions + # shape and Responses 400s the request. + if role == "tool": + _call_id = msg.get("tool_call_id") or "" + # If the matching assistant `function_call` was a + # server-side builtin we already dropped, drop the + # follow-up too to avoid emitting an orphan + # `function_call_output`. + if _call_id and _call_id in skipped_server_builtin_call_ids: + continue + if isinstance(content, list): + _flat_parts: list[str] = [] + for part in content: + if part.get("type") == "text" and part.get("text"): + _flat_parts.append(part["text"]) + _output_text = "".join(_flat_parts) + else: + _output_text = content if isinstance(content, str) else "" + if _call_id: + input_items.append( + { + "type": "function_call_output", + "call_id": _call_id, + "output": _output_text, + } + ) + continue + + # Assistant turns that returned tool_calls translate each + # call as a `function_call` item (carrying name + JSON + # arguments + call_id). Skip builtin server-side cards + # (canonical builtin name + `args._server_tool` marker) + # which never round-trip as user functions. We require both + # checks so a user function literally named `_server_tool` + # in its argument schema is not dropped. + _tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else None + if role == "assistant" and isinstance(_tool_calls, list): + # Preserve the prior `response.output` ordering: the + # model's text precedes its function_call items, and + # the matching role=tool follow-up arrives AFTER the + # call. Without this guard, history replay puts + # function_call -> assistant text -> function_call_output, + # which can put the tool output after an unrelated + # assistant message and confuse multi-turn function + # calling. + if isinstance(content, str) and content: + input_items.append({"role": "assistant", "content": content}) + elif isinstance(content, list): + _asst_parts: list[dict[str, Any]] = [] + for _part in content: + if not isinstance(_part, dict): + continue + _pt = _part.get("type") + if _pt == "text" and _part.get("text"): + _asst_parts.append( + { + "type": "input_text", + "text": _part.get("text", ""), + } + ) + elif _pt == "image_url": + _u = _part.get("image_url", {}).get("url", "") + if _u: + _asst_parts.append( + {"type": "input_image", "image_url": _u} + ) + if _asst_parts: + input_items.append( + {"role": "assistant", "content": _asst_parts} + ) + + for _tc in _tool_calls: + if not isinstance(_tc, dict): + continue + _fn = _tc.get("function") or {} + if not isinstance(_fn, dict) or not _fn.get("name"): + continue + _args_raw = _fn.get("arguments") or "" + if not isinstance(_args_raw, str): + try: + _args_raw = _json.dumps(_args_raw) + except Exception: + _args_raw = "" + _fn_name_lc = (_fn.get("name") or "").lower() + _is_server_builtin = False + if _fn_name_lc in _SERVER_SIDE_BUILTIN_TOOL_NAMES: + try: + _args_obj = _json.loads(_args_raw) if _args_raw else {} + except Exception: + _args_obj = None + if isinstance(_args_obj, dict): + if _args_obj.get("_server_tool") is True: + _is_server_builtin = True + else: + _g = _args_obj.get("google") + if isinstance(_g, dict) and isinstance( + _g.get("native_part"), dict + ): + _is_server_builtin = True + _call_id_out = _tc.get("id") or f"call_{time.time_ns()}" + if _is_server_builtin: + skipped_server_builtin_call_ids.add(_call_id_out) + continue + input_items.append( + { + "type": "function_call", + "call_id": _call_id_out, + "name": _fn["name"], + "arguments": _args_raw, + } + ) + # Assistant text already emitted above (in order) so we + # don't fall through to the generic content branches. + continue + + if isinstance(content, str): + input_items.append({"role": role, "content": content}) + continue + + if isinstance(content, list): + translated_parts: list[dict[str, Any]] = [] + used_previous_response_id = False + for part in content: + part_type = part.get("type") + if part_type == "text": + translated_parts.append( + {"type": "input_text", "text": part.get("text", "")} + ) + elif part_type == "image_url": + url = part.get("image_url", {}).get("url", "") + if url: + # Responses takes image_url as a flat string (both + # https:// URLs and data: URLs are accepted). + translated_parts.append( + {"type": "input_image", "image_url": url} + ) + elif ( + part_type == "reasoning" + and role == "assistant" + and image_generation_requested + ): + replay_item = _sanitize_openai_reasoning_replay_item(part) + if replay_item: + openai_replay_items.append(replay_item) + elif ( + part_type == "image_generation_call" + and role == "assistant" + and image_generation_requested + ): + response_id = ( + part.get("response_id") + or part.get("openai_response_id") + or part.get("previous_response_id") + ) + call_id = part.get("id") or part.get("image_generation_call_id") + if isinstance(call_id, str) and call_id: + if isinstance(response_id, str) and response_id: + previous_response_id = response_id + input_items = [] + translated_parts = [] + used_previous_response_id = True + else: + previous_response_id = None + openai_replay_items.append( + {"type": "image_generation_call", "id": call_id} + ) + elif part_type == "input_document": + # OpenAI Responses accepts PDFs / docs as + # `{type:"input_file", file_data:"data:application/pdf;base64,..."}` + # or `{type:"input_file", file_url:"https://..."}`, + # with optional `filename`. See + # https://developers.openai.com/api/docs/guides/images-vision + # Map Studio's normalised `input_document` shape + # straight onto Responses' `input_file`. + file_url = part.get("file_url") + file_data = part.get("file_data") + filename = part.get("filename") + # Mirror the Anthropic-side guard: any "data:" URI + # without an actual base64 payload (`data:application/pdf;base64,` + # or whitespace-only) would otherwise be forwarded + # to OpenAI as `file_data=""`, which 400s the whole + # turn. Treat such payloads as missing AND fall + # back to file_url if one is also present, so a + # recoverable remote URL doesn't get discarded in + # favour of a malformed inline payload. + file_data_valid = bool( + isinstance(file_data, str) + and file_data + and ( + not file_data.startswith("data:") or file_data.partition(",")[2].strip() ) ) @@ -3121,44 +5305,15 @@ async def _stream_openai_responses( if max_tokens is not None: body["max_output_tokens"] = max_tokens - # Prompt caching on /v1/responses is automatic and free, but the - # default in-memory policy only survives ~5-10 min of inactivity - # (up to ~1 hr). Opt into the 24-hour retention policy so a chat - # left idle overnight still hits the cache on the next turn. - # Pricing is identical to in_memory per OpenAI's docs. - # - # Gated on the base URL because ollama / llama.cpp / "custom" - # presets all collapse to provider_type="openai" in - # toExternalBackendProviderType, so they also land in this - # helper. Those servers expose /v1/responses-shaped routes in - # some configurations but don't implement - # prompt_cache_retention — sending the field unconditionally - # would 400 them. Match the public OpenAI host strictly so the - # field only goes to OpenAI cloud. Studio's openai model picker - # is registry-scoped to gpt-5.x / o3 / gpt-4.5, all of which - # accept this parameter (gpt-5.5+ already defaults to "24h" and - # rejects "in_memory", so it's a safe no-op there). - # OpenAI-family cloud: api.openai.com OR Azure OpenAI Foundry - # (*.openai.azure.com). Both expose the same Responses-API - # extensions used below -- prompt_cache_retention, - # context_management compaction, container shell tool -- so - # treat them uniformly. Non-cloud OpenAI-compatible servers - # (ollama / llama.cpp / vLLM / "custom" preset) hit /v1/responses - # without these extensions and would 400 on the unknown body - # fields, so they intentionally fall outside this gate. + # Opt into 24h prompt-cache retention (free, vs the default + # ~5-10 min). Gated on the OpenAI cloud host because ollama / + # llama.cpp / "custom" presets reach this code path too and + # would 400 on the unknown field. if is_openai_cloud and enable_prompt_caching is not False: body["prompt_cache_retention"] = "24h" - # OpenAI server-side context compaction — see - # https://developers.openai.com/api/docs/guides/compaction - # When `compaction_threshold` is provided on a cloud OpenAI - # request, attach `context_management: [{type:"compaction", - # compact_threshold:N}]` so the API runs server-side - # compaction when the rendered prompt crosses the threshold. - # No beta header is required; no dated version pin. The field - # is silently dropped for non-cloud backends because ollama / - # llama.cpp / "custom" presets land in this helper and would - # 400 on an unknown body field. + # Server-side context compaction (OpenAI cloud only). + # https://developers.openai.com/api/docs/guides/compaction if ( is_openai_cloud and compaction_threshold is not None @@ -3171,55 +5326,92 @@ async def _stream_openai_responses( } ] - # OpenAI server-side tools — see - # https://developers.openai.com/api/docs/guides/tools - # https://developers.openai.com/api/docs/guides/tools-shell - # The frontend's Search/Code buttons map to the unified - # enabled_tools shorthand; translate that into the Responses-API - # tool schema. Other built-in tools (file_search, - # code_interpreter, image_generation, computer_use_preview) can - # be added with the same pattern when we surface their toggles. + # Map enabled_tools onto Responses-API server tools (cloud only; + # local OAI-compat backends 400 on these). + # https://developers.openai.com/api/docs/guides/tools code_execution_enabled_openai = bool( enabled_tools and "code_execution" in enabled_tools and is_openai_cloud ) - # OpenAI's image_generation tool is a Responses-API server tool. - # See https://developers.openai.com/api/docs/guides/tools-image-generation - # The model picks size / quality / background server-side and - # delegates rendering to a gpt-image-* family model; the result - # comes back inline as an `image_generation_call` output item - # with a base64 image. Available on every gpt-5.x family member - # plus gpt-4.1 / gpt-4o / o3 per the docs; restrict to cloud - # OpenAI because the local llama.cpp / ollama backends don't - # implement it and would 400. - image_generation_enabled_openai = image_generation_requested + image_generation_enabled_openai = bool( + enabled_tools and "image_generation" in enabled_tools and is_openai_cloud + ) def _openai_image_generation_tool() -> dict[str, Any]: tool: dict[str, Any] = {"type": "image_generation"} if image_generation_has_reference: - # OpenAI's Responses image tool defaults to `auto`. For - # Studio's explicit follow-up edit flow, force edit mode so - # the provider uses the previous response / call id as image - # context instead of treating the text as a fresh generation. + # Force edit mode so the prior call id is used as context. tool["action"] = "edit" return tool - if enabled_tools: - tools_array: list[dict[str, Any]] = [] - if "web_search" in enabled_tools: + # Translate Chat-Completions function tools into the Responses + # function-tool shape (flattened name/description/parameters). + responses_user_function_tools: list[dict[str, Any]] = [] + if tools: + for _tool in tools: + if not isinstance(_tool, dict) or _tool.get("type") != "function": + continue + _fn = _tool.get("function") + if not isinstance(_fn, dict) or not _fn.get("name"): + continue + _entry: dict[str, Any] = { + "type": "function", + "name": _fn["name"], + } + if _fn.get("description"): + _entry["description"] = _fn["description"] + if isinstance(_fn.get("parameters"), dict): + _entry["parameters"] = _fn["parameters"] + responses_user_function_tools.append(_entry) + + # Translate tool_choice into the Responses shape. + _responses_tc_string: Optional[str] = None + if isinstance(tool_choice, str): + _tc_lc = tool_choice.strip().lower() + if _tc_lc in ("auto", "none", "required"): + _responses_tc_string = _tc_lc + responses_tool_choice: Optional[Any] = None + _has_responses_tools = bool(enabled_tools or responses_user_function_tools) + if _responses_tc_string is not None and _has_responses_tools: + responses_tool_choice = _responses_tc_string + elif ( + tool_choice is not None + and responses_user_function_tools + and isinstance(tool_choice, dict) + and tool_choice.get("type") == "function" + ): + _fn_pick = tool_choice.get("function") or {} + _name = _fn_pick.get("name") if isinstance(_fn_pick, dict) else None + if isinstance(_name, str) and _name: + responses_tool_choice = {"type": "function", "name": _name} + + _responses_tool_choice_none = _responses_tc_string == "none" + # A pinned user function suppresses hosted builtins (privacy + + # billing), matching the Gemini / Anthropic / OpenRouter gates. + _responses_tool_choice_forced_function = ( + isinstance(tool_choice, dict) + and tool_choice.get("type") == "function" + and isinstance(tool_choice.get("function"), dict) + and bool(tool_choice["function"].get("name")) + ) + _responses_hosted_builtins_allowed = ( + not _responses_tool_choice_none + and not _responses_tool_choice_forced_function + ) + + if ( + enabled_tools or responses_user_function_tools + ) and not _responses_tool_choice_none: + tools_array: list[dict[str, Any]] = list(responses_user_function_tools) + if ( + _responses_hosted_builtins_allowed + and enabled_tools + and "web_search" in enabled_tools + ): tools_array.append({"type": "web_search"}) - if code_execution_enabled_openai: - # `container_auto` lets OpenAI auto-create a fresh - # container per request; we capture the resulting - # container_id off the SSE stream and the chat-adapter - # persists it onto the thread record. Subsequent turns - # in the same thread pass it back as - # `openai_code_exec_container_id`, which we translate to - # `container_reference` here so the model sees - # filesystem state from prior turns. Container expires - # after ~20 min of inactivity per OpenAI's default - # policy — a stale id 400s, the chat-adapter clears it - # via container_invalidated, and the next turn falls - # back to auto-create. + if _responses_hosted_builtins_allowed and code_execution_enabled_openai: + # Reuse the thread's container so filesystem state + # persists; auto-create when there isn't one yet. Stale + # ids 400 and are cleared via container_invalidated. shell_env: dict[str, Any] if openai_code_exec_container_id: shell_env = { @@ -3229,10 +5421,12 @@ def _openai_image_generation_tool() -> dict[str, Any]: else: shell_env = {"type": "container_auto"} tools_array.append({"type": "shell", "environment": shell_env}) - if image_generation_enabled_openai: + if _responses_hosted_builtins_allowed and image_generation_enabled_openai: tools_array.append(_openai_image_generation_tool()) if tools_array: body["tools"] = tools_array + if responses_tool_choice is not None: + body["tool_choice"] = responses_tool_choice url = f"{self.base_url}/responses" completion_id = f"chatcmpl-openai-{model.replace('/', '-')}" @@ -3246,11 +5440,19 @@ def _build_body(container_id_for_this_attempt: Optional[str]) -> dict[str, Any]: first attempt. """ attempt_body = dict(body) - if enabled_tools: - tools_array_attempt: list[dict[str, Any]] = [] - if "web_search" in enabled_tools: + if ( + enabled_tools or responses_user_function_tools + ) and not _responses_tool_choice_none: + tools_array_attempt: list[dict[str, Any]] = list( + responses_user_function_tools + ) + if ( + _responses_hosted_builtins_allowed + and enabled_tools + and "web_search" in enabled_tools + ): tools_array_attempt.append({"type": "web_search"}) - if code_execution_enabled_openai: + if _responses_hosted_builtins_allowed and code_execution_enabled_openai: if container_id_for_this_attempt: env_attempt: dict[str, Any] = { "type": "container_reference", @@ -3261,12 +5463,17 @@ def _build_body(container_id_for_this_attempt: Optional[str]) -> dict[str, Any]: tools_array_attempt.append( {"type": "shell", "environment": env_attempt} ) - if image_generation_enabled_openai: + if ( + _responses_hosted_builtins_allowed + and image_generation_enabled_openai + ): tools_array_attempt.append(_openai_image_generation_tool()) if tools_array_attempt: attempt_body["tools"] = tools_array_attempt else: attempt_body.pop("tools", None) + if responses_tool_choice is not None: + attempt_body["tool_choice"] = responses_tool_choice return attempt_body def _is_openai_container_expired_error(error_text: str) -> bool: @@ -3328,52 +5535,26 @@ def _is_openai_container_expired_error(error_text: str) -> bool: done_emitted = False reasoning_open = False reasoning_emitted = False - # Latched from response.completed / response.incomplete so - # the final log can surface input_tokens_details.cached_tokens — - # the field that proves prompt_cache_retention="24h" is - # actually hitting OpenAI's cache instead of recomputing - # the prefix every turn. + # Per-call function-tool indexing; distinct slots so + # parallel calls don't collide on delta.tool_calls[].index. + saw_function_call = False + function_call_index = 0 + # Latched from response.completed/incomplete; surfaces + # input_tokens_details.cached_tokens to prove cache hits. last_usage: Optional[dict[str, Any]] = None - # Per-call state for OpenAI's server-side web_search tool. Mapped - # back into our local _toolEvent shape so the existing chat-UI - # renderer surfaces web_search the same way it does for local - # tool calls: a "Searching…" tool-call card, then a `tool_end` - # carrying citations formatted as - # Title: …\nURL: …\nSnippet: …\n---\n… - # blocks (which the frontend's parseSourcesFromResult lifts - # into source content parts at end of stream). - # web_search_calls preserves insertion order so we can apply - # the aggregated citation list onto the *last* call's - # tool_end — that's the one the frontend's source-pill - # extraction reads (parseSourcesFromResult flatMaps every - # web_search result, so a single non-empty result is enough - # to surface all sources at message tail). - # OpenAI emits url_citation annotations on text deltas, not - # per call — there's no wire field linking a citation back - # to a specific search invocation. Hence the shared list. - # web_search_calls: { item_id -> {query} } + # web_search state. Citations are emitted on text deltas + # (not per call), so the aggregate list is shared and + # applied to the LAST web_search tool_end (parseSourcesFromResult + # flatmaps every call, one non-empty is enough). web_search_calls: dict[str, dict[str, Any]] = {} all_url_citations: list[dict[str, Any]] = [] - # Shell-tool (code execution) state. OpenAI emits - # `shell_call` items (model requesting a command list) - # paired with `shell_call_output` items (execution - # results). We mirror the Anthropic code-execution UX - # by emitting one `_toolEvent` tool_start per - # shell_call and one tool_end per shell_call_output; - # they're linked via `shell_call_output.call_id` - # matching `shell_call.id`. Items are independent of - # web_search (different keyed map). - # shell_calls: { call_id -> {commands, output} } + # shell_calls (code execution): { call_id -> {commands, output} }. + # shell_call <-> shell_call_output match by call_id; emit + # tool_start/tool_end like the Anthropic UX. shell_calls: dict[str, dict[str, Any]] = {} - # Container id captured from the response stream. When - # it differs from the inbound id, emit a synthetic - # `container_ready` _toolEvent so the frontend can - # persist it onto the thread record for the next turn. - # Where OpenAI surfaces it is documented loosely; we - # probe two known fields (response.container_id on - # response.completed, item.environment.container_id on - # shell_call output items) and latch the first one we - # see. + # Container id latched from response.container_id or + # item.environment.container_id; emit container_ready + # when it differs from the inbound id. latched_container_id: Optional[str] = None container_id_emitted = False current_openai_response_id: Optional[str] = None @@ -3453,6 +5634,7 @@ def _flush_pending_marker_tail(tail: str) -> str: return rendered def _emit_tool_event(payload: dict[str, Any]) -> str: + _stamp_server_tool_marker(payload) chunk = { "id": completion_id, "object": "chat.completion.chunk", @@ -3775,17 +5957,9 @@ def _chunk_with_text(text: str) -> str: f"ws_{len(web_search_calls)}" ) web_search_calls.setdefault(item_id, {"query": ""}) - # Shell-tool: register the call eagerly so - # the matching shell_call_output can link - # back even if `done` arrives out of order. - # Also probe for container_id on the - # environment field — when container_auto - # auto-creates one, this is the first place - # the new id might surface (OpenAI doesn't - # promise this in docs, but the field is - # cheap to scan and lets us emit - # container_ready earlier than - # response.completed). + # Register shell_call eagerly so out-of-order + # output links back. Probe env.container_id + # to emit container_ready before response.completed. if ( isinstance(item, dict) and item.get("type") == "shell_call" @@ -3978,24 +6152,9 @@ def _chunk_with_text(text: str) -> str: } ) elif item.get("type") == "image_generation_call": - # OpenAI's image_generation tool returns - # a single output item with the base64 - # PNG/WebP/JPEG on `result` (sometimes - # `b64_json` depending on output_format). - # `revised_prompt` is what the gpt-image - # backbone actually used after refinement - # of the assistant's request. Emit - # tool_start + tool_end so the chat card - # renders the prompt + the generated - # image inline. The frontend chat-adapter - # decides how to render the base64 blob - # (likely an ) - # based on the `kind: "image"` hint we - # set on tool_start arguments. - # `time_ns()` (nanoseconds) instead of - # millisecond resolution so synthesised - # ids stay unique even when two image - # generations resolve in the same ms. + # Base64 image on `result` (or `b64_json`), + # `revised_prompt` for the rewritten prompt. + # ns-resolution id so concurrent gens stay unique. raw_item_id = item.get("id") item_id = raw_item_id or f"img_{time.time_ns()}" prompt_in = ( @@ -4034,6 +6193,54 @@ def _chunk_with_text(text: str) -> str: "prompt": prompt_in, } ) + elif item.get("type") == "function_call": + # Translate to Chat-Completions delta.tool_calls. + # https://platform.openai.com/docs/guides/function-calling?api-mode=responses + fn_call_id = ( + item.get("call_id") + or item.get("id") + or f"call_{time.time_ns()}" + ) + fn_name = item.get("name") or "" + fn_args = item.get("arguments") or "" + if not isinstance(fn_args, str): + try: + fn_args = _json.dumps(fn_args) + except Exception: + fn_args = "" + _tc_index = function_call_index + function_call_index += 1 + yield ( + "data: " + + _json.dumps( + { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [ + { + "index": _tc_index, + "id": fn_call_id, + "type": "function", + "function": { + "name": fn_name, + "arguments": ( + fn_args + ), + }, + } + ], + }, + "finish_reason": None, + } + ], + } + ) + ) + saw_function_call = True elif ( isinstance(event_type, str) @@ -4167,7 +6374,11 @@ def _chunk_with_text(text: str) -> str: { "index": 0, "delta": {}, - "finish_reason": "stop", + "finish_reason": ( + "tool_calls" + if saw_function_call + else "stop" + ), } ], } @@ -4448,11 +6659,70 @@ async def list_models(self) -> list[dict[str, Any]]: models = [model for model in raw_models if isinstance(model, dict)] if not models and self.provider_type == "ollama": models = await self._list_ollama_native_models() + # Gemini's native /v1beta/models returns + # {"models": [{"name": "models/gemini-2.5-flash", ...}]} + # -- repackage into the OpenAI-compatible shape the rest + # of Studio expects so dynamic model discovery works. + if not models and self.provider_type == "gemini": + models = self._parse_gemini_models(data) return models except httpx.HTTPError as exc: logger.error("Failed to list models from %s: %s", self.provider_type, exc) raise + @staticmethod + def _parse_gemini_models(payload: Any) -> list[dict[str, Any]]: + """Translate Gemini's native /v1beta/models payload to OpenAI shape. + + Native response: + {"models": [{"name": "models/gemini-2.5-flash", + "baseModelId": "gemini-2.5-flash", + "displayName": "Gemini 2.5 Flash", + "supportedGenerationMethods": [...]}]} + + We only keep entries that advertise + ``generateContent`` / ``streamGenerateContent`` so the picker + does not surface embedding-only models the chat path can't + drive. + """ + if not isinstance(payload, dict): + return [] + entries = payload.get("models") or [] + if not isinstance(entries, list): + return [] + out: list[dict[str, Any]] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + methods = entry.get("supportedGenerationMethods") or [] + if ( + isinstance(methods, list) + and methods + and not any( + m in methods for m in ("generateContent", "streamGenerateContent") + ) + ): + continue + base_id = entry.get("baseModelId") + name = entry.get("name") or "" + # ``name`` arrives as ``"models/gemini-2.5-flash"``; the + # chat path uses the bare id. + short_id = ( + base_id + if isinstance(base_id, str) and base_id + else (name.split("/", 1)[1] if "/" in name else name) + ) + if not short_id: + continue + out.append( + { + "id": short_id, + "owned_by": "google", + "display_name": entry.get("displayName") or short_id, + } + ) + return out + async def _list_ollama_native_models(self) -> list[dict[str, Any]]: """Fallback when Ollama's /v1/models returns an empty or null catalog.""" root = self.base_url.removesuffix("/v1").rstrip("/") @@ -4757,6 +7027,17 @@ def _build_usage_chunk( "total_tokens": prompt_tokens + completion_tokens, "prompt_tokens_details": {"cached_tokens": cached}, } + # Surface OpenAI Responses / Gemini reasoning-token detail. The + # caller pre-populates last_usage["output_tokens_details"] with + # at least {"reasoning_tokens": ...}; mirror it into the OAI + # `completion_tokens_details` shape so SDKs can render the + # hidden-thoughts slice. + out_details = last_usage.get("output_tokens_details") + if isinstance(out_details, dict) and out_details: + usage_block["completion_tokens_details"] = { + "reasoning_tokens": out_details.get("reasoning_tokens") or 0, + } + usage_block["output_tokens_details"] = out_details chunk = { "id": completion_id, diff --git a/studio/backend/core/inference/providers.py b/studio/backend/core/inference/providers.py index fef9ba3e124..785f1dec3b1 100644 --- a/studio/backend/core/inference/providers.py +++ b/studio/backend/core/inference/providers.py @@ -65,28 +65,77 @@ }, "gemini": { "display_name": "Google Gemini", - "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", - # Curated lineup — Google's /v1beta/openai/models returns dozens - # of historical / experimental / embedding ids. Cap to the current - # 3.x family plus the rolling `*-latest` aliases. + # Native Gemini REST endpoint -- the Gemini API does NOT speak + # OpenAI Chat Completions on this base. Requests/responses are + # translated in `_stream_gemini` in external_provider.py. + # API reference: https://ai.google.dev/gemini-api/docs + "base_url": "https://generativelanguage.googleapis.com/v1beta", + # Curated lineup -- the live ListModels response returns dozens + # of historical / experimental / embedding ids. Cap to the + # current chat-capable Gemini families (3.5 / 3.1 / 3 Flash / + # 2.5) plus the Nano Banana image trio and the rolling + # `*-latest` aliases. Excluded on purpose: + # - `gemini-2.0-flash*` (Google retired 2026-06-01; 404 on use) + # - `gemini-3-pro-preview` (shut down 2026-03-09; auto-redirects + # to `gemini-3.1-pro-preview` per Google's deprecation notice, + # so we surface 3.1 directly and skip the redirect). + # The allowlist below blocks the retired ids from re-appearing + # via the live ListModels fetch. Verified against the live + # `/v1beta/models` catalog 2026-05-24. "default_models": [ "gemini-3.1-pro-preview", + "gemini-3.5-flash", "gemini-3.1-flash-lite", "gemini-3-flash-preview", "gemini-pro-latest", "gemini-flash-latest", "gemini-flash-lite-latest", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-3-pro-image-preview", + "gemini-3.1-flash-image-preview", + "gemini-2.5-flash-image", ], "supports_streaming": True, "supports_vision": True, "supports_tool_calling": True, - "auth_header": "Authorization", - "auth_prefix": "Bearer ", - "notes": "OpenAI-compatible endpoint. API key from https://aistudio.google.com/apikey.", + # The native API takes the API key on the `x-goog-api-key` + # header. An empty `auth_prefix` ensures we send the bare key. + "auth_header": "x-goog-api-key", + "auth_prefix": "", + "openai_compatible": False, + "notes": ( + "Native Gemini API. Translation lives in _stream_gemini. " + "API key from https://aistudio.google.com/apikey. " + "See https://ai.google.dev/gemini-api/docs for endpoint shapes." + ), + # Even after the regex match, drop ids that Google still + # returns from ListModels but routes via implicit redirect. + # gemini-3-pro-preview was shut down 2026-03-09 and is + # auto-aliased to gemini-3.1-pro-preview; we surface the + # canonical id only so users do not see two cards for the + # same underlying model. + "model_id_deny_exact": ("gemini-3-pro-preview",), + # Matches the chat-capable 3.5 / 3.1 / 3 / 2.5 families plus the + # rolling *-latest aliases (which Google rolls forward as new + # generations ship). Image-tier ids (`-image`, `-image-preview`, + # `nano-banana-pro-preview`) flow through the Nano Banana + # `responseModalities` path in `_stream_gemini`. Retired 2.0 + # ids ARE NOT in this regex on purpose -- Google's ListModels + # would otherwise re-surface them and they 404 on use. "model_id_allowlist": re.compile( - r"^(gemini-3\.1-flash-lite|gemini-3-flash-preview|" - r"gemini-3\.1-pro-preview|gemini-pro-latest|" - r"gemini-flash-latest|gemini-flash-lite-latest)$" + r"^(" + r"gemini-3\.5-(?:flash|pro)(?:-preview)?|" + r"gemini-3\.1-(?:flash|pro|flash-lite)(?:-preview)?(?:-customtools)?|" + r"gemini-3\.1-flash-image-preview|" + r"gemini-3-(?:flash|pro)(?:-preview)?|" + r"gemini-3-pro-image-preview|" + r"nano-banana-pro-preview|" + r"gemini-2\.5-pro|gemini-2\.5-flash|gemini-2\.5-flash-lite|" + r"gemini-2\.5-flash-image|" + r"gemini-pro-latest|gemini-flash-latest|gemini-flash-lite-latest" + r")$" ), }, "deepseek": { diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 0e9cce7c3ee..35d59038267 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -632,8 +632,17 @@ def _validate_and_resolve_host(hostname: str, port: int) -> tuple[bool, str, str for *_, sockaddr in infos: ip = ipaddress.ip_address(sockaddr[0]) + # `not ip.is_global` rejects every category the denylist below + # also rejects PLUS shared address space (100.64.0.0/10 carrier- + # grade NAT) and benchmarking/documentation/exchange ranges that + # Python classifies with `is_private=False` and `is_global=False` + # (see https://docs.python.org/3/library/ipaddress.html#ipaddress.IPv4Address.is_global). + # The explicit predicates after it give human-readable categories + # in the error message, but a single non-global check is the + # source of truth and prevents future ranges from leaking. if ( - ip.is_private + not ip.is_global + or ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 0af9425fdcb..b58ca664400 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -581,6 +581,14 @@ class ChatMessage(BaseModel): None, description = "OpenAI tool-result messages: name of the tool whose result this is.", ) + extra_content: Optional[dict] = Field( + None, + description = ( + "Provider-specific extra fields the translator may read. " + "Gemini reads `extra_content.google.thought_signature` " + "from assistant messages to replay text-part signatures." + ), + ) @model_validator(mode = "after") def _validate_role_shape(self) -> "ChatMessage": @@ -752,17 +760,42 @@ class ChatCompletionRequest(BaseModel): None, description = "[x-unsloth] Override base URL for the external provider.", ) - enable_prompt_caching: Optional[bool] = Field( + enable_prompt_caching: Optional[Union[bool, str]] = Field( None, description = ( "[x-unsloth] Opt in to provider-side prompt caching. On Anthropic, " - "attaches cache_control={type:ephemeral} to the system block so the " - "static prefix is reused across turns. On OpenAI cloud, caching is " - "automatic for prompts >=1024 tokens and this flag is informational. " - "Ignored for every other provider (mistral, gemini, kimi, openrouter, " - "vllm, local, etc.). Treated as enabled when omitted." + "boolean true attaches cache_control={type:ephemeral} to the system " + "block so the static prefix is reused across turns. On OpenAI cloud, " + "caching is automatic for prompts >=1024 tokens and the boolean is " + "informational. On Gemini, pass a string cache resource name such " + "as `cachedContents/abc123` to attach `cachedContent` on the native " + "request (boolean true is a no-op on Gemini because creating the " + "cache requires a separate POST /cachedContents call). Ignored for " + "every other provider. Treated as enabled when omitted." ), ) + + @field_validator("enable_prompt_caching", mode = "before") + @classmethod + def _coerce_enable_prompt_caching(cls, value: Any) -> Any: + """Preserve the pre-PR coercion: the field used to be Optional[bool], + so callers historically sent JSON strings `"true"` / `"false"` and + Pydantic v1 coerced them. Widening to Optional[Union[bool, str]] for + Gemini cache resource names lets `"false"` slip through as a truthy + string. Coerce the canonical bool literals back so explicit opt-outs + stay opt-out.""" + if isinstance(value, str): + lowered = value.strip().lower() + # Match Pydantic v1's BooleanField coercion table (yes/y/on/t/1 + # and no/n/off/f/0) so opt-outs that used to parse still parse. + # Anything else is preserved as a string for Gemini's + # cachedContent resource path. + if lowered in ("true", "t", "1", "yes", "y", "on"): + return True + if lowered in ("false", "f", "0", "no", "n", "off"): + return False + return value + prompt_cache_ttl: Optional[str] = Field( None, description = ( diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index a156f2397c4..92498b8a9a4 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1705,6 +1705,7 @@ def _build_external_messages( messages: list, supports_vision: bool, provider_type: Optional[str] = None, + base_url: Optional[str] = None, ) -> list[dict]: """ Convert ChatMessage list to OpenAI-compatible dicts for external providers. @@ -1732,14 +1733,171 @@ def _build_external_messages( document_provider = provider_type in _INPUT_DOCUMENT_PROVIDERS anthropic = provider_type == "anthropic" openai = provider_type == "openai" + # `extra_content` is a Gemini-specific carrier for the assistant's + # text-part `thoughtSignature` round-trip on the native + # streamGenerateContent endpoint. Custom Gemini OpenAI-compatible + # gateways (LiteLLM etc.) route through /chat/completions where + # the field is unknown and can be rejected -- gate strictly on the + # Google-hosted Gemini base. + _native_gemini = False + if provider_type == "gemini" and base_url: + try: + from urllib.parse import urlparse as _urlparse + + _host = (_urlparse(base_url).hostname or "").lower() + _native_gemini = _host == "generativelanguage.googleapis.com" + except Exception: + _native_gemini = False + emit_extra_content = _native_gemini + + _SERVER_BUILTIN_TOOL_NAMES = frozenset( + {"web_search", "web_fetch", "code_execution", "image_generation"} + ) + + def _is_marked_server_builtin_tool_call(tc: Any) -> bool: + """Return True iff `tc` is a synthetic provider-side tool card + with one of the canonical builtin names and either: + - the new `args._server_tool` marker stamped by the backend, or + - a Gemini `args.google.native_part` payload (durable replay + signal for code_execution / image_generation that predates + the marker). + Such cards must not be forwarded to non-native providers + because they are not real user functions and the receiving API + will reject the orphan tool history. Real user functions with + these names normally have neither signal. + """ + if not isinstance(tc, dict): + return False + fn = tc.get("function") + if not isinstance(fn, dict): + return False + name = (fn.get("name") or "").lower() + if name not in _SERVER_BUILTIN_TOOL_NAMES: + return False + raw_args = fn.get("arguments") or "" + try: + args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args + except Exception: + return False + if not isinstance(args, dict): + return False + if args.get("_server_tool") is True: + return True + google = args.get("google") + return isinstance(google, dict) and isinstance(google.get("native_part"), dict) + + # When we drop a server-side builtin tool_call here, the matching + # `role="tool"` follow-up must also be dropped from the outbound + # history -- otherwise the provider receives an orphan + # tool_call_id with no matching assistant call, which OpenAI + # Responses and Anthropic both reject. + dropped_server_builtin_tool_call_ids: set[str] = set() + + def _filter_tool_calls(tool_calls: Any) -> Optional[list]: + """Sanitize assistant `tool_calls` for non-native-Gemini providers. + + Two concerns: + 1. `tool_calls[i].extra_content` carries Gemini-only + thoughtSignature metadata; strip it for providers that + cannot parse the unknown key. + 2. Marked server-side builtin cards (`_server_tool: true` on + a canonical builtin name, or a Gemini `native_part` + payload) are provider-internal Studio tool cards from a + prior native Gemini turn; forwarding them to OpenAI / + Anthropic / custom OAI-compat gateways sends an orphan + `tool_calls` entry (no matching tool declaration, often + no matching `role="tool"` reply) that can be rejected. + We record the dropped call_ids so the matching role=tool + message is also skipped below. + Native Gemini keeps both untouched so the native translator can + replay them via `native_part`. + """ + if not tool_calls: + return None + if not isinstance(tool_calls, list): + return tool_calls + if emit_extra_content: + return tool_calls + cleaned: list = [] + for _tc in tool_calls: + if _is_marked_server_builtin_tool_call(_tc): + _tc_id = _tc.get("id") if isinstance(_tc, dict) else None + if isinstance(_tc_id, str) and _tc_id: + dropped_server_builtin_tool_call_ids.add(_tc_id) + continue + if not isinstance(_tc, dict): + cleaned.append(_tc) + continue + if "extra_content" not in _tc: + cleaned.append(_tc) + continue + _stripped = {k: v for k, v in _tc.items() if k != "extra_content"} + cleaned.append(_stripped) + return cleaned + result = [] for msg in messages: + # Drop role=tool messages whose matching server-builtin + # tool_call was already filtered above. Forwarding an orphan + # tool_result with no matching tool_call would be rejected by + # OpenAI Responses and Anthropic. + if ( + msg.role == "tool" + and isinstance(msg.tool_call_id, str) + and msg.tool_call_id in dropped_server_builtin_tool_call_ids + ): + continue if isinstance(msg.content, str): - # Skip assistant messages with empty content (some providers reject them) - if msg.role == "assistant" and not msg.content.strip(): + # Drop bare assistant messages with no content AND no + # tool_calls (some providers reject empty assistant turns). + # Preserve assistant turns whose only payload is tool_calls + # so multi-turn function-call loops round-trip. + if ( + msg.role == "assistant" + and not msg.content.strip() + and not msg.tool_calls + ): continue - result.append({"role": msg.role, "content": msg.content}) - elif isinstance(msg.content, list): + out: dict[str, Any] = {"role": msg.role, "content": msg.content} + if msg.role == "assistant" and msg.tool_calls: + _tcs = _filter_tool_calls(msg.tool_calls) + if _tcs: + out["tool_calls"] = _tcs + elif not msg.content.strip(): + # Every tool_call was a synthetic provider-side + # card and was dropped; the assistant turn would + # be an empty `{"role":"assistant","content":""}` + # which some providers reject. Skip it entirely. + continue + if msg.role == "tool": + if msg.tool_call_id: + out["tool_call_id"] = msg.tool_call_id + if msg.name: + out["name"] = msg.name + if emit_extra_content and msg.role == "assistant" and msg.extra_content: + out["extra_content"] = msg.extra_content + result.append(out) + continue + # Assistant messages with content=None but populated tool_calls + # are valid (post-tool-call assistant turn). Forward them so the + # provider helper can rebuild the functionCall part. + if msg.content is None and msg.role == "assistant" and msg.tool_calls: + _filtered_tcs = _filter_tool_calls(msg.tool_calls) + if not _filtered_tcs: + # Every tool_call on this turn was provider-side + # synthetic and dropped; skipping the whole message + # avoids forwarding an empty assistant turn. + continue + _assistant_only: dict[str, Any] = { + "role": "assistant", + "content": "", + "tool_calls": _filtered_tcs, + } + if emit_extra_content and msg.extra_content: + _assistant_only["extra_content"] = msg.extra_content + result.append(_assistant_only) + continue + if isinstance(msg.content, list): if supports_vision: parts = [] for part in msg.content: @@ -1797,9 +1955,27 @@ def _build_external_messages( # provider would 400 on the unknown part, so # gate by provider_type. parts.append({"type": "compaction", "content": part.content}) - if msg.role == "assistant" and not parts: + entry: dict[str, Any] = {"role": msg.role, "content": parts} + if msg.role == "assistant" and msg.tool_calls: + _tcs = _filter_tool_calls(msg.tool_calls) + if _tcs: + entry["tool_calls"] = _tcs + elif not parts: + # All tool_calls were synthetic and dropped, + # and no preserved content parts survived. + # Skip rather than forward an empty assistant + # turn that downstream providers reject. + continue + elif msg.role == "assistant" and not parts: continue - result.append({"role": msg.role, "content": parts}) + if msg.role == "tool": + if msg.tool_call_id: + entry["tool_call_id"] = msg.tool_call_id + if msg.name: + entry["name"] = msg.name + if emit_extra_content and msg.role == "assistant" and msg.extra_content: + entry["extra_content"] = msg.extra_content + result.append(entry) else: # Non-vision provider: strip images / documents, keep # text, optionally keep compaction (Anthropic only -- @@ -1835,9 +2011,32 @@ def _build_external_messages( if len(preserved) == 1 and preserved[0]["type"] == "text": # Single text part collapses back to a string for # providers that don't accept content arrays. - result.append({"role": msg.role, "content": preserved[0]["text"]}) + entry = {"role": msg.role, "content": preserved[0]["text"]} else: - result.append({"role": msg.role, "content": preserved}) + entry = {"role": msg.role, "content": preserved} + if msg.role == "assistant" and msg.tool_calls: + _tcs = _filter_tool_calls(msg.tool_calls) + if _tcs: + entry["tool_calls"] = _tcs + else: + # All tool_calls were synthetic and dropped; + # skip if there's no surviving content either. + _entry_content = entry.get("content") + _has_text = ( + isinstance(_entry_content, str) and _entry_content.strip() + ) or ( + isinstance(_entry_content, list) and len(_entry_content) > 0 + ) + if not _has_text: + continue + if msg.role == "tool": + if msg.tool_call_id: + entry["tool_call_id"] = msg.tool_call_id + if msg.name: + entry["name"] = msg.name + if emit_extra_content and msg.role == "assistant" and msg.extra_content: + entry["extra_content"] = msg.extra_content + result.append(entry) return result @@ -1912,6 +2111,7 @@ async def _proxy_to_external_provider( payload.messages, _supports_vision, provider_type = provider_type, + base_url = base_url, ) client = ExternalProviderClient( @@ -1920,6 +2120,14 @@ async def _proxy_to_external_provider( api_key = api_key, ) + # `top_k` defaults to 20 in ChatCompletionRequest because the local + # inference path expects an int, but the external-provider path + # should treat "field omitted from JSON" as "use provider default" + # so callers that send only model/messages do not silently get + # different sampling than before this PR. Pydantic's + # `model_fields_set` tracks explicit-vs-default per request. + _top_k_explicit = payload.top_k if "top_k" in payload.model_fields_set else None + async def _stream(): gen = client.stream_chat_completion( messages = chat_messages, @@ -1928,7 +2136,7 @@ async def _stream(): top_p = payload.top_p, max_tokens = payload.max_tokens, presence_penalty = payload.presence_penalty, - top_k = payload.top_k, + top_k = _top_k_explicit, enable_thinking = payload.enable_thinking, reasoning_effort = payload.reasoning_effort, enabled_tools = payload.enabled_tools, @@ -1937,6 +2145,8 @@ async def _stream(): anthropic_code_exec_container_id = payload.anthropic_code_exec_container_id, prompt_cache_ttl = payload.prompt_cache_ttl, compaction_threshold = payload.compaction_threshold, + tools = payload.tools, + tool_choice = payload.tool_choice, fast_mode = payload.fast_mode, stream = payload.stream, ) @@ -4480,7 +4690,17 @@ async def anthropic_messages( [m.model_dump() for m in payload.messages], payload.system, ) - openai_messages = _drop_empty_assistant_sentinels(openai_messages) + # Strip synthetic provider-side builtin tool history (web_search, + # web_fetch, code_execution, image_generation cards tagged with + # _server_tool or extra_content.google.native_part) before handing + # off to local llama-server. The local /v1/chat/completions and + # GGUF passthrough builders apply the same strip; without it an + # Anthropic /v1/messages caller replaying a prior provider-side + # tool_use forwards fake builtin tool history to a backend that + # has no matching function declarations. + openai_messages = _strip_provider_synthetic_tool_history( + _drop_empty_assistant_sentinels(openai_messages) + ) # Enforce vision guard + re-encode embedded images to PNG so the # Anthropic endpoint matches the behavior of /v1/chat/completions. @@ -5271,6 +5491,110 @@ def _drop_empty_assistant_sentinels(messages: list[dict]) -> list[dict]: return out +_LOCAL_SERVER_BUILTIN_TOOL_NAMES = frozenset( + {"web_search", "web_fetch", "code_execution", "image_generation"} +) + + +def _strip_provider_synthetic_tool_history(messages: list[dict]) -> list[dict]: + """Drop synthetic provider-side tool_calls + matching role=tool replies + on the local-backend (llama-server / GGUF) dispatch path. + + A Gemini chat that ran code_execution / image_generation persists the + server-side tool card into thread history as an assistant tool_calls + entry tagged with ``args._server_tool`` (or a Gemini + ``args.google.native_part`` payload) plus a follow-up role=tool reply. + When the user switches the SAME thread to a local GGUF model, those + synthetic tool_calls are not real user functions, llama-server has no + matching declaration, and Gemini-only ``extra_content`` / + ``native_part`` payloads are meaningless. Forward only ordinary user + function calls; strip the matched role=tool replies too so the + backend does not see an orphan tool_call_id. + """ + dropped_ids: set[str] = set() + sanitized_assistant: list[dict] = [] + for m in messages: + if m.get("role") != "assistant": + sanitized_assistant.append(m) + continue + tool_calls = m.get("tool_calls") + if not isinstance(tool_calls, list) or not tool_calls: + # Plain text Gemini reply: still strip message-level + # `extra_content` (carries `google.thought_signature` replay + # metadata) so a text-only Gemini turn switched to a local + # GGUF backend does not leak Gemini-only fields to + # llama-server. ChatMessage previously did not have + # `extra_content`, so the field was implicitly dropped -- + # round-22 added it to ChatMessage, which is what made this + # leak possible. + if "extra_content" in m: + m = {k: v for k, v in m.items() if k != "extra_content"} + sanitized_assistant.append(m) + continue + cleaned: list[dict] = [] + for tc in tool_calls: + if not isinstance(tc, dict): + cleaned.append(tc) + continue + fn = tc.get("function") + name = "" + if isinstance(fn, dict): + name = (fn.get("name") or "").lower() + if name in _LOCAL_SERVER_BUILTIN_TOOL_NAMES: + raw_args = fn.get("arguments") if isinstance(fn, dict) else None + args_obj: Any = None + if isinstance(raw_args, str): + try: + args_obj = json.loads(raw_args) if raw_args else None + except Exception: + args_obj = None + elif isinstance(raw_args, dict): + args_obj = raw_args + is_synthetic = False + if isinstance(args_obj, dict): + if args_obj.get("_server_tool") is True: + is_synthetic = True + google = args_obj.get("google") + if isinstance(google, dict) and isinstance( + google.get("native_part"), dict + ): + is_synthetic = True + if is_synthetic: + tc_id = tc.get("id") + if isinstance(tc_id, str) and tc_id: + dropped_ids.add(tc_id) + continue + # Strip Gemini-only `extra_content` on real user tool_calls + # too — llama-server has no use for it and may pass it + # through to the model unchanged. + if "extra_content" in tc: + tc = {k: v for k, v in tc.items() if k != "extra_content"} + cleaned.append(tc) + # Drop top-level message-level `extra_content` (Gemini + # thoughtSignature replay metadata) on local dispatch. + m_clean = {k: v for k, v in m.items() if k != "extra_content"} + if cleaned: + m_clean["tool_calls"] = cleaned + else: + m_clean.pop("tool_calls", None) + if not m_clean.get("content") and not m_clean.get("tool_calls"): + continue # assistant turn now empty, drop + sanitized_assistant.append(m_clean) + + if not dropped_ids: + return sanitized_assistant + out: list[dict] = [] + for m in sanitized_assistant: + if ( + m.get("role") == "tool" + and isinstance(m.get("tool_call_id"), str) + and m["tool_call_id"] in dropped_ids + ): + continue + out.append(m) + return out + + def _openai_messages_for_passthrough(payload) -> list[dict]: """Build OpenAI-format message dicts for the /v1/chat/completions passthrough path. @@ -5287,8 +5611,10 @@ def _openai_messages_for_passthrough(payload) -> list[dict]: ``image_url`` content part so vision + function-calling requests work transparently. """ - messages = _drop_empty_assistant_sentinels( - [m.model_dump(exclude_none = True) for m in payload.messages] + messages = _strip_provider_synthetic_tool_history( + _drop_empty_assistant_sentinels( + [m.model_dump(exclude_none = True) for m in payload.messages] + ) ) if not payload.image_base64: @@ -5337,8 +5663,10 @@ def _openai_messages_for_gguf_chat(payload, is_vision: bool) -> tuple[list[dict] all per-turn ``image_url`` parts so multi-image chat history keeps each image attached to its original turn. """ - messages = _drop_empty_assistant_sentinels( - [m.model_dump(exclude_none = True) for m in payload.messages] + messages = _strip_provider_synthetic_tool_history( + _drop_empty_assistant_sentinels( + [m.model_dump(exclude_none = True) for m in payload.messages] + ) ) has_message_image = any( isinstance(msg.get("content"), list) diff --git a/studio/backend/routes/providers.py b/studio/backend/routes/providers.py index 2bb1de53668..5d4bd46e62c 100644 --- a/studio/backend/routes/providers.py +++ b/studio/backend/routes/providers.py @@ -318,22 +318,45 @@ async def list_provider_models( try: models = await client.list_models() - allow_prefixes = info.get("model_id_allow_prefixes") - if allow_prefixes is not None: - prefix_tuple = tuple(str(p) for p in allow_prefixes if str(p)) - if prefix_tuple: - models = [m for m in models if m.get("id", "").startswith(prefix_tuple)] - allowlist = info.get("model_id_allowlist") - if allowlist is not None: - models = [m for m in models if allowlist.match(m.get("id", ""))] - deny_exact = info.get("model_id_deny_exact") - if deny_exact is not None: - deny_ids = {str(m) for m in deny_exact if str(m)} - if deny_ids: - models = [m for m in models if m.get("id", "") not in deny_ids] - denylist = info.get("model_id_denylist") - if denylist is not None: - models = [m for m in models if not denylist.search(m.get("id", ""))] + # Registry-level model-id filters are scoped to the canonical + # native Gemini base. A custom Gemini OAI-compatible proxy + # (LiteLLM, deployment gateway) returns IDs like + # `google/gemini-2.5-flash`, `gemini/gemini-2.5-flash`, or + # team-prefixed deployment aliases; the native allowlist regex + # would strip those out and leave the picker empty even though + # the chat path now routes them via the OAI-compatible + # dispatcher (the same gate ExternalProviderClient applies for + # request building). Match the host check here so the model + # list and chat dispatch agree on what counts as "native". + apply_registry_model_filters = True + if payload.provider_type == "gemini": + try: + from urllib.parse import urlparse as _urlparse + + _host = (_urlparse(base_url).hostname or "").lower() + except Exception: + _host = "" + apply_registry_model_filters = _host == "generativelanguage.googleapis.com" + + if apply_registry_model_filters: + allow_prefixes = info.get("model_id_allow_prefixes") + if allow_prefixes is not None: + prefix_tuple = tuple(str(p) for p in allow_prefixes if str(p)) + if prefix_tuple: + models = [ + m for m in models if m.get("id", "").startswith(prefix_tuple) + ] + allowlist = info.get("model_id_allowlist") + if allowlist is not None: + models = [m for m in models if allowlist.match(m.get("id", ""))] + deny_exact = info.get("model_id_deny_exact") + if deny_exact is not None: + deny_ids = {str(m) for m in deny_exact if str(m)} + if deny_ids: + models = [m for m in models if m.get("id", "") not in deny_ids] + denylist = info.get("model_id_denylist") + if denylist is not None: + models = [m for m in models if not denylist.search(m.get("id", ""))] # Apply an optional cap after filtering so registry entries with a # large remote catalog (e.g. HF Inference Providers) can stay # picker-sized. No popularity sort happens server-side, so this is diff --git a/studio/backend/tests/test_anthropic_code_execution.py b/studio/backend/tests/test_anthropic_code_execution.py index 7f6fe583290..5c88437d175 100644 --- a/studio/backend/tests/test_anthropic_code_execution.py +++ b/studio/backend/tests/test_anthropic_code_execution.py @@ -275,7 +275,13 @@ async def run(): assert start["type"] == "tool_start" assert start["tool_name"] == "code_execution" assert start["tool_call_id"] == "srvtoolu_1" - assert start["arguments"] == {"kind": "bash", "command": "ls -la"} + # `_server_tool: True` marks this as a provider-side synthetic + # tool card for the frontend's history serializer. + assert start["arguments"] == { + "kind": "bash", + "command": "ls -la", + "_server_tool": True, + } assert end["type"] == "tool_end" assert end["tool_call_id"] == "srvtoolu_1" diff --git a/studio/backend/tests/test_anthropic_web_fetch.py b/studio/backend/tests/test_anthropic_web_fetch.py index da10d679ebc..88a922e7eb8 100644 --- a/studio/backend/tests/test_anthropic_web_fetch.py +++ b/studio/backend/tests/test_anthropic_web_fetch.py @@ -271,7 +271,12 @@ async def run(): assert start["type"] == "tool_start" assert start["tool_name"] == "web_fetch" assert start["tool_call_id"] == "srvtoolu_wf1" - assert start["arguments"] == {"url": "https://example.com/article"} + # `_server_tool: True` marks this as a provider-side synthetic + # tool card for the frontend's history serializer. + assert start["arguments"] == { + "url": "https://example.com/article", + "_server_tool": True, + } assert end["type"] == "tool_end" assert end["tool_call_id"] == "srvtoolu_wf1" # The source pill uses Title / URL / snippet as parseSourcesFromResult expects. diff --git a/studio/backend/tests/test_gemini_provider.py b/studio/backend/tests/test_gemini_provider.py new file mode 100644 index 00000000000..4dc97302e23 --- /dev/null +++ b/studio/backend/tests/test_gemini_provider.py @@ -0,0 +1,5501 @@ +# 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 +using httpx.MockTransport (no live network). Mirrors the structure of +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): + # Create a 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 the + # `Response.aiter_*.aclose was never awaited` warning 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` will aclose this at the end of the run inside the same + # event loop so we do not 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 can complete. + 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 + + # And the default of 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 migrated to 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 entirely + (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` is an alias for the Pro image model and + 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 (enabled_tools has no + image_generation), 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 do not benefit from a 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 image models + 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 part of the Gemini 3 family and uses + thinkingLevel (not thinkingBudget). "Off" maps to "low" because 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 carrying the legacy `/v1beta/openai` base + (from the 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 trigger tool execution.""" + 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 the value 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 + that signature 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 outbound + request, the inbound stream must NOT emit web_search tool_start / + tool_end (otherwise the UI shows a misleading 'Search complete' + card on a turn where Gemini never actually searched).""" + sse = [ + { + "candidates": [ + { + "content": {"role": "model", "parts": [{"text": "drawn"}]}, + "finishReason": "STOP", + } + ] + } + ] + lines = _collect( + monkeypatch, + sse, + model = "gemini-2.5-flash-image", + enabled_tools = ["image_generation", "web_search", "code_execution"], + ) + chunks = _parse_chunks(lines) + tool_evs = [ + ev + for c in chunks + for ev in [c.get("_toolEvent")] + if isinstance(ev, dict) and ev.get("tool_name") == "web_search" + ] + assert tool_evs == [], tool_evs + + +def test_image_generation_tool_on_image_model_drops_text_tools(monkeypatch): + """`enabled_tools=["image_generation", "web_search", "code_execution"]` + on a Gemini IMAGE model flips responseModalities to TEXT+IMAGE; in + that mode codeExecution must NOT be forwarded (Gemini rejects text + code tools alongside image responseModalities). Older image + families also drop googleSearch.""" + captured = _capture_body( + monkeypatch, + model = "gemini-2.5-flash-image", + enabled_tools = [ + "image_generation", + "web_search", + "code_execution", + ], + ) + assert "tools" not in captured["body"], captured["body"] + assert captured["body"]["generationConfig"].get("responseModalities") == [ + "TEXT", + "IMAGE", + ] + + +def test_prompt_feedback_block_reason_surfaces_as_error(monkeypatch): + """`promptFeedback.blockReason` with zero candidates must produce + an error chunk, not a silent empty assistant reply.""" + sse = [ + { + "promptFeedback": {"blockReason": "SAFETY"}, + } + ] + chunks = _parse_chunks(_collect(monkeypatch, sse)) + error_chunks = [c for c in chunks if "error" in c] + assert error_chunks, chunks + assert "SAFETY" in ( + error_chunks[0].get("error", {}).get("message") or "" + ), error_chunks + + +def test_usage_chunk_includes_thoughts_tokens(monkeypatch): + """`thoughtsTokenCount` is the hidden-reasoning slice of output; + roll it into `output_tokens` AND surface it on + `output_tokens_details.reasoning_tokens` so total_tokens reflects + the full billable spend.""" + sse = [ + { + "candidates": [ + { + "content": {"role": "model", "parts": [{"text": "ok"}]}, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 5, + "thoughtsTokenCount": 20, + "totalTokenCount": 35, + }, + } + ] + chunks = _parse_chunks(_collect(monkeypatch, sse)) + usage_chunk = next((c for c in chunks if isinstance(c.get("usage"), dict)), None) + assert usage_chunk is not None, chunks + usage = usage_chunk["usage"] + assert usage.get("prompt_tokens") == 10, usage + # candidates 5 + thoughts 20 = 25 output tokens; total = 35. + assert usage.get("completion_tokens") == 25, usage + assert usage.get("total_tokens") == 35, usage + + +# ── web_search forwarded as googleSearch tool ──────────────────────── + + +def test_web_search_forwarded_as_google_search_tool(monkeypatch): + captured = _capture_body( + monkeypatch, + enabled_tools = ["web_search"], + ) + tools = captured["body"].get("tools") or [] + assert {"googleSearch": {}} in tools, tools + + +def test_code_execution_forwarded_as_code_execution_tool(monkeypatch): + captured = _capture_body( + monkeypatch, + enabled_tools = ["code_execution"], + ) + tools = captured["body"].get("tools") or [] + assert {"codeExecution": {}} in tools, tools + + +def test_omitted_tools_leaves_body_untouched(monkeypatch): + captured = _capture_body(monkeypatch, enabled_tools = []) + assert "tools" not in captured["body"], captured["body"] + + +# ── prompt caching passthrough ─────────────────────────────────────── + + +def test_cached_content_pass_through(monkeypatch): + """A string cache id on enable_prompt_caching is forwarded verbatim.""" + cache_name = "cachedContents/abc123" + captured = _capture_body( + monkeypatch, + enable_prompt_caching = cache_name, + ) + assert captured["body"].get("cachedContent") == cache_name + + +def test_boolean_caching_does_not_set_cached_content(monkeypatch): + """Studio's existing True/False signals shouldn't fabricate a cache id.""" + captured = _capture_body(monkeypatch, enable_prompt_caching = True) + assert "cachedContent" not in captured["body"] + + +# ── image generation: request modalities + response translation ────── + + +def test_image_model_sets_response_modalities(monkeypatch): + captured = _capture_body( + monkeypatch, + model = "gemini-2.5-flash-image", + enabled_tools = ["image_generation"], + ) + assert captured["body"]["generationConfig"]["responseModalities"] == [ + "TEXT", + "IMAGE", + ] + + +def test_image_generation_tool_sets_response_modalities_on_image_model(monkeypatch): + """`enabled_tools=["image_generation"]` flips responseModalities + only when the selected model is image-capable; otherwise the + request stays plain text (text-only models 400 on + responseModalities).""" + captured = _capture_body( + monkeypatch, + model = "gemini-2.5-flash-image", + enabled_tools = ["image_generation"], + ) + assert captured["body"]["generationConfig"]["responseModalities"] == [ + "TEXT", + "IMAGE", + ] + + +def test_image_response_emits_image_b64_tool_event(monkeypatch): + """`inlineData` parts become a tool_end with image_b64 + image_mime.""" + fake_b64 = base64.b64encode(b"PNG-BYTES").decode() + sse = [ + { + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "inlineData": { + "mimeType": "image/png", + "data": fake_b64, + } + } + ], + }, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 5, + "candidatesTokenCount": 0, + }, + } + ] + lines = _collect( + monkeypatch, + sse, + model = "gemini-2.5-flash-image", + ) + chunks = _parse_chunks(lines) + tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c] + starts = [e for e in tool_events if e.get("type") == "tool_start"] + ends = [e for e in tool_events if e.get("type") == "tool_end"] + image_starts = [e for e in starts if e.get("tool_name") == "image_generation"] + image_ends = [e for e in ends if e.get("image_b64")] + assert len(image_starts) == 1, tool_events + assert len(image_ends) == 1, tool_events + assert image_ends[0]["image_b64"] == fake_b64 + assert image_ends[0]["image_mime"] == "image/png" + + +# ── function calling round-trips both directions ───────────────────── + + +def test_function_call_response_translates_to_tool_calls_delta(monkeypatch): + """Gemini `functionCall` parts become OpenAI `tool_calls` delta chunks.""" + sse = [ + { + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "get_weather", + "args": {"location": "Paris"}, + } + } + ], + }, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 12, + "candidatesTokenCount": 4, + }, + } + ] + lines = _collect(monkeypatch, sse) + chunks = _parse_chunks(lines) + tool_call_chunks = [ + c + for c in chunks + if "_toolEvent" not in c + and any( + (isinstance(ch.get("delta"), dict) and "tool_calls" in ch["delta"]) + for ch in c.get("choices", []) + ) + ] + assert len(tool_call_chunks) == 1, chunks + tc = tool_call_chunks[0]["choices"][0]["delta"]["tool_calls"][0] + assert tc["function"]["name"] == "get_weather" + args = json.loads(tc["function"]["arguments"]) + assert args == {"location": "Paris"} + + +def test_tool_message_translates_to_function_response_part(monkeypatch): + """role=tool follow-ups are rewritten to functionResponse parts.""" + messages = [ + {"role": "user", "content": "Weather?"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": json.dumps({"location": "Paris"}), + }, + } + ], + }, + { + "role": "tool", + "name": "get_weather", + "content": json.dumps({"temp_c": 18, "summary": "Sunny"}), + }, + ] + captured = _capture_body(monkeypatch, messages = messages) + contents = captured["body"]["contents"] + # Last turn must be a functionResponse part (Gemini wraps it as a + # role=user turn carrying the result). + last = contents[-1] + assert last["role"] == "user", last + fr = last["parts"][0].get("functionResponse") + assert fr is not None, last + assert fr["name"] == "get_weather" + assert fr["response"] == {"temp_c": 18, "summary": "Sunny"} + # And the assistant turn carries the original functionCall so the + # model sees the round-trip context. + assistant_turn = [c for c in contents if c["role"] == "model"][0] + fc_part = next( + (p for p in assistant_turn["parts"] if "functionCall" in p), + None, + ) + assert fc_part is not None, assistant_turn + assert fc_part["functionCall"]["name"] == "get_weather" + assert fc_part["functionCall"]["args"] == {"location": "Paris"} + + +def test_parallel_function_calls_get_distinct_tool_call_indices(monkeypatch): + """Each emitted functionCall in one assistant turn needs its own + tool_calls[*].index. Hardcoding index=0 collapses parallel calls + onto a single 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 the same tool_call_id so the + # frontend pairs them onto a single 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 that signature + 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 (that path is 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 instead of forwarding 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 -- otherwise 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 does not 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 now 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 changed native_part to 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 spawning + 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 containing 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..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) as well, 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, and 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` that contains + `children: [Node]`) must not infinite-loop. The inliner tracks the + set of refs in flight and short-circuits to `{}` on a cycle.""" + captured = _capture_body( + monkeypatch, + tools = [ + { + "type": "function", + "function": { + "name": "set_tree", + "parameters": { + "type": "object", + "$defs": { + "Node": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "children": { + "type": "array", + "items": {"$ref": "#/$defs/Node"}, + }, + }, + }, + }, + "properties": { + "root": {"$ref": "#/$defs/Node"}, + }, + }, + }, + } + ], + ) + tools_arr = captured["body"].get("tools") or [] + decls = next( + ( + t.get("functionDeclarations") + for t in tools_arr + if "functionDeclarations" in t + ), + None, + ) + assert decls is not None + root = decls[0]["parameters"]["properties"]["root"] + assert root.get("type") == "object" + assert root.get("properties", {}).get("value", {}).get("type") == "string" + + +def test_gemini_native_skips_orphan_function_response_for_dropped_builtin( + monkeypatch, +): + """Round 26: when the assistant-side synthetic web_search/web_fetch + tool_call is dropped from native Gemini history, the matching + role="tool" follow-up must also be dropped. Otherwise the outbound + body carries an orphan functionResponse with no preceding + functionCall, which 400s the Gemini turn.""" + from models.inference import ChatCompletionRequest + from routes.inference import _build_external_messages + + req = ChatCompletionRequest.model_validate( + { + "model": "gemini-2.5-flash", + "messages": [ + {"role": "user", "content": "search please"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_s", + "type": "function", + "function": { + "name": "web_search", + "arguments": ('{"_server_tool": true, "query": "x"}'), + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_s", + "content": "[search result]", + }, + {"role": "user", "content": "again"}, + ], + "max_tokens": 64, + "stream": True, + } + ) + built = _build_external_messages( + req.messages, + supports_vision = True, + provider_type = "gemini", + base_url = "https://generativelanguage.googleapis.com/v1beta", + ) + captured = _capture_body(monkeypatch, messages = built) + contents = captured["body"].get("contents") or [] + for entry in contents: + for part in entry.get("parts", []): + fr = part.get("functionResponse") + if isinstance(fr, dict): + assert fr.get("name") != "web_search", contents + + +def test_gemini_native_skips_orphan_function_response_for_native_part_replay( + monkeypatch, +): + """Round 26: code_execution / image_generation tool_calls are + replayed as Gemini-native executableCode / codeExecutionResult / + inlineData parts. The matching role="tool" follow-up must NOT then + be emitted as a functionResponse named code_execution -- there is + no declared user function with that name, and Gemini's history + rules already attribute the result to the native parts above.""" + captured = _capture_body( + monkeypatch, + messages = [ + {"role": "user", "content": "plot something"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_a", + "type": "function", + "function": { + "name": "code_execution", + "arguments": "{}", + }, + "extra_content": { + "google": { + "native_part": { + "parts": [ + { + "executableCode": { + "language": "PYTHON", + "code": "print(2)", + } + }, + { + "codeExecutionResult": { + "outcome": "OUTCOME_OK", + "output": "2\n", + } + }, + ] + } + } + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_a", + "name": "code_execution", + "content": "2", + }, + {"role": "user", "content": "next"}, + ], + ) + contents = captured["body"].get("contents") or [] + saw_native = False + for entry in contents: + for part in entry.get("parts", []): + if "executableCode" in part or "codeExecutionResult" in part: + saw_native = True + fr = part.get("functionResponse") + if isinstance(fr, dict): + assert fr.get("name") != "code_execution", contents + assert saw_native, contents + + +def test_gemini_native_part_falls_back_to_args_google(monkeypatch): + """Round 27: a direct OpenAI-compat API caller (or imported third- + party thread) cannot use Studio's non-standard + `tool_calls[].extra_content` field, so the native_part payload + round-trips through `function.arguments` as + `{"google": {"native_part": {...}}}`. The synthetic-builtin + detector recognizes that location, but the replay branch was only + reading from `tc.extra_content.google.native_part`. Result: the + round-25 guard saw a synthetic builtin with no _native_part and + dropped the entire assistant turn, losing the prior code/image + context. The translator must fall back to args.google.native_part + and still emit the native executableCode / inlineData parts.""" + import json as _json + + captured = _capture_body( + monkeypatch, + messages = [ + {"role": "user", "content": "draw a cat"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_img", + "type": "function", + "function": { + "name": "image_generation", + "arguments": _json.dumps( + { + "google": { + "native_part": { + "parts": [ + { + "inlineData": { + "mimeType": "image/png", + "data": "AAAA", + } + } + ] + } + } + } + ), + }, + } + ], + }, + {"role": "user", "content": "now make it a dog"}, + ], + ) + contents = captured["body"].get("contents") or [] + saw_inline = False + for entry in contents: + for part in entry.get("parts", []): + if "inlineData" in part: + saw_inline = True + assert saw_inline, contents + + +def test_gemini_native_skips_synthetic_server_builtin_replay(monkeypatch): + """Round 25: Marked server-side builtin tool_calls (web_search / + web_fetch with `_server_tool` or `args.google.native_part`) must + not fall through to the generic Gemini `functionCall` replay path + when no replayable native part exists. Without this guard the + outbound body contains a fake `functionCall` whose name is not 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 the field 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 field 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 because 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 show parallel functionResponses grouped + 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 id rejects text-only tools and + thinkingConfig at the model level regardless of whether the Images + pill is on. 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 the validate + step 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" + # The 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`; it must be + routed through the safe-fetch path. Previously the 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": { + # Looks like a Files API URL in the path + # but the 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*` (including `-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 does not 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 happen to contain `_server_tool: + true` UNLESS the function name is also one of the canonical + builtin names. 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 (matching 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 outbound 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=`.""" + 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 being + forwarded. The OpenAI `role="tool"` follow-up must become a + `role:"user"` message with a `tool_result` content 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) just 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 does not 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 the expected behavior.""" + 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, which broke 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 is 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. Forwarding + `{"role":"assistant","content":""}` is rejected by several + providers 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 + # cannot 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 outbound 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 outbound + 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 + placed 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"] + # Locate 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 compatibility 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 after the fact.""" + 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. This test is + 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" 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 the same way it does for Gemini. Pinning a user + function (`tool_choice={"type":"function","function":{"name":...}}`) + while also 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 should be in the body — only the caller's user- + # function declarations (which this test doesn't pass any of). + 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, OpenRouter must not + still 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) the same way 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 something to 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 this, 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 previous 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 be passed through; the native filter would strip the + prefixed IDs even though the 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 — the native allowlist was 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] + # The 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 was not 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 diff --git a/studio/backend/tests/test_openai_code_execution.py b/studio/backend/tests/test_openai_code_execution.py index 3d179371e34..0b1a65c69e7 100644 --- a/studio/backend/tests/test_openai_code_execution.py +++ b/studio/backend/tests/test_openai_code_execution.py @@ -269,7 +269,15 @@ async def run(): assert len(ends) == 1 assert starts[0]["tool_name"] == "code_execution" assert starts[0]["tool_call_id"] == "scall_1" - assert starts[0]["arguments"] == {"kind": "bash", "command": "ls -la"} + # `_server_tool: True` is the synthetic-builtin marker the + # backend stamps onto every provider-side tool_start so the + # frontend serializer can distinguish hosted tools from + # user-declared functions on history replay. + assert starts[0]["arguments"] == { + "kind": "bash", + "command": "ls -la", + "_server_tool": True, + } assert ends[0]["tool_call_id"] == "scall_1" assert "total 24" in ends[0]["result"] diff --git a/studio/backend/tests/test_openai_image_generation.py b/studio/backend/tests/test_openai_image_generation.py index 6d415316d4e..f5dee2561a3 100644 --- a/studio/backend/tests/test_openai_image_generation.py +++ b/studio/backend/tests/test_openai_image_generation.py @@ -207,9 +207,12 @@ def test_image_generation_done_emits_tool_event_chunks(monkeypatch): ends = [e for e in image_events if e.get("type") == "tool_end"] assert len(starts) == 1, image_events assert len(ends) == 1, image_events + # `_server_tool: True` marks this as a provider-side synthetic + # tool card on the frontend's history serializer. assert starts[0]["arguments"] == { "kind": "image", "prompt": "A photorealistic cat sitting", + "_server_tool": True, "openai_image_generation_call_id": "img_abc", } assert ends[0]["image_b64"] == "AAAA" diff --git a/studio/backend/tests/test_openai_responses_translation.py b/studio/backend/tests/test_openai_responses_translation.py index 22ccba70588..f177ed5ef3e 100644 --- a/studio/backend/tests/test_openai_responses_translation.py +++ b/studio/backend/tests/test_openai_responses_translation.py @@ -215,6 +215,254 @@ async def run(): assert payloads[-1] == "[DONE]" +def test_responses_function_call_output_translates_to_delta_tool_calls(monkeypatch): + """Round 12: caller-supplied function tools forwarded into /v1/responses + must have their `function_call` output items translated back into Chat + Completions delta.tool_calls, and the terminal chunk must emit + finish_reason="tool_calls" so the frontend's accumulator runs the + function instead of seeing finish_reason="stop".""" + + def handler(request: httpx.Request) -> httpx.Response: + events = [ + {"type": "response.created"}, + { + "type": "response.output_item.done", + "item": { + "type": "function_call", + "id": "fc_abc", + "call_id": "call_xyz", + "name": "get_weather", + "arguments": '{"city":"SF"}', + }, + }, + {"type": "response.completed", "response": {}}, + ] + return httpx.Response( + 200, + content = _responses_sse(events), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + lines = await _collect( + client._stream_openai_responses( + messages = [{"role": "user", "content": "weather?"}], + model = "gpt-5.5", + temperature = 0.7, + top_p = 0.95, + max_tokens = None, + enable_thinking = None, + reasoning_effort = None, + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + }, + } + ], + ) + ) + await client.close() + return lines + + lines = _drive(run()) + payloads = [ + json.loads(line[len("data:") :].strip()) + for line in lines + if line.startswith("data:") and line[len("data:") :].strip() != "[DONE]" + ] + tool_call_deltas = [ + p + for p in payloads + if isinstance(p, dict) + and p.get("choices") + and p["choices"][0].get("delta", {}).get("tool_calls") + ] + assert tool_call_deltas, payloads + tc = tool_call_deltas[0]["choices"][0]["delta"]["tool_calls"][0] + assert tc["id"] == "call_xyz" + assert tc["function"]["name"] == "get_weather" + assert tc["function"]["arguments"] == '{"city":"SF"}' + # Final chunk reports tool_calls instead of stop. + terminal = next( + p + for p in payloads + if isinstance(p, dict) + and p.get("choices") + and p["choices"][0].get("finish_reason") in ("stop", "tool_calls") + ) + assert terminal["choices"][0]["finish_reason"] == "tool_calls", payloads + + +def test_responses_parallel_function_calls_get_distinct_indices(monkeypatch): + """Round 13: parallel function_call items must land on distinct + delta.tool_calls[].index slots so index-keyed clients don't + collapse the second call into the first.""" + + def handler(request: httpx.Request) -> httpx.Response: + events = [ + {"type": "response.created"}, + { + "type": "response.output_item.done", + "item": { + "type": "function_call", + "id": "fc_a", + "call_id": "call_a", + "name": "lookup_a", + "arguments": "{}", + }, + }, + { + "type": "response.output_item.done", + "item": { + "type": "function_call", + "id": "fc_b", + "call_id": "call_b", + "name": "lookup_b", + "arguments": "{}", + }, + }, + {"type": "response.completed", "response": {}}, + ] + return httpx.Response( + 200, + content = _responses_sse(events), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + lines = await _collect( + client._stream_openai_responses( + messages = [{"role": "user", "content": "x"}], + model = "gpt-5.5", + temperature = 0.7, + top_p = 0.95, + max_tokens = None, + enable_thinking = None, + reasoning_effort = None, + tools = [ + { + "type": "function", + "function": { + "name": "lookup_a", + "parameters": {"type": "object"}, + }, + }, + { + "type": "function", + "function": { + "name": "lookup_b", + "parameters": {"type": "object"}, + }, + }, + ], + ) + ) + await client.close() + return lines + + lines = _drive(run()) + indices: list[int] = [] + for raw in lines: + if not raw.startswith("data:"): + continue + payload = raw[len("data:") :].strip() + if payload == "[DONE]": + continue + try: + obj = json.loads(payload) + except Exception: + continue + delta = (obj.get("choices") or [{}])[0].get("delta") or {} + for tc in delta.get("tool_calls") or []: + indices.append(tc.get("index")) + assert indices == [0, 1], indices + + +def test_responses_follow_up_tool_result_uses_function_call_output_items(monkeypatch): + """Round 13: a second turn after a Responses function call must + serialize the tool_calls history and tool result as Responses + `function_call` / `function_call_output` input items, not as + Chat Completions role="tool" content.""" + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _responses_sse( + [ + {"type": "response.created"}, + {"type": "response.completed", "response": {}}, + ] + ), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + await _collect( + client._stream_openai_responses( + messages = [ + {"role": "user", "content": "weather?"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_xyz", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city":"SF"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_xyz", + "content": "sunny", + }, + {"role": "user", "content": "thanks"}, + ], + model = "gpt-5.5", + temperature = 0.7, + top_p = 0.95, + max_tokens = None, + enable_thinking = None, + reasoning_effort = None, + ) + ) + await client.close() + + _drive(run()) + items = captured["body"]["input"] + types = [it.get("type") or it.get("role") for it in items] + assert "function_call" in types, items + assert "function_call_output" in types, items + fc = next(it for it in items if it.get("type") == "function_call") + assert fc["call_id"] == "call_xyz" + assert fc["name"] == "get_weather" + assert fc["arguments"] == '{"city":"SF"}' + fco = next(it for it in items if it.get("type") == "function_call_output") + assert fco["call_id"] == "call_xyz" + assert fco["output"] == "sunny" + + def test_responses_response_incomplete_maps_to_length_finish_reason(monkeypatch): def handler(request: httpx.Request) -> httpx.Response: events = [ diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index a5bb4029d15..51cf0008636 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -802,6 +802,7 @@ const ReasoningToggle: FC = () => { { isReasoningProvider: selectedExternalProvider?.isReasoningModel === true, + baseUrl: selectedExternalProvider?.baseUrl ?? null, }, ) : null; diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index ce59429762c..2ef14b5326c 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -25,6 +25,7 @@ import { getExternalMinOutputTokens, getExternalReasoningCapabilities, getProviderCapabilities, + isGeminiCustomOpenAICompatBase, providerSupportsBuiltinCodeExecution, providerSupportsBuiltinImageGeneration, providerSupportsBuiltinWebFetch, @@ -105,6 +106,33 @@ type RunMessage = RunMessages[number]; /** Tracks which user messages were sent with an audio file (messageId → filename). */ export const sentAudioNames = new Map(); +// Synthetic provider-side tool names; backend stamps args._server_tool +// so user functions with the same name aren't dropped. Mirror of +// _SERVER_SIDE_BUILTIN_TOOL_NAMES on the backend. +const SERVER_SIDE_BUILTIN_TOOL_NAMES = new Set([ + "web_search", + "web_fetch", + "code_execution", + "image_generation", +]); + +/** + * Whether a persisted tool-call part is provider-side synthetic and + * should be stripped from outbound history. Match on the + * args._server_tool marker or a Gemini native_part payload — no shape + * heuristic, because user functions can legitimately share a name. + */ +function isServerSideBuiltinToolPart( + toolNameLower: string, + _argsObj: Record | null, + hasServerToolMarker: boolean, + hasNativePart: boolean, +): boolean { + if (!SERVER_SIDE_BUILTIN_TOOL_NAMES.has(toolNameLower)) return false; + if (hasServerToolMarker) return true; + return hasNativePart; +} + /** * Match error messages that indicate the request filled or would fill * the KV cache, so the UI can show a dedicated toast pointing at the @@ -508,21 +536,200 @@ function isAnthropicRefusalMessage(message: RunMessage): boolean { return metadata?.custom?.anthropicRefusal === true; } -function toOpenAIMessage(message: RunMessage): { - role: "system" | "user" | "assistant"; - content: OpenAIMessageContent; -} | null { +function collectAssistantToolCalls( + message: RunMessage, +): Array<{ + id: string; + type: "function"; + function: { name: string; arguments: string }; + extra_content?: unknown; +}> { + const out: Array<{ + id: string; + type: "function"; + function: { name: string; arguments: string }; + extra_content?: unknown; + }> = []; + for (const part of message.content ?? []) { + if (part.type !== "tool-call") continue; + const tc = part as ToolCallMessagePart & { + argsText?: string; + extra_content?: unknown; + }; + const toolNameLower = (tc.toolName ?? "").toLowerCase(); + const argsObj = + tc.args && typeof tc.args === "object" + ? (tc.args as Record) + : null; + const argsGoogle = + argsObj && typeof argsObj.google === "object" && argsObj.google !== null + ? (argsObj.google as Record) + : null; + const hasNativePart = Boolean( + argsGoogle && + typeof argsGoogle.native_part === "object" && + argsGoogle.native_part !== null, + ); + const hasServerToolMarker = Boolean( + argsObj && (argsObj as Record)._server_tool === true, + ); + const isServerSideBuiltin = isServerSideBuiltinToolPart( + toolNameLower, + argsObj, + hasServerToolMarker, + hasNativePart, + ); + if (isServerSideBuiltin) { + // Gemini code_execution / image_generation still need to round- + // trip the native_part payload for native replay; drop the rest. + if (!hasNativePart) continue; + } + const argumentsStr = + typeof tc.argsText === "string" && tc.argsText.length > 0 + ? tc.argsText + : JSON.stringify(tc.args ?? {}); + const entry: { + id: string; + type: "function"; + function: { name: string; arguments: string }; + extra_content?: unknown; + } = { + id: tc.toolCallId, + type: "function" as const, + function: { + name: tc.toolName ?? "", + arguments: argumentsStr, + }, + }; + // Promote args.google to extra_content.google so the backend + // native_part replay branch can find it. The backend only inspects + // extra_content, not function.arguments. + if (tc.extra_content !== undefined) { + entry.extra_content = tc.extra_content; + } else if (argsGoogle) { + entry.extra_content = { google: argsGoogle }; + } + out.push(entry); + } + return out; +} + +function collectToolResultMessages( + message: RunMessage, +): Array<{ + role: "tool"; + content: string; + tool_call_id: string; + name?: string; +}> { + const out: Array<{ + role: "tool"; + content: string; + tool_call_id: string; + name?: string; + }> = []; + for (const part of message.content ?? []) { + if (part.type !== "tool-call") continue; + const tc = part as ToolCallMessagePart; + const result = (tc as { result?: unknown }).result; + // Skip provider-side builtins; see isServerSideBuiltinToolPart. + const argsObj = + tc.args && typeof tc.args === "object" + ? (tc.args as Record) + : null; + const argsGoogle = + argsObj && typeof argsObj.google === "object" && argsObj.google !== null + ? (argsObj.google as Record) + : null; + const toolNameLower = (tc.toolName ?? "").toLowerCase(); + const hasServerToolMarker = Boolean( + argsObj && argsObj._server_tool === true, + ); + const hasNativePart = Boolean( + argsGoogle && + typeof argsGoogle.native_part === "object" && + argsGoogle.native_part !== null, + ); + if ( + isServerSideBuiltinToolPart( + toolNameLower, + argsObj, + hasServerToolMarker, + hasNativePart, + ) + ) { + continue; + } + if (result === undefined || result === null) continue; + let content: string; + if (typeof result === "string") { + // Backend ChatMessage validator rejects role="tool" with empty + // content; serialise a sentinel JSON so legitimately empty tool + // outputs still round-trip the follow-up turn to the provider. + content = result.length > 0 ? result : JSON.stringify({ result: "" }); + } else { + try { + content = JSON.stringify(result); + } catch { + content = String(result); + } + } + out.push({ + role: "tool", + content, + tool_call_id: tc.toolCallId, + ...(tc.toolName ? { name: tc.toolName } : {}), + }); + } + return out; +} + +type SerializedMessage = { + role: "system" | "user" | "assistant" | "tool"; + content: OpenAIMessageContent | null; + tool_calls?: Array<{ + id: string; + type: "function"; + function: { name: string; arguments: string }; + extra_content?: unknown; + }>; + tool_call_id?: string; + name?: string; + /** + * Gemini text-part thoughtSignature stashed during streaming on the + * last text MessagePart. Backend reads + * `extra_content.google.thought_signature` and attaches it to the + * matching Gemini text part on the outbound turn. + */ + extra_content?: unknown; +}; + +function collectAssistantTextThoughtSignature( + message: RunMessage, +): string | undefined { + if (!Array.isArray(message.content)) return undefined; + for (let i = message.content.length - 1; i >= 0; i -= 1) { + const part = message.content[i] as { type?: string } & Record< + string, + unknown + >; + if (part?.type !== "text") continue; + const sig = part._google_thought_signature; + if (typeof sig === "string" && sig) return sig; + } + return undefined; +} + +function toOpenAIMessages(message: RunMessage): SerializedMessage[] { if ( message.role !== "system" && message.role !== "user" && message.role !== "assistant" ) { - return null; + return []; } let textContent = collectTextParts(message).join("\n"); - // Strip inline audio base64 from prior assistant messages to avoid - // inflating token counts (e.g. audio-player responses with embedded WAV). if (message.role === "assistant") { textContent = textContent.replace( /data:audio\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g, @@ -531,25 +738,70 @@ function toOpenAIMessage(message: RunMessage): { if (isAnthropicRefusalMessage(message)) { // Prune refused assistant turn from outbound history; the // rendered transcript still shows the user-visible notice. - return null; + return []; } } const imageParts = collectImageParts(message); - if (imageParts.length > 0) { - return { - role: message.role, - content: [ - ...(textContent ? [{ type: "text" as const, text: textContent }] : []), - ...imageParts, - ], - }; + const toolCalls = + message.role === "assistant" ? collectAssistantToolCalls(message) : []; + const toolResults = + message.role === "assistant" ? collectToolResultMessages(message) : []; + + const base: SerializedMessage = { + role: message.role, + content: + imageParts.length > 0 + ? [{ type: "text", text: textContent }, ...imageParts] + : textContent, + }; + if (toolCalls.length > 0) { + base.tool_calls = toolCalls; + // OpenAI requires content === null on assistant turns whose + // payload is entirely tool_calls (matches the wire shape Gemini + // expects for the next functionCall replay). + if (!textContent && imageParts.length === 0) { + base.content = null; + } + } + if (message.role === "assistant") { + const sig = collectAssistantTextThoughtSignature(message); + if (sig) { + base.extra_content = { google: { thought_signature: sig } }; + } } - if (!textContent) { + return toolResults.length > 0 ? [base, ...toolResults] : [base]; +} + +// Thin singular wrapper: returns only the first serialized message +// (without tool_calls or tool follow-ups) so the OpenAI image-edit +// replay path can map a thread to flat OpenAI chat messages without +// pulling in tool history. +function toOpenAIMessage(message: RunMessage): { + role: "system" | "user" | "assistant"; + content: OpenAIMessageContent; +} | null { + const serialized = toOpenAIMessages(message); + if (serialized.length === 0) return null; + const first = serialized[0]; + if ( + first.role !== "system" && + first.role !== "user" && + first.role !== "assistant" + ) { + return null; + } + if (first.content === null || first.content === undefined) { return null; } - return { role: message.role, content: textContent }; + if (typeof first.content === "string" && !first.content) { + return null; + } + return { + role: first.role, + content: first.content as OpenAIMessageContent, + }; } function extractImageBase64(input: string): string | undefined { @@ -1084,11 +1336,21 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { clearSelectedImageEditReference(); throw new Error("Connection not found."); } - // Local providers (llama.cpp / vLLM / Ollama) allow an empty key — only block hosted providers. + // Local providers and custom Gemini bases allow an empty key. const externalProviderIsCustom = externalProvider ? isCustomProviderType(externalProvider.providerType) : false; - if (isExternalRequest && !externalApiKey && !externalProviderIsCustom) { + const externalProviderIsGeminiCustomBase = Boolean( + externalProvider && + externalProvider.providerType === "gemini" && + isGeminiCustomOpenAICompatBase(externalProvider.baseUrl), + ); + if ( + isExternalRequest && + !externalApiKey && + !externalProviderIsCustom && + !externalProviderIsGeminiCustomBase + ) { toast.error("Missing API key for selected connection.", { description: "Open Settings → Connections and set the API key again.", }); @@ -1096,15 +1358,38 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { throw new Error("Missing connection API key."); } + // Image-generation flag (OpenAI cloud + Responses-capable model). + // Computed first so Gemini image mode can suppress Search/Code. + const imageGenerationEnabledForThisTurn = Boolean( + externalProvider && + externalSelection && + imageToolsEnabled && + providerSupportsBuiltinImageGeneration( + externalProvider.providerType, + externalSelection.modelId, + externalProvider.baseUrl, + ), + ); + // Per-model Search/Code allowances live in + // providerSupportsBuiltin*; this flag just signals image-mode. + const geminiImageModeForThisTurn = + externalProvider?.providerType === "gemini" && + imageGenerationEnabledForThisTurn; const webSearchEnabledForThisTurn = Boolean( externalProvider && + externalSelection && toolsEnabled && - providerSupportsBuiltinWebSearch(externalProvider.providerType), + providerSupportsBuiltinWebSearch( + externalProvider.providerType, + externalSelection.modelId, + externalProvider.baseUrl, + ), ); const codeExecEnabledForThisTurn = Boolean( externalProvider && externalSelection && codeToolsEnabled && + !geminiImageModeForThisTurn && providerSupportsBuiltinCodeExecution( externalProvider.providerType, externalSelection.modelId, @@ -1124,20 +1409,6 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { externalProvider && providerSupportsBuiltinWebFetch(externalProvider.providerType), ); - // OpenAI Responses-API image_generation server tool. Pill is - // gated on OpenAI cloud + a Responses-API model id; the backend - // additionally re-checks is_openai_cloud before appending - // {type:"image_generation"} to the request tools array. - const imageGenerationEnabledForThisTurn = Boolean( - externalProvider && - externalSelection && - imageToolsEnabled && - providerSupportsBuiltinImageGeneration( - externalProvider.providerType, - externalSelection.modelId, - externalProvider.baseUrl, - ), - ); if (selectedImageEditReference && !imageGenerationEnabledForThisTurn) { clearSelectedImageEditReference(); @@ -1148,11 +1419,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { throw new Error("Image generation edit unavailable."); } - // Two-pass build: a refused assistant turn also drops the user - // prompt that triggered it (leaving it in context re-triggers - // the classifier). Refusal flag rides assistant - // metadata.custom.anthropicRefusal, set out-of-band from the - // backend _toolEvent. + // Drop refused assistant turns + their triggering user prompt; + // otherwise context re-triggers the classifier. const survivingMessages: RunMessage[] = []; for (const message of messages) { if (isAnthropicRefusalMessage(message)) { @@ -1165,8 +1433,11 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { survivingMessages.push(message); } + // toOpenAIMessages emits assistant tool_calls + role="tool" + // follow-ups; the backend Gemini translator rebuilds the + // functionCall/functionResponse parts (with thoughtSignature). const outboundMessages = survivingMessages - .map(toOpenAIMessage) + .flatMap(toOpenAIMessages) .filter((message): message is NonNullable => Boolean(message), ); @@ -1189,7 +1460,15 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { break; } } - outboundMessages.splice(insertAt, 0, referenceMessage); + // OpenAIChatMessage is a structural superset of SerializedMessage + // for the role/content axis the outbound pipeline consumes; cast + // through unknown since referenceMessage carries no tool_calls + // (the image_edit reference is a plain assistant turn). + outboundMessages.splice( + insertAt, + 0, + referenceMessage as unknown as SerializedMessage, + ); } const safeSystemPrompt = @@ -1271,7 +1550,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { outboundMessages[0] = { ...firstMessage, content: [ - ...firstMessage.content, + ...(Array.isArray(firstMessage.content) + ? firstMessage.content + : []), { type: "text", text: `\n\n${disabledToolGuard}` }, ], }; @@ -1420,20 +1701,30 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { let cumulativeText = ""; let reasoningStartAt: number | null = null; let reasoningDuration = 0; - // Tracks whether we are currently inside a `` block opened by - // a `delta.reasoning_content` chunk. Kimi (kimi-k2.6, kimi-k2-thinking) - // and DeepSeek's reasoner stream their thinking as a separate - // `reasoning_content` field on the chat-completion delta — not as - // `content`, not as a structured part. We wrap those chunks with - // inline `...` so the existing parseAssistantContent - // lifts them into the reasoning panel the same way it does for - // local Harmony models. State has to live outside the SSE loop - // because the close tag fires when the next chunk carries content - // (or when the stream ends). + // True while wrapping a `delta.reasoning_content` stream in + // ... for parseAssistantContent. Lives outside + // the SSE loop because the close tag fires when content arrives. let reasoningContentOpen = false; - // Tool call content parts — accumulated and yielded cumulatively. - // result is set directly on the tool-call part when tool_end arrives. + // Tool call parts, cumulative; result lands on tool_end. const toolCallParts: ToolCallMessagePart[] = []; + // Latest Gemini text-part thoughtSignature; pinned onto the final + // text MessagePart so next-turn replay carries it. + let latestTextThoughtSignature: string | undefined; + const pinTextThoughtSignature = ( + parts: T[], + ): T[] => { + if (!latestTextThoughtSignature || parts.length === 0) return parts; + for (let i = parts.length - 1; i >= 0; i -= 1) { + if (parts[i].type === "text") { + parts[i] = { + ...parts[i], + _google_thought_signature: latestTextThoughtSignature, + } as T; + break; + } + } + return parts; + }; const orderAssistantContent = ( textParts: ReturnType, ) => { @@ -1525,6 +1816,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { { isReasoningProvider: externalProvider.isReasoningModel === true, + baseUrl: externalProvider.baseUrl ?? null, }, ) : { @@ -1557,13 +1849,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { forceRefreshPublicKey = false, ): Promise => { if (externalSelection && externalProvider) { - // OpenAI shell-tool container reuse: pull the per-thread - // container_id (if any) so subsequent turns in the same - // thread reference the existing container instead of - // auto-creating a fresh one. Empty string / undefined → - // backend falls back to container_auto. Anthropic uses - // the parallel `anthropicCodeExecContainerId` field below - // (sent as `container` on /v1/messages). + // Per-thread container reuse; empty/undefined falls back to + // container_auto. Anthropic uses anthropicCodeExecContainerId. let openaiCodeExecContainerId: string | null = null; let anthropicCodeExecContainerId: string | null = null; if (codeExecEnabledForThisTurn && resolvedThreadId) { @@ -1577,17 +1864,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { openaiCodeExecContainerId = null; anthropicCodeExecContainerId = null; } - // Pre-send container validation (OpenAI only). The list - // endpoint already filters status==="expired" server-side - // (studio/backend/routes/inference.py — list_openai_containers), - // so membership in this set means "OpenAI will accept it - // as container_reference". A stale id silently dropped here - // falls through to the inheritance + lazy-create logic - // below, so the user never sees "Container is expired" in - // the chat thread. On list-call failure we leave - // activeContainerIds null and skip validation — the - // backend's transparent retry path is the safety net for - // that case. + // Pre-send container validation (OpenAI). Stale ids drop + // silently and fall through to lazy-create. On list-call + // failure, skip and rely on the backend's retry path. let activeContainerIds: Set | null = null; if (externalProvider.providerType === "openai") { try { @@ -1610,15 +1889,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { openaiCodeExecContainerId = null; } } - // Cross-thread inheritance: when the active thread has - // no container yet, default to the one most recently - // used on *any* other thread (provider-scoped). - // Matches what the Code Execution settings section - // shows in the picker, and keeps the user from getting - // a fresh container on every new thread. The picker - // can still be set to "Auto-create per thread" - // explicitly to opt into a fresh container — but - // that's done via the dropdown, not silently. + // Cross-thread inheritance: reuse the most recently used + // container from any other thread; opt-out via the picker. if ( !openaiCodeExecContainerId && externalProvider.providerType === "openai" @@ -1651,13 +1923,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { /* fall through to lazy-create below */ } } - // Lazy pre-create when there's no inherited container. - // We always POST /v1/containers ourselves (rather than - // letting the backend send container_auto) so every - // container shows up in the picker with a friendly - // English-word name and the user's configured TTL. - // Falls back to container_auto only if the POST fails - // — keeps the chat moving in that case. + // Pre-create our own container (vs container_auto) so it + // shows up in the picker with a friendly name and the + // configured TTL. Falls back to container_auto on failure. if ( !openaiCodeExecContainerId && externalProvider.providerType === "openai" @@ -1716,22 +1984,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { externalSelection?.modelId, ), ), - // Only forward sampling knobs the provider actually accepts; the - // backend's external-provider proxy is param-permissive and would - // surface a 400 from providers that reject unknown fields (e.g. - // OpenAI rejects top_k, Anthropic/DeepSeek reject presence_penalty). + // Forward only sampling knobs the provider accepts. ...(externalCapabilities?.topK ? { top_k: params.topK } : {}), ...(externalCapabilities?.presencePenalty ? { presence_penalty: params.presencePenalty } : {}), - // Built-in tools: Search pill maps to provider-side - // web_search (currently OpenAI / Anthropic / OpenRouter / - // Kimi); Code pill maps to Anthropic's server-side - // code_execution_20250825 tool (Anthropic is the only - // external provider that ships one today). Backend - // translates enabled_tools into each provider's tool - // schema — for Anthropic that's the entries appended to - // body["tools"] inside _stream_anthropic. + // Compose the enabled_tools list from the active pills; + // backend maps each name to the provider's tool schema. ...(webSearchEnabledForThisTurn || webFetchEnabledForThisTurn || codeExecEnabledForThisTurn || @@ -1740,14 +1999,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { enable_tools: true, enabled_tools: [ ...(webSearchEnabledForThisTurn ? ["web_search"] : []), - // web_fetch has its own Fetch pill, independent - // of Search. Anthropic-only today. ...(webFetchEnabledForThisTurn ? ["web_fetch"] : []), ...(codeExecEnabledForThisTurn ? ["code_execution"] : []), - // OpenAI Responses-API only: `image_generation` - // returns inline image_generation_call output - // items; the backend's _stream_openai_responses - // path translates them to assistant tool events. ...(imageGenerationEnabledForThisTurn ? ["image_generation"] : []), @@ -1783,12 +2036,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { externalProvider.enablePromptCaching ?? true, } : {}), - // Anthropic-only: pass the cache TTL the user picked in - // Configuration → Provider. Omitted = inherit the default - // 5-minute pool. The backend's `_stream_anthropic` only - // attaches `cache_control.ttl` when the value is one of - // "5m" / "1h" (see external_provider.py near line 1375), - // so unknown values are a no-op end-to-end. + // Anthropic prompt-cache TTL; unknown values no-op on backend. ...(supportsProviderPromptCacheTtl( externalProvider.providerType, ) && @@ -1896,11 +2144,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { chunk as unknown as { _toolEvent?: Record } )._toolEvent; if (toolEvent !== undefined) { - // OpenAI shell-tool container persistence — see - // ThreadRecord.openaiCodeExecContainerId. The backend - // emits these synthetic events on the OpenAI Responses - // SSE stream after capturing the container_id from a - // response, or detecting an expired-container error. + // Persist container_id onto the thread (OpenAI / Anthropic). if (toolEvent.type === "container_ready") { const newContainerId = toolEvent.container_id as | string @@ -1997,12 +2241,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { typeof imageB64 === "string" && imageB64 ) { - // OpenAI Responses image_generation_call: the - // backend stashes the base64 PNG/WebP/JPEG on - // separate `image_b64` / `image_mime` fields on - // the synthetic _toolEvent so the JSON result - // string stays small enough to log. Repackage as - // a structured result for the dedicated tool UI. + // Backend keeps base64 on separate image_b64 / + // image_mime fields so logs stay small; repackage. parsedResult = { image_b64: imageB64, image_mime: @@ -2029,27 +2269,104 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { } else { parsedResult = rawResult; } + // Merge tool_end args first, then Gemini native_part. const nextArgs = toolEvent.arguments && typeof toolEvent.arguments === "object" ? (toolEvent.arguments as ToolCallMessagePart["args"]) : undefined; - const mergedArgs = nextArgs - ? { ...(toolCallParts[idx].args ?? {}), ...nextArgs } - : toolCallParts[idx].args; + const mergedArgs: ToolCallMessagePart["args"] = { + ...(toolCallParts[idx].args ?? {}), + ...(nextArgs ?? {}), + } as ToolCallMessagePart["args"]; + // Merge tool_end native_part into args.google so the + // outbound translator replays both start (executableCode) + // and end (result / inlineData) on the same turn. + // Concatenate parts so each keeps its own thoughtSignature. + const endGoogle = ( + toolEvent as { google?: { native_part?: unknown } } + ).google; + if ( + endGoogle && + typeof endGoogle === "object" && + endGoogle.native_part && + typeof endGoogle.native_part === "object" + ) { + const argsObj = mergedArgs as Record; + const existingGoogle = (argsObj.google ?? {}) as Record< + string, + unknown + >; + const existingNative = + (existingGoogle.native_part as Record< + string, + unknown + >) ?? {}; + const endNative = endGoogle.native_part as Record< + string, + unknown + >; + // Extract part entries from either parts:[...] or + // legacy single-object native_part. Legacy + // thoughtSignature always belongs on executableCode. + const collectParts = ( + native: Record, + ): Record[] => { + if (Array.isArray(native.parts)) { + return (native.parts as unknown[]).filter( + (entry): entry is Record => + Boolean(entry) && + typeof entry === "object" && + !Array.isArray(entry), + ); + } + const out: Record[] = []; + const legacySig = + typeof native.thoughtSignature === "string" + ? native.thoughtSignature + : typeof native.thought_signature === "string" + ? (native.thought_signature as string) + : null; + for (const key of [ + "executableCode", + "codeExecutionResult", + "inlineData", + ] as const) { + const sub = native[key]; + if (sub && typeof sub === "object") { + const entry: Record = { + [key]: sub, + }; + if (key === "executableCode" && legacySig) { + entry.thoughtSignature = legacySig; + } + out.push(entry); + } + } + return out; + }; + const mergedParts = [ + ...collectParts(existingNative), + ...collectParts(endNative), + ]; + argsObj.google = { + ...existingGoogle, + native_part: { parts: mergedParts }, + }; + } toolCallParts[idx] = { ...toolCallParts[idx], args: mergedArgs, - argsText: mergedArgs - ? JSON.stringify(mergedArgs) - : toolCallParts[idx].argsText, + argsText: JSON.stringify(mergedArgs ?? {}), result: parsedResult, }; } } - // Yield cumulative state so tool UI updates. Search/code tools stay - // before the text, while generated images sit after the answer. - const textParts = parseAssistantContent(cumulativeText); + // Cumulative yield. orderAssistantContent puts search/ + // code before text and generated images after. + const textParts = pinTextThoughtSignature( + parseAssistantContent(cumulativeText), + ); yield { content: orderAssistantContent(textParts), metadata: { @@ -2076,11 +2393,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { } totalChunks += 1; - // OpenRouter's free router (openrouter/free) picks a different - // underlying free model per request and reports it in every - // chunk's top-level `model` field. Latch the first non-empty - // value that differs from the requested checkpoint so the - // header chip can render "openrouter/free:". + // Latch the chunk's `model` field so the openrouter/free + // chip can show the chosen underlying model. if ( isExternalRequest && externalProvider?.providerType === "openrouter" && @@ -2099,30 +2413,37 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { } } const rawDelta = chunk.choices?.[0]?.delta?.content; - // Providers like Mistral's magistral return delta.content as an - // array of structured parts; normalize to text (with thinking - // parts re-wrapped as inline tags) so the rest of the - // accumulator stays string-based. + // Normalize structured delta.content (mistral magistral) to text. const delta = extractDeltaText(rawDelta); - // Kimi (kimi-k2.6, kimi-k2-thinking) and DeepSeek reasoner - // stream thinking via `delta.reasoning_content` as a plain - // string field — separate from `delta.content` which carries - // the answer. Wrap reasoning chunks inline as ... - // so parseAssistantContent treats them like any - // other reasoning. The close tag fires when the next chunk - // brings content, or when the stream ends. + // Latest Gemini text-part thoughtSignature for next-turn replay. + const deltaExtraContent = ( + chunk.choices?.[0]?.delta as + | { extra_content?: unknown } + | undefined + )?.extra_content; + if ( + deltaExtraContent && + typeof deltaExtraContent === "object" + ) { + const eGoogle = (deltaExtraContent as Record) + .google; + if (eGoogle && typeof eGoogle === "object") { + const sig = (eGoogle as Record) + .thought_signature; + if (typeof sig === "string" && sig) { + latestTextThoughtSignature = sig; + } + } + } + // Kimi / DeepSeek stream thinking via delta.reasoning_content. + // Wrap inline as ... for parseAssistantContent. const rawReasoning = ( chunk.choices?.[0]?.delta as | { reasoning_content?: unknown } | undefined )?.reasoning_content; - // OpenRouter uses a third reasoning shape: a structured - // `delta.reasoning_details` array of parts (each carrying - // `text`). The router emits this regardless of which - // underlying provider it picked, so we extract here and - // merge into the same ... wrap path used - // for Kimi / DeepSeek reasoning_content. See - // https://openrouter.ai/docs/guides/best-practices/reasoning-tokens + // OpenRouter ships reasoning as delta.reasoning_details[] + // regardless of underlying provider; merge into the same wrap path. const rawReasoningDetails = ( chunk.choices?.[0]?.delta as | { reasoning_details?: unknown } @@ -2140,6 +2461,138 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { const reasoning = (typeof rawReasoning === "string" ? rawReasoning : "") + reasoningFromDetails; + // OpenAI delta.tool_calls: streams fragments by index; + // accumulate into one part. extra_content carries Gemini 3 + // thoughtSignature for next-turn replay. + const rawDeltaToolCalls = ( + chunk.choices?.[0]?.delta as + | { tool_calls?: unknown } + | undefined + )?.tool_calls; + if ( + Array.isArray(rawDeltaToolCalls) && + rawDeltaToolCalls.length > 0 + ) { + for (const tc of rawDeltaToolCalls) { + if (!tc || typeof tc !== "object") continue; + const call = tc as { + id?: string; + index?: number; + function?: { name?: string; arguments?: string }; + extra_content?: unknown; + }; + const idx = + typeof call.index === "number" ? call.index : undefined; + const stableId = call.id; + // Match an existing fragment by id first (canonical), + // then by index slot. Fall back to a freshly-minted + // tool_call_ id for streams that send neither. + let existing = stableId + ? toolCallParts.find((p) => p.toolCallId === stableId) + : undefined; + if (!existing && idx !== undefined) { + existing = toolCallParts.find( + (p) => + ( + p as ToolCallMessagePart & { _delta_index?: number } + )._delta_index === idx, + ); + } + const argsFragment = call.function?.arguments ?? ""; + if (existing) { + const prevName = existing.toolName ?? ""; + const nextName = call.function?.name ?? prevName; + const merged = + (existing.argsText ?? "") + argsFragment; + let parsedArgs: + ToolCallMessagePart["args"] = existing.args ?? {}; + if (merged) { + try { + parsedArgs = JSON.parse( + merged, + ) as ToolCallMessagePart["args"]; + } catch { + parsedArgs = { + _raw: merged, + } as ToolCallMessagePart["args"]; + } + } + const prevExtra = ( + existing as ToolCallMessagePart & { + extra_content?: unknown; + } + ).extra_content; + const updated: ToolCallMessagePart & { + _delta_index?: number; + extra_content?: unknown; + } = { + ...(existing as ToolCallMessagePart), + toolName: nextName, + argsText: merged, + args: parsedArgs, + ...(call.extra_content !== undefined + ? { extra_content: call.extra_content } + : prevExtra !== undefined + ? { extra_content: prevExtra } + : {}), + ...(idx !== undefined ? { _delta_index: idx } : {}), + }; + const replaceIdx = toolCallParts.indexOf(existing); + if (replaceIdx >= 0) { + toolCallParts[replaceIdx] = updated; + } + } else { + const callId = + stableId || + `tool_call_${idx ?? toolCallParts.length}`; + const argsText = argsFragment; + let parsedArgs: ToolCallMessagePart["args"] = {}; + if (argsText) { + try { + parsedArgs = JSON.parse( + argsText, + ) as ToolCallMessagePart["args"]; + } catch { + parsedArgs = { + _raw: argsText, + } as ToolCallMessagePart["args"]; + } + } + const fresh: ToolCallMessagePart & { + _delta_index?: number; + extra_content?: unknown; + } = { + type: "tool-call" as const, + toolCallId: callId, + toolName: call.function?.name ?? "", + argsText, + args: parsedArgs, + ...(call.extra_content !== undefined + ? { extra_content: call.extra_content } + : {}), + ...(idx !== undefined ? { _delta_index: idx } : {}), + }; + toolCallParts.push(fresh); + } + } + yield { + content: [ + ...toolCallParts, + ...pinTextThoughtSignature( + parseAssistantContent(cumulativeText), + ), + ], + metadata: { + timing: buildTiming( + streamStartTime, + totalChunks, + firstTokenTime, + ), + custom: { reasoningDuration }, + }, + }; + continue; + } if (!delta && !reasoning) { continue; } @@ -2165,21 +2618,17 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { } cumulativeText += delta; } - // Mistral's magistral occasionally emits a trailing - // template-literal artifact (e.g. "${response}") at the end of - // an otherwise complete answer. It is never part of a real - // reply, so strip a trailing `${...}` token from external - // provider streams. The regex anchors to end-of-string and is - // idempotent — fragments mid-stream (e.g. "${re") leave the - // string untouched and only collapse once the closing brace - // arrives. Local-model output is left alone. + // Strip a trailing ${...} template-literal artifact from + // external streams (mistral magistral occasionally emits one). if (isExternalRequest) { cumulativeText = cumulativeText.replace( /\s*\$\{[^}]*\}\s*$/, "", ); } - const parts = parseAssistantContent(cumulativeText); + const parts = pinTextThoughtSignature( + parseAssistantContent(cumulativeText), + ); if ( parts.some((part) => part.type === "reasoning") && @@ -2295,7 +2744,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { yield { content: [ - ...orderAssistantContent(parseAssistantContent(cumulativeText)), + ...orderAssistantContent( + pinTextThoughtSignature(parseAssistantContent(cumulativeText)), + ), ...sourceParts, ...documentCitationParts, ], diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 1783a082817..c4a5f342731 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -728,7 +728,10 @@ export function ChatPage(): ReactElement { const reasoningCaps = getExternalReasoningCapabilities( provider?.providerType, selection.modelId, - { isReasoningProvider: provider?.isReasoningModel === true }, + { + isReasoningProvider: provider?.isReasoningModel === true, + baseUrl: provider?.baseUrl ?? null, + }, ); const state = useChatRuntimeStore.getState(); const preferredEffort = state.reasoningEffort; @@ -769,6 +772,8 @@ export function ChatPage(): ReactElement { : state.reasoningEffort; const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch( provider?.providerType, + selection.modelId, + provider?.baseUrl, ); const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution( provider?.providerType, @@ -968,6 +973,7 @@ export function ChatPage(): ReactElement { { isReasoningProvider: selectedProvider?.isReasoningModel === true, + baseUrl: selectedProvider?.baseUrl ?? null, }, ); const preferredEffort = store.reasoningEffort; @@ -1009,6 +1015,8 @@ export function ChatPage(): ReactElement { store.setCheckpoint(value, null); const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch( selectedProvider?.providerType, + selectedExternal?.modelId, + selectedProvider?.baseUrl, ); const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution( selectedProvider?.providerType, diff --git a/studio/frontend/src/features/chat/external-providers.ts b/studio/frontend/src/features/chat/external-providers.ts index dddf529068b..b10253c2808 100644 --- a/studio/frontend/src/features/chat/external-providers.ts +++ b/studio/frontend/src/features/chat/external-providers.ts @@ -37,6 +37,13 @@ export interface ExternalProviderConfig { updatedAt: number; } +// Gemini supports prompt caching, but the wire flow requires a +// separate POST to /v1beta/cachedContents to create the cache before +// the generateContent call can reference it; the boolean Studio +// currently emits on enable_prompt_caching is not enough on its own. +// Until that two-step orchestration ships we keep the picker off so +// the toggle does not silently no-op for Gemini users. See +// https://ai.google.dev/gemini-api/docs/caching. const PROMPT_CACHING_PROVIDER_TYPES = new Set(["openai", "anthropic"]); export function supportsProviderPromptCaching( diff --git a/studio/frontend/src/features/chat/provider-capabilities.ts b/studio/frontend/src/features/chat/provider-capabilities.ts index 5adc01ea2bd..f5502798261 100644 --- a/studio/frontend/src/features/chat/provider-capabilities.ts +++ b/studio/frontend/src/features/chat/provider-capabilities.ts @@ -189,7 +189,27 @@ function _inferProviderFromOpenrouterId( */ export function providerSupportsBuiltinWebSearch( providerType: string | null | undefined, + modelId?: string | null | undefined, + baseUrl?: string | null | undefined, ): boolean { + // Gemini ships grounded search via `tools: [{googleSearch: {}}]` on + // every chat-capable model. Most image-tier ids (`-image`, + // `nano-banana`) reject text-tool wiring because the + // responseModalities path is mutually exclusive with text tools, but + // Google explicitly documents Search grounding on the Gemini 3 image + // family (gemini-3-pro-image-preview, gemini-3.1-flash-image-preview, + // nano-banana-pro). Allow Search on those; hide on older image ids. + // Custom Gemini OpenAI-compat proxies (non-Google bases) skip the + // native translator on the backend, so native tool envelopes never + // reach them -- hide the pill there. + if (providerType === "gemini") { + if (isGeminiCustomOpenAICompatBase(baseUrl)) return false; + const normalized = modelId?.trim().toLowerCase() ?? ""; + if (normalized && isGeminiImageModel(normalized)) { + return geminiImageModelAllowsGoogleSearch(normalized); + } + return true; + } return ( providerType === "openai" || providerType === "anthropic" || @@ -319,6 +339,20 @@ export function providerSupportsBuiltinCodeExecution( normalized.startsWith(prefix), ); } + if (providerType === "gemini") { + // Gemini's `tools: [{codeExecution: {}}]` is supported on every + // chat-capable model. Image-tier ids (`-image`, `nano-banana`) + // reject text-tool wiring because the inline-image path is + // mutually exclusive with codeExecution. Custom Gemini + // OpenAI-compat proxies skip the native translator on the + // backend, so native codeExecution envelopes do not reach them. + // Wire-up lives in `_stream_gemini` on the backend; output comes + // back inline as executableCode/codeExecutionResult parts. See + // https://ai.google.dev/gemini-api/docs/code-execution. + if (isGeminiCustomOpenAICompatBase(baseUrl)) return false; + if (isGeminiImageModel(normalized)) return false; + return normalized.startsWith("gemini-"); + } return false; } @@ -351,12 +385,75 @@ export function providerSupportsBuiltinImageGeneration( modelId: string | null | undefined, baseUrl?: string | null, ): boolean { - if (providerType !== "openai") return false; - if (!isOpenAICloudBaseUrl(baseUrl)) return false; const normalized = modelId?.trim().toLowerCase() ?? ""; if (!normalized) return false; - return OPENAI_IMAGE_GENERATION_MODEL_PREFIXES.some((prefix) => - normalized.startsWith(prefix), + if (providerType === "openai") { + if (!isOpenAICloudBaseUrl(baseUrl)) return false; + return OPENAI_IMAGE_GENERATION_MODEL_PREFIXES.some((prefix) => + normalized.startsWith(prefix), + ); + } + if (providerType === "gemini") { + // Gemini's Nano Banana image-output ids carry either `-image` (e.g. + // `gemini-2.5-flash-image`, `gemini-3.1-flash-image-preview`) or the + // `nano-banana` alias (`nano-banana-pro-preview`). The backend flips + // generationConfig.responseModalities to ["TEXT", "IMAGE"] when one + // is picked, and translates inlineData parts into the same image_b64 + // tool_end envelope the OpenAI path emits so the chat UI renders the + // picture inline. Custom Gemini OpenAI-compat proxies skip the + // native translator on the backend, so hide the image pill there. + // See https://ai.google.dev/gemini-api/docs/image-generation. + if (isGeminiCustomOpenAICompatBase(baseUrl)) return false; + return normalized.includes("-image") || normalized.includes("nano-banana"); + } + return false; +} + +/** + * Whether `modelId` is a Gemini image-output id (Nano Banana family). + * Mirrors the backend's `is_image_picker_model` guard so the frontend + * hides text-only tool pills (web_search, code_execution) for these. + */ +function isGeminiImageModel(modelId: string): boolean { + const m = modelId.toLowerCase(); + return m.includes("-image") || m.includes("nano-banana"); +} + +/** + * Whether the saved Gemini connection points at a custom + * OpenAI-compatible gateway (any non-Google host). The backend + * `_is_openai_compatible` mirrors this to route those connections + * through `/chat/completions` instead of the native translator, so + * native Gemini tool envelopes (googleSearch, codeExecution, + * responseModalities) never reach them. Hide the corresponding + * Studio pills here so the request, builder, and UI agree. + */ +export function isGeminiCustomOpenAICompatBase( + baseUrl: string | null | undefined, +): boolean { + if (!baseUrl) return false; + try { + const host = new URL(baseUrl).hostname.toLowerCase(); + return host.length > 0 && host !== "generativelanguage.googleapis.com"; + } catch { + return false; + } +} + +/** + * Whether the given Gemini image model supports `tools: [{googleSearch: {}}]`. + * Google documents Search grounding on the Gemini 3 image family + * (gemini-3-pro-image-preview, gemini-3.1-flash-image-preview, + * "Nano Banana Pro"); older image ids (gemini-2.5-flash-image) reject + * it with "Search as tool is not enabled for this model". + */ +function geminiImageModelAllowsGoogleSearch(modelId: string): boolean { + const m = modelId.toLowerCase(); + return ( + m.startsWith("gemini-3-pro-image") || + m.startsWith("gemini-3.1-flash-image") || + m.startsWith("nano-banana-pro") || + m.startsWith("nano-banana-2") ); } @@ -431,7 +528,20 @@ const PROVIDER_CAPABILITIES: Record = { presencePenalty: false, }, mistral: OPENAI_COMPAT_BASE, - gemini: OPENAI_COMPAT_BASE, + // Gemini's native generationConfig accepts temperature, topP, topK and + // presencePenalty (plus a separate frequencyPenalty we do not surface + // today). minP and repetitionPenalty are not part of the contract -- + // see https://ai.google.dev/api/rest/v1beta/GenerationConfig. Backend + // request shaping lives in _stream_gemini in + // studio/backend/core/inference/external_provider.py. + gemini: { + temperature: true, + topP: true, + topK: true, + minP: false, + repetitionPenalty: false, + presencePenalty: true, + }, // Kimi k2.5/k2.6 are reasoning-class — the API locks temperature and // top_p to fixed defaults and 400s on any other value: // "invalid temperature: only 1 is allowed for this model". @@ -643,6 +753,119 @@ function resolveKimiReasoningCapabilities(modelId: string): ExternalReasoningCap return withEnableThinkingStyle(); } +// Gemini's thinking ladder. +// - Gemini 3.x (3 / 3.1 / 3.5, Pro + Flash + Flash-Lite) and the +// gemini-pro-latest / gemini-flash-latest aliases use the new +// `thinkingConfig.thinkingLevel` string field (LOW/MEDIUM/HIGH/ +// MINIMAL). Pro tier rejects MINIMAL. +// - Gemini 2.5 Flash + 2.5 Pro stay on the integer +// `thinkingConfig.thinkingBudget` (0=off on Flash, -1=dynamic, +// N>0=cap; Pro rejects 0). +// - 2.5 Flash-Lite: no native thinking surfaced; leave it off. +// - Image-tier ids (`*-image*`, `nano-banana-pro-preview`): image +// generation path -- no reasoning controls. +const GEMINI3_PRO_PREFIXES = [ + "gemini-3.5-pro", + "gemini-3.1-pro", + "gemini-3-pro-preview", + "gemini-pro-latest", +]; +const GEMINI3_FLASH_PREFIXES = [ + "gemini-3.5-flash", + "gemini-3.1-flash", + "gemini-3-flash", + "gemini-flash-latest", + "gemini-flash-lite-latest", +]; +const GEMINI25_PRO_PREFIXES = [ + "gemini-2.5-pro", +]; +const GEMINI25_FLASH_PREFIXES = [ + "gemini-2.5-flash", +]; +const GEMINI_IMAGE_HINTS = [ + "-image", + "nano-banana", +]; +function resolveGeminiReasoningCapabilities( + modelId: string, +): ExternalReasoningCapabilities { + const m = modelId.toLowerCase(); + if (GEMINI_IMAGE_HINTS.some((h) => m.includes(h))) { + // Image generation; no thinking knob. + return withEnableThinkingStyle(); + } + // Gemini 2.5 Flash-Lite supports `thinkingBudget` with `0` = off and + // a positive range starting at 512 (the backend maps "minimal" to + // that floor at external_provider._stream_gemini). Check this branch + // BEFORE the broader `gemini-2.5-flash` prefix. + // https://ai.google.dev/gemini-api/docs/thinking + if (m.startsWith("gemini-2.5-flash-lite")) { + return withReasoningEffortStyle({ + supportsReasoning: true, + supportsReasoningOff: true, + reasoningEffortLevels: [ + "none", + "minimal", + "low", + "medium", + "high", + "max", + ] as const, + }); + } + if (GEMINI3_PRO_PREFIXES.some((p) => m.startsWith(p))) { + // Gemini 3.x Pro: thinkingLevel supports low/medium/high per + // https://ai.google.dev/gemini-api/docs/thinking and + // https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-1-pro. + // Cannot fully disable thinking; "minimal" is rejected on Pro. + return withReasoningEffortStyle({ + supportsReasoning: true, + supportsReasoningOff: false, + reasoningEffortLevels: ["low", "medium", "high"] as const, + }); + } + if (GEMINI3_FLASH_PREFIXES.some((p) => m.startsWith(p))) { + // Gemini 3 Flash: thinkingLevel minimal/low/medium/high. Minimal + // is the closest to "off" Google offers on Gemini 3. + return withReasoningEffortStyle({ + supportsReasoning: true, + supportsReasoningOff: false, + reasoningEffortLevels: [ + "minimal", + "low", + "medium", + "high", + ] as const, + }); + } + if (GEMINI25_PRO_PREFIXES.some((p) => m.startsWith(p))) { + // Gemini 2.5 Pro: thinkingBudget cannot be 0 (API rejects with + // "only works in thinking mode"); backend coerces to a small + // positive budget. The picker still hides the off switch. + return withReasoningEffortStyle({ + supportsReasoning: true, + supportsReasoningOff: false, + reasoningEffortLevels: ["low", "medium", "high", "max"] as const, + }); + } + if (GEMINI25_FLASH_PREFIXES.some((p) => m.startsWith(p))) { + // Gemini 2.5 Flash: thinkingBudget supports 0 = off cleanly. + return withReasoningEffortStyle({ + supportsReasoning: true, + supportsReasoningOff: true, + reasoningEffortLevels: [ + "none", + "low", + "medium", + "high", + "max", + ] as const, + }); + } + return withEnableThinkingStyle(); +} + function resolveMistralReasoningCapabilities(modelId: string): ExternalReasoningCapabilities { if (modelId === "magistral-medium-latest") { return withReasoningEffortStyle({ @@ -665,6 +888,8 @@ function resolveMistralReasoningCapabilities(modelId: string): ExternalReasoning export interface ExternalReasoningResolveOptions { /** vLLM connection flagged as a reasoning model in provider config. */ isReasoningProvider?: boolean; + /** Provider base URL; used to detect custom Gemini OAI-compat gateways. */ + baseUrl?: string | null; } // vLLM has no per-model reasoning signal on OpenAI-compat — pin via user toggle. @@ -740,6 +965,16 @@ export function getExternalReasoningCapabilities( } if (isKimiProvider) return resolveKimiReasoningCapabilities(modelForMatching); if (isMistralProvider) return resolveMistralReasoningCapabilities(modelForMatching); + if (normalizedProvider === "gemini") { + // Custom Gemini OAI-compat gateways (LiteLLM, proxies) route + // through /chat/completions which drops the Gemini-native + // thinkingConfig payload. Hide the native thinking ladder so the + // UI does not advertise a control the backend cannot honor. + if (isGeminiCustomOpenAICompatBase(options?.baseUrl)) { + return withEnableThinkingStyle(); + } + return resolveGeminiReasoningCapabilities(modelForMatching); + } if (!isOpenAIProvider && !isAnthropicProvider) { return withEnableThinkingStyle(); } diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index ba4a897f633..a811125a20f 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -396,6 +396,7 @@ export function SharedComposer({ { isReasoningProvider: selectedExternalProvider?.isReasoningModel === true, + baseUrl: selectedExternalProvider?.baseUrl ?? null, }, ) : null; @@ -449,16 +450,36 @@ export function SharedComposer({ const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch( selectedExternalProvider?.providerType, ); + // Gemini rejects codeExecution alongside image modalities. Search is + // blocked on older Gemini image ids but allowed on Gemini 3 image + // models -- supportsBuiltinWebSearch already encodes the per-model + // allowance, so we only disable Code unconditionally in Gemini + // image mode. + const isExternalGemini = selectedExternalProvider?.providerType === "gemini"; + const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration; + const imageModeDisablesCode = + isExternalGemini && imageToolsEnabled && !imageDisabled; + // Image-tier Gemini models always reject codeExecution and reject + // web_search on older ids (Gemini 3.x Pro/Flash allow it -- encoded + // in supportsBuiltinWebSearch). Don't let the local `supportsTools` + // runtime flag re-enable a pill the Gemini backend will silently + // drop. Detect "external provider is Gemini AND model is image-tier" + // and gate strictly on the provider builtin support. + const isGeminiImageTier = + isExternalGemini && supportsBuiltinImageGeneration; const searchDisabled = - !modelLoaded || !(supportsTools || supportsBuiltinWebSearch); + !modelLoaded || + (isGeminiImageTier + ? !supportsBuiltinWebSearch + : !(supportsTools || supportsBuiltinWebSearch)); const codeDisabled = - !modelLoaded || !(supportsTools || supportsBuiltinCodeExecution); - // Images pill is only ever lit on OpenAI cloud's Responses-API models. - // No local tool runtime fallback because the only image-generation - // server tool we wire today is OpenAI's; local models cannot dispatch - // it. Hidden entirely when the active model does not advertise it so - // the pill row stays compact for providers without the capability. - const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration; + !modelLoaded || + (isGeminiImageTier + ? true + : !(supportsTools || supportsBuiltinCodeExecution)) || + imageModeDisablesCode; + // Images pill is only ever lit on OpenAI cloud's Responses-API models + // and Gemini Nano Banana family. No local tool runtime fallback. const showImagePill = supportsBuiltinImageGeneration; // Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209). const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch; diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index 5238875b71d..d313b43438a 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -219,9 +219,34 @@ export type OpenAIMessageContentPart = export type OpenAIMessageContent = string | OpenAIMessageContentPart[]; +/** + * OpenAI Chat Completions tool_call shape. Assistant turns echo back + * function/tool calls as `tool_calls`; the matching tool result rides + * on a separate `role="tool"` message keyed by `tool_call_id`. + * `extra_content.google.thought_signature` is the Gemini-specific + * round-trip field the backend translator both emits (on `delta. + * tool_calls`) and consumes (when rebuilding the native functionCall + * part on the next turn). + */ +export interface OpenAIToolCallPart { + id?: string; + type?: "function"; + function?: { + name?: string; + arguments?: string; + }; + extra_content?: unknown; +} + export interface OpenAIChatMessage { - role: "system" | "user" | "assistant"; - content: OpenAIMessageContent; + role: "system" | "user" | "assistant" | "tool"; + content: OpenAIMessageContent | null; + /** Assistant tool-call deltas, when the turn invoked a function tool. */ + tool_calls?: OpenAIToolCallPart[]; + /** `role="tool"` only: id matching `assistant.tool_calls[].id`. */ + tool_call_id?: string; + /** `role="tool"` only: name of the function that produced the result. */ + name?: string; } export interface OpenAIChatCompletionsRequest { @@ -262,7 +287,14 @@ export interface OpenAIChatCompletionsRequest { external_model?: string; encrypted_api_key?: string; provider_base_url?: string | null; - enable_prompt_caching?: boolean | null; + /** + * Boolean toggle for OpenAI/Anthropic ephemeral cache_control. For + * Gemini the backend also accepts the cached-content resource name + * (`cachedContents/...`) as a string, which is forwarded as + * `generationConfig.cachedContent` on the native streamGenerateContent + * request. + */ + enable_prompt_caching?: boolean | string | null; /** * OpenAI shell-tool container id captured from the prior response in * this chat thread. When set and the Code pill is on, the backend @@ -292,7 +324,20 @@ export interface OpenAIChatCompletionsRequest { export interface OpenAIChatDelta { role?: string; - content?: string; + content?: string | null; + /** + * Streamed assistant tool calls. The Gemini and OpenAI Responses + * translators emit incremental `tool_calls` deltas (function name + + * arguments fragments) so the chat-adapter can render tool cards as + * they arrive. + */ + tool_calls?: OpenAIToolCallPart[]; + /** + * Provider-specific passthrough. Gemini ships `thoughtSignature`, + * citations, `native_part`, etc., here so the round-trip can replay + * them on follow-up turns without bleeding into other providers. + */ + extra_content?: Record; } export interface OpenAIChatChunkChoice {