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/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/__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/faigate/oauth/cli.py b/faigate/oauth/cli.py index b5b37c2..2db36fd 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,258 @@ 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 +859,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("-v", "--verbose", action="store_true", help="Verbose logging") args = parser.parse_args() @@ -632,7 +893,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 +933,10 @@ 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( 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"