mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
parent
3bb4d7e5ca
commit
6a71bff7c7
4 changed files with 219 additions and 25 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue