llm-async-codex 0.1.0__tar.gz

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,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: llm-async-codex
3
+ Version: 0.1.0
4
+ Summary: ChatGPT Codex subscription provider for llm-async
5
+ Author: Johanderson Mogollon
6
+ Author-email: johander1822@gmail.com
7
+ Requires-Python: >=3.10,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Dist: aiosonic (>=1.0.6)
15
+ Requires-Dist: llm-async (>=0.5.2)
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "llm-async-codex"
3
+ version = "0.1.0"
4
+ description = "ChatGPT Codex subscription provider for llm-async"
5
+ authors = [
6
+ {name = "Johanderson Mogollon",email = "johander1822@gmail.com"}
7
+ ]
8
+ requires-python = ">=3.10,<4.0"
9
+ dependencies = [
10
+ "llm-async (>=0.5.2)",
11
+ "aiosonic (>=1.0.6)"
12
+ ]
13
+
14
+ [dependency-groups]
15
+ dev = [
16
+ "pytest (>=7.0)",
17
+ "ruff (>=0.8)"
18
+ ]
19
+
20
+
21
+ [tool.poetry]
22
+ packages = [{ include = "llm_async_codex", from = "src" }]
23
+
24
+ [tool.poetry.scripts]
25
+ llm-async-codex = "llm_async_codex.cli:main"
26
+
27
+ [tool.ruff]
28
+ target-version = "py310"
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
32
+
33
+ [build-system]
34
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
35
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,34 @@
1
+ """ChatGPT Codex subscription support for llm_async."""
2
+
3
+ from .auth import (
4
+ CodexAuthError,
5
+ CodexCredentials,
6
+ default_auth_path,
7
+ load_credentials,
8
+ save_credentials,
9
+ )
10
+ from .oauth import (
11
+ CodexLoginError,
12
+ login,
13
+ login_with_browser,
14
+ login_with_device_code,
15
+ refresh_access_token,
16
+ refresh_credentials,
17
+ )
18
+ from .provider import CODEX_BASE_URL, CodexProvider
19
+
20
+ __all__ = [
21
+ "CODEX_BASE_URL",
22
+ "CodexAuthError",
23
+ "CodexCredentials",
24
+ "CodexLoginError",
25
+ "CodexProvider",
26
+ "default_auth_path",
27
+ "load_credentials",
28
+ "login",
29
+ "login_with_browser",
30
+ "login_with_device_code",
31
+ "refresh_access_token",
32
+ "refresh_credentials",
33
+ "save_credentials",
34
+ ]
@@ -0,0 +1,76 @@
1
+ """Load credentials from an existing Codex CLI login."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ class CodexAuthError(ValueError):
14
+ """Raised when Codex credentials cannot be loaded."""
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class CodexCredentials:
19
+ """ChatGPT OAuth credentials used by the Codex Responses endpoint."""
20
+
21
+ access_token: str
22
+ refresh_token: str | None = None
23
+ account_id: str | None = None
24
+ expires_at: float | None = None
25
+
26
+
27
+ def default_auth_path() -> Path:
28
+ """Return the auth file used by the Codex CLI."""
29
+ return Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser() / "auth.json"
30
+
31
+
32
+ def load_credentials(path: Path | None = None) -> CodexCredentials:
33
+ """Load credentials from a Codex CLI auth file."""
34
+ auth_path = path or default_auth_path()
35
+ try:
36
+ value: Any = json.loads(auth_path.read_text(encoding="utf-8"))
37
+ except OSError as error:
38
+ raise CodexAuthError(f"cannot read Codex auth file: {auth_path}") from error
39
+ except json.JSONDecodeError as error:
40
+ raise CodexAuthError(f"invalid JSON in Codex auth file: {auth_path}") from error
41
+
42
+ if not isinstance(value, Mapping) or not isinstance(value.get("tokens"), Mapping):
43
+ raise CodexAuthError("Codex auth file has no tokens object")
44
+
45
+ tokens = value["tokens"]
46
+ access_token = tokens.get("access_token")
47
+ if not isinstance(access_token, str) or not access_token:
48
+ raise CodexAuthError("Codex auth file has no access token")
49
+
50
+ refresh_token = _string_or_none(tokens.get("refresh_token"))
51
+ account_id = _string_or_none(tokens.get("account_id")) or _string_or_none(
52
+ value.get("account_id")
53
+ )
54
+ expires_at = tokens.get("expires_at")
55
+ if not isinstance(expires_at, (int, float)):
56
+ expires_at = None
57
+ return CodexCredentials(access_token, refresh_token, account_id, expires_at)
58
+
59
+
60
+ def _string_or_none(value: object) -> str | None:
61
+ return value if isinstance(value, str) and value else None
62
+
63
+
64
+ def save_credentials(credentials: CodexCredentials, path: Path | None = None) -> Path:
65
+ """Write credentials to a Codex-CLI-compatible auth file. Returns the path written."""
66
+ auth_path = path or default_auth_path()
67
+ auth_path.parent.mkdir(parents=True, exist_ok=True)
68
+ tokens: dict[str, Any] = {"access_token": credentials.access_token}
69
+ if credentials.refresh_token:
70
+ tokens["refresh_token"] = credentials.refresh_token
71
+ if credentials.account_id:
72
+ tokens["account_id"] = credentials.account_id
73
+ if credentials.expires_at is not None:
74
+ tokens["expires_at"] = credentials.expires_at
75
+ auth_path.write_text(json.dumps({"tokens": tokens}, indent=2), encoding="utf-8")
76
+ return auth_path
@@ -0,0 +1,60 @@
1
+ """Command-line entry point for llm-async-codex."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ from pathlib import Path
8
+
9
+ from .oauth import CodexLoginError, login
10
+
11
+
12
+ def _build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(prog="llm-async-codex")
14
+ subparsers = parser.add_subparsers(dest="command", required=True)
15
+
16
+ login_parser = subparsers.add_parser("login", help="Log in to ChatGPT Codex")
17
+ login_parser.add_argument(
18
+ "--device-code",
19
+ action="store_true",
20
+ help="Use the device-code flow instead of opening a browser",
21
+ )
22
+ login_parser.add_argument(
23
+ "--auth-file",
24
+ type=Path,
25
+ default=None,
26
+ help="Where to write credentials (default: $CODEX_HOME/auth.json or ~/.codex/auth.json)",
27
+ )
28
+ login_parser.add_argument(
29
+ "--verbose",
30
+ "-v",
31
+ action="store_true",
32
+ help="Show detailed progress (HTTP statuses, full URLs, poll attempts)",
33
+ )
34
+ return parser
35
+
36
+
37
+ def main(argv: list[str] | None = None) -> int:
38
+ args = _build_parser().parse_args(argv)
39
+
40
+ if args.command == "login":
41
+ try:
42
+ credentials = asyncio.run(
43
+ login(
44
+ device_code=args.device_code,
45
+ auth_path=args.auth_file,
46
+ verbose=args.verbose,
47
+ )
48
+ )
49
+ except CodexLoginError as error:
50
+ print(f"Login failed: {error}")
51
+ return 1
52
+ masked = f"{credentials.access_token[:8]}..."
53
+ print(f"Logged in (access token {masked}).")
54
+ return 0
55
+
56
+ return 1
57
+
58
+
59
+ if __name__ == "__main__":
60
+ raise SystemExit(main())
@@ -0,0 +1,334 @@
1
+ """ChatGPT Codex login: browser OAuth (PKCE) and device-code flows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import hashlib
8
+ import json
9
+ import logging
10
+ import secrets
11
+ import time
12
+ import webbrowser
13
+ from collections.abc import Mapping
14
+ from http.server import BaseHTTPRequestHandler, HTTPServer
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib.parse import parse_qs, urlencode, urlparse
18
+
19
+ import aiosonic
20
+
21
+ from .auth import CodexCredentials, save_credentials
22
+
23
+ logger = logging.getLogger("llm_async_codex.oauth")
24
+
25
+ CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
26
+ ISSUER = "https://auth.openai.com"
27
+ OAUTH_PORT = 1455
28
+ REDIRECT_URI = f"http://localhost:{OAUTH_PORT}/auth/callback"
29
+ ORIGINATOR = "llm-async-codex"
30
+ CALLBACK_TIMEOUT_SECONDS = 300
31
+ DEVICE_POLL_SAFETY_MARGIN_SECONDS = 3
32
+ TOKEN_REFRESH_SAFETY_MARGIN_SECONDS = 60
33
+
34
+
35
+ class CodexLoginError(RuntimeError):
36
+ """Raised when the login flow fails."""
37
+
38
+
39
+ def _configure_logging(verbose: bool) -> None:
40
+ """Ensure login progress is visible by default, with more detail if verbose."""
41
+ if not logging.getLogger().handlers:
42
+ logging.basicConfig(format="%(message)s", level=logging.INFO)
43
+ logger.setLevel(logging.DEBUG if verbose else logging.INFO)
44
+
45
+
46
+ def _b64url(data: bytes) -> str:
47
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
48
+
49
+
50
+ def _generate_pkce() -> tuple[str, str]:
51
+ """Return (code_verifier, code_challenge) for an OAuth PKCE exchange."""
52
+ verifier = _b64url(secrets.token_bytes(32))
53
+ challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
54
+ return verifier, challenge
55
+
56
+
57
+ def _build_authorize_url(state: str, code_challenge: str) -> str:
58
+ params = {
59
+ "response_type": "code",
60
+ "client_id": CLIENT_ID,
61
+ "redirect_uri": REDIRECT_URI,
62
+ "scope": "openid profile email offline_access",
63
+ "code_challenge": code_challenge,
64
+ "code_challenge_method": "S256",
65
+ "id_token_add_organizations": "true",
66
+ "codex_cli_simplified_flow": "true",
67
+ "state": state,
68
+ "originator": ORIGINATOR,
69
+ }
70
+ return f"{ISSUER}/oauth/authorize?{urlencode(params)}"
71
+
72
+
73
+ def parse_jwt_claims(token: str) -> dict[str, Any] | None:
74
+ """Decode (without verifying) the claims payload of a JWT."""
75
+ parts = token.split(".")
76
+ if len(parts) != 3:
77
+ return None
78
+ padding = "=" * (-len(parts[1]) % 4)
79
+ try:
80
+ return json.loads(base64.urlsafe_b64decode(parts[1] + padding))
81
+ except (ValueError, json.JSONDecodeError):
82
+ return None
83
+
84
+
85
+ def _compute_expires_at(tokens: Mapping[str, Any]) -> float | None:
86
+ expires_in = tokens.get("expires_in")
87
+ if not isinstance(expires_in, (int, float)):
88
+ return None
89
+ return time.time() + expires_in
90
+
91
+
92
+ def extract_account_id(tokens: Mapping[str, Any]) -> str | None:
93
+ """Extract the ChatGPT account id from a token response's claims."""
94
+ for token_key in ("id_token", "access_token"):
95
+ token = tokens.get(token_key)
96
+ if not isinstance(token, str):
97
+ continue
98
+ claims = parse_jwt_claims(token)
99
+ if not claims:
100
+ continue
101
+ account_id = (
102
+ claims.get("chatgpt_account_id")
103
+ or claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id")
104
+ or (claims.get("organizations") or [{}])[0].get("id")
105
+ )
106
+ if account_id:
107
+ return account_id
108
+ return None
109
+
110
+
111
+ async def _exchange_code_for_tokens(
112
+ client: aiosonic.HTTPClient,
113
+ *,
114
+ code: str,
115
+ redirect_uri: str,
116
+ code_verifier: str,
117
+ ) -> dict[str, Any]:
118
+ response = await client.post(
119
+ f"{ISSUER}/oauth/token",
120
+ data={
121
+ "grant_type": "authorization_code",
122
+ "code": code,
123
+ "redirect_uri": redirect_uri,
124
+ "client_id": CLIENT_ID,
125
+ "code_verifier": code_verifier,
126
+ },
127
+ )
128
+ logger.debug("Token exchange response: %s", response.status_code)
129
+ if response.status_code != 200:
130
+ raise CodexLoginError(f"token exchange failed: {response.status_code}")
131
+ return json.loads(await response.text())
132
+
133
+
134
+ class _CallbackResult:
135
+ code: str | None = None
136
+ state: str | None = None
137
+ error: str | None = None
138
+
139
+
140
+ def _make_callback_handler(result: _CallbackResult) -> type[BaseHTTPRequestHandler]:
141
+ class Handler(BaseHTTPRequestHandler):
142
+ def do_GET(self) -> None:
143
+ parsed = urlparse(self.path)
144
+ if parsed.path != "/auth/callback":
145
+ self.send_response(404)
146
+ self.end_headers()
147
+ return
148
+
149
+ query = parse_qs(parsed.query)
150
+ result.code = query.get("code", [None])[0]
151
+ result.state = query.get("state", [None])[0]
152
+ result.error = query.get("error", [None])[0]
153
+
154
+ self.send_response(200)
155
+ self.send_header("Content-Type", "text/html; charset=utf-8")
156
+ self.end_headers()
157
+ message = (
158
+ "Login failed, you can close this window."
159
+ if result.error
160
+ else "Login successful, you can close this window."
161
+ )
162
+ self.wfile.write(f"<html><body>{message}</body></html>".encode())
163
+
164
+ def log_message(self, format: str, *args: Any) -> None:
165
+ return
166
+
167
+ return Handler
168
+
169
+
170
+ async def login_with_browser(
171
+ auth_path: Path | None = None, *, verbose: bool = False
172
+ ) -> CodexCredentials:
173
+ """Log in via the browser-based OAuth flow, opening a localhost listener."""
174
+ _configure_logging(verbose)
175
+ code_verifier, code_challenge = _generate_pkce()
176
+ state = secrets.token_urlsafe(24)
177
+
178
+ result = _CallbackResult()
179
+ server = HTTPServer(("localhost", OAUTH_PORT), _make_callback_handler(result))
180
+ server.timeout = CALLBACK_TIMEOUT_SECONDS
181
+
182
+ authorize_url = _build_authorize_url(state, code_challenge)
183
+ authorize_endpoint = urlparse(authorize_url)
184
+ logger.debug(
185
+ "Authorize endpoint: %s://%s%s",
186
+ authorize_endpoint.scheme,
187
+ authorize_endpoint.netloc,
188
+ authorize_endpoint.path,
189
+ )
190
+ logger.info("Listening on %s ...", REDIRECT_URI)
191
+ if webbrowser.open(authorize_url):
192
+ logger.info("Opened browser for ChatGPT login. Complete the login there.")
193
+ else:
194
+ logger.info(
195
+ "Could not open a browser automatically. Open this URL to log in:\n%s",
196
+ authorize_url,
197
+ )
198
+
199
+ loop = asyncio.get_running_loop()
200
+ try:
201
+ await loop.run_in_executor(None, server.handle_request)
202
+ finally:
203
+ server.server_close()
204
+
205
+ if result.code is None:
206
+ raise CodexLoginError(result.error or "OAuth callback timed out")
207
+ if result.state != state:
208
+ raise CodexLoginError("invalid OAuth state - potential CSRF")
209
+
210
+ logger.info("Received callback, exchanging code for tokens ...")
211
+ async with aiosonic.HTTPClient() as client:
212
+ tokens = await _exchange_code_for_tokens(
213
+ client,
214
+ code=result.code,
215
+ redirect_uri=REDIRECT_URI,
216
+ code_verifier=code_verifier,
217
+ )
218
+
219
+ credentials = CodexCredentials(
220
+ access_token=tokens["access_token"],
221
+ refresh_token=tokens.get("refresh_token"),
222
+ account_id=extract_account_id(tokens),
223
+ expires_at=_compute_expires_at(tokens),
224
+ )
225
+ resolved_path = save_credentials(credentials, auth_path)
226
+ logger.info("Saved credentials to %s", resolved_path)
227
+ return credentials
228
+
229
+
230
+ async def login_with_device_code(
231
+ auth_path: Path | None = None, *, verbose: bool = False
232
+ ) -> CodexCredentials:
233
+ """Log in via OpenAI's device-code flow (no local server required)."""
234
+ _configure_logging(verbose)
235
+ async with aiosonic.HTTPClient() as client:
236
+ usercode_response = await client.post(
237
+ f"{ISSUER}/api/accounts/deviceauth/usercode",
238
+ json={"client_id": CLIENT_ID},
239
+ headers={"Content-Type": "application/json"},
240
+ )
241
+ logger.debug("Device usercode response: %s", usercode_response.status_code)
242
+ if usercode_response.status_code != 200:
243
+ raise CodexLoginError("failed to start device authorization")
244
+ device_data = json.loads(await usercode_response.text())
245
+ device_auth_id = device_data["device_auth_id"]
246
+ user_code = device_data["user_code"]
247
+ interval = max(int(device_data.get("interval") or 5), 1)
248
+
249
+ logger.info("Enter code: %s", user_code)
250
+ logger.info("Then visit: %s/codex/device", ISSUER)
251
+
252
+ attempt = 0
253
+ while True:
254
+ attempt += 1
255
+ await asyncio.sleep(interval + DEVICE_POLL_SAFETY_MARGIN_SECONDS)
256
+ poll_response = await client.post(
257
+ f"{ISSUER}/api/accounts/deviceauth/token",
258
+ json={"device_auth_id": device_auth_id, "user_code": user_code},
259
+ headers={"Content-Type": "application/json"},
260
+ )
261
+ logger.debug(
262
+ "Poll attempt %d: status %s", attempt, poll_response.status_code
263
+ )
264
+ if poll_response.status_code == 200:
265
+ break
266
+ if poll_response.status_code not in (403, 404):
267
+ raise CodexLoginError(
268
+ f"device authorization failed: {poll_response.status_code}"
269
+ )
270
+ logger.info("Still waiting for confirmation ...")
271
+
272
+ poll_data = json.loads(await poll_response.text())
273
+ logger.info("Confirmed, exchanging code for tokens ...")
274
+ tokens = await _exchange_code_for_tokens(
275
+ client,
276
+ code=poll_data["authorization_code"],
277
+ redirect_uri=f"{ISSUER}/deviceauth/callback",
278
+ code_verifier=poll_data["code_verifier"],
279
+ )
280
+
281
+ credentials = CodexCredentials(
282
+ access_token=tokens["access_token"],
283
+ refresh_token=tokens.get("refresh_token"),
284
+ account_id=extract_account_id(tokens),
285
+ expires_at=_compute_expires_at(tokens),
286
+ )
287
+ resolved_path = save_credentials(credentials, auth_path)
288
+ logger.info("Saved credentials to %s", resolved_path)
289
+ return credentials
290
+
291
+
292
+ async def refresh_access_token(refresh_token: str) -> dict[str, Any]:
293
+ """Exchange a refresh token for a new access token."""
294
+ async with aiosonic.HTTPClient() as client:
295
+ response = await client.post(
296
+ f"{ISSUER}/oauth/token",
297
+ data={
298
+ "grant_type": "refresh_token",
299
+ "refresh_token": refresh_token,
300
+ "client_id": CLIENT_ID,
301
+ },
302
+ )
303
+ logger.debug("Token refresh response: %s", response.status_code)
304
+ if response.status_code != 200:
305
+ raise CodexLoginError(f"token refresh failed: {response.status_code}")
306
+ return json.loads(await response.text())
307
+
308
+
309
+ async def refresh_credentials(
310
+ credentials: CodexCredentials, auth_path: Path | None = None
311
+ ) -> CodexCredentials:
312
+ """Refresh an access token and persist the result."""
313
+ if not credentials.refresh_token:
314
+ raise CodexLoginError("no refresh token available")
315
+
316
+ tokens = await refresh_access_token(credentials.refresh_token)
317
+ refreshed = CodexCredentials(
318
+ access_token=tokens["access_token"],
319
+ refresh_token=tokens.get("refresh_token") or credentials.refresh_token,
320
+ account_id=extract_account_id(tokens) or credentials.account_id,
321
+ expires_at=_compute_expires_at(tokens),
322
+ )
323
+ resolved_path = save_credentials(refreshed, auth_path)
324
+ logger.debug("Refreshed credentials saved to %s", resolved_path)
325
+ return refreshed
326
+
327
+
328
+ async def login(
329
+ *, device_code: bool = False, auth_path: Path | None = None, verbose: bool = False
330
+ ) -> CodexCredentials:
331
+ """Log in to ChatGPT Codex, via browser by default or device code."""
332
+ if device_code:
333
+ return await login_with_device_code(auth_path, verbose=verbose)
334
+ return await login_with_browser(auth_path, verbose=verbose)
@@ -0,0 +1,106 @@
1
+ """ChatGPT subscription provider for Codex Responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from llm_async.models import Response
11
+ from llm_async.providers.openai_responses import OpenAIResponsesProvider
12
+
13
+ from .auth import CodexCredentials, default_auth_path, load_credentials
14
+ from .oauth import TOKEN_REFRESH_SAFETY_MARGIN_SECONDS, refresh_credentials
15
+
16
+ CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
17
+
18
+
19
+ class CodexProvider(OpenAIResponsesProvider):
20
+ """Send Responses API requests through a ChatGPT Codex subscription."""
21
+
22
+ def __init__(
23
+ self,
24
+ credentials: CodexCredentials,
25
+ *,
26
+ base_url: str = CODEX_BASE_URL,
27
+ http2: bool = False,
28
+ auth_path: Path | None = None,
29
+ ) -> None:
30
+ self.credentials = credentials
31
+ self.auth_path = auth_path
32
+ self._refresh_lock = asyncio.Lock()
33
+ super().__init__(api_key=credentials.access_token, base_url=base_url, http2=http2)
34
+
35
+ @classmethod
36
+ def from_codex_home(cls, path: Path | None = None) -> CodexProvider:
37
+ """Create a provider from an existing Codex CLI login."""
38
+ return cls(load_credentials(path), auth_path=path or default_auth_path())
39
+
40
+ async def _ensure_fresh_credentials(self) -> None:
41
+ credentials = self.credentials
42
+ if not credentials.refresh_token or credentials.expires_at is None:
43
+ return
44
+ if time.time() < credentials.expires_at - TOKEN_REFRESH_SAFETY_MARGIN_SECONDS:
45
+ return
46
+
47
+ async with self._refresh_lock:
48
+ credentials = self.credentials
49
+ if (
50
+ not credentials.refresh_token
51
+ or credentials.expires_at is None
52
+ or time.time() < credentials.expires_at - TOKEN_REFRESH_SAFETY_MARGIN_SECONDS
53
+ ):
54
+ return
55
+ self.credentials = await refresh_credentials(credentials, self.auth_path)
56
+ self.api_key = self.credentials.access_token
57
+
58
+ async def _single_complete(self, *args: Any, **kwargs: Any) -> Response:
59
+ stream = args[2] if len(args) > 2 else kwargs.get("stream", False)
60
+ if not stream:
61
+ raise ValueError("Codex subscriptions require stream=True")
62
+ kwargs.setdefault("store", False)
63
+ await self._ensure_fresh_credentials()
64
+ return await super()._single_complete(*args, **kwargs)
65
+
66
+ def _messages_to_input(
67
+ self, messages: list[dict[str, Any]]
68
+ ) -> list[dict[str, Any]]:
69
+ """Convert text messages to the list-only Codex Responses input format."""
70
+ input_items = super()._messages_to_input(messages)
71
+ if isinstance(input_items, str):
72
+ return [
73
+ {
74
+ "role": "user",
75
+ "content": [{"type": "input_text", "text": input_items}],
76
+ }
77
+ ]
78
+
79
+ normalized: list[dict[str, Any]] = []
80
+ for item in input_items:
81
+ if item.get("role") == "user" and isinstance(item.get("content"), str):
82
+ normalized.append(
83
+ {
84
+ **item,
85
+ "content": [{"type": "input_text", "text": item["content"]}],
86
+ }
87
+ )
88
+ elif item.get("role") == "assistant" and isinstance(
89
+ item.get("content"), str
90
+ ):
91
+ normalized.append(
92
+ {
93
+ **item,
94
+ "content": [{"type": "output_text", "text": item["content"]}],
95
+ }
96
+ )
97
+ else:
98
+ normalized.append(item)
99
+ return normalized
100
+
101
+ def _default_headers(self) -> dict[str, str]:
102
+ headers = super()._default_headers()
103
+ headers["originator"] = "llm-async-codex"
104
+ if self.credentials.account_id:
105
+ headers["ChatGPT-Account-Id"] = self.credentials.account_id
106
+ return headers