Skip to content

Callable tools: optional scalar parameters (int | None) get "type": "object" in parameters_json_schema, so Gemini sends {} #3060

Closed

Description

@ithinkihaveacat

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

Workaround

Pass explicit declarations built with
FunctionDeclaration.from_callable_with_api_option(callable=fn) instead of
bare callables.

Activity

  1. added
    type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.
    priority: p2Moderately-important priority. Fix may not be included in next release.
    on Oct 6, 2026
  2. Venkaiahbabuneelam commented on Oct 7, 2026

    @Venkaiahbabuneelam

    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

  3. added a commit that references this issue on Oct 9, 2026
    fcd9f8f
  4. added 2 commits that reference this issue on Oct 14, 2026
    586a4b6
    be32e9e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

priority: p2Moderately-important priority. Fix may not be included in next release.type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions