From 6a71bff7c7a8d162a10fdbee8246b5f468b2ab77 Mon Sep 17 00:00:00 2001 From: SwiftWinds <12981958+SwiftWinds@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:59:17 -0700 Subject: [PATCH] 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 --- .../handler.py | 17 +- .../transformation.py | 38 ++-- .../test_google_ai_studio_responses_api.py | 25 +-- .../test_litellm_completion_responses.py | 164 ++++++++++++++++++ 4 files changed, 219 insertions(+), 25 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/handler.py b/litellm/responses/litellm_completion_transformation/handler.py index 208ae26b880..da7e39c4ae1 100644 --- a/litellm/responses/litellm_completion_transformation/handler.py +++ b/litellm/responses/litellm_completion_transformation/handler.py @@ -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] diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 9b85e8eb63a..1b765b6f422 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -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 ) diff --git a/tests/llm_responses_api_testing/test_google_ai_studio_responses_api.py b/tests/llm_responses_api_testing/test_google_ai_studio_responses_api.py index bbebd44b8ba..31ec16615dc 100644 --- a/tests/llm_responses_api_testing/test_google_ai_studio_responses_api.py +++ b/tests/llm_responses_api_testing/test_google_ai_studio_responses_api.py @@ -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)) diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 4e44ef9e50c..22f3fb02ce0 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -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"