axio-transport-codex 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ env:
8
+ FORCE_COLOR: 1
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v6
16
+
17
+ - name: Set version from release tag
18
+ run: uv version "${GITHUB_REF_NAME#v}"
19
+
20
+ - name: Build package
21
+ run: uv build
22
+
23
+ - uses: actions/upload-artifact@v4
24
+ with:
25
+ name: dist
26
+ path: dist/
27
+
28
+ publish:
29
+ runs-on: ubuntu-latest
30
+ needs: build
31
+ environment: pypi
32
+ permissions:
33
+ id-token: write
34
+ steps:
35
+ - uses: actions/download-artifact@v4
36
+ with:
37
+ name: dist
38
+ path: dist/
39
+
40
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,44 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ branches: [ master, main ]
6
+ pull_request:
7
+ branches: [ master, main ]
8
+
9
+ env:
10
+ FORCE_COLOR: 1
11
+
12
+ jobs:
13
+ ruff:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v6
18
+ - run: uv sync --frozen
19
+ - run: uv run ruff check
20
+ - run: uv run ruff format --check
21
+
22
+ mypy:
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: astral-sh/setup-uv@v6
27
+ - run: uv sync --frozen
28
+ - run: uv run mypy .
29
+
30
+ tests:
31
+ runs-on: ubuntu-latest
32
+ strategy:
33
+ fail-fast: false
34
+ matrix:
35
+ python:
36
+ - "3.12"
37
+ - "3.13"
38
+ steps:
39
+ - uses: actions/checkout@v4
40
+ - uses: astral-sh/setup-uv@v6
41
+ with:
42
+ python-version: ${{ matrix.python }}
43
+ - run: uv sync --frozen
44
+ - run: uv run pytest -vv --cov=axio_transport_codex --cov-report=term-missing
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Axio contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,16 @@
1
+ .PHONY: fmt lint typecheck test all
2
+
3
+ fmt:
4
+ uv run ruff format src/ tests/
5
+ uv run ruff check --fix src/ tests/
6
+
7
+ lint:
8
+ uv run ruff check src/ tests/
9
+
10
+ typecheck:
11
+ uv run mypy src/
12
+
13
+ test:
14
+ uv run pytest tests/ -v
15
+
16
+ all: fmt lint typecheck test
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: axio-transport-codex
3
+ Version: 0.1.0
4
+ Summary: ChatGPT (Codex) OAuth transport for Axio using Responses API
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: aiohttp>=3.11
9
+ Requires-Dist: axio
10
+ Provides-Extra: tui
11
+ Requires-Dist: textual>=2.1.0; extra == 'tui'
@@ -0,0 +1,15 @@
1
+ # axio-transport-codex
2
+
3
+ ChatGPT (Codex) OAuth transport for Axio using Responses API.
4
+
5
+ Part of the [axio-agent](https://github.com/axio-agent) ecosystem.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install axio-transport-codex
11
+ ```
12
+
13
+ ## License
14
+
15
+ MIT
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "axio-transport-codex"
3
+ version = "0.1.0"
4
+ description = "ChatGPT (Codex) OAuth transport for Axio using Responses API"
5
+ requires-python = ">=3.12"
6
+ license = {text = "MIT"}
7
+ dependencies = ["axio", "aiohttp>=3.11"]
8
+
9
+ [project.optional-dependencies]
10
+ tui = ["textual>=2.1.0"]
11
+
12
+ [project.entry-points."axio.transport"]
13
+ codex = "axio_transport_codex:CodexTransport"
14
+
15
+ [project.entry-points."axio.transport.settings"]
16
+ codex = "axio_transport_codex:CodexSettingsScreen"
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/axio_transport_codex"]
24
+
25
+ [tool.pytest.ini_options]
26
+ asyncio_mode = "auto"
27
+
28
+ [tool.ruff]
29
+ line-length = 119
30
+ target-version = "py312"
31
+
32
+ [tool.ruff.lint]
33
+ select = ["E", "F", "I", "UP"]
34
+
35
+ [tool.mypy]
36
+ strict = true
37
+ python_version = "3.12"
38
+
39
+ [dependency-groups]
40
+ dev = ["pytest>=8", "pytest-asyncio>=0.24", "mypy>=1.14", "ruff>=0.9"]
@@ -0,0 +1,12 @@
1
+ """ChatGPT (Codex) OAuth transport for Axio — Responses API."""
2
+
3
+ from axio_transport_codex.transport import CODEX_MODELS, CodexTransport
4
+
5
+ __all__ = ["CODEX_MODELS", "CodexTransport"]
6
+
7
+ try:
8
+ from axio_transport_codex.settings import CodexSettingsScreen
9
+
10
+ __all__ += ["CodexSettingsScreen"]
11
+ except ImportError:
12
+ pass
@@ -0,0 +1,218 @@
1
+ """OAuth2 PKCE flow for ChatGPT (Codex) authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import secrets
10
+ import time
11
+ import webbrowser
12
+ from asyncio import Event
13
+ from typing import Any
14
+ from urllib.parse import urlencode
15
+
16
+ import aiohttp
17
+ from aiohttp import web
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ AUTH_URL = "https://auth.openai.com/oauth/authorize"
22
+ TOKEN_URL = "https://auth.openai.com/oauth/token"
23
+ CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
24
+ SCOPES = "openid profile email offline_access api.connectors.read api.connectors.invoke"
25
+ ORIGINATOR = "codex_cli_rs"
26
+
27
+
28
+ def _generate_pkce() -> tuple[str, str]:
29
+ """Generate PKCE code_verifier and code_challenge (S256)."""
30
+ verifier = secrets.token_urlsafe(96)
31
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
32
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
33
+ return verifier, challenge
34
+
35
+
36
+ def _decode_jwt_payload(token: str) -> dict[str, Any]:
37
+ """Decode JWT payload without verification (base64 only)."""
38
+ parts = token.split(".")
39
+ if len(parts) < 2:
40
+ return {}
41
+ payload = parts[1]
42
+ # Add padding
43
+ padding = 4 - len(payload) % 4
44
+ if padding != 4:
45
+ payload += "=" * padding
46
+ raw = base64.urlsafe_b64decode(payload)
47
+ return json.loads(raw) # type: ignore[no-any-return]
48
+
49
+
50
+ def _extract_account_id(access_token: str) -> str:
51
+ """Extract account_id from JWT payload."""
52
+ jwt_payload = _decode_jwt_payload(access_token)
53
+ orgs = jwt_payload.get("organizations", [])
54
+ if orgs and isinstance(orgs, list) and isinstance(orgs[0], dict):
55
+ account_id: str = orgs[0].get("id", "")
56
+ if account_id:
57
+ return account_id
58
+ return str(jwt_payload.get("sub", ""))
59
+
60
+
61
+ async def run_oauth_flow() -> dict[str, str]:
62
+ """Run full OAuth2 PKCE flow with localhost callback.
63
+
64
+ Opens browser for ChatGPT sign-in, waits for callback, exchanges code for tokens.
65
+
66
+ Returns dict with keys: access_token, refresh_token, expires_at, account_id.
67
+ """
68
+ code_verifier, code_challenge = _generate_pkce()
69
+ state = secrets.token_urlsafe(32)
70
+
71
+ result: dict[str, str] = {}
72
+ error: str | None = None
73
+ done = Event()
74
+
75
+ async def callback_handler(request: web.Request) -> web.Response:
76
+ nonlocal result, error
77
+
78
+ received_state = request.query.get("state", "")
79
+ if received_state != state:
80
+ error = f"State mismatch: expected {state!r}, got {received_state!r}"
81
+ done.set()
82
+ return web.Response(text="Authentication failed: state mismatch", status=400)
83
+
84
+ if "error" in request.query:
85
+ error = request.query.get("error_description", request.query["error"])
86
+ done.set()
87
+ return web.Response(text=f"Authentication failed: {error}", status=400)
88
+
89
+ code = request.query.get("code", "")
90
+ if not code:
91
+ error = "No authorization code received"
92
+ done.set()
93
+ return web.Response(text="Authentication failed: no code", status=400)
94
+
95
+ # Exchange code for tokens
96
+ try:
97
+ async with aiohttp.ClientSession() as session:
98
+ token_data = await _exchange_code(session, code, code_verifier)
99
+ result = token_data
100
+ except Exception as exc:
101
+ error = str(exc)
102
+ done.set()
103
+ return web.Response(text=f"Token exchange failed: {exc}", status=500)
104
+
105
+ done.set()
106
+ return web.Response(
107
+ text="<html><body><h2>Authentication successful!</h2>"
108
+ "<p>You can close this tab and return to the app.</p></body></html>",
109
+ content_type="text/html",
110
+ )
111
+
112
+ app = web.Application()
113
+ app.router.add_get("/auth/callback", callback_handler)
114
+
115
+ runner = web.AppRunner(app)
116
+ await runner.setup()
117
+ site = web.TCPSite(runner, "127.0.0.1", 1455)
118
+ await site.start()
119
+
120
+ redirect_uri = "http://localhost:1455/auth/callback"
121
+
122
+ # Build authorization URL (matching codex-cli exactly)
123
+ params = urlencode(
124
+ {
125
+ "response_type": "code",
126
+ "client_id": CLIENT_ID,
127
+ "redirect_uri": redirect_uri,
128
+ "scope": SCOPES,
129
+ "code_challenge": code_challenge,
130
+ "code_challenge_method": "S256",
131
+ "id_token_add_organizations": "true",
132
+ "codex_cli_simplified_flow": "true",
133
+ "state": state,
134
+ "originator": ORIGINATOR,
135
+ }
136
+ )
137
+ auth_url = f"{AUTH_URL}?{params}"
138
+
139
+ logger.info("Opening browser for OAuth sign-in...")
140
+ webbrowser.open(auth_url)
141
+
142
+ try:
143
+ await done.wait()
144
+ finally:
145
+ await runner.cleanup()
146
+
147
+ if error:
148
+ raise RuntimeError(f"OAuth flow failed: {error}")
149
+
150
+ return result
151
+
152
+
153
+ async def _exchange_code(
154
+ session: aiohttp.ClientSession,
155
+ code: str,
156
+ code_verifier: str,
157
+ ) -> dict[str, str]:
158
+ """Exchange authorization code for tokens via form-encoded POST."""
159
+ redirect_uri = "http://localhost:1455/auth/callback"
160
+ form_data = {
161
+ "grant_type": "authorization_code",
162
+ "code": code,
163
+ "redirect_uri": redirect_uri,
164
+ "client_id": CLIENT_ID,
165
+ "code_verifier": code_verifier,
166
+ }
167
+
168
+ async with session.post(
169
+ TOKEN_URL,
170
+ data=form_data,
171
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
172
+ ) as resp:
173
+ if resp.status != 200:
174
+ body = await resp.text()
175
+ raise RuntimeError(f"Token exchange failed ({resp.status}): {body}")
176
+ data: dict[str, Any] = await resp.json()
177
+
178
+ access_token: str = data["access_token"]
179
+ refresh_token: str = data.get("refresh_token", "")
180
+ expires_in: int = data.get("expires_in", 3600)
181
+ expires_at = str(int(time.time()) + expires_in)
182
+ account_id = _extract_account_id(access_token)
183
+
184
+ return {
185
+ "access_token": access_token,
186
+ "refresh_token": refresh_token,
187
+ "expires_at": expires_at,
188
+ "account_id": account_id,
189
+ }
190
+
191
+
192
+ async def refresh_access_token(refresh_token: str) -> dict[str, str]:
193
+ """Refresh an expired access token (JSON POST, matching codex-cli)."""
194
+ payload = {
195
+ "grant_type": "refresh_token",
196
+ "client_id": CLIENT_ID,
197
+ "refresh_token": refresh_token,
198
+ }
199
+
200
+ async with aiohttp.ClientSession() as session:
201
+ async with session.post(TOKEN_URL, json=payload) as resp:
202
+ if resp.status != 200:
203
+ body = await resp.text()
204
+ raise RuntimeError(f"Token refresh failed ({resp.status}): {body}")
205
+ data: dict[str, Any] = await resp.json()
206
+
207
+ access_token: str = data["access_token"]
208
+ new_refresh: str = data.get("refresh_token", refresh_token)
209
+ expires_in: int = data.get("expires_in", 3600)
210
+ expires_at = str(int(time.time()) + expires_in)
211
+ account_id = _extract_account_id(access_token)
212
+
213
+ return {
214
+ "access_token": access_token,
215
+ "refresh_token": new_refresh,
216
+ "expires_at": expires_at,
217
+ "account_id": account_id,
218
+ }
@@ -0,0 +1,78 @@
1
+ """Textual settings screen for ChatGPT (Codex) transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from textual.app import ComposeResult
7
+ from textual.binding import Binding
8
+ from textual.containers import Container, Horizontal
9
+ from textual.screen import ModalScreen
10
+ from textual.widgets import Button, Static
11
+
12
+ class CodexSettingsScreen(ModalScreen[dict[str, str] | None]):
13
+ """Sign in / sign out screen for ChatGPT OAuth."""
14
+
15
+ BINDINGS = [Binding("escape", "cancel", "Cancel")]
16
+ CSS = """
17
+ CodexSettingsScreen { align: center middle; }
18
+ #codex-settings {
19
+ width: 70; height: auto; border: heavy $accent;
20
+ background: $panel; padding: 1 2;
21
+ }
22
+ .field-label { margin-top: 1; }
23
+ .settings-buttons { height: auto; margin-top: 1; }
24
+ .settings-buttons Button { margin: 0 1; }
25
+ """
26
+
27
+ def __init__(self, settings: dict[str, str]) -> None:
28
+ super().__init__()
29
+ self._settings = settings
30
+ self._signed_in = bool(settings.get("api_key"))
31
+
32
+ def compose(self) -> ComposeResult:
33
+ with Container(id="codex-settings"):
34
+ yield Static("[bold]ChatGPT (Codex) Settings[/]")
35
+ if self._signed_in:
36
+ account = self._settings.get("account_id", "unknown")
37
+ yield Static(f"Signed in (account: {account[:16]}...)", classes="field-label")
38
+ with Horizontal(classes="settings-buttons"):
39
+ yield Button("Sign Out", id="btn-signout", variant="warning")
40
+ yield Button("Cancel", id="btn-cancel")
41
+ else:
42
+ yield Static(
43
+ "Sign in with your ChatGPT account to use this transport.",
44
+ classes="field-label",
45
+ )
46
+ with Horizontal(classes="settings-buttons"):
47
+ yield Button("Sign in with ChatGPT", id="btn-signin", variant="primary")
48
+ yield Button("Cancel", id="btn-cancel")
49
+
50
+ def on_button_pressed(self, event: Button.Pressed) -> None:
51
+ if event.button.id == "btn-signin":
52
+ self.run_worker(self._do_signin(), exclusive=True)
53
+ elif event.button.id == "btn-signout":
54
+ self.dismiss({})
55
+ else:
56
+ self.dismiss(None)
57
+
58
+ async def _do_signin(self) -> None:
59
+ from .oauth import run_oauth_flow
60
+
61
+ try:
62
+ tokens = await run_oauth_flow()
63
+ # Map access_token → api_key for registry compatibility
64
+ settings: dict[str, str] = {
65
+ "api_key": tokens["access_token"],
66
+ "refresh_token": tokens.get("refresh_token", ""),
67
+ "expires_at": tokens.get("expires_at", ""),
68
+ "account_id": tokens.get("account_id", ""),
69
+ }
70
+ self.dismiss(settings)
71
+ except Exception as exc:
72
+ self.notify(f"Sign-in failed: {exc}", severity="error")
73
+
74
+ def action_cancel(self) -> None:
75
+ self.dismiss(None)
76
+
77
+ except ImportError:
78
+ pass