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 +46 -0
- codex_auth/auth.py +268 -0
- codex_auth/client.py +38 -0
- codex_auth/patch.py +280 -0
- codex_auth/py.typed +0 -0
- codex_auth/tokens.py +138 -0
- codex_auth-0.1.0.dist-info/METADATA +128 -0
- codex_auth-0.1.0.dist-info/RECORD +10 -0
- codex_auth-0.1.0.dist-info/WHEEL +4 -0
- codex_auth-0.1.0.dist-info/licenses/LICENSE +11 -0
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,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.
|