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"