| """ChatGPT subscription / Codex backend OAuth helpers. | |
| This provider is intentionally separate from OpenAI API-key endpoints. It uses | |
| OpenAI account OAuth device authorization, stores refresh tokens server-side, | |
| and resolves a fresh bearer token at request time. | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import json | |
| import os | |
| import threading | |
| import time | |
| from typing import Any, Dict, Optional | |
| import httpx | |
| from fastapi import HTTPException | |
| DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL = ( | |
| os.getenv("CHATGPT_SUBSCRIPTION_BASE_URL", "").strip().rstrip("/") | |
| or "https://chatgpt.com/backend-api/codex" | |
| ) | |
| CHATGPT_SUBSCRIPTION_PROVIDER = "chatgpt-subscription" | |
| CHATGPT_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" | |
| CHATGPT_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" | |
| CHATGPT_OAUTH_ISSUER = "https://auth.openai.com" | |
| CHATGPT_OAUTH_REDIRECT_URI = f"{CHATGPT_OAUTH_ISSUER}/deviceauth/callback" | |
| CHATGPT_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 | |
| _AUTH_REFRESH_LOCKS: dict[str, threading.Lock] = {} | |
| _AUTH_REFRESH_LOCKS_GUARD = threading.Lock() | |
| def _database_handles(): | |
| from core.database import ProviderAuthSession, SessionLocal, utcnow_naive | |
| return ProviderAuthSession, SessionLocal, utcnow_naive | |
| def _refresh_lock_for(auth_id: str) -> threading.Lock: | |
| with _AUTH_REFRESH_LOCKS_GUARD: | |
| lock = _AUTH_REFRESH_LOCKS.get(auth_id) | |
| if lock is None: | |
| lock = threading.Lock() | |
| _AUTH_REFRESH_LOCKS[auth_id] = lock | |
| return lock | |
| class ChatGPTSubscriptionError(RuntimeError): | |
| """Base error for ChatGPT subscription provider failures.""" | |
| class ChatGPTSubscriptionReauthRequired(ChatGPTSubscriptionError): | |
| """Stored OAuth credentials are invalid or expired beyond refresh.""" | |
| class ChatGPTSubscriptionRateLimited(ChatGPTSubscriptionError): | |
| """Upstream quota/rate limit; reconnecting will not fix it.""" | |
| class ChatGPTSubscriptionAuthNotFound(ChatGPTSubscriptionError): | |
| """No matching owner-scoped auth session exists.""" | |
| def is_chatgpt_subscription_base(url: str) -> bool: | |
| try: | |
| from urllib.parse import urlparse | |
| parsed = urlparse(url or "") | |
| host = (parsed.hostname or "").lower().rstrip(".") | |
| path = (parsed.path or "").rstrip("/") | |
| except Exception: | |
| return False | |
| return host == "chatgpt.com" and ( | |
| path == "/backend-api/codex" or path.startswith("/backend-api/codex/") | |
| ) | |
| def chatgpt_headers(access_token: Optional[str]) -> Dict[str, str]: | |
| headers = { | |
| "Accept": "application/json, text/event-stream", | |
| "Origin": "https://chatgpt.com", | |
| "Referer": "https://chatgpt.com/codex", | |
| "User-Agent": "Odysseus ChatGPT Subscription", | |
| } | |
| if access_token: | |
| headers["Authorization"] = f"Bearer {access_token}" | |
| return headers | |
| def fetch_available_models(access_token: str, timeout: float = 10.0) -> list[str]: | |
| if not access_token: | |
| return [] | |
| try: | |
| response = httpx.get( | |
| "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0", | |
| headers=chatgpt_headers(access_token), | |
| timeout=timeout, | |
| ) | |
| if response.status_code != 200: | |
| return [] | |
| data = response.json() | |
| except Exception: | |
| return [] | |
| entries = data.get("models", []) if isinstance(data, dict) else [] | |
| sortable: list[tuple[int, str]] = [] | |
| for item in entries: | |
| if not isinstance(item, dict): | |
| continue | |
| slug = item.get("slug") | |
| if not isinstance(slug, str) or not slug.strip(): | |
| continue | |
| visibility = item.get("visibility", "") | |
| if isinstance(visibility, str) and visibility.strip().lower() in {"hide", "hidden"}: | |
| continue | |
| priority = item.get("priority") | |
| rank = int(priority) if isinstance(priority, (int, float)) else 10_000 | |
| sortable.append((rank, slug.strip())) | |
| sortable.sort(key=lambda item: (item[0], item[1])) | |
| ordered: list[str] = [] | |
| seen: set[str] = set() | |
| for _, slug in sortable: | |
| if slug not in seen: | |
| ordered.append(slug) | |
| seen.add(slug) | |
| return ordered | |
| def _raise_for_oauth_response(response: httpx.Response, action: str) -> None: | |
| if response.status_code < 400: | |
| return | |
| code = "" | |
| message = f"ChatGPT Subscription {action} failed with HTTP {response.status_code}." | |
| try: | |
| payload = response.json() | |
| err = payload.get("error") if isinstance(payload, dict) else None | |
| if isinstance(err, dict): | |
| code = str(err.get("code") or err.get("type") or "").strip() | |
| msg = err.get("message") | |
| if msg: | |
| message = f"ChatGPT Subscription {action} failed: {msg}" | |
| elif isinstance(err, str): | |
| code = err.strip() | |
| desc = payload.get("error_description") or payload.get("message") | |
| if desc: | |
| message = f"ChatGPT Subscription {action} failed: {desc}" | |
| except Exception: | |
| pass | |
| if response.status_code == 429: | |
| raise ChatGPTSubscriptionRateLimited( | |
| "ChatGPT Subscription quota or rate limit was reached. Credentials are still valid." | |
| ) | |
| if response.status_code in (401, 403) or code in {"invalid_grant", "invalid_token", "invalid_request", "refresh_token_reused"}: | |
| raise ChatGPTSubscriptionReauthRequired(message) | |
| raise ChatGPTSubscriptionError(message) | |
| def _json_or_error(response: httpx.Response, action: str) -> Dict[str, Any]: | |
| _raise_for_oauth_response(response, action) | |
| try: | |
| data = response.json() | |
| except Exception as exc: | |
| raise ChatGPTSubscriptionError(f"ChatGPT Subscription {action} returned invalid JSON.") from exc | |
| if not isinstance(data, dict): | |
| raise ChatGPTSubscriptionError(f"ChatGPT Subscription {action} returned an unexpected response.") | |
| return data | |
| def request_device_code(timeout: float = 15.0) -> Dict[str, Any]: | |
| response = httpx.post( | |
| f"{CHATGPT_OAUTH_ISSUER}/api/accounts/deviceauth/usercode", | |
| json={"client_id": CHATGPT_OAUTH_CLIENT_ID}, | |
| headers={"Content-Type": "application/json"}, | |
| timeout=timeout, | |
| ) | |
| data = _json_or_error(response, "device-code request") | |
| if not data.get("device_auth_id") or not data.get("user_code"): | |
| raise ChatGPTSubscriptionError("ChatGPT device-code response was missing required fields.") | |
| data.setdefault("verification_uri", f"{CHATGPT_OAUTH_ISSUER}/codex/device") | |
| data.setdefault("interval", 5) | |
| data.setdefault("expires_in", 900) | |
| return data | |
| def poll_device_auth(device_auth_id: str, user_code: str, timeout: float = 15.0) -> Dict[str, Any]: | |
| response = httpx.post( | |
| f"{CHATGPT_OAUTH_ISSUER}/api/accounts/deviceauth/token", | |
| json={"device_auth_id": device_auth_id, "user_code": user_code}, | |
| headers={"Content-Type": "application/json"}, | |
| timeout=timeout, | |
| ) | |
| if response.status_code in (403, 404): | |
| return {"status": "pending", "error": "authorization_pending"} | |
| return _json_or_error(response, "device-code poll") | |
| def exchange_authorization_code(authorization_code: str, code_verifier: str, timeout: float = 15.0) -> Dict[str, Any]: | |
| response = httpx.post( | |
| CHATGPT_OAUTH_TOKEN_URL, | |
| headers={"Content-Type": "application/x-www-form-urlencoded"}, | |
| data={ | |
| "grant_type": "authorization_code", | |
| "code": authorization_code, | |
| "redirect_uri": CHATGPT_OAUTH_REDIRECT_URI, | |
| "client_id": CHATGPT_OAUTH_CLIENT_ID, | |
| "code_verifier": code_verifier, | |
| }, | |
| timeout=timeout, | |
| ) | |
| data = _json_or_error(response, "token exchange") | |
| if not data.get("access_token"): | |
| raise ChatGPTSubscriptionReauthRequired("ChatGPT token exchange did not return an access token.") | |
| return data | |
| def refresh_oauth_tokens(access_token: str, refresh_token: str, timeout: float = 20.0) -> Dict[str, Any]: | |
| del access_token | |
| if not refresh_token: | |
| raise ChatGPTSubscriptionReauthRequired("ChatGPT Subscription is missing a refresh token. Reconnect the provider.") | |
| response = httpx.post( | |
| CHATGPT_OAUTH_TOKEN_URL, | |
| headers={"Content-Type": "application/x-www-form-urlencoded"}, | |
| data={ | |
| "grant_type": "refresh_token", | |
| "refresh_token": refresh_token, | |
| "client_id": CHATGPT_OAUTH_CLIENT_ID, | |
| }, | |
| timeout=timeout, | |
| ) | |
| data = _json_or_error(response, "token refresh") | |
| if not data.get("access_token"): | |
| raise ChatGPTSubscriptionReauthRequired("ChatGPT token refresh did not return an access token.") | |
| return data | |
| def _decode_jwt_payload(token: str) -> Dict[str, Any]: | |
| parts = (token or "").split(".") | |
| if len(parts) < 2: | |
| raise ValueError("not a JWT") | |
| segment = parts[1] | |
| segment += "=" * (-len(segment) % 4) | |
| raw = base64.urlsafe_b64decode(segment.encode("ascii")) | |
| payload = json.loads(raw.decode("utf-8")) | |
| return payload if isinstance(payload, dict) else {} | |
| def access_token_is_expiring(access_token: str, skew_seconds: int = CHATGPT_ACCESS_TOKEN_REFRESH_SKEW_SECONDS) -> bool: | |
| try: | |
| exp = int(_decode_jwt_payload(access_token).get("exp") or 0) | |
| except Exception: | |
| return True | |
| return exp <= int(time.time()) + int(skew_seconds) | |
| def resolve_runtime_credentials(auth_id: str, owner: Optional[str] = None, *, force_refresh: bool = False) -> Dict[str, Any]: | |
| ProviderAuthSession, SessionLocal, utcnow_naive = _database_handles() | |
| db = SessionLocal() | |
| try: | |
| q = db.query(ProviderAuthSession).filter( | |
| ProviderAuthSession.id == auth_id, | |
| ProviderAuthSession.provider == CHATGPT_SUBSCRIPTION_PROVIDER, | |
| ) | |
| if owner: | |
| q = q.filter(ProviderAuthSession.owner == owner) | |
| row = q.first() | |
| if row is None: | |
| raise ChatGPTSubscriptionAuthNotFound("ChatGPT Subscription credentials were not found for this user.") | |
| access_token = row.access_token or "" | |
| if force_refresh or access_token_is_expiring(access_token): | |
| with _refresh_lock_for(auth_id): | |
| db.refresh(row) | |
| access_token = row.access_token or "" | |
| refresh_token = row.refresh_token or "" | |
| if force_refresh or access_token_is_expiring(access_token): | |
| refreshed = refresh_oauth_tokens(access_token, refresh_token) | |
| row.access_token = refreshed["access_token"] | |
| if refreshed.get("refresh_token"): | |
| row.refresh_token = refreshed["refresh_token"] | |
| row.last_refresh = utcnow_naive() | |
| db.commit() | |
| db.refresh(row) | |
| access_token = row.access_token or "" | |
| return { | |
| "provider": CHATGPT_SUBSCRIPTION_PROVIDER, | |
| "base_url": (row.base_url or DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL).rstrip("/"), | |
| "api_key": access_token, | |
| "auth_mode": row.auth_mode or "chatgpt", | |
| } | |
| finally: | |
| db.close() | |
| def to_http_exception(exc: Exception) -> HTTPException: | |
| if isinstance(exc, ChatGPTSubscriptionRateLimited): | |
| return HTTPException(429, str(exc)) | |
| if isinstance(exc, (ChatGPTSubscriptionReauthRequired, ChatGPTSubscriptionAuthNotFound)): | |
| return HTTPException(401, f"{exc} Reconnect the provider.") | |
| return HTTPException(502, str(exc)) | |
| def build_responses_input(messages: list[dict]) -> list[dict]: | |
| input_items: list[dict] = [] | |
| for msg in messages or []: | |
| role = msg.get("role") or "user" | |
| if role == "tool": | |
| role = "user" | |
| content = msg.get("content") | |
| if isinstance(content, list): | |
| text = "\n".join(str(part.get("text") or part.get("content") or "") for part in content if isinstance(part, dict)) | |
| else: | |
| text = "" if content is None else str(content) | |
| input_type = "output_text" if role == "assistant" else "input_text" | |
| input_items.append({"role": role, "content": [{"type": input_type, "text": text}]}) | |
| return input_items | |