google / google/adk-python

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

Đang mở
#6,991 4 bình luận 0 reaction 1 người được giao Được @surajksharma07 nhận Xem trên GitHub
tools
Ngôn ngữ chính
Python
Star
21.5k
Fork
4k
Merge trung bình
1 ngày 22 giờ
Pull request đã merge (30 ngày)
31

Mô tả

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

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Đánh giá

Issue này chưa được đánh giá.

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.