openai-api-server-via-codex 0.0.1__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,5 @@
1
+ """OpenAI-compatible API server backed by the Codex ChatGPT endpoint."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.0.1"
@@ -0,0 +1,5 @@
1
+ from .server import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,206 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ import threading
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .redaction import redact_sensitive_text
15
+
16
+
17
+ REFRESH_URL = "https://auth.openai.com/oauth/token"
18
+ CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
19
+ REFRESH_SKEW_SECONDS = 30
20
+ AUTH_JSON_ENV = "OPENAI_VIA_CODEX_AUTH_JSON"
21
+
22
+
23
+ class BorrowKeyError(Exception):
24
+ """Raised when local Codex ChatGPT OAuth credentials cannot be used."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CodexAuthConfig:
29
+ auth_json: Path | None = None
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class _CachedBorrowedKey:
34
+ mtime_ns: int
35
+ size: int
36
+ access_token: str
37
+ account_id: str | None
38
+ exp: float | None
39
+
40
+
41
+ _AUTH_CACHE: dict[Path, _CachedBorrowedKey] = {}
42
+ _AUTH_CACHE_LOCK = threading.Lock()
43
+
44
+
45
+ def borrow_codex_key(auth_json: str | Path | None = None) -> tuple[str, str | None]:
46
+ """Return an access token and optional ChatGPT account id from Codex auth."""
47
+
48
+ auth_path = resolve_auth_path(auth_json)
49
+
50
+ with _AUTH_CACHE_LOCK:
51
+ stat = auth_path.stat()
52
+ cached = _AUTH_CACHE.get(auth_path)
53
+ if cached and _cache_matches(cached, stat) and _token_is_fresh(cached.exp):
54
+ return cached.access_token, cached.account_id
55
+
56
+ data = _read_auth(auth_path)
57
+
58
+ tokens = data.get("tokens")
59
+ if not isinstance(tokens, dict) or not tokens.get("access_token"):
60
+ raise BorrowKeyError("No ChatGPT tokens found. Run `codex login` first.")
61
+
62
+ access_token = str(tokens["access_token"])
63
+ exp = _jwt_exp(access_token)
64
+ if not _token_is_fresh(exp):
65
+ refresh_token = tokens.get("refresh_token")
66
+ if not refresh_token:
67
+ raise BorrowKeyError("No refresh token available. Run `codex login` again.")
68
+
69
+ new_tokens = _refresh(str(refresh_token))
70
+ for key in ("access_token", "id_token", "refresh_token"):
71
+ if new_tokens.get(key):
72
+ tokens[key] = new_tokens[key]
73
+
74
+ data["tokens"] = tokens
75
+ data["last_refresh"] = time.strftime(
76
+ "%Y-%m-%dT%H:%M:%S+00:00", time.gmtime()
77
+ )
78
+ _write_auth(auth_path, data)
79
+ stat = auth_path.stat()
80
+
81
+ access_token = str(tokens["access_token"])
82
+ account_id = _account_id(tokens)
83
+ exp = _jwt_exp(access_token)
84
+ _AUTH_CACHE[auth_path] = _CachedBorrowedKey(
85
+ mtime_ns=stat.st_mtime_ns,
86
+ size=stat.st_size,
87
+ access_token=access_token,
88
+ account_id=account_id,
89
+ exp=exp,
90
+ )
91
+ return access_token, account_id
92
+
93
+
94
+ def clear_codex_auth_cache() -> None:
95
+ with _AUTH_CACHE_LOCK:
96
+ _AUTH_CACHE.clear()
97
+
98
+
99
+ def resolve_auth_path(auth_json: str | Path | None = None) -> Path:
100
+ if auth_json is not None:
101
+ path = Path(auth_json)
102
+ elif env_auth_json := os.environ.get(AUTH_JSON_ENV):
103
+ path = Path(env_auth_json)
104
+ else:
105
+ codex_home = Path(os.environ.get("CODEX_HOME", "~/.codex"))
106
+ path = codex_home / "auth.json"
107
+
108
+ path = path.expanduser().resolve()
109
+ if not path.exists():
110
+ raise BorrowKeyError(f"Codex auth file not found at {path}.")
111
+ return path
112
+
113
+
114
+ def _cache_matches(cached: _CachedBorrowedKey, stat: os.stat_result) -> bool:
115
+ return cached.mtime_ns == stat.st_mtime_ns and cached.size == stat.st_size
116
+
117
+
118
+ def _token_is_fresh(exp: float | None) -> bool:
119
+ return exp is None or time.time() < (exp - REFRESH_SKEW_SECONDS)
120
+
121
+
122
+ def _account_id(tokens: dict[str, Any]) -> str | None:
123
+ account_id = tokens.get("account_id")
124
+ if account_id:
125
+ return str(account_id)
126
+ id_token = tokens.get("id_token")
127
+ if isinstance(id_token, str):
128
+ payload = _jwt_payload(id_token)
129
+ account_id = payload.get("chatgpt_account_id")
130
+ if account_id:
131
+ return str(account_id)
132
+
133
+ access_token = tokens.get("access_token")
134
+ if not isinstance(access_token, str):
135
+ return None
136
+ payload = _jwt_payload(access_token)
137
+ openai_auth = payload.get("https://api.openai.com/auth")
138
+ if isinstance(openai_auth, dict):
139
+ account_id = openai_auth.get("chatgpt_account_id")
140
+ if account_id:
141
+ return str(account_id)
142
+ account_id = payload.get("chatgpt_account_id")
143
+ return str(account_id) if account_id else None
144
+
145
+
146
+ def _read_auth(path: Path) -> dict[str, Any]:
147
+ data = json.loads(path.read_text(encoding="utf-8"))
148
+ if data.get("auth_mode") != "chatgpt":
149
+ raise BorrowKeyError(
150
+ f"Expected Codex auth_mode 'chatgpt', got {data.get('auth_mode')!r}."
151
+ )
152
+ return data
153
+
154
+
155
+ def _write_auth(path: Path, data: dict[str, Any]) -> None:
156
+ tmp = path.with_suffix(path.suffix + ".tmp")
157
+ tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
158
+ tmp.replace(path)
159
+ path.chmod(0o600)
160
+
161
+
162
+ def _jwt_exp(token: str) -> float | None:
163
+ payload = _jwt_payload(token)
164
+ exp = payload.get("exp")
165
+ if isinstance(exp, int | float):
166
+ return float(exp)
167
+ return None
168
+
169
+
170
+ def _jwt_payload(token: str) -> dict[str, Any]:
171
+ try:
172
+ payload_b64 = token.split(".")[1]
173
+ payload_b64 += "=" * (-len(payload_b64) % 4)
174
+ payload = json.loads(base64.urlsafe_b64decode(payload_b64))
175
+ if isinstance(payload, dict):
176
+ return payload
177
+ except Exception:
178
+ pass
179
+ return {}
180
+
181
+
182
+ def _refresh(refresh_token: str) -> dict[str, Any]:
183
+ body = json.dumps(
184
+ {
185
+ "client_id": CLIENT_ID,
186
+ "grant_type": "refresh_token",
187
+ "refresh_token": refresh_token,
188
+ }
189
+ ).encode()
190
+ req = urllib.request.Request(
191
+ REFRESH_URL,
192
+ data=body,
193
+ headers={"Content-Type": "application/json"},
194
+ )
195
+
196
+ try:
197
+ with urllib.request.urlopen(req) as resp:
198
+ return json.loads(resp.read())
199
+ except urllib.error.HTTPError as exc:
200
+ error_body = exc.read().decode(errors="replace")
201
+ raise BorrowKeyError(
202
+ f"Token refresh failed (HTTP {exc.code}): "
203
+ f"{redact_sensitive_text(error_body)}"
204
+ )
205
+ except urllib.error.URLError as exc:
206
+ raise BorrowKeyError(f"Token refresh failed: {exc}") from exc
@@ -0,0 +1,370 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import copy
5
+ import logging
6
+ import platform
7
+ import time
8
+ from collections.abc import AsyncIterator
9
+ from typing import Any, Protocol
10
+
11
+ import httpx
12
+ from openai import APIError, APIStatusError, AsyncOpenAI
13
+
14
+ from .auth import BorrowKeyError, CodexAuthConfig, borrow_codex_key
15
+ from .redaction import install_redacting_filter, redact_sensitive_text
16
+
17
+
18
+ LOGGER = logging.getLogger("openai_api_server_via_codex.backend")
19
+ install_redacting_filter(LOGGER)
20
+ CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
21
+ CODEX_BACKEND_HTTP = "codex-http"
22
+ DEFAULT_MODELS = [
23
+ "gpt-5.1",
24
+ "gpt-5.1-codex-max",
25
+ "gpt-5.1-codex-mini",
26
+ "gpt-5.2",
27
+ "gpt-5.2-codex",
28
+ "gpt-5.3-codex",
29
+ "gpt-5.3-codex-spark",
30
+ "gpt-5.4",
31
+ "gpt-5.4-mini",
32
+ "gpt-5.5",
33
+ ]
34
+ CODEX_REASONING_INCLUDE = "reasoning.encrypted_content"
35
+ CODEX_RESPONSE_STATUSES = {
36
+ "completed",
37
+ "incomplete",
38
+ "failed",
39
+ "cancelled",
40
+ "queued",
41
+ "in_progress",
42
+ }
43
+
44
+
45
+ class CodexBackend(Protocol):
46
+ async def create_response(self, payload: dict[str, Any]) -> dict[str, Any]:
47
+ """Create a Responses API response through Codex."""
48
+
49
+ def stream_response(self, payload: dict[str, Any]) -> AsyncIterator[dict[str, Any]]:
50
+ """Stream Responses API events through Codex."""
51
+
52
+ async def list_models(self) -> list[str]:
53
+ """Return model ids exposed by Codex."""
54
+
55
+
56
+ class CodexBackendError(Exception):
57
+ def __init__(self, message: str, *, status_code: int = 502) -> None:
58
+ super().__init__(message)
59
+ self.status_code = status_code
60
+
61
+
62
+ class CodexHttpBackend:
63
+ def __init__(
64
+ self,
65
+ *,
66
+ base_url: str = CODEX_BASE_URL,
67
+ client_version: str = "1.0.0",
68
+ timeout: float = 300.0,
69
+ auth_config: CodexAuthConfig | None = None,
70
+ ) -> None:
71
+ self.base_url = base_url.rstrip("/")
72
+ self.client_version = client_version
73
+ self.timeout = timeout
74
+ self.auth_config = auth_config or CodexAuthConfig()
75
+
76
+ async def create_response(self, payload: dict[str, Any]) -> dict[str, Any]:
77
+ stream = self.stream_response(payload)
78
+ return await _collect_streamed_response(stream, payload)
79
+
80
+ async def stream_response(
81
+ self, payload: dict[str, Any]
82
+ ) -> AsyncIterator[dict[str, Any]]:
83
+ LOGGER.info(
84
+ "codex-http.stream.start model=%s input_items=%d tools=%d base_url=%s timeout=%s",
85
+ payload.get("model"),
86
+ _list_len(payload.get("input")),
87
+ _list_len(payload.get("tools")),
88
+ self.base_url,
89
+ self.timeout,
90
+ )
91
+ token, account_id = await self._borrow_key()
92
+ codex_payload = _prepare_codex_payload(payload)
93
+ request_id = codex_payload.get("prompt_cache_key")
94
+ headers = self._headers(
95
+ account_id,
96
+ client_version=self.client_version,
97
+ request_id=request_id if isinstance(request_id, str) else None,
98
+ event_stream=True,
99
+ )
100
+ client = AsyncOpenAI(
101
+ api_key=token,
102
+ base_url=self.base_url,
103
+ default_headers=headers,
104
+ timeout=self.timeout,
105
+ )
106
+ event_count = 0
107
+ try:
108
+ stream = await client.responses.create(**codex_payload)
109
+ async for event in stream:
110
+ dumped = _normalize_codex_stream_event(_dump_openai_model(event))
111
+ event_count += 1
112
+ LOGGER.debug(
113
+ "codex-http.stream.event type=%s sequence_number=%s",
114
+ dumped.get("type"),
115
+ dumped.get("sequence_number"),
116
+ )
117
+ yield dumped
118
+ except APIStatusError as exc:
119
+ message = _status_error_message(exc)
120
+ LOGGER.warning(
121
+ "codex-http.stream.status_error status=%s message=%s",
122
+ exc.status_code,
123
+ message,
124
+ )
125
+ raise CodexBackendError(
126
+ message, status_code=exc.status_code
127
+ ) from exc
128
+ except APIError as exc:
129
+ message = redact_sensitive_text(str(exc))
130
+ LOGGER.warning("codex-http.stream.api_error message=%s", message)
131
+ raise CodexBackendError(message) from exc
132
+ finally:
133
+ LOGGER.info(
134
+ "codex-http.stream.end model=%s events=%d",
135
+ payload.get("model"),
136
+ event_count,
137
+ )
138
+ await client.close()
139
+
140
+ async def list_models(self) -> list[str]:
141
+ try:
142
+ token, account_id = await self._borrow_key()
143
+ except CodexBackendError:
144
+ LOGGER.info("codex-http.models.fallback reason=auth_unavailable")
145
+ return DEFAULT_MODELS
146
+
147
+ headers = self._headers(account_id, client_version=self.client_version)
148
+ headers["Authorization"] = f"Bearer {token}"
149
+ try:
150
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
151
+ response = await client.get(
152
+ f"{self.base_url}/models",
153
+ headers=headers,
154
+ params={"client_version": self.client_version},
155
+ )
156
+ response.raise_for_status()
157
+ data = response.json()
158
+ except Exception as exc:
159
+ LOGGER.info("codex-http.models.fallback reason=%s", exc)
160
+ return DEFAULT_MODELS
161
+
162
+ models = [
163
+ str(model["slug"])
164
+ for model in data.get("models", [])
165
+ if isinstance(model, dict)
166
+ and model.get("slug")
167
+ and model.get("supported_in_api")
168
+ and model.get("visibility") == "list"
169
+ ]
170
+ if not models:
171
+ LOGGER.info("codex-http.models.fallback reason=empty_model_list")
172
+ return DEFAULT_MODELS
173
+ LOGGER.info("codex-http.models.loaded count=%d", len(models))
174
+ return models
175
+
176
+ async def _borrow_key(self) -> tuple[str, str | None]:
177
+ try:
178
+ token, account_id = await asyncio.to_thread(
179
+ borrow_codex_key, self.auth_config.auth_json
180
+ )
181
+ LOGGER.debug(
182
+ "codex-http.auth.borrowed auth_json=%s account_id_present=%s",
183
+ self.auth_config.auth_json,
184
+ bool(account_id),
185
+ )
186
+ return token, account_id
187
+ except BorrowKeyError as exc:
188
+ message = redact_sensitive_text(str(exc))
189
+ LOGGER.warning("codex-http.auth.error message=%s", message)
190
+ raise CodexBackendError(message, status_code=401) from exc
191
+
192
+ @staticmethod
193
+ def _headers(
194
+ account_id: str | None,
195
+ *,
196
+ client_version: str,
197
+ request_id: str | None = None,
198
+ event_stream: bool = False,
199
+ ) -> dict[str, str]:
200
+ headers = {
201
+ "originator": "openai-api-server-via-codex",
202
+ "User-Agent": _user_agent(client_version),
203
+ }
204
+ if event_stream:
205
+ headers["OpenAI-Beta"] = "responses=experimental"
206
+ headers["Accept"] = "text/event-stream"
207
+ headers["Content-Type"] = "application/json"
208
+ if account_id:
209
+ headers["ChatGPT-Account-ID"] = account_id
210
+ if request_id:
211
+ headers["session_id"] = request_id
212
+ headers["x-client-request-id"] = request_id
213
+ return headers
214
+
215
+
216
+ def _dump_openai_model(value: Any) -> dict[str, Any]:
217
+ if hasattr(value, "model_dump"):
218
+ dumped = value.model_dump(mode="json")
219
+ if isinstance(dumped, dict):
220
+ return dumped
221
+ if isinstance(value, dict):
222
+ return value
223
+ raise CodexBackendError(f"Unsupported Codex response type: {type(value)!r}")
224
+
225
+
226
+ def _event_value(event: Any, key: str) -> Any:
227
+ if isinstance(event, dict):
228
+ return event.get(key)
229
+ return getattr(event, key, None)
230
+
231
+
232
+ def _list_len(value: Any) -> int:
233
+ return len(value) if isinstance(value, list) else 0
234
+
235
+
236
+ def _prepare_codex_payload(payload: dict[str, Any]) -> dict[str, Any]:
237
+ codex_payload = copy.deepcopy(payload)
238
+ codex_payload.pop("max_output_tokens", None)
239
+ codex_payload["stream"] = True
240
+ codex_payload["store"] = False
241
+ codex_payload.setdefault("tool_choice", "auto")
242
+ codex_payload.setdefault("parallel_tool_calls", True)
243
+
244
+ text = codex_payload.get("text")
245
+ text_config = dict(text) if isinstance(text, dict) else {}
246
+ text_config.setdefault("verbosity", "low")
247
+ codex_payload["text"] = text_config
248
+
249
+ include = codex_payload.get("include")
250
+ include_values = list(include) if isinstance(include, list) else []
251
+ if CODEX_REASONING_INCLUDE not in include_values:
252
+ include_values.append(CODEX_REASONING_INCLUDE)
253
+ codex_payload["include"] = include_values
254
+ return codex_payload
255
+
256
+
257
+ def _normalize_codex_stream_event(event: dict[str, Any]) -> dict[str, Any]:
258
+ normalized = copy.deepcopy(event)
259
+ if normalized.get("type") == "response.done":
260
+ normalized["type"] = "response.completed"
261
+
262
+ response = normalized.get("response")
263
+ if isinstance(response, dict):
264
+ status = response.get("status")
265
+ if isinstance(status, str) and status not in CODEX_RESPONSE_STATUSES:
266
+ response.pop("status", None)
267
+ return normalized
268
+
269
+
270
+ async def _collect_streamed_response(
271
+ stream: Any, payload: dict[str, Any]
272
+ ) -> dict[str, Any]:
273
+ text_parts: list[str] = []
274
+ output_items: list[dict[str, Any]] = []
275
+ completed_response: dict[str, Any] | None = None
276
+ last_response_id: str | None = None
277
+
278
+ async for event in stream:
279
+ normalized = (
280
+ _normalize_codex_stream_event(event)
281
+ if isinstance(event, dict)
282
+ else _normalize_codex_stream_event(_dump_openai_model(event))
283
+ )
284
+ event_type = _event_value(normalized, "type")
285
+ if event_type == "response.created":
286
+ response = _event_value(normalized, "response")
287
+ if response is not None:
288
+ dumped = _dump_openai_model(response)
289
+ last_response_id = str(dumped.get("id") or last_response_id or "")
290
+ elif event_type == "response.output_text.delta":
291
+ delta = _event_value(normalized, "delta")
292
+ if isinstance(delta, str):
293
+ text_parts.append(delta)
294
+ elif event_type == "response.output_item.done":
295
+ item = _event_value(normalized, "item")
296
+ if item is not None:
297
+ output_items.append(_dump_openai_model(item))
298
+ elif event_type in {"response.completed", "response.incomplete"}:
299
+ response = _event_value(normalized, "response")
300
+ if response is not None:
301
+ completed_response = _dump_openai_model(response)
302
+
303
+ if completed_response is not None:
304
+ if output_items and not completed_response.get("output"):
305
+ completed_response["output"] = output_items
306
+ return completed_response
307
+
308
+ created_at = time.time()
309
+ text = "".join(text_parts)
310
+ return {
311
+ "id": last_response_id or f"resp_{int(created_at * 1000)}",
312
+ "object": "response",
313
+ "created_at": created_at,
314
+ "status": "completed",
315
+ "model": payload.get("model"),
316
+ "output": [
317
+ {
318
+ "id": f"msg_{int(created_at * 1000)}",
319
+ "type": "message",
320
+ "role": "assistant",
321
+ "status": "completed",
322
+ "phase": "final_answer",
323
+ "content": [
324
+ {
325
+ "type": "output_text",
326
+ "text": text,
327
+ "annotations": [],
328
+ }
329
+ ],
330
+ }
331
+ ],
332
+ "parallel_tool_calls": True,
333
+ "tool_choice": payload.get("tool_choice") or "auto",
334
+ "tools": payload.get("tools") or [],
335
+ }
336
+
337
+
338
+ def _status_error_message(exc: APIStatusError) -> str:
339
+ try:
340
+ body = exc.response.json()
341
+ except Exception:
342
+ return redact_sensitive_text(exc.message)
343
+ if isinstance(body, dict):
344
+ error = body.get("error")
345
+ if isinstance(error, dict):
346
+ code = str(error.get("code") or error.get("type") or "")
347
+ if exc.status_code == 429 or (
348
+ "usage_limit" in code or "rate_limit" in code
349
+ ):
350
+ plan = str(error.get("plan_type") or "").lower()
351
+ plan_text = f" ({plan} plan)" if plan else ""
352
+ reset_text = _reset_time_text(error.get("resets_at"))
353
+ return f"You have hit your ChatGPT usage limit{plan_text}.{reset_text}"
354
+ if error.get("message"):
355
+ return redact_sensitive_text(str(error["message"]))
356
+ return redact_sensitive_text(exc.message)
357
+
358
+
359
+ def _reset_time_text(value: Any) -> str:
360
+ if not isinstance(value, int | float):
361
+ return ""
362
+ minutes = max(0, round((float(value) * 1000 - time.time() * 1000) / 60000))
363
+ return f" Try again in ~{minutes} min."
364
+
365
+
366
+ def _user_agent(client_version: str) -> str:
367
+ return (
368
+ f"openai-api-server-via-codex/{client_version} "
369
+ f"({platform.system().lower()} {platform.release()}; {platform.machine()})"
370
+ )