From 888686d858571b102391bab7ff4dc3ee2819ddf3 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:29:46 -0700 Subject: [PATCH] test(headroom): pin that SDK-echoed null tool_calls never reach /v1/compress (#39667) * 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> * test(headroom): pin that SDK-echoed null tool_calls never reach /v1/compress Main now withholds every assistant row from /v1/compress (#39974), so the null tool_calls strip added here never runs on a row headroom-ai 0.27-0.30 fails on. Drop the strip and pin the customer's history shape through the public OpenAI chat translation handler instead. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com> --- .../guardrail_hooks/test_headroom.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_headroom.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_headroom.py index 6f4b1816150..5bdcdddf4b7 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_headroom.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_headroom.py @@ -2008,6 +2008,45 @@ async def test_apply_guardrail_sends_textless_parts_rows_unflattened( assert wire_messages[1]["content"] == "D" * 5000 +@pytest.mark.asyncio +async def test_sdk_echoed_null_tool_calls_never_reach_compression_service(guardrail: HeadroomGuardrail): + """LIT-6921: OpenAI SDK assistant messages echoed back as history carry + ``"tool_calls": null``, which headroom-ai 0.27-0.30 answer with a 503. The + chat handler sends no row carrying that null and forwards the echoed rows + unchanged.""" + from openai.types.chat import ChatCompletionMessage + + from litellm.llms.openai.chat.guardrail_translation.handler import OpenAIChatCompletionsHandler + + def _sdk_assistant(**fields: object) -> dict: + return ChatCompletionMessage.model_validate({"role": "assistant", **fields}).model_dump() + + tool_call = {"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}} + history = [ + {"role": "user", "content": "E" * 5000}, + _sdk_assistant(content=None, tool_calls=[tool_call]), + {"role": "tool", "tool_call_id": "call_1", "content": "R" * 5000}, + _sdk_assistant(content="summary"), + {"role": "user", "content": "F" * 5000}, + _sdk_assistant(content="noted"), + {"role": "user", "content": "and now?"}, + ] + assert history[3]["tool_calls"] is None + sent: dict = {} + + def _echo(**kwargs): + sent["messages"] = kwargs["json"]["messages"] + return _make_compress_response(json.loads(json.dumps(sent["messages"]))) + + data = {"model": "claude-sonnet-5", "messages": json.loads(json.dumps(history))} + with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock, side_effect=_echo): + result = await OpenAIChatCompletionsHandler().process_input_messages(data=data, guardrail_to_apply=guardrail) + + assert [row["role"] for row in sent["messages"]] == ["user", "tool", "user"] + assert all(row.get("tool_calls", []) is not None for row in sent["messages"]) + assert [result["messages"][index] for index in (1, 3, 5)] == [history[index] for index in (1, 3, 5)] + + @pytest.mark.asyncio async def test_fail_open_returns_original_parts_shapes(): guardrail = _make_guardrail(unreachable_fallback="fail_open")