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>
This commit is contained in:
devin-ai-integration[bot] 2026-10-10 17:29:46 -07:00 • committed by GitHub
parent 9e2aae8490
commit 888686d858
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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")