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.
- openai_api_server_via_codex/__init__.py +5 -0
- openai_api_server_via_codex/__main__.py +5 -0
- openai_api_server_via_codex/auth.py +206 -0
- openai_api_server_via_codex/backend.py +370 -0
- openai_api_server_via_codex/compat.py +785 -0
- openai_api_server_via_codex/config.py +95 -0
- openai_api_server_via_codex/daemon.py +201 -0
- openai_api_server_via_codex/redaction.py +93 -0
- openai_api_server_via_codex/server.py +2148 -0
- openai_api_server_via_codex-0.0.1.dist-info/METADATA +555 -0
- openai_api_server_via_codex-0.0.1.dist-info/RECORD +15 -0
- openai_api_server_via_codex-0.0.1.dist-info/WHEEL +5 -0
- openai_api_server_via_codex-0.0.1.dist-info/entry_points.txt +2 -0
- openai_api_server_via_codex-0.0.1.dist-info/licenses/LICENSE +201 -0
- openai_api_server_via_codex-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|