fix(vertex_ai): prevent Gemini from misinterpreting $ref in tool results (#38284)

* fix(vertex_ai): prevent Gemini from misinterpreting $ref in tool results

* fix(vertex_ai): use object instead of Any in schema-ref traversal

* fix(vertex_ai): use immutable tuple traversal to satisfy type-discipline gate

* test(vertex_ai): add direct contract test for _contains_json_schema_ref

* fix(vertex_ai): type the $ref walk over JsonValue and drop the private-helper test

* fix(vertex_ai): wrap a tool result only when a $ref holds a non-empty string

Gemini rejects {"$ref": "<name>"} in function_response.response but accepts null, empty,
numeric, boolean, object, and list values, so those tool results stay structured

* perf(vertex_ai): skip the $ref walk when the tool result text cannot spell the key

* test(vertex_ai): cover a $ref on the tool result's root object

* docs(vertex_ai): cite where the Gemini $ref rule was observed

* fix(vertex_ai): keep a $ref under a list of lists structured

Gemini accepts a $ref reached through a list nested directly in a list,
so those tool results keep their structured form. Adds the audit's
integration cells for the tool result wire, chaos and edge shapes

* test(vertex_ai): teach the fake Gemini servers the list of lists rule

The audit's scripted upstreams now skip lists inside a list like Gemini
does, add list of lists cells on every endpoint, and give the 8 MiB
timing calls their own client timeout

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
Sushit Lal Pakrashy 2026-10-15 18:56:47 +05:30 • committed by GitHub
parent d1a7fcde8e
commit 958cba7016
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1957 additions and 4 deletions

View file

@ -6,13 +6,13 @@ import json
import mimetypes
import re
import xml.etree.ElementTree as ET
from collections.abc import Container, Iterator, Mapping, Sequence
from collections.abc import Container, Iterable, Iterator, Mapping, Sequence
from enum import Enum
from types import MappingProxyType
from typing import Any, Final, TypeAlias, TypedDict, cast, overload
from jinja2.sandbox import ImmutableSandboxedEnvironment
from pydantic import BaseModel
from pydantic import BaseModel, JsonValue
import litellm
import litellm.types
@ -1440,6 +1440,45 @@ def convert_to_gemini_tool_call_invoke(
raise Exception(f"Unable to convert openai tool calls={message} to gemini tool calls. Received error={e}")
def _json_children(node: JsonValue) -> Iterable[JsonValue]:
if isinstance(node, dict):
return node.values()
if isinstance(node, list):
return (child for child in node if not isinstance(child, list))
return ()
def _next_json_level(level: tuple[JsonValue, ...], _: None) -> tuple[JsonValue, ...]:
return tuple(itertools.chain.from_iterable(_json_children(node) for node in level))
def _is_part_reference(node: JsonValue) -> bool:
if not isinstance(node, dict):
return False
ref: Final[JsonValue] = node.get("$ref")
return isinstance(ref, str) and ref != ""
_REF_CHAR_ESCAPE: Final = re.compile(r"\\u00(?:24|72|65|66)")
def _may_spell_ref_key(raw_json: str) -> bool:
"""JSON text can only spell the key $ref literally or by \\u-escaping one of its four characters"""
return '"$ref"' in raw_json or _REF_CHAR_ESCAPE.search(raw_json) is not None
def _contains_part_reference(payload: JsonValue) -> bool:
"""Gemini reads a {"$ref": "<non-empty string>"} object in a function_response as a pointer to one of its parts,
unless it sits under a list nested directly in a list
Google does not document this. It was observed on the Gemini API and Vertex AI on 2026-10-15, see issue #38223
"""
levels: Final = itertools.takewhile(
bool, itertools.accumulate(itertools.repeat(None), _next_json_level, initial=(payload,))
)
return any(_is_part_reference(node) for node in itertools.chain.from_iterable(levels))
def convert_to_gemini_tool_call_result(
message: ChatCompletionToolMessage | ChatCompletionFunctionMessage,
last_message_with_tool_calls: dict | None,
@ -1584,8 +1623,8 @@ def convert_to_gemini_tool_call_result(
try:
if content_str.strip().startswith("{") or content_str.strip().startswith("["):
# Try to parse as JSON (for Computer Use structured responses)
parsed: Final = json.loads(content_str)
if isinstance(parsed, dict):
parsed: Final[JsonValue] = json.loads(content_str)
if isinstance(parsed, dict) and not (_may_spell_ref_key(content_str) and _contains_part_reference(parsed)):
response_data = parsed # Use the parsed JSON directly
else:
response_data = {"content": content_str}

View file

@ -0,0 +1,491 @@
import asyncio
import json
import re
import threading
import uuid
from collections.abc import Callable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from queue import SimpleQueue
from types import MappingProxyType
from typing import Final, Literal, TypeAlias
from urllib.parse import urlsplit
import httpx
import psutil
import pytest
import yaml
from integration._support.client import Gateway, eventually
from integration._support.database import read_rows
from integration._support.process import graceful_stop_seconds, owned_proxy_process
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
_BACKEND: Final = "gemini-2.5-flash"
_API_KEY: Final = "synthetic-gemini-key"
_CONFIG_MODEL: Final = "gemini-tool-result-part-reference-chaos"
_TOOL: Final = "inspect_schema"
_PARAMETERS: Final[Mapping[str, JsonValue]] = {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
}
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_MARKER: Final = re.compile(r"marker-([0-9a-f]{32})")
_STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]")
_GENERATE: Final = f"/models/{_BACKEND}:generateContent"
_STREAM_GENERATE: Final = f"/models/{_BACKEND}:streamGenerateContent?alt=sse"
_USAGE: Final = {"promptTokenCount": 30, "candidatesTokenCount": 5, "totalTokenCount": 35}
Endpoint: TypeAlias = Literal["chat", "messages", "responses"]
@dataclass(frozen=True, slots=True)
class _Call:
endpoint: Endpoint
stream: bool
marker: str
reference: bool
@dataclass(frozen=True, slots=True)
class _Served:
call: _Call
status: int
text: str
def _tool_text(call: _Call) -> str:
value: Final = {"$ref": "#/$defs/humanScalar"} if call.reference else {"type": "string"}
return json.dumps(
{
"title": f"marker-{call.marker}",
"properties": {"value": value},
"$defs": {"humanScalar": {"type": "string"}},
},
separators=(",", ":"),
)
def _answer(marker: str) -> str:
return f"answer marker-{marker}"
def _response_id(marker: str) -> str:
return f"gemini-reply-{marker}"
def _path(endpoint: Endpoint) -> str:
match endpoint:
case "chat":
return "/v1/chat/completions"
case "messages":
return "/v1/messages"
case "responses":
return "/v1/responses"
def _body(model: str, call: _Call) -> Mapping[str, JsonValue]:
question: Final = f"Question marker-{call.marker}"
arguments: Final = json.dumps({"name": "Config"})
common: Final[Mapping[str, JsonValue]] = {
"model": model,
"stream": call.stream,
"num_retries": 0,
"cache": {"no-cache": True},
}
match call.endpoint:
case "chat":
return {
**common,
"tools": [{"type": "function", "function": {"name": _TOOL, "parameters": dict(_PARAMETERS)}}],
"messages": [
{"role": "user", "content": question},
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "call_0", "type": "function", "function": {"name": _TOOL, "arguments": arguments}}
],
},
{"role": "tool", "tool_call_id": "call_0", "content": _tool_text(call)},
],
}
case "messages":
return {
**common,
"max_tokens": 64,
"tools": [{"name": _TOOL, "input_schema": dict(_PARAMETERS)}],
"messages": [
{"role": "user", "content": question},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "toolu_0", "name": _TOOL, "input": {"name": "Config"}}],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "toolu_0", "content": _tool_text(call)}],
},
],
}
case "responses":
return {
**common,
"tools": [{"type": "function", "name": _TOOL, "parameters": dict(_PARAMETERS)}],
"input": [
{"role": "user", "content": question},
{"type": "function_call", "call_id": "call_0", "name": _TOOL, "arguments": arguments},
{"type": "function_call_output", "call_id": "call_0", "output": _tool_text(call)},
],
}
def _frame(marker: str, text: str, finished: bool) -> Mapping[str, JsonValue]:
return {
"responseId": _response_id(marker),
"candidates": [
{
"content": {"role": "model", "parts": [{"text": text}]},
"index": 0,
**({"finishReason": "STOP"} if finished else {}),
}
],
"usageMetadata": dict(_USAGE),
"modelVersion": _BACKEND,
}
def _gemini_reply(marker: str, stream: bool, abort_after: int | None = None, pause: float = 0) -> Reply:
if not stream:
return Reply(body=json.dumps(_frame(marker, _answer(marker), finished=True)).encode())
frames: Final = (_frame(marker, "answer ", finished=False), _frame(marker, f"marker-{marker}", finished=True))
return Reply(
content_type="text/event-stream",
chunks=tuple(f"data: {json.dumps(frame)}\n\n".encode() for frame in frames),
abort_after=abort_after,
pause_between_chunks=pause,
)
def _marker_of(request: Request) -> str:
found: Final = _MARKER.search(request.body.decode())
assert found is not None, request.body
return found.group(1)
def _function_response(request: Request) -> Mapping[str, JsonValue]:
contents: Final = _JSON_OBJECT.validate_json(request.body)["contents"]
assert isinstance(contents, list), contents
last: Final = contents[-1]
assert isinstance(last, dict) and isinstance(last["parts"], list), last
(part,) = last["parts"]
assert isinstance(part, dict), part
found: Final = part["function_response"]
assert isinstance(found, dict), part
return found
def _items_gemini_walks(items: list[JsonValue]) -> Iterator[JsonValue]:
return (item for item in items if not isinstance(item, list))
def _references(node: JsonValue) -> Sequence[str]:
if isinstance(node, dict):
own: Final = (node["$ref"],) if isinstance(node.get("$ref"), str) and node["$ref"] else ()
return (*own, *(ref for child in node.values() for ref in _references(child)))
if isinstance(node, list):
return tuple(ref for child in _items_gemini_walks(node) for ref in _references(child))
return ()
def _vendor_rejection(request: Request) -> Reply | None:
unresolved: Final = _references(_function_response(request)["response"])
if not unresolved:
return None
message: Final = (
f"The referenced name `{unresolved[0]}` in function_response.response does not match to a display_name in "
"the function_response.parts."
)
return Reply(
status=400,
body=json.dumps({"error": {"code": 400, "message": message, "status": "INVALID_ARGUMENT"}}).encode(),
)
def _echo(request: Request) -> Reply:
assert request.target in (_GENERATE, _STREAM_GENERATE), request.target
rejection: Final = _vendor_rejection(request)
if rejection is not None:
return rejection
return _gemini_reply(_marker_of(request), stream=request.target == _STREAM_GENERATE)
def _assert_each_in_its_own_form(received: tuple[Request, ...], calls: Sequence[_Call]) -> None:
by_marker: Final = MappingProxyType({call.marker: call for call in calls})
forwarded: Final = tuple((_marker_of(request), _function_response(request)["response"]) for request in received)
assert sorted(marker for marker, _ in forwarded) == sorted(by_marker), forwarded
expected: Final = tuple(
(marker, {"content": _tool_text(by_marker[marker])})
if by_marker[marker].reference
else (marker, json.loads(_tool_text(by_marker[marker])))
for marker, _ in forwarded
)
assert forwarded == expected
def _spend_rows_by_model(model: str, expected: int) -> frozenset[str]:
rows: Final = eventually(
lambda: read_rows('SELECT request_id, status FROM "LiteLLM_SpendLogs" WHERE model_group=%s', (model,)),
lambda found: len(found) >= expected,
seconds=60,
)
assert [row["status"] for row in rows] == ["success"] * len(rows), rows
assert len(rows) == expected, rows
return frozenset(str(row["request_id"]) for row in rows)
def _spend_status_by_request(request_ids: frozenset[str]) -> Mapping[str, JsonValue]:
rows: Final = eventually(
lambda: read_rows(
'SELECT request_id, status FROM "LiteLLM_SpendLogs" WHERE request_id = ANY(%s)',
(list(request_ids),), # pyright: ignore[reportArgumentType] # psycopg adapts the list to a text array
),
lambda found: len(found) >= len(request_ids),
seconds=60,
)
assert len(rows) == len(request_ids), rows
return {str(row["request_id"]): row["status"] for row in rows}
async def _send(client: httpx.AsyncClient, key: str, model: str, call: _Call) -> _Served:
async with client.stream(
"POST",
_path(call.endpoint),
json=_body(model, call),
headers={"Authorization": f"Bearer {key}", "anthropic-version": "2023-06-01"},
) as response:
raw: Final = await response.aread()
return _Served(call=call, status=response.status_code, text=raw.decode())
async def _burst(
base_url: str, key: str, model: str, calls: tuple[_Call, ...], *, tolerate_transport_errors: bool = False
) -> tuple[_Served, ...]:
async with httpx.AsyncClient(base_url=base_url, timeout=60, trust_env=False) as client:
results: Final = await asyncio.gather(
*(_send(client, key, model, call) for call in calls), return_exceptions=tolerate_transport_errors
)
for result in results:
assert not isinstance(result, BaseException) or isinstance(result, httpx.TransportError), repr(result)
return tuple(result for result in results if isinstance(result, _Served))
def _calls(
count: int, endpoints: tuple[Endpoint, ...], stream: Callable[[int], bool], reference: Callable[[int], bool]
) -> tuple[_Call, ...]:
return tuple(
_Call(
endpoint=endpoints[index % len(endpoints)],
stream=stream(index),
marker=uuid.uuid4().hex,
reference=reference(index),
)
for index in range(count)
)
def _assert_answered_with_its_own_marker(served: _Served) -> None:
assert served.status == 200, served.text
assert set(_MARKER.findall(served.text)) == {served.call.marker}, served.text
async def test_a_concurrent_mixed_burst_reaches_gemini_with_each_tool_result_in_its_own_form(gateway: Gateway) -> None:
calls: Final = _calls(
30, ("chat", "messages", "responses"), lambda index: index % 2 == 0, lambda index: (index // 2) % 2 == 0
)
with wire_server(_echo) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=f"gemini/{_BACKEND}", api_base=wire.url, api_key=_API_KEY)
served: Final = await _burst(str(gateway.client.base_url), gateway.key, model, calls)
assert len(served) == 30
for item in served:
_assert_answered_with_its_own_marker(item)
_assert_each_in_its_own_form(wire.drain(), calls)
landed: Final = _spend_rows_by_model(model, 30)
assert landed >= {
_response_id(call.marker) for call in calls if not (call.endpoint == "messages" and call.stream)
}
async def test_upstream_stream_drops_reach_callers_and_a_later_ref_tool_result_still_reaches_gemini(
gateway: Gateway,
) -> None:
calls: Final = _calls(12, ("chat", "messages", "responses"), lambda _: True, lambda _: True)
dropped: Final = frozenset(call.marker for index, call in enumerate(calls) if index % 3 == 0)
def respond(request: Request) -> Reply:
rejection: Final = _vendor_rejection(request)
if rejection is not None:
return rejection
marker: Final = _marker_of(request)
return _gemini_reply(marker, stream=True, abort_after=0 if marker in dropped else None)
with wire_server(respond) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=f"gemini/{_BACKEND}", api_base=wire.url, api_key=_API_KEY)
served: Final = await _burst(str(gateway.client.base_url), gateway.key, model, calls)
assert len(served) == 12
for item in served:
if item.call.marker in dropped:
assert item.status == 500, item.text
assert "marker-" not in item.text, item.text
else:
_assert_answered_with_its_own_marker(item)
recovery: Final = _Call(endpoint="chat", stream=True, marker=uuid.uuid4().hex, reference=True)
(recovered,) = await _burst(str(gateway.client.base_url), gateway.key, model, (recovery,))
_assert_answered_with_its_own_marker(recovered)
assert recovered.text.rstrip().endswith("data: [DONE]"), recovered.text
_assert_each_in_its_own_form(wire.drain(), (*calls, recovery))
async def test_a_slow_upstream_completes_every_ref_tool_result_stream_exactly_once(gateway: Gateway) -> None:
calls: Final = _calls(12, ("chat", "messages", "responses"), lambda _: True, lambda _: True)
def respond(request: Request) -> Reply:
rejection: Final = _vendor_rejection(request)
if rejection is not None:
return rejection
return _gemini_reply(_marker_of(request), stream=True, pause=0.5)
with wire_server(respond) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=f"gemini/{_BACKEND}", api_base=wire.url, api_key=_API_KEY)
served: Final = await _burst(str(gateway.client.base_url), gateway.key, model, calls)
assert len(served) == 12
for item in served:
_assert_answered_with_its_own_marker(item)
_assert_each_in_its_own_form(wire.drain(), calls)
_spend_rows_by_model(model, 12)
def _chaos_config(wire: Wire, tmp_path: Path) -> Path:
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
config["model_list"] = [
{
"model_name": _CONFIG_MODEL,
"litellm_params": {"model": f"gemini/{_BACKEND}", "api_base": wire.url, "api_key": _API_KEY},
}
]
path: Final = tmp_path / "gemini-tool-result-part-reference-chaos.yaml"
path.write_text(yaml.safe_dump(config))
return path
def _open_upstream_connections(pid: int, upstream: str) -> int:
port: Final = urlsplit(upstream).port
return sum(
1
for connection in psutil.Process(pid).net_connections(kind="tcp")
if connection.status == psutil.CONN_ESTABLISHED and connection.raddr and connection.raddr.port == port
)
def _non_stream_spend_ids(served: Sequence[_Served]) -> frozenset[str]:
return frozenset(_response_id(item.call.marker) for item in served)
@dataclass(frozen=True, slots=True)
class _HeldUpstream:
release: threading.Event
held: SimpleQueue[str]
def respond(self, request: Request) -> Reply:
self.held.put(_marker_of(request))
assert self.release.wait(timeout=120), "The burst was never released"
return _echo(request)
@pytest.mark.timeout(2 * graceful_stop_seconds() + 120)
async def test_worker_sigkill_mid_burst_leaves_the_sibling_sending_ref_tool_results_as_raw_text(
gateway: Gateway, tmp_path: Path
) -> None:
calls: Final = _calls(20, ("chat", "messages", "responses"), lambda _: False, lambda _: True)
upstream: Final = _HeldUpstream(threading.Event(), SimpleQueue())
with wire_server(upstream.respond) as wire:
path: Final = _chaos_config(wire, tmp_path)
with owned_proxy_process(gateway, tmp_path, {}, config=path, workers=2) as owned:
candidate: Final = owned.gateway
workers: Final = eventually(
lambda: tuple(int(pid) for pid in _STARTED_WORKER.findall(owned.log.read_text())),
lambda pids: len(pids) == 2,
seconds=graceful_stop_seconds() + 60,
)
burst: Final = asyncio.create_task(
_burst(
str(candidate.client.base_url), candidate.key, _CONFIG_MODEL, calls, tolerate_transport_errors=True
)
)
await asyncio.to_thread(eventually, upstream.held.qsize, lambda size: size == 20, 60)
held_by: Final = MappingProxyType({pid: _open_upstream_connections(pid, wire.url) for pid in workers})
assert sum(held_by.values()) == 20, held_by
victim_pid, survivor_pid = sorted(workers, key=held_by.__getitem__)
with owned.replacing_worker(victim_pid) as kill:
kill.freeze()
await asyncio.to_thread(kill)
upstream.release.set()
served: Final = await burst
assert held_by[survivor_pid] >= 10, held_by
assert len(served) == held_by[survivor_pid], (held_by, len(served))
for item in served:
_assert_answered_with_its_own_marker(item)
follow_up: Final = _Call(endpoint="messages", stream=False, marker=uuid.uuid4().hex, reference=True)
(answered,) = await _burst(str(candidate.client.base_url), candidate.key, _CONFIG_MODEL, (follow_up,))
_assert_answered_with_its_own_marker(answered)
received: Final = wire.drain()
assert {(request.method, request.target) for request in received} == {("POST", _GENERATE)}, received
_assert_each_in_its_own_form(received, (*calls, follow_up))
assert _spend_status_by_request(_non_stream_spend_ids((*served, answered))) == {
spend_id: "success" for spend_id in _non_stream_spend_ids((*served, answered))
}
@pytest.mark.timeout(600)
async def test_proxy_sigterm_mid_burst_finishes_in_flight_ref_tool_results_and_a_relaunch_serves_again(
gateway: Gateway, tmp_path: Path
) -> None:
calls: Final = _calls(9, ("chat", "messages", "responses"), lambda _: False, lambda _: True)
upstream: Final = _HeldUpstream(threading.Event(), SimpleQueue())
with wire_server(upstream.respond) as wire:
first: Final = tmp_path / "first"
first.mkdir()
with owned_proxy_process(gateway, first, {}, config=_chaos_config(wire, first), workers=2) as owned:
burst: Final = asyncio.create_task(
_burst(
str(owned.gateway.client.base_url),
owned.gateway.key,
_CONFIG_MODEL,
calls,
tolerate_transport_errors=True,
)
)
await asyncio.to_thread(eventually, upstream.held.qsize, lambda size: size == len(calls), 120)
owned.begin_shutdown()
upstream.release.set()
served: Final = await burst
exit_code: Final = await asyncio.to_thread(
eventually, owned.process.poll, lambda code: code is not None, 120
)
assert exit_code is not None
assert len(served) == len(calls), (len(served), len(calls))
for item in served:
_assert_answered_with_its_own_marker(item)
_assert_each_in_its_own_form(wire.drain(), calls)
second: Final = tmp_path / "second"
second.mkdir()
with owned_proxy_process(gateway, second, {}, config=_chaos_config(wire, second), workers=2) as relaunched:
follow_up: Final = _Call(endpoint="chat", stream=False, marker=uuid.uuid4().hex, reference=True)
(answered,) = await _burst(
str(relaunched.gateway.client.base_url), relaunched.gateway.key, _CONFIG_MODEL, (follow_up,)
)
_assert_answered_with_its_own_marker(answered)
_assert_each_in_its_own_form(wire.drain(), (follow_up,))
assert _spend_status_by_request(_non_stream_spend_ids((*served, answered))) == {
spend_id: "success" for spend_id in _non_stream_spend_ids((*served, answered))
}

File diff suppressed because it is too large Load diff

View file

@ -1373,6 +1373,167 @@ def test_convert_gemini_tool_call_result_with_data_url_extra_params():
)
def _gemini_tool_result_fixture(
content: str,
) -> tuple[ChatCompletionToolMessage, dict]:
message = ChatCompletionToolMessage(
role="tool",
tool_call_id="call_schema",
content=content,
)
last_message_with_tool_calls = {
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_schema",
"type": "function",
"index": 0,
"function": {"name": "inspect_schema", "arguments": "{}"},
}
],
}
return message, last_message_with_tool_calls
def test_convert_gemini_tool_result_wraps_output_containing_json_schema_ref():
"""
Gemini treats {"$ref": ...} objects inside function_response.response as
references to named parts in function_response.parts, so schema-bearing
tool output must be delivered opaquely instead of structurally.
Fixes: https://github.com/BerriAI/litellm/issues/38223
"""
schema_json = (
'{"$defs":{"humanScalar":{"type":"string"}},'
'"type":"object","properties":{"value":{"$ref":"#/$defs/humanScalar"}}}'
)
message, last_message_with_tool_calls = _gemini_tool_result_fixture(schema_json)
result = convert_to_gemini_tool_call_result(
message=message,
last_message_with_tool_calls=last_message_with_tool_calls,
)
function_response = result["function_response"]
assert function_response["name"] == "inspect_schema"
assert function_response["response"] == {"content": schema_json}
def test_convert_gemini_tool_result_wraps_deeply_nested_json_schema_ref():
"""
A $ref key at any depth must trigger opaque delivery.
Fixes: https://github.com/BerriAI/litellm/issues/38223
"""
nested_json = '{"results":[{"schemas":[{"node":{"$ref":"#/$defs/deep"}}]}]}'
message, last_message_with_tool_calls = _gemini_tool_result_fixture(nested_json)
result = convert_to_gemini_tool_call_result(
message=message,
last_message_with_tool_calls=last_message_with_tool_calls,
)
function_response = result["function_response"]
assert function_response["response"] == {"content": nested_json}
def test_convert_gemini_tool_result_wraps_non_schema_string_ref():
"""Gemini reads any non-empty string $ref as a part name, a MongoDB DBRef included."""
dbref_json = '{"owner":{"$ref":"users","$id":{"$oid":"64b7f0c2a1"}}}'
message, last_message_with_tool_calls = _gemini_tool_result_fixture(dbref_json)
result = convert_to_gemini_tool_call_result(
message=message,
last_message_with_tool_calls=last_message_with_tool_calls,
)
assert result["function_response"]["response"] == {"content": dbref_json}
def test_convert_gemini_tool_result_wraps_top_level_ref():
"""A $ref on the tool result's root object is read as a part name too."""
root_ref_json = '{"$ref":"#/$defs/Config","$defs":{"Config":{"type":"object"}}}'
message, last_message_with_tool_calls = _gemini_tool_result_fixture(root_ref_json)
result = convert_to_gemini_tool_call_result(
message=message,
last_message_with_tool_calls=last_message_with_tool_calls,
)
assert result["function_response"]["response"] == {"content": root_ref_json}
def _json_unicode_escape(text: str) -> str:
return "".join(f"\\u{ord(char):04x}" for char in text)
@pytest.mark.parametrize(
"ref_key",
["$ref"[:index] + _json_unicode_escape("$ref"[index]) + "$ref"[index + 1 :] for index in range(4)],
)
def test_convert_gemini_tool_result_wraps_unicode_escaped_ref_key(ref_key: str):
"""A $ref key spelled with JSON unicode escapes still decodes to $ref, so it must be wrapped too."""
escaped_json = '{"v":{"' + ref_key + '":"#/a"}}'
message, last_message_with_tool_calls = _gemini_tool_result_fixture(escaped_json)
result = convert_to_gemini_tool_call_result(
message=message,
last_message_with_tool_calls=last_message_with_tool_calls,
)
assert result["function_response"]["response"] == {"content": escaped_json}
@pytest.mark.parametrize(
"schema_json",
[
'{"$defs":{"Foo":{"type":"string"}},"type":"object","properties":{"bar":{"type":"string"}}}',
'{"definitions":{"Foo":{"type":"string"}},"type":"object","properties":{"bar":{"type":"string"}}}',
'{"status":"ok","count":2,"tags":["a","b"]}',
'{"v":{"$ref":null}}',
'{"v":{"$ref":""}}',
'{"v":{"$ref":5}}',
'{"v":{"$ref":true}}',
'{"v":{"$ref":{"a":1}}}',
'{"v":{"$ref":["#/a"]}}',
'{"columns":["user","name"],"rows":[[{"$ref":"#/users/1"},"alice"]]}',
'{"a":[1,[{"b":{"$ref":"x"}}]]}',
],
)
def test_convert_gemini_tool_result_preserves_structured_passthrough_without_part_references(
schema_json: str,
):
"""
Gemini only rejects a $ref holding a non-empty string outside a list nested
directly in a list, so tool output with no $ref, a $ref holding any other
value, or a $ref under a list of lists stays structured.
Google does not document that rule. It was observed on the Gemini API and
Vertex AI (gemini-3.8-flash, gemini-4-argon) on 2026-10-15.
Fixes: https://github.com/BerriAI/litellm/issues/38223
"""
message, last_message_with_tool_calls = _gemini_tool_result_fixture(schema_json)
result = convert_to_gemini_tool_call_result(
message=message,
last_message_with_tool_calls=last_message_with_tool_calls,
)
function_response = result["function_response"]
assert function_response["name"] == "inspect_schema"
assert function_response["response"] == json.loads(schema_json)
def test_convert_gemini_tool_result_malformed_json_still_wrapped_as_content():
"""Unparseable tool output keeps taking the existing content-wrapping path."""
message, last_message_with_tool_calls = _gemini_tool_result_fixture("{not valid json")
result = convert_to_gemini_tool_call_result(
message=message,
last_message_with_tool_calls=last_message_with_tool_calls,
)
assert result["function_response"]["response"] == {"content": "{not valid json"}
def test_bedrock_tools_unpack_defs():
"""
Test that the unpack_defs method handles nested $ref inside anyOf items correctly

View file

@ -415,6 +415,52 @@ def test_empty_content_handling():
assert contents[0]["parts"][0]["text"] == ""
def test_transform_request_body_wraps_tool_result_with_json_schema_ref():
"""
Regression: Gemini treats {"$ref": ...} objects inside functionResponse.response
as references to named parts in function_response.parts and rejects the request.
The shared request-body transformation must deliver schema-bearing tool output
opaquely instead of structurally.
Fixes: https://github.com/BerriAI/litellm/issues/38223
"""
schema_json = (
'{"$defs":{"humanScalar":{"type":"string"}},'
'"type":"object","properties":{"value":{"$ref":"#/$defs/humanScalar"}}}'
)
messages = [
{"role": "user", "content": "Inspect this schema."},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_schema",
"type": "function",
"function": {"name": "inspect_schema", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "call_schema", "content": schema_json},
{"role": "user", "content": "Acknowledge the result."},
]
result = transform_request_body(
messages=messages,
model="gemini-2.0-flash",
optional_params={},
custom_llm_provider="vertex_ai",
litellm_params={},
cached_content=None,
)
parts = [part for content in result["contents"] for part in content.get("parts", [])]
function_responses = [
part[key] for part in parts for key in ("function_response", "functionResponse") if key in part
]
assert len(function_responses) == 1
assert function_responses[0]["response"] == {"content": schema_json}
def test_thought_signature_extraction_from_response():
"""Test that thought signatures are extracted from Gemini response parts and stored in provider_specific_fields"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (