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.
- axio_transport_codex-0.1.0/.github/workflows/publish.yml +40 -0
- axio_transport_codex-0.1.0/.github/workflows/tests.yml +44 -0
- axio_transport_codex-0.1.0/LICENSE +21 -0
- axio_transport_codex-0.1.0/Makefile +16 -0
- axio_transport_codex-0.1.0/PKG-INFO +11 -0
- axio_transport_codex-0.1.0/README.md +15 -0
- axio_transport_codex-0.1.0/pyproject.toml +40 -0
- axio_transport_codex-0.1.0/src/axio_transport_codex/__init__.py +12 -0
- axio_transport_codex-0.1.0/src/axio_transport_codex/oauth.py +218 -0
- axio_transport_codex-0.1.0/src/axio_transport_codex/settings.py +78 -0
- axio_transport_codex-0.1.0/src/axio_transport_codex/transport.py +490 -0
- axio_transport_codex-0.1.0/tests/test_oauth.py +163 -0
- axio_transport_codex-0.1.0/tests/test_transport.py +719 -0
|
@@ -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
|