Repository navigation
Callable tools: optional scalar parameters (int | None) get "type": "object" in parameters_json_schema, so Gemini sends {} #3060
Description
Environment
- google-genai 2.25.0 (latest on PyPI as of 2026-10-06), pydantic 2.13.5
- Python 3.13.5, Gemini Developer API, model
gemini-pro-latest
Summary
When a plain Python function is passed in tools=[...], an optional scalar
parameter such as start_line: int | None = None is declared as:
{"anyOf": [{"type": "integer"}, {"type": "null"}], "type": "object", "default": null}anyOf already allows only an integer or null. The extra "type": "object"
adds a requirement that the value also be an object, which no integer meets.
The model follows the object part and fills the argument with {}. Calling
the function with the returned args then fails, e.g. max(1, {}) raises
TypeError: '>' not supported between instances of 'dict' and 'int'.
Cause
t_tool in _transformers.py builds callable tools with
FunctionDeclaration.from_callable(..., use_json_schema=True). That path,
parse_function_declaration_json_schema in _automatic_function_calling_util.py,
does:
# pydantic doesn't assign the `type` field when the schema has 'anyOf'.
if not 'type' in json_schema_dict and 'anyOf' in json_schema_dict:
json_schema_dict['type'] = 'object'pydantic's schema for int | None is already complete without type. Setting
it to 'object' is only correct for unions of object types, and it breaks
every optional scalar (int | None, str | None, bool | None, ...).
Reproduction
# /// script
# dependencies = ["google-genai"]
# ///
import json
import os
from google import genai
from google.genai import types
def read_file(path: str, start_line: int | None = None, end_line: int | None = None) -> dict:
"""Reads a file, optionally only lines start_line to end_line (1-indexed)."""
return {}
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
config = types.GenerateContentConfig(
tools=[read_file],
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
tool_config=types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(mode="ANY")
),
)
for i in range(3):
response = client.models.generate_content(
model="gemini-pro-latest",
contents="Read lines 100 to 120 of CONCEPT.md.",
config=config,
)
for call in response.function_calls or []:
print(json.dumps(call.args))Output, even though the prompt names the lines explicitly:
{"start_line": {}, "end_line": {}, "path": "CONCEPT.md"}
{"path": "CONCEPT.md", "end_line": {}, "start_line": {}}
{"end_line": {}, "path": "CONCEPT.md", "start_line": {}}
The generated schema can be inspected without an API call:
from google.genai import _transformers
print(_transformers.t_tool(client._api_client, read_file).model_dump(mode="json", exclude_none=True))Expected behavior
The model sends integers for start_line/end_line, or leaves them out.
Building the declaration through the non-JSON-schema path does exactly that:
types.Tool(function_declarations=[
types.FunctionDeclaration.from_callable_with_api_option(callable=read_file)
])This yields {"type": "INTEGER", "nullable": true}. Over 8 trials of a similar
prompt, the model sent integers every time with that schema, and {} every
time with the anyOf + object schema.
Suggested fix
The type: 'object' default exists for a reason: some backends reject an
anyOf schema without a top-level type. See #1807, where Vertex AI fails
with "schema didn't specify the schema type field", and
google/adk-python#3424. So the fix should keep a top-level type but derive
it from the union's non-null members, falling back to 'object' only when
they are objects:
if 'type' not in json_schema_dict and 'anyOf' in json_schema_dict:
member_types = {
m.get('type') for m in json_schema_dict['anyOf'] if m.get('type') != 'null'
}
json_schema_dict['type'] = (
member_types.pop() if len(member_types) == 1 and None not in member_types
else 'object'
)I tested the candidate schemas against the Gemini Developer API
(gemini-pro-latest, prompt "Read lines 100 to 120 of CONCEPT.md.", 3 calls
each):
Schema for start_line / end_line |
Args returned |
|---|---|
Current: anyOf[integer, null] + "type": "object" |
{} 3/3 |
anyOf[integer, null], no type |
100/120 3/3 |
anyOf[integer, null] + "type": "integer" (proposed) |
100/120 3/3 |
Collapsed: "type": ["integer", "null"] |
100/120 3/3 |
Leaving type unset also works on the Developer API, but it would likely
bring back the Vertex errors in #1807. Collapsing anyOf: [X, null] into a
type list is an alternative if Vertex accepts type lists. I couldn't test
either option on Vertex.
Related
- VertexAI schema validation issue with complex Union Types #1807: why the
type: 'object'default was added (Vertex AI rejectsanyOf
withouttype). - MCP tools with
anyOfschemas fail Gemini validation - missing top-leveltypefield google/adk-python#3424: the same missing-typevalidation failure for MCP
tools. - [BUG] AI Gateway Gemini provider forwards function parameters as unsanitized parametersJsonSchema, so Gemini returns empty arguments for nested object parameters mlflow/mlflow#26359: the same symptom (Gemini returns empty arguments for
anyOf: [T, null]) from a different source, mlflow's gateway forwarding
OpenAI schemas. - Schema.from_json_schema() loses description and title when unwrapping nullable any_of schemas #1992 and fix: preserve schema metadata for nullable anyOf #2917: nullable-
anyOfunwrapping dropstitle/description. A
different code path (Schema.from_json_schema), in the same area.
Workaround
Pass explicit declarations built with
FunctionDeclaration.from_callable_with_api_option(callable=fn) instead of
bare callables.
Activity
Venkaiahbabuneelam commentedon Oct 7, 2026
Hi @ithinkihaveacat, Thank you for bringing this to our attention. We truly appreciate you flagging this issue regarding optional tool parameters receiving "type": "object" and returning {}, we will file a bug internally.
Thanks
Infer scalar type from anyOf members for optional parameters
fix: fix the function declaration parser for union types. Fixes #3060
fix: fix the function declaration parser for union types. Fixes #3060