馃敶 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:
pip install google-adk (reproduced on main @ 3f4bb8f).
- 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.
馃敶 Required Information
Describe the Bug:
RestApiToolignores a query parameter's OpenAPIstyleandexplode. It passes the model's value straight to httpx: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 asfilter={'status': 'open'}. That's wrong for every style. Non-exploded and delimited arrays (explode: false,spaceDelimited,pipeDelimited) are also sent exploded.ApiParameterdoesn't recordstyle/explodeat 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[...]-styledeepObjectparams. The API then either ignores the filter, which silently returns unfiltered data to the agent, or rejects the request.Steps to Reproduce:
pip install google-adk(reproduced onmain@ 3f4bb8f).RunnerplusLlmAgentplusOpenAPIToolset, a mocked model, and anhttpx.MockTransportbackend.Expected Behavior:
Per the OpenAPI 3 serialization rules:
status=open&priority=P1&filter[assignee]=me&fields=id,title.Observed Behavior:
Environment Details:
main@ 3f4bb8fModel Information:
馃煛 Optional Information
Minimal Reproduction Code:
Suggested fix: carry
style/explodefrom the spec'sParameterintoApiParameter(optional fields, so serialized parameters still load), and serialize query values accordingly:form, exploded, the default), bracketed (deepObject), ork,v,...(form, not exploded);,,, or|;I have a PR ready with tests.
How often has this issue occurred?: