Add Codex/ChatGPT account OAuth provider

Create a generic OAuth Connections plugin with Codex/ChatGPT Account as the first provider, using OpenAI's device-code flow to persist Codex-compatible account tokens. Expose a loopback OpenAI-compatible wrapper for models, responses, and chat completions, and point LiteLLM at the container-local Agent Zero origin. Add a dummy API-key extension and focused tests so the account-backed provider appears configured without requiring a user-entered key. docs: add Codex plan OAuth callout Highlight that Agent Zero can use an existing OpenAI Codex plan through the new OAuth flow. Add the account-backed LLM plans image and surface the section from the README navigation, while pointing toward future Gemini CLI and Claude Code integrations. Handle Codex account SSE chat chunks Teach the Codex/ChatGPT account bridge to extract text from OpenAI-style SSE chat completion deltas and fall back to a normal output_text response when upstream only streams chunks. Strip user-supplied stream kwargs before LiteLLM calls so Agent Zero owns streaming mode and custom parameters cannot pass stream twice. Add targeted tests for streamed delta extraction and reconstructed responses. update README.md with LLM plans mention

Alessandro committed Apr 28, 2026 at 15:11 UTC f67564a8aeccd4b0011069ae71803356f04add1f
23 files changed +2511 -5
README.md
+12 -1
@@ -14,7 +14,8 @@ Agent Zero is a dynamic, organic agentic framework for running AI agents that ca
14
15 [Introduction](#what-agent-zero-is) |
16 [Space Agent](#agent-zero-and-space-agent) |
17 -[Quick Start](#quick-start) |
17 +[Quick Start](#how-to-install) |
18 +[LLM Plans](#use-your-openai-codex-plan) |
19 [CLI Connector](#a0-cli-connector-use-agent-zero-on-your-host-machine) |
20 [Features](#what-makes-agent-zero-different) |
21 [Examples](#try-these-first) |
@@ -98,6 +99,16 @@ For web and mobile development, Annotate mode lets you click page elements or re
99
100 The Browser also supports Chrome extensions installed from the Chrome Web Store directly inside the Agent Zero browser environment, so workflows can use the same kind of browser capabilities real users depend on.
101
102 +## Use Your OpenAI Codex Plan
103 +
104 +Agent Zero can now connect to your OpenAI Codex plan through the new OAuth flow. Sign in with your account, pick the Codex-backed provider, and let Agent Zero use the plan you already have.
105 +
106 +<img width="1184" height="604" alt="OAuth LLM plans in Agent Zero" src="docs/res/oauth_llm_plans-ok.png" />
107 +<br>
108 +
109 +Click "Connect", enter the device code in the OpenAI page. Choose your model after checking the list, and you're all set.
110 +
111 +This is the first step toward account-backed LLM plans in Agent Zero. More integrations are coming, including Gemini CLI, Claude Code based on extra-usage, and more.
112
113 # A0 CLI Connector: Use Agent Zero on Your Host Machine
114
docs/res/oauth_llm_plans-ok.png
Binary files /dev/null and b/docs/res/oauth_llm_plans-ok.png differ
models.py
+12 -4
@@ -396,8 +396,9 @@ class LiteLLMChatWrapper(SimpleChatModel):
396 apply_rate_limiter_sync(self.a0_model_conf, str(msgs))
397
398 # Call the model
399 + call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs})
400 resp = completion(
400 - model=self.model_name, messages=msgs, stop=stop, **{**self.kwargs, **kwargs}
401 + model=self.model_name, messages=msgs, stop=stop, **call_kwargs
402 )
403
404 # Parse output
@@ -420,13 +421,14 @@ class LiteLLMChatWrapper(SimpleChatModel):
421 apply_rate_limiter_sync(self.a0_model_conf, str(msgs))
422
423 result = ChatGenerationResult()
424 + call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs})
425
426 for chunk in completion(
427 model=self.model_name,
428 messages=msgs,
429 stream=True,
430 stop=stop,
429 - **{**self.kwargs, **kwargs},
431 + **call_kwargs,
432 ):
433 # parse chunk
434 parsed = _parse_chunk(chunk) # chunk parsing
@@ -451,13 +453,14 @@ class LiteLLMChatWrapper(SimpleChatModel):
453 await apply_rate_limiter(self.a0_model_conf, str(msgs))
454
455 result = ChatGenerationResult()
456 + call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs})
457
458 response = await acompletion(
459 model=self.model_name,
460 messages=msgs,
461 stream=True,
462 stop=stop,
460 - **{**self.kwargs, **kwargs},
463 + **call_kwargs,
464 )
465 async for chunk in response: # type: ignore
466 # parse chunk
@@ -504,7 +507,7 @@ class LiteLLMChatWrapper(SimpleChatModel):
507 )
508
509 # Prepare call kwargs and retry config (strip A0-only params before calling LiteLLM)
507 - call_kwargs: dict[str, Any] = {**self.kwargs, **kwargs}
510 + call_kwargs: dict[str, Any] = _without_stream_kwarg({**self.kwargs, **kwargs})
511 max_retries: int = int(call_kwargs.pop("a0_retry_attempts", 2))
512 retry_delay_s: float = float(call_kwargs.pop("a0_retry_delay_seconds", 1.5))
513 stream = reasoning_callback is not None or response_callback is not None or tokens_callback is not None
@@ -760,6 +763,11 @@ def _parse_chunk(chunk: Any) -> ChatChunk:
763 return ChatChunk(reasoning_delta=reasoning_delta, response_delta=response_delta)
764
765
766 +def _without_stream_kwarg(kwargs: dict[str, Any]) -> dict[str, Any]:
767 + kwargs.pop("stream", None)
768 + return kwargs
769 +
770 +
771
772 def _adjust_call_args(provider_name: str, model_name: str, kwargs: dict):
773
plugins/_oauth/README.md new
+12
@@ -0,0 +1,12 @@
1 +# OAuth Connections
2 +
3 +Generic local OAuth bridge for Agent Zero.
4 +
5 +The first provider is `Codex/ChatGPT Account`:
6 +
7 +- signs in with OpenAI's Codex device-code flow
8 +- writes Codex-compatible `auth.json` credentials
9 +- refreshes local tokens when needed
10 +- exposes a loopback OpenAI-compatible wrapper at `/oauth/codex/v1`
11 +
12 +Tokens in `auth.json` are password-equivalent credentials. Keep this plugin on trusted local machines only.
plugins/_oauth/api/models.py new
+13
@@ -0,0 +1,13 @@
1 +from __future__ import annotations
2 +
3 +from helpers.api import ApiHandler, Request
4 +from plugins._oauth.helpers import codex
5 +
6 +
7 +class Models(ApiHandler):
8 + async def process(self, input: dict, request: Request) -> dict:
9 + try:
10 + models = codex.fetch_models()
11 + return {"ok": True, "models": models}
12 + except Exception as exc:
13 + return {"ok": False, "error": str(exc), "models": []}
plugins/_oauth/api/poll_device_login.py new
+35
@@ -0,0 +1,35 @@
1 +from __future__ import annotations
2 +
3 +from helpers.api import ApiHandler, Request
4 +from plugins._oauth.helpers import codex
5 +from plugins._oauth.helpers.state import get_device_attempt, pop_device_attempt
6 +
7 +
8 +class PollDeviceLogin(ApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict:
10 + attempt_id = str(input.get("attempt_id") or "").strip()
11 + if not attempt_id:
12 + return {"ok": False, "error": "Missing device authorization attempt."}
13 +
14 + attempt = get_device_attempt(attempt_id)
15 + if attempt is None:
16 + return {"ok": False, "expired": True, "error": "Device authorization expired."}
17 +
18 + try:
19 + result = codex.poll_device_authorization(
20 + attempt.device_auth_id,
21 + attempt.user_code,
22 + )
23 + except Exception as exc:
24 + return {"ok": False, "error": str(exc)}
25 +
26 + if result.get("completed"):
27 + pop_device_attempt(attempt_id)
28 + return {"ok": True, "completed": True, "account_id": result.get("account_id", "")}
29 +
30 + return {
31 + "ok": True,
32 + "completed": False,
33 + "interval": attempt.interval,
34 + "expires_at": attempt.expires_at,
35 + }
plugins/_oauth/api/start_device_login.py new
+36
@@ -0,0 +1,36 @@
1 +from __future__ import annotations
2 +
3 +import secrets
4 +
5 +from helpers.api import ApiHandler, Request
6 +from plugins._oauth.helpers import codex
7 +from plugins._oauth.helpers.config import codex_config
8 +from plugins._oauth.helpers.state import put_device_attempt
9 +
10 +
11 +class StartDeviceLogin(ApiHandler):
12 + async def process(self, input: dict, request: Request) -> dict:
13 + cfg = codex_config()
14 + if not cfg["enabled"]:
15 + return {"ok": False, "error": "Codex/ChatGPT account connection is disabled."}
16 +
17 + try:
18 + device = codex.request_device_code()
19 + attempt_id = secrets.token_urlsafe(24)
20 + attempt = put_device_attempt(
21 + attempt_id,
22 + device["device_auth_id"],
23 + device["user_code"],
24 + device["interval"],
25 + device["expires_at"],
26 + )
27 + return {
28 + "ok": True,
29 + "attempt_id": attempt.attempt_id,
30 + "verification_url": device["verification_url"],
31 + "user_code": attempt.user_code,
32 + "interval": attempt.interval,
33 + "expires_at": attempt.expires_at,
34 + }
35 + except Exception as exc:
36 + return {"ok": False, "error": str(exc)}
plugins/_oauth/api/start_login.py new
+54
@@ -0,0 +1,54 @@
1 +from __future__ import annotations
2 +
3 +import webbrowser
4 +
5 +from helpers.api import ApiHandler, Request
6 +from plugins._oauth.helpers import codex
7 +from plugins._oauth.helpers.config import codex_config
8 +from plugins._oauth.helpers.state import put_attempt
9 +
10 +
11 +class StartLogin(ApiHandler):
12 + async def process(self, input: dict, request: Request) -> dict:
13 + cfg = codex_config()
14 + if not cfg["enabled"]:
15 + return {"ok": False, "error": "Codex/ChatGPT account connection is disabled."}
16 +
17 + redirect_uri = _redirect_uri(request, cfg["callback_path"])
18 + pkce = codex.generate_pkce()
19 + state = codex.generate_state()
20 + attempt = put_attempt(state, pkce.verifier, redirect_uri)
21 + auth_url = codex.build_authorize_url(redirect_uri, state, pkce)
22 +
23 + if cfg["open_browser_from_server"]:
24 + try:
25 + webbrowser.open(auth_url)
26 + except Exception:
27 + pass
28 +
29 + return {
30 + "ok": True,
31 + "auth_url": auth_url,
32 + "redirect_uri": redirect_uri,
33 + "expires_at": attempt.expires_at,
34 + }
35 +
36 +
37 +def _redirect_uri(request: Request, callback_path: str) -> str:
38 + origin = (request.headers.get("Origin") or "").rstrip("/")
39 + if not _is_local_origin(origin):
40 + origin = request.url_root.rstrip("/")
41 + return f"{origin}{callback_path}"
42 +
43 +
44 +def _is_local_origin(origin: str) -> bool:
45 + if not origin:
46 + return False
47 + return (
48 + origin.startswith("http://localhost:")
49 + or origin == "http://localhost"
50 + or origin.startswith("http://127.0.0.1:")
51 + or origin == "http://127.0.0.1"
52 + or origin.startswith("http://[::1]:")
53 + or origin == "http://[::1]"
54 + )
plugins/_oauth/api/status.py new
+22
@@ -0,0 +1,22 @@
1 +from __future__ import annotations
2 +
3 +from helpers.api import ApiHandler, Request
4 +from plugins._oauth.helpers import codex
5 +from plugins._oauth.helpers.config import codex_config
6 +from plugins._oauth.helpers.route_bootstrap import is_installed
7 +
8 +
9 +class Status(ApiHandler):
10 + async def process(self, input: dict, request: Request) -> dict:
11 + cfg = codex_config()
12 + return {
13 + "ok": True,
14 + "routes_installed": is_installed(),
15 + "codex": {
16 + **codex.status(),
17 + "enabled": cfg["enabled"],
18 + "proxy_base_path": cfg["proxy_base_path"],
19 + "callback_path": cfg["callback_path"],
20 + "v1_base_path": f'{cfg["proxy_base_path"]}/v1',
21 + },
22 + }
plugins/_oauth/conf/model_providers.yaml new
+9
@@ -0,0 +1,9 @@
1 +chat:
2 + codex_oauth:
3 + name: Codex/ChatGPT Account
4 + litellm_provider: openai
5 + models_list:
6 + endpoint_url: "/models"
7 + kwargs:
8 + api_base: "http://127.0.0.1/oauth/codex/v1"
9 + api_key: "oauth"
plugins/_oauth/default_config.yaml new
+33
@@ -0,0 +1,33 @@
1 +# Generic OAuth connection settings. Only Codex is implemented for now.
2 +codex:
3 + enabled: true
4 +
5 + # Empty means auto-discover CODEX_HOME/auth.json, ~/.codex/auth.json,
6 + # CHATGPT_LOCAL_HOME/auth.json, then ~/.chatgpt-local/auth.json.
7 + auth_file_path: ""
8 +
9 + issuer: "https://auth.openai.com"
10 + token_url: "https://auth.openai.com/oauth/token"
11 + client_id: "app_EMoamEEZ73f0CkXaXp7hrann"
12 + scopes:
13 + - openid
14 + - profile
15 + - email
16 + - offline_access
17 + - api.connectors.read
18 + - api.connectors.invoke
19 +
20 + open_browser_from_server: false
21 + forced_workspace_id: ""
22 +
23 + upstream_base_url: "https://chatgpt.com/backend-api/codex"
24 + codex_version: ""
25 + models: []
26 + request_timeout_seconds: 120
27 +
28 + # The OpenAI-compatible wrapper is mounted at /oauth/codex/v1.
29 + # It is loopback-only by default and does not emit CORS headers.
30 + proxy_base_path: "/oauth/codex"
31 + callback_path: "/auth/callback"
32 + require_proxy_token: false
33 + proxy_token: ""
plugins/_oauth/extensions/python/_functions/models/get_api_key/end/_20_codex_account_dummy_key.py new
+28
@@ -0,0 +1,28 @@
1 +from __future__ import annotations
2 +
3 +from helpers.extension import Extension
4 +
5 +
6 +DUMMY_API_KEY = "oauth"
7 +PROVIDERS = {"codex_oauth"}
8 +
9 +
10 +class CodexAccountDummyKey(Extension):
11 + def execute(self, data: dict | None = None, **kwargs):
12 + if not isinstance(data, dict):
13 + return
14 +
15 + args = data.get("args")
16 + call_kwargs = data.get("kwargs")
17 + service = ""
18 + if isinstance(args, tuple) and args:
19 + service = str(args[0] or "")
20 + elif isinstance(call_kwargs, dict):
21 + service = str(call_kwargs.get("service") or "")
22 +
23 + if service.lower() not in PROVIDERS:
24 + return
25 +
26 + result = str(data.get("result") or "").strip()
27 + if not result or result == "None":
28 + data["result"] = DUMMY_API_KEY
plugins/_oauth/extensions/python/startup_migration/_20_oauth_routes.py new
+9
@@ -0,0 +1,9 @@
1 +from __future__ import annotations
2 +
3 +from helpers.extension import Extension
4 +from plugins._oauth.helpers.route_bootstrap import install_route_hooks
5 +
6 +
7 +class OAuthRoutesStartup(Extension):
8 + def execute(self, **kwargs):
9 + install_route_hooks()
plugins/_oauth/helpers/__init__.py new
+1
@@ -0,0 +1 @@
1 +"""OAuth Connections plugin helpers."""
plugins/_oauth/helpers/codex.py new
+859
@@ -0,0 +1,859 @@
1 +from __future__ import annotations
2 +
3 +import base64
4 +import hashlib
5 +import json
6 +import os
7 +import secrets
8 +import subprocess
9 +import time
10 +from dataclasses import dataclass
11 +from datetime import datetime, timedelta, timezone
12 +from pathlib import Path
13 +from typing import Any, Iterable
14 +from urllib.parse import parse_qs, urlencode, urljoin, urlparse
15 +
16 +import requests
17 +
18 +from helpers import files
19 +from plugins._oauth.helpers.config import codex_config
20 +
21 +
22 +AUTH_FILENAME = "auth.json"
23 +ACCESS_EXPIRY_MARGIN = timedelta(minutes=5)
24 +REFRESH_INTERVAL = timedelta(minutes=55)
25 +FALLBACK_CODEX_VERSION = "0.124.0"
26 +OAUTH_ERROR_KEYS = {"error", "error_description"}
27 +DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60
28 +
29 +
30 +@dataclass(frozen=True)
31 +class PkcePair:
32 + verifier: str
33 + challenge: str
34 +
35 +
36 +@dataclass(frozen=True)
37 +class EffectiveAuth:
38 + access_token: str
39 + account_id: str
40 + id_token: str = ""
41 + refresh_token: str = ""
42 + source_path: str = ""
43 + last_refresh: str = ""
44 +
45 +
46 +def generate_pkce() -> PkcePair:
47 + verifier = _base64url(secrets.token_bytes(64))
48 + challenge = _base64url(hashlib.sha256(verifier.encode("utf-8")).digest())
49 + return PkcePair(verifier=verifier, challenge=challenge)
50 +
51 +
52 +def generate_state() -> str:
53 + return _base64url(secrets.token_bytes(32))
54 +
55 +
56 +def build_authorize_url(redirect_uri: str, state: str, pkce: PkcePair) -> str:
57 + cfg = codex_config()
58 + query = {
59 + "response_type": "code",
60 + "client_id": cfg["client_id"],
61 + "redirect_uri": redirect_uri,
62 + "scope": " ".join(cfg["scopes"]),
63 + "code_challenge": pkce.challenge,
64 + "code_challenge_method": "S256",
65 + "id_token_add_organizations": "true",
66 + "codex_cli_simplified_flow": "true",
67 + "state": state,
68 + "originator": "codex_cli_rs",
69 + }
70 + if cfg["forced_workspace_id"]:
71 + query["allowed_workspace_id"] = cfg["forced_workspace_id"]
72 +
73 + return f'{cfg["issuer"]}/oauth/authorize?{urlencode(query)}'
74 +
75 +
76 +def exchange_code_for_tokens(
77 + code: str,
78 + redirect_uri: str,
79 + verifier: str,
80 +) -> dict[str, str]:
81 + cfg = codex_config()
82 + response = requests.post(
83 + cfg["token_url"],
84 + headers={"Content-Type": "application/x-www-form-urlencoded"},
85 + data={
86 + "grant_type": "authorization_code",
87 + "code": code,
88 + "redirect_uri": redirect_uri,
89 + "client_id": cfg["client_id"],
90 + "code_verifier": verifier,
91 + },
92 + timeout=30,
93 + )
94 + if not response.ok:
95 + raise RuntimeError(_token_error_message(response))
96 +
97 + payload = response.json()
98 + if not isinstance(payload, dict):
99 + raise RuntimeError("OAuth token endpoint returned a malformed response.")
100 +
101 + tokens = {
102 + "id_token": str(payload.get("id_token") or ""),
103 + "access_token": str(payload.get("access_token") or ""),
104 + "refresh_token": str(payload.get("refresh_token") or ""),
105 + }
106 + missing = [key for key, value in tokens.items() if not value]
107 + if missing:
108 + raise RuntimeError(f"OAuth token response is missing: {', '.join(missing)}")
109 +
110 + return tokens
111 +
112 +
113 +def obtain_api_key(id_token: str) -> str:
114 + cfg = codex_config()
115 + response = requests.post(
116 + cfg["token_url"],
117 + headers={"Content-Type": "application/x-www-form-urlencoded"},
118 + data={
119 + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
120 + "client_id": cfg["client_id"],
121 + "requested_token": "openai-api-key",
122 + "subject_token": id_token,
123 + "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
124 + },
125 + timeout=30,
126 + )
127 + if not response.ok:
128 + raise RuntimeError(f"API-key token exchange failed with status {response.status_code}.")
129 + payload = response.json()
130 + if not isinstance(payload, dict) or not payload.get("access_token"):
131 + raise RuntimeError("API-key token exchange returned a malformed response.")
132 + return str(payload["access_token"])
133 +
134 +
135 +def complete_login(code: str, redirect_uri: str, verifier: str) -> EffectiveAuth:
136 + tokens = exchange_code_for_tokens(code, redirect_uri, verifier)
137 + return persist_exchanged_tokens(tokens)
138 +
139 +
140 +def persist_exchanged_tokens(tokens: dict[str, str]) -> EffectiveAuth:
141 + id_token = tokens["id_token"]
142 + account_id = derive_account_id(id_token)
143 + if not account_id:
144 + raise RuntimeError("OAuth ID token did not include a ChatGPT account id.")
145 +
146 + cfg = codex_config()
147 + if cfg["forced_workspace_id"] and account_id != cfg["forced_workspace_id"]:
148 + raise RuntimeError(
149 + f'Login is restricted to workspace id {cfg["forced_workspace_id"]}.'
150 + )
151 +
152 + try:
153 + api_key = obtain_api_key(id_token)
154 + except Exception:
155 + api_key = ""
156 +
157 + auth_data = {
158 + "auth_mode": "chatgpt",
159 + "OPENAI_API_KEY": api_key or None,
160 + "tokens": {
161 + "id_token": id_token,
162 + "access_token": tokens["access_token"],
163 + "refresh_token": tokens["refresh_token"],
164 + "account_id": account_id,
165 + },
166 + "last_refresh": utc_now_iso(),
167 + }
168 + path = resolve_auth_write_path()
169 + write_auth_file(path, auth_data)
170 + return load_auth(ensure_fresh=False)
171 +
172 +
173 +def request_device_code() -> dict[str, Any]:
174 + cfg = codex_config()
175 + base_url = cfg["issuer"].rstrip("/")
176 + response = requests.post(
177 + f"{base_url}/api/accounts/deviceauth/usercode",
178 + headers={"Content-Type": "application/json"},
179 + json={"client_id": cfg["client_id"]},
180 + timeout=30,
181 + )
182 + if not response.ok:
183 + raise RuntimeError(_token_error_message(response))
184 +
185 + payload = response.json()
186 + if not isinstance(payload, dict):
187 + raise RuntimeError("Device authorization returned a malformed response.")
188 +
189 + device_auth_id = _string(payload.get("device_auth_id"))
190 + user_code = _string(payload.get("user_code") or payload.get("usercode"))
191 + if not device_auth_id or not user_code:
192 + raise RuntimeError("Device authorization response did not include a code.")
193 +
194 + interval = _safe_int(payload.get("interval"), 5)
195 + expires_at = _device_expires_at(payload.get("expires_at"))
196 + return {
197 + "device_auth_id": device_auth_id,
198 + "user_code": user_code,
199 + "interval": interval,
200 + "expires_at": expires_at,
201 + "verification_url": f"{base_url}/codex/device",
202 + }
203 +
204 +
205 +def poll_device_authorization(device_auth_id: str, user_code: str) -> dict[str, Any]:
206 + cfg = codex_config()
207 + base_url = cfg["issuer"].rstrip("/")
208 + response = requests.post(
209 + f"{base_url}/api/accounts/deviceauth/token",
210 + headers={"Content-Type": "application/json"},
211 + json={"device_auth_id": device_auth_id, "user_code": user_code},
212 + timeout=30,
213 + )
214 +
215 + if response.status_code in {403, 404}:
216 + return {"completed": False}
217 + if not response.ok:
218 + raise RuntimeError(_token_error_message(response))
219 +
220 + payload = response.json()
221 + if not isinstance(payload, dict):
222 + raise RuntimeError("Device authorization token response was malformed.")
223 + authorization_code = _string(payload.get("authorization_code"))
224 + verifier = _string(payload.get("code_verifier"))
225 + if not authorization_code or not verifier:
226 + raise RuntimeError("Device authorization response was missing token exchange data.")
227 +
228 + tokens = exchange_code_for_tokens(
229 + authorization_code,
230 + f"{base_url}/deviceauth/callback",
231 + verifier,
232 + )
233 + auth = persist_exchanged_tokens(tokens)
234 + return {"completed": True, "account_id": auth.account_id}
235 +
236 +
237 +def load_auth(*, ensure_fresh: bool = True) -> EffectiveAuth:
238 + path, data = read_auth_file()
239 + tokens = data.get("tokens") if isinstance(data, dict) else {}
240 + tokens = tokens if isinstance(tokens, dict) else {}
241 +
242 + access_token = _string(tokens.get("access_token"))
243 + id_token = _string(tokens.get("id_token"))
244 + refresh_token = _string(tokens.get("refresh_token"))
245 + account_id = _string(tokens.get("account_id")) or derive_account_id(id_token)
246 + last_refresh = _string(data.get("last_refresh")) if isinstance(data, dict) else ""
247 +
248 + if ensure_fresh and refresh_token and should_refresh(access_token, last_refresh):
249 + refreshed = refresh_tokens(refresh_token)
250 + access_token = refreshed.get("access_token") or access_token
251 + id_token = refreshed.get("id_token") or id_token
252 + refresh_token = refreshed.get("refresh_token") or refresh_token
253 + account_id = derive_account_id(id_token) or account_id
254 + last_refresh = utc_now_iso()
255 + data["tokens"] = {
256 + "id_token": id_token,
257 + "access_token": access_token,
258 + "refresh_token": refresh_token,
259 + "account_id": account_id,
260 + }
261 + data["last_refresh"] = last_refresh
262 + write_auth_file(path, data)
263 +
264 + if not access_token:
265 + raise RuntimeError("Codex/ChatGPT account access token not found. Connect the account first.")
266 + if not account_id:
267 + raise RuntimeError("Codex/ChatGPT account id not found. Connect the account again.")
268 +
269 + return EffectiveAuth(
270 + access_token=access_token,
271 + account_id=account_id,
272 + id_token=id_token,
273 + refresh_token=refresh_token,
274 + source_path=str(path),
275 + last_refresh=last_refresh,
276 + )
277 +
278 +
279 +def status() -> dict[str, Any]:
280 + candidates = resolve_auth_file_candidates()
281 + existing = [str(path) for path in candidates if path.is_file()]
282 + result: dict[str, Any] = {
283 + "connected": False,
284 + "auth_file_path": str(resolve_auth_write_path()),
285 + "discovered_auth_files": existing,
286 + }
287 + try:
288 + auth = load_auth(ensure_fresh=False)
289 + except Exception as exc:
290 + result["message"] = str(exc)
291 + return result
292 +
293 + id_claims = parse_jwt_claims(auth.id_token)
294 + access_claims = parse_jwt_claims(auth.access_token)
295 + auth_claims = _auth_claims(id_claims)
296 + result.update(
297 + {
298 + "connected": True,
299 + "auth_file_path": auth.source_path,
300 + "account_id": auth.account_id,
301 + "email": id_claims.get("email")
302 + or _record(id_claims.get("https://api.openai.com/profile")).get("email"),
303 + "plan_type": auth_claims.get("chatgpt_plan_type"),
304 + "user_id": auth_claims.get("chatgpt_user_id") or auth_claims.get("user_id"),
305 + "access_expires_at": _jwt_expiration_iso(access_claims),
306 + "last_refresh": auth.last_refresh,
307 + }
308 + )
309 + return result
310 +
311 +
312 +def refresh_tokens(refresh_token: str) -> dict[str, str]:
313 + cfg = codex_config()
314 + response = requests.post(
315 + cfg["token_url"],
316 + headers={"Content-Type": "application/json"},
317 + json={
318 + "client_id": cfg["client_id"],
319 + "grant_type": "refresh_token",
320 + "refresh_token": refresh_token,
321 + },
322 + timeout=30,
323 + )
324 + if not response.ok:
325 + raise RuntimeError(_token_error_message(response))
326 +
327 + payload = response.json()
328 + if not isinstance(payload, dict):
329 + raise RuntimeError("OAuth refresh endpoint returned a malformed response.")
330 +
331 + return {
332 + "id_token": _string(payload.get("id_token")),
333 + "access_token": _string(payload.get("access_token")),
334 + "refresh_token": _string(payload.get("refresh_token")) or refresh_token,
335 + }
336 +
337 +
338 +def should_refresh(access_token: str, last_refresh: str) -> bool:
339 + if not access_token:
340 + return True
341 +
342 + claims = parse_jwt_claims(access_token)
343 + exp = claims.get("exp")
344 + if isinstance(exp, (int, float)):
345 + expires_at = datetime.fromtimestamp(float(exp), tz=timezone.utc)
346 + if expires_at <= datetime.now(timezone.utc) + ACCESS_EXPIRY_MARGIN:
347 + return True
348 +
349 + refreshed_at = parse_iso(last_refresh)
350 + if refreshed_at is not None:
351 + return refreshed_at <= datetime.now(timezone.utc) - REFRESH_INTERVAL
352 + return False
353 +
354 +
355 +def request_codex(
356 + path: str,
357 + *,
358 + method: str = "GET",
359 + headers: dict[str, str] | None = None,
360 + body: bytes | str | None = None,
361 + stream: bool = False,
362 + params: dict[str, str] | None = None,
363 +) -> requests.Response:
364 + cfg = codex_config()
365 + auth = load_auth()
366 + target = build_upstream_url(path, cfg["upstream_base_url"])
367 + request_headers = sanitize_forward_headers(headers or {})
368 + request_headers.update(
369 + {
370 + "Authorization": f"Bearer {auth.access_token}",
371 + "chatgpt-account-id": auth.account_id,
372 + "OpenAI-Beta": "responses=experimental",
373 + }
374 + )
375 +
376 + return requests.request(
377 + method,
378 + target,
379 + headers=request_headers,
380 + data=body,
381 + params=params,
382 + timeout=max(5, cfg["request_timeout_seconds"]),
383 + stream=stream,
384 + )
385 +
386 +
387 +def fetch_models() -> list[str]:
388 + cfg = codex_config()
389 + configured = cfg["models"]
390 + if configured:
391 + return configured
392 +
393 + response = request_codex(
394 + "/models",
395 + params={"client_version": resolve_codex_version()},
396 + )
397 + if not response.ok:
398 + raise RuntimeError(upstream_error_message(response, "Failed to load Codex models."))
399 +
400 + payload = response.json()
401 + raw_models = payload.get("models") if isinstance(payload, dict) else None
402 + if not isinstance(raw_models, list):
403 + raise RuntimeError("Codex returned a malformed models response.")
404 +
405 + models: list[str] = []
406 + seen: set[str] = set()
407 + for item in raw_models:
408 + slug = item.get("slug") if isinstance(item, dict) else None
409 + if isinstance(slug, str) and slug and slug not in seen:
410 + seen.add(slug)
411 + models.append(slug)
412 + if not models:
413 + raise RuntimeError("Codex returned an empty models list.")
414 + return models
415 +
416 +
417 +def prepare_responses_body(body: dict[str, Any], *, force_stream: bool) -> dict[str, Any]:
418 + normalized = dict(body)
419 + normalized.setdefault("instructions", "")
420 + normalized.setdefault("store", False)
421 + if force_stream:
422 + normalized["stream"] = True
423 + normalized.pop("max_output_tokens", None)
424 + return normalized
425 +
426 +
427 +def collect_completed_response(response: requests.Response) -> dict[str, Any]:
428 + latest_response: dict[str, Any] | None = None
429 + latest_error: Any = None
430 + text_pieces: list[str] = []
431 + latest_usage: dict[str, Any] | None = None
432 + for event in iter_sse_events(response):
433 + data = event.get("data")
434 + if not data:
435 + continue
436 + try:
437 + parsed = json.loads(data)
438 + except json.JSONDecodeError:
439 + continue
440 + if not isinstance(parsed, dict):
441 + continue
442 + if event.get("event") == "error":
443 + latest_error = parsed
444 + continue
445 + text_pieces.extend(extract_sse_text_deltas(parsed, event.get("event", "")))
446 + usage = parsed.get("usage")
447 + if isinstance(usage, dict):
448 + latest_usage = usage
449 + candidate = parsed.get("response")
450 + if isinstance(candidate, dict):
451 + latest_response = candidate
452 +
453 + if latest_response is not None:
454 + return latest_response
455 + if text_pieces:
456 + result: dict[str, Any] = {"output_text": "".join(text_pieces)}
457 + if latest_usage:
458 + result["usage"] = latest_usage
459 + return result
460 + suffix = f" Last error: {json.dumps(latest_error)}" if latest_error else ""
461 + raise RuntimeError(f"No completed response found in Codex SSE stream.{suffix}")
462 +
463 +
464 +def iter_sse_events(response: requests.Response) -> Iterable[dict[str, str]]:
465 + buffer = ""
466 + for chunk in response.iter_content(chunk_size=8192, decode_unicode=True):
467 + if not chunk:
468 + continue
469 + buffer += chunk
470 + while "\n\n" in buffer or "\r\n\r\n" in buffer:
471 + sep = "\r\n\r\n" if "\r\n\r\n" in buffer else "\n\n"
472 + block, buffer = buffer.split(sep, 1)
473 + event = parse_sse_block(block)
474 + if event:
475 + yield event
476 + event = parse_sse_block(buffer)
477 + if event:
478 + yield event
479 +
480 +
481 +def parse_sse_block(block: str) -> dict[str, str]:
482 + event: dict[str, str] = {}
483 + data_lines: list[str] = []
484 + for line in block.splitlines():
485 + if line.startswith("event:"):
486 + event["event"] = line[6:].strip()
487 + elif line.startswith("data:"):
488 + data_lines.append(line[5:].lstrip())
489 + if data_lines:
490 + event["data"] = "\n".join(data_lines)
491 + return event
492 +
493 +
494 +def extract_sse_text_deltas(payload: dict[str, Any], event_type: str = "") -> list[str]:
495 + pieces: list[str] = []
496 +
497 + choices = payload.get("choices")
498 + if isinstance(choices, list):
499 + for choice in choices:
500 + if not isinstance(choice, dict):
501 + continue
502 + delta = choice.get("delta")
503 + if isinstance(delta, dict):
504 + _append_text_value(pieces, delta.get("content"))
505 + elif isinstance(delta, str):
506 + pieces.append(delta)
507 +
508 + message = choice.get("message")
509 + if isinstance(message, dict):
510 + _append_text_value(pieces, message.get("content"))
511 +
512 + delta = payload.get("delta")
513 + if isinstance(delta, str):
514 + pieces.append(delta)
515 + elif isinstance(delta, dict):
516 + _append_text_value(pieces, delta.get("content"))
517 + _append_text_value(pieces, delta.get("text"))
518 +
519 + if (payload.get("type") or event_type) in {
520 + "response.output_text.delta",
521 + "response.text.delta",
522 + }:
523 + _append_text_value(pieces, payload.get("text"))
524 +
525 + return [piece for piece in pieces if piece]
526 +
527 +
528 +def _append_text_value(pieces: list[str], value: Any) -> None:
529 + if isinstance(value, str):
530 + pieces.append(value)
531 + return
532 + if isinstance(value, list):
533 + for item in value:
534 + if isinstance(item, str):
535 + pieces.append(item)
536 + elif isinstance(item, dict):
537 + _append_text_value(pieces, item.get("text"))
538 + _append_text_value(pieces, item.get("content"))
539 +
540 +
541 +def chat_messages_to_response_body(body: dict[str, Any]) -> dict[str, Any]:
542 + messages = body.get("messages")
543 + if not isinstance(messages, list):
544 + raise RuntimeError("`messages` must be an array.")
545 + if body.get("tools"):
546 + raise RuntimeError("Codex/ChatGPT account wrapper does not yet support tool calls.")
547 +
548 + instructions: list[str] = []
549 + response_input: list[dict[str, Any]] = []
550 + for message in messages:
551 + if not isinstance(message, dict):
552 + continue
553 + role = str(message.get("role") or "user")
554 + content = message.get("content", "")
555 + text = normalize_message_content(content)
556 + if role in {"system", "developer"}:
557 + if text:
558 + instructions.append(text)
559 + continue
560 + response_input.append({"role": role, "content": text})
561 +
562 + response_body: dict[str, Any] = {
563 + "model": body.get("model") or "gpt-5.2",
564 + "input": response_input,
565 + "instructions": "\n\n".join(instructions),
566 + "store": False,
567 + }
568 + if body.get("temperature") is not None:
569 + response_body["temperature"] = body["temperature"]
570 + if body.get("top_p") is not None:
571 + response_body["top_p"] = body["top_p"]
572 + if body.get("reasoning_effort") is not None:
573 + response_body["reasoning"] = {"effort": body["reasoning_effort"]}
574 + return response_body
575 +
576 +
577 +def normalize_message_content(content: Any) -> str:
578 + if isinstance(content, str):
579 + return content
580 + if isinstance(content, list):
581 + parts: list[str] = []
582 + for item in content:
583 + if isinstance(item, dict):
584 + text = item.get("text")
585 + if isinstance(text, str):
586 + parts.append(text)
587 + elif isinstance(item, str):
588 + parts.append(item)
589 + return "\n".join(parts)
590 + if content is None:
591 + return ""
592 + return str(content)
593 +
594 +
595 +def response_text(response: dict[str, Any]) -> str:
596 + value = response.get("output_text")
597 + if isinstance(value, str):
598 + return value
599 +
600 + pieces: list[str] = []
601 + output = response.get("output")
602 + if isinstance(output, list):
603 + for item in output:
604 + if not isinstance(item, dict):
605 + continue
606 + content = item.get("content")
607 + if isinstance(content, list):
608 + for block in content:
609 + if isinstance(block, dict):
610 + text = block.get("text")
611 + if isinstance(text, str):
612 + pieces.append(text)
613 + return "".join(pieces)
614 +
615 +
616 +def build_upstream_url(path: str, base_url: str) -> str:
617 + if path.startswith("http://") or path.startswith("https://"):
618 + parsed = urlparse(path)
619 + path = parsed.path
620 + if parsed.query:
621 + path = f"{path}?{parsed.query}"
622 + if path == "/v1":
623 + path = "/"
624 + elif path.startswith("/v1/"):
625 + path = path[3:]
626 + return urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
627 +
628 +
629 +def sanitize_forward_headers(headers: dict[str, str]) -> dict[str, str]:
630 + blocked = {
631 + "authorization",
632 + "chatgpt-account-id",
633 + "host",
634 + "openai-beta",
635 + "content-length",
636 + "connection",
637 + }
638 + return {
639 + key: value
640 + for key, value in headers.items()
641 + if key.lower() not in blocked and value is not None
642 + }
643 +
644 +
645 +def response_headers(response: requests.Response) -> dict[str, str]:
646 + blocked = {
647 + "connection",
648 + "content-encoding",
649 + "content-length",
650 + "transfer-encoding",
651 + }
652 + return {
653 + key: value
654 + for key, value in response.headers.items()
655 + if key.lower() not in blocked
656 + }
657 +
658 +
659 +def upstream_error_message(response: requests.Response, fallback: str) -> str:
660 + text = response.text
661 + if not text:
662 + return fallback
663 + try:
664 + payload = json.loads(text)
665 + except json.JSONDecodeError:
666 + return text
667 + if isinstance(payload, dict):
668 + detail = payload.get("detail")
669 + if isinstance(detail, str):
670 + return detail
671 + error = payload.get("error")
672 + if isinstance(error, dict) and isinstance(error.get("message"), str):
673 + return error["message"]
674 + if isinstance(error, str):
675 + return error
676 + return text
677 +
678 +
679 +def resolve_codex_version() -> str:
680 + configured = codex_config()["codex_version"]
681 + if configured:
682 + return configured
683 + try:
684 + result = subprocess.run(
685 + ["codex", "--version"],
686 + check=False,
687 + capture_output=True,
688 + text=True,
689 + timeout=2,
690 + )
691 + version = _extract_semver(result.stdout) or _extract_semver(result.stderr)
692 + if version:
693 + return version
694 + except Exception:
695 + pass
696 + return FALLBACK_CODEX_VERSION
697 +
698 +
699 +def resolve_auth_file_candidates() -> list[Path]:
700 + cfg = codex_config()
701 + explicit = cfg["auth_file_path"]
702 + if explicit:
703 + return [Path(explicit).expanduser()]
704 +
705 + candidates: list[Path] = []
706 + for env_name in ("CHATGPT_LOCAL_HOME", "CODEX_HOME"):
707 + env_home = os.getenv(env_name)
708 + if env_home:
709 + candidates.append(Path(env_home).expanduser() / AUTH_FILENAME)
710 +
711 + home = Path.home()
712 + candidates.extend(
713 + [
714 + home / ".codex" / AUTH_FILENAME,
715 + home / ".chatgpt-local" / AUTH_FILENAME,
716 + Path(files.get_abs_path("usr", "plugins", "_oauth", "codex", AUTH_FILENAME)),
717 + ]
718 + )
719 + return _unique_paths(candidates)
720 +
721 +
722 +def resolve_auth_write_path() -> Path:
723 + for candidate in resolve_auth_file_candidates():
724 + if candidate.is_file():
725 + return candidate
726 + return resolve_auth_file_candidates()[-1]
727 +
728 +
729 +def read_auth_file() -> tuple[Path, dict[str, Any]]:
730 + candidates = resolve_auth_file_candidates()
731 + for candidate in candidates:
732 + try:
733 + with candidate.open("r", encoding="utf-8") as handle:
734 + payload = json.load(handle)
735 + if isinstance(payload, dict):
736 + return candidate, payload
737 + except FileNotFoundError:
738 + continue
739 + except Exception:
740 + continue
741 + return resolve_auth_write_path(), {}
742 +
743 +
744 +def write_auth_file(path: Path, data: dict[str, Any]) -> None:
745 + path.parent.mkdir(parents=True, exist_ok=True)
746 + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
747 + try:
748 + path.chmod(0o600)
749 + except OSError:
750 + pass
751 +
752 +
753 +def parse_jwt_claims(token: str) -> dict[str, Any]:
754 + if not token or token.count(".") != 2:
755 + return {}
756 + try:
757 + payload = token.split(".")[1]
758 + padding = "=" * ((4 - len(payload) % 4) % 4)
759 + decoded = base64.urlsafe_b64decode((payload + padding).encode("ascii"))
760 + value = json.loads(decoded)
761 + return value if isinstance(value, dict) else {}
762 + except Exception:
763 + return {}
764 +
765 +
766 +def derive_account_id(id_token: str) -> str:
767 + return _string(_auth_claims(parse_jwt_claims(id_token)).get("chatgpt_account_id"))
768 +
769 +
770 +def parse_iso(value: str) -> datetime | None:
771 + if not value:
772 + return None
773 + normalized = value.replace("Z", "+00:00")
774 + try:
775 + parsed = datetime.fromisoformat(normalized)
776 + except ValueError:
777 + return None
778 + if parsed.tzinfo is None:
779 + parsed = parsed.replace(tzinfo=timezone.utc)
780 + return parsed.astimezone(timezone.utc)
781 +
782 +
783 +def utc_now_iso() -> str:
784 + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
785 +
786 +
787 +def _auth_claims(claims: dict[str, Any]) -> dict[str, Any]:
788 + return _record(claims.get("https://api.openai.com/auth"))
789 +
790 +
791 +def _record(value: Any) -> dict[str, Any]:
792 + return value if isinstance(value, dict) else {}
793 +
794 +
795 +def _string(value: Any) -> str:
796 + return value if isinstance(value, str) else ""
797 +
798 +
799 +def _jwt_expiration_iso(claims: dict[str, Any]) -> str:
800 + exp = claims.get("exp")
801 + if not isinstance(exp, (int, float)):
802 + return ""
803 + return datetime.fromtimestamp(float(exp), tz=timezone.utc).isoformat()
804 +
805 +
806 +def _base64url(data: bytes) -> str:
807 + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
808 +
809 +
810 +def _token_error_message(response: requests.Response) -> str:
811 + try:
812 + payload = response.json()
813 + except Exception:
814 + payload = None
815 + if isinstance(payload, dict):
816 + for key in OAUTH_ERROR_KEYS:
817 + value = payload.get(key)
818 + if isinstance(value, str) and value:
819 + return value
820 + error = payload.get("error")
821 + if isinstance(error, dict) and isinstance(error.get("message"), str):
822 + return error["message"]
823 + if isinstance(error, str):
824 + return error
825 + return f"OAuth token endpoint returned status {response.status_code}: {response.text}"
826 +
827 +
828 +def _extract_semver(value: str) -> str:
829 + import re
830 +
831 + match = re.search(r"\b\d+\.\d+\.\d+\b", value or "")
832 + return match.group(0) if match else ""
833 +
834 +
835 +def _safe_int(value: Any, default: int) -> int:
836 + try:
837 + return int(value)
838 + except (TypeError, ValueError):
839 + return default
840 +
841 +
842 +def _device_expires_at(value: Any) -> float:
843 + if isinstance(value, str):
844 + parsed = parse_iso(value)
845 + if parsed is not None:
846 + return parsed.timestamp()
847 + return time.time() + DEVICE_CODE_TIMEOUT_SECONDS
848 +
849 +
850 +def _unique_paths(paths: list[Path]) -> list[Path]:
851 + result: list[Path] = []
852 + seen: set[str] = set()
853 + for path in paths:
854 + key = str(path)
855 + if key in seen:
856 + continue
857 + seen.add(key)
858 + result.append(path)
859 + return result
plugins/_oauth/helpers/config.py new
+112
@@ -0,0 +1,112 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from helpers import plugins
6 +
7 +
8 +PLUGIN_NAME = "_oauth"
9 +
10 +DEFAULT_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
11 +DEFAULT_CODEX_ISSUER = "https://auth.openai.com"
12 +DEFAULT_CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token"
13 +DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
14 +DEFAULT_CODEX_SCOPES = [
15 + "openid",
16 + "profile",
17 + "email",
18 + "offline_access",
19 + "api.connectors.read",
20 + "api.connectors.invoke",
21 +]
22 +
23 +
24 +def oauth_config() -> dict[str, Any]:
25 + value = plugins.get_plugin_config(PLUGIN_NAME) or {}
26 + return value if isinstance(value, dict) else {}
27 +
28 +
29 +def codex_config(config: dict[str, Any] | None = None) -> dict[str, Any]:
30 + source = config if isinstance(config, dict) else oauth_config()
31 + raw = source.get("codex", {}) if isinstance(source, dict) else {}
32 + raw = raw if isinstance(raw, dict) else {}
33 +
34 + return {
35 + "enabled": _as_bool(raw.get("enabled"), True),
36 + "auth_file_path": _as_str(raw.get("auth_file_path")),
37 + "issuer": _trim_url(raw.get("issuer"), DEFAULT_CODEX_ISSUER),
38 + "token_url": _as_str(raw.get("token_url")) or DEFAULT_CODEX_TOKEN_URL,
39 + "client_id": _as_str(raw.get("client_id")) or DEFAULT_CODEX_CLIENT_ID,
40 + "scopes": _as_str_list(raw.get("scopes")) or DEFAULT_CODEX_SCOPES,
41 + "open_browser_from_server": _as_bool(raw.get("open_browser_from_server"), False),
42 + "forced_workspace_id": _as_str(raw.get("forced_workspace_id")),
43 + "upstream_base_url": _trim_url(raw.get("upstream_base_url"), DEFAULT_CODEX_BASE_URL),
44 + "codex_version": _as_str(raw.get("codex_version")),
45 + "models": _as_str_list(raw.get("models")),
46 + "request_timeout_seconds": _as_int(raw.get("request_timeout_seconds"), 120),
47 + "proxy_base_path": _normalize_base_path(raw.get("proxy_base_path"), "/oauth/codex"),
48 + "callback_path": _normalize_base_path(raw.get("callback_path"), "/auth/callback"),
49 + "require_proxy_token": _as_bool(raw.get("require_proxy_token"), False),
50 + "proxy_token": _as_str(raw.get("proxy_token")),
51 + }
52 +
53 +
54 +def _as_str(value: Any) -> str:
55 + if value is None:
56 + return ""
57 + return str(value).strip()
58 +
59 +
60 +def _as_int(value: Any, default: int) -> int:
61 + try:
62 + return int(value)
63 + except (TypeError, ValueError):
64 + return default
65 +
66 +
67 +def _as_bool(value: Any, default: bool) -> bool:
68 + if value is None or value == "":
69 + return default
70 + if isinstance(value, bool):
71 + return value
72 + if isinstance(value, (int, float)):
73 + return bool(value)
74 + normalized = str(value).strip().lower()
75 + if normalized in {"1", "true", "yes", "on", "enabled"}:
76 + return True
77 + if normalized in {"0", "false", "no", "off", "disabled"}:
78 + return False
79 + return default
80 +
81 +
82 +def _as_str_list(value: Any) -> list[str]:
83 + if value is None:
84 + return []
85 + if isinstance(value, str):
86 + values = value.replace(",", "\n").splitlines()
87 + elif isinstance(value, (list, tuple, set)):
88 + values = list(value)
89 + else:
90 + values = [value]
91 +
92 + result: list[str] = []
93 + seen: set[str] = set()
94 + for item in values:
95 + text = _as_str(item)
96 + if not text or text in seen:
97 + continue
98 + seen.add(text)
99 + result.append(text)
100 + return result
101 +
102 +
103 +def _trim_url(value: Any, default: str) -> str:
104 + text = _as_str(value) or default
105 + return text.rstrip("/")
106 +
107 +
108 +def _normalize_base_path(value: Any, default: str) -> str:
109 + text = _as_str(value) or default
110 + if not text.startswith("/"):
111 + text = "/" + text
112 + return text.rstrip("/") or default
plugins/_oauth/helpers/route_bootstrap.py new
+26
@@ -0,0 +1,26 @@
1 +from __future__ import annotations
2 +
3 +
4 +def install_route_hooks() -> None:
5 + from helpers.ui_server import UiServerRuntime
6 +
7 + if getattr(UiServerRuntime, "_a0_oauth_route_hooks_installed", False):
8 + return
9 +
10 + original_register_http_routes = UiServerRuntime.register_http_routes
11 +
12 + def register_http_routes(self):
13 + result = original_register_http_routes(self)
14 + from plugins._oauth.helpers.routes import register_oauth_routes
15 +
16 + register_oauth_routes(self.webapp)
17 + return result
18 +
19 + UiServerRuntime.register_http_routes = register_http_routes
20 + UiServerRuntime._a0_oauth_route_hooks_installed = True
21 +
22 +
23 +def is_installed() -> bool:
24 + from helpers.ui_server import UiServerRuntime
25 +
26 + return bool(getattr(UiServerRuntime, "_a0_oauth_route_hooks_installed", False))
plugins/_oauth/helpers/routes.py new
+372
@@ -0,0 +1,372 @@
1 +from __future__ import annotations
2 +
3 +import ipaddress
4 +import json
5 +import time
6 +from typing import Any
7 +
8 +from flask import Response, jsonify, request, stream_with_context
9 +
10 +from plugins._oauth.helpers import codex
11 +from plugins._oauth.helpers.config import codex_config
12 +from plugins._oauth.helpers.state import pop_attempt
13 +
14 +
15 +def register_oauth_routes(app) -> None:
16 + cfg = codex_config()
17 + base = cfg["proxy_base_path"]
18 +
19 + routes = [
20 + (f"{base}/health", "oauth_codex_health", codex_health, ["GET"]),
21 + (f"{base}/callback", "oauth_codex_callback", codex_callback, ["GET"]),
22 + (cfg["callback_path"], "oauth_codex_compat_callback", codex_callback, ["GET"]),
23 + (f"{base}/v1/models", "oauth_codex_models", codex_models, ["GET", "OPTIONS"]),
24 + (
25 + f"{base}/v1/responses",
26 + "oauth_codex_responses",
27 + codex_responses,
28 + ["POST", "OPTIONS"],
29 + ),
30 + (
31 + f"{base}/v1/chat/completions",
32 + "oauth_codex_chat_completions",
33 + codex_chat_completions,
34 + ["POST", "OPTIONS"],
35 + ),
36 + ]
37 + for rule, endpoint, view_func, methods in routes:
38 + if endpoint in app.view_functions:
39 + continue
40 + app.add_url_rule(rule, endpoint, view_func, methods=methods)
41 +
42 +
43 +def codex_health():
44 + return jsonify({"ok": True, "provider": "codex", "base_path": codex_config()["proxy_base_path"]})
45 +
46 +
47 +def codex_callback():
48 + error = request.args.get("error")
49 + if error:
50 + description = request.args.get("error_description") or error
51 + return _html_page("Codex Sign-In Failed", description), 400
52 +
53 + state = request.args.get("state", "")
54 + code = request.args.get("code", "")
55 + attempt = pop_attempt(state)
56 + if not attempt:
57 + return _html_page("Codex Sign-In Expired", "Return to Agent Zero and start a new Codex connection."), 400
58 + if not code:
59 + return _html_page("Codex Sign-In Failed", "The OAuth callback did not include an authorization code."), 400
60 +
61 + try:
62 + auth = codex.complete_login(code, attempt.redirect_uri, attempt.verifier)
63 + info = codex.status()
64 + except Exception as exc:
65 + return _html_page("Codex Sign-In Failed", str(exc)), 500
66 +
67 + email = info.get("email") or "Connected"
68 + detail = f"{email}\n{auth.account_id}"
69 + return _html_page("Codex Connected", detail)
70 +
71 +
72 +def codex_models():
73 + if request.method == "OPTIONS":
74 + return _options_response()
75 + denied = _proxy_denied_response()
76 + if denied:
77 + return denied
78 + try:
79 + models = codex.fetch_models()
80 + return jsonify(
81 + {
82 + "object": "list",
83 + "data": [
84 + {
85 + "id": model,
86 + "object": "model",
87 + "created": 0,
88 + "owned_by": "codex-oauth",
89 + }
90 + for model in models
91 + ],
92 + }
93 + )
94 + except Exception as exc:
95 + return _json_error(str(exc), status=502, code="upstream_error")
96 +
97 +
98 +def codex_responses():
99 + if request.method == "OPTIONS":
100 + return _options_response()
101 + denied = _proxy_denied_response()
102 + if denied:
103 + return denied
104 +
105 + body = request.get_json(silent=True)
106 + if not isinstance(body, dict):
107 + return _json_error("Request body must be a JSON object.")
108 +
109 + wants_stream = body.get("stream") is True
110 + upstream_body = codex.prepare_responses_body(body, force_stream=True)
111 + try:
112 + upstream = codex.request_codex(
113 + "/responses",
114 + method="POST",
115 + headers={"Content-Type": "application/json"},
116 + body=json.dumps(upstream_body),
117 + stream=True,
118 + )
119 + except Exception as exc:
120 + return _json_error(str(exc), status=502, code="upstream_error")
121 +
122 + if not upstream.ok:
123 + return _copy_upstream_response(upstream)
124 + if wants_stream:
125 + return _stream_upstream_sse(upstream)
126 +
127 + try:
128 + completed = codex.collect_completed_response(upstream)
129 + except Exception as exc:
130 + return _json_error(str(exc), status=502, code="upstream_error")
131 + return jsonify(completed)
132 +
133 +
134 +def codex_chat_completions():
135 + if request.method == "OPTIONS":
136 + return _options_response()
137 + denied = _proxy_denied_response()
138 + if denied:
139 + return denied
140 +
141 + body = request.get_json(silent=True)
142 + if not isinstance(body, dict):
143 + return _json_error("Request body must be a JSON object.")
144 +
145 + try:
146 + response_body = codex.chat_messages_to_response_body(body)
147 + except Exception as exc:
148 + return _json_error(str(exc))
149 +
150 + wants_stream = body.get("stream") is True
151 + response_body["stream"] = True
152 + try:
153 + upstream = codex.request_codex(
154 + "/responses",
155 + method="POST",
156 + headers={"Content-Type": "application/json"},
157 + body=json.dumps(codex.prepare_responses_body(response_body, force_stream=True)),
158 + stream=True,
159 + )
160 + except Exception as exc:
161 + return _json_error(str(exc), status=502, code="upstream_error")
162 +
163 + if not upstream.ok:
164 + return _copy_upstream_response(upstream)
165 + if wants_stream:
166 + return _stream_chat_completion(upstream, str(body.get("model") or response_body["model"]))
167 +
168 + try:
169 + completed = codex.collect_completed_response(upstream)
170 + except Exception as exc:
171 + return _json_error(str(exc), status=502, code="upstream_error")
172 +
173 + text = codex.response_text(completed)
174 + return jsonify(
175 + {
176 + "id": f"chatcmpl_{int(time.time() * 1000)}",
177 + "object": "chat.completion",
178 + "created": int(time.time()),
179 + "model": body.get("model") or response_body["model"],
180 + "choices": [
181 + {
182 + "index": 0,
183 + "message": {"role": "assistant", "content": text},
184 + "finish_reason": "stop",
185 + }
186 + ],
187 + "usage": completed.get("usage") or {},
188 + }
189 + )
190 +
191 +
192 +def _stream_upstream_sse(upstream):
193 + headers = codex.response_headers(upstream)
194 + headers.setdefault("Content-Type", "text/event-stream")
195 + headers.setdefault("Cache-Control", "no-cache")
196 + return Response(
197 + stream_with_context(upstream.iter_content(chunk_size=8192)),
198 + status=upstream.status_code,
199 + headers=headers,
200 + )
201 +
202 +
203 +def _stream_chat_completion(upstream, model: str):
204 + created = int(time.time())
205 + chunk_id = f"chatcmpl_{int(time.time() * 1000)}"
206 +
207 + def generate():
208 + yield _sse_data(
209 + {
210 + "id": chunk_id,
211 + "object": "chat.completion.chunk",
212 + "created": created,
213 + "model": model,
214 + "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
215 + }
216 + )
217 + for event in codex.iter_sse_events(upstream):
218 + data = event.get("data")
219 + if not data:
220 + continue
221 + try:
222 + parsed = json.loads(data)
223 + except json.JSONDecodeError:
224 + continue
225 + if not isinstance(parsed, dict):
226 + continue
227 + for delta in codex.extract_sse_text_deltas(parsed, event.get("event", "")):
228 + yield _sse_data(
229 + {
230 + "id": chunk_id,
231 + "object": "chat.completion.chunk",
232 + "created": created,
233 + "model": model,
234 + "choices": [
235 + {
236 + "index": 0,
237 + "delta": {"content": delta},
238 + "finish_reason": None,
239 + }
240 + ],
241 + }
242 + )
243 + yield _sse_data(
244 + {
245 + "id": chunk_id,
246 + "object": "chat.completion.chunk",
247 + "created": created,
248 + "model": model,
249 + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
250 + }
251 + )
252 + yield "data: [DONE]\n\n"
253 +
254 + return Response(
255 + stream_with_context(generate()),
256 + headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"},
257 + )
258 +
259 +
260 +def _copy_upstream_response(upstream):
261 + return Response(
262 + upstream.content,
263 + status=upstream.status_code,
264 + headers=codex.response_headers(upstream),
265 + )
266 +
267 +
268 +def _proxy_denied_response() -> Response | None:
269 + if _proxy_authorized():
270 + return None
271 + return _json_error("Codex/ChatGPT account proxy access denied.", status=403, code="access_denied")
272 +
273 +
274 +def _proxy_authorized() -> bool:
275 + cfg = codex_config()
276 + token = cfg["proxy_token"]
277 + supplied = _supplied_proxy_token()
278 + if token and supplied == token:
279 + return True
280 + if cfg["require_proxy_token"]:
281 + return False
282 + return _host_is_local(request.host) or _remote_is_loopback(request.remote_addr)
283 +
284 +
285 +def _supplied_proxy_token() -> str:
286 + auth = request.headers.get("Authorization", "")
287 + if auth.lower().startswith("bearer "):
288 + return auth[7:].strip()
289 + return (
290 + request.headers.get("X-API-Key")
291 + or request.args.get("api_key")
292 + or request.args.get("key")
293 + or ""
294 + ).strip()
295 +
296 +
297 +def _host_is_local(host: str) -> bool:
298 + hostname = (host or "").split(":", 1)[0].strip("[]").lower()
299 + if hostname in {"localhost", "127.0.0.1", "::1"}:
300 + return True
301 + try:
302 + return ipaddress.ip_address(hostname).is_loopback
303 + except ValueError:
304 + return False
305 +
306 +
307 +def _remote_is_loopback(addr: str | None) -> bool:
308 + try:
309 + return ipaddress.ip_address(addr or "").is_loopback
310 + except ValueError:
311 + return False
312 +
313 +
314 +def _json_error(message: str, *, status: int = 400, code: str = "invalid_request") -> Response:
315 + return jsonify({"error": {"message": message, "type": code, "code": code}}), status
316 +
317 +
318 +def _options_response() -> Response:
319 + return Response(status=204)
320 +
321 +
322 +def _sse_data(payload: dict[str, Any]) -> str:
323 + return f"data: {json.dumps(payload, separators=(',', ':'))}\n\n"
324 +
325 +
326 +def _html_page(title: str, body: str) -> str:
327 + return f"""<!doctype html>
328 +<html lang="en">
329 +<head>
330 + <meta charset="utf-8">
331 + <meta name="viewport" content="width=device-width,initial-scale=1">
332 + <title>{_escape_html(title)}</title>
333 + <style>
334 + body {{
335 + margin: 0;
336 + min-height: 100vh;
337 + display: grid;
338 + place-items: center;
339 + background: #101214;
340 + color: #f2f5f7;
341 + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
342 + }}
343 + main {{
344 + width: min(560px, calc(100vw - 32px));
345 + border: 1px solid rgba(255,255,255,.14);
346 + border-radius: 8px;
347 + padding: 24px;
348 + background: #171a1d;
349 + box-shadow: 0 18px 70px rgba(0,0,0,.28);
350 + }}
351 + h1 {{ margin: 0 0 10px; font-size: 24px; }}
352 + p {{ margin: 0; color: #b9c1c9; line-height: 1.5; white-space: pre-line; }}
353 + span {{ color: #7f8b96; font-size: 13px; }}
354 + </style>
355 +</head>
356 +<body>
357 + <main>
358 + <h1>{_escape_html(title)}</h1>
359 + <p>{_escape_html(body)}</p>
360 + </main>
361 +</body>
362 +</html>"""
363 +
364 +
365 +def _escape_html(value: str) -> str:
366 + return (
367 + value.replace("&", "&amp;")
368 + .replace("<", "&lt;")
369 + .replace(">", "&gt;")
370 + .replace('"', "&quot;")
371 + .replace("'", "&#39;")
372 + )
plugins/_oauth/helpers/state.py new
+118
@@ -0,0 +1,118 @@
1 +from __future__ import annotations
2 +
3 +import threading
4 +import time
5 +from dataclasses import dataclass
6 +
7 +
8 +LOGIN_TTL_SECONDS = 10 * 60
9 +
10 +
11 +@dataclass(frozen=True)
12 +class LoginAttempt:
13 + state: str
14 + verifier: str
15 + redirect_uri: str
16 + created_at: float
17 +
18 + @property
19 + def expires_at(self) -> float:
20 + return self.created_at + LOGIN_TTL_SECONDS
21 +
22 + def expired(self) -> bool:
23 + return time.time() > self.expires_at
24 +
25 +
26 +@dataclass(frozen=True)
27 +class DeviceAttempt:
28 + attempt_id: str
29 + device_auth_id: str
30 + user_code: str
31 + interval: int
32 + expires_at_value: float
33 +
34 + @property
35 + def expires_at(self) -> float:
36 + return self.expires_at_value
37 +
38 + def expired(self) -> bool:
39 + return time.time() > self.expires_at
40 +
41 +
42 +_lock = threading.RLock()
43 +_attempts: dict[str, LoginAttempt] = {}
44 +_device_attempts: dict[str, DeviceAttempt] = {}
45 +
46 +
47 +def put_attempt(state: str, verifier: str, redirect_uri: str) -> LoginAttempt:
48 + cleanup_expired()
49 + attempt = LoginAttempt(
50 + state=state,
51 + verifier=verifier,
52 + redirect_uri=redirect_uri,
53 + created_at=time.time(),
54 + )
55 + with _lock:
56 + _attempts[state] = attempt
57 + return attempt
58 +
59 +
60 +def pop_attempt(state: str) -> LoginAttempt | None:
61 + cleanup_expired()
62 + with _lock:
63 + attempt = _attempts.pop(state, None)
64 + if attempt is None or attempt.expired():
65 + return None
66 + return attempt
67 +
68 +
69 +def put_device_attempt(
70 + attempt_id: str,
71 + device_auth_id: str,
72 + user_code: str,
73 + interval: int,
74 + expires_at: float,
75 +) -> DeviceAttempt:
76 + cleanup_expired()
77 + attempt = DeviceAttempt(
78 + attempt_id=attempt_id,
79 + device_auth_id=device_auth_id,
80 + user_code=user_code,
81 + interval=interval,
82 + expires_at_value=expires_at,
83 + )
84 + with _lock:
85 + _device_attempts[attempt_id] = attempt
86 + return attempt
87 +
88 +
89 +def get_device_attempt(attempt_id: str) -> DeviceAttempt | None:
90 + cleanup_expired()
91 + with _lock:
92 + attempt = _device_attempts.get(attempt_id)
93 + if attempt is None or attempt.expired():
94 + return None
95 + return attempt
96 +
97 +
98 +def pop_device_attempt(attempt_id: str) -> DeviceAttempt | None:
99 + cleanup_expired()
100 + with _lock:
101 + return _device_attempts.pop(attempt_id, None)
102 +
103 +
104 +def cleanup_expired() -> None:
105 + now = time.time()
106 + with _lock:
107 + expired = [
108 + state for state, attempt in _attempts.items() if now > attempt.expires_at
109 + ]
110 + for state in expired:
111 + _attempts.pop(state, None)
112 + expired_devices = [
113 + attempt_id
114 + for attempt_id, attempt in _device_attempts.items()
115 + if now > attempt.expires_at
116 + ]
117 + for attempt_id in expired_devices:
118 + _device_attempts.pop(attempt_id, None)
plugins/_oauth/plugin.yaml new
+9
@@ -0,0 +1,9 @@
1 +name: _oauth
2 +title: OAuth Connections
3 +description: Generic local OAuth bridge for account-backed providers. Includes Codex/ChatGPT Account as the first provider.
4 +version: 0.1.0
5 +always_enabled: false
6 +settings_sections:
7 + - external
8 +per_project_config: false
9 +per_agent_config: false
plugins/_oauth/webui/config.html new
+391
@@ -0,0 +1,391 @@
1 +<html>
2 +<head>
3 + <title>OAuth Connections</title>
4 + <script type="module">
5 + import { store } from "/plugins/_oauth/webui/oauth-config-store.js";
6 + </script>
7 +</head>
8 +
9 +<body>
10 + <div x-data>
11 + <template x-if="$store.oauthConfig && config">
12 + <div
13 + class="oauth"
14 + x-init="$store.oauthConfig.init(config)"
15 + x-effect="$store.oauthConfig.bindConfig(config)"
16 + x-destroy="$store.oauthConfig.cleanup()"
17 + >
18 + <section class="oauth-hero" :class="$store.oauthConfig.connected() ? 'is-connected' : ''">
19 + <div class="oauth-mark">
20 + <span class="material-symbols-outlined" x-text="$store.oauthConfig.connected() ? 'check' : 'key'"></span>
21 + </div>
22 +
23 + <div class="oauth-copy">
24 + <h2>Codex/ChatGPT</h2>
25 + <p x-show="!$store.oauthConfig.connected() && !$store.oauthConfig.connecting">
26 + Connect your account to unlock Codex models locally.
27 + </p>
28 + <p x-show="$store.oauthConfig.connected()">
29 + Connected and ready.
30 + </p>
31 + <p x-show="$store.oauthConfig.connecting">
32 + Finish sign-in in the browser tab.
33 + </p>
34 + </div>
35 +
36 + <div class="oauth-primary">
37 + <button
38 + class="oauth-connect"
39 + type="button"
40 + @click="$store.oauthConfig.connectCodex()"
41 + :disabled="$store.oauthConfig.connecting"
42 + x-show="!$store.oauthConfig.connected()"
43 + >
44 + <span class="material-symbols-outlined" x-text="$store.oauthConfig.connecting ? 'progress_activity' : 'login'"></span>
45 + <span x-text="$store.oauthConfig.connecting ? 'Waiting' : 'Connect'"></span>
46 + </button>
47 + <button
48 + class="oauth-connect secondary"
49 + type="button"
50 + @click="$store.oauthConfig.loadModels()"
51 + :disabled="$store.oauthConfig.loadingModels"
52 + x-show="$store.oauthConfig.connected()"
53 + >
54 + <span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModels ? 'progress_activity' : 'view_list'"></span>
55 + <span>Check Models</span>
56 + </button>
57 + </div>
58 + </section>
59 +
60 + <section class="oauth-device" x-show="$store.oauthConfig.device">
61 + <span>Enter this code</span>
62 + <strong x-text="$store.oauthConfig.device?.user_code"></strong>
63 + <button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect()">
64 + <span class="material-symbols-outlined">close</span>
65 + <span>Cancel</span>
66 + </button>
67 + </section>
68 +
69 + <section class="oauth-status-row">
70 + <div>
71 + <span>Status</span>
72 + <strong x-text="$store.oauthConfig.statusLabel()"></strong>
73 + </div>
74 + <div x-show="$store.oauthConfig.status?.codex?.email">
75 + <span>Account</span>
76 + <strong x-text="$store.oauthConfig.status?.codex?.email"></strong>
77 + </div>
78 + <button class="oauth-icon-button" type="button" @click="$store.oauthConfig.loadStatus()" title="Refresh status" aria-label="Refresh status">
79 + <span class="material-symbols-outlined">refresh</span>
80 + </button>
81 + </section>
82 +
83 + <details class="oauth-advanced">
84 + <summary>Advanced</summary>
85 +
86 + <div class="oauth-details">
87 + <div>
88 + <span>Endpoint</span>
89 + <code x-text="$store.oauthConfig.endpointUrl()"></code>
90 + </div>
91 + <div>
92 + <span>Auth file</span>
93 + <code x-text="$store.oauthConfig.status?.codex?.auth_file_path || 'Auto-discover'"></code>
94 + </div>
95 + </div>
96 +
97 + <div class="oauth-grid">
98 + <label>
99 + <span>Auth file path</span>
100 + <input type="text" x-model="$store.oauthConfig.codex().auth_file_path" placeholder="Auto-discover" />
101 + </label>
102 + <label>
103 + <span>Issuer</span>
104 + <input type="text" x-model="$store.oauthConfig.codex().issuer" />
105 + </label>
106 + <label>
107 + <span>Token URL</span>
108 + <input type="text" x-model="$store.oauthConfig.codex().token_url" />
109 + </label>
110 + <label>
111 + <span>Base path</span>
112 + <input type="text" x-model="$store.oauthConfig.codex().proxy_base_path" />
113 + </label>
114 + <label>
115 + <span>Upstream URL</span>
116 + <input type="text" x-model="$store.oauthConfig.codex().upstream_base_url" />
117 + </label>
118 + <label>
119 + <span>Codex version</span>
120 + <input type="text" x-model="$store.oauthConfig.codex().codex_version" placeholder="Auto" />
121 + </label>
122 + <label class="oauth-switch">
123 + <span>Require proxy token</span>
124 + <input type="checkbox" x-model="$store.oauthConfig.codex().require_proxy_token" />
125 + </label>
126 + <label>
127 + <span>Proxy token</span>
128 + <input type="password" x-model="$store.oauthConfig.codex().proxy_token" autocomplete="off" />
129 + </label>
130 + </div>
131 + </details>
132 +
133 + <div class="oauth-models" x-show="$store.oauthConfig.models.length">
134 + <template x-for="model in $store.oauthConfig.models" :key="model">
135 + <span x-text="model"></span>
136 + </template>
137 + </div>
138 + </div>
139 + </template>
140 + </div>
141 +
142 + <style>
143 + .oauth {
144 + display: flex;
145 + flex-direction: column;
146 + gap: 14px;
147 + color: var(--color-text);
148 + }
149 +
150 + .oauth-hero {
151 + display: grid;
152 + grid-template-columns: 52px minmax(0, 1fr) auto;
153 + align-items: center;
154 + gap: 16px;
155 + min-height: 118px;
156 + padding: 18px;
157 + border: 1px solid var(--color-border);
158 + border-radius: 8px;
159 + background: color-mix(in srgb, var(--color-panel) 86%, transparent);
160 + }
161 +
162 + .oauth-mark {
163 + display: grid;
164 + width: 52px;
165 + height: 52px;
166 + place-items: center;
167 + border-radius: 50%;
168 + background: color-mix(in srgb, var(--color-border) 52%, transparent);
169 + }
170 +
171 + .oauth-mark .material-symbols-outlined {
172 + font-size: 28px;
173 + }
174 +
175 + .oauth-hero.is-connected .oauth-mark {
176 + color: #08120c;
177 + background: #35d07f;
178 + }
179 +
180 + .oauth-copy h2 {
181 + margin: 0 0 4px;
182 + font-size: 1.35rem;
183 + letter-spacing: 0;
184 + }
185 +
186 + .oauth-copy p {
187 + margin: 0;
188 + color: var(--color-text-secondary);
189 + font-size: 0.88rem;
190 + line-height: 1.4;
191 + }
192 +
193 + .oauth-connect {
194 + display: inline-flex;
195 + align-items: center;
196 + justify-content: center;
197 + gap: 8px;
198 + min-width: 124px;
199 + min-height: 40px;
200 + padding: 0 16px;
201 + border: 0;
202 + border-radius: 8px;
203 + background: #f5f7fa;
204 + color: #111418;
205 + font: inherit;
206 + font-size: 0.88rem;
207 + font-weight: 750;
208 + cursor: pointer;
209 + }
210 +
211 + .oauth-connect.secondary {
212 + background: color-mix(in srgb, var(--color-border) 60%, transparent);
213 + color: var(--color-text);
214 + }
215 +
216 + .oauth-connect:disabled {
217 + cursor: default;
218 + opacity: .65;
219 + }
220 +
221 + .oauth-device {
222 + display: grid;
223 + grid-template-columns: minmax(0, 1fr) auto auto;
224 + align-items: center;
225 + gap: 14px;
226 + padding: 14px 16px;
227 + border: 1px solid color-mix(in srgb, #f5f7fa 18%, var(--color-border));
228 + border-radius: 8px;
229 + background: color-mix(in srgb, #f5f7fa 7%, var(--color-panel));
230 + }
231 +
232 + .oauth-device span {
233 + color: var(--color-text-secondary);
234 + font-size: 0.84rem;
235 + font-weight: 700;
236 + }
237 +
238 + .oauth-device strong {
239 + font-size: 1.45rem;
240 + letter-spacing: 0;
241 + }
242 +
243 + .oauth-status-row {
244 + display: grid;
245 + grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
246 + align-items: center;
247 + gap: 10px;
248 + padding: 12px 14px;
249 + border: 1px solid var(--color-border);
250 + border-radius: 8px;
251 + }
252 +
253 + .oauth-status-row div {
254 + display: flex;
255 + min-width: 0;
256 + flex-direction: column;
257 + gap: 3px;
258 + }
259 +
260 + .oauth-status-row span,
261 + .oauth-details span,
262 + .oauth-grid label span {
263 + color: var(--color-text-secondary);
264 + font-size: 0.76rem;
265 + font-weight: 700;
266 + }
267 +
268 + .oauth-status-row strong {
269 + overflow: hidden;
270 + font-size: 0.88rem;
271 + text-overflow: ellipsis;
272 + white-space: nowrap;
273 + }
274 +
275 + .oauth-icon-button {
276 + display: grid;
277 + width: 34px;
278 + height: 34px;
279 + place-items: center;
280 + border: 1px solid var(--color-border);
281 + border-radius: 8px;
282 + background: transparent;
283 + color: var(--color-text);
284 + cursor: pointer;
285 + }
286 +
287 + .oauth-advanced {
288 + border: 1px solid var(--color-border);
289 + border-radius: 8px;
290 + padding: 0;
291 + }
292 +
293 + .oauth-advanced summary {
294 + padding: 12px 14px;
295 + color: var(--color-text-secondary);
296 + font-size: 0.84rem;
297 + font-weight: 750;
298 + cursor: pointer;
299 + }
300 +
301 + .oauth-details {
302 + display: grid;
303 + gap: 8px;
304 + padding: 0 14px 14px;
305 + }
306 +
307 + .oauth-details div {
308 + display: grid;
309 + grid-template-columns: 84px minmax(0, 1fr);
310 + align-items: center;
311 + gap: 10px;
312 + }
313 +
314 + .oauth-details code {
315 + overflow: hidden;
316 + font-size: 0.76rem;
317 + text-overflow: ellipsis;
318 + white-space: nowrap;
319 + }
320 +
321 + .oauth-grid {
322 + display: grid;
323 + grid-template-columns: repeat(2, minmax(0, 1fr));
324 + gap: 12px;
325 + padding: 0 14px 14px;
326 + }
327 +
328 + .oauth-grid label {
329 + display: flex;
330 + min-width: 0;
331 + flex-direction: column;
332 + gap: 6px;
333 + }
334 +
335 + .oauth-grid input[type="text"],
336 + .oauth-grid input[type="password"] {
337 + width: 100%;
338 + min-height: 36px;
339 + padding: 7px 10px;
340 + border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
341 + border-radius: 8px;
342 + background: var(--color-input);
343 + color: var(--color-text);
344 + font: inherit;
345 + font-size: 0.82rem;
346 + }
347 +
348 + .oauth-switch {
349 + justify-content: center;
350 + }
351 +
352 + .oauth-switch input {
353 + width: 18px;
354 + height: 18px;
355 + accent-color: #f5f7fa;
356 + }
357 +
358 + .oauth-models {
359 + display: flex;
360 + flex-wrap: wrap;
361 + gap: 8px;
362 + }
363 +
364 + .oauth-models span {
365 + padding: 5px 8px;
366 + border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
367 + border-radius: 999px;
368 + color: var(--color-text-secondary);
369 + font-size: 0.76rem;
370 + }
371 +
372 + @media (max-width: 720px) {
373 + .oauth-hero,
374 + .oauth-device,
375 + .oauth-status-row,
376 + .oauth-grid,
377 + .oauth-details div {
378 + grid-template-columns: 1fr;
379 + }
380 +
381 + .oauth-primary {
382 + width: 100%;
383 + }
384 +
385 + .oauth-connect {
386 + width: 100%;
387 + }
388 + }
389 + </style>
390 +</body>
391 +</html>
plugins/_oauth/webui/oauth-config-store.js new
+192
@@ -0,0 +1,192 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +import {
4 + toastFrontendError,
5 + toastFrontendInfo,
6 + toastFrontendSuccess,
7 +} from "/components/notifications/notification-store.js";
8 +
9 +const STATUS_API = "/plugins/_oauth/status";
10 +const START_DEVICE_LOGIN_API = "/plugins/_oauth/start_device_login";
11 +const POLL_DEVICE_LOGIN_API = "/plugins/_oauth/poll_device_login";
12 +const MODELS_API = "/plugins/_oauth/models";
13 +const MAX_POLL_MS = 120000;
14 +
15 +function ensureConfig(config) {
16 + if (!config || typeof config !== "object") return null;
17 + config.codex = config.codex && typeof config.codex === "object" ? config.codex : {};
18 + const codex = config.codex;
19 + codex.enabled = codex.enabled !== false;
20 + codex.auth_file_path = String(codex.auth_file_path || "");
21 + codex.issuer = String(codex.issuer || "https://auth.openai.com");
22 + codex.token_url = String(codex.token_url || "https://auth.openai.com/oauth/token");
23 + codex.client_id = String(codex.client_id || "app_EMoamEEZ73f0CkXaXp7hrann");
24 + codex.upstream_base_url = String(codex.upstream_base_url || "https://chatgpt.com/backend-api/codex");
25 + codex.proxy_base_path = String(codex.proxy_base_path || "/oauth/codex");
26 + codex.callback_path = String(codex.callback_path || "/auth/callback");
27 + codex.require_proxy_token = Boolean(codex.require_proxy_token);
28 + codex.proxy_token = String(codex.proxy_token || "");
29 + codex.codex_version = String(codex.codex_version || "");
30 + codex.models = Array.isArray(codex.models) ? codex.models : [];
31 + return config;
32 +}
33 +
34 +function messageOf(error) {
35 + return error instanceof Error ? error.message : String(error);
36 +}
37 +
38 +export const store = createStore("oauthConfig", {
39 + config: null,
40 + status: null,
41 + loadingStatus: false,
42 + connecting: false,
43 + loadingModels: false,
44 + models: [],
45 + device: null,
46 + pollTimer: null,
47 + pollStartedAt: 0,
48 +
49 + async init(config) {
50 + this.bindConfig(config);
51 + await this.loadStatus();
52 + },
53 +
54 + cleanup() {
55 + this.stopPolling();
56 + this.config = null;
57 + this.status = null;
58 + this.models = [];
59 + this.device = null;
60 + },
61 +
62 + bindConfig(config) {
63 + const safeConfig = ensureConfig(config);
64 + if (!safeConfig) return;
65 + if (this.config === safeConfig) return;
66 + this.config = safeConfig;
67 + },
68 +
69 + codex() {
70 + return this.config?.codex || {};
71 + },
72 +
73 + connected() {
74 + return Boolean(this.status?.codex?.connected);
75 + },
76 +
77 + statusLabel() {
78 + if (this.loadingStatus) return "Checking";
79 + return this.connected() ? "Connected" : "Not connected";
80 + },
81 +
82 + endpointUrl() {
83 + const base = this.codex().proxy_base_path || "/oauth/codex";
84 + return `${window.location.origin}${base}/v1`;
85 + },
86 +
87 + callbackUrl() {
88 + const path = this.codex().callback_path || "/auth/callback";
89 + return `${window.location.origin}${path}`;
90 + },
91 +
92 + async loadStatus() {
93 + if (this.loadingStatus) return;
94 + this.loadingStatus = true;
95 + try {
96 + const response = await callJsonApi(STATUS_API, {});
97 + this.status = response;
98 + } catch (error) {
99 + void toastFrontendError(messageOf(error), "OAuth Connections");
100 + } finally {
101 + this.loadingStatus = false;
102 + }
103 + },
104 +
105 + async connectCodex() {
106 + if (this.connecting) return;
107 + this.connecting = true;
108 + try {
109 + const response = await callJsonApi(START_DEVICE_LOGIN_API, {});
110 + if (!response?.ok || !response.verification_url || !response.attempt_id) {
111 + throw new Error(response?.error || "Could not start Codex sign-in.");
112 + }
113 + this.device = response;
114 + window.open(response.verification_url, "_blank", "noopener,noreferrer");
115 + void toastFrontendInfo("Enter the code shown here in the opened browser tab.", "OAuth Connections");
116 + this.startPolling();
117 + } catch (error) {
118 + this.connecting = false;
119 + void toastFrontendError(messageOf(error), "OAuth Connections");
120 + }
121 + },
122 +
123 + startPolling() {
124 + this.stopPolling();
125 + this.pollStartedAt = Date.now();
126 + const tick = async () => {
127 + if (!this.device?.attempt_id) return;
128 + try {
129 + const response = await callJsonApi(POLL_DEVICE_LOGIN_API, {
130 + attempt_id: this.device.attempt_id,
131 + });
132 + if (!response?.ok) {
133 + if (response?.expired) {
134 + this.connecting = false;
135 + this.device = null;
136 + this.stopPolling();
137 + }
138 + throw new Error(response?.error || "Could not finish Codex sign-in.");
139 + }
140 + if (response.completed) {
141 + await this.loadStatus();
142 + this.device = null;
143 + this.connecting = false;
144 + this.stopPolling();
145 + void toastFrontendSuccess("Codex account connected.", "OAuth Connections");
146 + return;
147 + }
148 + } catch (error) {
149 + this.connecting = false;
150 + this.stopPolling();
151 + void toastFrontendError(messageOf(error), "OAuth Connections");
152 + return;
153 + }
154 + if (Date.now() - this.pollStartedAt > MAX_POLL_MS) {
155 + this.connecting = false;
156 + this.device = null;
157 + this.stopPolling();
158 + return;
159 + }
160 + };
161 + void tick();
162 + const delay = Math.max(1500, Number(this.device.interval || 5) * 1000);
163 + this.pollTimer = window.setInterval(tick, delay);
164 + },
165 +
166 + stopPolling() {
167 + if (this.pollTimer) window.clearInterval(this.pollTimer);
168 + this.pollTimer = null;
169 + },
170 +
171 + async loadModels() {
172 + if (this.loadingModels) return;
173 + this.loadingModels = true;
174 + try {
175 + const response = await callJsonApi(MODELS_API, {});
176 + if (!response?.ok) throw new Error(response?.error || "Could not load Codex models.");
177 + this.models = Array.isArray(response.models) ? response.models : [];
178 + void toastFrontendSuccess("Codex models loaded.", "OAuth Connections");
179 + } catch (error) {
180 + this.models = [];
181 + void toastFrontendError(messageOf(error), "OAuth Connections");
182 + } finally {
183 + this.loadingModels = false;
184 + }
185 + },
186 +
187 + cancelConnect() {
188 + this.connecting = false;
189 + this.device = null;
190 + this.stopPolling();
191 + },
192 +});
tests/test_oauth_codex.py new
+156
@@ -0,0 +1,156 @@
1 +from __future__ import annotations
2 +
3 +import json
4 +import sys
5 +from pathlib import Path
6 +
7 +import yaml
8 +
9 +sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
10 +from plugins._oauth.helpers import codex
11 +from plugins._oauth.extensions.python._functions.models.get_api_key.end._20_codex_account_dummy_key import (
12 + CodexAccountDummyKey,
13 +)
14 +
15 +
16 +def test_generate_pkce_produces_urlsafe_verifier_and_challenge():
17 + pair = codex.generate_pkce()
18 +
19 + assert 43 <= len(pair.verifier) <= 128
20 + assert pair.verifier
21 + assert pair.challenge
22 + assert "=" not in pair.verifier
23 + assert "=" not in pair.challenge
24 +
25 +
26 +def test_build_authorize_url_uses_existing_a0_origin_callback(monkeypatch):
27 + monkeypatch.setattr(
28 + codex,
29 + "codex_config",
30 + lambda: {
31 + "issuer": "https://auth.openai.com",
32 + "client_id": "app_EMoamEEZ73f0CkXaXp7hrann",
33 + "scopes": [
34 + "openid",
35 + "profile",
36 + "email",
37 + "offline_access",
38 + "api.connectors.read",
39 + "api.connectors.invoke",
40 + ],
41 + "forced_workspace_id": "",
42 + },
43 + )
44 + pair = codex.PkcePair(verifier="verifier", challenge="challenge")
45 + auth_url = codex.build_authorize_url(
46 + "http://localhost:50001/auth/callback",
47 + "state",
48 + pair,
49 + )
50 +
51 + assert auth_url.startswith("https://auth.openai.com/oauth/authorize?")
52 + assert "redirect_uri=http%3A%2F%2Flocalhost%3A50001%2Fauth%2Fcallback" in auth_url
53 + assert "code_challenge=challenge" in auth_url
54 + assert "originator=codex_cli_rs" in auth_url
55 +
56 +
57 +def test_chat_messages_to_response_body_extracts_instructions():
58 + body = codex.chat_messages_to_response_body(
59 + {
60 + "model": "gpt-5.2",
61 + "messages": [
62 + {"role": "system", "content": "Be precise."},
63 + {"role": "user", "content": "Hello"},
64 + ],
65 + "temperature": 0.2,
66 + "reasoning_effort": "high",
67 + }
68 + )
69 +
70 + assert body["model"] == "gpt-5.2"
71 + assert body["instructions"] == "Be precise."
72 + assert body["input"] == [{"role": "user", "content": "Hello"}]
73 + assert body["temperature"] == 0.2
74 + assert body["reasoning"] == {"effort": "high"}
75 +
76 +
77 +def test_response_text_reads_output_text_or_output_blocks():
78 + assert codex.response_text({"output_text": "direct"}) == "direct"
79 +
80 + assert (
81 + codex.response_text(
82 + {
83 + "output": [
84 + {
85 + "content": [
86 + {"type": "output_text", "text": "a"},
87 + {"type": "output_text", "text": "b"},
88 + ]
89 + }
90 + ]
91 + }
92 + )
93 + == "ab"
94 + )
95 +
96 +
97 +def test_parse_sse_block_joins_data_lines():
98 + event = codex.parse_sse_block(
99 + 'event: response.completed\ndata: {"response":\ndata: {"id":"r"}}\n'
100 + )
101 +
102 + assert event["event"] == "response.completed"
103 + assert json.loads(event["data"]) == {"response": {"id": "r"}}
104 +
105 +
106 +def test_extract_sse_text_deltas_reads_chat_completion_chunks():
107 + deltas = codex.extract_sse_text_deltas(
108 + {
109 + "id": "chatcmpl_test",
110 + "choices": [
111 + {"delta": {"role": "assistant"}},
112 + {"delta": {"content": "Hel"}},
113 + {"delta": {"content": "lo"}},
114 + ],
115 + }
116 + )
117 +
118 + assert deltas == ["Hel", "lo"]
119 +
120 +
121 +def test_collect_completed_response_falls_back_to_text_deltas():
122 + class FakeResponse:
123 + def iter_content(self, chunk_size=8192, decode_unicode=True):
124 + del chunk_size, decode_unicode
125 + yield 'data: {"choices":[{"delta":{"content":"Hel"}}]}\n\n'
126 + yield 'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n'
127 + yield "data: [DONE]\n\n"
128 +
129 + assert codex.collect_completed_response(FakeResponse()) == {"output_text": "Hello"}
130 +
131 +
132 +def test_provider_config_uses_container_local_agent_zero_origin():
133 + provider_path = Path(__file__).resolve().parents[1] / "plugins/_oauth/conf/model_providers.yaml"
134 + provider_config = yaml.safe_load(provider_path.read_text(encoding="utf-8"))
135 + codex_provider = provider_config["chat"]["codex_oauth"]
136 +
137 + assert codex_provider["name"] == "Codex/ChatGPT Account"
138 + assert codex_provider["models_list"]["endpoint_url"] == "/models"
139 + assert codex_provider["kwargs"]["api_base"] == "http://127.0.0.1/oauth/codex/v1"
140 + assert "50001" not in json.dumps(codex_provider)
141 +
142 +
143 +def test_codex_provider_reports_dummy_api_key_when_missing():
144 + data = {"args": ("codex_oauth",), "kwargs": {}, "result": "None"}
145 +
146 + CodexAccountDummyKey(agent=None).execute(data=data)
147 +
148 + assert data["result"] == "oauth"
149 +
150 +
151 +def test_codex_provider_preserves_configured_api_key():
152 + data = {"args": ("codex_oauth",), "kwargs": {}, "result": "configured"}
153 +
154 + CodexAccountDummyKey(agent=None).execute(data=data)
155 +
156 + assert data["result"] == "configured"