codex-auth 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_auth/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """codex-auth — drop-in Codex OAuth for the OpenAI Python SDK.
2
+
3
+ import codex_auth # patches openai automatically
4
+ from openai import OpenAI
5
+ client = OpenAI() # uses Codex OAuth, no API key needed
6
+
7
+ Or use the explicit client:
8
+
9
+ from codex_auth import CodexClient
10
+ client = CodexClient()
11
+ """
12
+
13
+ import os
14
+
15
+ from .auth import authenticate
16
+ from .client import AsyncCodexClient, CodexClient
17
+ from .patch import apply_patch, remove_patch
18
+ from .tokens import VERSION as __version__ # noqa: N811
19
+
20
+ __all__ = [
21
+ "__version__",
22
+ "CodexClient",
23
+ "AsyncCodexClient",
24
+ "authenticate",
25
+ "apply_patch",
26
+ "remove_patch",
27
+ "init",
28
+ ]
29
+
30
+ _auto_patched = False
31
+
32
+
33
+ def init(auto_patch: bool = True) -> None:
34
+ """Called on import. Set ``CODEX_AUTH_NO_PATCH=1`` to suppress."""
35
+ global _auto_patched
36
+ if auto_patch and not _auto_patched:
37
+ if os.environ.get("CODEX_AUTH_NO_PATCH") == "1":
38
+ return
39
+ apply_patch()
40
+ _auto_patched = True
41
+ elif not auto_patch and _auto_patched:
42
+ remove_patch()
43
+ _auto_patched = False
44
+
45
+
46
+ init()
codex_auth/auth.py ADDED
@@ -0,0 +1,268 @@
1
+ """Browser (PKCE) and device-code OAuth flows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.server
6
+ import logging
7
+ import os
8
+ import sys
9
+ import threading
10
+ import time
11
+ import urllib.parse
12
+ import webbrowser
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from .tokens import (
18
+ CLIENT_ID,
19
+ ISSUER,
20
+ OAUTH_PORT,
21
+ OAUTH_SCOPES,
22
+ TOKEN_URL,
23
+ USER_AGENT,
24
+ AuthTokens,
25
+ TokenStore,
26
+ generate_pkce,
27
+ generate_state,
28
+ )
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+ _TIMEOUT = httpx.Timeout(30.0, connect=10.0)
33
+ _token_store = TokenStore()
34
+
35
+
36
+ def _exchange_code(code: str, redirect_uri: str, verifier: str) -> dict[str, Any]:
37
+ r = httpx.post(
38
+ TOKEN_URL,
39
+ data={
40
+ "grant_type": "authorization_code",
41
+ "code": code,
42
+ "redirect_uri": redirect_uri,
43
+ "client_id": CLIENT_ID,
44
+ "code_verifier": verifier,
45
+ },
46
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
47
+ timeout=_TIMEOUT,
48
+ )
49
+ r.raise_for_status()
50
+ return r.json()
51
+
52
+
53
+ def refresh_access_token(refresh_token: str) -> dict[str, Any]:
54
+ r = httpx.post(
55
+ TOKEN_URL,
56
+ data={
57
+ "grant_type": "refresh_token",
58
+ "refresh_token": refresh_token,
59
+ "client_id": CLIENT_ID,
60
+ },
61
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
62
+ timeout=_TIMEOUT,
63
+ )
64
+ r.raise_for_status()
65
+ return r.json()
66
+
67
+
68
+ def _authorize_url(redirect_uri: str, challenge: str, state: str) -> str:
69
+ params = urllib.parse.urlencode({
70
+ "response_type": "code",
71
+ "client_id": CLIENT_ID,
72
+ "redirect_uri": redirect_uri,
73
+ "scope": OAUTH_SCOPES,
74
+ "code_challenge": challenge,
75
+ "code_challenge_method": "S256",
76
+ "id_token_add_organizations": "true",
77
+ "codex_cli_simplified_flow": "true",
78
+ "state": state,
79
+ "originator": "codex-auth",
80
+ })
81
+ return f"{ISSUER}/oauth/authorize?{params}"
82
+
83
+
84
+ _HTML_OK = (
85
+ "<!doctype html><html><head><title>Codex Auth</title>"
86
+ "<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;"
87
+ "align-items:center;height:100vh;margin:0;background:#131010;color:#f1ecec}"
88
+ ".c{text-align:center;padding:2rem}h1{margin-bottom:1rem}p{color:#b7b1b1}</style>"
89
+ "</head><body><div class='c'><h1>Authorization Successful</h1>"
90
+ "<p>You can close this window.</p></div>"
91
+ "<script>setTimeout(()=>window.close(),2000)</script></body></html>"
92
+ )
93
+
94
+ _HTML_ERR = (
95
+ "<!doctype html><html><head><title>Codex Auth</title>"
96
+ "<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;"
97
+ "align-items:center;height:100vh;margin:0;background:#131010;color:#f1ecec}"
98
+ ".c{text-align:center;padding:2rem}h1{color:#fc533a;margin-bottom:1rem}"
99
+ "p{color:#b7b1b1}.e{color:#ff917b;font-family:monospace;margin-top:1rem;"
100
+ "padding:1rem;background:#3c140d;border-radius:.5rem}</style>"
101
+ "</head><body><div class='c'><h1>Authorization Failed</h1>"
102
+ "<p>An error occurred.</p><div class='e'>%s</div></div></body></html>"
103
+ )
104
+
105
+
106
+ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
107
+ code: str | None = None
108
+ state: str | None = None
109
+ error: str | None = None
110
+
111
+ def do_GET(self) -> None:
112
+ parsed = urllib.parse.urlparse(self.path)
113
+ qs = urllib.parse.parse_qs(parsed.query)
114
+
115
+ if parsed.path != "/auth/callback":
116
+ self.send_response(404)
117
+ self.end_headers()
118
+ return
119
+
120
+ if "error" in qs:
121
+ msg = qs.get("error_description", qs["error"])[0]
122
+ _CallbackHandler.error = msg
123
+ self._html(200, _HTML_ERR % msg)
124
+ return
125
+
126
+ code = qs.get("code", [None])[0]
127
+ if not code:
128
+ _CallbackHandler.error = "Missing authorization code"
129
+ self._html(400, _HTML_ERR % "Missing authorization code")
130
+ return
131
+
132
+ _CallbackHandler.code = code
133
+ _CallbackHandler.state = qs.get("state", [None])[0]
134
+ self._html(200, _HTML_OK)
135
+
136
+ def _html(self, status: int, body: str) -> None:
137
+ self.send_response(status)
138
+ self.send_header("Content-Type", "text/html")
139
+ self.end_headers()
140
+ self.wfile.write(body.encode())
141
+
142
+ def log_message(self, format: str, *args: Any) -> None:
143
+ pass
144
+
145
+
146
+ def browser_auth() -> AuthTokens:
147
+ verifier, challenge = generate_pkce()
148
+ state = generate_state()
149
+ redirect = f"http://localhost:{OAUTH_PORT}/auth/callback"
150
+ url = _authorize_url(redirect, challenge, state)
151
+
152
+ _CallbackHandler.code = _CallbackHandler.state = _CallbackHandler.error = None
153
+
154
+ srv = http.server.HTTPServer(("localhost", OAUTH_PORT), _CallbackHandler)
155
+ srv.timeout = 300
156
+
157
+ def serve() -> None:
158
+ while _CallbackHandler.code is None and _CallbackHandler.error is None:
159
+ srv.handle_request()
160
+
161
+ t = threading.Thread(target=serve, daemon=True)
162
+ t.start()
163
+
164
+ print(f"Opening browser for authentication...\nIf it doesn't open, visit:\n {url}")
165
+ webbrowser.open(url)
166
+
167
+ t.join(timeout=300)
168
+ srv.server_close()
169
+
170
+ if _CallbackHandler.error:
171
+ raise RuntimeError(f"OAuth error: {_CallbackHandler.error}")
172
+ if not _CallbackHandler.code:
173
+ raise TimeoutError("OAuth callback timed out")
174
+ if _CallbackHandler.state != state:
175
+ raise RuntimeError("OAuth state mismatch — possible CSRF")
176
+
177
+ raw = _exchange_code(_CallbackHandler.code, redirect, verifier)
178
+ auth = AuthTokens.from_response(raw)
179
+ _token_store.save(auth)
180
+ return auth
181
+
182
+
183
+ def device_auth() -> AuthTokens:
184
+ r = httpx.post(
185
+ f"{ISSUER}/api/accounts/deviceauth/usercode",
186
+ json={"client_id": CLIENT_ID},
187
+ headers={"Content-Type": "application/json", "User-Agent": USER_AGENT},
188
+ timeout=_TIMEOUT,
189
+ )
190
+ r.raise_for_status()
191
+ data = r.json()
192
+
193
+ device_id = data["device_auth_id"]
194
+ user_code = data["user_code"]
195
+ interval = max(int(data.get("interval", 5)), 1)
196
+
197
+ print(f"\nTo authenticate, visit: {ISSUER}/codex/device")
198
+ print(f"Enter code: {user_code}\n")
199
+
200
+ for _ in range(300 // interval):
201
+ time.sleep(interval + 3)
202
+
203
+ poll = httpx.post(
204
+ f"{ISSUER}/api/accounts/deviceauth/token",
205
+ json={"device_auth_id": device_id, "user_code": user_code},
206
+ headers={"Content-Type": "application/json", "User-Agent": USER_AGENT},
207
+ timeout=_TIMEOUT,
208
+ )
209
+
210
+ if poll.status_code in (403, 404):
211
+ continue
212
+
213
+ if poll.is_success:
214
+ d = poll.json()
215
+ tok = httpx.post(
216
+ TOKEN_URL,
217
+ data={
218
+ "grant_type": "authorization_code",
219
+ "code": d["authorization_code"],
220
+ "redirect_uri": f"{ISSUER}/deviceauth/callback",
221
+ "client_id": CLIENT_ID,
222
+ "code_verifier": d["code_verifier"],
223
+ },
224
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
225
+ timeout=_TIMEOUT,
226
+ )
227
+ tok.raise_for_status()
228
+ auth = AuthTokens.from_response(tok.json())
229
+ _token_store.save(auth)
230
+ print("Authentication successful!")
231
+ return auth
232
+
233
+ raise RuntimeError(f"Device auth failed: HTTP {poll.status_code}")
234
+
235
+ raise TimeoutError("Device authentication timed out")
236
+
237
+
238
+ def _has_display() -> bool:
239
+ if sys.platform in ("darwin", "win32"):
240
+ return True
241
+ return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
242
+
243
+
244
+ def authenticate(force: bool = False) -> AuthTokens:
245
+ """Return valid tokens — from cache, refresh, or a fresh OAuth flow."""
246
+ if not force:
247
+ existing = _token_store.load()
248
+ if existing and existing.access_token:
249
+ if not existing.is_expired():
250
+ return existing
251
+ if existing.refresh_token:
252
+ try:
253
+ raw = refresh_access_token(existing.refresh_token)
254
+ auth = AuthTokens.from_response(
255
+ raw, existing.refresh_token, existing.account_id,
256
+ )
257
+ _token_store.save(auth)
258
+ return auth
259
+ except Exception:
260
+ log.debug("Token refresh failed, re-authenticating", exc_info=True)
261
+
262
+ if _has_display():
263
+ try:
264
+ return browser_auth()
265
+ except Exception as exc:
266
+ print(f"Browser auth failed ({exc}), trying device flow...")
267
+
268
+ return device_auth()
codex_auth/client.py ADDED
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import openai
5
+
6
+ from .auth import authenticate
7
+ from .patch import AsyncCodexTransport, CodexTransport
8
+ from .tokens import AuthTokens, TokenStore
9
+
10
+
11
+ class CodexClient(openai.OpenAI):
12
+ """``openai.OpenAI`` subclass that authenticates via Codex OAuth."""
13
+
14
+ def __init__(self, *, token: str | None = None, **kwargs: object) -> None:
15
+ store = TokenStore()
16
+ auth = AuthTokens(access_token=token) if token else authenticate()
17
+
18
+ kwargs.setdefault(
19
+ "http_client",
20
+ httpx.Client(transport=CodexTransport(auth_tokens=auth, token_store=store)),
21
+ )
22
+ kwargs.setdefault("api_key", "codex-auth-dummy-key")
23
+ super().__init__(**kwargs)
24
+
25
+
26
+ class AsyncCodexClient(openai.AsyncOpenAI):
27
+ """Async variant of :class:`CodexClient`."""
28
+
29
+ def __init__(self, *, token: str | None = None, **kwargs: object) -> None:
30
+ store = TokenStore()
31
+ auth = AuthTokens(access_token=token) if token else authenticate()
32
+
33
+ kwargs.setdefault(
34
+ "http_client",
35
+ httpx.AsyncClient(transport=AsyncCodexTransport(auth_tokens=auth, token_store=store)),
36
+ )
37
+ kwargs.setdefault("api_key", "codex-auth-dummy-key")
38
+ super().__init__(**kwargs)
codex_auth/patch.py ADDED
@@ -0,0 +1,280 @@
1
+ """Monkey-patch the OpenAI SDK to route requests through Codex OAuth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+
8
+ import httpx
9
+ import openai
10
+
11
+ from .auth import authenticate, refresh_access_token
12
+ from .tokens import CODEX_PARSED_URL, USER_AGENT, AuthTokens, TokenStore
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ _original_init = None
17
+ _original_async_init = None
18
+ _patched = False
19
+
20
+
21
+ def _chat_completions_to_responses(body: dict) -> dict:
22
+ """The Codex endpoint only speaks the Responses API."""
23
+ messages = body.get("messages", [])
24
+ instructions: list[str] = []
25
+ items: list[dict] = []
26
+
27
+ for msg in messages:
28
+ role = msg.get("role", "")
29
+ content = msg.get("content", "")
30
+ if role in ("system", "developer"):
31
+ if isinstance(content, str):
32
+ instructions.append(content)
33
+ elif isinstance(content, list):
34
+ instructions.extend(
35
+ p.get("text", "") for p in content
36
+ if isinstance(p, dict) and p.get("type") == "text"
37
+ )
38
+ elif role in ("user", "assistant"):
39
+ items.append({"role": role, "content": content})
40
+
41
+ result: dict = {
42
+ "model": body.get("model", ""),
43
+ "instructions": "\n".join(instructions) or "You are a helpful assistant.",
44
+ "input": items,
45
+ "store": False,
46
+ "stream": True,
47
+ }
48
+ for key in ("temperature", "max_output_tokens", "top_p", "tools"):
49
+ if key in body:
50
+ result[key] = body[key]
51
+ if "max_tokens" in body and "max_output_tokens" not in body:
52
+ result["max_output_tokens"] = body["max_tokens"]
53
+ return result
54
+
55
+
56
+ def _normalize_responses_body(body: dict) -> dict:
57
+ body = body.copy()
58
+ if isinstance(body.get("input"), str):
59
+ body["input"] = [{"role": "user", "content": body["input"]}]
60
+ body.setdefault("instructions", "You are a helpful assistant.")
61
+ body["store"] = False
62
+ body["stream"] = True
63
+ return body
64
+
65
+
66
+ def _extract_sse_response(raw: bytes) -> dict | None:
67
+ """Parse SSE bytes and return the response dict from the completed event."""
68
+ for line in raw.decode("utf-8", errors="replace").splitlines():
69
+ if not line.startswith("data: ") or line == "data: [DONE]":
70
+ continue
71
+ try:
72
+ event = json.loads(line[6:])
73
+ if event.get("type") == "response.completed":
74
+ return event.get("response")
75
+ except json.JSONDecodeError:
76
+ continue
77
+ return None
78
+
79
+
80
+ def _responses_to_chat_completion(resp: dict) -> dict:
81
+ """Convert a Responses API response object to chat.completions format."""
82
+ status = resp.get("status", "completed")
83
+ return {
84
+ "id": resp.get("id", ""),
85
+ "object": "chat.completion",
86
+ "created": int(resp.get("created_at", 0)),
87
+ "model": resp.get("model", ""),
88
+ "choices": [{
89
+ "index": 0,
90
+ "message": {"role": "assistant", "content": resp.get("output_text", "")},
91
+ "finish_reason": "stop" if status == "completed" else "length",
92
+ }],
93
+ "usage": resp.get("usage", {}),
94
+ }
95
+
96
+
97
+ def _buffer_sse(raw: bytes, as_chat_completion: bool = False) -> httpx.Response | None:
98
+ """Turn raw SSE bytes into a JSON httpx.Response, or None on failure."""
99
+ resp = _extract_sse_response(raw)
100
+ if resp is None:
101
+ return None
102
+ if as_chat_completion:
103
+ resp = _responses_to_chat_completion(resp)
104
+ body = json.dumps(resp).encode()
105
+ return httpx.Response(200, headers={"content-type": "application/json"}, content=body)
106
+
107
+
108
+ def _ensure_valid_auth(
109
+ auth: AuthTokens | None, store: TokenStore,
110
+ ) -> AuthTokens:
111
+ if auth is None:
112
+ return authenticate()
113
+
114
+ if auth.is_expired() and auth.refresh_token:
115
+ try:
116
+ raw = refresh_access_token(auth.refresh_token)
117
+ auth = AuthTokens.from_response(
118
+ raw, auth.refresh_token, auth.account_id,
119
+ )
120
+ store.save(auth)
121
+ except Exception:
122
+ log.debug("Token refresh failed, re-authenticating", exc_info=True)
123
+ auth = authenticate(force=True)
124
+
125
+ return auth
126
+
127
+
128
+ def _is_codex_path(path: str) -> bool:
129
+ return "/chat/completions" in path or "/v1/responses" in path
130
+
131
+
132
+ def _user_wants_stream(request: httpx.Request) -> bool:
133
+ try:
134
+ return json.loads(request.content).get("stream") is True
135
+ except Exception:
136
+ return False
137
+
138
+
139
+ def _rewrite_request(request: httpx.Request, auth: AuthTokens) -> httpx.Request:
140
+ path = request.url.path
141
+ is_chat = "/chat/completions" in path
142
+
143
+ if not (is_chat or "/v1/responses" in path):
144
+ request.headers["authorization"] = f"Bearer {auth.access_token}"
145
+ if auth.account_id:
146
+ request.headers["chatgpt-account-id"] = auth.account_id
147
+ return request
148
+
149
+ p = CODEX_PARSED_URL
150
+ request.url = request.url.copy_with(
151
+ scheme=p.scheme, host=p.hostname, port=p.port, path=p.path,
152
+ )
153
+
154
+ try:
155
+ body = json.loads(request.content)
156
+ body = _chat_completions_to_responses(body) if is_chat else _normalize_responses_body(body)
157
+ content = json.dumps(body).encode()
158
+ except (json.JSONDecodeError, UnicodeDecodeError):
159
+ content = request.content
160
+
161
+ headers = {
162
+ k: v for k, v in request.headers.items()
163
+ if not k.lower().startswith("x-stainless")
164
+ }
165
+ headers.update({
166
+ "host": p.hostname,
167
+ "user-agent": USER_AGENT,
168
+ "originator": "codex-auth",
169
+ "authorization": f"Bearer {auth.access_token}",
170
+ "content-length": str(len(content)),
171
+ })
172
+ if auth.account_id:
173
+ headers["chatgpt-account-id"] = auth.account_id
174
+
175
+ return httpx.Request(
176
+ method=request.method, url=request.url,
177
+ headers=headers, content=content,
178
+ )
179
+
180
+
181
+ class CodexTransport(httpx.BaseTransport):
182
+ def __init__(
183
+ self,
184
+ auth_tokens: AuthTokens | None = None,
185
+ token_store: TokenStore | None = None,
186
+ wrapped: httpx.BaseTransport | None = None,
187
+ ):
188
+ self._auth = auth_tokens
189
+ self._store = token_store or TokenStore()
190
+ self._wrapped = wrapped or httpx.HTTPTransport()
191
+
192
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
193
+ self._auth = _ensure_valid_auth(self._auth, self._store)
194
+ path = request.url.path
195
+ needs_buffer = _is_codex_path(path) and not _user_wants_stream(request)
196
+ is_chat = "/chat/completions" in path
197
+
198
+ response = self._wrapped.handle_request(_rewrite_request(request, self._auth))
199
+
200
+ if needs_buffer and response.status_code == 200:
201
+ buffered = _buffer_sse(response.read(), as_chat_completion=is_chat)
202
+ if buffered is not None:
203
+ return buffered
204
+
205
+ return response
206
+
207
+ def close(self) -> None:
208
+ self._wrapped.close()
209
+
210
+
211
+ class AsyncCodexTransport(httpx.AsyncBaseTransport):
212
+ def __init__(
213
+ self,
214
+ auth_tokens: AuthTokens | None = None,
215
+ token_store: TokenStore | None = None,
216
+ wrapped: httpx.AsyncBaseTransport | None = None,
217
+ ):
218
+ self._auth = auth_tokens
219
+ self._store = token_store or TokenStore()
220
+ self._wrapped = wrapped or httpx.AsyncHTTPTransport()
221
+
222
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
223
+ self._auth = _ensure_valid_auth(self._auth, self._store)
224
+ path = request.url.path
225
+ needs_buffer = _is_codex_path(path) and not _user_wants_stream(request)
226
+ is_chat = "/chat/completions" in path
227
+
228
+ response = await self._wrapped.handle_async_request(
229
+ _rewrite_request(request, self._auth)
230
+ )
231
+
232
+ if needs_buffer and response.status_code == 200:
233
+ buffered = _buffer_sse(await response.aread(), as_chat_completion=is_chat)
234
+ if buffered is not None:
235
+ return buffered
236
+
237
+ return response
238
+
239
+ async def aclose(self) -> None:
240
+ await self._wrapped.aclose()
241
+
242
+
243
+ def apply_patch() -> None:
244
+ global _original_init, _original_async_init, _patched
245
+ if _patched:
246
+ return
247
+
248
+ _original_init = openai.OpenAI.__init__
249
+ _original_async_init = openai.AsyncOpenAI.__init__
250
+
251
+ # Capture in locals — the globals get nulled by remove_patch()
252
+ real_init = _original_init
253
+ real_async_init = _original_async_init
254
+
255
+ def patched_init(self, **kw):
256
+ kw.setdefault("http_client", httpx.Client(transport=CodexTransport()))
257
+ kw.setdefault("api_key", "codex-auth-dummy-key")
258
+ real_init(self, **kw)
259
+
260
+ def patched_async_init(self, **kw):
261
+ kw.setdefault("http_client", httpx.AsyncClient(transport=AsyncCodexTransport()))
262
+ kw.setdefault("api_key", "codex-auth-dummy-key")
263
+ real_async_init(self, **kw)
264
+
265
+ openai.OpenAI.__init__ = patched_init # type: ignore[assignment]
266
+ openai.AsyncOpenAI.__init__ = patched_async_init # type: ignore[assignment]
267
+ _patched = True
268
+
269
+
270
+ def remove_patch() -> None:
271
+ global _original_init, _original_async_init, _patched
272
+ if not _patched or _original_init is None:
273
+ return
274
+
275
+ openai.OpenAI.__init__ = _original_init # type: ignore[assignment]
276
+ if _original_async_init is not None:
277
+ openai.AsyncOpenAI.__init__ = _original_async_init # type: ignore[assignment]
278
+
279
+ _original_init = _original_async_init = None
280
+ _patched = False
codex_auth/py.typed ADDED
File without changes
codex_auth/tokens.py ADDED
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import platform as _platform
8
+ import secrets
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from urllib.parse import urlparse
14
+
15
+ VERSION = "0.1.0"
16
+
17
+ CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
18
+ ISSUER = "https://auth.openai.com"
19
+ TOKEN_URL = f"{ISSUER}/oauth/token"
20
+ CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
21
+ CODEX_PARSED_URL = urlparse(CODEX_API_ENDPOINT)
22
+ OAUTH_PORT = 1455
23
+ OAUTH_SCOPES = "openid profile email offline_access"
24
+
25
+ AUTH_DIR = Path.home() / ".codex-auth"
26
+ AUTH_FILE = AUTH_DIR / "auth.json"
27
+
28
+ USER_AGENT = (
29
+ f"codex-auth/{VERSION}"
30
+ f" ({_platform.system()} {_platform.release()}; {_platform.machine()})"
31
+ )
32
+
33
+
34
+ def _base64url(data: bytes) -> str:
35
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
36
+
37
+
38
+ def generate_pkce() -> tuple[str, str]:
39
+ alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
40
+ verifier = "".join(secrets.choice(alphabet) for _ in range(64))
41
+ challenge = _base64url(hashlib.sha256(verifier.encode("ascii")).digest())
42
+ return verifier, challenge
43
+
44
+
45
+ def generate_state() -> str:
46
+ return _base64url(secrets.token_bytes(32))
47
+
48
+
49
+ def parse_jwt_claims(token: str) -> dict[str, Any] | None:
50
+ parts = token.split(".")
51
+ if len(parts) != 3:
52
+ return None
53
+ try:
54
+ payload = parts[1] + "=" * (-len(parts[1]) % 4)
55
+ return json.loads(base64.urlsafe_b64decode(payload))
56
+ except Exception:
57
+ return None
58
+
59
+
60
+ def extract_account_id(tokens: dict[str, str]) -> str | None:
61
+ """Try id_token then access_token, checking several known claim locations."""
62
+ for key in ("id_token", "access_token"):
63
+ raw = tokens.get(key)
64
+ if not raw:
65
+ continue
66
+ claims = parse_jwt_claims(raw)
67
+ if not claims:
68
+ continue
69
+
70
+ if acct := claims.get("chatgpt_account_id"):
71
+ return acct
72
+
73
+ nested = claims.get("https://api.openai.com/auth")
74
+ if isinstance(nested, dict) and (acct := nested.get("chatgpt_account_id")):
75
+ return acct
76
+
77
+ orgs = claims.get("organizations", [])
78
+ if orgs and isinstance(orgs[0], dict) and (acct := orgs[0].get("id")):
79
+ return acct
80
+
81
+ return None
82
+
83
+
84
+ @dataclass
85
+ class AuthTokens:
86
+ access_token: str
87
+ refresh_token: str = ""
88
+ expires: float = 0.0
89
+ account_id: str = ""
90
+
91
+ def is_expired(self) -> bool:
92
+ return bool(self.expires) and time.time() * 1000 >= self.expires
93
+
94
+ @classmethod
95
+ def from_response(
96
+ cls,
97
+ raw: dict[str, Any],
98
+ fallback_refresh: str = "",
99
+ fallback_account_id: str = "",
100
+ ) -> AuthTokens:
101
+ return cls(
102
+ access_token=raw["access_token"],
103
+ refresh_token=raw.get("refresh_token", fallback_refresh),
104
+ expires=time.time() * 1000 + raw.get("expires_in", 3600) * 1000,
105
+ account_id=extract_account_id(raw) or fallback_account_id,
106
+ )
107
+
108
+
109
+ @dataclass
110
+ class TokenStore:
111
+ auth_file: Path = field(default_factory=lambda: AUTH_FILE)
112
+
113
+ def load(self) -> AuthTokens | None:
114
+ if env_token := os.environ.get("CODEX_AUTH_TOKEN"):
115
+ return AuthTokens(access_token=env_token)
116
+ try:
117
+ data = json.loads(self.auth_file.read_text())
118
+ return AuthTokens(
119
+ access_token=data.get("access_token", ""),
120
+ refresh_token=data.get("refresh_token", ""),
121
+ expires=data.get("expires", 0),
122
+ account_id=data.get("account_id", ""),
123
+ )
124
+ except (json.JSONDecodeError, OSError):
125
+ return None
126
+
127
+ def save(self, tokens: AuthTokens) -> None:
128
+ self.auth_file.parent.mkdir(parents=True, exist_ok=True)
129
+ self.auth_file.write_text(json.dumps({
130
+ "access_token": tokens.access_token,
131
+ "refresh_token": tokens.refresh_token,
132
+ "expires": tokens.expires,
133
+ "account_id": tokens.account_id,
134
+ }, indent=2))
135
+ self.auth_file.chmod(0o600)
136
+
137
+ def clear(self) -> None:
138
+ self.auth_file.unlink(missing_ok=True)
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: codex-auth
3
+ Version: 0.1.0
4
+ Summary: Drop-in OpenAI SDK patch for ChatGPT Codex OAuth authentication
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: httpx>=0.24
17
+ Requires-Dist: openai>=1.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
20
+ Requires-Dist: pytest>=7.0; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # codex-auth
24
+
25
+ Drop-in OAuth for the OpenAI Python SDK — use the ChatGPT Codex API with your Pro/Plus account instead of an API key.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install codex-auth
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```python
36
+ import codex_auth
37
+
38
+ from openai import OpenAI
39
+ client = OpenAI() # no API key needed
40
+
41
+ response = client.responses.create(
42
+ model="gpt-5.1-codex-mini",
43
+ input="Write a one-sentence bedtime story about a unicorn.",
44
+ )
45
+ print(response.output_text)
46
+ ```
47
+
48
+ A browser window opens on first run for OAuth. Tokens are cached in
49
+ `~/.codex-auth/auth.json` and refreshed automatically.
50
+
51
+ Both streaming and non-streaming calls work — the library handles the
52
+ Codex endpoint's streaming requirement transparently.
53
+
54
+ ### Streaming
55
+
56
+ ```python
57
+ stream = client.responses.create(
58
+ model="gpt-5.1-codex-mini",
59
+ input="Write a hello-world in Rust.",
60
+ stream=True,
61
+ )
62
+ for event in stream:
63
+ if event.type == "response.completed":
64
+ print(event.response.output_text)
65
+ ```
66
+
67
+ ### `chat.completions` compatibility
68
+
69
+ Existing code using `chat.completions` works too — requests are converted
70
+ to the Responses API format automatically:
71
+
72
+ ```python
73
+ response = client.chat.completions.create(
74
+ model="gpt-5.1-codex-mini",
75
+ messages=[{"role": "user", "content": "Hello!"}],
76
+ )
77
+ print(response.choices[0].message.content)
78
+ ```
79
+
80
+ ### Explicit client
81
+
82
+ If you prefer not to monkey-patch:
83
+
84
+ ```python
85
+ from codex_auth import CodexClient
86
+
87
+ client = CodexClient() # browser / device auth
88
+ client = CodexClient(token="…") # existing token
89
+ ```
90
+
91
+ Async variant:
92
+
93
+ ```python
94
+ from codex_auth import AsyncCodexClient
95
+ client = AsyncCodexClient()
96
+ ```
97
+
98
+ ### Disable auto-patch
99
+
100
+ ```bash
101
+ export CODEX_AUTH_NO_PATCH=1
102
+ ```
103
+
104
+ Or in code:
105
+
106
+ ```python
107
+ import codex_auth
108
+ codex_auth.init(auto_patch=False)
109
+ ```
110
+
111
+ ## How it works
112
+
113
+ A custom [httpx transport](https://www.python-httpx.org/advanced/transports/) intercepts OpenAI SDK requests to:
114
+
115
+ 1. Rewrite URLs to the Codex backend (`chatgpt.com/backend-api/codex/responses`)
116
+ 2. Convert `chat.completions` payloads to the Responses API format
117
+ 3. Buffer SSE responses for non-streaming callers
118
+ 4. Inject OAuth bearer tokens and refresh them transparently
119
+
120
+ Browser-based PKCE auth is used on desktop; device-code flow on headless/SSH.
121
+
122
+ ## Token storage
123
+
124
+ `~/.codex-auth/auth.json` (mode `0600`). Override with `CODEX_AUTH_TOKEN` env var.
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,10 @@
1
+ codex_auth/__init__.py,sha256=sK5l1PEb3NGKoFKfK3iCJCb3eVp1X3OjvaXkLxEaSX4,1109
2
+ codex_auth/auth.py,sha256=YXuap9USefPiBr07vAeXhf33EJDkVRZP-54TtYA1qnQ,8568
3
+ codex_auth/client.py,sha256=kNZjdQhsrTk3nM0FI1_npvOx_KixmVP0cb0Fnk8DW74,1275
4
+ codex_auth/patch.py,sha256=t33iE3kV4agxjpjajjKgYqbcfon4_I-OIwBMMGuUsBg,9385
5
+ codex_auth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ codex_auth/tokens.py,sha256=EfyYYNJIRPaSGQisf0gr9edM5SCatOqEQ595sRnfoho,4191
7
+ codex_auth-0.1.0.dist-info/METADATA,sha256=YpnB_a1n4Ko2_hF-nxbCUTQK8Ydb5umyuzLkkbEj1ww,3151
8
+ codex_auth-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ codex_auth-0.1.0.dist-info/licenses/LICENSE,sha256=J2DYk1GTb8XHFUCH50BQDdbj4keTGgj9Bdf8PpB7HY0,432
10
+ codex_auth-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,11 @@
1
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
2
+ Version 2, December 2004
3
+
4
+ Everyone is permitted to copy and distribute verbatim or modified
5
+ copies of this license document, and changing it is allowed as long
6
+ as the name is changed.
7
+
8
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
9
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
10
+
11
+ 0. You just DO WHAT THE FUCK YOU WANT TO.