Skip to content

RestApiTool silently sends an empty request body when the OpenAPI schema uses oneOf/anyOf/allOf #6991

Description

@elbourne12345

🔴 Required Information

Describe the Bug:

For an OpenAPI operation whose request body schema is polymorphic (oneOf, anyOf,
allOf) or has no type, RestApiTool generates a correct function declaration with a
body parameter, the model supplies that argument — and then the HTTP request is sent
with no body at all. No exception, no warning, no log line. Content-Type: application/json is still set, so the server receives a well-formed request with
Content-Length: 0.

The two halves of one contract disagree:

  • OperationParser._process_request_body names a polymorphic/untyped body parameter
    'body' (operation_parser.py:198-200 in 2.8.0):

    # Prefer explicit body name to avoid empty keys when schema lacks type
    # information (e.g., oneOf/anyOf/allOf) while retaining legacy behavior
    # for simple scalar types.
    if schema.oneOf or schema.anyOf or schema.allOf:
        param_name = 'body'
    elif not schema.type:
        param_name = 'body'
    else:
        param_name = ''
  • But RestApiTool._prepare_request_params only attaches a full body for a parameter
    whose original_name is empty (rest_api_tool.py:453 in 2.8.0):

    else:  # like string
        for param in parameters:
            # original_name = '' indicating this param applies to the full body.
            if param.param_location == "body" and not param.original_name:
                body_data = (...)

A polymorphic schema has no type, so it takes this else branch — but its parameter is
named 'body', so not param.original_name is never true, body_data stays None, and
body_kwargs["json"] is never set.

Steps to Reproduce:

  1. pip install google-adk (reproduced on 2.8.0)
  2. Run the minimal reproduction script below.
  3. Observe that the constructed request contains neither a json nor a data body.

Expected Behavior:

The body argument supplied by the model is serialized as the JSON request body:

json body sent: {'card': '4111-1111'}

Observed Behavior:

declared params: [('body', 'body', 'body')]
function declaration json schema: {'properties': {'body': {'oneOf': [{'properties': {'card': {'type': 'string'}}, 'type': 'object'}, {'properties': {'iban': {'type': 'string'}}, 'type': 'object'}]}}, 'required': [], 'title': 'create_payment_Arguments', 'type': 'object'}
json body sent: None
data body sent: None

Against a real HTTP server, the request arrives as:

server received Content-Type:   application/json
server received Content-Length: 0
server received body:           b''
tool returned:                  {"ok": true}

The tool reports success while having silently dropped the payload.

Environment Details:

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

Model Information:

  • Are you using LiteLLM: N/A
  • Which model is being used: N/A — this reproduces directly in request construction, with
    no model call involved.

🟡 Optional Information

Regression:

Not exactly a regression to a working state — the failure mode changed from loud to
silent. The mismatch was introduced by commit 084c2de0 (2025-11-20, "fix: Make sure
request bodies without explicit names are named 'body'", closing #2213). Before it,
these parameters were built with original_name='', which the sender matched; the
declaration however carried an empty-named property, which is exactly what #2213
reported (Gemini rejecting it with INVALID_ARGUMENT). That commit repaired the
declaration side — touching common.py, operation_parser.py and both their tests —
but not rest_api_tool.py, whose matching condition depends on the original_name
invariant it changed. The stale comment at rest_api_tool.py:452 still documents the
old invariant.

So: before 2025-11-20 the tool failed immediately and visibly; since then it appears to
work and silently discards the payload. Present in every release since, up to and
including 2.8.0.

Logs:

N/A — there is no log output to attach, and that is central to the bug: nothing is
logged, raised, or warned. The request is built without a body and sent as if normal.

Screenshots / Video:

N/A

Additional Context:

Scope: only top-level oneOf/anyOf/allOf or untyped request bodies are affected.
Plain type: object and type: array bodies take different branches and work correctly,
as do simple scalar bodies (which still get original_name='').

This is adjacent to #6503 (required body properties dropped), which fixed the
type: object branch of the same method — but that fix was parser-side and does not
touch this path.

Note the impact depends on the target API: some will reject the empty body with a 4xx
that is confusing to debug (the model's output was correct), while others may accept it
and perform an unintended no-op or create an empty resource.

On a possible fix: accept the parser's named full-body parameter in the same branch:

if param.param_location == "body" and param.original_name in ("", "body"):

Ideally the literal would be shared between OperationParser and RestApiTool rather
than duplicated, so the two sides cannot drift again. A regression test asserting
request_params["json"] for a oneOf body would cover the gap — test_operation_parser.py
pins the parser side ("Ensures oneOf bodies result in a named parameter") but no
test_rest_api_tool.py case covers sending such a body.

Minimal Reproduction Code:

from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_spec_parser import (
    OpenApiSpecParser,
)
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import RestApiTool

SPEC = {
    "openapi": "3.0.0",
    "info": {"title": "t", "version": "1"},
    "servers": [{"url": "https://example.invalid"}],
    "paths": {
        "/pay": {
            "post": {
                "operationId": "create_payment",
                "requestBody": {
                    "required": True,
                    "content": {
                        "application/json": {
                            "schema": {
                                "oneOf": [
                                    {"type": "object",
                                     "properties": {"card": {"type": "string"}}},
                                    {"type": "object",
                                     "properties": {"iban": {"type": "string"}}},
                                ]
                            }
                        }
                    },
                },
                "responses": {"200": {"description": "ok"}},
            }
        }
    },
}

tool = RestApiTool.from_parsed_operation(OpenApiSpecParser().parse(SPEC)[0])
params = tool._operation_parser.get_parameters()

print("declared params:", [(p.py_name, p.original_name, p.param_location) for p in params])

# The argument the model supplies, per the generated declaration:
request_params = tool._prepare_request_params(params, {"body": {"card": "4111-1111"}})
print("json body sent:", request_params.get("json"))
print("data body sent:", request_params.get("data"))
Wire-level proof against a local HTTP server (optional, self-contained)
import asyncio, json, threading
from http.server import BaseHTTPRequestHandler, HTTPServer

captured = {}

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length") or 0)
        captured["body"] = self.rfile.read(length)
        captured["content_type"] = self.headers.get("Content-Type")
        captured["content_length"] = length
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(b'{"ok": true}')
    def log_message(self, *a):
        pass

server = HTTPServer(("127.0.0.1", 0), Handler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()

from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_spec_parser import (
    OpenApiSpecParser,
)
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import RestApiTool

SPEC = {
    "openapi": "3.0.0",
    "info": {"title": "t", "version": "1"},
    "servers": [{"url": f"http://127.0.0.1:{port}"}],
    "paths": {"/pay": {"post": {
        "operationId": "create_payment",
        "requestBody": {"required": True, "content": {"application/json": {"schema": {
            "oneOf": [
                {"type": "object", "properties": {"card": {"type": "string"}}},
                {"type": "object", "properties": {"iban": {"type": "string"}}},
            ]}}}},
        "responses": {"200": {"description": "ok"}},
    }}},
}

tool = RestApiTool.from_parsed_operation(OpenApiSpecParser().parse(SPEC)[0])
result = asyncio.run(tool.call(args={"body": {"card": "4111-1111"}}, tool_context=None))
server.shutdown()

print("server received C-Type:   ", captured.get("content_type"))
print("server received C-Length: ", captured.get("content_length"))
print("server received body:     ", captured.get("body"))
print("tool returned:            ", json.dumps(result))

Output:

server received C-Type:    application/json
server received C-Length:  0
server received body:      b''
tool returned:             {"ok": true}

How often has this issue occurred?:

  • Always (100%)

Activity

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

Metadata

Metadata

Labels

request clarification[Status] The maintainer need clarification or more information from the authortools[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