codex-proxy 3.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codex_proxy/store.py ADDED
@@ -0,0 +1,97 @@
1
+ """In-memory response store for previous_response_id support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections import OrderedDict
7
+
8
+
9
+ class ResponseStore:
10
+ """Stores completed responses so Codex can reference them in multi-turn."""
11
+
12
+ def __init__(self, ttl_seconds: int = 600, max_entries: int = 100):
13
+ self._store: OrderedDict[str, dict] = OrderedDict()
14
+ self.ttl_seconds = ttl_seconds
15
+ self.max_entries = max_entries
16
+
17
+ def put(self, response_id: str, response: dict) -> None:
18
+ self._store[response_id] = {
19
+ "response": response,
20
+ "timestamp": time.time(),
21
+ }
22
+ if len(self._store) > self.max_entries:
23
+ self._store.popitem(last=False)
24
+
25
+ def get(self, response_id: str) -> dict | None:
26
+ entry = self._store.get(response_id)
27
+ if not entry:
28
+ return None
29
+ if time.time() - entry["timestamp"] > self.ttl_seconds:
30
+ del self._store[response_id]
31
+ return None
32
+ res = entry["response"]
33
+ assert isinstance(res, dict)
34
+ return res
35
+
36
+ def resolve_input(self, body: dict) -> dict:
37
+ """If body has previous_response_id, prepend that response's output to input."""
38
+ prev_id = body.get("previous_response_id")
39
+ if not prev_id:
40
+ return body
41
+
42
+ prev = self.get(prev_id)
43
+ if not prev:
44
+ return body
45
+
46
+ current_input = body.get("input", [])
47
+ prev_output = prev.get("output", [])
48
+
49
+ # Convert previous output items to input items for context
50
+ context_items = []
51
+ for item in prev_output:
52
+ t = item.get("type", "")
53
+ if t == "message":
54
+ role = item.get("role", "assistant")
55
+ parts = item.get("content", [])
56
+ text = ""
57
+ for p in parts:
58
+ if isinstance(p, dict):
59
+ text += p.get("text", "")
60
+ if text:
61
+ context_items.append({
62
+ "type": "message", "role": role,
63
+ "content": [{"type": "input_text", "text": text}]
64
+ })
65
+ elif t == "function_call":
66
+ context_items.append({
67
+ "type": "function_call",
68
+ "id": item.get("id", ""),
69
+ "call_id": item.get("call_id", ""),
70
+ "name": item.get("name", ""),
71
+ "arguments": item.get("arguments", "{}"),
72
+ })
73
+
74
+ # Get previous input (the conversation before the response)
75
+ prev_input = prev.get("_original_input", [])
76
+
77
+ # Combine: previous input + previous output + current input
78
+ combined = prev_input + context_items
79
+ if isinstance(current_input, list):
80
+ combined += current_input
81
+ elif isinstance(current_input, str):
82
+ combined.append({"type": "message", "role": "user",
83
+ "content": [{"type": "input_text",
84
+ "text": current_input}]})
85
+
86
+ body = dict(body)
87
+ body["input"] = combined
88
+ body.pop("previous_response_id", None)
89
+ return body
90
+
91
+ def size(self) -> int:
92
+ return len(self._store)
93
+
94
+ def entries(self) -> list[tuple[str, float]]:
95
+ """Return list of (response_id, age_seconds) for all cached entries."""
96
+ now = time.time()
97
+ return [(rid, now - entry["timestamp"]) for rid, entry in self._store.items()]
@@ -0,0 +1,360 @@
1
+ """Responses API <-> Chat Completions protocol translator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import uuid
8
+
9
+ from .compaction import compact_messages
10
+
11
+ # ── Responses API input -> Chat Completions messages ────────────────────
12
+
13
+ def input_to_messages(input_data, instructions: str | None = None) -> list[dict]:
14
+ """Convert Responses API `input` to Chat Completions `messages`."""
15
+ messages: list[dict] = []
16
+ if instructions:
17
+ messages.append({"role": "system", "content": instructions})
18
+
19
+ if isinstance(input_data, str):
20
+ messages.append({"role": "user", "content": input_data})
21
+ return messages
22
+
23
+ if not isinstance(input_data, list):
24
+ messages.append({"role": "user", "content": str(input_data)})
25
+ return messages
26
+
27
+ for item in input_data:
28
+ if isinstance(item, str):
29
+ messages.append({"role": "user", "content": item})
30
+ continue
31
+ if not isinstance(item, dict):
32
+ continue
33
+
34
+ t = item.get("type", "")
35
+
36
+ if t == "message":
37
+ role = item.get("role", "user")
38
+ parts = item.get("content", [])
39
+ text = ""
40
+ for p in parts:
41
+ if isinstance(p, str):
42
+ text += p
43
+ elif isinstance(p, dict) and p.get("type") in ("input_text", "text"):
44
+ text += p.get("text", "")
45
+ messages.append({"role": role, "content": text})
46
+
47
+ elif t == "function_call":
48
+ tc = {
49
+ "id": item.get("call_id", item.get("id", "")),
50
+ "type": "function",
51
+ "function": {
52
+ "name": item.get("name", ""),
53
+ "arguments": item.get("arguments", "{}"),
54
+ },
55
+ }
56
+ if (messages and messages[-1].get("role") == "assistant"
57
+ and "tool_calls" in messages[-1]):
58
+ messages[-1]["tool_calls"].append(tc)
59
+ else:
60
+ messages.append({"role": "assistant", "content": None,
61
+ "tool_calls": [tc]})
62
+
63
+ elif t == "function_call_output":
64
+ messages.append({"role": "tool",
65
+ "tool_call_id": item.get("call_id", ""),
66
+ "content": item.get("output", "")})
67
+
68
+ return messages
69
+
70
+
71
+ # ── Tool format conversion ──────────────────────────────────────────────
72
+
73
+ def convert_tools(tools: list | None) -> list | None:
74
+ """Convert Responses API tools -> Chat Completions tools."""
75
+ if not tools:
76
+ return None
77
+ out = []
78
+ for t in tools:
79
+ tt = t.get("type", "")
80
+ if tt == "function":
81
+ fn = t.get("function", {})
82
+ if not fn and t.get("name"):
83
+ fn = {"name": t["name"],
84
+ "description": t.get("description", ""),
85
+ "parameters": t.get("parameters", {})}
86
+ out.append({"type": "function", "function": fn})
87
+ return out or None
88
+
89
+
90
+ # ── Request builder ─────────────────────────────────────────────────────
91
+
92
+ def build_cc_request(
93
+ body: dict,
94
+ compaction_enabled: bool = True,
95
+ compaction_max_messages: int = 50,
96
+ compaction_keep_last: int = 20,
97
+ ) -> dict:
98
+ """Parse Responses API body, return Chat Completions request dict."""
99
+ model = body.get("model", "glm-5.1")
100
+ messages = input_to_messages(body.get("input"), body.get("instructions"))
101
+ if compaction_enabled:
102
+ messages = compact_messages(
103
+ messages,
104
+ max_messages=compaction_max_messages,
105
+ keep_last=compaction_keep_last,
106
+ )
107
+ cc: dict = {"model": model, "messages": messages,
108
+ "stream": body.get("stream", True)}
109
+
110
+ if cc["stream"]:
111
+ cc["stream_options"] = {"include_usage": True}
112
+
113
+ for k in ("temperature", "top_p"):
114
+ if k in body:
115
+ cc[k] = body[k]
116
+ if "max_output_tokens" in body:
117
+ cc["max_tokens"] = body["max_output_tokens"]
118
+ elif "max_tokens" in body:
119
+ cc["max_tokens"] = body["max_tokens"]
120
+
121
+ tools = convert_tools(body.get("tools"))
122
+ if tools:
123
+ cc["tools"] = tools
124
+ if "tool_choice" in body:
125
+ cc["tool_choice"] = body["tool_choice"]
126
+
127
+ return cc
128
+
129
+
130
+ def unwrap_envelope(raw: str) -> dict:
131
+ """Parse JSON, unwrap Realtime API envelope if present."""
132
+ body = json.loads(raw)
133
+ if not isinstance(body, dict):
134
+ return {}
135
+ if body.get("type") == "response.create":
136
+ res = body.get("response")
137
+ if isinstance(res, dict):
138
+ return res
139
+ return body
140
+
141
+
142
+ # ── Response conversion: CC -> Responses API ────────────────────────────
143
+
144
+ def generate_response_id() -> str:
145
+ return f"resp_{uuid.uuid4().hex[:24]}"
146
+
147
+
148
+ def cc_to_response(body: dict, model: str) -> dict:
149
+ """Non-streaming Chat Completions response -> Responses API object."""
150
+ out: list[dict] = []
151
+ if body.get("choices"):
152
+ msg = body["choices"][0].get("message", {})
153
+ content = msg.get("content")
154
+ reasoning = msg.get("reasoning_content")
155
+
156
+ if reasoning:
157
+ out.append({
158
+ "type": "reasoning", "id": f"rs_{uuid.uuid4().hex[:24]}",
159
+ "summary": [{"type": "summary_text", "text": reasoning}],
160
+ })
161
+
162
+ if content:
163
+ out.append({
164
+ "type": "message", "id": f"msg_{uuid.uuid4().hex[:24]}",
165
+ "status": "completed", "role": "assistant",
166
+ "content": [{"type": "output_text", "text": content,
167
+ "annotations": []}],
168
+ })
169
+
170
+ for tc in msg.get("tool_calls", []):
171
+ out.append({
172
+ "type": "function_call",
173
+ "id": tc.get("id", ""), "call_id": tc.get("id", ""),
174
+ "name": tc["function"]["name"],
175
+ "arguments": tc["function"].get("arguments", "{}"),
176
+ })
177
+
178
+ if not content and not reasoning and not msg.get("tool_calls"):
179
+ out.append({
180
+ "type": "message", "id": f"msg_{uuid.uuid4().hex[:24]}",
181
+ "status": "completed", "role": "assistant",
182
+ "content": [{"type": "output_text", "text": "",
183
+ "annotations": []}],
184
+ })
185
+
186
+ usage = body.get("usage", {})
187
+ return {
188
+ "id": generate_response_id(), "object": "response",
189
+ "created_at": int(time.time()), "model": model,
190
+ "status": "completed", "output": out,
191
+ "usage": {"input_tokens": usage.get("prompt_tokens", 0),
192
+ "output_tokens": usage.get("completion_tokens", 0),
193
+ "total_tokens": usage.get("total_tokens", 0)},
194
+ }
195
+
196
+
197
+ # ── Streaming core: shared parser + helpers ─────────────────────────────
198
+
199
+ async def parse_cc_stream(line_iter):
200
+ """Core SSE parser — yields (event_type, data) tuples.
201
+
202
+ Event types: "text", "reasoning", "tool_call", "usage"
203
+ """
204
+ async for line in line_iter:
205
+ if not line.startswith("data: "):
206
+ continue
207
+ payload = line[6:].strip()
208
+ if payload == "[DONE]":
209
+ break
210
+ try:
211
+ chunk = json.loads(payload)
212
+ except json.JSONDecodeError:
213
+ continue
214
+ for choice in chunk.get("choices", []):
215
+ delta = choice.get("delta", {})
216
+ rc = delta.get("reasoning_content")
217
+ if rc:
218
+ yield ("reasoning", rc)
219
+ txt = delta.get("content")
220
+ if txt:
221
+ yield ("text", txt)
222
+ for tc in delta.get("tool_calls", []):
223
+ yield ("tool_call", tc)
224
+ chunk_usage = chunk.get("usage")
225
+ if chunk_usage:
226
+ yield ("usage", chunk_usage)
227
+
228
+
229
+ def accumulate_tool_call(tool_calls: list[dict], tc: dict) -> None:
230
+ """Accumulate a streaming tool_call delta into the tool_calls list."""
231
+ idx = tc.get("index", 0)
232
+ while len(tool_calls) <= idx:
233
+ tool_calls.append({"id": "", "function": {"name": "", "arguments": ""}})
234
+ if tc.get("id"):
235
+ tool_calls[idx]["id"] = tc["id"]
236
+ fn = tc.get("function", {})
237
+ if fn.get("name"):
238
+ tool_calls[idx]["function"]["name"] = fn["name"]
239
+ if fn.get("arguments"):
240
+ tool_calls[idx]["function"]["arguments"] += fn["arguments"]
241
+
242
+
243
+ def build_final_output(mid: str, full_text: str, reasoning_text: str,
244
+ tool_calls: list[dict]) -> list[dict]:
245
+ """Build the Responses API output list from accumulated stream data."""
246
+ final_out: list[dict] = []
247
+ if reasoning_text:
248
+ final_out.append({
249
+ "type": "reasoning", "id": f"rs_{uuid.uuid4().hex[:24]}",
250
+ "summary": [{"type": "summary_text", "text": reasoning_text}],
251
+ })
252
+ final_out.append({
253
+ "type": "message", "id": mid, "status": "completed",
254
+ "role": "assistant",
255
+ "content": [{"type": "output_text", "text": full_text,
256
+ "annotations": []}],
257
+ })
258
+ for tc in tool_calls:
259
+ fc_id = tc["id"] or f"fc_{uuid.uuid4().hex[:24]}"
260
+ final_out.append({
261
+ "type": "function_call", "id": fc_id, "call_id": fc_id,
262
+ "name": tc["function"]["name"],
263
+ "arguments": tc["function"]["arguments"],
264
+ "status": "completed",
265
+ })
266
+ return final_out
267
+
268
+
269
+ # ── Streaming: CC SSE -> Responses API SSE ──────────────────────────────
270
+
271
+ def _ev(event: str, data: dict) -> str:
272
+ return f"event: {event}\ndata: {json.dumps(data)}\n\n"
273
+
274
+
275
+ async def stream_cc_to_response(cc_iter, model: str, result: dict | None = None):
276
+ """Convert Chat Completions SSE stream -> Responses API SSE events.
277
+
278
+ If *result* is provided, it will be populated with the completed response
279
+ dict (including id and output) after the generator finishes.
280
+ """
281
+ rid = generate_response_id()
282
+ mid = f"msg_{uuid.uuid4().hex[:24]}"
283
+ now = int(time.time())
284
+
285
+ init = {"id": rid, "object": "response", "created_at": now,
286
+ "model": model, "status": "in_progress", "output": [],
287
+ "usage": {"input_tokens": 0, "output_tokens": 0,
288
+ "total_tokens": 0}}
289
+
290
+ yield _ev("response.created", {"type": "response.created", "response": init})
291
+ yield _ev("response.in_progress", {"type": "response.in_progress", "response": init})
292
+ yield _ev("response.output_item.added", {
293
+ "type": "response.output_item.added", "output_index": 0,
294
+ "item": {"type": "message", "id": mid, "status": "in_progress",
295
+ "role": "assistant", "content": []}})
296
+ yield _ev("response.content_part.added", {
297
+ "type": "response.content_part.added", "output_index": 0,
298
+ "content_index": 0,
299
+ "part": {"type": "output_text", "text": "", "annotations": []}})
300
+
301
+ full_text = ""
302
+ reasoning_text = ""
303
+ tool_calls: list[dict] = []
304
+ usage_data = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
305
+
306
+ async for event_type, data in parse_cc_stream(cc_iter):
307
+ if event_type == "reasoning":
308
+ reasoning_text += data
309
+ elif event_type == "text":
310
+ full_text += data
311
+ yield _ev("response.output_text.delta", {
312
+ "type": "response.output_text.delta",
313
+ "output_index": 0, "content_index": 0, "delta": data})
314
+ elif event_type == "tool_call":
315
+ accumulate_tool_call(tool_calls, data)
316
+ elif event_type == "usage":
317
+ usage_data = {
318
+ "input_tokens": data.get("prompt_tokens", 0),
319
+ "output_tokens": data.get("completion_tokens", 0),
320
+ "total_tokens": data.get("total_tokens", 0),
321
+ }
322
+
323
+ # Finish text
324
+ yield _ev("response.output_text.done", {
325
+ "type": "response.output_text.done",
326
+ "output_index": 0, "content_index": 0, "text": full_text})
327
+ yield _ev("response.content_part.done", {
328
+ "type": "response.content_part.done", "output_index": 0,
329
+ "content_index": 0,
330
+ "part": {"type": "output_text", "text": full_text, "annotations": []}})
331
+ yield _ev("response.output_item.done", {
332
+ "type": "response.output_item.done", "output_index": 0,
333
+ "item": {"type": "message", "id": mid, "status": "completed",
334
+ "role": "assistant",
335
+ "content": [{"type": "output_text", "text": full_text,
336
+ "annotations": []}]}})
337
+
338
+ # Build final output
339
+ final_out = build_final_output(mid, full_text, reasoning_text, tool_calls)
340
+
341
+ # Emit tool call items as separate events
342
+ text_and_reasoning_count = len(final_out) - len(tool_calls)
343
+ for i, item in enumerate(final_out[text_and_reasoning_count:], text_and_reasoning_count):
344
+ fc = {k: v for k, v in item.items() if k != "status"}
345
+ yield _ev("response.output_item.added", {
346
+ "type": "response.output_item.added",
347
+ "output_index": i, "item": fc})
348
+ yield _ev("response.output_item.done", {
349
+ "type": "response.output_item.done",
350
+ "output_index": i, "item": fc})
351
+
352
+ # Completed
353
+ completed = {"id": rid, "object": "response", "created_at": now,
354
+ "model": model, "status": "completed", "output": final_out,
355
+ "usage": usage_data}
356
+ yield _ev("response.completed", {
357
+ "type": "response.completed", "response": completed})
358
+
359
+ if result is not None:
360
+ result["response"] = completed