diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index e8e4b4e18..b415d6e25 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -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() diff --git a/tests/test_openai_responses_null_arguments.py b/tests/test_openai_responses_null_arguments.py new file mode 100644 index 000000000..bd50487c9 --- /dev/null +++ b/tests/test_openai_responses_null_arguments.py @@ -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