google / google/adk-python

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

オープン
#6,991 コメント 4 件 リアクション 0 件 担当者 1 名 @surajksharma07 が担当を希望しています GitHub で見る
tools
主要言語
Python
スター
21.5k
フォーク
4k
平均マージ
1日 14時間
マージ済み PR(30日)
37

説明

## 🔴 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):

```python
# 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):

```python
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:

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

**Observed Behavior:**

```text
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:

```text
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:

```python
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:**

```python
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)

```python
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:

```text
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%)

コントリビューションガイド

コントリビューションガイドを開く

評価

この issue はまだ評価されていません。

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。