mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
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>
This commit is contained in:
parent
8699998c9e
commit
737f2dce41
2 changed files with 50 additions and 10 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue