Skip to content

RestApiTool ignores query parameter style/explode: object query params are sent as a Python dict repr#7204

Description

@1aifanatic

馃敶 Required Information

Describe the Bug:

RestApiTool ignores a query parameter's OpenAPI style and explode. It passes the model's value straight to httpx:

elif param_location == "query":
  if v is not None:
    query_params[original_k] = v

httpx repeats the key for a list, which happens to match the default (style: form, explode: true) for arrays. But it serializes a dict as its Python repr, so any object-typed query parameter goes out as filter={'status': 'open'}. That's wrong for every style. Non-exploded and delimited arrays (explode: false, spaceDelimited, pipeDelimited) are also sent exploded. ApiParameter doesn't record style/explode at all, so the spec's instructions are dropped at parse time.

Object query parameters are common in enterprise APIs: search/filter objects, JSON:API filter[...], metadata[...]-style deepObject params. The API then either ignores the filter, which silently returns unfiltered data to the agent, or rejects the request.

Steps to Reproduce:

  1. pip install google-adk (reproduced on main @ 3f4bb8f).
  2. Run the script below: a real Runner plus LlmAgent plus OpenAPIToolset, a mocked model, and an httpx.MockTransport backend.

Expected Behavior:
Per the OpenAPI 3 serialization rules: status=open&priority=P1&filter[assignee]=me&fields=id,title.

Observed Behavior:

query sent: match={'status':+'open',+'priority':+'P1'}&filter={'assignee':+'me'}&fields=id&fields=title

Environment Details:

  • ADK Library Version (pip show google-adk): main @ 3f4bb8f
  • Desktop OS: Windows 11
  • Python Version (python -V): 3.12.10

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: N/A (mocked model; the bug is in request building)

馃煛 Optional Information

Minimal Reproduction Code:

import asyncio

import httpx
from google.adk.agents.llm_agent import LlmAgent
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import InMemoryRunner
from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_toolset import OpenAPIToolset
from google.genai import types

spec = {
    "openapi": "3.0.0",
    "info": {"title": "Tickets", "version": "1"},
    "servers": [{"url": "https://itsm.example.com"}],
    "paths": {"/tickets": {"get": {
        "operationId": "listTickets",
        "parameters": [
            # style: form, explode: true (the OpenAPI default for query)
            {"name": "match", "in": "query",
             "schema": {"type": "object", "properties": {"status": {"type": "string"}, "priority": {"type": "string"}}}},
            {"name": "filter", "in": "query", "style": "deepObject", "explode": True,
             "schema": {"type": "object", "properties": {"assignee": {"type": "string"}}}},
            {"name": "fields", "in": "query", "style": "form", "explode": False,
             "schema": {"type": "array", "items": {"type": "string"}}},
        ],
        "responses": {"200": {"description": "ok"}},
    }}},
}
sent = []


def handler(request: httpx.Request):
  sent.append(request.url)
  return httpx.Response(200, json={"tickets": []})


class FakeModel(BaseLlm):
  model: str = "fake"
  calls: int = 0

  async def generate_content_async(self, llm_request, stream=False):
    self.calls += 1
    part = (types.Part.from_function_call(name="list_tickets", args={
        "match": {"status": "open", "priority": "P1"},
        "filter": {"assignee": "me"},
        "fields": ["id", "title"],
    }) if self.calls == 1 else types.Part.from_text(text="done"))
    yield LlmResponse(content=types.Content(role="model", parts=[part]))


agent = LlmAgent(
    name="itsm_agent",
    model=FakeModel(),
    tools=[OpenAPIToolset(
        spec_dict=spec,
        httpx_client_factory=lambda: httpx.AsyncClient(transport=httpx.MockTransport(handler)),
    )],
)


async def main():
  runner = InMemoryRunner(agent=agent)
  session = await runner.session_service.create_session(app_name=runner.app_name, user_id="u")
  async for _ in runner.run_async(
      user_id="u", session_id=session.id,
      new_message=types.Content(role="user", parts=[types.Part(text="my open P1 tickets")])):
    pass
  from urllib.parse import unquote
  print("query sent:", unquote(sent[0].query.decode()))


asyncio.run(main())

Suggested fix: carry style/explode from the spec's Parameter into ApiParameter (optional fields, so serialized parameters still load), and serialize query values accordingly:

  • objects are spread (form, exploded, the default), bracketed (deepObject), or k,v,... (form, not exploded);
  • non-exploded arrays are joined with ,, , or |;
  • scalars and default arrays are unchanged.

I have a PR ready with tests.

How often has this issue occurred?:

  • Always (100%): any object-typed or non-exploded query parameter.

Activity

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

Metadata

Metadata

Assignees

Labels

tools[Component] This issue is related to tools

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions