codex-auth 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,23 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+ .eggs/
9
+ *.so
10
+ .tox/
11
+ .nox/
12
+ .mypy_cache/
13
+ .pytest_cache/
14
+ .ruff_cache/
15
+ htmlcov/
16
+ .coverage
17
+ .coverage.*
18
+ *.cover
19
+ .venv/
20
+ venv/
21
+ env/
22
+ .env
23
+ *.log
@@ -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.
@@ -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,106 @@
1
+ # codex-auth
2
+
3
+ Drop-in OAuth for the OpenAI Python SDK — use the ChatGPT Codex API with your Pro/Plus account instead of an API key.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install codex-auth
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ import codex_auth
15
+
16
+ from openai import OpenAI
17
+ client = OpenAI() # no API key needed
18
+
19
+ response = client.responses.create(
20
+ model="gpt-5.1-codex-mini",
21
+ input="Write a one-sentence bedtime story about a unicorn.",
22
+ )
23
+ print(response.output_text)
24
+ ```
25
+
26
+ A browser window opens on first run for OAuth. Tokens are cached in
27
+ `~/.codex-auth/auth.json` and refreshed automatically.
28
+
29
+ Both streaming and non-streaming calls work — the library handles the
30
+ Codex endpoint's streaming requirement transparently.
31
+
32
+ ### Streaming
33
+
34
+ ```python
35
+ stream = client.responses.create(
36
+ model="gpt-5.1-codex-mini",
37
+ input="Write a hello-world in Rust.",
38
+ stream=True,
39
+ )
40
+ for event in stream:
41
+ if event.type == "response.completed":
42
+ print(event.response.output_text)
43
+ ```
44
+
45
+ ### `chat.completions` compatibility
46
+
47
+ Existing code using `chat.completions` works too — requests are converted
48
+ to the Responses API format automatically:
49
+
50
+ ```python
51
+ response = client.chat.completions.create(
52
+ model="gpt-5.1-codex-mini",
53
+ messages=[{"role": "user", "content": "Hello!"}],
54
+ )
55
+ print(response.choices[0].message.content)
56
+ ```
57
+
58
+ ### Explicit client
59
+
60
+ If you prefer not to monkey-patch:
61
+
62
+ ```python
63
+ from codex_auth import CodexClient
64
+
65
+ client = CodexClient() # browser / device auth
66
+ client = CodexClient(token="…") # existing token
67
+ ```
68
+
69
+ Async variant:
70
+
71
+ ```python
72
+ from codex_auth import AsyncCodexClient
73
+ client = AsyncCodexClient()
74
+ ```
75
+
76
+ ### Disable auto-patch
77
+
78
+ ```bash
79
+ export CODEX_AUTH_NO_PATCH=1
80
+ ```
81
+
82
+ Or in code:
83
+
84
+ ```python
85
+ import codex_auth
86
+ codex_auth.init(auto_patch=False)
87
+ ```
88
+
89
+ ## How it works
90
+
91
+ A custom [httpx transport](https://www.python-httpx.org/advanced/transports/) intercepts OpenAI SDK requests to:
92
+
93
+ 1. Rewrite URLs to the Codex backend (`chatgpt.com/backend-api/codex/responses`)
94
+ 2. Convert `chat.completions` payloads to the Responses API format
95
+ 3. Buffer SSE responses for non-streaming callers
96
+ 4. Inject OAuth bearer tokens and refresh them transparently
97
+
98
+ Browser-based PKCE auth is used on desktop; device-code flow on headless/SSH.
99
+
100
+ ## Token storage
101
+
102
+ `~/.codex-auth/auth.json` (mode `0600`). Override with `CODEX_AUTH_TOKEN` env var.
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "codex-auth"
7
+ version = "0.1.0"
8
+ description = "Drop-in OpenAI SDK patch for ChatGPT Codex OAuth authentication"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "openai>=1.0",
24
+ "httpx>=0.24",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=7.0",
30
+ "pytest-asyncio>=0.21",
31
+ ]
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/codex_auth"]
@@ -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()
@@ -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()
@@ -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)