fix(proxy/openai): don't crash the Responses memory tool loops on null arguments (#2273)
## Description
The OpenAI Responses memory tool-execution loops crash when a
`function_call` item has `"arguments": null`.
Both loops parse the arguments the same way:
```python
args_str = fc.get("arguments", "{}")
try:
args = json.loads(args_str)
except json.JSONDecodeError:
args = {}
```
`dict.get("arguments", "{}")` only substitutes `"{}"` when the key is
*missing*. A `function_call` item with a present-but-null `arguments`
(which upstreams emit for a tool call with no arguments, or a
partial/streamed item) makes `args_str` be `None`, and
`json.loads(None)` raises `TypeError` — not `JSONDecodeError`, so the
`except` doesn't catch it and the streaming request handler blows up.
`parse_tool_call` in `headroom/ccr/tool_injection.py` already catches
this exact case (`except (json.JSONDecodeError, TypeError)`, with a
comment noting `json.loads(None)`), so the hazard is known in the
codebase; these two loops just weren't hardened.
## Fix
Coalesce the arguments string with `or "{}"` (so a null value becomes
`"{}"`) and add `TypeError` to the `except` for defence in depth, at
both sites:
```python
args_str = fc.get("arguments") or "{}"
try:
args = json.loads(args_str)
except (json.JSONDecodeError, TypeError):
args = {}
```
Real arguments parse exactly as before.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/proxy/handlers/openai.py`: coalesce `fc.get("arguments")`
with `or "{}"` and catch `TypeError` in both OpenAI Responses memory
tool-execution loops.
- `tests/test_openai_responses_null_arguments.py`: source-level
regression guard that the vulnerable form is gone and both loops use the
null-safe form.
- `CHANGELOG.md`: Bug Fixes entry.
## Testing
- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uvx ruff@0.15.17 check headroom/proxy/handlers/openai.py tests/test_openai_responses_null_arguments.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/handlers/openai.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the parse with a dependency-free script and left the full
pytest to CI.
- Exact command / steps: ran a `function_call` item with `"arguments":
null` (plus real args and a missing-key case) through the OLD
`get("arguments", "{}")` + `json.loads` and the NEW `get("arguments") or
"{}"` + `(JSONDecodeError, TypeError)` logic.
- Observed result: OLD raises `TypeError` (`json.loads(None)`); NEW
returns `{}` for the null case, parses real args to `{"content": "hi"}`,
and returns `{}` for the missing key.
- Not tested: a live Responses stream emitting a null-arguments tool
call; full local `pytest` deferred to CI (OOM).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. Both fixed sites are deep
inside streaming request handlers, so the added test is a source-level
guard (it reads the file, without importing the ML stack) and runs under
the normal CI pytest job; the behaviour is verified by the standalone
proof above. This is the OpenAI-Responses sibling of the same
null-`arguments` `json.loads(None)` hazard the memory tool adapter also
had.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
This commit is contained in:
@@ -5720,10 +5720,10 @@ class OpenAIHandlerMixin:
|
||||
for fc in memory_fc_items:
|
||||
call_id = fc.get("call_id", fc.get("id", ""))
|
||||
name = fc.get("name", "")
|
||||
args_str = fc.get("arguments", "{}")
|
||||
args_str = fc.get("arguments") or "{}"
|
||||
try:
|
||||
args = json.loads(args_str)
|
||||
except json.JSONDecodeError:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
|
||||
await self.memory_handler._ensure_initialized()
|
||||
@@ -7993,10 +7993,10 @@ class OpenAIHandlerMixin:
|
||||
for fc in pending_fcs:
|
||||
call_id = fc.get("call_id", fc.get("id", ""))
|
||||
fc_name = fc.get("name", "")
|
||||
args_str = fc.get("arguments", "{}")
|
||||
args_str = fc.get("arguments") or "{}"
|
||||
try:
|
||||
fc_args = json.loads(args_str)
|
||||
except json.JSONDecodeError:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
fc_args = {}
|
||||
|
||||
await self.memory_handler._ensure_initialized()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""The OpenAI Responses memory tool-call loops must not crash on a null
|
||||
``arguments``.
|
||||
|
||||
A ``function_call`` item with ``"arguments": null`` makes ``fc.get("arguments",
|
||||
"{}")`` return ``None``, and ``json.loads(None)`` raises ``TypeError`` — which the
|
||||
``except json.JSONDecodeError`` around it does not catch. The two memory
|
||||
tool-execution loops in ``handlers/openai.py`` are deep inside streaming request
|
||||
handlers, so this guards the fix at the source level (reading the file, not
|
||||
importing the ML stack) plus a behavioural proof in the standalone script.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_OPENAI = Path(__file__).resolve().parents[1] / "headroom" / "proxy" / "handlers" / "openai.py"
|
||||
|
||||
|
||||
def test_memory_tool_argument_parsing_is_null_safe():
|
||||
src = _OPENAI.read_text(encoding="utf-8")
|
||||
|
||||
# The vulnerable form (bare default + json.loads that can receive None) is gone.
|
||||
assert 'fc.get("arguments", "{}")' not in src
|
||||
|
||||
# Both memory tool-call loops now coalesce the arguments string and catch
|
||||
# TypeError alongside JSONDecodeError.
|
||||
assert src.count('fc.get("arguments") or "{}"') >= 2
|
||||
assert src.count("except (json.JSONDecodeError, TypeError):") >= 2
|
||||
Reference in New Issue
Block a user