diff --git a/codex-oauth-gateway-python/.gitignore b/codex-oauth-gateway-python/.gitignore new file mode 100644 index 0000000..f48f998 --- /dev/null +++ b/codex-oauth-gateway-python/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.venv/ +.tokens/ diff --git a/codex-oauth-gateway-python/README.md b/codex-oauth-gateway-python/README.md new file mode 100644 index 0000000..0de0009 --- /dev/null +++ b/codex-oauth-gateway-python/README.md @@ -0,0 +1,34 @@ +# codex-oauth-gateway-python + +This directory is an **isolated Python migration workspace** for `experiments/codex-oauth-gateway`. + +## Scope +- Build a Python implementation of the gateway incrementally. +- Keep `experiments/codex-oauth-gateway` unchanged during migration. + +## Current status (v0 scaffold) +- Basic Python HTTP server with `GET /health` and `POST /responses`. +- Model normalization and SSE final-event parsing helpers. +- Structured gateway errors (`status` + `code`). +- Minimal unit tests for normalization/response helpers. + +## Run locally +```bash +cd codex-oauth-gateway-python +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +PYTHONPATH=. python -m unittest discover -s tests +python auth_cli.py +python main.py +``` + +Then check: +```bash +curl -s http://127.0.0.1:8787/health +``` + +## Notes +- This is migration work-in-progress and not feature-parity yet. +- OAuth token refresh flow is now wired (refresh token grant). +- OAuth tokens are stored at `~/.codex-oauth-gateway-python/openai.json` by default. Set `CODEX_GATEWAY_TOKEN_FILE` to override this path. diff --git a/codex-oauth-gateway-python/auth_cli.py b/codex-oauth-gateway-python/auth_cli.py new file mode 100644 index 0000000..4dba9cb --- /dev/null +++ b/codex-oauth-gateway-python/auth_cli.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import threading +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +from gateway.auth import ( + create_authorization_flow, + exchange_authorization_code, + parse_authorization_input, + save_tokens, +) + + +def _wait_for_callback(expected_state: str, timeout_seconds: int = 180) -> tuple[str | None, str | None]: + result = {"code": None, "state": None} + done = threading.Event() + + class CallbackHandler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + parsed = urlparse(self.path) + if parsed.path != "/auth/callback": + self.send_response(404) + self.end_headers() + return + + query = parse_qs(parsed.query) + result["code"] = query.get("code", [None])[0] + result["state"] = query.get("state", [None])[0] + done.set() + + self.send_response(200) + self.send_header("content-type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write(b"OAuth callback received. You can return to terminal.") + + def log_message(self, format, *args): # noqa: A003 + return + + try: + server = ThreadingHTTPServer(("127.0.0.1", 1455), CallbackHandler) + except OSError: + return None, None + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + done.wait(timeout_seconds) + return result["code"], result["state"] + finally: + server.shutdown() + server.server_close() + thread.join(timeout=1) + + +def main() -> int: + flow = create_authorization_flow() + print("Open this URL in your browser and complete login (trying auto-open):\n") + print(flow.url) + try: + webbrowser.open(flow.url) + except Exception: + pass + + print("\nWaiting for callback on http://localhost:1455/auth/callback ...") + code, state = _wait_for_callback(flow.state) + + if not code: + print("No callback captured. Paste callback URL, 'code=...&state=...', or 'code#state':") + raw = input("> ") + code, state = parse_authorization_input(raw) + + if not code: + print("No authorization code found.") + return 1 + if state and state != flow.state: + print("State mismatch. Aborting.") + return 1 + + tokens = exchange_authorization_code(code, flow.verifier) + save_tokens(tokens) + print("OAuth tokens saved successfully.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/codex-oauth-gateway-python/docs/manual-real-function-tests.md b/codex-oauth-gateway-python/docs/manual-real-function-tests.md new file mode 100644 index 0000000..dcd8878 --- /dev/null +++ b/codex-oauth-gateway-python/docs/manual-real-function-tests.md @@ -0,0 +1,482 @@ +# Manual Real-Function Test Cases + +This document collects manual test cases for validating the gateway against real OAuth and backend behavior. It is intended as a living checklist while the gateway is still evolving. + +## Scope + +These tests focus on externally visible behavior: + +- OAuth login and callback capture +- token persistence and refresh +- gateway health checks +- non-streaming real responses +- streaming real responses +- request transformation behavior +- error handling and edge cases + +They are not a replacement for unit tests. They are meant to answer: "Can this gateway actually work end to end with a real account and real backend?" + +## Prerequisites + +- Python environment is ready. +- Dependencies are installed with `pip install -r requirements.txt`. +- The gateway is run from `codex-oauth-gateway-python`. +- The machine can reach `auth.openai.com` and `chatgpt.com`. +- The user has a ChatGPT account that can access the target Codex backend models. +- No unrelated service is using port `1455` during OAuth callback testing. +- No unrelated service is using port `8787` during gateway testing, unless `CODEX_GATEWAY_PORT` is set. + +Useful commands: + +```powershell +python -m unittest discover -s tests +python auth_cli.py +python main.py +``` + +Token default path: + +```text +~/.codex-oauth-gateway-python/openai.json +``` + +Override path: + +```powershell +$env:CODEX_GATEWAY_TOKEN_FILE = "C:\path\to\openai.json" +``` + +## Test Group 1: OAuth Login + +### 1.1 Automatic Browser Callback + +Purpose: verify that the CLI can start an OAuth flow, open the browser, capture the callback, and save tokens. + +Steps: + +1. Delete or move any existing token file from `~/.codex-oauth-gateway-python/openai.json`. +2. Run: + +```powershell +python auth_cli.py +``` + +3. Complete login in the browser. +4. Return to the terminal after the callback page appears. + +Expected result: + +- Browser opens automatically or the terminal prints a login URL. +- Terminal prints that OAuth tokens were saved successfully. +- `~/.codex-oauth-gateway-python/openai.json` exists. +- The saved JSON contains `type`, `access`, `refresh`, and `expires`. + +Notes to observe: + +- Whether the browser opens reliably on Windows and Linux. +- Whether the callback message is understandable. +- Whether token save failures produce a useful error. + +### 1.2 Manual Callback Fallback + +Purpose: verify that login still works if the local callback server cannot be used. + +Steps: + +1. Occupy port `1455` with another process, or otherwise make the callback listener fail. +2. Run: + +```powershell +python auth_cli.py +``` + +3. Complete login and manually paste the callback URL or `code=...&state=...` into the terminal. + +Expected result: + +- CLI falls back to manual input. +- Valid callback input is accepted. +- Tokens are saved successfully. + +### 1.3 State Mismatch Rejection + +Purpose: verify that the CLI rejects a callback with the wrong OAuth state. + +Steps: + +1. Run: + +```powershell +python auth_cli.py +``` + +2. At the manual input prompt, paste a callback value with a valid-looking code but incorrect state. + +Expected result: + +- CLI rejects the input. +- It prints a state mismatch message. +- No new token file should be written from the invalid callback. + +Discussion point: + +- The callback listener receives `expected_state`; the strictness of state validation should be checked carefully in both automatic and manual paths. + +## Test Group 2: Token Lifecycle + +### 2.1 Health Check With Valid Token + +Purpose: verify that the gateway can detect an authenticated state. + +Steps: + +1. Complete OAuth login. +2. Start the gateway: + +```powershell +python main.py +``` + +3. In another terminal: + +```powershell +curl.exe -s http://127.0.0.1:8787/health +``` + +Expected result: + +- Response has `"ok": true`. +- Response has `"authenticated": true`. +- Response includes the token file path. +- Response includes a non-null `expires` value. + +### 2.2 Missing Token + +Purpose: verify behavior when no token file exists. + +Steps: + +1. Move or delete the token file. +2. Start the gateway. +3. Call: + +```powershell +curl.exe -s http://127.0.0.1:8787/health +``` + +4. Send a minimal response request. + +Expected result: + +- `/health` reports `"authenticated": false`. +- `POST /responses` returns an error with code `MISSING_TOKENS`. +- The gateway process does not crash. + +### 2.3 Expired Token Refresh + +Purpose: verify that an expired access token is refreshed automatically. + +Steps: + +1. Complete OAuth login. +2. Open the token file. +3. Change `expires` to a timestamp in the past, such as `0`. +4. Start the gateway. +5. Send a minimal non-streaming request. + +Expected result: + +- Gateway refreshes the access token before calling the backend. +- Token file is updated. +- Request succeeds if the refresh token is valid. + +### 2.4 Invalid Refresh Token + +Purpose: verify a clean error when token refresh fails. + +Steps: + +1. Complete OAuth login. +2. Change the token file's `expires` to `0`. +3. Replace `refresh` with an invalid value. +4. Send a minimal request. + +Expected result: + +- Gateway returns an error with code `TOKEN_REFRESH_FAILED`. +- The error is not a Python traceback. +- The gateway remains running. + +## Test Group 3: Real Non-Streaming Response + +### 3.1 Minimal Non-Streaming Request + +Purpose: verify that the gateway can send a real request and convert final SSE output to JSON. + +Steps: + +1. Complete OAuth login. +2. Start the gateway. +3. Send: + +```powershell +$body = @{ + model = "gpt-5.1-codex" + stream = $false + input = @( + @{ + role = "user" + content = "Reply with exactly: gateway-ok" + } + ) +} | ConvertTo-Json -Depth 10 + +curl.exe -s http://127.0.0.1:8787/responses ` + -H "content-type: application/json" ` + -d $body +``` + +Expected result: + +- HTTP status is 200. +- Response content type is JSON. +- Response contains a final response object. +- `output_text` or `output` contains a reply close to `gateway-ok`. + +Notes to observe: + +- Whether the backend returns empty `output` and relies on delta reconstruction. +- Whether `parse_final_response` reconstructs usable JSON. + +### 3.2 Custom Instructions Override + +Purpose: verify that caller-provided instructions override default gateway instructions. + +Steps: + +```powershell +$body = @{ + model = "gpt-5.1-codex" + stream = $false + instructions = "Always reply with exactly: custom-instructions-ok" + input = @( + @{ + role = "user" + content = "Say hello." + } + ) +} | ConvertTo-Json -Depth 10 + +curl.exe -s http://127.0.0.1:8787/responses ` + -H "content-type: application/json" ` + -d $body +``` + +Expected result: + +- Response follows the caller-provided instructions. +- Gateway does not overwrite the supplied `instructions`. + +## Test Group 4: Real Streaming Response + +### 4.1 Basic Streaming Request + +Purpose: verify that `stream: true` returns server-sent events. + +Steps: + +```powershell +$body = @{ + model = "gpt-5.1-codex" + stream = $true + input = @( + @{ + role = "user" + content = "Count from 1 to 5, one number per line." + } + ) +} | ConvertTo-Json -Depth 10 + +curl.exe -N http://127.0.0.1:8787/responses ` + -H "content-type: application/json" ` + -d $body +``` + +Expected result: + +- Output arrives as `data: ...` events. +- Content appears incrementally. +- Final event is present. +- Gateway remains usable after the stream completes. + +### 4.2 Client Interrupt + +Purpose: verify that interrupting a streaming client does not kill the gateway. + +Steps: + +1. Start a streaming request. +2. Press `Ctrl+C` before the response finishes. +3. Send `GET /health`. + +Expected result: + +- Client interruption may end that request. +- Gateway process remains alive. +- `/health` still responds. + +## Test Group 5: Request Transformation + +### 5.1 Default Model + +Purpose: verify behavior when no model is provided. + +Steps: + +```powershell +$body = @{ + stream = $false + input = @( + @{ + role = "user" + content = "Reply with exactly: default-model-ok" + } + ) +} | ConvertTo-Json -Depth 10 + +curl.exe -s http://127.0.0.1:8787/responses ` + -H "content-type: application/json" ` + -d $body +``` + +Expected result: + +- Request succeeds. +- Gateway chooses the default normalized model. + +### 5.2 Legacy Codex Model Alias + +Purpose: verify that legacy model names map to supported model names. + +Suggested inputs: + +- `gpt-5-codex` +- `gpt-5-codex-mini` +- `codex-mini-latest` + +Expected result: + +- Requests succeed when the mapped backend model is available. +- Failures should be backend/model-access errors, not local validation crashes. + +### 5.3 Include Preservation + +Purpose: verify that caller-supplied `include` values are preserved while `reasoning.encrypted_content` is added. + +Steps: + +1. Send a request with a custom `include` array. +2. Observe response behavior. + +Expected result: + +- Request succeeds. +- No duplicate include values should be sent. +- Stateless encrypted reasoning behavior should remain enabled. + +Discussion point: + +- The gateway currently lacks safe request logging, so this is hard to verify directly without instrumentation. + +## Test Group 6: Error And Boundary Behavior + +### 6.1 Invalid JSON + +Purpose: verify invalid body handling. + +Steps: + +```powershell +curl.exe -s http://127.0.0.1:8787/responses ` + -H "content-type: application/json" ` + -d "{not-json" +``` + +Expected result: + +- HTTP status is 400. +- Error code is `INVALID_JSON`. + +### 6.2 Missing Input + +Purpose: verify required input validation. + +Steps: + +```powershell +curl.exe -s http://127.0.0.1:8787/responses ` + -H "content-type: application/json" ` + -d '{"model":"gpt-5.1-codex"}' +``` + +Expected result: + +- HTTP status is 400. +- Error code is `MISSING_INPUT`. + +### 6.3 Large Body + +Purpose: verify request size protection. + +Steps: + +1. Send a body larger than 20 MB. +2. Observe response. + +Expected result: + +- HTTP status is 413. +- Error code is `REQUEST_BODY_TOO_LARGE`. + +### 6.4 Backend Unavailable + +Purpose: verify upstream request error handling. + +Steps: + +1. Temporarily block access to the backend or set the machine offline. +2. Send a request. + +Expected result: + +- Gateway returns `UPSTREAM_REQUEST_FAILED` or `UPSTREAM_TIMEOUT`. +- Gateway process remains running. + +### 6.5 Usage Limit + +Purpose: verify usage limit mapping. + +Steps: + +1. Trigger or simulate a backend `usage_limit_exceeded` response. +2. Observe gateway status code. + +Expected result: + +- Gateway maps backend 404 usage-limit responses to HTTP 429. + +Discussion point: + +- This may be difficult to trigger safely with a real account and may need a mock upstream test harness later. + +## First Manual Test Pass Recommendation + +For the first real test pass, prioritize: + +1. OAuth login saves a token. +2. `/health` reports authenticated. +3. Non-streaming `/responses` returns JSON. +4. Streaming `/responses` returns SSE. +5. Expired token refreshes successfully. + +If these pass, the main gateway path is usable. The remaining cases can be used to harden reliability, security, and compatibility. diff --git a/codex-oauth-gateway-python/gateway/__init__.py b/codex-oauth-gateway-python/gateway/__init__.py new file mode 100644 index 0000000..5cea705 --- /dev/null +++ b/codex-oauth-gateway-python/gateway/__init__.py @@ -0,0 +1 @@ +"""Python gateway package.""" diff --git a/codex-oauth-gateway-python/gateway/auth.py b/codex-oauth-gateway-python/gateway/auth.py new file mode 100644 index 0000000..291d575 --- /dev/null +++ b/codex-oauth-gateway-python/gateway/auth.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import base64 +import json +import secrets +import time +from hashlib import sha256 +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import parse_qs, urlencode, urlparse + +import requests + +from .config import ( + AUTHORIZE_URL, + CLIENT_ID, + JWT_CLAIM_PATH, + REDIRECT_URI, + SCOPE, + TOKEN_FILE, + TOKEN_URL, +) +from .errors import GatewayError + + +@dataclass +class TokenSet: + access: str + refresh: str + expires: int + + +@dataclass +class AuthorizationFlow: + url: str + verifier: str + state: str + + +def _pad_base64url(value: str) -> str: + return value + "=" * ((4 - len(value) % 4) % 4) + + +def decode_jwt(token: str) -> dict | None: + try: + parts = token.split(".") + if len(parts) != 3: + return None + payload = parts[1] + data = base64.urlsafe_b64decode(_pad_base64url(payload)).decode("utf-8") + return json.loads(data) + except Exception: + return None + + +def create_state() -> str: + return secrets.token_hex(16) + + +def create_pkce_pair() -> tuple[str, str]: + verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8").rstrip("=") + challenge = base64.urlsafe_b64encode(sha256(verifier.encode("utf-8")).digest()).decode("utf-8").rstrip("=") + return verifier, challenge + + +def create_authorization_flow() -> AuthorizationFlow: + verifier, challenge = create_pkce_pair() + state = create_state() + query = urlencode( + { + "response_type": "code", + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "scope": SCOPE, + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": state, + "id_token_add_organizations": "true", + "codex_cli_simplified_flow": "true", + "originator": "codex_cli_rs", + } + ) + return AuthorizationFlow(url=f"{AUTHORIZE_URL}?{query}", verifier=verifier, state=state) + + +def parse_authorization_input(value: str) -> tuple[str | None, str | None]: + raw = value.strip() + if not raw: + return None, None + + if raw.startswith("http://") or raw.startswith("https://"): + parsed = urlparse(raw) + query = parse_qs(parsed.query) + return query.get("code", [None])[0], query.get("state", [None])[0] + + if "code=" in raw: + query = parse_qs(raw) + return query.get("code", [None])[0], query.get("state", [None])[0] + + if "#" in raw: + code, state = raw.split("#", 1) + return code or None, state or None + + return raw, None + + +def get_chatgpt_account_id(access_token: str) -> str | None: + decoded = decode_jwt(access_token) + if not decoded: + return None + claim = decoded.get(JWT_CLAIM_PATH, {}) + return claim.get("chatgpt_account_id") + + +def load_tokens() -> TokenSet | None: + if not TOKEN_FILE.exists(): + return None + try: + parsed = json.loads(TOKEN_FILE.read_text(encoding="utf-8")) + if parsed.get("type") != "oauth": + return None + return TokenSet( + access=parsed["access"], + refresh=parsed["refresh"], + expires=int(parsed["expires"]), + ) + except Exception: + return None + + +def save_tokens(tokens: TokenSet) -> None: + token_path = Path(TOKEN_FILE) + token_path.parent.mkdir(parents=True, exist_ok=True) + token_path.write_text( + json.dumps( + { + "type": "oauth", + "access": tokens.access, + "refresh": tokens.refresh, + "expires": tokens.expires, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + +def refresh_access_token(refresh_token: str) -> TokenSet: + try: + response = requests.post( + TOKEN_URL, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + }, + timeout=30, + ) + except requests.RequestException as error: + raise GatewayError(401, "TOKEN_REFRESH_FAILED", f"Token refresh request failed: {error}") from error + + if response.status_code != 200: + raise GatewayError( + 401, + "TOKEN_REFRESH_FAILED", + f"Token refresh failed with status {response.status_code}.", + response.text, + ) + + try: + parsed = response.json() + access = parsed["access_token"] + refresh = parsed.get("refresh_token", refresh_token) + expires_in = int(parsed["expires_in"]) + except Exception as error: + raise GatewayError(401, "TOKEN_REFRESH_FAILED", "Token refresh response missing fields.") from error + + return TokenSet( + access=access, + refresh=refresh, + expires=int(time.time() * 1000) + expires_in * 1000, + ) + + +def exchange_authorization_code(code: str, verifier: str) -> TokenSet: + try: + response = requests.post( + TOKEN_URL, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "grant_type": "authorization_code", + "client_id": CLIENT_ID, + "code": code, + "code_verifier": verifier, + "redirect_uri": REDIRECT_URI, + }, + timeout=30, + ) + except requests.RequestException as error: + raise GatewayError(401, "TOKEN_EXCHANGE_FAILED", f"Token exchange request failed: {error}") from error + + if response.status_code != 200: + raise GatewayError( + 401, + "TOKEN_EXCHANGE_FAILED", + f"Token exchange failed with status {response.status_code}.", + response.text, + ) + + try: + parsed = response.json() + access = parsed["access_token"] + refresh = parsed["refresh_token"] + expires_in = int(parsed["expires_in"]) + except Exception as error: + raise GatewayError(401, "TOKEN_EXCHANGE_FAILED", "Token exchange response missing fields.") from error + + return TokenSet( + access=access, + refresh=refresh, + expires=int(time.time() * 1000) + expires_in * 1000, + ) + + +def get_valid_tokens() -> TokenSet: + tokens = load_tokens() + if not tokens: + raise GatewayError(401, "MISSING_TOKENS", f"No OAuth tokens found at {TOKEN_FILE}") + + if tokens.expires > int(time.time() * 1000) + 60_000: + return tokens + + refreshed = refresh_access_token(tokens.refresh) + save_tokens(refreshed) + return refreshed diff --git a/codex-oauth-gateway-python/gateway/config.py b/codex-oauth-gateway-python/gateway/config.py new file mode 100644 index 0000000..9df3980 --- /dev/null +++ b/codex-oauth-gateway-python/gateway/config.py @@ -0,0 +1,30 @@ +import os +from pathlib import Path + +DEFAULT_GATEWAY_PORT = int(os.getenv("CODEX_GATEWAY_PORT", "8787")) +DEFAULT_UPSTREAM_TIMEOUT_SECONDS = int(os.getenv("CODEX_UPSTREAM_TIMEOUT_SECONDS", "60")) +CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses" +TOKEN_URL = "https://auth.openai.com/oauth/token" +AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" +CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +REDIRECT_URI = "http://localhost:1455/auth/callback" +SCOPE = "openid profile email offline_access" +DEFAULT_TOKEN_FILE = Path.home() / ".codex-oauth-gateway-python" / "openai.json" +TOKEN_FILE = Path(os.getenv("CODEX_GATEWAY_TOKEN_FILE") or DEFAULT_TOKEN_FILE).expanduser() +JWT_CLAIM_PATH = "https://api.openai.com/auth" + +OPENAI_HEADERS = { + "account_id": "chatgpt-account-id", + "beta": "OpenAI-Beta", + "originator": "originator", + "conversation_id": "conversation_id", + "session_id": "session_id", +} + +OPENAI_HEADER_VALUES = { + "beta": "responses=experimental", + "originator": "codex_cli_rs", +} + + +DEFAULT_INSTRUCTIONS = "You are a helpful coding assistant. Answer clearly and directly." diff --git a/codex-oauth-gateway-python/gateway/errors.py b/codex-oauth-gateway-python/gateway/errors.py new file mode 100644 index 0000000..5885645 --- /dev/null +++ b/codex-oauth-gateway-python/gateway/errors.py @@ -0,0 +1,6 @@ +class GatewayError(Exception): + def __init__(self, status: int, code: str, message: str, details=None): + super().__init__(message) + self.status = status + self.code = code + self.details = details diff --git a/codex-oauth-gateway-python/gateway/model.py b/codex-oauth-gateway-python/gateway/model.py new file mode 100644 index 0000000..a87ae5a --- /dev/null +++ b/codex-oauth-gateway-python/gateway/model.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +def normalize_model(model: str | None) -> str: + if not model: + return "gpt-5.1" + + model_id = model.split("/")[-1].lower() + + if "gpt-5.2-codex" in model_id: + return "gpt-5.2-codex" + if "gpt-5.2" in model_id: + return "gpt-5.2" + if "gpt-5.1-codex-max" in model_id: + return "gpt-5.1-codex-max" + if "gpt-5.1-codex-mini" in model_id or model_id == "codex-mini-latest": + return "gpt-5.1-codex-mini" + if "gpt-5-codex-mini" in model_id: + return "gpt-5.1-codex-mini" + if "gpt-5.1-codex" in model_id or "gpt-5-codex" in model_id or "codex" in model_id: + return "gpt-5.1-codex" + if "gpt-5.1" in model_id or "gpt-5" in model_id: + return "gpt-5.1" + + return "gpt-5.1" diff --git a/codex-oauth-gateway-python/gateway/response.py b/codex-oauth-gateway-python/gateway/response.py new file mode 100644 index 0000000..1230d8c --- /dev/null +++ b/codex-oauth-gateway-python/gateway/response.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json + + +def parse_final_response(sse_text: str): + text_deltas: list[str] = [] + output_items: list[dict] = [] + output_text_done: str | None = None + final_response = None + + for line in sse_text.splitlines(): + if not line.startswith("data: "): + continue + try: + event = json.loads(line[6:]) + except Exception: + continue + + if event.get("type") in {"response.output_text.delta", "output_text.delta"}: + delta = event.get("delta") + if isinstance(delta, str): + text_deltas.append(delta) + continue + + if event.get("type") == "response.output_text.done": + done_text = event.get("text") + if isinstance(done_text, str): + output_text_done = done_text + continue + + if event.get("type") == "response.output_item.done": + item = event.get("item") + if isinstance(item, dict): + output_items.append(item) + continue + + if event.get("type") in {"response.done", "response.completed"}: + final_response = event.get("response") + + if not final_response: + return None + + if not isinstance(final_response, dict): + return final_response + + output = final_response.get("output") + has_output = isinstance(output, list) and len(output) > 0 + if has_output: + return final_response + + if output_items: + final_response["output"] = output_items + if not final_response.get("output_text"): + text_parts: list[str] = [] + for item in output_items: + if item.get("type") != "message": + continue + for part in item.get("content") or []: + if isinstance(part, dict) and part.get("type") == "output_text" and isinstance(part.get("text"), str): + text_parts.append(part["text"]) + if text_parts: + final_response["output_text"] = "".join(text_parts) + return final_response + + combined_text = output_text_done if output_text_done is not None else "".join(text_deltas) + if combined_text: + final_response["output_text"] = combined_text + final_response["output"] = [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": combined_text}], + } + ] + + return final_response + + +def map_usage_limit_404(status_code: int, body_text: str) -> tuple[int, str]: + if status_code != 404: + return status_code, body_text + try: + parsed = json.loads(body_text) + code = (parsed.get("error") or {}).get("code") or (parsed.get("error") or {}).get("type") + if code == "usage_limit_exceeded": + return 429, body_text + except Exception: + pass + return status_code, body_text diff --git a/codex-oauth-gateway-python/gateway/server.py b/codex-oauth-gateway-python/gateway/server.py new file mode 100644 index 0000000..65a59e2 --- /dev/null +++ b/codex-oauth-gateway-python/gateway/server.py @@ -0,0 +1,163 @@ +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import requests + +from .auth import get_chatgpt_account_id, get_valid_tokens, load_tokens +from .config import ( + CODEX_RESPONSES_URL, + DEFAULT_INSTRUCTIONS, + DEFAULT_GATEWAY_PORT, + DEFAULT_UPSTREAM_TIMEOUT_SECONDS, + OPENAI_HEADERS, + OPENAI_HEADER_VALUES, + TOKEN_FILE, +) +from .errors import GatewayError +from .model import normalize_model +from .response import map_usage_limit_404, parse_final_response + + +def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: dict): + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + handler.send_response(status) + handler.send_header("content-type", "application/json; charset=utf-8") + handler.send_header("content-length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + +def _read_json_body(handler: BaseHTTPRequestHandler) -> dict: + content_length = int(handler.headers.get("content-length", "0")) + if content_length > 20 * 1024 * 1024: + raise GatewayError(413, "REQUEST_BODY_TOO_LARGE", "Request body too large.") + raw = handler.rfile.read(content_length) if content_length > 0 else b"{}" + try: + return json.loads(raw.decode("utf-8")) + except Exception: + raise GatewayError(400, "INVALID_JSON", "Request body must be valid JSON.") + + +def _transform_body(body: dict) -> dict: + if "input" not in body: + raise GatewayError(400, "MISSING_INPUT", "Request body must include an input field.") + + include = list(dict.fromkeys((body.get("include") or []) + ["reasoning.encrypted_content"])) + return { + **body, + "model": normalize_model(body.get("model")), + "store": False, + "stream": True, + "instructions": body.get("instructions") or DEFAULT_INSTRUCTIONS, + "reasoning": { + "effort": (body.get("reasoning") or {}).get("effort", "medium"), + "summary": (body.get("reasoning") or {}).get("summary", "auto"), + }, + "text": { + "verbosity": (body.get("text") or {}).get("verbosity", "medium"), + }, + "include": include, + } + + +class GatewayHandler(BaseHTTPRequestHandler): + server_version = "codex-oauth-gateway-python/0.1" + + def do_GET(self): + if self.path == "/health": + tokens = load_tokens() + return _json_response( + self, + 200, + { + "ok": True, + "authenticated": tokens is not None, + "tokenFile": str(TOKEN_FILE), + "expires": tokens.expires if tokens else None, + }, + ) + return _json_response(self, 404, {"error": "Not found", "routes": ["GET /health", "POST /responses"]}) + + def do_POST(self): + if self.path != "/responses": + return _json_response(self, 404, {"error": "Not found", "routes": ["GET /health", "POST /responses"]}) + + try: + input_body = _read_json_body(self) + requested_stream = input_body.get("stream") is True + body = _transform_body(input_body) + tokens = get_valid_tokens() + account_id = get_chatgpt_account_id(tokens.access) + if not account_id: + raise GatewayError(401, "INVALID_ACCESS_TOKEN", "Could not extract ChatGPT account id from access token.") + + headers = { + "Authorization": f"Bearer {tokens.access}", + OPENAI_HEADERS["account_id"]: account_id, + OPENAI_HEADERS["beta"]: OPENAI_HEADER_VALUES["beta"], + OPENAI_HEADERS["originator"]: OPENAI_HEADER_VALUES["originator"], + "accept": "text/event-stream", + "content-type": "application/json", + } + if body.get("prompt_cache_key"): + headers[OPENAI_HEADERS["conversation_id"]] = body["prompt_cache_key"] + headers[OPENAI_HEADERS["session_id"]] = body["prompt_cache_key"] + + upstream = requests.post( + CODEX_RESPONSES_URL, + headers=headers, + json=body, + timeout=DEFAULT_UPSTREAM_TIMEOUT_SECONDS, + stream=True, + ) + + if requested_stream: + self.send_response(upstream.status_code) + self.send_header("x-gateway-upstream-retry-attempts", "0") + self.send_header("content-type", upstream.headers.get("content-type", "text/event-stream; charset=utf-8")) + self.end_headers() + for chunk in upstream.iter_content(chunk_size=1024): + if chunk: + self.wfile.write(chunk) + return + + full_text = upstream.text + status, full_text = map_usage_limit_404(upstream.status_code, full_text) + final = parse_final_response(full_text) + if final is None: + self.send_response(status) + self.send_header("x-gateway-upstream-retry-attempts", "0") + self.send_header("content-type", "text/event-stream; charset=utf-8") + self.end_headers() + self.wfile.write(full_text.encode("utf-8")) + return + + payload = json.dumps(final, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("x-gateway-upstream-retry-attempts", "0") + self.send_header("content-type", "application/json; charset=utf-8") + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + except GatewayError as error: + return _json_response( + self, + error.status, + { + "error": str(error), + "code": error.code, + "details": error.details, + }, + ) + except requests.Timeout: + return _json_response(self, 504, {"error": "Upstream timeout.", "code": "UPSTREAM_TIMEOUT"}) + except requests.RequestException as error: + return _json_response(self, 502, {"error": str(error), "code": "UPSTREAM_REQUEST_FAILED"}) + except Exception as error: # noqa: BLE001 + return _json_response(self, 500, {"error": str(error), "code": "INTERNAL_SERVER_ERROR"}) + + +def start_server(port: int = DEFAULT_GATEWAY_PORT): + server = ThreadingHTTPServer(("127.0.0.1", port), GatewayHandler) + print(f"codex-oauth-gateway-python listening on http://127.0.0.1:{port}") + server.serve_forever() diff --git a/codex-oauth-gateway-python/main.py b/codex-oauth-gateway-python/main.py new file mode 100644 index 0000000..b3b992b --- /dev/null +++ b/codex-oauth-gateway-python/main.py @@ -0,0 +1,6 @@ +from gateway.config import DEFAULT_GATEWAY_PORT +from gateway.server import start_server + + +if __name__ == "__main__": + start_server(DEFAULT_GATEWAY_PORT) diff --git a/codex-oauth-gateway-python/requirements.txt b/codex-oauth-gateway-python/requirements.txt new file mode 100644 index 0000000..0eb8cae --- /dev/null +++ b/codex-oauth-gateway-python/requirements.txt @@ -0,0 +1 @@ +requests>=2.31.0 diff --git a/codex-oauth-gateway-python/tests/test_auth.py b/codex-oauth-gateway-python/tests/test_auth.py new file mode 100644 index 0000000..1134c43 --- /dev/null +++ b/codex-oauth-gateway-python/tests/test_auth.py @@ -0,0 +1,75 @@ +import json +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +from gateway import auth + + +class AuthTests(unittest.TestCase): + def test_get_valid_tokens_returns_existing_token_when_not_expired(self): + with tempfile.TemporaryDirectory() as tmpdir: + token_file = Path(tmpdir) / "openai.json" + token_file.write_text( + json.dumps( + { + "type": "oauth", + "access": "a.b.c", + "refresh": "refresh_1", + "expires": int(time.time() * 1000) + 3600 * 1000, + } + ), + encoding="utf-8", + ) + original = auth.TOKEN_FILE + auth.TOKEN_FILE = token_file + try: + token_set = auth.get_valid_tokens() + self.assertEqual(token_set.refresh, "refresh_1") + finally: + auth.TOKEN_FILE = original + + @patch("gateway.auth.requests.post") + def test_get_valid_tokens_refreshes_and_persists_when_expired(self, mock_post): + class FakeResponse: + status_code = 200 + + @staticmethod + def json(): + return { + "access_token": "new_access", + "refresh_token": "new_refresh", + "expires_in": 3600, + } + + mock_post.return_value = FakeResponse() + + with tempfile.TemporaryDirectory() as tmpdir: + token_file = Path(tmpdir) / "openai.json" + token_file.write_text( + json.dumps( + { + "type": "oauth", + "access": "a.b.c", + "refresh": "refresh_1", + "expires": int(time.time() * 1000) - 1000, + } + ), + encoding="utf-8", + ) + original = auth.TOKEN_FILE + auth.TOKEN_FILE = token_file + try: + token_set = auth.get_valid_tokens() + self.assertEqual(token_set.access, "new_access") + persisted = json.loads(token_file.read_text(encoding="utf-8")) + self.assertEqual(persisted["access"], "new_access") + self.assertEqual(persisted["refresh"], "new_refresh") + finally: + auth.TOKEN_FILE = original + + +if __name__ == "__main__": + unittest.main() diff --git a/codex-oauth-gateway-python/tests/test_auth_flow.py b/codex-oauth-gateway-python/tests/test_auth_flow.py new file mode 100644 index 0000000..fbc26d2 --- /dev/null +++ b/codex-oauth-gateway-python/tests/test_auth_flow.py @@ -0,0 +1,52 @@ +import unittest +from unittest.mock import patch + +from gateway.auth import ( + create_authorization_flow, + exchange_authorization_code, + parse_authorization_input, +) + + +class AuthFlowTests(unittest.TestCase): + def test_create_authorization_flow_contains_pkce_and_state(self): + flow = create_authorization_flow() + self.assertIn("response_type=code", flow.url) + self.assertIn("code_challenge_method=S256", flow.url) + self.assertTrue(flow.verifier) + self.assertTrue(flow.state) + + def test_parse_authorization_input_variants(self): + code, state = parse_authorization_input("http://localhost/callback?code=abc&state=xyz") + self.assertEqual(code, "abc") + self.assertEqual(state, "xyz") + + code, state = parse_authorization_input("code=abc&state=xyz") + self.assertEqual(code, "abc") + self.assertEqual(state, "xyz") + + code, state = parse_authorization_input("abc#xyz") + self.assertEqual(code, "abc") + self.assertEqual(state, "xyz") + + @patch("gateway.auth.requests.post") + def test_exchange_authorization_code_success(self, mock_post): + class FakeResponse: + status_code = 200 + + @staticmethod + def json(): + return { + "access_token": "acc", + "refresh_token": "ref", + "expires_in": 3600, + } + + mock_post.return_value = FakeResponse() + token_set = exchange_authorization_code("auth_code", "pkce_verifier") + self.assertEqual(token_set.access, "acc") + self.assertEqual(token_set.refresh, "ref") + + +if __name__ == "__main__": + unittest.main() diff --git a/codex-oauth-gateway-python/tests/test_model_response.py b/codex-oauth-gateway-python/tests/test_model_response.py new file mode 100644 index 0000000..1ad50da --- /dev/null +++ b/codex-oauth-gateway-python/tests/test_model_response.py @@ -0,0 +1,73 @@ +import unittest + +from gateway.model import normalize_model +from gateway.response import map_usage_limit_404, parse_final_response + + +class GatewayMigrationTests(unittest.TestCase): + def test_normalize_model(self): + self.assertEqual(normalize_model("gpt-5-codex-mini"), "gpt-5.1-codex-mini") + self.assertEqual(normalize_model("gpt-5-codex"), "gpt-5.1-codex") + self.assertEqual(normalize_model("gpt-5.2"), "gpt-5.2") + + def test_parse_final_response(self): + sse = "\n".join([ + 'data: {"type":"response.output_text.delta","delta":"hi"}', + 'data: {"type":"response.done","response":{"id":"resp_1"}}', + "", + ]) + self.assertEqual( + parse_final_response(sse), + { + "id": "resp_1", + "output_text": "hi", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi"}], + } + ], + }, + ) + + def test_parse_final_response_preserves_existing_output(self): + sse = "\n".join([ + 'data: {"type":"response.output_text.delta","delta":"hi"}', + 'data: {"type":"response.done","response":{"id":"resp_1","output":[{"type":"message"}]}}', + "", + ]) + self.assertEqual( + parse_final_response(sse), + {"id": "resp_1", "output": [{"type": "message"}]}, + ) + + def test_parse_final_response_uses_output_item_done(self): + sse = "\n".join([ + 'data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi\\n"}]}}', + 'data: {"type":"response.completed","response":{"id":"resp_1","output":[]}}', + "", + ]) + self.assertEqual( + parse_final_response(sse), + { + "id": "resp_1", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi\n"}], + } + ], + "output_text": "hi\n", + }, + ) + + def test_map_usage_limit(self): + status, body = map_usage_limit_404(404, '{"error":{"code":"usage_limit_exceeded"}}') + self.assertEqual(status, 429) + self.assertEqual(body, '{"error":{"code":"usage_limit_exceeded"}}') + + +if __name__ == "__main__": + unittest.main() diff --git a/codex-oauth-gateway-python/tests/test_server_integration.py b/codex-oauth-gateway-python/tests/test_server_integration.py new file mode 100644 index 0000000..f9bf934 --- /dev/null +++ b/codex-oauth-gateway-python/tests/test_server_integration.py @@ -0,0 +1,126 @@ +import base64 +import json +import tempfile +import threading +import time +import unittest +from http.client import HTTPConnection +from pathlib import Path +from unittest.mock import patch + +from gateway import auth, server +from gateway.server import GatewayHandler + + +def _jwt_with_account(account_id: str) -> str: + payload = base64.urlsafe_b64encode( + json.dumps({"https://api.openai.com/auth": {"chatgpt_account_id": account_id}}).encode("utf-8") + ).decode("utf-8").rstrip("=") + return f"a.{payload}.c" + + +class ServerIntegrationTests(unittest.TestCase): + def setUp(self): + self.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), GatewayHandler) + self.port = self.httpd.server_address[1] + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + time.sleep(0.05) + + def tearDown(self): + self.httpd.shutdown() + self.httpd.server_close() + self.thread.join(timeout=1) + + def test_health_and_validation_error_paths(self): + with tempfile.TemporaryDirectory() as tmpdir: + token_file = Path(tmpdir) / "openai.json" + token_file.write_text( + json.dumps( + { + "type": "oauth", + "access": _jwt_with_account("acct_test"), + "refresh": "refresh_1", + "expires": int(time.time() * 1000) + 3600 * 1000, + } + ), + encoding="utf-8", + ) + + original_auth_token_file = auth.TOKEN_FILE + original_server_token_file = server.TOKEN_FILE + auth.TOKEN_FILE = token_file + server.TOKEN_FILE = token_file + try: + conn = HTTPConnection("127.0.0.1", self.port, timeout=5) + conn.request("GET", "/health") + response = conn.getresponse() + self.assertEqual(response.status, 200) + payload = json.loads(response.read().decode("utf-8")) + self.assertTrue(payload["authenticated"]) + conn.close() + + conn = HTTPConnection("127.0.0.1", self.port, timeout=5) + conn.request("POST", "/responses", body=b'{"model":"gpt-5.1-codex"}', headers={"content-type": "application/json"}) + response = conn.getresponse() + self.assertEqual(response.status, 400) + payload = json.loads(response.read().decode("utf-8")) + self.assertEqual(payload["code"], "MISSING_INPUT") + conn.close() + finally: + auth.TOKEN_FILE = original_auth_token_file + server.TOKEN_FILE = original_server_token_file + + @patch("gateway.server.requests.post") + def test_non_stream_path_returns_json(self, mock_post): + class FakeResponse: + status_code = 200 + headers = {"content-type": "text/event-stream"} + text = 'data: {"type":"response.done","response":{"id":"resp_1"}}\n' + + @staticmethod + def iter_content(chunk_size=1024): + yield b"" + + mock_post.return_value = FakeResponse() + + with tempfile.TemporaryDirectory() as tmpdir: + token_file = Path(tmpdir) / "openai.json" + token_file.write_text( + json.dumps( + { + "type": "oauth", + "access": _jwt_with_account("acct_test"), + "refresh": "refresh_1", + "expires": int(time.time() * 1000) + 3600 * 1000, + } + ), + encoding="utf-8", + ) + + original_auth_token_file = auth.TOKEN_FILE + original_server_token_file = server.TOKEN_FILE + auth.TOKEN_FILE = token_file + server.TOKEN_FILE = token_file + try: + conn = HTTPConnection("127.0.0.1", self.port, timeout=5) + body = json.dumps( + {"model": "gpt-5.1-codex", "stream": False, "input": [{"role": "user", "content": "hi"}]} + ).encode("utf-8") + conn.request("POST", "/responses", body=body, headers={"content-type": "application/json"}) + response = conn.getresponse() + self.assertEqual(response.status, 200) + payload = json.loads(response.read().decode("utf-8")) + self.assertEqual(payload["id"], "resp_1") + self.assertTrue(mock_post.called) + called_json = mock_post.call_args.kwargs["json"] + self.assertIn("instructions", called_json) + self.assertTrue(called_json["instructions"]) + conn.close() + finally: + auth.TOKEN_FILE = original_auth_token_file + server.TOKEN_FILE = original_server_token_file + + +if __name__ == "__main__": + unittest.main() diff --git a/experiments/codex-oauth-gateway/.gitignore b/experiments/codex-oauth-gateway/.gitignore new file mode 100644 index 0000000..89dac1a --- /dev/null +++ b/experiments/codex-oauth-gateway/.gitignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +.tokens/ +*.log diff --git a/experiments/codex-oauth-gateway/PROJECT_STATUS_2026-04.md b/experiments/codex-oauth-gateway/PROJECT_STATUS_2026-04.md new file mode 100644 index 0000000..96fe968 --- /dev/null +++ b/experiments/codex-oauth-gateway/PROJECT_STATUS_2026-04.md @@ -0,0 +1,69 @@ +# 项目阶段评估(experiments/codex-oauth-gateway) + +> 评估日期:2026-04-28 + +## 1) 当前阶段结论 + +该项目目前处于 **可运行 PoC(Proof of Concept)/ 预产品化** 阶段,不是主仓库主插件那种“成熟维护期”。 + +判断依据: + +- 项目定位在 README 中明确写为 `Standalone experiment`(独立实验),并强调“personal experimentation”。 +- 版本仍是 `0.1.0` 且 `private: true`,说明还未进入可公开发布与稳定 API 承诺阶段。 +- 已实现核心闭环:OAuth 登录 + token 刷新 + 本地网关 `/responses` 转发 + `stream:false` 时 SSE→JSON 转换。 +- 但缺少测试体系、CI、发布流程、配置校验、可观测性与安全加固等产品化要素。 + +## 2) 已完成能力(你们已经做对了什么) + +- OAuth 核心流程完成:PKCE、state、授权码交换、refresh token 刷新、JWT 解析 account id。 +- API 网关基本能力完成:`GET /health`、`POST /responses`、请求体大小保护(20MB)。 +- 与 Codex 后端的关键约束对齐:强制 `store:false`、默认 `include: ["reasoning.encrypted_content"]`、统一 headers。 +- 支持请求意图分流:客户端要求流式时透传 SSE,非流式时转换为 JSON 返回。 + +## 3) 后续工作(按优先级) + +### P0(应尽快完成) + +1. **补单元测试(最小门槛)** + - 覆盖 `normalizeModel`、`parseFinalResponse`、`parseAuthorizationInput`、`transformBody`。 + - 目标:先做到核心纯函数 80%+ 覆盖,避免后续重构回归。 + +2. **错误处理标准化** + - 当前 500 错误统一返回 message,但上游错误映射较粗。 + - 建议把 token 失效、account-id 缺失、上游 401/429/5xx 分级返回,便于调用方重试策略。 + +3. **安全与配置硬化** + - 增加 `.tokens/openai.json` 的存在性/权限检查提示(当前写入是 0600,但缺读取时友好诊断)。 + - 增加环境变量文档:`CODEX_GATEWAY_PORT`、`CODEX_GATEWAY_TOKEN_FILE`。 + +4. **README 补“已知限制”** + - 明确“仅本地单用户开发用途”“无并发/多租户设计”“无速率限制与审计”。 + +### P1(中期推进) + +5. **开发体验改进** + - 增加 `dev` 真正 watch 模式(目前是 build+start,一次性)。 + - 增加 `lint` / `format`(至少统一 import 顺序、空格、行宽规则)。 + +6. **轻量可观测性** + - 结构化日志(请求 id、上游耗时、状态码、模式 stream/non-stream)。 + - /health 增加 token 剩余有效期秒数,便于巡检。 + +7. **Python 示例可维护化** + - 给 `python/main.py` 增加参数化模型、stream 开关与异常提示,避免示例过于固定。 + +### P2(产品化前) + +8. **CI 与质量门禁** + - GitHub Actions:build + typecheck + test。 + - 覆盖率门槛(如 70% 起步)。 + +9. **接口契约化** + - 为 `/responses` 增加 JSON Schema 或类型导出,减少前后端对接歧义。 + +10. **发布策略决策** + - 明确是否从 `experiments/` 孵化为独立仓库/包;若是,补版本策略与迁移说明。 + +## 4) 一句话总结 + +这个子项目已经完成“能跑通”的关键路径;下一步重点不是继续堆功能,而是补齐测试、错误语义、安全与工程化基线,让它从实验代码进入“可持续迭代”的小型产品阶段。 diff --git a/experiments/codex-oauth-gateway/README.md b/experiments/codex-oauth-gateway/README.md new file mode 100644 index 0000000..f291a1d --- /dev/null +++ b/experiments/codex-oauth-gateway/README.md @@ -0,0 +1,206 @@ +# Codex OAuth Gateway + +Standalone experiment for calling the ChatGPT Codex backend from your own Python +program through a local Node.js gateway. + +This project is intentionally separate from the parent OpenCode plugin. It +copies only the minimum ideas needed for personal experimentation: + +## Project purpose (TL;DR) + +This gateway exists to let a **single local developer** call the ChatGPT Codex backend +from custom scripts (especially Python) using official OAuth, without rebuilding all of +OpenCode plugin features. + +### In scope +- Local OAuth login and token refresh +- Local `POST /responses` proxy for text/image coding requests +- Stream passthrough and non-stream SSE->JSON conversion + +### Out of scope +- Multi-user auth/session management +- Production hosting/SLA hardening +- Commercial API resale scenarios + +### What a product-grade gateway would additionally need + +If this experiment is promoted to production, add at least: + +1. **Identity & access control** + - Multi-user auth, tenant isolation, scoped API keys/JWTs, RBAC. +2. **Security controls** + - Secrets manager, key rotation, WAF/rate limits, audit trails, abuse detection. +3. **Reliability & scalability** + - Stateless horizontal scaling, queues/backpressure, retries with idempotency keys. +4. **Observability & operations** + - Structured logs, metrics, distributed tracing, SLO/alerting, runbooks. +5. **API governance** + - Versioned contracts (OpenAPI/JSON Schema), backward compatibility policy. +6. **Data governance** + - Data retention/PII policy, encryption at rest/in transit, deletion workflows. +7. **Developer platform basics** + - Automated tests, CI/CD, staged deploys, canary/rollback, migration tooling. + +- OAuth login with PKCE +- Local callback on `http://localhost:1455/auth/callback` +- Access-token refresh +- ChatGPT account id extraction from the access-token JWT +- `POST /responses` proxying to `https://chatgpt.com/backend-api/codex/responses` +- Forced `store: false` +- `include: ["reasoning.encrypted_content"]` + +## Usage + +From this directory: + +```bash +npm install +npm run build +npm run auth +npm run start +``` + +Then, in another terminal: + +```bash +python python/main.py +``` + +The token file is stored locally at: + +```text +.tokens/openai.json +``` + +Do not commit or share that file. + + +## What can be tested now + +> Current status: there is **no automated test suite** for this experiment yet. +> The following are **manual test scenarios** you can run today. + +### 1) Build and auth flow + +```bash +npm install +npm run build +npm run auth +``` + +What to verify: +- Browser opens the OAuth URL (or manual paste fallback works). +- After success, token file exists at `.tokens/openai.json`. + +### 2) Health endpoint + +Start server: + +```bash +npm run start +``` + +In another terminal: + +```bash +curl -s http://127.0.0.1:8787/health | jq +``` + +What to verify: +- `ok: true` +- `authenticated: true` after auth +- `tokenFile` and `expires` are populated + +### 3) Non-stream request (SSE -> JSON conversion) + +```bash +curl -s http://127.0.0.1:8787/responses \ + -H 'content-type: application/json' \ + -d '{ + "model": "gpt-5.2", + "stream": false, + "input": [{"role":"user","content":"Say hello in one sentence."}] + }' | jq +``` + +What to verify: +- Returns JSON (not SSE text/event-stream). +- Response contains the final model output. + +### 4) Stream request (SSE passthrough) + +```bash +curl -N http://127.0.0.1:8787/responses \ + -H 'content-type: application/json' \ + -d '{ + "model": "gpt-5.2", + "stream": true, + "input": [{"role":"user","content":"List 3 short tips for Python debugging."}] + }' +``` + +What to verify: +- `data: {...}` style SSE chunks stream progressively. +- Ends with a final completion event. + +### 5) Model normalization + +```bash +curl -s http://127.0.0.1:8787/responses \ + -H 'content-type: application/json' \ + -d '{ + "model": "gpt-5-codex-mini", + "stream": false, + "input": [{"role":"user","content":"Reply with normalized model only."}] + }' +``` + +What to verify: +- Request should succeed with legacy aliases too (e.g. `gpt-5-codex-mini`, `gpt-5.2-codex-high`). + +### 6) Error paths + +- Delete or rename token file and call `/responses` -> should return an auth-related error. +- Send malformed JSON body -> should return server error JSON. +- Send >20MB request body -> should fail with `Request body too large.` + + +## HTTP API + +```http +GET /health +POST /responses +``` + +Example request: + +```json +{ + "model": "gpt-5.2", + "input": [ + { "role": "user", "content": "Explain Python decorators." } + ], + "stream": false +} +``` + +The gateway fills in defaults: + +```json +{ + "store": false, + "stream": true, + "reasoning": { "effort": "medium", "summary": "auto" }, + "text": { "verbosity": "medium" }, + "include": ["reasoning.encrypted_content"] +} +``` + +For `stream: false`, the gateway converts the Codex SSE response into the final +JSON response. For `stream: true`, it passes the SSE stream through. + +## Notes + +This is for personal local development with your own ChatGPT Plus/Pro account. +For production, commercial, multi-user, or resale use cases, use the OpenAI +Platform API instead. diff --git a/experiments/codex-oauth-gateway/package-lock.json b/experiments/codex-oauth-gateway/package-lock.json new file mode 100644 index 0000000..2ee8839 --- /dev/null +++ b/experiments/codex-oauth-gateway/package-lock.json @@ -0,0 +1,50 @@ +{ + "name": "codex-oauth-gateway", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codex-oauth-gateway", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^24.6.2", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/experiments/codex-oauth-gateway/package.json b/experiments/codex-oauth-gateway/package.json new file mode 100644 index 0000000..47b541b --- /dev/null +++ b/experiments/codex-oauth-gateway/package.json @@ -0,0 +1,21 @@ +{ + "name": "codex-oauth-gateway", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Standalone local gateway for calling the ChatGPT Codex backend with OAuth.", + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "npm run build && node --test tests/*.test.mjs", + "auth": "node dist/auth-cli.js", + "start": "node dist/server.js", + "dev": "npm run build && npm run start" + }, + "engines": { + "node": ">=20.0.0" + }, + "devDependencies": { + "@types/node": "^24.6.2", + "typescript": "^5.9.3" + } +} diff --git a/experiments/codex-oauth-gateway/python/main.py b/experiments/codex-oauth-gateway/python/main.py new file mode 100644 index 0000000..9702af9 --- /dev/null +++ b/experiments/codex-oauth-gateway/python/main.py @@ -0,0 +1,30 @@ +import json +import urllib.request + + +def ask(prompt: str) -> dict: + payload = { + "model": "gpt-5.2", + "input": [ + { + "role": "user", + "content": prompt, + } + ], + "stream": False, + } + + request = urllib.request.Request( + "http://127.0.0.1:8787/responses", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + + with urllib.request.urlopen(request, timeout=120) as response: + return json.loads(response.read().decode("utf-8")) + + +if __name__ == "__main__": + result = ask("用三句话解释 Python 装饰器。") + print(json.dumps(result, ensure_ascii=False, indent=2)) diff --git a/experiments/codex-oauth-gateway/python/requirements.txt b/experiments/codex-oauth-gateway/python/requirements.txt new file mode 100644 index 0000000..f9184d6 --- /dev/null +++ b/experiments/codex-oauth-gateway/python/requirements.txt @@ -0,0 +1 @@ +# No third-party dependencies. The example uses Python's standard library. diff --git a/experiments/codex-oauth-gateway/src/auth-cli.ts b/experiments/codex-oauth-gateway/src/auth-cli.ts new file mode 100644 index 0000000..7ae2ed4 --- /dev/null +++ b/experiments/codex-oauth-gateway/src/auth-cli.ts @@ -0,0 +1,78 @@ +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { startCallbackServer } from "./auth/callback-server.js"; +import { openBrowser } from "./auth/browser.js"; +import { + createAuthorizationFlow, + exchangeAuthorizationCode, + getChatGptAccountId, + getTokenFilePath, + parseAuthorizationInput, + saveTokens, +} from "./auth/oauth.js"; + +async function promptForCode(url: string, expectedState: string): Promise { + const rl = createInterface({ input, output }); + try { + console.log("\nOpen this URL in your browser:"); + console.log(url); + const value = await rl.question( + "\nPaste the full redirect URL, or paste the authorization code: ", + ); + const parsed = parseAuthorizationInput(value); + if (!parsed.code) return null; + if (parsed.state && parsed.state !== expectedState) { + throw new Error("State mismatch in pasted authorization response."); + } + return parsed.code; + } finally { + rl.close(); + } +} + +async function main(): Promise { + const flow = createAuthorizationFlow(); + let code: string | null = null; + let callbackServer: Awaited> | null = null; + + try { + callbackServer = await startCallbackServer(flow.state); + console.log("Waiting for OAuth callback on http://localhost:1455/auth/callback"); + console.log("Opening browser for ChatGPT OAuth login..."); + openBrowser(flow.url); + console.log("If the browser does not open, use this URL:"); + console.log(flow.url); + code = await callbackServer.waitForCode(); + } catch (error) { + console.warn( + "Could not start local callback server on port 1455; falling back to manual paste.", + ); + code = await promptForCode(flow.url, flow.state); + } finally { + callbackServer?.close(); + } + + if (!code) { + throw new Error("No authorization code received."); + } + + const tokens = await exchangeAuthorizationCode(code, flow.pkce.verifier); + if (tokens.type !== "oauth") { + throw new Error("Token exchange failed."); + } + + const accountId = getChatGptAccountId(tokens.access); + if (!accountId) { + throw new Error("Could not extract ChatGPT account id from access token."); + } + + await saveTokens(tokens); + console.log("\nAuthentication complete."); + console.log(`Token file: ${getTokenFilePath()}`); + console.log(`ChatGPT account id: ${accountId}`); +} + +main().catch((error) => { + console.error("[codex-oauth-gateway] auth failed:", error); + process.exitCode = 1; +}); diff --git a/experiments/codex-oauth-gateway/src/auth/browser.ts b/experiments/codex-oauth-gateway/src/auth/browser.ts new file mode 100644 index 0000000..beb6121 --- /dev/null +++ b/experiments/codex-oauth-gateway/src/auth/browser.ts @@ -0,0 +1,21 @@ +import { spawn } from "node:child_process"; + +function getBrowserOpener(): string { + if (process.platform === "darwin") return "open"; + if (process.platform === "win32") return "start"; + return "xdg-open"; +} + +export function openBrowser(url: string): boolean { + try { + const child = spawn(getBrowserOpener(), [url], { + stdio: "ignore", + shell: process.platform === "win32", + windowsHide: true, + }); + child.on("error", () => {}); + return true; + } catch { + return false; + } +} diff --git a/experiments/codex-oauth-gateway/src/auth/callback-server.ts b/experiments/codex-oauth-gateway/src/auth/callback-server.ts new file mode 100644 index 0000000..e53b419 --- /dev/null +++ b/experiments/codex-oauth-gateway/src/auth/callback-server.ts @@ -0,0 +1,70 @@ +import http from "node:http"; + +const SUCCESS_HTML = ` + +Codex OAuth Gateway + +

Authentication complete

+

You can close this tab and return to your terminal.

+ +`; + +export interface CallbackServer { + url: string; + close: () => void; + waitForCode: () => Promise; +} + +export function startCallbackServer(state: string): Promise { + let lastCode: string | null = null; + + const server = http.createServer((req, res) => { + try { + const url = new URL(req.url ?? "", "http://localhost:1455"); + if (url.pathname !== "/auth/callback") { + res.statusCode = 404; + res.end("Not found"); + return; + } + + if (url.searchParams.get("state") !== state) { + res.statusCode = 400; + res.end("State mismatch"); + return; + } + + const code = url.searchParams.get("code"); + if (!code) { + res.statusCode = 400; + res.end("Missing authorization code"); + return; + } + + lastCode = code; + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(SUCCESS_HTML); + } catch { + res.statusCode = 500; + res.end("Internal error"); + } + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(1455, "127.0.0.1", () => { + server.removeAllListeners("error"); + resolve({ + url: "http://localhost:1455/auth/callback", + close: () => server.close(), + waitForCode: async () => { + for (let i = 0; i < 6000; i++) { + if (lastCode) return lastCode; + await new Promise((done) => setTimeout(done, 100)); + } + return null; + }, + }); + }); + }); +} diff --git a/experiments/codex-oauth-gateway/src/auth/oauth.ts b/experiments/codex-oauth-gateway/src/auth/oauth.ts new file mode 100644 index 0000000..ad04167 --- /dev/null +++ b/experiments/codex-oauth-gateway/src/auth/oauth.ts @@ -0,0 +1,216 @@ +import { createHash, randomBytes } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { + AUTHORIZE_URL, + CLIENT_ID, + JWT_CLAIM_PATH, + REDIRECT_URI, + SCOPE, + TOKEN_URL, +} from "../constants.js"; +import type { + AuthorizationFlow, + JWTPayload, + ParsedAuthInput, + PKCEPair, + TokenResult, + TokenSet, +} from "../types.js"; + +const TOKEN_FILE = + process.env.CODEX_GATEWAY_TOKEN_FILE ?? resolve(".tokens", "openai.json"); + +function base64Url(bytes: Buffer): string { + return bytes.toString("base64url"); +} + +export function getTokenFilePath(): string { + return TOKEN_FILE; +} + +export function createState(): string { + return randomBytes(16).toString("hex"); +} + +export function createPKCEPair(): PKCEPair { + const verifier = base64Url(randomBytes(32)); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +export function createAuthorizationFlow(): AuthorizationFlow { + const pkce = createPKCEPair(); + const state = createState(); + const url = new URL(AUTHORIZE_URL); + + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPE); + url.searchParams.set("code_challenge", pkce.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + url.searchParams.set("id_token_add_organizations", "true"); + url.searchParams.set("codex_cli_simplified_flow", "true"); + url.searchParams.set("originator", "codex_cli_rs"); + + return { pkce, state, url: url.toString() }; +} + +export function parseAuthorizationInput(input: string): ParsedAuthInput { + const value = input.trim(); + if (!value) return {}; + + try { + const url = new URL(value); + return { + code: url.searchParams.get("code") ?? undefined, + state: url.searchParams.get("state") ?? undefined, + }; + } catch { + // Continue with non-URL parsing. + } + + if (value.includes("#")) { + const [code, state] = value.split("#", 2); + return { code, state }; + } + + if (value.includes("code=")) { + const params = new URLSearchParams(value); + return { + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + }; + } + + return { code: value }; +} + +async function parseTokenResponse(response: Response): Promise { + if (!response.ok) { + const text = await response.text().catch(() => ""); + console.error("[codex-oauth-gateway] token request failed:", response.status, text); + return { type: "failed" }; + } + + const json = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + + if ( + !json.access_token || + !json.refresh_token || + typeof json.expires_in !== "number" + ) { + console.error("[codex-oauth-gateway] token response missing fields:", json); + return { type: "failed" }; + } + + return { + type: "oauth", + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + }; +} + +export async function exchangeAuthorizationCode( + code: string, + verifier: string, +): Promise { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + code, + code_verifier: verifier, + redirect_uri: REDIRECT_URI, + }), + }); + + return parseTokenResponse(response); +} + +export async function refreshAccessToken( + refreshToken: string, +): Promise { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }), + }); + + return parseTokenResponse(response); +} + +export function decodeJWT(token: string): JWTPayload | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); + } catch { + return null; + } +} + +export function getChatGptAccountId(accessToken: string): string | null { + const decoded = decodeJWT(accessToken); + return decoded?.[JWT_CLAIM_PATH]?.chatgpt_account_id ?? null; +} + +export async function saveTokens(tokens: TokenSet): Promise { + await mkdir(dirname(TOKEN_FILE), { recursive: true }); + await writeFile(TOKEN_FILE, `${JSON.stringify(tokens, null, 2)}\n`, { + mode: 0o600, + }); +} + +export async function loadTokens(): Promise { + try { + const raw = await readFile(TOKEN_FILE, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.type !== "oauth" || + !parsed.access || + !parsed.refresh || + typeof parsed.expires !== "number" + ) { + return null; + } + return parsed as TokenSet; + } catch { + return null; + } +} + +export async function getValidTokens(): Promise { + const tokens = await loadTokens(); + if (!tokens) { + throw new Error( + `No OAuth tokens found. Run "npm run build && npm run auth" first. Expected token file: ${TOKEN_FILE}`, + ); + } + + const refreshSkewMs = 60_000; + if (tokens.expires > Date.now() + refreshSkewMs) { + return tokens; + } + + const refreshed = await refreshAccessToken(tokens.refresh); + if (refreshed.type !== "oauth") { + throw new Error("OAuth token refresh failed. Re-run npm run auth."); + } + + await saveTokens(refreshed); + return refreshed; +} diff --git a/experiments/codex-oauth-gateway/src/codex/client.ts b/experiments/codex-oauth-gateway/src/codex/client.ts new file mode 100644 index 0000000..279bab7 --- /dev/null +++ b/experiments/codex-oauth-gateway/src/codex/client.ts @@ -0,0 +1,129 @@ +import { + CODEX_RESPONSES_URL, + DEFAULT_UPSTREAM_TIMEOUT_MS, + DEFAULT_INSTRUCTIONS, + OPENAI_HEADER_VALUES, + OPENAI_HEADERS, +} from "../constants.js"; +import { GatewayError } from "../errors.js"; +import type { GatewayRequestBody } from "../types.js"; +import { normalizeModel } from "./model.js"; +import { + handleErrorResponse, + handleSuccessResponse, + wantsStream, +} from "./response.js"; + +function ensureInclude(include: string[] | undefined): string[] { + const merged = new Set(include ?? []); + merged.add("reasoning.encrypted_content"); + return Array.from(merged); +} + +function transformBody(input: GatewayRequestBody): GatewayRequestBody { + if (!input.input) { + throw new GatewayError( + 400, + "MISSING_INPUT", + "Request body must include an input field.", + ); + } + + return { + ...input, + model: normalizeModel(input.model), + store: false, + stream: true, + instructions: input.instructions ?? DEFAULT_INSTRUCTIONS, + reasoning: { + effort: input.reasoning?.effort ?? "medium", + summary: input.reasoning?.summary ?? "auto", + }, + text: { + verbosity: input.text?.verbosity ?? "medium", + }, + include: ensureInclude(input.include), + }; +} + +function createHeaders( + accessToken: string, + accountId: string, + promptCacheKey?: string, +): Headers { + const headers = new Headers(); + headers.set("Authorization", `Bearer ${accessToken}`); + headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId); + headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES); + headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX); + headers.set("accept", "text/event-stream"); + headers.set("content-type", "application/json"); + + if (promptCacheKey) { + headers.set(OPENAI_HEADERS.CONVERSATION_ID, promptCacheKey); + headers.set(OPENAI_HEADERS.SESSION_ID, promptCacheKey); + } + + return headers; +} + +export async function callCodex( + body: GatewayRequestBody, + accessToken: string, + accountId: string, +): Promise { + const transformedBody = transformBody(body); + const timeoutMs = Number( + process.env.CODEX_UPSTREAM_TIMEOUT_MS ?? DEFAULT_UPSTREAM_TIMEOUT_MS, + ); + const timeoutSignal = AbortSignal.timeout( + Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : DEFAULT_UPSTREAM_TIMEOUT_MS, + ); + + let response: Response; + try { + response = await fetch(CODEX_RESPONSES_URL, { + method: "POST", + headers: createHeaders( + accessToken, + accountId, + transformedBody.prompt_cache_key, + ), + body: JSON.stringify(transformedBody), + signal: timeoutSignal, + }); + } catch (error) { + if ( + error instanceof DOMException && + error.name === "TimeoutError" + ) { + throw new GatewayError( + 504, + "UPSTREAM_TIMEOUT", + "Upstream Codex request timed out.", + { + timeoutMs, + upstreamRetryAttempts: 0, + }, + ); + } + + throw new GatewayError( + 502, + "UPSTREAM_REQUEST_FAILED", + "Failed to reach upstream Codex service.", + { + upstreamRetryAttempts: 0, + cause: error instanceof Error ? error.message : String(error), + }, + ); + } + + if (!response.ok) { + return await handleErrorResponse(response); + } + + return await handleSuccessResponse(response, wantsStream(body)); +} diff --git a/experiments/codex-oauth-gateway/src/codex/model.ts b/experiments/codex-oauth-gateway/src/codex/model.ts new file mode 100644 index 0000000..b26bc66 --- /dev/null +++ b/experiments/codex-oauth-gateway/src/codex/model.ts @@ -0,0 +1,51 @@ +const MODEL_MAP: Record = { + "gpt-5.2": "gpt-5.2", + "gpt-5.2-none": "gpt-5.2", + "gpt-5.2-low": "gpt-5.2", + "gpt-5.2-medium": "gpt-5.2", + "gpt-5.2-high": "gpt-5.2", + "gpt-5.2-xhigh": "gpt-5.2", + "gpt-5.2-codex": "gpt-5.2-codex", + "gpt-5.2-codex-low": "gpt-5.2-codex", + "gpt-5.2-codex-medium": "gpt-5.2-codex", + "gpt-5.2-codex-high": "gpt-5.2-codex", + "gpt-5.2-codex-xhigh": "gpt-5.2-codex", + "gpt-5.1": "gpt-5.1", + "gpt-5.1-none": "gpt-5.1", + "gpt-5.1-low": "gpt-5.1", + "gpt-5.1-medium": "gpt-5.1", + "gpt-5.1-high": "gpt-5.1", + "gpt-5.1-codex": "gpt-5.1-codex", + "gpt-5.1-codex-low": "gpt-5.1-codex", + "gpt-5.1-codex-medium": "gpt-5.1-codex", + "gpt-5.1-codex-high": "gpt-5.1-codex", + "gpt-5.1-codex-max": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-low": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-medium": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-high": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-xhigh": "gpt-5.1-codex-max", + "gpt-5.1-codex-mini": "gpt-5.1-codex-mini", + "gpt-5.1-codex-mini-medium": "gpt-5.1-codex-mini", + "gpt-5.1-codex-mini-high": "gpt-5.1-codex-mini", + "gpt-5": "gpt-5.1", + "gpt-5-codex": "gpt-5.1-codex", + "gpt-5-codex-mini": "gpt-5.1-codex-mini", + "codex-mini-latest": "gpt-5.1-codex-mini", +}; + +export function normalizeModel(model: string | undefined): string { + if (!model) return "gpt-5.2"; + const modelId = model.includes("/") ? model.split("/").pop()! : model; + const direct = MODEL_MAP[modelId] ?? MODEL_MAP[modelId.toLowerCase()]; + if (direct) return direct; + + const lower = modelId.toLowerCase(); + if (lower.includes("gpt-5.2-codex")) return "gpt-5.2-codex"; + if (lower.includes("gpt-5.2")) return "gpt-5.2"; + if (lower.includes("codex-mini")) return "gpt-5.1-codex-mini"; + if (lower.includes("codex-max")) return "gpt-5.1-codex-max"; + if (lower.includes("codex")) return "gpt-5.1-codex"; + if (lower.includes("gpt-5.1")) return "gpt-5.1"; + if (lower.includes("gpt-5")) return "gpt-5.1"; + return modelId; +} diff --git a/experiments/codex-oauth-gateway/src/codex/response.ts b/experiments/codex-oauth-gateway/src/codex/response.ts new file mode 100644 index 0000000..c89bab1 --- /dev/null +++ b/experiments/codex-oauth-gateway/src/codex/response.ts @@ -0,0 +1,122 @@ +import type { GatewayRequestBody } from "../types.js"; + +export function wantsStream(body: GatewayRequestBody): boolean { + return body.stream === true; +} + +export async function convertSseToJson(response: Response): Promise { + if (!response.body) { + return response; + } + + const text = await response.text(); + const finalResponse = parseFinalResponse(text); + const headers = ensureContentType(response.headers); + headers.set("content-type", "application/json; charset=utf-8"); + + if (!finalResponse) { + return new Response(text, { + status: response.status, + statusText: response.statusText, + headers: ensureContentType(response.headers), + }); + } + + return new Response(JSON.stringify(finalResponse), { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +export function ensureContentType(headers: Headers): Headers { + const responseHeaders = new Headers(headers); + + if (!responseHeaders.has("content-type")) { + responseHeaders.set("content-type", "text/event-stream; charset=utf-8"); + } + + return responseHeaders; +} + +export async function handleErrorResponse(response: Response): Promise { + const mapped = await mapUsageLimit404(response); + return mapped ?? response; +} + +export async function handleSuccessResponse( + response: Response, + isStreaming: boolean, +): Promise { + const responseHeaders = ensureContentType(response.headers); + + if (!isStreaming) { + return await convertSseToJson( + new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }), + ); + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); +} + +async function mapUsageLimit404(response: Response): Promise { + if (response.status !== 404) return null; + + const clone = response.clone(); + let text = ""; + try { + text = await clone.text(); + } catch { + text = ""; + } + if (!text) return null; + + let code = ""; + try { + const parsed = JSON.parse(text) as { + error?: { code?: string; type?: string }; + }; + code = (parsed?.error?.code ?? parsed?.error?.type ?? "").toString(); + } catch { + code = ""; + } + + if (code !== "usage_limit_exceeded") { + return null; + } + + return new Response(response.body, { + status: 429, + statusText: "Too Many Requests", + headers: response.headers, + }); +} + +function parseFinalResponse(sseText: string): unknown | null { + for (const line of sseText.split("\n")) { + if (!line.startsWith("data: ")) continue; + try { + const event = JSON.parse(line.slice(6)) as { + type?: string; + response?: unknown; + }; + if ( + event.type === "response.done" || + event.type === "response.completed" + ) { + return event.response ?? null; + } + } catch { + // Ignore malformed SSE data lines. + } + } + return null; +} diff --git a/experiments/codex-oauth-gateway/src/constants.ts b/experiments/codex-oauth-gateway/src/constants.ts new file mode 100644 index 0000000..372b4ac --- /dev/null +++ b/experiments/codex-oauth-gateway/src/constants.ts @@ -0,0 +1,29 @@ +export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +export const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; +export const TOKEN_URL = "https://auth.openai.com/oauth/token"; +export const REDIRECT_URI = "http://localhost:1455/auth/callback"; +export const SCOPE = "openid profile email offline_access"; + +export const CODEX_RESPONSES_URL = + "https://chatgpt.com/backend-api/codex/responses"; + +export const JWT_CLAIM_PATH = "https://api.openai.com/auth"; + +export const OPENAI_HEADERS = { + BETA: "OpenAI-Beta", + ACCOUNT_ID: "chatgpt-account-id", + ORIGINATOR: "originator", + SESSION_ID: "session_id", + CONVERSATION_ID: "conversation_id", +} as const; + +export const OPENAI_HEADER_VALUES = { + BETA_RESPONSES: "responses=experimental", + ORIGINATOR_CODEX: "codex_cli_rs", +} as const; + +export const DEFAULT_INSTRUCTIONS = + "You are a helpful coding assistant. Answer clearly and directly."; + +export const DEFAULT_GATEWAY_PORT = 8787; +export const DEFAULT_UPSTREAM_TIMEOUT_MS = 60_000; diff --git a/experiments/codex-oauth-gateway/src/errors.ts b/experiments/codex-oauth-gateway/src/errors.ts new file mode 100644 index 0000000..39f617f --- /dev/null +++ b/experiments/codex-oauth-gateway/src/errors.ts @@ -0,0 +1,18 @@ +export class GatewayError extends Error { + status: number; + code: string; + details?: unknown; + + constructor( + status: number, + code: string, + message: string, + details?: unknown, + ) { + super(message); + this.name = "GatewayError"; + this.status = status; + this.code = code; + this.details = details; + } +} diff --git a/experiments/codex-oauth-gateway/src/server.ts b/experiments/codex-oauth-gateway/src/server.ts new file mode 100644 index 0000000..6937cdf --- /dev/null +++ b/experiments/codex-oauth-gateway/src/server.ts @@ -0,0 +1,152 @@ +import http from "node:http"; +import { pathToFileURL } from "node:url"; +import { DEFAULT_GATEWAY_PORT } from "./constants.js"; +import { GatewayError } from "./errors.js"; +import { + getChatGptAccountId, + getTokenFilePath, + getValidTokens, + loadTokens, +} from "./auth/oauth.js"; +import { callCodex } from "./codex/client.js"; +import type { GatewayRequestBody } from "./types.js"; + +async function readJsonBody(req: http.IncomingMessage): Promise { + let body = ""; + for await (const chunk of req) { + body += chunk; + if (body.length > 20 * 1024 * 1024) { + throw new GatewayError( + 413, + "REQUEST_BODY_TOO_LARGE", + "Request body too large.", + ); + } + } + if (!body) { + return {}; + } + + try { + return JSON.parse(body); + } catch { + throw new GatewayError( + 400, + "INVALID_JSON", + "Request body must be valid JSON.", + ); + } +} + +function sendJson( + res: http.ServerResponse, + status: number, + payload: unknown, +): void { + res.statusCode = status; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end(JSON.stringify(payload, null, 2)); +} + +async function handleResponses( + req: http.IncomingMessage, + res: http.ServerResponse, +): Promise { + const body = (await readJsonBody(req)) as GatewayRequestBody; + const tokens = await getValidTokens(); + const accountId = getChatGptAccountId(tokens.access); + if (!accountId) { + throw new GatewayError( + 401, + "INVALID_ACCESS_TOKEN", + "Could not extract ChatGPT account id from access token.", + ); + } + + const upstream = await callCodex(body, tokens.access, accountId); + res.statusCode = upstream.status; + res.statusMessage = upstream.statusText; + res.setHeader("x-gateway-upstream-retry-attempts", "0"); + upstream.headers.forEach((value, key) => res.setHeader(key, value)); + + if (!upstream.body) { + res.end(); + return; + } + + const reader = upstream.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(Buffer.from(value)); + } + res.end(); +} + +async function handleHealth(res: http.ServerResponse): Promise { + const tokens = await loadTokens(); + sendJson(res, 200, { + ok: true, + authenticated: !!tokens, + tokenFile: getTokenFilePath(), + expires: tokens?.expires ?? null, + }); +} + +export function createGatewayServer(): http.Server { + return http.createServer((req, res) => { + void (async () => { + const url = new URL( + req.url ?? "/", + `http://${req.headers.host ?? "localhost"}`, + ); + if (req.method === "GET" && url.pathname === "/health") { + await handleHealth(res); + return; + } + + if (req.method === "POST" && url.pathname === "/responses") { + await handleResponses(req, res); + return; + } + + sendJson(res, 404, { + error: "Not found", + routes: ["GET /health", "POST /responses"], + }); + })().catch((error) => { + if (error instanceof GatewayError) { + sendJson(res, error.status, { + error: error.message, + code: error.code, + details: error.details ?? null, + }); + return; + } + + sendJson(res, 500, { + error: error instanceof Error ? error.message : String(error), + code: "INTERNAL_SERVER_ERROR", + }); + }); + }); +} + +export function startGatewayServer(port = Number( + process.env.CODEX_GATEWAY_PORT ?? DEFAULT_GATEWAY_PORT, +)): http.Server { + const server = createGatewayServer(); + server.listen(port, "127.0.0.1", () => { + console.log(`codex-oauth-gateway listening on http://127.0.0.1:${port}`); + console.log("Run npm run auth first if /health says authenticated=false."); + }); + return server; +} + +const isMainModule = + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMainModule) { + startGatewayServer(); +} diff --git a/experiments/codex-oauth-gateway/src/types.ts b/experiments/codex-oauth-gateway/src/types.ts new file mode 100644 index 0000000..95d446b --- /dev/null +++ b/experiments/codex-oauth-gateway/src/types.ts @@ -0,0 +1,48 @@ +export interface PKCEPair { + verifier: string; + challenge: string; +} + +export interface AuthorizationFlow { + pkce: PKCEPair; + state: string; + url: string; +} + +export interface TokenSet { + type: "oauth"; + access: string; + refresh: string; + expires: number; +} + +export type TokenResult = TokenSet | { type: "failed" }; + +export interface ParsedAuthInput { + code?: string; + state?: string; +} + +export interface JWTPayload { + "https://api.openai.com/auth"?: { + chatgpt_account_id?: string; + }; + [key: string]: unknown; +} + +export interface GatewayRequestBody { + model?: string; + input?: unknown; + stream?: boolean; + instructions?: string; + reasoning?: { + effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; + summary?: "auto" | "concise" | "detailed" | "off" | "on"; + }; + text?: { + verbosity?: "low" | "medium" | "high"; + }; + include?: string[]; + prompt_cache_key?: string; + [key: string]: unknown; +} diff --git a/experiments/codex-oauth-gateway/tests/model-and-client.test.mjs b/experiments/codex-oauth-gateway/tests/model-and-client.test.mjs new file mode 100644 index 0000000..ba15e9f --- /dev/null +++ b/experiments/codex-oauth-gateway/tests/model-and-client.test.mjs @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { normalizeModel } from "../dist/codex/model.js"; +import { callCodex } from "../dist/codex/client.js"; +import { GatewayError } from "../dist/errors.js"; + +test("normalizeModel keeps GPT-5.1 codex mini compatibility mappings", () => { + assert.equal(normalizeModel("gpt-5-codex-mini-high"), "gpt-5.1-codex-mini"); + assert.equal(normalizeModel("codex-mini-latest"), "gpt-5.1-codex-mini"); + assert.equal(normalizeModel("gpt-5-codex"), "gpt-5.1-codex"); +}); + +test("callCodex rejects missing input with structured GatewayError", async () => { + await assert.rejects( + () => callCodex({ model: "gpt-5.1-codex" }, "fake_token", "fake_account"), + (error) => + error instanceof GatewayError && + error.status === 400 && + error.code === "MISSING_INPUT", + ); +}); diff --git a/experiments/codex-oauth-gateway/tests/response.test.mjs b/experiments/codex-oauth-gateway/tests/response.test.mjs new file mode 100644 index 0000000..7e15c93 --- /dev/null +++ b/experiments/codex-oauth-gateway/tests/response.test.mjs @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + convertSseToJson, + handleErrorResponse, + wantsStream, +} from "../dist/codex/response.js"; + +test("wantsStream only returns true when stream=true", () => { + assert.equal(wantsStream({ stream: true }), true); + assert.equal(wantsStream({ stream: false }), false); + assert.equal(wantsStream({}), false); +}); + +test("convertSseToJson extracts final response from SSE", async () => { + const ssePayload = [ + 'data: {"type":"response.output_text.delta","delta":"Hi"}', + 'data: {"type":"response.done","response":{"id":"resp_1","output_text":"Hi"}}', + "", + ].join("\n"); + const response = new Response(ssePayload, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + + const converted = await convertSseToJson(response); + assert.equal(converted.status, 200); + assert.match( + converted.headers.get("content-type") ?? "", + /application\/json/, + ); + assert.deepEqual(await converted.json(), { id: "resp_1", output_text: "Hi" }); +}); + +test("convertSseToJson falls back to original SSE body when final event is missing", async () => { + const ssePayload = 'data: {"type":"response.output_text.delta","delta":"partial"}\n'; + const response = new Response(ssePayload, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + + const converted = await convertSseToJson(response); + assert.equal(converted.status, 200); + assert.match( + converted.headers.get("content-type") ?? "", + /text\/event-stream/, + ); + assert.equal(await converted.text(), ssePayload); +}); + +test("handleErrorResponse maps usage_limit_exceeded from 404 to 429", async () => { + const response = new Response( + JSON.stringify({ error: { code: "usage_limit_exceeded" } }), + { + status: 404, + headers: { "content-type": "application/json" }, + }, + ); + + const mapped = await handleErrorResponse(response); + assert.equal(mapped.status, 429); + assert.equal(mapped.statusText, "Too Many Requests"); +}); diff --git a/experiments/codex-oauth-gateway/tests/server.integration.test.mjs b/experiments/codex-oauth-gateway/tests/server.integration.test.mjs new file mode 100644 index 0000000..2aaa20c --- /dev/null +++ b/experiments/codex-oauth-gateway/tests/server.integration.test.mjs @@ -0,0 +1,92 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +function encodeBase64Url(value) { + return Buffer.from(value).toString("base64url"); +} + +function createFakeAccessToken(accountId) { + const header = encodeBase64Url(JSON.stringify({ alg: "none", typ: "JWT" })); + const payload = encodeBase64Url( + JSON.stringify({ + "https://api.openai.com/auth": { chatgpt_account_id: accountId }, + }), + ); + return `${header}.${payload}.signature`; +} + +async function waitForHealthy(baseUrl, timeoutMs = 10_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const response = await fetch(`${baseUrl}/health`); + if (response.ok) return; + } catch { + // Retry while server starts. + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + throw new Error("Timed out waiting for gateway server to start."); +} + +test("gateway HTTP integration: health + request validation errors", async (t) => { + const tempRoot = await mkdtemp(join(tmpdir(), "codex-gateway-test-")); + const tokenFile = join(tempRoot, ".tokens", "openai.json"); + await mkdir(join(tempRoot, ".tokens"), { recursive: true }); + + await writeFile( + tokenFile, + JSON.stringify( + { + type: "oauth", + access: createFakeAccessToken("acct_integration_test"), + refresh: "refresh_token_for_test", + expires: Date.now() + 60 * 60 * 1000, + }, + null, + 2, + ), + "utf8", + ); + + process.env.CODEX_GATEWAY_TOKEN_FILE = tokenFile; + const { startGatewayServer } = await import("../dist/server.js"); + const port = 18787; + const baseUrl = `http://127.0.0.1:${port}`; + const server = startGatewayServer(port); + + t.after(() => { + delete process.env.CODEX_GATEWAY_TOKEN_FILE; + server.close(); + }); + + await waitForHealthy(baseUrl); + + const healthResponse = await fetch(`${baseUrl}/health`); + assert.equal(healthResponse.status, 200); + const healthJson = await healthResponse.json(); + assert.equal(healthJson.ok, true); + assert.equal(healthJson.authenticated, true); + assert.equal(healthJson.tokenFile, tokenFile); + + const invalidJsonResponse = await fetch(`${baseUrl}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"input":', + }); + assert.equal(invalidJsonResponse.status, 400); + const invalidJsonBody = await invalidJsonResponse.json(); + assert.equal(invalidJsonBody.code, "INVALID_JSON"); + + const missingInputResponse = await fetch(`${baseUrl}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.1-codex" }), + }); + assert.equal(missingInputResponse.status, 400); + const missingInputBody = await missingInputResponse.json(); + assert.equal(missingInputBody.code, "MISSING_INPUT"); +}); diff --git a/experiments/codex-oauth-gateway/tsconfig.json b/experiments/codex-oauth-gateway/tsconfig.json new file mode 100644 index 0000000..a2a4789 --- /dev/null +++ b/experiments/codex-oauth-gateway/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +}