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.
- codex_api_proxy/__init__.py +3 -0
- codex_api_proxy/app_server_runner.py +554 -0
- codex_api_proxy/cli.py +570 -0
- codex_api_proxy/codex_runner.py +278 -0
- codex_api_proxy/config.py +83 -0
- codex_api_proxy/main.py +561 -0
- codex_api_proxy/prompt.py +31 -0
- codex_api_proxy/schemas.py +48 -0
- codex_api_proxy-0.1.0.dist-info/METADATA +347 -0
- codex_api_proxy-0.1.0.dist-info/RECORD +13 -0
- codex_api_proxy-0.1.0.dist-info/WHEEL +5 -0
- codex_api_proxy-0.1.0.dist-info/entry_points.txt +2 -0
- codex_api_proxy-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|