From 484f117994edf754616291f25220777806249891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Mon, 6 Apr 2026 23:03:15 +0200 Subject: [PATCH 1/4] feat: implement OpenAI Codex ChatGPT OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openai_codex_oauth(): reads ~/.codex/auth.json (auth_mode=chatgpt), decodes JWT exp claim for expiry detection - openai_codex_refresh(): single-use refresh token flow, always writes new token back to auth.json immediately - openai_codex_login(): full Auth Code + PKCE flow on port 1455, matching Codex CLI's redirect URI - _codex_jwt_expiry(): decode JWT exp without signature verification - main(): dispatch for --refresh / --login / read-existing - registry.py: correct base_url → chatgpt.com/backend-api/codex/responses, backend → oauth - config.yaml: fix token/refresh endpoints to auth.openai.com, correct client_id and base_url Co-Authored-By: Claude Sonnet 4.6 --- config.yaml | 21 ++-- faigate/oauth/cli.py | 287 ++++++++++++++++++++++++++++++++++++++++++- faigate/registry.py | 18 +-- 3 files changed, 306 insertions(+), 20 deletions(-) diff --git a/config.yaml b/config.yaml index 4e3ff1c..9bf9c77 100644 --- a/config.yaml +++ b/config.yaml @@ -1009,17 +1009,20 @@ providers: # connect_s: 10 # read_s: 90 - # openai‑codex: + # openai-codex: + # # Token from ~/.codex/auth.json (auth_mode=chatgpt). Run: faigate-auth openai-codex + # # Inference endpoint: chatgpt.com/backend-api/codex/responses (NOT api.openai.com) + # # Refresh tokens are single-use; faigate rewrites auth.json after every refresh. # backend: oauth # oauth: - # helper: "faigate‑auth openai‑codex" - # client_id: "openai‑codex‑client" - # token_endpoint: "https://api.openai.com/oauth/token" - # refresh_endpoint: "https://api.openai.com/oauth/refresh" - # scope: "openid email" - # underlying_backend: openai‑compat - # base_url: ${OPENAI_BASE_URL:-https://api.openai.com/v1} - # model: openai‑codex/gpt‑5.3‑codex + # helper: "faigate-auth openai-codex" + # client_id: "app_EMoamEEZ73f0CkXaXp7hrann" + # token_endpoint: "https://auth.openai.com/oauth/token" + # refresh_endpoint: "https://auth.openai.com/oauth/token" + # scope: "openid profile email offline_access" + # underlying_backend: openai-compat + # base_url: "https://chatgpt.com/backend-api/codex/responses" + # model: openai-codex/gpt-5.3-codex # tier: default # timeout: # connect_s: 10 diff --git a/faigate/oauth/cli.py b/faigate/oauth/cli.py index b5b37c2..c37bcdf 100644 --- a/faigate/oauth/cli.py +++ b/faigate/oauth/cli.py @@ -45,6 +45,15 @@ _ANTIGRAVITY_BASE_URL_DEFAULT = "https://generativelanguage.googleapis.com/v1beta/openai" _ANTIGRAVITY_BASE_URL_ENV = "ANTIGRAVITY_BASE_URL" +# ── OpenAI Codex constants (from Codex CLI source / community research) ────── +_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +_CODEX_AUTH_ENDPOINT = "https://auth.openai.com/oauth/authorize" +_CODEX_TOKEN_ENDPOINT = "https://auth.openai.com/oauth/token" +_CODEX_SCOPE = "openid profile email offline_access" +_CODEX_CREDS_PATH = "~/.codex/auth.json" +_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex/responses" +_CODEX_CALLBACK_PORT = 1455 + # ── Qwen constants (from qwen-code source) ─────────────────────────────────── _QWEN_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56" _QWEN_SCOPE = "openid profile email model.completion" @@ -513,9 +522,262 @@ def claude_code_oauth() -> dict[str, Any]: raise RuntimeError("Claude Code token not found.") +def _codex_jwt_expiry(token: str) -> float | None: + """Decode JWT exp claim without verifying signature. Returns UTC epoch seconds or None.""" + try: + import base64 as _b64 + + parts = token.split(".") + if len(parts) != 3: + return None + padding = 4 - len(parts[1]) % 4 + payload = json.loads(_b64.urlsafe_b64decode(parts[1] + "=" * padding)) + return float(payload["exp"]) + except Exception: + return None + + def openai_codex_oauth() -> dict[str, Any]: - """Obtain OpenAI Codex token via ChatGPT OAuth.""" - raise NotImplementedError("OpenAI Codex OAuth not yet implemented") + """Read OpenAI Codex credentials from the local Codex CLI token store. + + The OpenAI Codex CLI stores ChatGPT OAuth credentials at ~/.codex/auth.json + after completing the interactive login on first run. + + Token format: + { + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "access_token": "eyJ...", # JWT – sent as Bearer to inference endpoint + "refresh_token": "rt_...", # opaque, single-use + "id_token": "eyJ...", # JWT – identity only + "account_id": "...", # UUID + }, + "last_refresh": "2026-04-05T..." # ISO-8601 UTC + } + + Expiry is derived from the JWT exp claim (no expiry_date field in the file). + Inference endpoint: https://chatgpt.com/backend-api/codex/responses + """ + creds_path = os.path.expanduser(_CODEX_CREDS_PATH) + if not os.path.exists(creds_path): + raise RuntimeError( + f"OpenAI Codex credentials not found at {creds_path}.\n" + "Please install and log in to the OpenAI Codex CLI:\n" + " npm install -g @openai/codex\n" + " codex # completes OAuth login on first run\n" + "Or run: faigate-auth openai-codex --login" + ) + + try: + with open(creds_path) as f: + creds = json.load(f) + except (OSError, json.JSONDecodeError) as e: + raise RuntimeError(f"Failed to read Codex credentials from {creds_path}: {e}") + + if creds.get("auth_mode") != "chatgpt": + raise RuntimeError( + f"Codex credentials at {creds_path} use auth_mode={creds.get('auth_mode')!r}, " + "expected 'chatgpt'. Please log in with: codex or faigate-auth openai-codex --login" + ) + + tokens = creds.get("tokens", {}) + access_token = tokens.get("access_token") + if not access_token: + raise RuntimeError( + f"No access_token in Codex credentials at {creds_path}. " + "Please re-authenticate: codex or faigate-auth openai-codex --login" + ) + + exp = _codex_jwt_expiry(access_token) + if exp is not None and exp < time.time(): + logger.warning( + "OpenAI Codex token expired (exp=%s). Run: faigate-auth openai-codex --refresh", exp + ) + + return { + "access_token": access_token, + "refresh_token": tokens.get("refresh_token"), + "id_token": tokens.get("id_token"), + "account_id": tokens.get("account_id"), + "token_type": "Bearer", + "expires_at": exp, + "base_url": _CODEX_BASE_URL, + } + + +def openai_codex_refresh(refresh_token: str) -> dict[str, Any]: + """Refresh an expired OpenAI Codex token. + + Codex refresh tokens are single-use — the new refresh_token from the + response is always written back to ~/.codex/auth.json immediately. + """ + if requests is None: + raise RuntimeError("requests package required. Install with: pip install faigate[oauth]") + + resp = requests.post( + _CODEX_TOKEN_ENDPOINT, + json={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": _CODEX_CLIENT_ID, + }, + timeout=30, + ) + resp.raise_for_status() + token = resp.json() + + new_access_token = token["access_token"] + new_refresh_token = token.get("refresh_token", refresh_token) + exp = _codex_jwt_expiry(new_access_token) + + # Preserve existing structure; only overwrite token fields + creds_path = os.path.expanduser(_CODEX_CREDS_PATH) + existing: dict[str, Any] = {} + try: + with open(creds_path) as f: + existing = json.load(f) + except Exception: + pass + + existing.setdefault("tokens", {}) + existing["tokens"]["access_token"] = new_access_token + existing["tokens"]["refresh_token"] = new_refresh_token + if "id_token" in token: + existing["tokens"]["id_token"] = token["id_token"] + existing["last_refresh"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + os.makedirs(os.path.dirname(creds_path), exist_ok=True) + tmp = creds_path + ".tmp" + with open(tmp, "w") as f: + json.dump(existing, f, indent=2) + os.replace(tmp, creds_path) + os.chmod(creds_path, 0o600) + logger.info("OpenAI Codex token refreshed and written to %s", creds_path) + + return { + "access_token": new_access_token, + "refresh_token": new_refresh_token, + "id_token": token.get("id_token"), + "token_type": "Bearer", + "expires_at": exp, + "base_url": _CODEX_BASE_URL, + } + + +def openai_codex_login() -> dict[str, Any]: + """Full OpenAI Codex login via Authorization Code + PKCE. + + Opens a browser to auth.openai.com, listens on + http://localhost:1455/auth/callback for the redirect, exchanges the + code for tokens, and writes credentials to ~/.codex/auth.json. + """ + import base64 + import hashlib + import secrets + import urllib.parse + from http.server import BaseHTTPRequestHandler, HTTPServer + + if requests is None: + raise RuntimeError("requests package required. Install with: pip install faigate[oauth]") + + code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode() + code_challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode() + ) + state = secrets.token_urlsafe(24) + redirect_uri = f"http://localhost:{_CODEX_CALLBACK_PORT}/auth/callback" + + params = { + "client_id": _CODEX_CLIENT_ID, + "response_type": "code", + "redirect_uri": redirect_uri, + "scope": _CODEX_SCOPE, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + auth_url = f"{_CODEX_AUTH_ENDPOINT}?{urllib.parse.urlencode(params)}" + received: dict[str, str] = {} + + class _CallbackHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + parsed = urllib.parse.urlparse(self.path) + qs = urllib.parse.parse_qs(parsed.query) + received["code"] = qs.get("code", [""])[0] + received["state"] = qs.get("state", [""])[0] + self.send_response(200) + self.end_headers() + self.wfile.write(b"

OpenAI Codex login complete. You can close this tab.

") + + def log_message(self, *args: Any) -> None: + pass + + server = HTTPServer(("localhost", _CODEX_CALLBACK_PORT), _CallbackHandler) + server.timeout = 120 + + print(f"\nOpening browser for OpenAI Codex login...\n{auth_url}\n") + if webbrowser: + webbrowser.open(auth_url) + else: + print(f"Open this URL manually:\n{auth_url}") + + print(f"Waiting for callback on {redirect_uri} ...") + server.handle_request() + server.server_close() + + code = received.get("code") + if not code: + raise RuntimeError("No authorization code received from callback.") + if received.get("state") != state: + raise RuntimeError("OAuth state mismatch — possible CSRF. Aborting.") + + resp = requests.post( + _CODEX_TOKEN_ENDPOINT, + json={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": _CODEX_CLIENT_ID, + "code_verifier": code_verifier, + }, + timeout=30, + ) + resp.raise_for_status() + token = resp.json() + + new_access_token = token["access_token"] + exp = _codex_jwt_expiry(new_access_token) + + new_creds = { + "auth_mode": "chatgpt", + "OPENAI_API_KEY": None, + "tokens": { + "access_token": new_access_token, + "refresh_token": token.get("refresh_token"), + "id_token": token.get("id_token"), + "account_id": None, + }, + "last_refresh": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + + creds_path = os.path.expanduser(_CODEX_CREDS_PATH) + os.makedirs(os.path.dirname(creds_path), exist_ok=True) + tmp = creds_path + ".tmp" + with open(tmp, "w") as f: + json.dump(new_creds, f, indent=2) + os.replace(tmp, creds_path) + os.chmod(creds_path, 0o600) + print(f"OpenAI Codex credentials written to {creds_path}") + + return { + "access_token": new_access_token, + "refresh_token": token.get("refresh_token"), + "id_token": token.get("id_token"), + "token_type": "Bearer", + "expires_at": exp, + "base_url": _CODEX_BASE_URL, + } def google_vertex_adc() -> dict[str, Any]: @@ -601,6 +863,7 @@ def main() -> None: parser.add_argument("--client-id", help="OAuth client ID (for Google flows)") parser.add_argument("--scope", help="OAuth scope override") parser.add_argument("--refresh", action="store_true", help="Refresh existing token instead of new login") + parser.add_argument("--login", action="store_true", help="Force interactive browser login (openai-codex, google-antigravity)") parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logging") args = parser.parse_args() @@ -632,7 +895,23 @@ def main() -> None: token_data = claude_code_oauth() elif args.provider == "openai-codex": - token_data = openai_codex_oauth() + if args.refresh: + creds_path = os.path.expanduser(_CODEX_CREDS_PATH) + with open(creds_path) as f: + creds = json.load(f) + rt = creds.get("tokens", {}).get("refresh_token") + if not rt: + raise RuntimeError("No refresh_token in existing Codex credentials.") + token_data = openai_codex_refresh(rt) + elif args.login: + token_data = openai_codex_login() + else: + try: + token_data = openai_codex_oauth() + print("Using existing OpenAI Codex credentials.", file=sys.stderr) + except RuntimeError: + print("No valid credentials found, starting browser login...", file=sys.stderr) + token_data = openai_codex_login() elif args.provider == "google-gemini-cli": token_data = google_vertex_adc() @@ -656,7 +935,7 @@ def main() -> None: else: print(f"Unknown provider: {args.provider}", file=sys.stderr) - print("Supported: qwen-portal, claude-code, google-gemini-cli, google-antigravity", file=sys.stderr) + print("Supported: qwen-portal, claude-code, openai-codex, google-gemini-cli, google-antigravity", file=sys.stderr) sys.exit(1) # Tokens are written to the provider credentials file by each auth function. diff --git a/faigate/registry.py b/faigate/registry.py index ca78b81..ca7ce88 100644 --- a/faigate/registry.py +++ b/faigate/registry.py @@ -73,18 +73,22 @@ class ProviderDef(TypedDict, total=False): pricing={"input": 15.00, "output": 75.00, "cache_read": 1.50}, notes="Anthropic – Claude Opus/Sonnet/Haiku", ), - # ── OpenAI Code / Codex (OAuth via ChatGPT) ──────────────────────────── - # Auth is OAuth-based; users must run: openclaw models auth login --provider openai-codex - # No static API key env var. Documented only. + # ── OpenAI Codex (ChatGPT OAuth) ────────────────────────────────────── + # Token: ~/.codex/auth.json (auth_mode=chatgpt). Run: faigate-auth openai-codex + # Inference endpoint is chatgpt.com/backend-api/codex/responses, NOT api.openai.com. "openai-codex": ProviderDef( - backend="openai-compat", - base_url="https://api.openai.com/v1", - api_key_env="OPENAI_CODEX_TOKEN", # token injected after OAuth login + backend="oauth", + base_url="https://chatgpt.com/backend-api/codex/responses", + api_key_env="OPENAI_CODEX_TOKEN", auth_optional=True, tier="default", example_model="openai-codex/gpt-5.3-codex", pricing={"input": 0.0, "output": 0.0}, - notes="OpenAI Codex (OAuth via ChatGPT) – requires interactive login", + notes=( + "OpenAI Codex (ChatGPT OAuth) – token from ~/.codex/auth.json. " + "Inference via chatgpt.com/backend-api/codex/responses. " + "Run: faigate-auth openai-codex" + ), ), # ── OpenCode Zen ─────────────────────────────────────────────────────── "opencode": ProviderDef( From ef15b70a5f202054c264f69ca588e96780dc5cdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Mon, 6 Apr 2026 23:07:54 +0200 Subject: [PATCH 2/4] chore: bump version to v2.1.1 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 11 +++++++++++ faigate/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a208aaf..8c84eea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # fusionAIze Gate Changelog +## v2.1.1 - 2026-04-06 + +### Added + +- **OpenAI Codex OAuth**: full ChatGPT OAuth implementation — reads `~/.codex/auth.json`, Auth Code + PKCE login flow on port 1455, single-use refresh token handling, JWT `exp` claim for expiry detection. Inference via `chatgpt.com/backend-api/codex/responses`. Run: `faigate-auth openai-codex` + +### Fixed + +- `registry.py`: corrected `openai-codex` base URL to `chatgpt.com/backend-api/codex/responses` (was incorrectly set to `api.openai.com/v1`) +- `config.yaml`: corrected OAuth endpoints to `auth.openai.com/oauth/token` and client ID to `app_EMoamEEZ73f0CkXaXp7hrann` + ## v2.1.0 - 2026-04-05 ### Added diff --git a/faigate/__init__.py b/faigate/__init__.py index 64a26f0..8f8acbc 100644 --- a/faigate/__init__.py +++ b/faigate/__init__.py @@ -1,3 +1,3 @@ """fusionAIze Gate package.""" -__version__ = "2.1.0" +__version__ = "2.1.1" diff --git a/pyproject.toml b/pyproject.toml index fb0e9a4..10ba670 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "faigate" -version = "2.1.0" +version = "2.1.1" description = "Local OpenAI-compatible routing gateway for OpenClaw and other AI-native clients." readme = "README.md" license = "Apache-2.0" From 56ae6c153c2583dca5a136c5e8679b7222533d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Mon, 6 Apr 2026 23:32:10 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20ruff=20E501=20=E2=80=93=20wrap=20lon?= =?UTF-8?q?g=20lines=20in=20cli.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- faigate/oauth/cli.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/faigate/oauth/cli.py b/faigate/oauth/cli.py index c37bcdf..0fa0f7a 100644 --- a/faigate/oauth/cli.py +++ b/faigate/oauth/cli.py @@ -863,7 +863,9 @@ def main() -> None: parser.add_argument("--client-id", help="OAuth client ID (for Google flows)") parser.add_argument("--scope", help="OAuth scope override") parser.add_argument("--refresh", action="store_true", help="Refresh existing token instead of new login") - parser.add_argument("--login", action="store_true", help="Force interactive browser login (openai-codex, google-antigravity)") + parser.add_argument( + "--login", action="store_true", help="Force interactive browser login (openai-codex, google-antigravity)" + ) parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logging") args = parser.parse_args() @@ -935,7 +937,10 @@ def main() -> None: else: print(f"Unknown provider: {args.provider}", file=sys.stderr) - print("Supported: qwen-portal, claude-code, openai-codex, google-gemini-cli, google-antigravity", file=sys.stderr) + print( + "Supported: qwen-portal, claude-code, openai-codex, google-gemini-cli, google-antigravity", + file=sys.stderr, + ) sys.exit(1) # Tokens are written to the provider credentials file by each auth function. From cf6ea562d5ca8b342c57ea8722eabaa50bbe1050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Mon, 6 Apr 2026 23:42:37 +0200 Subject: [PATCH 4/4] fix: ruff format oauth/cli.py Co-Authored-By: Claude Sonnet 4.6 --- faigate/oauth/cli.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/faigate/oauth/cli.py b/faigate/oauth/cli.py index 0fa0f7a..2db36fd 100644 --- a/faigate/oauth/cli.py +++ b/faigate/oauth/cli.py @@ -591,9 +591,7 @@ def openai_codex_oauth() -> dict[str, Any]: exp = _codex_jwt_expiry(access_token) if exp is not None and exp < time.time(): - logger.warning( - "OpenAI Codex token expired (exp=%s). Run: faigate-auth openai-codex --refresh", exp - ) + logger.warning("OpenAI Codex token expired (exp=%s). Run: faigate-auth openai-codex --refresh", exp) return { "access_token": access_token, @@ -682,9 +680,7 @@ def openai_codex_login() -> dict[str, Any]: raise RuntimeError("requests package required. Install with: pip install faigate[oauth]") code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode() - code_challenge = ( - base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode() - ) + code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode() state = secrets.token_urlsafe(24) redirect_uri = f"http://localhost:{_CODEX_CALLBACK_PORT}/auth/callback"