chopratejas commited on
Commit
16d1d82
·
1 Parent(s): 9d72cac

Add compression for OpenAI Responses API (/v1/responses)

Browse files

The /v1/responses handler was passing through without compression,
meaning Codex CLI users got zero savings. Now converts Responses API
items (function_call, function_call_output, reasoning, message) to
Chat Completions format, runs the existing pipeline, and converts back.

- New: headroom/proxy/responses_converter.py — pure conversion functions
- 21 unit tests + 3 integration tests (tested with real OpenAI API)
- Preserves reasoning items, images, unknown types verbatim
- Skips compression when previous_response_id is set
- 27% compression on real Codex-pattern payloads (500 records → 14K tokens saved)

Closes #73

headroom/proxy/responses_converter.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert between OpenAI Responses API items and Chat Completions messages.
2
+
3
+ The Responses API uses a flat item model where function_call and
4
+ function_call_output are top-level items, content parts use input_text /
5
+ output_text types, and reasoning items must be preserved verbatim.
6
+
7
+ The Headroom compression pipeline works on Chat Completions messages
8
+ (role + content / tool_calls). This module converts back and forth so
9
+ the existing pipeline can compress Responses API input without changes.
10
+
11
+ Pattern follows the Gemini converter in server.py (_gemini_contents_to_messages).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import copy
17
+ from typing import Any
18
+
19
+ # Content part types that indicate non-text media (must be preserved, not compressed)
20
+ _NON_TEXT_CONTENT_TYPES = frozenset(
21
+ {
22
+ "input_image",
23
+ "input_file",
24
+ "input_audio",
25
+ "image_url",
26
+ "image_file",
27
+ }
28
+ )
29
+
30
+
31
+ def responses_items_to_messages(
32
+ items: list[dict[str, Any]],
33
+ ) -> tuple[list[dict[str, Any]], list[int]]:
34
+ """Convert Responses API input items to Chat Completions messages.
35
+
36
+ Args:
37
+ items: The ``input`` array from a ``/v1/responses`` request.
38
+ Contains a mix of message items, function_call items,
39
+ function_call_output items, reasoning items, etc.
40
+
41
+ Returns:
42
+ (messages, preserved_indices) where:
43
+ - messages: OpenAI Chat Completions format messages suitable for
44
+ the Headroom compression pipeline.
45
+ - preserved_indices: Indices into *items* for entries that must
46
+ be restored verbatim (reasoning, images, unknown types).
47
+ """
48
+ if not items:
49
+ return [], []
50
+
51
+ messages: list[dict[str, Any]] = []
52
+ preserved_indices: list[int] = []
53
+ pending_tool_calls: list[tuple[int, dict[str, Any]]] = []
54
+
55
+ for idx, item in enumerate(items):
56
+ item_type = item.get("type")
57
+ role = item.get("role")
58
+
59
+ # --- Reasoning items: preserve exactly ---
60
+ if item_type == "reasoning":
61
+ _flush_pending(messages, pending_tool_calls)
62
+ preserved_indices.append(idx)
63
+ continue
64
+
65
+ # --- function_call items: accumulate, flush as one assistant message ---
66
+ if item_type == "function_call":
67
+ pending_tool_calls.append((idx, item))
68
+ continue
69
+
70
+ # --- function_call_output items: convert to role=tool ---
71
+ if item_type == "function_call_output":
72
+ _flush_pending(messages, pending_tool_calls)
73
+ messages.append(
74
+ {
75
+ "role": "tool",
76
+ "tool_call_id": item.get("call_id", ""),
77
+ "content": item.get("output", ""),
78
+ }
79
+ )
80
+ continue
81
+
82
+ # --- Message items (role-based, with or without type="message") ---
83
+ if role is not None:
84
+ _flush_pending(messages, pending_tool_calls)
85
+ content = item.get("content", "")
86
+
87
+ # Handle content part arrays
88
+ if isinstance(content, list):
89
+ if _has_non_text_parts(content):
90
+ preserved_indices.append(idx)
91
+ continue
92
+ content = _extract_text_from_parts(content)
93
+
94
+ mapped_role = "system" if role == "developer" else role
95
+ messages.append({"role": mapped_role, "content": content})
96
+ continue
97
+
98
+ # --- Unknown item type: preserve ---
99
+ _flush_pending(messages, pending_tool_calls)
100
+ preserved_indices.append(idx)
101
+
102
+ # Flush any trailing tool calls
103
+ _flush_pending(messages, pending_tool_calls)
104
+
105
+ return messages, preserved_indices
106
+
107
+
108
+ def messages_to_responses_items(
109
+ messages: list[dict[str, Any]],
110
+ original_items: list[dict[str, Any]],
111
+ preserved_indices: list[int],
112
+ ) -> list[dict[str, Any]]:
113
+ """Convert compressed Chat Completions messages back to Responses API items.
114
+
115
+ Uses a two-pass approach:
116
+ 1. Index compressed messages by call_id (for tool outputs) and collect
117
+ regular messages in order.
118
+ 2. Walk original_items, restoring preserved items and substituting
119
+ compressed content where applicable.
120
+
121
+ Args:
122
+ messages: Compressed messages from the pipeline.
123
+ original_items: The original ``input`` array (pre-compression).
124
+ preserved_indices: Indices returned by ``responses_items_to_messages``.
125
+
126
+ Returns:
127
+ New items list with compressed content, ready to send to OpenAI.
128
+ """
129
+ if not original_items:
130
+ return []
131
+
132
+ preserved_set = frozenset(preserved_indices)
133
+
134
+ # --- Pass 1: Index compressed messages ---
135
+ tool_outputs: dict[str, str] = {} # call_id → compressed output
136
+ regular_msgs: list[dict[str, Any]] = []
137
+
138
+ for msg in messages:
139
+ role = msg.get("role")
140
+ if role == "tool":
141
+ tool_outputs[msg.get("tool_call_id", "")] = msg.get("content", "")
142
+ elif role == "assistant" and msg.get("tool_calls"):
143
+ # function_call items pass through uncompressed — skip
144
+ pass
145
+ else:
146
+ regular_msgs.append(msg)
147
+
148
+ # --- Pass 2: Reconstruct items ---
149
+ result: list[dict[str, Any]] = []
150
+ reg_idx = 0
151
+
152
+ for orig_idx, item in enumerate(original_items):
153
+ # Preserved items go back exactly as they were
154
+ if orig_idx in preserved_set:
155
+ result.append(item)
156
+ continue
157
+
158
+ item_type = item.get("type")
159
+
160
+ if item_type == "function_call":
161
+ # Small — pass through unmodified
162
+ result.append(item)
163
+
164
+ elif item_type == "function_call_output":
165
+ call_id = item.get("call_id", "")
166
+ compressed = tool_outputs.get(call_id, item.get("output", ""))
167
+ result.append({**item, "output": compressed})
168
+
169
+ else:
170
+ # Regular message — take next compressed message
171
+ if reg_idx < len(regular_msgs):
172
+ msg = regular_msgs[reg_idx]
173
+ reg_idx += 1
174
+ result.append(_reconstruct_item(item, msg))
175
+ else:
176
+ # Safety: more original items than compressed messages
177
+ result.append(item)
178
+
179
+ return result
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Helpers
184
+ # ---------------------------------------------------------------------------
185
+
186
+
187
+ def _flush_pending(
188
+ messages: list[dict[str, Any]],
189
+ pending: list[tuple[int, dict[str, Any]]],
190
+ ) -> None:
191
+ """Flush accumulated function_call items as one assistant message."""
192
+ if not pending:
193
+ return
194
+ tool_calls = []
195
+ for _idx, item in pending:
196
+ tool_calls.append(
197
+ {
198
+ "id": item.get("call_id", ""),
199
+ "type": "function",
200
+ "function": {
201
+ "name": item.get("name", ""),
202
+ "arguments": item.get("arguments", "{}"),
203
+ },
204
+ }
205
+ )
206
+ messages.append(
207
+ {
208
+ "role": "assistant",
209
+ "content": None,
210
+ "tool_calls": tool_calls,
211
+ }
212
+ )
213
+ pending.clear()
214
+
215
+
216
+ def _has_non_text_parts(content: list[dict[str, Any]]) -> bool:
217
+ """Check if a content array contains non-text parts (images, files, audio)."""
218
+ return any(p.get("type") in _NON_TEXT_CONTENT_TYPES for p in content)
219
+
220
+
221
+ def _extract_text_from_parts(content: list[dict[str, Any]]) -> str:
222
+ """Extract text from Responses API content parts.
223
+
224
+ Handles both input (input_text) and output (output_text) part types,
225
+ plus standard ``text`` parts.
226
+ """
227
+ parts = []
228
+ for p in content:
229
+ ptype = p.get("type", "")
230
+ if ptype in ("input_text", "output_text", "text"):
231
+ parts.append(p.get("text", ""))
232
+ return "\n".join(parts) if parts else ""
233
+
234
+
235
+ def _reconstruct_item(
236
+ original: dict[str, Any],
237
+ compressed_msg: dict[str, Any],
238
+ ) -> dict[str, Any]:
239
+ """Rebuild a Responses API item from its original structure + compressed text.
240
+
241
+ Preserves the original content format: if the original had
242
+ ``content: [{"type": "input_text", ...}]``, the compressed text goes
243
+ back into that same structure rather than being flattened to a string.
244
+ """
245
+ compressed_text = compressed_msg.get("content", "")
246
+ original_content = original.get("content")
247
+
248
+ # If original had a content-part array, reconstruct it
249
+ if isinstance(original_content, list) and original_content:
250
+ new_content = []
251
+ text_replaced = False
252
+ for part in original_content:
253
+ ptype = part.get("type", "")
254
+ if ptype in ("input_text", "output_text", "text") and not text_replaced:
255
+ new_content.append({**part, "text": compressed_text})
256
+ text_replaced = True
257
+ else:
258
+ new_content.append(part)
259
+ rebuilt = copy.copy(original)
260
+ rebuilt["content"] = new_content
261
+ return rebuilt
262
+
263
+ # String content or missing — just replace
264
+ rebuilt = copy.copy(original)
265
+ rebuilt["content"] = compressed_text if compressed_text is not None else ""
266
+ return rebuilt
headroom/proxy/server.py CHANGED
@@ -6365,20 +6365,32 @@ class HeadroomProxy:
6365
  model = body.get("model", "unknown")
6366
  stream = body.get("stream", False)
6367
 
6368
- # Convert Responses API input to messages format for optimization
6369
- # The Responses API accepts either a string or array of messages
 
 
 
 
 
 
 
6370
  input_data = body.get("input", "")
6371
  instructions = body.get("instructions")
 
 
 
 
 
6372
 
6373
- messages = []
6374
  if instructions:
6375
  messages.append({"role": "system", "content": instructions})
6376
 
6377
  if isinstance(input_data, str):
6378
  messages.append({"role": "user", "content": input_data})
6379
  elif isinstance(input_data, list):
6380
- # Input is already an array of message objects
6381
- messages.extend(input_data)
 
6382
 
6383
  headers = dict(request.headers.items())
6384
  headers.pop("host", None)
@@ -6400,12 +6412,60 @@ class HeadroomProxy:
6400
  tokenizer = get_tokenizer(model)
6401
  original_tokens = tokenizer.count_messages(messages)
6402
 
6403
- # Note: We pass through to OpenAI without optimization for now
6404
- # The Responses API has different semantics that may not work well with compression
6405
  tokens_saved = 0
6406
  transforms_applied: list[str] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6407
  optimization_latency = (time.time() - start_time) * 1000
6408
 
 
 
 
 
 
 
 
 
 
 
6409
  url = f"{self.OPENAI_API_URL}/v1/responses"
6410
 
6411
  try:
 
6365
  model = body.get("model", "unknown")
6366
  stream = body.get("stream", False)
6367
 
6368
+ # Convert Responses API input to messages format for optimization.
6369
+ # The Responses API uses a different item model (function_call,
6370
+ # function_call_output, reasoning as top-level items) — we convert to
6371
+ # Chat Completions messages for the pipeline, then convert back.
6372
+ from headroom.proxy.responses_converter import (
6373
+ messages_to_responses_items,
6374
+ responses_items_to_messages,
6375
+ )
6376
+
6377
  input_data = body.get("input", "")
6378
  instructions = body.get("instructions")
6379
+ previous_response_id = body.get("previous_response_id")
6380
+
6381
+ messages: list[dict[str, Any]] = []
6382
+ original_items: list[dict[str, Any]] | None = None
6383
+ preserved_indices: list[int] = []
6384
 
 
6385
  if instructions:
6386
  messages.append({"role": "system", "content": instructions})
6387
 
6388
  if isinstance(input_data, str):
6389
  messages.append({"role": "user", "content": input_data})
6390
  elif isinstance(input_data, list):
6391
+ original_items = input_data
6392
+ converted, preserved_indices = responses_items_to_messages(input_data)
6393
+ messages.extend(converted)
6394
 
6395
  headers = dict(request.headers.items())
6396
  headers.pop("host", None)
 
6412
  tokenizer = get_tokenizer(model)
6413
  original_tokens = tokenizer.count_messages(messages)
6414
 
6415
+ # Optimize: convert items compress convert back
 
6416
  tokens_saved = 0
6417
  transforms_applied: list[str] = []
6418
+ optimized_messages = messages
6419
+ optimized_tokens = original_tokens
6420
+
6421
+ _bypass = (
6422
+ request.headers.get("x-headroom-bypass", "").lower() == "true"
6423
+ or request.headers.get("x-headroom-mode", "").lower() == "passthrough"
6424
+ )
6425
+ _should_compress = (
6426
+ self.config.optimize
6427
+ and original_items is not None
6428
+ and not previous_response_id
6429
+ and not _bypass
6430
+ and len(messages) > 1
6431
+ )
6432
+ _license_ok = self.usage_reporter.should_compress if self.usage_reporter else True
6433
+
6434
+ if _should_compress and _license_ok:
6435
+ try:
6436
+ context_limit = self.openai_provider.get_context_limit(model)
6437
+ result = await asyncio.wait_for(
6438
+ asyncio.to_thread(
6439
+ lambda: self.openai_pipeline.apply(
6440
+ messages=messages,
6441
+ model=model,
6442
+ model_limit=context_limit,
6443
+ context=extract_user_query(messages),
6444
+ )
6445
+ ),
6446
+ timeout=COMPRESSION_TIMEOUT_SECONDS,
6447
+ )
6448
+ if result.messages != messages:
6449
+ optimized_messages = result.messages
6450
+ transforms_applied = result.transforms_applied
6451
+ original_tokens = result.tokens_before
6452
+ optimized_tokens = result.tokens_after
6453
+ except Exception as e:
6454
+ logger.warning(f"[{request_id}] Responses API optimization failed: {e}")
6455
+
6456
+ tokens_saved = max(0, original_tokens - optimized_tokens)
6457
  optimization_latency = (time.time() - start_time) * 1000
6458
 
6459
+ # Convert compressed messages back to Responses API items
6460
+ if optimized_messages is not messages and original_items is not None:
6461
+ opt_msgs = optimized_messages
6462
+ # Strip system message (instructions) — it's separate in Responses API
6463
+ if instructions and opt_msgs and opt_msgs[0].get("role") == "system":
6464
+ body["instructions"] = opt_msgs[0]["content"]
6465
+ opt_msgs = opt_msgs[1:]
6466
+
6467
+ body["input"] = messages_to_responses_items(opt_msgs, original_items, preserved_indices)
6468
+
6469
  url = f"{self.OPENAI_API_URL}/v1/responses"
6470
 
6471
  try:
tests/test_proxy_openai_responses_integration.py CHANGED
@@ -219,6 +219,85 @@ class TestOpenAIResponsesCompression:
219
  # At least some tokens should have been saved
220
  assert stats["tokens"]["saved"] >= 0 # May or may not compress depending on size
221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
 
223
  class TestOpenAIResponsesStats:
224
  """Test that proxy stats track /v1/responses requests correctly."""
 
219
  # At least some tokens should have been saved
220
  assert stats["tokens"]["saved"] >= 0 # May or may not compress depending on size
221
 
222
+ def test_compression_on_function_call_output(self, openai_responses_client, api_key):
223
+ """Large function_call_output gets compressed (Codex pattern)."""
224
+ # Create large tool output (simulating Codex file read or shell output)
225
+ large_output = json.dumps(
226
+ [{"id": i, "name": f"record_{i}", "value": f"data_{i}" * 10} for i in range(200)]
227
+ )
228
+
229
+ response = openai_responses_client.post(
230
+ "/v1/responses",
231
+ headers={"Authorization": f"Bearer {api_key}"},
232
+ json={
233
+ "model": "gpt-4o-mini",
234
+ "input": [
235
+ {"role": "user", "content": "How many records are in the database?"},
236
+ {
237
+ "type": "function_call",
238
+ "call_id": "call_test_1",
239
+ "name": "query_database",
240
+ "arguments": "{}",
241
+ },
242
+ {
243
+ "type": "function_call_output",
244
+ "call_id": "call_test_1",
245
+ "output": large_output,
246
+ },
247
+ ],
248
+ },
249
+ )
250
+ assert response.status_code == 200
251
+ data = response.json()
252
+
253
+ # Model should be able to answer
254
+ assert "output" in data
255
+ assert len(data["output"]) > 0
256
+
257
+ # Compression should have saved tokens
258
+ stats = openai_responses_client.get("/stats").json()
259
+ assert stats["tokens"]["saved"] > 0
260
+
261
+ def test_no_compression_with_string_input(self, openai_responses_client, api_key):
262
+ """String input (single message) should not crash or compress."""
263
+ response = openai_responses_client.post(
264
+ "/v1/responses",
265
+ headers={"Authorization": f"Bearer {api_key}"},
266
+ json={"model": "gpt-4o-mini", "input": "What is 1+1?"},
267
+ )
268
+ assert response.status_code == 200
269
+
270
+ def test_bypass_header_skips_compression(self, openai_responses_client, api_key):
271
+ """x-headroom-bypass header skips compression."""
272
+ items = [
273
+ {"id": i, "name": f"Item {i}", "desc": f"Description for item {i}"} for i in range(100)
274
+ ]
275
+ tool_output = json.dumps(items)
276
+
277
+ # Reset stats first
278
+ openai_responses_client.post("/stats/reset")
279
+
280
+ response = openai_responses_client.post(
281
+ "/v1/responses",
282
+ headers={
283
+ "Authorization": f"Bearer {api_key}",
284
+ "x-headroom-bypass": "true",
285
+ },
286
+ json={
287
+ "model": "gpt-4o-mini",
288
+ "input": [
289
+ {"role": "user", "content": "Get items"},
290
+ {"role": "assistant", "content": f"Results:\n{tool_output}"},
291
+ {"role": "user", "content": "How many?"},
292
+ ],
293
+ },
294
+ )
295
+ assert response.status_code == 200
296
+
297
+ stats = openai_responses_client.get("/stats").json()
298
+ # With bypass, no tokens should be saved
299
+ assert stats["tokens"]["saved"] == 0
300
+
301
 
302
  class TestOpenAIResponsesStats:
303
  """Test that proxy stats track /v1/responses requests correctly."""
tests/test_responses_converter.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for OpenAI Responses API ↔ Chat Completions message conversion.
2
+
3
+ Tests cover:
4
+ 1. Forward conversion (Responses items → Chat Completions messages)
5
+ 2. Reverse conversion (compressed messages → Responses items)
6
+ 3. Round-trip fidelity (convert → compress → convert back)
7
+ 4. Edge cases (empty input, unknown types, mixed ordering)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+ from headroom.proxy.responses_converter import (
15
+ messages_to_responses_items,
16
+ responses_items_to_messages,
17
+ )
18
+
19
+ # =============================================================================
20
+ # Forward conversion: responses_items_to_messages
21
+ # =============================================================================
22
+
23
+
24
+ class TestItemsToMessages:
25
+ """Test converting Responses API items to Chat Completions messages."""
26
+
27
+ def test_simple_user_message(self):
28
+ """String content user message passes through."""
29
+ items = [{"role": "user", "content": "Hello"}]
30
+ messages, preserved = responses_items_to_messages(items)
31
+
32
+ assert len(messages) == 1
33
+ assert messages[0] == {"role": "user", "content": "Hello"}
34
+ assert preserved == []
35
+
36
+ def test_content_array_input_text(self):
37
+ """input_text content parts are extracted to plain text."""
38
+ items = [
39
+ {
40
+ "role": "user",
41
+ "content": [{"type": "input_text", "text": "What is 2+2?"}],
42
+ }
43
+ ]
44
+ messages, preserved = responses_items_to_messages(items)
45
+
46
+ assert len(messages) == 1
47
+ assert messages[0]["role"] == "user"
48
+ assert messages[0]["content"] == "What is 2+2?"
49
+
50
+ def test_output_text_assistant(self):
51
+ """output_text content parts from assistant messages are extracted."""
52
+ items = [
53
+ {
54
+ "type": "message",
55
+ "role": "assistant",
56
+ "content": [{"type": "output_text", "text": "The answer is 4."}],
57
+ }
58
+ ]
59
+ messages, preserved = responses_items_to_messages(items)
60
+
61
+ assert len(messages) == 1
62
+ assert messages[0]["role"] == "assistant"
63
+ assert messages[0]["content"] == "The answer is 4."
64
+
65
+ def test_function_call_to_tool_calls(self):
66
+ """Single function_call item becomes assistant message with tool_calls."""
67
+ items = [
68
+ {
69
+ "type": "function_call",
70
+ "call_id": "call_abc",
71
+ "name": "get_weather",
72
+ "arguments": '{"city": "Paris"}',
73
+ }
74
+ ]
75
+ messages, preserved = responses_items_to_messages(items)
76
+
77
+ assert len(messages) == 1
78
+ msg = messages[0]
79
+ assert msg["role"] == "assistant"
80
+ assert msg["content"] is None
81
+ assert len(msg["tool_calls"]) == 1
82
+ tc = msg["tool_calls"][0]
83
+ assert tc["id"] == "call_abc"
84
+ assert tc["function"]["name"] == "get_weather"
85
+ assert tc["function"]["arguments"] == '{"city": "Paris"}'
86
+
87
+ def test_consecutive_function_calls_merge(self):
88
+ """Consecutive function_call items merge into one assistant message."""
89
+ items = [
90
+ {
91
+ "type": "function_call",
92
+ "call_id": "call_1",
93
+ "name": "search",
94
+ "arguments": '{"q": "foo"}',
95
+ },
96
+ {
97
+ "type": "function_call",
98
+ "call_id": "call_2",
99
+ "name": "read_file",
100
+ "arguments": '{"path": "/tmp/x"}',
101
+ },
102
+ ]
103
+ messages, preserved = responses_items_to_messages(items)
104
+
105
+ assert len(messages) == 1
106
+ assert len(messages[0]["tool_calls"]) == 2
107
+ assert messages[0]["tool_calls"][0]["id"] == "call_1"
108
+ assert messages[0]["tool_calls"][1]["id"] == "call_2"
109
+
110
+ def test_function_call_output_to_tool_role(self):
111
+ """function_call_output becomes role=tool message."""
112
+ items = [
113
+ {
114
+ "type": "function_call_output",
115
+ "call_id": "call_abc",
116
+ "output": '{"temp": 22, "unit": "C"}',
117
+ }
118
+ ]
119
+ messages, preserved = responses_items_to_messages(items)
120
+
121
+ assert len(messages) == 1
122
+ assert messages[0]["role"] == "tool"
123
+ assert messages[0]["tool_call_id"] == "call_abc"
124
+ assert messages[0]["content"] == '{"temp": 22, "unit": "C"}'
125
+
126
+ def test_reasoning_preserved(self):
127
+ """Reasoning items go to preserved_indices, not messages."""
128
+ items = [
129
+ {"role": "user", "content": "Think hard."},
130
+ {
131
+ "type": "reasoning",
132
+ "id": "rs_1",
133
+ "summary": [{"type": "summary_text", "text": "Thinking..."}],
134
+ },
135
+ {
136
+ "type": "message",
137
+ "role": "assistant",
138
+ "content": [{"type": "output_text", "text": "Done."}],
139
+ },
140
+ ]
141
+ messages, preserved = responses_items_to_messages(items)
142
+
143
+ assert len(messages) == 2 # user + assistant (reasoning skipped)
144
+ assert 1 in preserved # index 1 is the reasoning item
145
+
146
+ def test_image_content_preserved(self):
147
+ """Items with input_image content are preserved, not converted."""
148
+ items = [
149
+ {
150
+ "role": "user",
151
+ "content": [
152
+ {"type": "input_text", "text": "Describe this image."},
153
+ {"type": "input_image", "image_url": "data:image/png;base64,abc"},
154
+ ],
155
+ }
156
+ ]
157
+ messages, preserved = responses_items_to_messages(items)
158
+
159
+ assert len(messages) == 0 # skipped (has non-text)
160
+ assert 0 in preserved
161
+
162
+ def test_developer_maps_to_system(self):
163
+ """developer role maps to system."""
164
+ items = [{"role": "developer", "content": "You are helpful."}]
165
+ messages, preserved = responses_items_to_messages(items)
166
+
167
+ assert messages[0]["role"] == "system"
168
+ assert messages[0]["content"] == "You are helpful."
169
+
170
+ def test_system_passthrough(self):
171
+ """system role passes through as-is."""
172
+ items = [{"role": "system", "content": "Be concise."}]
173
+ messages, preserved = responses_items_to_messages(items)
174
+
175
+ assert messages[0]["role"] == "system"
176
+ assert messages[0]["content"] == "Be concise."
177
+
178
+ def test_message_type_item(self):
179
+ """Item with explicit type='message' is handled."""
180
+ items = [
181
+ {"type": "message", "role": "user", "content": "Hi"},
182
+ ]
183
+ messages, preserved = responses_items_to_messages(items)
184
+
185
+ assert messages[0] == {"role": "user", "content": "Hi"}
186
+
187
+ def test_empty_input(self):
188
+ """Empty items list produces empty messages."""
189
+ messages, preserved = responses_items_to_messages([])
190
+ assert messages == []
191
+ assert preserved == []
192
+
193
+ def test_unknown_type_preserved(self):
194
+ """Unknown item types are preserved, not converted."""
195
+ items = [{"type": "some_future_type", "data": "something"}]
196
+ messages, preserved = responses_items_to_messages(items)
197
+
198
+ assert len(messages) == 0
199
+ assert 0 in preserved
200
+
201
+ def test_function_call_flush_on_non_function_call(self):
202
+ """Pending function_calls flush when a non-function_call item arrives."""
203
+ items = [
204
+ {"type": "function_call", "call_id": "c1", "name": "f1", "arguments": "{}"},
205
+ {"type": "function_call_output", "call_id": "c1", "output": "result"},
206
+ ]
207
+ messages, preserved = responses_items_to_messages(items)
208
+
209
+ # Should be: assistant (tool_calls), tool (output)
210
+ assert len(messages) == 2
211
+ assert messages[0]["role"] == "assistant"
212
+ assert messages[0]["tool_calls"][0]["id"] == "c1"
213
+ assert messages[1]["role"] == "tool"
214
+
215
+
216
+ # =============================================================================
217
+ # Reverse conversion: messages_to_responses_items
218
+ # =============================================================================
219
+
220
+
221
+ class TestMessagesToItems:
222
+ """Test converting compressed messages back to Responses API items."""
223
+
224
+ def test_round_trip_simple(self):
225
+ """Simple user/assistant conversation round-trips."""
226
+ original = [
227
+ {"role": "user", "content": "Hello"},
228
+ {"type": "message", "role": "assistant", "content": "Hi!"},
229
+ ]
230
+ messages, preserved = responses_items_to_messages(original)
231
+ result = messages_to_responses_items(messages, original, preserved)
232
+
233
+ assert len(result) == 2
234
+ assert result[0]["content"] == "Hello"
235
+ assert result[1]["content"] == "Hi!"
236
+
237
+ def test_round_trip_with_tools(self):
238
+ """Full tool call flow round-trips correctly."""
239
+ original = [
240
+ {"role": "user", "content": "Weather in Paris?"},
241
+ {
242
+ "type": "function_call",
243
+ "call_id": "call_1",
244
+ "name": "get_weather",
245
+ "arguments": '{"city": "Paris"}',
246
+ },
247
+ {
248
+ "type": "function_call_output",
249
+ "call_id": "call_1",
250
+ "output": "Sunny, 22C",
251
+ },
252
+ {
253
+ "type": "message",
254
+ "role": "assistant",
255
+ "content": [{"type": "output_text", "text": "It's sunny."}],
256
+ },
257
+ ]
258
+ messages, preserved = responses_items_to_messages(original)
259
+
260
+ assert len(messages) == 4 # user, assistant(tool_calls), tool, assistant
261
+
262
+ result = messages_to_responses_items(messages, original, preserved)
263
+
264
+ assert len(result) == 4
265
+ # function_call passes through unmodified
266
+ assert result[1]["type"] == "function_call"
267
+ assert result[1]["call_id"] == "call_1"
268
+ # function_call_output has original content (no compression happened)
269
+ assert result[2]["type"] == "function_call_output"
270
+ assert result[2]["output"] == "Sunny, 22C"
271
+
272
+ def test_round_trip_compressed_output(self):
273
+ """Simulated compression: shortened tool output appears in result."""
274
+ original = [
275
+ {"role": "user", "content": "Get data"},
276
+ {
277
+ "type": "function_call",
278
+ "call_id": "call_1",
279
+ "name": "search",
280
+ "arguments": "{}",
281
+ },
282
+ {
283
+ "type": "function_call_output",
284
+ "call_id": "call_1",
285
+ "output": json.dumps([{"id": i, "name": f"item_{i}"} for i in range(100)]),
286
+ },
287
+ ]
288
+ messages, preserved = responses_items_to_messages(original)
289
+
290
+ # Simulate compression: replace tool output with shorter version
291
+ for msg in messages:
292
+ if msg.get("role") == "tool":
293
+ msg["content"] = "[100 items, first: item_0, last: item_99]"
294
+
295
+ result = messages_to_responses_items(messages, original, preserved)
296
+
297
+ assert result[2]["type"] == "function_call_output"
298
+ assert result[2]["output"] == "[100 items, first: item_0, last: item_99]"
299
+
300
+ def test_round_trip_with_reasoning(self):
301
+ """Reasoning items survive round-trip exactly."""
302
+ reasoning_item = {
303
+ "type": "reasoning",
304
+ "id": "rs_abc",
305
+ "summary": [{"type": "summary_text", "text": "Let me think..."}],
306
+ }
307
+ original = [
308
+ {"role": "user", "content": "Complex question"},
309
+ reasoning_item,
310
+ {"type": "message", "role": "assistant", "content": "Answer."},
311
+ ]
312
+ messages, preserved = responses_items_to_messages(original)
313
+ result = messages_to_responses_items(messages, original, preserved)
314
+
315
+ assert len(result) == 3
316
+ assert result[1] == reasoning_item # Exact match
317
+ assert result[1]["type"] == "reasoning"
318
+
319
+ def test_round_trip_content_array_preserved(self):
320
+ """Content array structure (input_text) is preserved through round-trip."""
321
+ original = [
322
+ {
323
+ "role": "user",
324
+ "content": [{"type": "input_text", "text": "Original question"}],
325
+ },
326
+ ]
327
+ messages, preserved = responses_items_to_messages(original)
328
+
329
+ # Simulate compression changing the text
330
+ messages[0]["content"] = "Compressed question"
331
+
332
+ result = messages_to_responses_items(messages, original, preserved)
333
+
334
+ # Should reconstruct the array structure
335
+ assert isinstance(result[0]["content"], list)
336
+ assert result[0]["content"][0]["type"] == "input_text"
337
+ assert result[0]["content"][0]["text"] == "Compressed question"
338
+
339
+ def test_mixed_ordering(self):
340
+ """Complex sequence maintains correct ordering."""
341
+ original = [
342
+ {"role": "user", "content": "Do two things"},
343
+ {
344
+ "type": "function_call",
345
+ "call_id": "c1",
346
+ "name": "task_a",
347
+ "arguments": "{}",
348
+ },
349
+ {
350
+ "type": "function_call",
351
+ "call_id": "c2",
352
+ "name": "task_b",
353
+ "arguments": "{}",
354
+ },
355
+ {
356
+ "type": "function_call_output",
357
+ "call_id": "c1",
358
+ "output": "Result A",
359
+ },
360
+ {
361
+ "type": "function_call_output",
362
+ "call_id": "c2",
363
+ "output": "Result B",
364
+ },
365
+ {
366
+ "type": "reasoning",
367
+ "id": "rs_1",
368
+ "summary": [{"type": "summary_text", "text": "Thinking..."}],
369
+ },
370
+ {"type": "message", "role": "assistant", "content": "All done."},
371
+ ]
372
+ messages, preserved = responses_items_to_messages(original)
373
+ result = messages_to_responses_items(messages, original, preserved)
374
+
375
+ assert len(result) == 7
376
+ assert result[0]["role"] == "user"
377
+ assert result[1]["type"] == "function_call"
378
+ assert result[1]["call_id"] == "c1"
379
+ assert result[2]["type"] == "function_call"
380
+ assert result[2]["call_id"] == "c2"
381
+ assert result[3]["type"] == "function_call_output"
382
+ assert result[3]["call_id"] == "c1"
383
+ assert result[4]["type"] == "function_call_output"
384
+ assert result[4]["call_id"] == "c2"
385
+ assert result[5]["type"] == "reasoning"
386
+ assert result[6]["content"] == "All done."
387
+
388
+ def test_image_preserved_in_round_trip(self):
389
+ """Image items survive round-trip at their original position."""
390
+ image_item = {
391
+ "role": "user",
392
+ "content": [
393
+ {"type": "input_text", "text": "Describe this"},
394
+ {"type": "input_image", "image_url": "https://example.com/img.png"},
395
+ ],
396
+ }
397
+ original = [
398
+ {"role": "user", "content": "Hi"},
399
+ image_item,
400
+ {"role": "user", "content": "Also tell me about this"},
401
+ ]
402
+ messages, preserved = responses_items_to_messages(original)
403
+ result = messages_to_responses_items(messages, original, preserved)
404
+
405
+ assert len(result) == 3
406
+ assert result[0]["content"] == "Hi"
407
+ assert result[1] == image_item # Preserved exactly
408
+ assert result[2]["content"] == "Also tell me about this"