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.
- llm_async_codex-0.1.0/PKG-INFO +15 -0
- llm_async_codex-0.1.0/pyproject.toml +35 -0
- llm_async_codex-0.1.0/src/llm_async_codex/__init__.py +34 -0
- llm_async_codex-0.1.0/src/llm_async_codex/auth.py +76 -0
- llm_async_codex-0.1.0/src/llm_async_codex/cli.py +60 -0
- llm_async_codex-0.1.0/src/llm_async_codex/oauth.py +334 -0
- llm_async_codex-0.1.0/src/llm_async_codex/provider.py +106 -0
|
@@ -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
|