fix(responses): align compaction format with OpenAI v1/responses spec

- Place compaction item after assistant message (index 1) instead of before (index 0)
- Use encrypted_content (base64-encoded) instead of plaintext content field
- Add id (cmp_ prefix) and created_by fields to match OpenAI format
- On input, find last compaction item, keep only its predecessor + items after it,
  base64-decode encrypted_content into a user message
- Backward compat: fall back to content field if encrypted_content is absent
This commit is contained in:
SwiftWinds 2026-04-03 14:59:17 -07:00
parent 3bb4d7e5ca
commit 6a71bff7c7
4 changed files with 219 additions and 25 deletions

View file

@ -2,6 +2,8 @@
Handler for transforming responses api requests to litellm.completion requests
"""
import base64
import uuid
from typing import Any, Coroutine, Dict, List, Optional, Union
import litellm
@ -135,7 +137,7 @@ class LiteLLMCompletionTransformationHandler:
)
if summary_text is not None:
responses_api_response.output = _prepend_compaction_output(
responses_api_response.output = _append_compaction_output(
summary_text, responses_api_response.output
)
@ -157,12 +159,17 @@ class LiteLLMCompletionTransformationHandler:
)
def _prepend_compaction_output(
def _append_compaction_output(
summary_text: str, existing_output: List[Any]
) -> List[Any]:
"""Prepend a compaction output item before the existing output items."""
"""Append a compaction output item after the first output item."""
encoded_content = base64.b64encode(summary_text.encode("utf-8")).decode("utf-8")
compaction_item = {
"type": "compaction",
"content": summary_text,
"id": "cmp_" + uuid.uuid4().hex,
"encrypted_content": encoded_content,
"created_by": None,
}
return [compaction_item] + list(existing_output)
if existing_output:
return [existing_output[0], compaction_item] + list(existing_output[1:])
return [compaction_item]

View file

@ -2,6 +2,7 @@
Handles transforming from Responses API -> LiteLLM completion (Chat Completion API)
"""
import base64
from collections.abc import Sequence
from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Union, cast
@ -374,18 +375,35 @@ class LiteLLMCompletionResponsesConfig:
if isinstance(input, str):
messages.append(ChatCompletionUserMessage(role="user", content=input))
elif isinstance(input, list):
last_compaction_idx: Optional[int] = None
for idx, _inp in enumerate(input):
if isinstance(_inp, dict) and _inp.get("type") == "compaction":
last_compaction_idx = idx
if last_compaction_idx is not None:
compaction_item = input[last_compaction_idx]
encrypted = compaction_item.get("encrypted_content", "")
if encrypted:
decoded_content = base64.b64decode(
encrypted.encode("utf-8")
).decode("utf-8")
else:
decoded_content = compaction_item.get("content", "")
messages.append(
ChatCompletionUserMessage(
role="user", content=decoded_content
)
)
if last_compaction_idx > 0:
pre_item = input[last_compaction_idx - 1]
pre_msgs = LiteLLMCompletionResponsesConfig._transform_responses_api_input_item_to_chat_completion_message(
input_item=pre_item
)
messages.extend(pre_msgs)
input = list(input[last_compaction_idx + 1:])
existing_tool_call_ids: Set[str] = set()
for _input in input:
if isinstance(_input, dict) and _input.get("type") == "compaction":
messages.clear()
compaction_content = _input.get("content", "")
messages.append(
ChatCompletionSystemMessage(
role="system", content=compaction_content
)
)
continue
chat_completion_messages = LiteLLMCompletionResponsesConfig._transform_responses_api_input_item_to_chat_completion_message(
input_item=_input
)

View file

@ -346,21 +346,26 @@ async def test_mock_google_ai_studio_compaction():
f"got {len(response.output)}"
)
# First output item should be the compaction block
compaction_item = response.output[0]
if isinstance(compaction_item, dict):
assert compaction_item["type"] == "compaction"
assert "cats many times" in compaction_item["content"]
else:
assert getattr(compaction_item, "type", None) == "compaction"
# Second output item should be the text response
text_item = response.output[1]
# First output item should be the text response (assistant message)
text_item = response.output[0]
if isinstance(text_item, dict):
assert text_item.get("type") == "message"
else:
assert getattr(text_item, "type", None) == "message"
# Second output item should be the compaction block
import base64
compaction_item = response.output[1]
if isinstance(compaction_item, dict):
assert compaction_item["type"] == "compaction"
assert "encrypted_content" in compaction_item
decoded = base64.b64decode(compaction_item["encrypted_content"]).decode("utf-8")
assert "cats many times" in decoded
assert compaction_item["id"].startswith("cmp_")
assert compaction_item["created_by"] is None
else:
assert getattr(compaction_item, "type", None) == "compaction"
print("compaction test passed: response output =", json.dumps(response.output, indent=2, default=str))

View file

@ -2079,3 +2079,167 @@ class TestEnsureOutputItemContentPartAdded:
events = iterator._pending_response_events
assert len(events) == 2
class TestCompactionOutputFormat:
"""Tests for _append_compaction_output in handler.py"""
def test_append_compaction_output_format(self):
"""Compaction item should be at index 1 with encrypted_content, id, created_by."""
import base64
from litellm.responses.litellm_completion_transformation.handler import (
_append_compaction_output,
)
summary = "The user repeated the word cats many times."
existing_output = [{"type": "message", "role": "assistant", "content": "Hello"}]
result = _append_compaction_output(summary, existing_output)
assert len(result) == 2
# First item is the assistant message
assert result[0]["type"] == "message"
# Second item is the compaction
compaction = result[1]
assert compaction["type"] == "compaction"
assert compaction["id"].startswith("cmp_")
assert compaction["created_by"] is None
assert "content" not in compaction
# Verify encrypted_content is base64-encoded summary
decoded = base64.b64decode(compaction["encrypted_content"]).decode("utf-8")
assert decoded == summary
def test_append_compaction_output_empty_existing(self):
"""When existing_output is empty, compaction item is the only element."""
from litellm.responses.litellm_completion_transformation.handler import (
_append_compaction_output,
)
result = _append_compaction_output("summary", [])
assert len(result) == 1
assert result[0]["type"] == "compaction"
def test_append_compaction_output_preserves_extra_items(self):
"""Items after the first in existing_output are preserved after compaction."""
from litellm.responses.litellm_completion_transformation.handler import (
_append_compaction_output,
)
existing = [
{"type": "message", "role": "assistant"},
{"type": "function_call", "name": "foo"},
]
result = _append_compaction_output("summary", existing)
assert len(result) == 3
assert result[0]["type"] == "message"
assert result[1]["type"] == "compaction"
assert result[2]["type"] == "function_call"
class TestCompactionInputProcessing:
"""Tests for compaction input handling in transformation.py"""
def test_compaction_input_processing(self):
"""Compaction item in input should produce decoded user msg + predecessor + remaining."""
import base64
summary = "Summary of conversation"
encrypted = base64.b64encode(summary.encode("utf-8")).decode("utf-8")
input_items = [
{"type": "message", "role": "user", "content": "old msg 1"},
{"type": "message", "role": "user", "content": "old msg 2"},
{"type": "message", "role": "assistant", "content": "assistant reply"},
{"type": "compaction", "id": "cmp_abc", "encrypted_content": encrypted, "created_by": None},
{"type": "message", "role": "user", "content": "new question"},
]
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=input_items,
responses_api_request={},
)
# Should be: [user(decoded_summary), assistant(reply), user(new question)]
assert len(messages) == 3
assert messages[0]["role"] == "user"
assert messages[0]["content"] == summary
assert messages[1]["role"] == "assistant"
assert messages[1]["content"] == "assistant reply"
assert messages[2]["role"] == "user"
assert messages[2]["content"] == "new question"
def test_compaction_input_at_index_0(self):
"""Compaction at index 0 with no predecessor should still work."""
import base64
summary = "Summary"
encrypted = base64.b64encode(summary.encode("utf-8")).decode("utf-8")
input_items = [
{"type": "compaction", "id": "cmp_abc", "encrypted_content": encrypted, "created_by": None},
{"type": "message", "role": "user", "content": "follow up"},
]
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=input_items,
responses_api_request={},
)
assert len(messages) == 2
assert messages[0]["role"] == "user"
assert messages[0]["content"] == summary
assert messages[1]["role"] == "user"
assert messages[1]["content"] == "follow up"
def test_compaction_input_backward_compat(self):
"""Old format with 'content' field (no encrypted_content) should still work."""
input_items = [
{"type": "message", "role": "assistant", "content": "prior reply"},
{"type": "compaction", "content": "plaintext summary"},
{"type": "message", "role": "user", "content": "new msg"},
]
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=input_items,
responses_api_request={},
)
assert len(messages) == 3
assert messages[0]["role"] == "user"
assert messages[0]["content"] == "plaintext summary"
assert messages[1]["role"] == "assistant"
assert messages[1]["content"] == "prior reply"
assert messages[2]["role"] == "user"
assert messages[2]["content"] == "new msg"
def test_multiple_compaction_items_uses_last(self):
"""When multiple compaction items exist, only the last one matters."""
import base64
old_summary = base64.b64encode(b"old summary").decode("utf-8")
new_summary = base64.b64encode(b"new summary").decode("utf-8")
input_items = [
{"type": "message", "role": "user", "content": "ancient msg"},
{"type": "message", "role": "assistant", "content": "ancient reply"},
{"type": "compaction", "id": "cmp_old", "encrypted_content": old_summary},
{"type": "message", "role": "user", "content": "mid msg"},
{"type": "message", "role": "assistant", "content": "mid reply"},
{"type": "compaction", "id": "cmp_new", "encrypted_content": new_summary},
{"type": "message", "role": "user", "content": "latest question"},
]
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=input_items,
responses_api_request={},
)
# Should use new_summary, keep mid reply (predecessor of last compaction), then latest question
assert len(messages) == 3
assert messages[0]["role"] == "user"
assert messages[0]["content"] == "new summary"
assert messages[1]["role"] == "assistant"
assert messages[1]["content"] == "mid reply"
assert messages[2]["role"] == "user"
assert messages[2]["content"] == "latest question"