diff --git a/docker-compose.yml b/docker-compose.yml index 2f80f6a9..1376740b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: ports: # You can replace 5000 with any other port number of your choice to run Presenton on a different port number. - "5000:80" + # Required for Codex OAuth callback (OpenAI redirects browser directly to localhost:1455) + - "1455:1455" volumes: - ./app_data:/app_data environment: @@ -23,6 +25,7 @@ services: - CUSTOM_LLM_URL=${CUSTOM_LLM_URL} - CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY} - CUSTOM_MODEL=${CUSTOM_MODEL} + - CODEX_MODEL=${CODEX_MODEL} - PEXELS_API_KEY=${PEXELS_API_KEY} - EXTENDED_REASONING=${EXTENDED_REASONING} - TOOL_CALLS=${TOOL_CALLS} @@ -48,6 +51,8 @@ services: ports: # You can replace 5000 with any other port number of your choice to run Presenton on a different port number. - "5000:80" + # Required for Codex OAuth callback (OpenAI redirects browser directly to localhost:1455) + - "1455:1455" volumes: - ./app_data:/app_data environment: @@ -64,6 +69,7 @@ services: - CUSTOM_LLM_URL=${CUSTOM_LLM_URL} - CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY} - CUSTOM_MODEL=${CUSTOM_MODEL} + - CODEX_MODEL=${CODEX_MODEL} - PEXELS_API_KEY=${PEXELS_API_KEY} - EXTENDED_REASONING=${EXTENDED_REASONING} - TOOL_CALLS=${TOOL_CALLS} @@ -80,6 +86,8 @@ services: dockerfile: Dockerfile.dev ports: - "5000:80" + # Required for Codex OAuth callback (OpenAI redirects browser directly to localhost:1455) + - "1455:1455" volumes: - .:/app - ./app_data:/app_data @@ -97,6 +105,7 @@ services: - CUSTOM_LLM_URL=${CUSTOM_LLM_URL} - CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY} - CUSTOM_MODEL=${CUSTOM_MODEL} + - CODEX_MODEL=${CODEX_MODEL} - PEXELS_API_KEY=${PEXELS_API_KEY} - EXTENDED_REASONING=${EXTENDED_REASONING} - TOOL_CALLS=${TOOL_CALLS} @@ -120,6 +129,8 @@ services: capabilities: [gpu] ports: - "5000:80" + # Required for Codex OAuth callback (OpenAI redirects browser directly to localhost:1455) + - "1455:1455" volumes: - .:/app - ./app_data:/app_data @@ -137,6 +148,7 @@ services: - CUSTOM_LLM_URL=${CUSTOM_LLM_URL} - CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY} - CUSTOM_MODEL=${CUSTOM_MODEL} + - CODEX_MODEL=${CODEX_MODEL} - PEXELS_API_KEY=${PEXELS_API_KEY} - EXTENDED_REASONING=${EXTENDED_REASONING} - TOOL_CALLS=${TOOL_CALLS} @@ -145,4 +157,4 @@ services: - DATABASE_URL=${DATABASE_URL} - DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING} - COMFYUI_URL=${COMFYUI_URL} - - COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW} \ No newline at end of file + - COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW} diff --git a/servers/fastapi/api/v1/ppt/endpoints/codex_auth.py b/servers/fastapi/api/v1/ppt/endpoints/codex_auth.py new file mode 100644 index 00000000..c6576888 --- /dev/null +++ b/servers/fastapi/api/v1/ppt/endpoints/codex_auth.py @@ -0,0 +1,278 @@ +""" +OpenAI Codex OAuth endpoints. + +Flow: + 1. POST /codex/auth/initiate — start the flow, get back an auth URL + session_id + 2. Browser opens the URL, user authenticates with OpenAI + 3. OpenAI redirects to http://localhost:1455/auth/callback (captured by local server) + 4. GET /codex/auth/status/{session_id} — poll until code captured; exchanges and stores tokens + 5. POST /codex/auth/exchange — manual fallback if browser callback didn't fire + 6. POST /codex/auth/refresh — refresh a stored token +""" +import uuid +from typing import Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from utils.oauth.openai_codex import ( + OAuthCallbackServer, + TokenSuccess, + create_authorization_flow, + exchange_authorization_code, + get_account_id, + parse_authorization_input, + refresh_access_token, +) +from utils.get_env import ( + get_codex_access_token_env, + get_codex_refresh_token_env, + get_codex_token_expires_env, +) +from utils.set_env import ( + set_codex_access_token_env, + set_codex_account_id_env, + set_codex_refresh_token_env, + set_codex_token_expires_env, + set_codex_model_env, +) +from utils.user_config import save_codex_tokens_to_user_config + +CODEX_AUTH_ROUTER = APIRouter(prefix="/codex/auth", tags=["Codex OAuth"]) + +# --------------------------------------------------------------------------- +# In-memory session store {session_id: {"verifier": str, "state": str, "server": OAuthCallbackServer}} +# Sessions are short-lived; garbage-collected when consumed. +# --------------------------------------------------------------------------- +_sessions: dict[str, dict] = {} + + +# --------------------------------------------------------------------------- +# Request / Response models +# --------------------------------------------------------------------------- + +class InitiateResponse(BaseModel): + session_id: str + url: str + instructions: str + + +class StatusResponse(BaseModel): + status: str # "pending" | "success" | "failed" + account_id: Optional[str] = None + detail: Optional[str] = None + + +class ExchangeRequest(BaseModel): + session_id: str + code: str # raw code OR full redirect URL OR code#state shorthand + + +class ExchangeResponse(BaseModel): + account_id: str + + +class RefreshResponse(BaseModel): + account_id: Optional[str] + detail: str + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + +def _store_token(result: TokenSuccess) -> Optional[str]: + """Persist token fields in env vars and userConfig.json. Returns account_id or None.""" + set_codex_access_token_env(result.access) + set_codex_refresh_token_env(result.refresh) + set_codex_token_expires_env(str(result.expires)) + account_id = get_account_id(result.access) + if account_id: + set_codex_account_id_env(account_id) + save_codex_tokens_to_user_config() + return account_id + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@CODEX_AUTH_ROUTER.post("/initiate", response_model=InitiateResponse) +async def initiate_codex_auth(): + """ + Start the OpenAI Codex OAuth flow. + + Returns an authorization URL to open in the browser and a session_id to use + when polling /status or calling /exchange. A local HTTP server is started + on port 1455 to receive the redirect automatically. + """ + flow = create_authorization_flow() + server = OAuthCallbackServer(state=flow.state) + server_started = server.start() + + session_id = str(uuid.uuid4()) + _sessions[session_id] = { + "verifier": flow.verifier, + "state": flow.state, + "server": server, + "server_started": server_started, + } + + instructions = ( + "Open the URL in your browser and complete the OpenAI login. " + + ( + "The callback will be captured automatically." + if server_started + else "Port 1455 could not be bound — paste the redirect URL or code into /exchange." + ) + ) + + return InitiateResponse( + session_id=session_id, + url=flow.url, + instructions=instructions, + ) + + +@CODEX_AUTH_ROUTER.get("/status/{session_id}", response_model=StatusResponse) +async def poll_codex_auth_status(session_id: str): + """ + Poll for the result of an ongoing OAuth flow. + + Returns {"status": "pending"} until the callback server captures the code. + On success the tokens are stored in environment variables and the session + is cleaned up. + """ + session = _sessions.get(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found or already consumed") + + server: OAuthCallbackServer = session["server"] + + # Non-blocking peek — check whether the callback server already received a code + code = server.get_code_nowait() if session.get("server_started") else None + + if code is None: + return StatusResponse(status="pending") + + # We have a code — exchange it + verifier: str = session["verifier"] + result = exchange_authorization_code(code, verifier) + + # Clean up session + server.close() + _sessions.pop(session_id, None) + + if not isinstance(result, TokenSuccess): + return StatusResponse(status="failed", detail=result.reason) + + account_id = _store_token(result) + return StatusResponse(status="success", account_id=account_id) + + +@CODEX_AUTH_ROUTER.post("/exchange", response_model=ExchangeResponse) +async def exchange_codex_code(body: ExchangeRequest): + """ + Manual code exchange fallback. + + Accepts the session_id from /initiate and either: + - a bare authorization code + - the full redirect URL (http://localhost:1455/auth/callback?code=…&state=…) + - the code#state shorthand + + Exchanges the code for tokens and stores them in environment variables. + """ + session = _sessions.get(body.session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found or already consumed") + + parsed = parse_authorization_input(body.code) + code = parsed.get("code") + incoming_state = parsed.get("state") + + if not code: + raise HTTPException(status_code=400, detail="Could not extract authorization code from input") + + if incoming_state and incoming_state != session["state"]: + raise HTTPException(status_code=400, detail="State mismatch — possible CSRF") + + verifier: str = session["verifier"] + server: OAuthCallbackServer = session["server"] + + result = exchange_authorization_code(code, verifier) + + server.close() + _sessions.pop(body.session_id, None) + + if not isinstance(result, TokenSuccess): + raise HTTPException(status_code=502, detail=f"Token exchange failed: {result.reason}") + + account_id = _store_token(result) + if not account_id: + raise HTTPException(status_code=502, detail="Token exchanged but could not extract account ID") + + return ExchangeResponse(account_id=account_id) + + +@CODEX_AUTH_ROUTER.post("/refresh", response_model=RefreshResponse) +async def refresh_codex_token(): + """ + Refresh the stored Codex OAuth access token using the refresh token. + + Updates environment variables with the new tokens. + """ + refresh_token = get_codex_refresh_token_env() + if not refresh_token: + raise HTTPException( + status_code=400, + detail="No Codex refresh token stored. Please authenticate first via /initiate", + ) + + result = refresh_access_token(refresh_token) + if not isinstance(result, TokenSuccess): + raise HTTPException(status_code=502, detail=f"Token refresh failed: {result.reason}") + + account_id = _store_token(result) + return RefreshResponse( + account_id=account_id, + detail="Token refreshed successfully", + ) + + +@CODEX_AUTH_ROUTER.get("/status", response_model=StatusResponse) +async def get_codex_auth_status(): + """ + Return whether a valid Codex OAuth token is currently stored. + """ + import time + + access_token = get_codex_access_token_env() + if not access_token: + return StatusResponse(status="not_authenticated", detail="No access token stored") + + expires_str = get_codex_token_expires_env() + if expires_str: + try: + expires_ms = int(expires_str) + now_ms = int(time.time() * 1000) + if now_ms >= expires_ms: + return StatusResponse(status="expired", detail="Access token has expired — call /refresh") + except (ValueError, TypeError): + pass + + account_id = get_account_id(access_token) + return StatusResponse(status="authenticated", account_id=account_id) + + +@CODEX_AUTH_ROUTER.post("/logout") +async def logout_codex(): + """ + Clear all stored Codex OAuth credentials from environment variables and userConfig.json. + """ + set_codex_access_token_env("") + set_codex_refresh_token_env("") + set_codex_token_expires_env("") + set_codex_account_id_env("") + set_codex_model_env("") + save_codex_tokens_to_user_config() + return {"detail": "Logged out successfully"} diff --git a/servers/fastapi/api/v1/ppt/router.py b/servers/fastapi/api/v1/ppt/router.py index 1f89a2f1..3449e22a 100644 --- a/servers/fastapi/api/v1/ppt/router.py +++ b/servers/fastapi/api/v1/ppt/router.py @@ -3,6 +3,7 @@ from fastapi import APIRouter from api.v1.ppt.endpoints.slide_to_html import SLIDE_TO_HTML_ROUTER, HTML_TO_REACT_ROUTER, HTML_EDIT_ROUTER, LAYOUT_MANAGEMENT_ROUTER from api.v1.ppt.endpoints.presentation import PRESENTATION_ROUTER from api.v1.ppt.endpoints.anthropic import ANTHROPIC_ROUTER +from api.v1.ppt.endpoints.codex_auth import CODEX_AUTH_ROUTER from api.v1.ppt.endpoints.google import GOOGLE_ROUTER from api.v1.ppt.endpoints.openai import OPENAI_ROUTER from api.v1.ppt.endpoints.files import FILES_ROUTER @@ -36,4 +37,5 @@ API_V1_PPT_ROUTER.include_router(PDF_SLIDES_ROUTER) API_V1_PPT_ROUTER.include_router(OPENAI_ROUTER) API_V1_PPT_ROUTER.include_router(ANTHROPIC_ROUTER) API_V1_PPT_ROUTER.include_router(GOOGLE_ROUTER) +API_V1_PPT_ROUTER.include_router(CODEX_AUTH_ROUTER) API_V1_PPT_ROUTER.include_router(PPTX_FONTS_ROUTER) diff --git a/servers/fastapi/constants/llm.py b/servers/fastapi/constants/llm.py index 7d374f30..cc9053e1 100644 --- a/servers/fastapi/constants/llm.py +++ b/servers/fastapi/constants/llm.py @@ -4,3 +4,4 @@ OPENAI_URL = "https://api.openai.com/v1" DEFAULT_OPENAI_MODEL = "gpt-4.1" DEFAULT_GOOGLE_MODEL = "models/gemini-2.5-flash" DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514" +DEFAULT_CODEX_MODEL = "gpt-5.3-codex-spark" diff --git a/servers/fastapi/enums/llm_provider.py b/servers/fastapi/enums/llm_provider.py index 049d365f..3bf23f09 100644 --- a/servers/fastapi/enums/llm_provider.py +++ b/servers/fastapi/enums/llm_provider.py @@ -7,3 +7,4 @@ class LLMProvider(Enum): GOOGLE = "google" ANTHROPIC = "anthropic" CUSTOM = "custom" + CODEX = "codex" diff --git a/servers/fastapi/models/user_config.py b/servers/fastapi/models/user_config.py index da424b59..c26a6cb0 100644 --- a/servers/fastapi/models/user_config.py +++ b/servers/fastapi/models/user_config.py @@ -48,3 +48,10 @@ class UserConfig(BaseModel): # Web Search WEB_GROUNDING: Optional[bool] = None + + # Codex OAuth (ChatGPT) + CODEX_MODEL: Optional[str] = None + CODEX_ACCESS_TOKEN: Optional[str] = None + CODEX_REFRESH_TOKEN: Optional[str] = None + CODEX_TOKEN_EXPIRES: Optional[str] = None + CODEX_ACCOUNT_ID: Optional[str] = None diff --git a/servers/fastapi/services/llm_client.py b/servers/fastapi/services/llm_client.py index 9662122d..1c4d8e5d 100644 --- a/servers/fastapi/services/llm_client.py +++ b/servers/fastapi/services/llm_client.py @@ -1,5 +1,6 @@ import asyncio import dirtyjson +import httpx import json from typing import AsyncGenerator, List, Optional from fastapi import HTTPException @@ -44,6 +45,10 @@ from utils.async_iterator import iterator_to_async from utils.dummy_functions import do_nothing_async from utils.get_env import ( get_anthropic_api_key_env, + get_codex_access_token_env, + get_codex_account_id_env, + get_codex_refresh_token_env, + get_codex_token_expires_env, get_custom_llm_api_key_env, get_custom_llm_url_env, get_disable_thinking_env, @@ -53,6 +58,12 @@ from utils.get_env import ( get_tool_calls_env, get_web_grounding_env, ) +from utils.set_env import ( + set_codex_access_token_env, + set_codex_account_id_env, + set_codex_refresh_token_env, + set_codex_token_expires_env, +) from utils.llm_provider import get_llm_provider, get_model from utils.parsers import parse_bool_or_none from utils.schema_utils import ( @@ -62,6 +73,27 @@ from utils.schema_utils import ( ) +def _to_responses_tools(chat_tools: List[dict]) -> List[dict]: + """Convert Chat Completions tool format to flat Responses API format. + + Chat Completions: {"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}} + Responses API: {"type": "function", "name": ..., "description": ..., "parameters": ...} + """ + result = [] + for tool in chat_tools: + if tool.get("type") != "function": + result.append(tool) + continue + fn = tool.get("function") or tool + result.append({ + "type": "function", + "name": fn.get("name", ""), + "description": fn.get("description", ""), + "parameters": fn.get("parameters", {}), + }) + return result + + class LLMClient: def __init__(self): self.llm_provider = get_llm_provider() @@ -100,10 +132,12 @@ class LLMClient: return self._get_ollama_client() case LLMProvider.CUSTOM: return self._get_custom_client() + case LLMProvider.CODEX: + return None # Codex uses direct httpx calls, not self._client case _: raise HTTPException( status_code=400, - detail="LLM Provider must be either openai, google, anthropic, ollama, or custom", + detail="LLM Provider must be either openai, google, anthropic, ollama, custom, or codex", ) def _get_openai_client(self): @@ -147,6 +181,403 @@ class LLMClient: api_key=get_custom_llm_api_key_env() or "null", ) + def _get_codex_headers(self) -> dict: + """Return the HTTP headers required for Codex Responses API requests. + + Handles token auto-refresh if the stored token is expired or within + 60 s of expiry before building the header dict. + """ + access_token = get_codex_access_token_env() + if not access_token: + raise HTTPException( + status_code=400, + detail="Codex OAuth access token is not set. Please authenticate via /api/v1/ppt/codex/auth/initiate", + ) + + # Auto-refresh if the token is expired or about to expire (within 60 s) + expires_str = get_codex_token_expires_env() + if expires_str: + try: + expires_ms = int(expires_str) + now_ms = int(__import__("time").time() * 1000) + if now_ms >= expires_ms - 60_000: + refresh_token = get_codex_refresh_token_env() + if refresh_token: + from utils.oauth.openai_codex import ( + get_account_id, + refresh_access_token, + TokenSuccess, + ) + result = refresh_access_token(refresh_token) + if isinstance(result, TokenSuccess): + set_codex_access_token_env(result.access) + set_codex_refresh_token_env(result.refresh) + set_codex_token_expires_env(str(result.expires)) + account_id = get_account_id(result.access) + if account_id: + set_codex_account_id_env(account_id) + access_token = result.access + except (ValueError, TypeError): + pass + + account_id = get_codex_account_id_env() or "" + return { + "Authorization": f"Bearer {access_token}", + "chatgpt-account-id": account_id, + "OpenAI-Beta": "responses=experimental", + "originator": "pi", + "content-type": "application/json", + "accept": "text/event-stream", + } + + # ------------------------------------------------------------------------- + # Codex (Responses API) helpers + # ------------------------------------------------------------------------- + + def _build_codex_body( + self, + model: str, + messages: List[LLMMessage], + tools: Optional[List[dict]] = None, + ) -> dict: + """Convert LLMMessages to the Responses API request body for Codex.""" + instructions = None + input_messages = [] + + for msg in messages: + if isinstance(msg, LLMSystemMessage): + instructions = msg.content + elif isinstance(msg, LLMUserMessage): + input_messages.append({ + "role": "user", + "content": [{"type": "input_text", "text": msg.content}], + }) + elif isinstance(msg, OpenAIAssistantMessage): + # Assistant turn — may carry text or tool-call data + text = msg.content or "" + if text: + input_messages.append({ + "role": "assistant", + "content": [{"type": "output_text", "text": text}], + }) + # Tool calls from a previous assistant turn are already resolved + # via the tool result messages that follow; skip raw tool call data. + else: + # Generic fallback: treat as user message + text = getattr(msg, "content", "") or "" + if text: + input_messages.append({ + "role": "user", + "content": [{"type": "input_text", "text": text}], + }) + + body: dict = { + "model": model, + "store": False, + "stream": True, + "text": {"verbosity": "medium"}, + "include": ["reasoning.encrypted_content"], + "tool_choice": "auto", + "parallel_tool_calls": True, + } + if instructions: + body["instructions"] = instructions + if input_messages: + body["input"] = input_messages + if tools: + # Responses API uses flat format: {"type":"function","name":...} + body["tools"] = tools + + return body + + async def _stream_codex_raw( + self, + model: str, + messages: List[LLMMessage], + tools: Optional[List[dict]] = None, + ): + """Async generator of raw SSE event dicts from the Codex Responses API.""" + headers = self._get_codex_headers() + body = self._build_codex_body(model, messages, tools) + + async with httpx.AsyncClient(timeout=120.0) as http_client: + async with http_client.stream( + "POST", + "https://chatgpt.com/backend-api/codex/responses", + json=body, + headers=headers, + ) as resp: + if resp.status_code != 200: + error_text = await resp.aread() + raise HTTPException( + status_code=resp.status_code, + detail=f"Codex API error {resp.status_code}: {error_text.decode(errors='replace')[:400]}", + ) + async for line in resp.aiter_lines(): + if not line.startswith("data: "): + continue + data = line[6:].strip() + if not data or data == "[DONE]": + continue + try: + yield json.loads(data) + except json.JSONDecodeError: + continue + + async def _stream_codex( + self, + model: str, + messages: List[LLMMessage], + max_tokens: Optional[int] = None, + tools: Optional[List[dict]] = None, + depth: int = 0, + ) -> AsyncGenerator[str, None]: + """Stream text from the Codex Responses API, handling tool calls.""" + # Convert Chat-Completions tool format to flat Responses API format + responses_tools = _to_responses_tools(tools) if tools else None + + tool_calls_by_id: dict[str, dict] = {} + + async for event in self._stream_codex_raw(model, messages, responses_tools): + event_type = event.get("type", "") + + if event_type == "response.output_text.delta": + delta = event.get("delta", "") + if delta: + yield delta + + elif event_type == "response.output_item.done": + item = event.get("item") or {} + if item.get("type") == "function_call": + tool_calls_by_id[item.get("call_id", item.get("id", ""))] = item + + elif event_type in ("response.failed", "error"): + msg_text = event.get("message") or str(event) + raise HTTPException(status_code=502, detail=f"Codex stream error: {msg_text}") + + if tool_calls_by_id and tools and depth < 5: + openai_calls = [ + OpenAIToolCall( + id=item.get("call_id", item.get("id", "")), + type="function", + function=OpenAIToolCallFunction( + name=item.get("name", ""), + arguments=item.get("arguments", "{}"), + ), + ) + for item in tool_calls_by_id.values() + ] + tool_call_messages = await self.tool_calls_handler.handle_tool_calls_openai( + openai_calls + ) + new_messages = [ + *messages, + OpenAIAssistantMessage( + role="assistant", + content=None, + tool_calls=[tc.model_dump() for tc in openai_calls], + ), + *tool_call_messages, + ] + async for chunk in self._stream_codex( + model=model, + messages=new_messages, + max_tokens=max_tokens, + tools=tools, + depth=depth + 1, + ): + yield chunk + + async def _generate_codex( + self, + model: str, + messages: List[LLMMessage], + max_tokens: Optional[int] = None, + tools: Optional[List[dict]] = None, + depth: int = 0, + ) -> str | None: + """Non-streaming text generation via Codex Responses API.""" + responses_tools = _to_responses_tools(tools) if tools else None + + text_parts: list[str] = [] + tool_calls_by_id: dict[str, dict] = {} + + async for event in self._stream_codex_raw(model, messages, responses_tools): + event_type = event.get("type", "") + + if event_type == "response.output_text.delta": + delta = event.get("delta", "") + if delta: + text_parts.append(delta) + + elif event_type == "response.output_item.done": + item = event.get("item") or {} + if item.get("type") == "function_call": + tool_calls_by_id[item.get("call_id", item.get("id", ""))] = item + + elif event_type in ("response.failed", "error"): + msg_text = event.get("message") or str(event) + raise HTTPException(status_code=502, detail=f"Codex error: {msg_text}") + + if tool_calls_by_id and tools and depth < 5: + openai_calls = [ + OpenAIToolCall( + id=item.get("call_id", item.get("id", "")), + type="function", + function=OpenAIToolCallFunction( + name=item.get("name", ""), + arguments=item.get("arguments", "{}"), + ), + ) + for item in tool_calls_by_id.values() + ] + tool_call_messages = await self.tool_calls_handler.handle_tool_calls_openai( + openai_calls + ) + new_messages = [ + *messages, + OpenAIAssistantMessage( + role="assistant", + content=None, + tool_calls=[tc.model_dump() for tc in openai_calls], + ), + *tool_call_messages, + ] + return await self._generate_codex( + model=model, + messages=new_messages, + max_tokens=max_tokens, + tools=tools, + depth=depth + 1, + ) + + return "".join(text_parts) or None + + async def _stream_codex_structured( + self, + model: str, + messages: List[LLMMessage], + response_format: dict, + strict: bool = False, + max_tokens: Optional[int] = None, + tools: Optional[List[dict]] = None, + depth: int = 0, + ) -> AsyncGenerator[str, None]: + """Stream structured output from Codex via a ResponseSchema tool call.""" + # Build the ResponseSchema tool in flat Responses API format + schema = response_format + if strict and depth == 0: + schema = ensure_strict_json_schema(schema, path=(), root=schema) + + response_schema_tool = { + "type": "function", + "name": "ResponseSchema", + "description": "Provide response to the user", + "parameters": schema, + } + + all_tools: list[dict] = [response_schema_tool] + if tools: + all_tools.extend(_to_responses_tools(tools)) + + has_response_schema_call = False + tool_calls_by_id: dict[str, dict] = {} + current_call_id: Optional[str] = None + + async for event in self._stream_codex_raw(model, messages, all_tools): + event_type = event.get("type", "") + + if event_type == "response.output_item.added": + item = event.get("item") or {} + if item.get("type") == "function_call" and item.get("name") == "ResponseSchema": + current_call_id = item.get("call_id", item.get("id")) + has_response_schema_call = True + + elif event_type == "response.function_call_arguments.delta": + if current_call_id is not None or has_response_schema_call: + delta = event.get("delta", "") + if delta: + yield delta + + elif event_type == "response.output_item.done": + item = event.get("item") or {} + if item.get("type") == "function_call": + tool_calls_by_id[item.get("call_id", item.get("id", ""))] = item + + elif event_type in ("response.failed", "error"): + msg_text = event.get("message") or str(event) + raise HTTPException(status_code=502, detail=f"Codex structured error: {msg_text}") + + # Handle non-ResponseSchema tool calls recursively + other_tool_calls = { + k: v for k, v in tool_calls_by_id.items() + if v.get("name") != "ResponseSchema" + } + if other_tool_calls and tools and depth < 5: + openai_calls = [ + OpenAIToolCall( + id=item.get("call_id", item.get("id", "")), + type="function", + function=OpenAIToolCallFunction( + name=item.get("name", ""), + arguments=item.get("arguments", "{}"), + ), + ) + for item in other_tool_calls.values() + ] + tool_call_messages = await self.tool_calls_handler.handle_tool_calls_openai( + openai_calls + ) + new_messages = [ + *messages, + OpenAIAssistantMessage( + role="assistant", + content=None, + tool_calls=[tc.model_dump() for tc in openai_calls], + ), + *tool_call_messages, + ] + async for chunk in self._stream_codex_structured( + model=model, + messages=new_messages, + response_format=response_format, + strict=strict, + max_tokens=max_tokens, + tools=tools, + depth=depth + 1, + ): + yield chunk + + async def _generate_codex_structured( + self, + model: str, + messages: List[LLMMessage], + response_format: dict, + strict: bool = False, + max_tokens: Optional[int] = None, + tools: Optional[List[dict]] = None, + depth: int = 0, + ) -> dict | None: + """Non-streaming structured output from Codex via ResponseSchema tool.""" + accumulated: list[str] = [] + async for chunk in self._stream_codex_structured( + model=model, + messages=messages, + response_format=response_format, + strict=strict, + max_tokens=max_tokens, + tools=tools, + depth=depth, + ): + accumulated.append(chunk) + + raw = "".join(accumulated) + if not raw: + return None + if depth == 0: + return dict(dirtyjson.loads(raw)) + return raw + # ? Prompts def _get_system_prompt(self, messages: List[LLMMessage]) -> str: for message in messages: @@ -419,6 +850,13 @@ class LLMClient: max_tokens=max_tokens, tools=parsed_tools, ) + case LLMProvider.CODEX: + content = await self._generate_codex( + model=model, + messages=messages, + max_tokens=max_tokens, + tools=parsed_tools, + ) case LLMProvider.GOOGLE: content = await self._generate_google( model=model, @@ -795,6 +1233,15 @@ class LLMClient: tools=parsed_tools, max_tokens=max_tokens, ) + case LLMProvider.CODEX: + content = await self._generate_codex_structured( + model=model, + messages=messages, + response_format=response_format, + strict=strict, + tools=parsed_tools, + max_tokens=max_tokens, + ) case LLMProvider.GOOGLE: content = await self._generate_google_structured( model=model, @@ -1112,6 +1559,13 @@ class LLMClient: max_tokens=max_tokens, tools=parsed_tools, ) + case LLMProvider.CODEX: + return self._stream_codex( + model=model, + messages=messages, + max_tokens=max_tokens, + tools=parsed_tools, + ) case LLMProvider.GOOGLE: return self._stream_google( model=model, @@ -1538,6 +1992,15 @@ class LLMClient: tools=parsed_tools, max_tokens=max_tokens, ) + case LLMProvider.CODEX: + return self._stream_codex_structured( + model=model, + messages=messages, + response_format=response_format, + strict=strict, + tools=parsed_tools, + max_tokens=max_tokens, + ) case LLMProvider.GOOGLE: return self._stream_google_structured( model=model, diff --git a/servers/fastapi/utils/get_env.py b/servers/fastapi/utils/get_env.py index c7dc16d0..e7454f87 100644 --- a/servers/fastapi/utils/get_env.py +++ b/servers/fastapi/utils/get_env.py @@ -117,3 +117,24 @@ def get_dall_e_3_quality_env(): # Gpt Image 1.5 Quality def get_gpt_image_1_5_quality_env(): return os.getenv("GPT_IMAGE_1_5_QUALITY") + + +# Codex OAuth +def get_codex_access_token_env(): + return os.getenv("CODEX_ACCESS_TOKEN") + + +def get_codex_refresh_token_env(): + return os.getenv("CODEX_REFRESH_TOKEN") + + +def get_codex_token_expires_env(): + return os.getenv("CODEX_TOKEN_EXPIRES") + + +def get_codex_account_id_env(): + return os.getenv("CODEX_ACCOUNT_ID") + + +def get_codex_model_env(): + return os.getenv("CODEX_MODEL") diff --git a/servers/fastapi/utils/llm_provider.py b/servers/fastapi/utils/llm_provider.py index aabc8f61..e20c76ae 100644 --- a/servers/fastapi/utils/llm_provider.py +++ b/servers/fastapi/utils/llm_provider.py @@ -8,6 +8,7 @@ from constants.llm import ( from enums.llm_provider import LLMProvider from utils.get_env import ( get_anthropic_model_env, + get_codex_model_env, get_custom_model_env, get_google_model_env, get_llm_provider_env, @@ -22,7 +23,7 @@ def get_llm_provider(): except: raise HTTPException( status_code=500, - detail=f"Invalid LLM provider. Please select one of: openai, google, anthropic, ollama, custom", + detail=f"Invalid LLM provider. Please select one of: openai, google, anthropic, ollama, custom, codex", ) @@ -46,6 +47,10 @@ def is_custom_llm_selected(): return get_llm_provider() == LLMProvider.CUSTOM +def is_codex_selected(): + return get_llm_provider() == LLMProvider.CODEX + + def get_model(): selected_llm = get_llm_provider() if selected_llm == LLMProvider.OPENAI: @@ -58,8 +63,10 @@ def get_model(): return get_ollama_model_env() elif selected_llm == LLMProvider.CUSTOM: return get_custom_model_env() + elif selected_llm == LLMProvider.CODEX: + return get_codex_model_env() else: raise HTTPException( status_code=500, - detail=f"Invalid LLM provider. Please select one of: openai, google, anthropic, ollama, custom", + detail=f"Invalid LLM provider. Please select one of: openai, google, anthropic, ollama, custom, codex", ) diff --git a/servers/fastapi/utils/oauth/__init__.py b/servers/fastapi/utils/oauth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/servers/fastapi/utils/oauth/openai_codex.py b/servers/fastapi/utils/oauth/openai_codex.py new file mode 100644 index 00000000..b1b5578d --- /dev/null +++ b/servers/fastapi/utils/oauth/openai_codex.py @@ -0,0 +1,348 @@ +""" +OpenAI Codex (ChatGPT OAuth) flow — Python port of +pi-mono-main/packages/ai/src/utils/oauth/openai-codex.ts + +Handles PKCE authorization, local callback server, token exchange and refresh. +No FastAPI dependencies; all HTTP is done with the standard library + httpx. +""" +import base64 +import json +import secrets +import threading +import time +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Optional +from urllib.parse import parse_qs, urlencode, urlparse + +import httpx + +from utils.oauth.pkce import generate_pkce + +CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" +TOKEN_URL = "https://auth.openai.com/oauth/token" +REDIRECT_URI = "http://localhost:1455/auth/callback" +SCOPE = "openid profile email offline_access" +JWT_CLAIM_PATH = "https://api.openai.com/auth" + +CALLBACK_PORT = 1455 + +SUCCESS_HTML = b""" + +
+ + +Authentication successful. Return to your terminal / application to continue.
+ +""" + + +# --------------------------------------------------------------------------- +# Data types +# --------------------------------------------------------------------------- + +@dataclass +class TokenSuccess: + access: str + refresh: str + expires: int # Unix ms timestamp when the token expires + + +@dataclass +class TokenFailure: + reason: str + + +TokenResult = TokenSuccess | TokenFailure + + +@dataclass +class AuthorizationFlow: + verifier: str + state: str + url: str + + +# --------------------------------------------------------------------------- +# JWT helpers +# --------------------------------------------------------------------------- + +def _decode_jwt_payload(token: str) -> Optional[dict]: + """Decode the payload segment of a JWT without verifying the signature.""" + try: + parts = token.split(".") + if len(parts) != 3: + return None + payload_b64 = parts[1] + # Add padding if needed + padding = 4 - len(payload_b64) % 4 + if padding != 4: + payload_b64 += "=" * padding + decoded = base64.urlsafe_b64decode(payload_b64) + return json.loads(decoded) + except Exception: + return None + + +def get_account_id(access_token: str) -> Optional[str]: + """Extract the ChatGPT account ID from an access token JWT.""" + payload = _decode_jwt_payload(access_token) + if not payload: + return None + auth_claims = payload.get(JWT_CLAIM_PATH) + if not isinstance(auth_claims, dict): + return None + account_id = auth_claims.get("chatgpt_account_id") + if isinstance(account_id, str) and account_id: + return account_id + return None + + +# --------------------------------------------------------------------------- +# Authorization URL + PKCE +# --------------------------------------------------------------------------- + +def create_authorization_flow(originator: str = "pi") -> AuthorizationFlow: + """Generate PKCE verifier/challenge, state, and the full authorization URL.""" + verifier, challenge = generate_pkce() + state = secrets.token_hex(16) + + params = { + "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": originator, + } + url = f"{AUTHORIZE_URL}?{urlencode(params)}" + return AuthorizationFlow(verifier=verifier, state=state, url=url) + + +# --------------------------------------------------------------------------- +# Local callback server +# --------------------------------------------------------------------------- + +class _CallbackHandler(BaseHTTPRequestHandler): + """Minimal HTTP handler that captures the OAuth callback code.""" + + def do_GET(self): # noqa: N802 + parsed = urlparse(self.path) + if parsed.path != "/auth/callback": + self.send_response(404) + self.end_headers() + self.wfile.write(b"Not found") + return + + qs = parse_qs(parsed.query) + state_vals = qs.get("state", []) + code_vals = qs.get("code", []) + + expected_state: str = self.server.expected_state # type: ignore[attr-defined] + + if not state_vals or state_vals[0] != expected_state: + self.send_response(400) + self.end_headers() + self.wfile.write(b"State mismatch") + return + + if not code_vals: + self.send_response(400) + self.end_headers() + self.wfile.write(b"Missing authorization code") + return + + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(SUCCESS_HTML) + + self.server.captured_code = code_vals[0] # type: ignore[attr-defined] + + def log_message(self, format, *args): # noqa: A002 + pass # suppress default stderr logging + + +class OAuthCallbackServer: + """ + Wraps an HTTPServer that listens on port 1455 for the OAuth callback. + Runs in a background daemon thread so it doesn't block the caller. + """ + + def __init__(self, state: str): + self._state = state + self._server: Optional[HTTPServer] = None + self._thread: Optional[threading.Thread] = None + self._started = threading.Event() + self._cancelled = False + + def start(self) -> bool: + """Start the background HTTP server. Returns True if successful.""" + try: + server = HTTPServer(("0.0.0.0", CALLBACK_PORT), _CallbackHandler) + server.expected_state = self._state # type: ignore[attr-defined] + server.captured_code = None # type: ignore[attr-defined] + server.timeout = 0.2 # short poll interval so we can check cancel + self._server = server + + def _serve(): + self._started.set() + while not self._cancelled and server.captured_code is None: + server.handle_request() + server.server_close() + + self._thread = threading.Thread(target=_serve, daemon=True) + self._thread.start() + self._started.wait(timeout=2) + return True + except OSError: + return False + + def get_code_nowait(self) -> Optional[str]: + """Non-blocking peek — returns the captured code or None immediately.""" + if self._server is None: + return None + return self._server.captured_code # type: ignore[attr-defined] + + def wait_for_code(self, timeout_seconds: int = 120) -> Optional[str]: + """ + Block until the callback delivers a code or timeout / cancellation. + Returns the authorization code or None. + """ + if self._server is None: + return None + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if self._cancelled: + return None + code = self._server.captured_code # type: ignore[attr-defined] + if code: + return code + time.sleep(0.1) + return None + + def cancel(self): + self._cancelled = True + + def close(self): + self._cancelled = True + if self._thread: + self._thread.join(timeout=2) + + +# --------------------------------------------------------------------------- +# Token exchange / refresh (sync — called from thread or FastAPI background) +# --------------------------------------------------------------------------- + +def exchange_authorization_code( + code: str, + verifier: str, + redirect_uri: str = REDIRECT_URI, +) -> TokenResult: + """Exchange an authorization code for access + refresh tokens.""" + try: + response = httpx.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, + ) + if not response.is_success: + return TokenFailure(reason=f"HTTP {response.status_code}: {response.text[:200]}") + + body = response.json() + access = body.get("access_token") + refresh = body.get("refresh_token") + expires_in = body.get("expires_in") + + if not access or not refresh or not isinstance(expires_in, (int, float)): + return TokenFailure(reason=f"Token response missing fields: {list(body.keys())}") + + expires_ms = int(time.time() * 1000) + int(expires_in) * 1000 + return TokenSuccess(access=access, refresh=refresh, expires=expires_ms) + except Exception as exc: + return TokenFailure(reason=str(exc)) + + +def refresh_access_token(refresh_token: str) -> TokenResult: + """Use a refresh token to obtain a new access token.""" + try: + response = httpx.post( + TOKEN_URL, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLIENT_ID, + }, + timeout=30, + ) + if not response.is_success: + return TokenFailure(reason=f"HTTP {response.status_code}: {response.text[:200]}") + + body = response.json() + access = body.get("access_token") + refresh = body.get("refresh_token") + expires_in = body.get("expires_in") + + if not access or not refresh or not isinstance(expires_in, (int, float)): + return TokenFailure(reason=f"Token refresh response missing fields: {list(body.keys())}") + + expires_ms = int(time.time() * 1000) + int(expires_in) * 1000 + return TokenSuccess(access=access, refresh=refresh, expires=expires_ms) + except Exception as exc: + return TokenFailure(reason=str(exc)) + + +# --------------------------------------------------------------------------- +# Parsing helpers (for manual code paste / redirect URL fallback) +# --------------------------------------------------------------------------- + +def parse_authorization_input(raw: str) -> dict: + """ + Accept a variety of user-pasted inputs: + - Full redirect URL: http://localhost:1455/auth/callback?code=X&state=Y + - code#state shorthand + - Raw query string: code=X&state=Y + - Bare code value + Returns a dict with optional 'code' and 'state' keys. + """ + value = raw.strip() + if not value: + return {} + + try: + parsed = urlparse(value) + if parsed.scheme in ("http", "https"): + qs = parse_qs(parsed.query) + return { + k: qs[k][0] + for k in ("code", "state") + if k in qs + } + except Exception: + pass + + if "#" in value: + parts = value.split("#", 1) + return {"code": parts[0], "state": parts[1]} + + if "code=" in value: + qs = parse_qs(value) + return {k: qs[k][0] for k in ("code", "state") if k in qs} + + return {"code": value} diff --git a/servers/fastapi/utils/oauth/pkce.py b/servers/fastapi/utils/oauth/pkce.py new file mode 100644 index 00000000..782bc98a --- /dev/null +++ b/servers/fastapi/utils/oauth/pkce.py @@ -0,0 +1,23 @@ +""" +PKCE utilities using Python's secrets and hashlib. +Python port of pi-mono-main/packages/ai/src/utils/oauth/pkce.ts +""" +import base64 +import hashlib +import secrets + + +def generate_pkce() -> tuple[str, str]: + """ + Generate PKCE code verifier and challenge (S256 method). + + Returns: + (verifier, challenge) — both base64url-encoded, no padding + """ + verifier_bytes = secrets.token_bytes(32) + verifier = base64.urlsafe_b64encode(verifier_bytes).rstrip(b"=").decode() + + digest = hashlib.sha256(verifier.encode()).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + + return verifier, challenge diff --git a/servers/fastapi/utils/set_env.py b/servers/fastapi/utils/set_env.py index e388d391..6f26b34f 100644 --- a/servers/fastapi/utils/set_env.py +++ b/servers/fastapi/utils/set_env.py @@ -103,3 +103,24 @@ def set_dall_e_3_quality_env(value): def set_gpt_image_1_5_quality_env(value): os.environ["GPT_IMAGE_1_5_QUALITY"] = value + + +# Codex OAuth +def set_codex_access_token_env(value: str): + os.environ["CODEX_ACCESS_TOKEN"] = value + + +def set_codex_refresh_token_env(value: str): + os.environ["CODEX_REFRESH_TOKEN"] = value + + +def set_codex_token_expires_env(value: str): + os.environ["CODEX_TOKEN_EXPIRES"] = value + + +def set_codex_account_id_env(value: str): + os.environ["CODEX_ACCOUNT_ID"] = value + + +def set_codex_model_env(value: str): + os.environ["CODEX_MODEL"] = value diff --git a/servers/fastapi/utils/user_config.py b/servers/fastapi/utils/user_config.py index 1dd799bb..f83d3047 100644 --- a/servers/fastapi/utils/user_config.py +++ b/servers/fastapi/utils/user_config.py @@ -28,6 +28,11 @@ from utils.get_env import ( get_pixabay_api_key_env, get_extended_reasoning_env, get_web_grounding_env, + get_codex_access_token_env, + get_codex_refresh_token_env, + get_codex_token_expires_env, + get_codex_account_id_env, + get_codex_model_env, ) from utils.parsers import parse_bool_or_none from utils.set_env import ( @@ -55,6 +60,11 @@ from utils.set_env import ( set_pixabay_api_key_env, set_tool_calls_env, set_web_grounding_env, + set_codex_access_token_env, + set_codex_refresh_token_env, + set_codex_token_expires_env, + set_codex_account_id_env, + set_codex_model_env, ) @@ -118,6 +128,11 @@ def get_user_config(): if existing_config.WEB_GROUNDING is not None else (parse_bool_or_none(get_web_grounding_env()) or False) ), + CODEX_MODEL=existing_config.CODEX_MODEL or get_codex_model_env(), + CODEX_ACCESS_TOKEN=existing_config.CODEX_ACCESS_TOKEN or get_codex_access_token_env(), + CODEX_REFRESH_TOKEN=existing_config.CODEX_REFRESH_TOKEN or get_codex_refresh_token_env(), + CODEX_TOKEN_EXPIRES=existing_config.CODEX_TOKEN_EXPIRES or get_codex_token_expires_env(), + CODEX_ACCOUNT_ID=existing_config.CODEX_ACCOUNT_ID or get_codex_account_id_env(), ) @@ -171,3 +186,43 @@ def update_env_with_user_config(): set_extended_reasoning_env(str(user_config.EXTENDED_REASONING)) if user_config.WEB_GROUNDING is not None: set_web_grounding_env(str(user_config.WEB_GROUNDING)) + if user_config.CODEX_MODEL: + set_codex_model_env(user_config.CODEX_MODEL) + if user_config.CODEX_ACCESS_TOKEN: + set_codex_access_token_env(user_config.CODEX_ACCESS_TOKEN) + if user_config.CODEX_REFRESH_TOKEN: + set_codex_refresh_token_env(user_config.CODEX_REFRESH_TOKEN) + if user_config.CODEX_TOKEN_EXPIRES: + set_codex_token_expires_env(user_config.CODEX_TOKEN_EXPIRES) + if user_config.CODEX_ACCOUNT_ID: + set_codex_account_id_env(user_config.CODEX_ACCOUNT_ID) + + +def save_codex_tokens_to_user_config() -> None: + """ + Write the current in-memory Codex OAuth token env vars back to userConfig.json + so they survive container restarts. Called after a successful token exchange + and on logout (where the env vars have already been cleared to ""). + """ + user_config_path = get_user_config_path_env() + if not user_config_path: + return + + existing: dict = {} + try: + if os.path.exists(user_config_path): + with open(user_config_path, "r") as f: + existing = json.load(f) + except Exception: + pass + + existing["CODEX_ACCESS_TOKEN"] = get_codex_access_token_env() + existing["CODEX_REFRESH_TOKEN"] = get_codex_refresh_token_env() + existing["CODEX_TOKEN_EXPIRES"] = get_codex_token_expires_env() + existing["CODEX_ACCOUNT_ID"] = get_codex_account_id_env() + + try: + with open(user_config_path, "w") as f: + json.dump(existing, f) + except Exception: + pass diff --git a/servers/nextjs/app/api/user-config/route.ts b/servers/nextjs/app/api/user-config/route.ts index 586e9cf1..082b42c6 100644 --- a/servers/nextjs/app/api/user-config/route.ts +++ b/servers/nextjs/app/api/user-config/route.ts @@ -91,6 +91,11 @@ export async function POST(request: Request) { userConfig.USE_CUSTOM_URL === undefined ? existingConfig.USE_CUSTOM_URL : userConfig.USE_CUSTOM_URL, + CODEX_MODEL: userConfig.CODEX_MODEL || existingConfig.CODEX_MODEL, + CODEX_ACCESS_TOKEN: existingConfig.CODEX_ACCESS_TOKEN, + CODEX_REFRESH_TOKEN: existingConfig.CODEX_REFRESH_TOKEN, + CODEX_TOKEN_EXPIRES: existingConfig.CODEX_TOKEN_EXPIRES, + CODEX_ACCOUNT_ID: existingConfig.CODEX_ACCOUNT_ID, }; fs.writeFileSync(userConfigPath, JSON.stringify(mergedConfig)); return NextResponse.json(mergedConfig); diff --git a/servers/nextjs/components/CodexConfig.tsx b/servers/nextjs/components/CodexConfig.tsx new file mode 100644 index 00000000..0dad054a --- /dev/null +++ b/servers/nextjs/components/CodexConfig.tsx @@ -0,0 +1,429 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import { + Check, + ChevronsUpDown, + Loader2, + LogIn, + LogOut, + RefreshCw, + UserCheck, +} from "lucide-react"; +import { Button } from "./ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "./ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; + +interface CodexConfigProps { + codexModel: string; + onInputChange: (value: string | boolean, field: string) => void; +} + +type AuthStatus = "checking" | "unauthenticated" | "polling" | "authenticated"; + +interface StatusResponse { + status: string; + account_id?: string; + detail?: string; +} + +interface CodexModel { + id: string; + name: string; +} + +const CHATGPT_MODELS: CodexModel[] = [ + { id: "gpt-5.1", name: "GPT-5.1" }, + { id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" }, + { id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" }, + { id: "gpt-5.2", name: "GPT-5.2" }, + { id: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, + { id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark (Free)" }, +]; + +const DEFAULT_CODEX_MODEL = "gpt-5.3-codex-spark"; + +export default function CodexConfig({ + codexModel, + onInputChange, +}: CodexConfigProps) { + const [authStatus, setAuthStatus] = useState+ Waiting for authentication… +
++ Complete the sign-in in the browser tab that just opened. +
++ Didn't get redirected automatically? +
++ After completing the sign-in, paste the full redirect URL or + authorization code below. +
+ setManualCode(e.target.value)} + /> + ++ Signed in to ChatGPT +
+ {accountId && ( ++ Account: {accountId} +
+ )} ++ + Model availability depends on your ChatGPT subscription tier. +
++ Sign in with your OpenAI account to use ChatGPT models directly via + OAuth — no API key required. +
++ + A browser window will open for you to authenticate with your OpenAI + account. Your credentials are stored locally and never shared. +
+