codex-api-proxy 0.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.
@@ -0,0 +1,278 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import json
6
+ import os
7
+ import re
8
+ import time
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ class CodexRunError(RuntimeError):
15
+ """Raised when Codex exits unsuccessfully or emits unusable output."""
16
+
17
+
18
+ def _content_text(content: Any) -> str:
19
+ if isinstance(content, str):
20
+ return content
21
+ if isinstance(content, list):
22
+ texts: list[str] = []
23
+ for item in content:
24
+ if isinstance(item, dict) and "text" in item:
25
+ texts.append(str(item["text"]))
26
+ return "".join(texts)
27
+ return ""
28
+
29
+
30
+ def _assistant_text_from_event(event: dict[str, Any]) -> str | None:
31
+ if event.get("type") == "agent_message" and isinstance(event.get("message"), str):
32
+ return event["message"]
33
+ item = event.get("item")
34
+ if event.get("type") == "item.completed" and isinstance(item, dict):
35
+ if item.get("type") == "agent_message" and isinstance(item.get("text"), str):
36
+ return item["text"]
37
+ if item.get("type") == "message" and item.get("role") == "assistant":
38
+ text = _content_text(item.get("content"))
39
+ if text:
40
+ return text
41
+ return None
42
+
43
+
44
+ def parse_final_message(lines: list[str]) -> str:
45
+ final: str | None = None
46
+ for line in lines:
47
+ line = line.strip()
48
+ if not line:
49
+ continue
50
+ try:
51
+ event = json.loads(line)
52
+ except json.JSONDecodeError:
53
+ continue
54
+ text = _assistant_text_from_event(event)
55
+ if text is not None:
56
+ final = text
57
+ if final is None:
58
+ raise CodexRunError("No final assistant message found in Codex output")
59
+ return final
60
+
61
+
62
+ def summarize_stderr(stderr: str, max_chars: int = 1200) -> str:
63
+ text = stderr.strip() or "no stderr"
64
+ text = re.sub(r"<html>.*?</html>", "<html omitted>", text, flags=re.DOTALL | re.IGNORECASE)
65
+ text = re.sub(r"\s+", " ", text).strip()
66
+ if len(text) <= max_chars:
67
+ return text
68
+ marker = " ... "
69
+ keep = max_chars - len(marker)
70
+ if keep <= 0:
71
+ return text[:max_chars]
72
+ head = keep // 2
73
+ tail = keep - head
74
+ return f"{text[:head]}{marker}{text[-tail:]}"
75
+
76
+
77
+ def _extract_error_message(raw_message: Any) -> str | None:
78
+ if not raw_message:
79
+ return None
80
+ if not isinstance(raw_message, str):
81
+ return str(raw_message)
82
+ try:
83
+ parsed = json.loads(raw_message)
84
+ except json.JSONDecodeError:
85
+ return raw_message
86
+ if isinstance(parsed, dict):
87
+ error = parsed.get("error")
88
+ if isinstance(error, dict) and error.get("message"):
89
+ return str(error["message"])
90
+ if parsed.get("message"):
91
+ return str(parsed["message"])
92
+ return raw_message
93
+
94
+
95
+ def summarize_process_error(stderr: str, stdout: str) -> str:
96
+ stderr_summary = summarize_stderr(stderr)
97
+ if stderr_summary != "no stderr":
98
+ return stderr_summary
99
+
100
+ for line in stdout.splitlines():
101
+ try:
102
+ event = json.loads(line)
103
+ except json.JSONDecodeError:
104
+ continue
105
+ if event.get("type") == "error":
106
+ message = _extract_error_message(event.get("message"))
107
+ if message:
108
+ return summarize_stderr(message)
109
+ if event.get("type") == "turn.failed" and isinstance(event.get("error"), dict):
110
+ message = _extract_error_message(event["error"].get("message"))
111
+ if message:
112
+ return summarize_stderr(message)
113
+ return "no stderr"
114
+
115
+
116
+ def build_proxy_env(proxy: str | None, base_env: dict[str, str] | None = None) -> dict[str, str] | None:
117
+ if not proxy:
118
+ return None
119
+ env = dict(os.environ if base_env is None else base_env)
120
+ env["http_proxy"] = proxy
121
+ env["https_proxy"] = proxy
122
+ env["HTTP_PROXY"] = proxy
123
+ env["HTTPS_PROXY"] = proxy
124
+ return env
125
+
126
+
127
+ def build_codex_command(
128
+ *,
129
+ codex_bin: str,
130
+ cwd: Path,
131
+ model: str | None,
132
+ codex_configs: list[str],
133
+ ephemeral: bool,
134
+ ) -> list[str]:
135
+ has_sandbox_override = any(config.strip().startswith("sandbox_mode") for config in codex_configs)
136
+ command = [
137
+ codex_bin,
138
+ "exec",
139
+ "--json",
140
+ "--skip-git-repo-check",
141
+ "--ignore-user-config",
142
+ "--ignore-rules",
143
+ ]
144
+ if not has_sandbox_override:
145
+ command.extend(["--sandbox", "read-only"])
146
+ command.extend(["--cd", str(cwd)])
147
+ if model:
148
+ command.extend(["--model", model])
149
+ for config in codex_configs:
150
+ command.extend(["-c", config])
151
+ if ephemeral:
152
+ command.append("--ephemeral")
153
+ command.append("-")
154
+ return command
155
+
156
+
157
+ def _record_latency(callback: Callable[[str, float], None] | None, name: str, started_at: float) -> None:
158
+ if callback:
159
+ callback(name, (time.perf_counter() - started_at) * 1000)
160
+
161
+
162
+ async def _read_process_output(
163
+ *,
164
+ process: asyncio.subprocess.Process,
165
+ prompt: str,
166
+ latency_callback: Callable[[str, float], None] | None,
167
+ ) -> tuple[str, str]:
168
+ if process.stdin is None or process.stdout is None or process.stderr is None:
169
+ raise CodexRunError("Codex subprocess was created without stdio pipes")
170
+
171
+ io_started_at = time.perf_counter()
172
+ stderr_task = asyncio.create_task(process.stderr.read())
173
+ stdout_lines: list[str] = []
174
+ saw_stdout_event = False
175
+ saw_assistant_event = False
176
+
177
+ try:
178
+ stdin_started_at = time.perf_counter()
179
+ try:
180
+ process.stdin.write(prompt.encode("utf-8"))
181
+ await process.stdin.drain()
182
+ except BrokenPipeError:
183
+ pass
184
+ finally:
185
+ process.stdin.close()
186
+ with contextlib.suppress(BrokenPipeError, ConnectionResetError):
187
+ await process.stdin.wait_closed()
188
+ _record_latency(latency_callback, "codex_stdin_write", stdin_started_at)
189
+
190
+ stdout_started_at = time.perf_counter()
191
+ while True:
192
+ line_bytes = await process.stdout.readline()
193
+ if not line_bytes:
194
+ break
195
+ line = line_bytes.decode("utf-8", errors="replace")
196
+ stdout_lines.append(line)
197
+ if line.strip() and not saw_stdout_event:
198
+ saw_stdout_event = True
199
+ _record_latency(latency_callback, "codex_first_stdout_event", io_started_at)
200
+ if not saw_assistant_event:
201
+ try:
202
+ event = json.loads(line)
203
+ except json.JSONDecodeError:
204
+ continue
205
+ if _assistant_text_from_event(event) is not None:
206
+ saw_assistant_event = True
207
+ _record_latency(latency_callback, "codex_first_assistant_event", io_started_at)
208
+ _record_latency(latency_callback, "codex_stdout_read", stdout_started_at)
209
+
210
+ wait_started_at = time.perf_counter()
211
+ await process.wait()
212
+ _record_latency(latency_callback, "codex_process_wait", wait_started_at)
213
+
214
+ stderr = await stderr_task
215
+ _record_latency(latency_callback, "codex_communicate", io_started_at)
216
+ return "".join(stdout_lines), stderr.decode("utf-8", errors="replace")
217
+ finally:
218
+ if not stderr_task.done():
219
+ stderr_task.cancel()
220
+ with contextlib.suppress(asyncio.CancelledError):
221
+ await stderr_task
222
+
223
+
224
+ async def run_codex_exec(
225
+ *,
226
+ codex_bin: str,
227
+ cwd: Path,
228
+ prompt: str,
229
+ timeout_seconds: float,
230
+ proxy: str | None = None,
231
+ model: str | None = None,
232
+ codex_configs: list[str] | None = None,
233
+ ephemeral: bool = False,
234
+ latency_callback: Callable[[str, float], None] | None = None,
235
+ ) -> str:
236
+ command_started_at = time.perf_counter()
237
+ command = build_codex_command(
238
+ codex_bin=codex_bin,
239
+ cwd=cwd,
240
+ model=model,
241
+ codex_configs=codex_configs or [],
242
+ ephemeral=ephemeral,
243
+ )
244
+ if latency_callback:
245
+ latency_callback("codex_command_build", (time.perf_counter() - command_started_at) * 1000)
246
+
247
+ spawn_started_at = time.perf_counter()
248
+ process = await asyncio.create_subprocess_exec(
249
+ *command,
250
+ stdin=asyncio.subprocess.PIPE,
251
+ stdout=asyncio.subprocess.PIPE,
252
+ stderr=asyncio.subprocess.PIPE,
253
+ env=build_proxy_env(proxy),
254
+ )
255
+ if latency_callback:
256
+ latency_callback("codex_process_spawn", (time.perf_counter() - spawn_started_at) * 1000)
257
+
258
+ try:
259
+ stdout_text, stderr_text = await asyncio.wait_for(
260
+ _read_process_output(
261
+ process=process,
262
+ prompt=prompt,
263
+ latency_callback=latency_callback,
264
+ ),
265
+ timeout=timeout_seconds,
266
+ )
267
+ except TimeoutError as exc:
268
+ process.kill()
269
+ await process.wait()
270
+ raise TimeoutError(f"Codex execution exceeded {timeout_seconds} seconds") from exc
271
+
272
+ if process.returncode != 0:
273
+ raise CodexRunError(f"Codex exited with {process.returncode}: {summarize_process_error(stderr_text, stdout_text)}")
274
+ parse_started_at = time.perf_counter()
275
+ final_message = parse_final_message(stdout_text.splitlines())
276
+ if latency_callback:
277
+ latency_callback("codex_output_parse", (time.perf_counter() - parse_started_at) * 1000)
278
+ return final_message
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ class SettingsError(ValueError):
9
+ """Raised when proxy settings or request-scoped paths are invalid."""
10
+
11
+
12
+ def _split_paths(raw: str | None) -> list[Path]:
13
+ if not raw:
14
+ return []
15
+ return [Path(part).expanduser() for part in raw.split(":") if part]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Settings:
20
+ host: str = "127.0.0.1"
21
+ port: int = 8765
22
+ api_key: str | None = None
23
+ codex_bin: str = "codex"
24
+ proxy: str | None = None
25
+ model: str | None = None
26
+ codex_configs: list[str] | None = None
27
+ ephemeral: bool = True
28
+ engine: str = "exec"
29
+ workers: int = 1
30
+ max_queue_size: int = 64
31
+ queue_timeout_seconds: float = 30.0
32
+ app_server_codex_home: Path | None = None
33
+ default_cwd: Path = Path.cwd()
34
+ allowed_roots: list[Path] | None = None
35
+ request_timeout_seconds: float = 300.0
36
+ max_concurrency: int = 1
37
+ log_level: str = "info"
38
+
39
+ @classmethod
40
+ def from_env(cls) -> "Settings":
41
+ default_cwd = Path(os.environ.get("CODEX_PROXY_DEFAULT_CWD", os.getcwd())).expanduser()
42
+ allowed_roots = _split_paths(os.environ.get("CODEX_PROXY_ALLOWED_ROOTS"))
43
+ if not allowed_roots:
44
+ allowed_roots = [default_cwd]
45
+ return cls(
46
+ host=os.environ.get("CODEX_PROXY_HOST", "127.0.0.1"),
47
+ port=int(os.environ.get("CODEX_PROXY_PORT", "8765")),
48
+ api_key=os.environ.get("CODEX_PROXY_API_KEY") or None,
49
+ codex_bin=os.environ.get("CODEX_PROXY_CODEX_BIN", "codex"),
50
+ proxy=os.environ.get("CODEX_PROXY_PROXY") or None,
51
+ model=os.environ.get("CODEX_PROXY_MODEL") or None,
52
+ codex_configs=[
53
+ item
54
+ for item in os.environ.get("CODEX_PROXY_CODEX_CONFIGS", "").split(";;")
55
+ if item
56
+ ],
57
+ ephemeral=os.environ.get("CODEX_PROXY_EPHEMERAL", "true").lower() in {"1", "true", "yes"},
58
+ engine=os.environ.get("CODEX_PROXY_ENGINE", "exec"),
59
+ workers=int(os.environ.get("CODEX_PROXY_WORKERS", "1")),
60
+ max_queue_size=int(os.environ.get("CODEX_PROXY_MAX_QUEUE_SIZE", "64")),
61
+ queue_timeout_seconds=float(os.environ.get("CODEX_PROXY_QUEUE_TIMEOUT_SECONDS", "30")),
62
+ app_server_codex_home=(
63
+ Path(raw_home).expanduser()
64
+ if (raw_home := os.environ.get("CODEX_PROXY_APP_SERVER_CODEX_HOME"))
65
+ else None
66
+ ),
67
+ default_cwd=default_cwd,
68
+ allowed_roots=allowed_roots,
69
+ request_timeout_seconds=float(os.environ.get("CODEX_PROXY_TIMEOUT_SECONDS", "300")),
70
+ max_concurrency=int(os.environ.get("CODEX_PROXY_MAX_CONCURRENCY", "1")),
71
+ log_level=os.environ.get("CODEX_PROXY_LOG_LEVEL", "info"),
72
+ )
73
+
74
+ def resolve_cwd(self, requested_cwd: str | None) -> Path:
75
+ candidate = Path(requested_cwd).expanduser() if requested_cwd else self.default_cwd
76
+ resolved = candidate.resolve()
77
+ roots = [root.expanduser().resolve() for root in (self.allowed_roots or [self.default_cwd])]
78
+ if not any(resolved == root or root in resolved.parents for root in roots):
79
+ allowed = ", ".join(str(root) for root in roots)
80
+ raise SettingsError(f"cwd {resolved} is outside allowed roots: {allowed}")
81
+ if not resolved.exists() or not resolved.is_dir():
82
+ raise SettingsError(f"cwd {resolved} is not an existing directory")
83
+ return resolved