Skip to content

fix(tools): declare repeated Google API query parameters as arrays - #7639

Open
Feldt wants to merge 1 commit into
google:mainfrom
Feldt:fix/google-api-repeated-params
Open

Feldt wants to merge 1 commit into
google:mainfrom
Feldt:fix/google-api-repeated-params

Conversation

@Feldt

@Feldt Feldt commented Oct 20, 2026

Copy link
Copy Markdown

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

2. Or, if no issue exists, describe the change:

Problem:

GoogleApiToOpenApiConverter._convert_parameter_schema copies type, enum, format, default and pattern from each discovery parameter, but it ignores repeated: true. Every repeated query parameter therefore comes out as a scalar, both in the generated OpenAPI spec and in the function declaration the model sees. Some examples from the built-in toolsets:

  • SheetsToolset: spreadsheets.values.batchGet ranges
  • GmailToolset: users.messages.list labelIds
  • CalendarToolset: events.list eventTypes
  • YoutubeToolset: videos.list id

Because the declaration says string, the model can ask for only one value. For example, it cannot read two ranges in one batchGet call, and a list does not match the declared schema. The discovery documents bundled with google-api-python-client 2.198.0 contain 975 repeated parameters (all location: query), and none of them was declared as an array.

Request building already handles lists. RestApiTool passes query values to httpx unchanged, and httpx encodes a list as repeated keys (?ranges=a&ranges=b), which is what Google APIs expect. Only the declared schema is wrong.

Steps to reproduce (this needs Application Default Credentials, like any GoogleApiToolset):

from google.adk.tools.google_api_tool.googleapi_to_openapi_converter import (
    GoogleApiToOpenApiConverter,
)

spec = GoogleApiToOpenApiConverter("sheets", "v4").convert()
op = spec["paths"]["/v4/spreadsheets/{spreadsheetId}/values:batchGet"]["get"]
print(next(p["schema"] for p in op["parameters"] if p["name"] == "ranges"))
  • Expected: {'type': 'array', 'items': {'type': 'string'}}
  • Observed: {'type': 'string'}
  • Environment: main at c08225d (google-adk 2.12.0), macOS, Python 3.12.14.
  • Model: LiteLLM not used; no model needed, because the bug is in the generated declaration.

Solution:

When a discovery parameter has repeated: true, the converter now wraps the schema it has built in {"type": "array", "items": <that schema>}. A scalar default moves to the array as a one-item list, so the default still matches the schema. Only one bundled document has a repeated parameter with a default: Blogger blogs.listByUser status, default LIVE. Parameters without repeated are unchanged. The fix is 8 lines in that one function.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally. The affected suites pass on Python 3.11 to 3.14. The full suite was run on 3.12 only, in a venv without every optional extra, and it has the same environment-only failures on base and on this branch (details below).

New tests in tests/unittests/tools/google_api_tool/test_googleapi_to_openapi_converter.py:

  • three new test_convert_parameter_schema cases: a repeated string, a repeated enum, and a repeated parameter with a default;
  • test_convert_repeated_query_parameter_to_array: a repeated eventTypes query parameter on the Calendar events.list fixture comes out of convert() as an array of the enum.

On the base commit (fix not applied, Python 3.12), all 4 new cases fail because the schema type is 'string' where 'array' is expected. With the fix:

$ pytest tests/unittests/tools/google_api_tool/test_googleapi_to_openapi_converter.py
29 passed

$ pytest tests/unittests/tools/google_api_tool tests/unittests/tools/openapi_tool
477 passed, 59 warnings

Both commands were run in a separate venv for each Python version CI tests:

Python converter tests google_api_tool + openapi_tool
3.11.16 29 passed 477 passed
3.12.14 29 passed 477 passed
3.13.15 29 passed 477 passed
3.14.7 29 passed 477 passed

pyink, isort, ruff, codespell and scripts/compliance_checks.py are clean on the changed files. mypy reports no issues on the converter, before or after the change, with --python-version 3.11, 3.12 and 3.13.

pytest tests/unittests -n 4 was also run on Python 3.12, on base c08225d and on this branch. The local venv does not have every optional extra (agentplatform, a2a, bs4, sqlalchemy, mcp, ...), so base reports 208 failures and 176 errors. They come from that environment: packages that are missing, or installed at versions the release-bounds test does not expect. This branch has the same failures plus one: tests/unittests/workflow/test_workflow_parallel_worker.py::test_parallel_worker_preserves_input_order_regardless_of_completion_order. That test depends on asyncio.sleep timing and does not load the converter. Re-run on its own on the same busy machine, it also failed on base (1 of 6 runs) and on this branch (2 of 5 runs). No failure is in google_api_tool or openapi_tool. This branch passes 12155 tests and base passes 12152: the 4 new cases, minus that timing test.

The full tox matrix (py311 to py314) that CONTRIBUTING.md asks for before a PR was not run. On 3.11, 3.13 and 3.14, only the tests in the table above were run. The full suite and the E2E runs below ran on Python 3.12 only.

Manual End-to-End (E2E) Tests:

Setup: one LlmAgent with SheetsToolset(tool_filter=["sheets_spreadsheets_values_batch_get"]) runs through InMemoryRunner. The toolset uses the discovery document bundled with google-api-python-client, so it needs no network. Three boundaries are stubbed:

  • the model: a scripted BaseLlm that records the ranges declaration it receives, then calls the tool with ranges=["Sheet1!A1:B2", "Sheet2!C1:D2"];
  • HTTP: an httpx.MockTransport that records the outgoing URL;
  • credentials: anonymous credentials for discovery, plus a pre-set access token.

Each mode was run before and after the fix: the default JSON-schema declarations, and --legacy-schema, which turns JSON_SCHEMA_FOR_FUNC_DECL off to exercise the types.Schema path. In the output below, warning lines are trimmed, and the legacy runs omit the function_call/function_response lines, which are identical.

## BEFORE (base c08225da)
$ python e2e_runner.py
function_call -> sheets_spreadsheets_values_batch_get {"spreadsheet_id": "abc", "ranges": ["Sheet1!A1:B2", "Sheet2!C1:D2"]}
function_response -> {"spreadsheetId": "abc", "valueRanges": []}
JSON_SCHEMA_FOR_FUNC_DECL enabled: True
declared 'ranges' the model saw (description omitted) -> {"type": "string"}
request sent -> https://sheets.googleapis.com/v4/spreadsheets/abc/values:batchGet?ranges=Sheet1%21A1%3AB2&ranges=Sheet2%21C1%3AD2
$ python e2e_runner.py --legacy-schema
JSON_SCHEMA_FOR_FUNC_DECL enabled: False
declared 'ranges' the model saw (description omitted) -> {"type": "STRING"}
request sent -> https://sheets.googleapis.com/v4/spreadsheets/abc/values:batchGet?ranges=Sheet1%21A1%3AB2&ranges=Sheet2%21C1%3AD2

## AFTER (this branch)
$ python e2e_runner.py
function_call -> sheets_spreadsheets_values_batch_get {"spreadsheet_id": "abc", "ranges": ["Sheet1!A1:B2", "Sheet2!C1:D2"]}
function_response -> {"spreadsheetId": "abc", "valueRanges": []}
JSON_SCHEMA_FOR_FUNC_DECL enabled: True
declared 'ranges' the model saw (description omitted) -> {"items": {"type": "string"}, "type": "array"}
request sent -> https://sheets.googleapis.com/v4/spreadsheets/abc/values:batchGet?ranges=Sheet1%21A1%3AB2&ranges=Sheet2%21C1%3AD2
$ python e2e_runner.py --legacy-schema
JSON_SCHEMA_FOR_FUNC_DECL enabled: False
declared 'ranges' the model saw (description omitted) -> {"items": {"type": "STRING"}, "type": "ARRAY"}
request sent -> https://sheets.googleapis.com/v4/spreadsheets/abc/values:batchGet?ranges=Sheet1%21A1%3AB2&ranges=Sheet2%21C1%3AD2

The request URL is the same before and after, because the scripted model sends a list either way. What changes is the declaration: before the fix, the model is told ranges is a single string.

A second script converted all 585 bundled discovery documents, then built every tool declaration of the seven built-in toolsets in both declaration modes. Nothing raised. Results:

before after
repeated parameters declared as array 0 / 975 975 / 975
non-repeated parameters declared as array 0 0
array arguments, Sheets / Gmail / Calendar / YouTube / BigQuery 16 / 21 / 16 / 2 / 28 18 / 26 / 22 / 77 / 29
e2e_runner.py
"""Manual E2E: SheetsToolset batchGet through a Runner."""

import asyncio
import json
import sys
from typing import AsyncGenerator

import google.auth
import google.auth.credentials

google.auth.default = lambda *args, **kwargs: (
    google.auth.credentials.AnonymousCredentials(),
    None,
)

from google.adk.agents.llm_agent import LlmAgent
from google.adk.auth.auth_credential import AuthCredential
from google.adk.auth.auth_credential import AuthCredentialTypes
from google.adk.auth.auth_credential import OAuth2Auth
from google.adk.features import FeatureName
from google.adk.features import is_feature_enabled
from google.adk.features import override_feature_enabled
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import InMemoryRunner
from google.adk.tools.google_api_tool import SheetsToolset
from google.genai import types
import httpx

TOOL = "sheets_spreadsheets_values_batch_get"
RANGES = ["Sheet1!A1:B2", "Sheet2!C1:D2"]
sent_urls: list[str] = []
seen_declarations: list[dict] = []


class ScriptedModel(BaseLlm):
  model: str = "scripted"

  async def generate_content_async(
      self, llm_request: LlmRequest, stream: bool = False
  ) -> AsyncGenerator[LlmResponse, None]:
    for tool in llm_request.config.tools or []:
      for decl in tool.function_declarations or []:
        if decl.name == TOOL:
          if decl.parameters_json_schema is not None:
            props = decl.parameters_json_schema["properties"]
          else:
            props = decl.parameters.model_dump(
                mode="json", exclude_none=True
            )["properties"]
          seen_declarations.append(props["ranges"])
    last = llm_request.contents[-1].parts[0]
    if last.function_response is None:
      yield LlmResponse(
          content=types.Content(
              role="model",
              parts=[
                  types.Part.from_function_call(
                      name=TOOL,
                      args={"spreadsheet_id": "abc", "ranges": RANGES},
                  )
              ],
          )
      )
    else:
      yield LlmResponse(
          content=types.Content(
              role="model", parts=[types.Part.from_text(text="done")]
          )
      )


def handler(request: httpx.Request) -> httpx.Response:
  sent_urls.append(str(request.url))
  return httpx.Response(200, json={"spreadsheetId": "abc", "valueRanges": []})


async def main() -> None:
  toolset = SheetsToolset(tool_filter=[TOOL])
  (tool,) = await toolset.get_tools()
  rest_tool = tool._rest_api_tool
  rest_tool.auth_credential = AuthCredential(
      auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
      oauth2=OAuth2Auth(
          client_id="id", client_secret="secret", access_token="token"
      ),
  )
  rest_tool._httpx_client_factory = lambda: httpx.AsyncClient(
      transport=httpx.MockTransport(handler)
  )
  rest_tool._allow_private_network_access = True  # skip DNS for the mock

  agent = LlmAgent(name="sheets_agent", model=ScriptedModel(), tools=[tool])
  runner = InMemoryRunner(agent=agent, app_name="e2e")
  session = await runner.session_service.create_session(
      app_name="e2e", user_id="u"
  )
  message = types.Content(
      role="user", parts=[types.Part.from_text(text="Read both ranges")]
  )
  async for event in runner.run_async(
      user_id="u", session_id=session.id, new_message=message
  ):
    for part in event.content.parts if event.content else []:
      if part.function_call:
        print("function_call ->", part.function_call.name,
              json.dumps(part.function_call.args))
      if part.function_response:
        print("function_response ->",
              json.dumps(part.function_response.response))

  print("JSON_SCHEMA_FOR_FUNC_DECL enabled:",
        is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL))
  declared = {k: v for k, v in seen_declarations[0].items()
              if k != "description"}
  print("declared 'ranges' the model saw (description omitted) ->",
        json.dumps(declared, sort_keys=True))
  print("request sent ->", sent_urls[0])
  await toolset.close()


if "--legacy-schema" in sys.argv:
  override_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, False)
asyncio.run(main())

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code. (Self-review by the AI agent, against the repo's adk-review checklist.)
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes. The affected suites pass; for the full suite, see the note under Unit Tests.
  • I have manually tested my changes end-to-end. (Run by the AI agent; the logs above are its output.)
  • Any dependent changes have been merged and published in downstream modules. (None are needed.)

Additional context

This change was prepared with an AI coding agent (Claude Code), which found the bug, wrote the fix and tests, and ran the commands above. I am responsible for the change and will answer review comments.

Repeated parameters that are not strings take the same path. For example, Fitness users.sessions.list activityType (an integer) becomes an array of integers.

🤖 Generated with Claude Code

GoogleApiToOpenApiConverter copied type, enum, format, default and pattern
from each discovery parameter but ignored `repeated: true`. Every repeated
query parameter (Sheets values.batchGet `ranges`, Gmail messages.list
`labelIds`, Calendar events.list `eventTypes`, YouTube videos.list `id`)
was therefore declared to the model as a single scalar, so the model could
ask for only one value.

Wrap the schema of a repeated parameter in an array of that schema, and
turn its default into a one-item list. RestApiTool already sends a list
query value as repeated keys (`?ranges=a&ranges=b`), so only the
declaration changes.
@google-cla

google-cla Bot commented Oct 20, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant