From 737f2dce4181b00502a77115bc90e61ab94d2b3a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:53:20 +0000 Subject: [PATCH] fix(headroom): drop null tool_calls before calling /v1/compress headroom-ai 0.27.0 through 0.30.0 iterate msg.get("tool_calls", []) and fail with 'NoneType' object is not iterable when an assistant row carries an explicit tool_calls: null, which the fail-closed guardrail then surfaces as a 502. Strip the null key from rows sent to the compression service; real tool_calls lists are forwarded unchanged Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../guardrail_hooks/headroom/headroom.py | 25 +++++++------ .../guardrail_hooks/test_headroom.py | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py b/litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py index 9d993384461..641042e95af 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py +++ b/litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py @@ -80,17 +80,22 @@ def _flatten_messages_for_compression(messages: list[dict[str, object]]) -> list at its part), so merging text across a non-text part would move a later breakpoint to the other side of it. Rows with non-text parts are sent unchanged and pass through the service untouched. + + An explicit ``"tool_calls": null`` (what OpenAI SDK message objects + serialize to) is dropped: the service iterates the field and older + releases fail on ``None``. """ - flattened: Final[list[dict[str, object]]] = [] - for msg in messages: - content = msg.get("content") - if is_all_text_parts(content): - text = content_to_text(content) - if text: - flattened.append({**msg, "content": text}) - continue - flattened.append(msg) - return flattened + return [_compression_row(msg) for msg in messages] + + +def _compression_row(message: Mapping[str, object]) -> dict[str, object]: + content: Final = message.get("content") + text: Final = content_to_text(content) if is_all_text_parts(content) else "" + return { + key: text if key == "content" and text else value + for key, value in message.items() + if key != "tool_calls" or value is not None + } def _restore_content_shapes( diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py index 400eaf8ab3f..ec0f00b1974 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py @@ -1938,6 +1938,41 @@ async def test_apply_guardrail_sends_textless_parts_rows_unflattened( assert wire_messages[1]["content"] == "D" * 5000 +@pytest.mark.asyncio +async def test_apply_guardrail_drops_null_tool_calls_but_keeps_real_ones( + guardrail: HeadroomGuardrail, +): + real_tool_calls = [{"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}}] + history = [ + {"role": "user", "content": "E" * 5000}, + {"role": "assistant", "content": None, "tool_calls": real_tool_calls}, + {"role": "tool", "tool_call_id": "call_1", "content": "result"}, + {"role": "assistant", "content": "summary", "tool_calls": None, "function_call": None}, + ] + inputs = GenericGuardrailAPIInputs( + texts=["E" * 5000], + structured_messages=json.loads(json.dumps(history)) + + [{"role": "assistant", "content": "last turn"}, {"role": "user", "content": "and now?"}], + ) + mock_response = _make_compress_response(json.loads(json.dumps(history))) + + with patch.object( + guardrail.async_handler, + "post", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_post: + await guardrail.apply_guardrail( + inputs=inputs, + request_data={"model": "claude-fable-5"}, + input_type="request", + ) + + wire_messages = mock_post.call_args.kwargs["json"]["messages"] + assert wire_messages[1]["tool_calls"] == real_tool_calls + assert wire_messages[3] == {"role": "assistant", "content": "summary", "function_call": None} + + @pytest.mark.asyncio async def test_fail_open_returns_original_parts_shapes(): guardrail = _make_guardrail(unreachable_fallback="fail_open")