diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index 6b6b504e2..4a3633a4f 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -478,6 +478,16 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ clearInput(params) }, }), + defineCommand({ + name: 'connect:codex', + aliases: ['codex', 'openai'], + handler: (params) => { + // Enter connect:codex mode to show the OAuth banner + useChatStore.getState().setInputMode('connect:codex') + params.saveToHistory(params.inputValue.trim()) + clearInput(params) + }, + }), defineCommand({ name: 'history', aliases: ['chats'], diff --git a/cli/src/commands/router.ts b/cli/src/commands/router.ts index 5587c13af..2193a2c9f 100644 --- a/cli/src/commands/router.ts +++ b/cli/src/commands/router.ts @@ -16,6 +16,7 @@ import { parseCommandInput, } from './router-utils' import { handleClaudeAuthCode } from '../components/claude-connect-banner' +import { handleCodexAuthCode } from '../components/codex-connect-banner' import { getProjectRoot } from '../project-files' import { useChatStore } from '../state/chat-store' import { @@ -356,6 +357,23 @@ export async function routeUserPrompt( return } + // Handle connect:codex mode input (authorization code) + if (inputMode === 'connect:codex') { + const code = trimmed + if (code) { + const result = await handleCodexAuthCode(code) + setMessages((prev) => [ + ...prev, + getUserMessage(trimmed), + getSystemMessage(result.message), + ]) + } + saveToHistory(trimmed) + setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) + setInputMode('default') + return + } + // Handle referral mode input if (inputMode === 'referral') { // Validate the referral code (3-50 alphanumeric chars with optional dashes) diff --git a/cli/src/components/codex-connect-banner.tsx b/cli/src/components/codex-connect-banner.tsx new file mode 100644 index 000000000..397fdec3b --- /dev/null +++ b/cli/src/components/codex-connect-banner.tsx @@ -0,0 +1,187 @@ +import React, { useState, useEffect } from 'react' + +import { BottomBanner } from './bottom-banner' +import { Button } from './button' +import { useChatStore } from '../state/chat-store' +import { + startOAuthFlowWithCallback, + stopCallbackServer, + exchangeCodeForTokens, + disconnectCodexOAuth, + getCodexOAuthStatus, +} from '../utils/codex-oauth' +import { useTheme } from '../hooks/use-theme' + +type FlowState = + | 'checking' + | 'not-connected' + | 'waiting-for-code' + | 'connected' + | 'error' + +export const CodexConnectBanner = () => { + const setInputMode = useChatStore((state) => state.setInputMode) + const theme = useTheme() + const [flowState, setFlowState] = useState('checking') + const [error, setError] = useState(null) + const [manualUrl, setManualUrl] = useState(null) + const [isDisconnectHovered, setIsDisconnectHovered] = useState(false) + const [isConnectHovered, setIsConnectHovered] = useState(false) + + const onOAuthStatusChange = (callbackStatus: 'waiting' | 'success' | 'error', message?: string) => { + if (callbackStatus === 'success') { + setFlowState('connected') + } else if (callbackStatus === 'error') { + setError(message ?? 'Authorization failed') + setFlowState('error') + } else if (callbackStatus === 'waiting' && message) { + setManualUrl(message) + } + } + + const startFlow = () => { + setFlowState('waiting-for-code') + setManualUrl(null) + startOAuthFlowWithCallback(onOAuthStatusChange).catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to start OAuth flow') + setFlowState('error') + }) + } + + // Check initial connection status and auto-open browser if not connected + useEffect(() => { + const status = getCodexOAuthStatus() + if (status.connected) { + setFlowState('connected') + } else { + startFlow() + } + + // Cleanup: stop the callback server when the component unmounts + return () => { + stopCallbackServer() + } + }, []) + + const handleDisconnect = () => { + stopCallbackServer() + disconnectCodexOAuth() + setFlowState('not-connected') + } + + const handleClose = () => { + setInputMode('default') + } + + // Connected state + if (flowState === 'connected') { + const status = getCodexOAuthStatus() + const connectedDate = status.connectedAt + ? new Date(status.connectedAt).toLocaleDateString() + : 'Unknown' + + return ( + + + โœ“ Connected to Codex + + Since {connectedDate} + ยท + + + + + ) + } + + // Error state + if (flowState === 'error') { + return ( + + ) + } + + // Waiting for code state + if (flowState === 'waiting-for-code') { + return ( + + + Waiting for authorization + {manualUrl ? ( + + Could not open browser. Open this URL manually:{' '} + {manualUrl} + + ) : ( + + Sign in with your OpenAI account in the browser. The authorization + will complete automatically. + + )} + + + ) + } + + // Not connected / checking state - show connect button + return ( + + + Connect to Codex + + Use your ChatGPT Plus/Pro subscription + ยท + + + + + ) +} + +/** + * Handle the authorization code input from the user. + * This is called when the user pastes their code in connect:codex mode. + */ +export async function handleCodexAuthCode(code: string): Promise<{ + success: boolean + message: string +}> { + try { + await exchangeCodeForTokens(code) + return { + success: true, + message: + 'Successfully connected your Codex subscription! Codebuff will now use it for OpenAI model requests.', + } + } catch (err) { + return { + success: false, + message: + err instanceof Error + ? err.message + : 'Failed to exchange authorization code', + } + } +} diff --git a/cli/src/components/input-mode-banner.tsx b/cli/src/components/input-mode-banner.tsx index e73b74f8a..02a8c6043 100644 --- a/cli/src/components/input-mode-banner.tsx +++ b/cli/src/components/input-mode-banner.tsx @@ -1,6 +1,7 @@ import React from 'react' import { ClaudeConnectBanner } from './claude-connect-banner' +import { CodexConnectBanner } from './codex-connect-banner' import { HelpBanner } from './help-banner' import { PendingAttachmentsBanner } from './pending-attachments-banner' import { ReferralBanner } from './referral-banner' @@ -26,6 +27,7 @@ const BANNER_REGISTRY: Record< referral: () => , help: () => , 'connect:claude': () => , + 'connect:codex': () => , } /** diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 385ff19ce..6a6381897 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -40,6 +40,12 @@ export const SLASH_COMMANDS: SlashCommand[] = [ description: 'Connect your Claude Pro/Max subscription', aliases: ['claude'], }, + { + id: 'connect:codex', + label: 'connect:codex', + description: 'Connect your ChatGPT Plus/Pro subscription', + aliases: ['codex', 'openai'], + }, { id: 'ads:enable', label: 'ads:enable', diff --git a/cli/src/utils/codex-oauth.ts b/cli/src/utils/codex-oauth.ts new file mode 100644 index 000000000..531dd0e5b --- /dev/null +++ b/cli/src/utils/codex-oauth.ts @@ -0,0 +1,479 @@ +/** + * Codex OAuth PKCE flow implementation for connecting to user's ChatGPT Plus/Pro subscription. + */ + +import crypto from 'crypto' +import http from 'http' +import open from 'open' +import { + CODEX_OAUTH_CLIENT_ID, + CODEX_OAUTH_AUTHORIZE_URL, + CODEX_OAUTH_TOKEN_URL, + CODEX_OAUTH_SCOPES, + CODEX_OAUTH_REDIRECT_URI, +} from '@codebuff/common/constants/codex-oauth' +import { + saveCodexOAuthCredentials, + clearCodexOAuthCredentials, + getCodexOAuthCredentials, + isCodexOAuthValid, + resetCodexOAuthRateLimit, +} from '@codebuff/sdk' + +import type { CodexOAuthCredentials } from '@codebuff/sdk' +import type { Server } from 'http' + +// Port for the local OAuth callback server (derived from redirect URI) +const OAUTH_CALLBACK_PORT = Number(new URL(CODEX_OAUTH_REDIRECT_URI).port) + +/** + * Generate a nicely styled success HTML page for OAuth callback. + */ +function getSuccessPage(): string { + return ` + + + + + Authorization Successful - Codebuff + + + +
+
+ +
+

Authorization Successful

+

Your ChatGPT Plus/Pro subscription has been connected to Codebuff.

+
+

You can close this window and return to the terminal.

+
+
+ +` +} + +/** + * Generate a nicely styled error HTML page for OAuth callback. + */ +function getErrorPage(errorMessage: string): string { + return ` + + + + + Authorization Failed - Codebuff + + + +
+
+ +
+

Authorization Failed

+

${escapeHtml(errorMessage)}

+
+

You can close this window and try again from the terminal.

+
+
+ +` +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +// PKCE code verifier and challenge generation +function generateCodeVerifier(): string { + // Generate 32 random bytes and encode as base64url + const buffer = crypto.randomBytes(32) + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +function generateCodeChallenge(verifier: string): string { + // SHA256 hash of the verifier, encoded as base64url + const hash = crypto.createHash('sha256').update(verifier).digest() + return hash + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +function generateState(): string { + return crypto.randomBytes(16).toString('hex') +} + +// Store the code verifier during the OAuth flow +let pendingCodeVerifier: string | null = null +let callbackServer: Server | null = null + +/** + * Start the OAuth authorization flow. + * Opens the browser to OpenAI's authorization page. + * @returns The code verifier, state, and auth URL + */ +export function startOAuthFlow(): { codeVerifier: string; state: string; authUrl: string } { + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + const state = generateState() + + // Store the code verifier for later use + pendingCodeVerifier = codeVerifier + + // Build the authorization URL + const authUrl = new URL(CODEX_OAUTH_AUTHORIZE_URL) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('client_id', CODEX_OAUTH_CLIENT_ID) + authUrl.searchParams.set('redirect_uri', CODEX_OAUTH_REDIRECT_URI) + authUrl.searchParams.set('scope', CODEX_OAUTH_SCOPES) + authUrl.searchParams.set('code_challenge', codeChallenge) + authUrl.searchParams.set('code_challenge_method', 'S256') + authUrl.searchParams.set('state', state) + // Additional parameters required by OpenAI's Codex OAuth flow + authUrl.searchParams.set('id_token_add_organizations', 'true') + authUrl.searchParams.set('codex_cli_simplified_flow', 'true') + authUrl.searchParams.set('originator', 'codex_cli_rs') + + return { codeVerifier, state, authUrl: authUrl.toString() } +} + +/** + * Stop the callback server if it's running. + */ +export function stopCallbackServer(): void { + if (callbackServer) { + callbackServer.close() + callbackServer = null + } +} + +/** + * Start the OAuth flow with a local callback server. + * This starts a local HTTP server to catch the OAuth redirect, + * opens the browser, and returns a promise that resolves with the credentials. + */ +export function startOAuthFlowWithCallback( + onStatusChange?: (status: 'waiting' | 'success' | 'error', message?: string) => void, +): Promise { + return new Promise((resolve, reject) => { + const { authUrl, codeVerifier, state } = startOAuthFlow() + + // Stop any existing server + stopCallbackServer() + + // Create local server to catch the callback + callbackServer = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://localhost:${OAUTH_CALLBACK_PORT}`) + + if (url.pathname === '/auth/callback') { + const code = url.searchParams.get('code') + const returnedState = url.searchParams.get('state') + const error = url.searchParams.get('error') + const errorDescription = url.searchParams.get('error_description') + + // Handle error response from OAuth provider + if (error) { + const errorMsg = errorDescription || error + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(getErrorPage(errorMsg)) + stopCallbackServer() + onStatusChange?.('error', errorMsg) + reject(new Error(errorMsg)) + return + } + + // Verify state matches + if (returnedState !== state) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(getErrorPage('State mismatch - possible CSRF attack.')) + stopCallbackServer() + onStatusChange?.('error', 'State mismatch') + reject(new Error('State mismatch - possible CSRF attack')) + return + } + + if (code) { + try { + // Exchange the code for tokens + const credentials = await exchangeCodeForTokens(code, codeVerifier) + + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(getSuccessPage()) + stopCallbackServer() + onStatusChange?.('success') + resolve(credentials) + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Token exchange failed' + res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(getErrorPage(errorMsg)) + stopCallbackServer() + onStatusChange?.('error', errorMsg) + reject(err) + } + } else { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(getErrorPage('No authorization code received.')) + stopCallbackServer() + onStatusChange?.('error', 'No authorization code received') + reject(new Error('No authorization code received')) + } + } else { + res.writeHead(404) + res.end('Not found') + } + }) + + callbackServer.on('error', (err) => { + stopCallbackServer() + const nodeErr = err as NodeJS.ErrnoException + if (nodeErr.code === 'EADDRINUSE') { + onStatusChange?.('error', `Port ${OAUTH_CALLBACK_PORT} is already in use`) + reject(new Error(`Port ${OAUTH_CALLBACK_PORT} is already in use. Please close any other OAuth flows.`)) + } else { + onStatusChange?.('error', err.message) + reject(err) + } + }) + + // Bind to loopback only for security - prevents remote access to the callback server + callbackServer.listen(OAUTH_CALLBACK_PORT, '127.0.0.1', async () => { + onStatusChange?.('waiting') + try { + await open(authUrl) + } catch { + // Browser open failed - surface the URL so the user can open it manually + onStatusChange?.('waiting', authUrl) + } + }) + }) +} + +/** + * Exchange an authorization code for access and refresh tokens. + */ +export async function exchangeCodeForTokens( + authorizationCode: string, + codeVerifier?: string, +): Promise { + const verifier = codeVerifier ?? pendingCodeVerifier + if (!verifier) { + throw new Error( + 'No code verifier found. Please start the OAuth flow again.', + ) + } + + // The authorization code might be a full callback URL or just the code + let code: string + const trimmed = authorizationCode.trim() + try { + const parsed = new URL(trimmed) + const extractedCode = parsed.searchParams.get('code') + code = extractedCode ?? trimmed.split('#')[0] + } catch { + // Not a URL - treat as a raw authorization code + code = trimmed.split('#')[0] + } + + // Exchange the code for tokens + // IMPORTANT: Use application/x-www-form-urlencoded, NOT application/json + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: CODEX_OAUTH_CLIENT_ID, + code: code, + code_verifier: verifier, + redirect_uri: CODEX_OAUTH_REDIRECT_URI, + }) + + const response = await fetch(CODEX_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Failed to exchange code for tokens: ${errorText}`) + } + + const data = await response.json() + + // Clear the pending code verifier + pendingCodeVerifier = null + + const credentials: CodexOAuthCredentials = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + data.expires_in * 1000, + connectedAt: Date.now(), + } + + // Save credentials to file + saveCodexOAuthCredentials(credentials) + + // Reset any cached rate limit since user just reconnected + resetCodexOAuthRateLimit() + + return credentials +} + +/** + * Disconnect from Codex OAuth (clear credentials). + */ +export function disconnectCodexOAuth(): void { + clearCodexOAuthCredentials() +} + +/** + * Get the current Codex OAuth connection status. + */ +export function getCodexOAuthStatus(): { + connected: boolean + expiresAt?: number + connectedAt?: number +} { + if (!isCodexOAuthValid()) { + return { connected: false } + } + + const credentials = getCodexOAuthCredentials() + if (!credentials) { + return { connected: false } + } + + return { + connected: true, + expiresAt: credentials.expiresAt, + connectedAt: credentials.connectedAt, + } +} diff --git a/cli/src/utils/input-modes.ts b/cli/src/utils/input-modes.ts index be2196223..d3f255b7c 100644 --- a/cli/src/utils/input-modes.ts +++ b/cli/src/utils/input-modes.ts @@ -12,6 +12,7 @@ export type InputMode = | 'image' | 'help' | 'connect:claude' + | 'connect:codex' | 'outOfCredits' // Theme color keys that are valid color values (must match ChatTheme keys) @@ -106,6 +107,14 @@ export const INPUT_MODE_CONFIGS: Record = { showAgentModeToggle: false, disableSlashSuggestions: true, }, + 'connect:codex': { + icon: '๐Ÿ”—', + color: 'info', + placeholder: 'waiting for browser authorization...', + widthAdjustment: 3, // emoji width + padding + showAgentModeToggle: false, + disableSlashSuggestions: true, + }, outOfCredits: { icon: null, color: 'warning', diff --git a/common/src/constants/analytics-events.ts b/common/src/constants/analytics-events.ts index e620fdb72..7a7717f39 100644 --- a/common/src/constants/analytics-events.ts +++ b/common/src/constants/analytics-events.ts @@ -127,6 +127,11 @@ export enum AnalyticsEvent { CLAUDE_OAUTH_RATE_LIMITED = 'sdk.claude_oauth_rate_limited', CLAUDE_OAUTH_AUTH_ERROR = 'sdk.claude_oauth_auth_error', + // Codex OAuth + CODEX_OAUTH_REQUEST = 'sdk.codex_oauth_request', + CODEX_OAUTH_RATE_LIMITED = 'sdk.codex_oauth_rate_limited', + CODEX_OAUTH_AUTH_ERROR = 'sdk.codex_oauth_auth_error', + // Common FLUSH_FAILED = 'common.flush_failed', diff --git a/common/src/constants/codex-oauth.ts b/common/src/constants/codex-oauth.ts new file mode 100644 index 000000000..84b61a347 --- /dev/null +++ b/common/src/constants/codex-oauth.ts @@ -0,0 +1,111 @@ +/** + * Codex OAuth constants for connecting to user's ChatGPT Plus/Pro subscription. + * These are used by the CLI for the OAuth PKCE flow and by the SDK for direct OpenAI API calls. + */ + +// OAuth client ID used by Codex CLI (same as official OpenAI Codex CLI) +// This is the public client ID for the Codex CLI OAuth flow +export const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' + +// OpenAI OAuth endpoints +export const CODEX_OAUTH_AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize' +export const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token' + +// ChatGPT backend API endpoint for Codex requests +export const CHATGPT_BACKEND_API_URL = 'https://chatgpt.com/backend-api' + +// Environment variable for OAuth token override +export const CODEX_OAUTH_TOKEN_ENV_VAR = 'CODEBUFF_CODEX_OAUTH_TOKEN' + +// OAuth scopes needed for ChatGPT Plus/Pro access +export const CODEX_OAUTH_SCOPES = 'openid profile email offline_access' + +// Redirect URI for OAuth callback (local server) +export const CODEX_OAUTH_REDIRECT_URI = 'http://localhost:1455/auth/callback' + +/** + * Models that can use Codex OAuth (ChatGPT Plus/Pro subscription). + * These models are supported via the ChatGPT backend API. + */ +export const CODEX_OAUTH_MODELS = [ + 'gpt-5.2-codex', + 'gpt-5.2', + 'gpt-5.1-codex', + 'gpt-5.1-codex-max', + 'gpt-5.1-codex-mini', + 'gpt-5.1', +] as const + +/** + * Model ID mapping from various formats to normalized Codex API model names. + * Based on the opencode implementation's model map. + * Maps OpenRouter-style IDs and legacy names to the normalized API model names. + */ +export const CODEX_MODEL_MAP: Record = { + // OpenRouter format + 'openai/gpt-5.2-codex': 'gpt-5.2-codex', + 'openai/gpt-5.2': 'gpt-5.2', + 'openai/gpt-5.1-codex': 'gpt-5.1-codex', + 'openai/gpt-5.1-codex-max': 'gpt-5.1-codex-max', + 'openai/gpt-5.1-codex-mini': 'gpt-5.1-codex-mini', + 'openai/gpt-5.1': 'gpt-5.1', + // Direct model names + 'gpt-5.2-codex': 'gpt-5.2-codex', + 'gpt-5.2': 'gpt-5.2', + 'gpt-5.1-codex': 'gpt-5.1-codex', + 'gpt-5.1-codex-max': 'gpt-5.1-codex-max', + 'gpt-5.1-codex-mini': 'gpt-5.1-codex-mini', + 'gpt-5.1': 'gpt-5.1', + // Legacy mappings (gpt-5 -> gpt-5.1) + 'gpt-5-codex': 'gpt-5.1-codex', + 'gpt-5': 'gpt-5.1', + 'openai/gpt-5-codex': 'gpt-5.1-codex', + 'openai/gpt-5': 'gpt-5.1', +} + +/** + * Check if a model is an OpenAI model that can use Codex OAuth. + * Only returns true for models explicitly in the CODEX_MODEL_MAP. + * This prevents unknown openai/* models from accidentally routing through Codex OAuth. + */ +export function isOpenAIModel(model: string): boolean { + // Only return true for models explicitly in the model map + // This is an allowlist approach - unknown models fall back to Codebuff backend + if (CODEX_MODEL_MAP[model]) { + return true + } + // Check without provider prefix + if (model.includes('/')) { + const modelId = model.split('/').pop()! + if (CODEX_MODEL_MAP[modelId]) { + return true + } + } + return false +} + +/** + * Normalize a model ID to the Codex API format. + * Uses the model map for known models. Returns null for unknown models + * to signal that the model cannot be used with Codex OAuth. + */ +export function toCodexModelId(model: string): string | null { + // Check the mapping table first + const mapped = CODEX_MODEL_MAP[model] + if (mapped) { + return mapped + } + + // Strip provider prefix if present and check again + if (model.includes('/')) { + const modelId = model.split('/').pop()! + const mappedWithoutPrefix = CODEX_MODEL_MAP[modelId] + if (mappedWithoutPrefix) { + return mappedWithoutPrefix + } + } + + // Unknown model - return null to signal fallback to Codebuff backend + return null +} + diff --git a/scripts/test-codex-api.ts b/scripts/test-codex-api.ts new file mode 100644 index 000000000..a7407943d --- /dev/null +++ b/scripts/test-codex-api.ts @@ -0,0 +1,246 @@ +#!/usr/bin/env bun +/** + * Test script for Codex OAuth API requests + * + * This script reads your stored Codex OAuth credentials and makes a test request + * to the ChatGPT backend API to verify the OAuth flow is working correctly. + * + * Usage: bun scripts/test-codex-api.ts [model] + * + * Examples: + * bun scripts/test-codex-api.ts # Uses gpt-5.1 by default + * bun scripts/test-codex-api.ts gpt-5.2 # Uses gpt-5.2 + */ + +import fs from 'fs' +import path from 'path' +import os from 'os' + +// Constants from the codebase +const CHATGPT_BACKEND_API_URL = 'https://chatgpt.com/backend-api' + +// Get model from command line args or default to gpt-5.1 +const model = process.argv[2] || 'gpt-5.1' + +console.log('๐Ÿ” Codex OAuth API Test Script') +console.log('==============================') +console.log(`Model: ${model}`) +console.log('') + +// Read credentials from the credentials file +function getCredentialsPath(): string { + const env = process.env.NEXT_PUBLIC_CB_ENVIRONMENT + const envSuffix = env && env !== 'prod' ? `-${env}` : '' + return path.join(os.homedir(), '.config', `manicode${envSuffix}`, 'credentials.json') +} + +function getCodexOAuthCredentials(): { accessToken: string; refreshToken: string; expiresAt: number } | null { + const credentialsPath = getCredentialsPath() + console.log(`๐Ÿ“ Credentials path: ${credentialsPath}`) + + if (!fs.existsSync(credentialsPath)) { + console.error('โŒ Credentials file not found') + return null + } + + try { + const content = JSON.parse(fs.readFileSync(credentialsPath, 'utf8')) + if (!content.codexOAuth) { + console.error('โŒ No Codex OAuth credentials found in credentials file') + console.log(' Run /connect:codex in the CLI to authenticate first') + return null + } + return content.codexOAuth + } catch (error) { + console.error('โŒ Error reading credentials:', error) + return null + } +} + +/** + * Extract the ChatGPT account ID from the JWT access token. + * The token contains a claim at "https://api.openai.com/auth" with the account ID. + */ +function extractAccountIdFromToken(accessToken: string): string | null { + try { + // JWT format: header.payload.signature + const parts = accessToken.split('.') + if (parts.length !== 3) { + console.error('โŒ Invalid JWT format') + return null + } + + // Decode the payload (base64url) + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) + console.log('๐Ÿ“‹ JWT payload claims:', Object.keys(payload)) + + // The account ID is in the custom claim at "https://api.openai.com/auth" + // IMPORTANT: Use chatgpt_account_id, NOT user_id + const authClaim = payload['https://api.openai.com/auth'] + if (authClaim?.chatgpt_account_id) { + console.log('๐Ÿ“‹ Found chatgpt_account_id:', authClaim.chatgpt_account_id) + return authClaim.chatgpt_account_id + } + + console.log('๐Ÿ“‹ Full auth claim:', JSON.stringify(authClaim, null, 2)) + return null + } catch (error) { + console.error('โŒ Error decoding JWT:', error) + return null + } +} + +async function makeCodexRequest(accessToken: string, accountId: string) { + const url = `${CHATGPT_BACKEND_API_URL}/codex/responses` + + // Request body matching opencode's format + const requestBody = { + model: model, + input: [ + { + type: 'message', + role: 'user', + content: [ + { + type: 'input_text', + text: 'Say "Hello from Codex!" and nothing else.', + }, + ], + }, + ], + instructions: 'You are a helpful assistant.', + // IMPORTANT: These are required by the ChatGPT backend + store: false, // ChatGPT backend REQUIRES store=false + stream: true, // Always stream + // Reasoning configuration + reasoning: { + effort: 'medium', + summary: 'auto', + }, + // Text verbosity + text: { + verbosity: 'medium', + }, + // Include encrypted reasoning content for stateless operation + include: ['reasoning.encrypted_content'], + } + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + 'chatgpt-account-id': accountId, + 'OpenAI-Beta': 'responses=experimental', + originator: 'codex_cli_rs', + accept: 'text/event-stream', + } + + console.log('๐Ÿ“ค Request URL:', url) + console.log('๐Ÿ“ค Request headers:', JSON.stringify(headers, null, 2)) + console.log('๐Ÿ“ค Request body:', JSON.stringify(requestBody, null, 2)) + console.log('') + + try { + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + }) + + console.log('๐Ÿ“ฅ Response status:', response.status, response.statusText) + console.log('๐Ÿ“ฅ Response headers:') + response.headers.forEach((value, key) => { + console.log(` ${key}: ${value}`) + }) + console.log('') + + if (!response.ok) { + const errorText = await response.text() + console.error('โŒ Error response body:', errorText) + return + } + + // Handle streaming response + console.log('๐Ÿ“ฅ Streaming response:') + console.log('---') + + const reader = response.body?.getReader() + if (!reader) { + console.error('โŒ No response body') + return + } + + const decoder = new TextDecoder() + let fullResponse = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value, { stream: true }) + fullResponse += chunk + + // Parse SSE events + const lines = chunk.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6) + if (data === '[DONE]') { + console.log('\n[DONE]') + } else { + try { + const parsed = JSON.parse(data) + // Extract text content from the response + if (parsed.type === 'response.output_text.delta') { + process.stdout.write(parsed.delta || '') + } else if (parsed.type === 'response.completed') { + console.log('\n\nโœ… Response completed!') + } else { + // Log other event types for debugging + console.log(`[${parsed.type}]`) + } + } catch { + // Non-JSON data, just log it + if (data.trim()) { + console.log(`[raw]: ${data}`) + } + } + } + } + } + } + + console.log('---') + console.log('') + console.log('โœ… Request completed successfully!') + } catch (error) { + console.error('โŒ Request failed:', error) + } +} + +async function main() { + // Get credentials + const credentials = getCodexOAuthCredentials() + if (!credentials) { + process.exit(1) + } + + console.log('โœ… Found Codex OAuth credentials') + console.log(` Expires at: ${new Date(credentials.expiresAt).toISOString()}`) + console.log(` Is expired: ${credentials.expiresAt < Date.now()}`) + console.log('') + + // Extract account ID from token + const accountId = extractAccountIdFromToken(credentials.accessToken) + if (!accountId) { + console.error('โŒ Could not extract account ID from access token') + process.exit(1) + } + + console.log(`โœ… Extracted account ID: ${accountId}`) + console.log('') + + // Make the test request + await makeCodexRequest(credentials.accessToken, accountId) +} + +main().catch(console.error) diff --git a/scripts/test-codex-messages.ts b/scripts/test-codex-messages.ts new file mode 100644 index 000000000..142bea716 --- /dev/null +++ b/scripts/test-codex-messages.ts @@ -0,0 +1,291 @@ +/** + * Test script to verify Codex API message format conversions. + * Tests various message types: user, assistant, multi-turn conversations. + * + * Usage: bun scripts/test-codex-messages.ts [test-name] + * + * Tests: + * simple - Single user message (default) + * multi - Multi-turn conversation with assistant messages + * system - System message as instructions + * all - Run all tests + */ + +import fs from 'fs' +import os from 'os' +import path from 'path' + +// Read credentials +const credentialsPath = path.join(os.homedir(), '.config/manicode-dev/credentials.json') +const credentials = JSON.parse(fs.readFileSync(credentialsPath, 'utf8')) +const accessToken = credentials.codexOAuth?.accessToken + +if (!accessToken) { + console.error('โŒ No Codex OAuth credentials found') + process.exit(1) +} + +// Extract account ID from JWT +const parts = accessToken.split('.') +const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) +const accountId = payload['https://api.openai.com/auth']?.chatgpt_account_id + +if (!accountId) { + console.error('โŒ Could not extract account ID from token') + process.exit(1) +} + +console.log('โœ… Credentials loaded') +console.log(` Account ID: ${accountId}\n`) + +// Test configurations +interface TestCase { + name: string + description: string + body: Record +} + +const tests: TestCase[] = [ + { + name: 'simple', + description: 'Single user message', + body: { + model: 'gpt-5.2', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Say "Test 1 passed!" and nothing else.' }], + }, + ], + instructions: 'You are a helpful assistant.', + store: false, + stream: true, + reasoning: { effort: 'medium', summary: 'auto' }, + text: { verbosity: 'medium' }, + include: ['reasoning.encrypted_content'], + }, + }, + { + name: 'multi', + description: 'Multi-turn conversation with assistant message (output_text)', + body: { + model: 'gpt-5.2', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Remember the number 42.' }], + }, + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'I will remember the number 42.' }], + }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'What number did I tell you? Say just the number.' }], + }, + ], + instructions: 'You are a helpful assistant.', + store: false, + stream: true, + reasoning: { effort: 'medium', summary: 'auto' }, + text: { verbosity: 'medium' }, + include: ['reasoning.encrypted_content'], + }, + }, + { + name: 'multi-wrong', + description: 'Multi-turn with WRONG format (input_text for assistant - should fail)', + body: { + model: 'gpt-5.2', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Remember the number 42.' }], + }, + { + type: 'message', + role: 'assistant', + // WRONG: using input_text instead of output_text + content: [{ type: 'input_text', text: 'I will remember the number 42.' }], + }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'What number did I tell you?' }], + }, + ], + instructions: 'You are a helpful assistant.', + store: false, + stream: true, + reasoning: { effort: 'medium', summary: 'auto' }, + text: { verbosity: 'medium' }, + include: ['reasoning.encrypted_content'], + }, + }, + { + name: 'system', + description: 'System message as developer role', + body: { + model: 'gpt-5.2', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'What is your name?' }], + }, + ], + instructions: 'Your name is CodexBot. Always introduce yourself by name.', + store: false, + stream: true, + reasoning: { effort: 'medium', summary: 'auto' }, + text: { verbosity: 'medium' }, + include: ['reasoning.encrypted_content'], + }, + }, + { + name: 'tool-call', + description: 'Tool call and result (function_call + function_call_output)', + body: { + model: 'gpt-5.2', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'What is 2+2? Use the calculator tool.' }], + }, + { + type: 'function_call', + call_id: 'call_123', + name: 'calculator', + arguments: '{"expression": "2+2"}', + }, + { + type: 'function_call_output', + call_id: 'call_123', + output: '4', + }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'What was the result? Just say the number.' }], + }, + ], + instructions: 'You are a helpful assistant with access to a calculator tool.', + store: false, + stream: true, + reasoning: { effort: 'medium', summary: 'auto' }, + text: { verbosity: 'medium' }, + include: ['reasoning.encrypted_content'], + }, + }, +] + +async function runTest(test: TestCase): Promise<{ success: boolean; output: string; error?: string }> { + console.log(`\n๐Ÿ“‹ Test: ${test.name}`) + console.log(` ${test.description}`) + + try { + const response = await fetch('https://chatgpt.com/backend-api/codex/responses', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + 'chatgpt-account-id': accountId, + 'OpenAI-Beta': 'responses=experimental', + 'originator': 'codex_cli_rs', + 'accept': 'text/event-stream', + }, + body: JSON.stringify(test.body), + }) + + if (!response.ok) { + const errorBody = await response.text() + console.log(` โŒ HTTP ${response.status}: ${errorBody}`) + return { success: false, output: '', error: errorBody } + } + + // Read and parse streaming response + const reader = response.body!.getReader() + const decoder = new TextDecoder() + let output = '' + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || !trimmed.startsWith('data: ')) continue + + const data = trimmed.slice(6) + if (data === '[DONE]') continue + + try { + const event = JSON.parse(data) + if (event.type === 'response.output_text.delta' && event.delta) { + output += event.delta + process.stdout.write(event.delta) + } + } catch { + // Skip unparseable + } + } + } + + console.log(`\n โœ… Success: "${output.trim()}"`) + return { success: true, output: output.trim() } + } catch (error) { + const err = error as Error + console.log(` โŒ Error: ${err.message}`) + return { success: false, output: '', error: err.message } + } +} + +// Main +const testName = process.argv[2] || 'simple' + +console.log('๐Ÿงช Codex API Message Format Tests\n') +console.log('=' .repeat(50)) + +if (testName === 'all') { + let passed = 0 + let failed = 0 + + for (const test of tests) { + const result = await runTest(test) + if (test.name === 'multi-wrong') { + // This test SHOULD fail + if (!result.success) { + console.log(' โœ… (Expected failure - confirms input_text is invalid for assistant)') + passed++ + } else { + console.log(' โŒ (Unexpectedly succeeded - API may have changed)') + failed++ + } + } else { + if (result.success) passed++ + else failed++ + } + } + + console.log('\n' + '='.repeat(50)) + console.log(`\n๐Ÿ“Š Results: ${passed} passed, ${failed} failed`) +} else { + const test = tests.find(t => t.name === testName) + if (!test) { + console.error(`Unknown test: ${testName}`) + console.log('Available tests:', tests.map(t => t.name).join(', ')) + process.exit(1) + } + await runTest(test) +} diff --git a/scripts/test-codex-oauth.ts b/scripts/test-codex-oauth.ts new file mode 100644 index 000000000..c5b391fe0 --- /dev/null +++ b/scripts/test-codex-oauth.ts @@ -0,0 +1,258 @@ +/** + * Test script for Codex OAuth protocol. + * + * This script tests the OAuth flow for connecting to ChatGPT Plus/Pro subscriptions. + * Based on the opencode implementation: https://github.com/numman-ali/opencode-openai-codex-auth + * + * Usage: + * bun scripts/test-codex-oauth.ts + * bun scripts/test-codex-oauth.ts --exchange # Exchange an auth code for tokens + */ + +import crypto from 'crypto' +import http from 'http' + +// Correct OAuth constants (from opencode/codex CLI) +const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' +const AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize' +const TOKEN_URL = 'https://auth.openai.com/oauth/token' +const REDIRECT_URI = 'http://localhost:1455/auth/callback' +const SCOPE = 'openid profile email offline_access' + +// PKCE helpers +function generateCodeVerifier(): string { + const buffer = crypto.randomBytes(32) + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +function generateCodeChallenge(verifier: string): string { + const hash = crypto.createHash('sha256').update(verifier).digest() + return hash + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +function generateState(): string { + return crypto.randomBytes(16).toString('hex') +} + +// Build the authorization URL +function buildAuthUrl(codeChallenge: string, state: string): string { + 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', codeChallenge) + url.searchParams.set('code_challenge_method', 'S256') + url.searchParams.set('state', state) + // Additional parameters from opencode implementation + url.searchParams.set('id_token_add_organizations', 'true') + url.searchParams.set('codex_cli_simplified_flow', 'true') + url.searchParams.set('originator', 'codex_cli_rs') + return url.toString() +} + +// Exchange authorization code for tokens +async function exchangeCodeForTokens(code: string, codeVerifier: string): Promise { + console.log('\n๐Ÿ“ค Exchanging authorization code for tokens...') + console.log('Code:', code.substring(0, 20) + '...') + console.log('Verifier:', codeVerifier.substring(0, 20) + '...') + + // IMPORTANT: Use application/x-www-form-urlencoded, NOT application/json + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: CLIENT_ID, + code: code, + code_verifier: codeVerifier, + redirect_uri: REDIRECT_URI, + }) + + console.log('\nRequest details:') + console.log('URL:', TOKEN_URL) + console.log('Content-Type: application/x-www-form-urlencoded') + console.log('Body:', body.toString()) + + try { + const response = await fetch(TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }) + + const responseText = await response.text() + + if (!response.ok) { + console.error('\nโŒ Token exchange failed!') + console.error('Status:', response.status, response.statusText) + console.error('Response:', responseText) + return + } + + const data = JSON.parse(responseText) + console.log('\nโœ… Token exchange successful!') + console.log('Access Token:', data.access_token?.substring(0, 30) + '...') + console.log('Refresh Token:', data.refresh_token?.substring(0, 30) + '...') + console.log('Expires In:', data.expires_in, 'seconds') + console.log('Token Type:', data.token_type) + } catch (error) { + console.error('\nโŒ Error during token exchange:', error) + } +} + +// Parse authorization response from URL +function parseAuthResponse(url: string): { code?: string; state?: string } { + try { + const parsed = new URL(url) + return { + code: parsed.searchParams.get('code') ?? undefined, + state: parsed.searchParams.get('state') ?? undefined, + } + } catch { + // Maybe it's just the code or code#state format + if (url.includes('#')) { + const [code, state] = url.split('#', 2) + return { code, state } + } + return { code: url } + } +} + +// Main flow +async function main() { + const args = process.argv.slice(2) + + // If --exchange flag is provided, exchange the code + if (args[0] === '--exchange' && args[1]) { + const input = args[1] + const verifier = args[2] // Optional: provide verifier if you have it + + const { code } = parseAuthResponse(input) + if (!code) { + console.error('โŒ No authorization code found in input') + process.exit(1) + } + + if (!verifier) { + console.error('โŒ Code verifier is required. Run the script without --exchange first to get one.') + process.exit(1) + } + + await exchangeCodeForTokens(code, verifier) + return + } + + console.log('๐Ÿ” Codex OAuth Test Script') + console.log('=' .repeat(50)) + + // Generate PKCE values + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + const state = generateState() + + console.log('\n๐Ÿ“‹ PKCE Values:') + console.log('Code Verifier:', codeVerifier) + console.log('Code Challenge:', codeChallenge) + console.log('State:', state) + + // Build authorization URL + const authUrl = buildAuthUrl(codeChallenge, state) + + console.log('\n๐ŸŒ Authorization URL:') + console.log(authUrl) + + console.log('\n๐Ÿ“ Instructions:') + console.log('1. Open the URL above in your browser') + console.log('2. Sign in with your OpenAI account') + console.log('3. After authorization, you will be redirected to localhost:1455') + console.log('4. Copy the "code" parameter from the redirect URL') + console.log('5. Run: bun scripts/test-codex-oauth.ts --exchange ' + codeVerifier) + + // Start a local server to catch the redirect + console.log('\n๐Ÿ–ฅ๏ธ Starting local server on http://localhost:1455 to catch the redirect...') + console.log(' (Press Ctrl+C to stop)\n') + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://localhost:1455`) + + if (url.pathname === '/auth/callback') { + const code = url.searchParams.get('code') + const returnedState = url.searchParams.get('state') + + console.log('\n๐Ÿ“ฅ Received callback!') + console.log('Code:', code?.substring(0, 30) + '...') + console.log('State:', returnedState) + + if (returnedState !== state) { + console.error('โš ๏ธ State mismatch! Expected:', state, 'Got:', returnedState) + } + + if (code) { + // Exchange the code for tokens + await exchangeCodeForTokens(code, codeVerifier) + + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(` + + +

โœ… Authorization Successful!

+

You can close this window and return to the terminal.

+ + + `) + + // Close server after a short delay + setTimeout(() => { + server.close() + process.exit(0) + }, 1000) + } else { + res.writeHead(400, { 'Content-Type': 'text/html' }) + res.end(` + + +

โŒ Authorization Failed

+

No authorization code received.

+ + + `) + } + } else { + res.writeHead(404) + res.end('Not found') + } + }) + + server.listen(1455, () => { + console.log('Server listening on http://localhost:1455') + console.log('Waiting for OAuth callback...\n') + + // Try to open the URL in the browser + import('open').then(({ default: open }) => { + open(authUrl).catch(() => { + console.log('Could not auto-open browser. Please open the URL manually.') + }) + }).catch(() => { + console.log('Could not auto-open browser. Please open the URL manually.') + }) + }) + + server.on('error', (err) => { + if ((err as NodeJS.ErrnoException).code === 'EADDRINUSE') { + console.error('โŒ Port 1455 is already in use. Please close any other instances.') + } else { + console.error('โŒ Server error:', err) + } + process.exit(1) + }) +} + +main().catch(console.error) diff --git a/sdk/src/__tests__/codex-message-transform.test.ts b/sdk/src/__tests__/codex-message-transform.test.ts new file mode 100644 index 000000000..7c223177a --- /dev/null +++ b/sdk/src/__tests__/codex-message-transform.test.ts @@ -0,0 +1,971 @@ +import { describe, expect, test } from 'bun:test' + +import { + transformMessagesToCodexInput, + transformCodexEventToOpenAI, + extractAccountIdFromToken, + type ChatMessage, + type ToolCallState, + type ImageUrlContentPart, +} from '../impl/codex-transform' + +/** + * Unit tests for Codex OAuth message transformation functions. + * + * These functions convert between: + * - AI SDK OpenAI chat format (messages array with role/content) + * - Codex API format (input array with type/role/content and function_call items) + * + * Key differences: + * - User messages use content type 'input_text' + * - Assistant messages use content type 'output_text' + * - Tool calls become 'function_call' items (not messages) + * - Tool results become 'function_call_output' items (not role: 'tool' messages) + * - System messages become 'developer' role (but usually go in 'instructions' field) + */ + +// ============================================================================ +// transformMessagesToCodexInput Tests +// ============================================================================ + +describe('transformMessagesToCodexInput', () => { + describe('basic message conversion', () => { + test('converts simple user message with string content', () => { + const messages: ChatMessage[] = [ + { role: 'user', content: 'Hello, world!' }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toEqual([ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Hello, world!' }], + }, + ]) + }) + + test('converts simple assistant message with string content', () => { + const messages: ChatMessage[] = [ + { role: 'assistant', content: 'Hello! How can I help you?' }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toEqual([ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Hello! How can I help you?' }], + }, + ]) + }) + + test('converts system message to developer role', () => { + const messages: ChatMessage[] = [ + { role: 'system', content: 'You are a helpful assistant.' }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toEqual([ + { + type: 'message', + role: 'developer', + content: [{ type: 'input_text', text: 'You are a helpful assistant.' }], + }, + ]) + }) + }) + + describe('content type handling', () => { + test('user messages use input_text content type', () => { + const messages: ChatMessage[] = [ + { role: 'user', content: 'Test message' }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result[0]).toMatchObject({ + content: [{ type: 'input_text' }], + }) + }) + + test('assistant messages use output_text content type', () => { + const messages: ChatMessage[] = [ + { role: 'assistant', content: 'Test response' }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result[0]).toMatchObject({ + content: [{ type: 'output_text' }], + }) + }) + + test('developer (system) messages use input_text content type', () => { + const messages: ChatMessage[] = [ + { role: 'system', content: 'System prompt' }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result[0]).toMatchObject({ + content: [{ type: 'input_text' }], + }) + }) + }) + + describe('array content handling', () => { + test('converts array content with text parts for user', () => { + const messages: ChatMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'Part 1' }, + { type: 'text', text: 'Part 2' }, + ], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toEqual([ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'Part 1' }, + { type: 'input_text', text: 'Part 2' }, + ], + }, + ]) + }) + + test('converts array content with text parts for assistant', () => { + const messages: ChatMessage[] = [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Response text' }], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toEqual([ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Response text' }], + }, + ]) + }) + + test('transforms AI SDK image_url format to Codex input_image format', () => { + const messages: ChatMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'Look at this image:' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,ABC123==' } } as ImageUrlContentPart, + ], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result[0]).toMatchObject({ + content: [ + { type: 'input_text', text: 'Look at this image:' }, + { type: 'input_image', image_url: 'data:image/png;base64,ABC123==' }, + ], + }) + }) + + test('transforms image URL (non-base64) to Codex format', () => { + const messages: ChatMessage[] = [ + { + role: 'user', + content: [ + { type: 'image_url', image_url: { url: 'https://example.com/image.png' } } as ImageUrlContentPart, + ], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result[0]).toMatchObject({ + content: [ + { type: 'input_image', image_url: 'https://example.com/image.png' }, + ], + }) + }) + + test('handles message with only image (no text)', () => { + const messages: ChatMessage[] = [ + { + role: 'user', + content: [ + { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ==' } } as ImageUrlContentPart, + ], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + type: 'message', + role: 'user', + content: [ + { type: 'input_image', image_url: 'data:image/jpeg;base64,/9j/4AAQ==' }, + ], + }) + }) + + test('handles multiple images in one message', () => { + const messages: ChatMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'Compare these images:' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,IMAGE1==' } } as ImageUrlContentPart, + { type: 'image_url', image_url: { url: 'data:image/png;base64,IMAGE2==' } } as ImageUrlContentPart, + ], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result[0]).toMatchObject({ + content: [ + { type: 'input_text', text: 'Compare these images:' }, + { type: 'input_image', image_url: 'data:image/png;base64,IMAGE1==' }, + { type: 'input_image', image_url: 'data:image/png;base64,IMAGE2==' }, + ], + }) + }) + }) + + describe('image handling', () => { + test('transforms base64 image to Codex input_image format', () => { + const messages: ChatMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'What is in this image?' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' } } as ImageUrlContentPart, + ], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'What is in this image?' }, + { type: 'input_image', image_url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' }, + ], + }) + }) + + test('transforms HTTPS image URL to Codex format', () => { + const messages: ChatMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'Describe this:' }, + { type: 'image_url', image_url: { url: 'https://cdn.example.com/photo.jpg' } } as ImageUrlContentPart, + ], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result[0]).toMatchObject({ + content: [ + { type: 'input_text', text: 'Describe this:' }, + { type: 'input_image', image_url: 'https://cdn.example.com/photo.jpg' }, + ], + }) + }) + + test('handles image-only message without text', () => { + const messages: ChatMessage[] = [ + { + role: 'user', + content: [ + { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,/9j/4AAQSkZJRg==' } } as ImageUrlContentPart, + ], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + type: 'message', + role: 'user', + content: [ + { type: 'input_image', image_url: 'data:image/jpeg;base64,/9j/4AAQSkZJRg==' }, + ], + }) + }) + + test('handles multiple images in conversation', () => { + const messages: ChatMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'First image:' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,FIRST==' } } as ImageUrlContentPart, + ], + }, + { + role: 'assistant', + content: 'I see a cat in the first image.', + }, + { + role: 'user', + content: [ + { type: 'text', text: 'Second image:' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,SECOND==' } } as ImageUrlContentPart, + ], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toHaveLength(3) + expect(result[0]).toMatchObject({ + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'First image:' }, + { type: 'input_image', image_url: 'data:image/png;base64,FIRST==' }, + ], + }) + expect(result[1]).toMatchObject({ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'I see a cat in the first image.' }], + }) + expect(result[2]).toMatchObject({ + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'Second image:' }, + { type: 'input_image', image_url: 'data:image/png;base64,SECOND==' }, + ], + }) + }) + + test('handles mixed content types (text, image, text)', () => { + const messages: ChatMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'Here is an image:' }, + { type: 'image_url', image_url: { url: 'data:image/webp;base64,UklGR==' } } as ImageUrlContentPart, + { type: 'text', text: 'What do you see?' }, + ], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result[0]).toMatchObject({ + content: [ + { type: 'input_text', text: 'Here is an image:' }, + { type: 'input_image', image_url: 'data:image/webp;base64,UklGR==' }, + { type: 'input_text', text: 'What do you see?' }, + ], + }) + }) + }) + + describe('tool call handling', () => { + test('converts assistant message with tool calls to function_call items', () => { + const messages: ChatMessage[] = [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"location": "San Francisco"}', + }, + }, + ], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toEqual([ + { + type: 'function_call', + call_id: 'call_123', + name: 'get_weather', + arguments: '{"location": "San Francisco"}', + }, + ]) + }) + + test('includes assistant text content before tool calls', () => { + const messages: ChatMessage[] = [ + { + role: 'assistant', + content: 'Let me check the weather for you.', + tool_calls: [ + { + id: 'call_456', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"location": "NYC"}', + }, + }, + ], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Let me check the weather for you.' }], + }) + expect(result[1]).toEqual({ + type: 'function_call', + call_id: 'call_456', + name: 'get_weather', + arguments: '{"location": "NYC"}', + }) + }) + + test('handles multiple tool calls in one message', () => { + const messages: ChatMessage[] = [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"location": "SF"}', + }, + }, + { + id: 'call_2', + type: 'function', + function: { + name: 'get_time', + arguments: '{"timezone": "PST"}', + }, + }, + ], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ type: 'function_call', call_id: 'call_1', name: 'get_weather' }) + expect(result[1]).toMatchObject({ type: 'function_call', call_id: 'call_2', name: 'get_time' }) + }) + }) + + describe('tool result handling', () => { + test('converts tool result message to function_call_output', () => { + const messages: ChatMessage[] = [ + { + role: 'tool', + tool_call_id: 'call_123', + content: '{"temperature": 72, "conditions": "sunny"}', + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toEqual([ + { + type: 'function_call_output', + call_id: 'call_123', + output: '{"temperature": 72, "conditions": "sunny"}', + }, + ]) + }) + + test('handles tool result with array content by stringifying', () => { + const messages: ChatMessage[] = [ + { + role: 'tool', + tool_call_id: 'call_789', + content: [{ type: 'text', text: 'Result text' }], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toEqual([ + { + type: 'function_call_output', + call_id: 'call_789', + output: JSON.stringify([{ type: 'text', text: 'Result text' }]), + }, + ]) + }) + + test('uses "unknown" for missing tool_call_id', () => { + const messages: ChatMessage[] = [ + { + role: 'tool', + content: 'Some result', + }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result[0]).toMatchObject({ + type: 'function_call_output', + call_id: 'unknown', + }) + }) + }) + + describe('multi-turn conversation', () => { + test('handles complete conversation with user, assistant, and tool calls', () => { + const messages: ChatMessage[] = [ + { role: 'user', content: "What's the weather in SF?" }, + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_weather', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"location": "San Francisco"}', + }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call_weather', + content: '{"temp": 65, "conditions": "foggy"}', + }, + { role: 'assistant', content: 'The weather in San Francisco is 65ยฐF and foggy.' }, + { role: 'user', content: 'Thanks!' }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toHaveLength(5) + expect(result[0]).toMatchObject({ type: 'message', role: 'user' }) + expect(result[1]).toMatchObject({ type: 'function_call', name: 'get_weather' }) + expect(result[2]).toMatchObject({ type: 'function_call_output', call_id: 'call_weather' }) + expect(result[3]).toMatchObject({ type: 'message', role: 'assistant' }) + expect(result[4]).toMatchObject({ type: 'message', role: 'user' }) + }) + }) + + describe('edge cases', () => { + test('skips messages with empty content', () => { + const messages: ChatMessage[] = [ + { role: 'user', content: '' }, + { role: 'user', content: 'Hello' }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ content: [{ text: 'Hello' }] }) + }) + + test('skips messages with empty array content', () => { + const messages: ChatMessage[] = [ + { role: 'user', content: [] }, + { role: 'user', content: 'Hello' }, + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toHaveLength(1) + }) + + test('handles empty messages array', () => { + const result = transformMessagesToCodexInput([]) + expect(result).toEqual([]) + }) + + test('handles undefined content gracefully', () => { + const messages: ChatMessage[] = [ + { role: 'user' }, // no content property + ] + + const result = transformMessagesToCodexInput(messages) + + expect(result).toHaveLength(0) + }) + + test('ignores non-function tool call types', () => { + const messages: ChatMessage[] = [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_1', + type: 'unknown_type' as 'function', + function: { + name: 'test', + arguments: '{}', + }, + }, + ], + }, + ] + + const result = transformMessagesToCodexInput(messages) + + // Should not include the tool call since type is not 'function' + expect(result).toHaveLength(0) + }) + }) +}) + +// ============================================================================ +// transformCodexEventToOpenAI Tests +// ============================================================================ + +describe('transformCodexEventToOpenAI', () => { + const createToolCallState = (modelId = 'gpt-5.1'): ToolCallState => ({ + currentToolCallId: null, + currentToolCallIndex: 0, + modelId, + }) + + describe('text delta events', () => { + test('transforms text delta event to OpenAI chat format', () => { + const event = { + type: 'response.output_text.delta', + delta: 'Hello', + response_id: 'resp_123', + } + const state = createToolCallState() + + const result = transformCodexEventToOpenAI(event, state) + + expect(result).not.toBeNull() + const parsed = JSON.parse(result!.replace('data: ', '').trim()) + expect(parsed.id).toBe('resp_123') + expect(parsed.object).toBe('chat.completion.chunk') + expect(parsed.choices[0].delta.content).toBe('Hello') + expect(parsed.choices[0].finish_reason).toBeNull() + }) + + test('uses default id when response_id is missing', () => { + const event = { + type: 'response.output_text.delta', + delta: 'World', + } + const state = createToolCallState() + + const result = transformCodexEventToOpenAI(event, state) + + const parsed = JSON.parse(result!.replace('data: ', '').trim()) + expect(parsed.id).toBe('chatcmpl-codex') + }) + + test('returns null when delta is missing', () => { + const event = { + type: 'response.output_text.delta', + } + const state = createToolCallState() + + const result = transformCodexEventToOpenAI(event, state) + + expect(result).toBeNull() + }) + }) + + describe('function call events', () => { + test('transforms function call added event', () => { + const event = { + type: 'response.output_item.added', + item: { + type: 'function_call', + call_id: 'call_abc123', + name: 'get_weather', + }, + } + const state = createToolCallState() + + const result = transformCodexEventToOpenAI(event, state) + + expect(result).not.toBeNull() + const parsed = JSON.parse(result!.replace('data: ', '').trim()) + expect(parsed.choices[0].delta.tool_calls).toBeDefined() + expect(parsed.choices[0].delta.tool_calls[0]).toMatchObject({ + index: 0, + id: 'call_abc123', + type: 'function', + function: { + name: 'get_weather', + arguments: '', + }, + }) + }) + + test('updates toolCallState when function call is added', () => { + const event = { + type: 'response.output_item.added', + item: { + type: 'function_call', + call_id: 'call_xyz', + name: 'test_func', + }, + } + const state = createToolCallState() + + transformCodexEventToOpenAI(event, state) + + expect(state.currentToolCallId).toBe('call_xyz') + }) + + test('transforms function call arguments delta', () => { + const event = { + type: 'response.function_call_arguments.delta', + delta: '{"location":', + } + const state = createToolCallState() + state.currentToolCallIndex = 0 + + const result = transformCodexEventToOpenAI(event, state) + + expect(result).not.toBeNull() + const parsed = JSON.parse(result!.replace('data: ', '').trim()) + expect(parsed.choices[0].delta.tool_calls[0].function.arguments).toBe('{"location":') + }) + + test('increments toolCallIndex on function call done', () => { + const event = { + type: 'response.function_call_arguments.done', + } + const state = createToolCallState() + state.currentToolCallIndex = 0 + + const result = transformCodexEventToOpenAI(event, state) + + expect(result).toBeNull() // Should not emit anything + expect(state.currentToolCallIndex).toBe(1) + }) + + test('ignores non-function_call output items', () => { + const event = { + type: 'response.output_item.added', + item: { + type: 'text', + content: 'some text', + }, + } + const state = createToolCallState() + + const result = transformCodexEventToOpenAI(event, state) + + expect(result).toBeNull() + }) + }) + + describe('completion events', () => { + test('transforms response.completed event with stop finish reason', () => { + const event = { + type: 'response.completed', + response: { + id: 'resp_final', + model: 'gpt-5.2-codex', + output: [{ type: 'text', content: 'Hello' }], + }, + } + const state = createToolCallState() + + const result = transformCodexEventToOpenAI(event, state) + + expect(result).not.toBeNull() + const parsed = JSON.parse(result!.replace('data: ', '').trim()) + expect(parsed.id).toBe('resp_final') + expect(parsed.model).toBe('gpt-5.2-codex') + expect(parsed.choices[0].delta).toEqual({}) + expect(parsed.choices[0].finish_reason).toBe('stop') + }) + + test('uses tool_calls finish reason when output contains function_call', () => { + const event = { + type: 'response.completed', + response: { + id: 'resp_tool', + output: [ + { type: 'function_call', name: 'get_weather' }, + ], + }, + } + const state = createToolCallState() + + const result = transformCodexEventToOpenAI(event, state) + + const parsed = JSON.parse(result!.replace('data: ', '').trim()) + expect(parsed.choices[0].finish_reason).toBe('tool_calls') + }) + + test('handles response.done event same as response.completed', () => { + const event = { + type: 'response.done', + response: { + id: 'resp_done', + }, + } + const state = createToolCallState() + + const result = transformCodexEventToOpenAI(event, state) + + expect(result).not.toBeNull() + const parsed = JSON.parse(result!.replace('data: ', '').trim()) + expect(parsed.id).toBe('resp_done') + expect(parsed.choices[0].finish_reason).toBe('stop') + }) + }) + + describe('ignored events', () => { + test('returns null for response.created event', () => { + const event = { type: 'response.created' } + const state = createToolCallState() + + const result = transformCodexEventToOpenAI(event, state) + + expect(result).toBeNull() + }) + + test('returns null for response.in_progress event', () => { + const event = { type: 'response.in_progress' } + const state = createToolCallState() + + const result = transformCodexEventToOpenAI(event, state) + + expect(result).toBeNull() + }) + + test('returns null for unknown event types', () => { + const event = { type: 'unknown.event.type' } + const state = createToolCallState() + + const result = transformCodexEventToOpenAI(event, state) + + expect(result).toBeNull() + }) + }) +}) + +// ============================================================================ +// extractAccountIdFromToken Tests +// ============================================================================ + +describe('extractAccountIdFromToken', () => { + // Helper to create a mock JWT token + const createMockJWT = (payload: Record): string => { + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url') + const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url') + const signature = 'mock_signature' + return `${header}.${payloadBase64}.${signature}` + } + + test('extracts account ID from valid JWT with chatgpt_account_id claim', () => { + const token = createMockJWT({ + 'https://api.openai.com/auth': { + chatgpt_account_id: 'acct_123456789', + }, + }) + + const result = extractAccountIdFromToken(token) + + expect(result).toBe('acct_123456789') + }) + + test('returns null when JWT is missing auth claim', () => { + const token = createMockJWT({ + sub: 'user_123', + email: 'test@example.com', + }) + + const result = extractAccountIdFromToken(token) + + expect(result).toBeNull() + }) + + test('returns null when auth claim is missing chatgpt_account_id', () => { + const token = createMockJWT({ + 'https://api.openai.com/auth': { + organization_id: 'org_123', + }, + }) + + const result = extractAccountIdFromToken(token) + + expect(result).toBeNull() + }) + + test('returns null for token with wrong number of parts', () => { + expect(extractAccountIdFromToken('not.a.valid.jwt.token')).toBeNull() + expect(extractAccountIdFromToken('toofew.parts')).toBeNull() + expect(extractAccountIdFromToken('')).toBeNull() + }) + + test('returns null for invalid base64 in payload', () => { + const token = 'header.!!!invalid_base64!!!.signature' + + const result = extractAccountIdFromToken(token) + + expect(result).toBeNull() + }) + + test('returns null for invalid JSON in payload', () => { + const header = Buffer.from('{}').toString('base64url') + const invalidPayload = Buffer.from('not json').toString('base64url') + const token = `${header}.${invalidPayload}.signature` + + const result = extractAccountIdFromToken(token) + + expect(result).toBeNull() + }) + + test('handles chatgpt_account_id with various formats', () => { + // UUID format + const uuidToken = createMockJWT({ + 'https://api.openai.com/auth': { + chatgpt_account_id: '550e8400-e29b-41d4-a716-446655440000', + }, + }) + expect(extractAccountIdFromToken(uuidToken)).toBe('550e8400-e29b-41d4-a716-446655440000') + + // Prefixed format + const prefixedToken = createMockJWT({ + 'https://api.openai.com/auth': { + chatgpt_account_id: 'acct_abc123xyz', + }, + }) + expect(extractAccountIdFromToken(prefixedToken)).toBe('acct_abc123xyz') + }) +}) diff --git a/sdk/src/credentials.ts b/sdk/src/credentials.ts index c6f103f06..090797b0d 100644 --- a/sdk/src/credentials.ts +++ b/sdk/src/credentials.ts @@ -4,10 +4,11 @@ import os from 'os' import { env } from '@codebuff/common/env' import { CLAUDE_OAUTH_CLIENT_ID } from '@codebuff/common/constants/claude-oauth' +import { CODEX_OAUTH_CLIENT_ID, CODEX_OAUTH_TOKEN_URL } from '@codebuff/common/constants/codex-oauth' import { userSchema } from '@codebuff/common/util/credentials' import { z } from 'zod/v4' -import { getClaudeOAuthTokenFromEnv } from './env' +import { getClaudeOAuthTokenFromEnv, getCodexOAuthTokenFromEnv } from './env' import type { ClientEnv } from '@codebuff/common/types/contracts/env' import type { User } from '@codebuff/common/util/credentials' @@ -22,18 +23,29 @@ const claudeOAuthSchema = z.object({ connectedAt: z.number(), }) +/** + * Schema for Codex OAuth credentials. + */ +const codexOAuthSchema = z.object({ + accessToken: z.string(), + refreshToken: z.string(), + expiresAt: z.number(), + connectedAt: z.number(), +}) + /** * Unified schema for the credentials file. - * Contains both Codebuff user credentials and Claude OAuth credentials. + * Contains Codebuff user credentials, Claude OAuth credentials, and Codex OAuth credentials. */ const credentialsFileSchema = z.object({ default: userSchema.optional(), claudeOAuth: claudeOAuthSchema.optional(), + codexOAuth: codexOAuthSchema.optional(), }) const ensureDirectoryExistsSync = (dir: string) => { if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) } } @@ -92,6 +104,16 @@ export interface ClaudeOAuthCredentials { connectedAt: number // Unix timestamp in milliseconds } +/** + * Codex OAuth credentials stored in the credentials file. + */ +export interface CodexOAuthCredentials { + accessToken: string + refreshToken: string + expiresAt: number // Unix timestamp in milliseconds + connectedAt: number // Unix timestamp in milliseconds +} + /** * Get Claude OAuth credentials from file or environment variable. * Environment variable takes precedence. @@ -158,7 +180,7 @@ export const saveClaudeOAuthCredentials = ( claudeOAuth: credentials, } - fs.writeFileSync(credentialsPath, JSON.stringify(updatedData, null, 2)) + fs.writeFileSync(credentialsPath, JSON.stringify(updatedData, null, 2), { mode: 0o600 }) } /** @@ -298,3 +320,217 @@ export const getValidClaudeOAuthCredentials = async ( // Token is expired or expiring soon, try to refresh return refreshClaudeOAuthToken(clientEnv) } + +// ============================================================================ +// Codex OAuth Credentials +// ============================================================================ + +/** + * Get Codex OAuth credentials from file or environment variable. + * Environment variable takes precedence. + * @returns OAuth credentials or null if not found + */ +export const getCodexOAuthCredentials = ( + clientEnv: ClientEnv = env, +): CodexOAuthCredentials | null => { + // Check environment variable first + const envToken = getCodexOAuthTokenFromEnv() + if (envToken) { + // Return a synthetic credentials object for env var tokens + // These tokens are assumed to be valid and non-expiring for simplicity + return { + accessToken: envToken, + refreshToken: '', + expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000, // 1 year from now + connectedAt: Date.now(), + } + } + + const credentialsPath = getCredentialsPath(clientEnv) + if (!fs.existsSync(credentialsPath)) { + return null + } + + try { + const credentialsFile = fs.readFileSync(credentialsPath, 'utf8') + const parsed = credentialsFileSchema.safeParse(JSON.parse(credentialsFile)) + if (!parsed.success || !parsed.data.codexOAuth) { + return null + } + return parsed.data.codexOAuth + } catch (error) { + console.error('Error reading Codex OAuth credentials', error) + return null + } +} + +/** + * Save Codex OAuth credentials to the credentials file. + * Preserves existing user credentials. + */ +export const saveCodexOAuthCredentials = ( + credentials: CodexOAuthCredentials, + clientEnv: ClientEnv = env, +): void => { + const configDir = getConfigDir(clientEnv) + const credentialsPath = getCredentialsPath(clientEnv) + + ensureDirectoryExistsSync(configDir) + + let existingData: Record = {} + if (fs.existsSync(credentialsPath)) { + try { + existingData = JSON.parse(fs.readFileSync(credentialsPath, 'utf8')) + } catch { + // Ignore parse errors, start fresh + } + } + + const updatedData = { + ...existingData, + codexOAuth: credentials, + } + + fs.writeFileSync(credentialsPath, JSON.stringify(updatedData, null, 2), { mode: 0o600 }) +} + +/** + * Clear Codex OAuth credentials from the credentials file. + * Preserves other credentials. + */ +export const clearCodexOAuthCredentials = ( + clientEnv: ClientEnv = env, +): void => { + const credentialsPath = getCredentialsPath(clientEnv) + if (!fs.existsSync(credentialsPath)) { + return + } + + try { + const existingData = JSON.parse(fs.readFileSync(credentialsPath, 'utf8')) + delete existingData.codexOAuth + fs.writeFileSync(credentialsPath, JSON.stringify(existingData, null, 2)) + } catch { + // Ignore errors + } +} + +/** + * Check if Codex OAuth credentials are valid (not expired). + * Returns true if credentials exist and haven't expired. + */ +export const isCodexOAuthValid = (clientEnv: ClientEnv = env): boolean => { + const credentials = getCodexOAuthCredentials(clientEnv) + if (!credentials) { + return false + } + // Add 5 minute buffer before expiry + const bufferMs = 5 * 60 * 1000 + return credentials.expiresAt > Date.now() + bufferMs +} + +// Mutex to prevent concurrent Codex refresh attempts +let codexRefreshPromise: Promise | null = null + +/** + * Refresh the Codex OAuth access token using the refresh token. + * Returns the new credentials if successful, null if refresh fails. + * Uses a mutex to prevent concurrent refresh attempts. + */ +export const refreshCodexOAuthToken = async ( + clientEnv: ClientEnv = env, +): Promise => { + // If a refresh is already in progress, wait for it + if (codexRefreshPromise) { + return codexRefreshPromise + } + + const credentials = getCodexOAuthCredentials(clientEnv) + if (!credentials?.refreshToken) { + return null + } + + // Start the refresh and store the promise + codexRefreshPromise = (async () => { + try { + // IMPORTANT: Use application/x-www-form-urlencoded, NOT application/json + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: credentials.refreshToken, + client_id: CODEX_OAUTH_CLIENT_ID, + }) + + const response = await fetch( + CODEX_OAUTH_TOKEN_URL, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }, + ) + + if (!response.ok) { + // Refresh failed, clear credentials + clearCodexOAuthCredentials(clientEnv) + return null + } + + const data = await response.json() + + const newCredentials: CodexOAuthCredentials = { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? credentials.refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + connectedAt: credentials.connectedAt, + } + + // Save updated credentials + saveCodexOAuthCredentials(newCredentials, clientEnv) + + return newCredentials + } catch { + // Refresh failed, clear credentials + clearCodexOAuthCredentials(clientEnv) + return null + } finally { + // Clear the mutex after completion + codexRefreshPromise = null + } + })() + + return codexRefreshPromise +} + +/** + * Get valid Codex OAuth credentials, refreshing if necessary. + * This is the main function to use when you need credentials for an API call. + * + * - Returns credentials immediately if valid (>5 min until expiry) + * - Attempts refresh if token is expired or near-expiry + * - Returns null if no credentials or refresh fails + */ +export const getValidCodexOAuthCredentials = async ( + clientEnv: ClientEnv = env, +): Promise => { + const credentials = getCodexOAuthCredentials(clientEnv) + if (!credentials) { + return null + } + + // Check if token is from environment variable (synthetic credentials, no refresh needed) + if (!credentials.refreshToken) { + // Environment variable tokens are assumed valid + return credentials + } + + // Check if token is valid with 5 minute buffer + const bufferMs = 5 * 60 * 1000 + if (credentials.expiresAt > Date.now() + bufferMs) { + return credentials + } + + // Token is expired or expiring soon, try to refresh + return refreshCodexOAuthToken(clientEnv) +} diff --git a/sdk/src/env.ts b/sdk/src/env.ts index ab9fbce49..d6e554cc7 100644 --- a/sdk/src/env.ts +++ b/sdk/src/env.ts @@ -8,6 +8,7 @@ import { getBaseEnv } from '@codebuff/common/env-process' import { BYOK_OPENROUTER_ENV_VAR } from '@codebuff/common/constants/byok' import { CLAUDE_OAUTH_TOKEN_ENV_VAR } from '@codebuff/common/constants/claude-oauth' +import { CODEX_OAUTH_TOKEN_ENV_VAR } from '@codebuff/common/constants/codex-oauth' import { API_KEY_ENV_VAR } from '@codebuff/common/constants/paths' import type { SdkEnv } from './types/env' @@ -49,3 +50,11 @@ export const getByokOpenrouterApiKeyFromEnv = (): string | undefined => { export const getClaudeOAuthTokenFromEnv = (): string | undefined => { return process.env[CLAUDE_OAUTH_TOKEN_ENV_VAR] } + +/** + * Get Codex OAuth token from environment variable. + * This allows users to provide their ChatGPT Plus/Pro OAuth token for direct OpenAI API access. + */ +export const getCodexOAuthTokenFromEnv = (): string | undefined => { + return process.env[CODEX_OAUTH_TOKEN_ENV_VAR] +} diff --git a/sdk/src/impl/codex-transform.ts b/sdk/src/impl/codex-transform.ts new file mode 100644 index 000000000..aec4a0dc6 --- /dev/null +++ b/sdk/src/impl/codex-transform.ts @@ -0,0 +1,331 @@ +/** + * Codex message and event transformation utilities. + * + * Converts between: + * - AI SDK OpenAI chat format (messages array with role/content) + * - Codex API format (input array with type/role/content and function_call items) + */ + +/** + * Content part types from AI SDK + */ +export interface TextContentPart { + type: 'text' + text: string +} + +export interface ImageUrlContentPart { + type: 'image_url' + image_url: { url: string } +} + +export type ContentPart = TextContentPart | ImageUrlContentPart | { type: string; text?: string } + +/** + * OpenAI chat message type from AI SDK + */ +export interface ChatMessage { + role: string + content?: string | Array + tool_calls?: Array<{ + id: string + type: string + function: { + name: string + arguments: string + } + }> + tool_call_id?: string + name?: string +} + +/** + * State for tracking tool calls during streaming (per-request) + */ +export interface ToolCallState { + currentToolCallId: string | null + currentToolCallIndex: number + modelId: string +} + +/** + * Extract the ChatGPT account ID from the JWT access token. + * The account ID is required for the chatgpt-account-id header. + */ +export function extractAccountIdFromToken(accessToken: string): string | null { + try { + // JWT format: header.payload.signature + const parts = accessToken.split('.') + if (parts.length !== 3) { + return null + } + + // Decode the payload (base64url) + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) + + // The account ID is in the custom claim at "https://api.openai.com/auth" + const authClaim = payload['https://api.openai.com/auth'] + if (authClaim?.chatgpt_account_id) { + return authClaim.chatgpt_account_id + } + + return null + } catch { + return null + } +} + +/** + * Transform OpenAI chat format messages to Codex input format. + * The Codex API expects a different structure than the standard OpenAI chat API. + * + * Key differences: + * - User messages use content type 'input_text' + * - Assistant messages use content type 'output_text' (NOT input_text!) + * - System messages become 'developer' role (but usually go in 'instructions' field instead) + * - Tool calls are NOT messages - they are 'function_call' items + * - Tool results are NOT messages with role 'tool' - they are 'function_call_output' items + */ +export function transformMessagesToCodexInput( + messages: Array, +): Array> { + const result: Array> = [] + + for (const msg of messages) { + // Handle tool result messages (role: 'tool') + // These become function_call_output items in Codex format + if (msg.role === 'tool') { + result.push({ + type: 'function_call_output', + call_id: msg.tool_call_id || 'unknown', + output: typeof msg.content === 'string' + ? msg.content + : JSON.stringify(msg.content), + }) + continue + } + + // Handle assistant messages with tool calls + if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) { + // First, add the assistant message if it has text content + if (msg.content) { + const textContent = typeof msg.content === 'string' + ? msg.content + : msg.content + .filter((p): p is TextContentPart => p.type === 'text') + .map(p => p.text) + .join('') + + if (textContent) { + result.push({ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: textContent }], + }) + } + } + + // Then add each tool call as a separate function_call item + for (const toolCall of msg.tool_calls) { + if (toolCall.type === 'function') { + result.push({ + type: 'function_call', + call_id: toolCall.id, + name: toolCall.function.name, + arguments: toolCall.function.arguments, + }) + } + } + continue + } + + // Handle regular messages (user, assistant without tool calls, system) + // Determine the content type based on role: + // - user messages use 'input_text' + // - assistant messages use 'output_text' + // - developer/system messages use 'input_text' + const isAssistant = msg.role === 'assistant' + const textContentType = isAssistant ? 'output_text' : 'input_text' + + // Convert content to Codex format + const content: Array> = [] + if (typeof msg.content === 'string') { + content.push({ type: textContentType, text: msg.content }) + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === 'text') { + content.push({ type: textContentType, text: (part as TextContentPart).text }) + } else if (part.type === 'image_url') { + // Transform AI SDK image format to Codex format + // AI SDK: { type: 'image_url', image_url: { url: '...' } } + // Codex: { type: 'input_image', image_url: '...' } + const imagePart = part as ImageUrlContentPart + content.push({ + type: 'input_image', + image_url: imagePart.image_url.url, + }) + } else { + // Pass through other content types as-is + content.push(part as Record) + } + } + } + + // Skip empty messages (but allow messages with images) + const hasContent = content.length > 0 && content.some( + (c) => c.type === 'input_image' || (c.type === 'output_text' && c.text) || (c.type === 'input_text' && c.text) + ) + if (!hasContent) { + continue + } + + // Map roles: assistant -> assistant, user -> user, system -> developer + let role = msg.role + if (role === 'system') { + role = 'developer' + } + + result.push({ + type: 'message', + role, + content, + }) + } + + return result +} + +/** + * Transform a Codex event to OpenAI chat format. + * The ChatGPT backend returns events like "response.output_text.delta" + * but the AI SDK expects OpenAI chat format like "choices[0].delta.content". + * Returns null if the event should be skipped (not passed to SDK). + * + * @param event - The Codex event to transform + * @param toolCallState - Mutable state for tracking tool calls (scoped per-request) + */ +export function transformCodexEventToOpenAI( + event: Record, + toolCallState: ToolCallState, +): string | null { + const { modelId } = toolCallState + + // Handle text delta events - these contain the actual content + if (event.type === 'response.output_text.delta' && event.delta) { + const transformed = { + id: event.response_id || 'chatcmpl-codex', + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: modelId, + choices: [ + { + index: 0, + delta: { + content: event.delta, + }, + finish_reason: null, + }, + ], + } + return `data: ${JSON.stringify(transformed)}\n\n` + } + + // Handle function call added event - start of a new tool call + if (event.type === 'response.output_item.added') { + const item = event.item as Record | undefined + if (item?.type === 'function_call') { + toolCallState.currentToolCallId = (item.call_id as string) || `call_${Date.now()}` + const transformed = { + id: 'chatcmpl-codex', + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: modelId, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: toolCallState.currentToolCallIndex, + id: toolCallState.currentToolCallId, + type: 'function', + function: { + name: item.name as string, + arguments: '', + }, + }, + ], + }, + finish_reason: null, + }, + ], + } + return `data: ${JSON.stringify(transformed)}\n\n` + } + } + + // Handle function call arguments delta - streaming tool call arguments + if (event.type === 'response.function_call_arguments.delta' && event.delta) { + const transformed = { + id: 'chatcmpl-codex', + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: modelId, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: toolCallState.currentToolCallIndex, + function: { + arguments: event.delta as string, + }, + }, + ], + }, + finish_reason: null, + }, + ], + } + return `data: ${JSON.stringify(transformed)}\n\n` + } + + // Handle function call done event + if (event.type === 'response.function_call_arguments.done') { + toolCallState.currentToolCallIndex++ + // Don't emit anything here - the arguments were already streamed + return null + } + + // Handle completion events + if (event.type === 'response.completed' || event.type === 'response.done') { + const response = event.response as Record | undefined + + // Determine finish reason based on output + let finishReason = 'stop' + const output = response?.output as Array> | undefined + if (output?.some(item => item.type === 'function_call')) { + finishReason = 'tool_calls' + } + + const transformed = { + id: response?.id || 'chatcmpl-codex', + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: (response?.model as string) || modelId, + choices: [ + { + index: 0, + delta: {}, + finish_reason: finishReason, + }, + ], + } + return `data: ${JSON.stringify(transformed)}\n\n` + } + + // Skip other events (response.created, response.in_progress, etc.) + // These are metadata events that don't contain content + return null +} diff --git a/sdk/src/impl/llm.ts b/sdk/src/impl/llm.ts index 4b74c1613..c99fbbd79 100644 --- a/sdk/src/impl/llm.ts +++ b/sdk/src/impl/llm.ts @@ -16,7 +16,7 @@ import { } from 'ai' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { getModelForRequest, markClaudeOAuthRateLimited, fetchClaudeOAuthResetTime } from './model-provider' +import { getModelForRequest, markClaudeOAuthRateLimited, markCodexOAuthRateLimited, fetchClaudeOAuthResetTime } from './model-provider' import { getValidClaudeOAuthCredentials } from '../credentials' import { getErrorStatusCode } from '../error-utils' @@ -181,10 +181,79 @@ function isClaudeOAuthAuthError(error: unknown): boolean { return false } +/** + * Check if an error is a Codex OAuth rate limit error that should trigger fallback. + */ +function isCodexOAuthRateLimitError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + + // Check status code + const statusCode = getErrorStatusCode(error) + if (statusCode === 429) return true + + // Check error message for rate limit indicators + const err = error as { + message?: string + responseBody?: string + } + const message = (err.message || '').toLowerCase() + const responseBody = (err.responseBody || '').toLowerCase() + + if (message.includes('rate_limit') || message.includes('rate limit')) + return true + if (message.includes('overloaded') || message.includes('capacity')) + return true + if ( + responseBody.includes('rate_limit') || + responseBody.includes('overloaded') + ) + return true + + return false +} + +/** + * Check if an error is a Codex OAuth authentication error (expired/invalid token). + */ +function isCodexOAuthAuthError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + + // Check status code + const statusCode = getErrorStatusCode(error) + if (statusCode === 401 || statusCode === 403) return true + + // Check error message for auth indicators + const err = error as { + message?: string + responseBody?: string + } + const message = (err.message || '').toLowerCase() + const responseBody = (err.responseBody || '').toLowerCase() + + if (message.includes('unauthorized') || message.includes('invalid_token')) + return true + if (message.includes('authentication') || message.includes('expired')) + return true + if ( + responseBody.includes('unauthorized') || + responseBody.includes('invalid_token') + ) + return true + if ( + responseBody.includes('authentication') || + responseBody.includes('expired') + ) + return true + + return false +} + export async function* promptAiSdkStream( params: ParamsOf & { skipClaudeOAuth?: boolean + skipCodexOAuth?: boolean onClaudeOAuthStatusChange?: (isActive: boolean) => void + onCodexOAuthStatusChange?: (isActive: boolean) => void }, ): ReturnType { const { logger, trackEvent, userId, userInputId, model: requestedModel } = params @@ -206,8 +275,9 @@ export async function* promptAiSdkStream( apiKey: params.apiKey, model: params.model, skipClaudeOAuth: params.skipClaudeOAuth, + skipCodexOAuth: params.skipCodexOAuth, } - const { model: aiSDKModel, isClaudeOAuth } = await getModelForRequest(modelParams) + const { model: aiSDKModel, isClaudeOAuth, isCodexOAuth } = await getModelForRequest(modelParams) // Track and notify about Claude OAuth usage if (isClaudeOAuth) { @@ -225,14 +295,30 @@ export async function* promptAiSdkStream( } } + // Track and notify about Codex OAuth usage + if (isCodexOAuth) { + trackEvent({ + event: AnalyticsEvent.CODEX_OAUTH_REQUEST, + userId: userId ?? '', + properties: { + model: requestedModel, + userInputId, + }, + logger, + }) + if (params.onCodexOAuthStatusChange) { + params.onCodexOAuthStatusChange(true) + } + } + const response = streamText({ ...params, prompt: undefined, model: aiSDKModel, messages: convertCbToModelMessages(params), - // When using Claude OAuth, disable retries so we can immediately fall back to Codebuff + // When using OAuth, disable retries so we can immediately fall back to Codebuff // backend on rate limit errors instead of retrying 4 times first - ...(isClaudeOAuth && { maxRetries: 0 }), + ...((isClaudeOAuth || isCodexOAuth) && { maxRetries: 0 }), providerOptions: getProviderOptions({ ...params, agentProviderOptions: params.agentProviderOptions, @@ -478,6 +564,72 @@ export async function* promptAiSdkStream( return fallbackResult } + // Check if this is a Codex OAuth rate limit error - only fall back if no content yielded yet + if ( + isCodexOAuth && + !params.skipCodexOAuth && + !hasYieldedContent && + isCodexOAuthRateLimitError(chunkValue.error) + ) { + logger.info( + { error: getErrorObject(chunkValue.error) }, + 'Codex OAuth rate limited during stream, falling back to Codebuff backend', + ) + // Track the rate limit event + trackEvent({ + event: AnalyticsEvent.CODEX_OAUTH_RATE_LIMITED, + userId: userId ?? '', + properties: { + model: requestedModel, + userInputId, + }, + logger, + }) + // Mark as rate-limited so subsequent requests skip Codex OAuth + markCodexOAuthRateLimited() + if (params.onCodexOAuthStatusChange) { + params.onCodexOAuthStatusChange(false) + } + // Retry with Codebuff backend + const fallbackResult = yield* promptAiSdkStream({ + ...params, + skipCodexOAuth: true, + }) + return fallbackResult + } + + // Check if this is a Codex OAuth authentication error (expired token) - only fall back if no content yielded yet + if ( + isCodexOAuth && + !params.skipCodexOAuth && + !hasYieldedContent && + isCodexOAuthAuthError(chunkValue.error) + ) { + logger.info( + { error: getErrorObject(chunkValue.error) }, + 'Codex OAuth auth error during stream, falling back to Codebuff backend', + ) + // Track the auth error event + trackEvent({ + event: AnalyticsEvent.CODEX_OAUTH_AUTH_ERROR, + userId: userId ?? '', + properties: { + model: requestedModel, + userInputId, + }, + logger, + }) + if (params.onCodexOAuthStatusChange) { + params.onCodexOAuthStatusChange(false) + } + // Retry with Codebuff backend (skipCodexOAuth will bypass the failed OAuth) + const fallbackResult = yield* promptAiSdkStream({ + ...params, + skipCodexOAuth: true, + }) + return fallbackResult + } + logger.error( { chunk: { ...chunkValue, error: undefined }, @@ -549,8 +701,8 @@ export async function* promptAiSdkStream( const responseValue = await response.response const messageId = responseValue.id - // Skip cost tracking for Claude OAuth (user is on their own subscription) - if (!isClaudeOAuth) { + // Skip cost tracking for OAuth (user is on their own subscription) + if (!isClaudeOAuth && !isCodexOAuth) { const providerMetadataResult = await response.providerMetadata const providerMetadata = providerMetadataResult ?? {} @@ -597,6 +749,7 @@ export async function promptAiSdk( apiKey: params.apiKey, model: params.model, skipClaudeOAuth: true, // Always use Codebuff backend for non-streaming + skipCodexOAuth: true, // Always use Codebuff backend for non-streaming } const { model: aiSDKModel } = await getModelForRequest(modelParams) @@ -654,6 +807,7 @@ export async function promptAiSdkStructured( apiKey: params.apiKey, model: params.model, skipClaudeOAuth: true, // Always use Codebuff backend for non-streaming + skipCodexOAuth: true, // Always use Codebuff backend for non-streaming } const { model: aiSDKModel } = await getModelForRequest(modelParams) diff --git a/sdk/src/impl/model-provider.ts b/sdk/src/impl/model-provider.ts index 71e33ca49..106defbd0 100644 --- a/sdk/src/impl/model-provider.ts +++ b/sdk/src/impl/model-provider.ts @@ -16,16 +16,28 @@ import { isClaudeModel, toAnthropicModelId, } from '@codebuff/common/constants/claude-oauth' +import { + CHATGPT_BACKEND_API_URL, + isOpenAIModel, + toCodexModelId, +} from '@codebuff/common/constants/codex-oauth' import { OpenAICompatibleChatLanguageModel, VERSION, } from '@codebuff/internal/openai-compatible/index' import { WEBSITE_URL } from '../constants' -import { getValidClaudeOAuthCredentials } from '../credentials' +import { getValidClaudeOAuthCredentials, getValidCodexOAuthCredentials } from '../credentials' import { getByokOpenrouterApiKeyFromEnv } from '../env' +import { + extractAccountIdFromToken, + transformMessagesToCodexInput, + transformCodexEventToOpenAI, +} from './codex-transform' + import type { LanguageModel } from 'ai' +import type { ToolCallState } from './codex-transform' // ============================================================================ // Claude OAuth Rate Limit Cache @@ -68,6 +80,47 @@ export function resetClaudeOAuthRateLimit(): void { claudeOAuthRateLimitedUntil = null } +// ============================================================================ +// Codex OAuth Rate Limit Cache +// ============================================================================ + +/** Timestamp (ms) when Codex OAuth rate limit expires, or null if not rate-limited */ +let codexOAuthRateLimitedUntil: number | null = null + +/** + * Mark Codex OAuth as rate-limited. Subsequent requests will skip Codex OAuth + * and use Codebuff backend until the reset time. + * @param resetAt - When the rate limit resets. If not provided, guesses 5 minutes from now. + */ +export function markCodexOAuthRateLimited(resetAt?: Date): void { + const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000 + codexOAuthRateLimitedUntil = resetAt ? resetAt.getTime() : fiveMinutesFromNow +} + +/** + * Check if Codex OAuth is currently rate-limited. + * Returns true if rate-limited and reset time hasn't passed. + */ +export function isCodexOAuthRateLimited(): boolean { + if (codexOAuthRateLimitedUntil === null) { + return false + } + if (Date.now() >= codexOAuthRateLimitedUntil) { + // Rate limit expired, clear the cache + codexOAuthRateLimitedUntil = null + return false + } + return true +} + +/** + * Reset the Codex OAuth rate limit cache. + * Call this when user reconnects their Codex subscription. + */ +export function resetCodexOAuthRateLimit(): void { + codexOAuthRateLimitedUntil = null +} + // ============================================================================ // Claude OAuth Quota Fetching // ============================================================================ @@ -139,6 +192,8 @@ export interface ModelRequestParams { model: string /** If true, skip Claude OAuth and use Codebuff backend (for fallback after rate limit) */ skipClaudeOAuth?: boolean + /** If true, skip Codex OAuth and use Codebuff backend (for fallback after rate limit) */ + skipCodexOAuth?: boolean } /** @@ -149,6 +204,8 @@ export interface ModelResult { model: LanguageModel /** Whether this model uses Claude OAuth direct (affects cost tracking) */ isClaudeOAuth: boolean + /** Whether this model uses Codex OAuth direct (affects cost tracking) */ + isCodexOAuth: boolean } // Usage accounting type for OpenRouter/Codebuff backend responses @@ -163,12 +220,14 @@ type OpenRouterUsageAccounting = { * Get the appropriate model for a request. * * If Claude OAuth credentials are available and the model is a Claude model, - * returns an Anthropic direct model. Otherwise, returns the Codebuff backend model. + * returns an Anthropic direct model. If Codex OAuth credentials are available + * and the model is an OpenAI model, returns an OpenAI direct model. + * Otherwise, returns the Codebuff backend model. * * This function is async because it may need to refresh the OAuth token. */ export async function getModelForRequest(params: ModelRequestParams): Promise { - const { apiKey, model, skipClaudeOAuth } = params + const { apiKey, model, skipClaudeOAuth, skipCodexOAuth } = params // Check if we should use Claude OAuth direct // Skip if explicitly requested, if rate-limited, or if not a Claude model @@ -182,7 +241,30 @@ export async function getModelForRequest(params: ModelRequestParams): Promise => { + // Parse and transform the request body from OpenAI chat format to Codex format + let transformedBody = init?.body + if (init?.body && typeof init.body === 'string') { + try { + const originalBody = JSON.parse(init.body) + + // Extract system message for instructions and separate non-system messages + const allMessages = originalBody.messages || [] + const systemMessage = allMessages.find( + (m: { role: string }) => m.role === 'system', + ) + const nonSystemMessages = allMessages.filter( + (m: { role: string }) => m.role !== 'system', + ) + + // Extract instructions from system message (Codex API REQUIRES this field) + let instructions = 'You are a helpful assistant.' + if (systemMessage) { + instructions = + typeof systemMessage.content === 'string' + ? systemMessage.content + : systemMessage.content + ?.map((p: { text?: string }) => p.text) + .filter(Boolean) + .join('\n') || 'You are a helpful assistant.' + } + + // Transform from OpenAI chat format to Codex format + const codexBody: Record = { + model: modelId, + instructions, + input: transformMessagesToCodexInput(nonSystemMessages), + store: false, // ChatGPT backend REQUIRES store=false + stream: true, // Always stream + reasoning: { + effort: 'high', + summary: 'auto', + }, + text: { + verbosity: 'medium', + }, + include: ['reasoning.encrypted_content'], + } + + // Pass through tool definitions if present, transforming from AI SDK format to Codex format + // AI SDK sends: { type: 'function', function: { name, description, parameters } } + // Codex expects: { type: 'function', name, description, parameters } + if (originalBody.tools && Array.isArray(originalBody.tools)) { + codexBody.tools = originalBody.tools.map((tool: Record) => { + // If tool has nested 'function' object (AI SDK format), flatten it + if (tool.type === 'function' && tool.function && typeof tool.function === 'object') { + const fn = tool.function as Record + return { + type: 'function', + name: fn.name, + description: fn.description, + parameters: fn.parameters, + ...(fn.strict !== undefined && { strict: fn.strict }), + } + } + return tool + }) + } + + transformedBody = JSON.stringify(codexBody) + } catch { + // If parsing fails, use original body + } + } + + // Make the request to the Codex backend + const response = await globalThis.fetch( + `${CHATGPT_BACKEND_API_URL}/codex/responses`, + { + method: 'POST', + signal: init?.signal, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${oauthToken}`, + 'chatgpt-account-id': accountId, + 'OpenAI-Beta': 'responses=experimental', + originator: 'codex_cli_rs', + accept: 'text/event-stream', + }, + body: transformedBody, + }, + ) + + // If not streaming or error, return as-is + if (!response.ok || !response.body) { + return response + } + + // Transform the streaming response from Codex format to OpenAI format + // Use a TransformStream for proper backpressure handling + const decoder = new TextDecoder() + const encoder = new TextEncoder() + let buffer = '' + // Tool call state is scoped to this request to avoid race conditions + const toolCallState: ToolCallState = { + currentToolCallId: null, + currentToolCallIndex: 0, + modelId, + } + + const transformStream = new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }) + + // Process complete lines + const lines = buffer.split('\n') + buffer = lines.pop() || '' // Keep incomplete line in buffer + + for (const line of lines) { + const trimmed = line.trim() + // Only parse SSE data: lines, skip event:/id:/retry: and blank lines + if (!trimmed.startsWith('data:')) continue + + const data = trimmed.slice(5).trim() + if (data === '[DONE]') continue + + try { + const event = JSON.parse(data) + const transformed = transformCodexEventToOpenAI(event, toolCallState) + if (transformed) { + controller.enqueue(encoder.encode(transformed)) + } + } catch { + // Skip unparseable lines + } + } + }, + flush(controller) { + // Process any remaining buffer + if (buffer.trim()) { + const lines = buffer.split('\n') + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed.startsWith('data:')) continue + + const data = trimmed.slice(5).trim() + if (data === '[DONE]') continue + + try { + const event = JSON.parse(data) + const transformed = transformCodexEventToOpenAI(event, toolCallState) + if (transformed) { + controller.enqueue(encoder.encode(transformed)) + } + } catch { + // Skip unparseable lines + } + } + } + // Always send [DONE] to signal end of stream to AI SDK + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + }, + }) + + const transformedStream = response.body.pipeThrough(transformStream) + + // Return a new response with the transformed stream + return new Response(transformedStream, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) + } + + return customFetch as typeof globalThis.fetch +} + +/** + * Create an OpenAI/Codex model that uses OAuth Bearer token authentication. + * Uses a custom fetch that transforms OpenAI chat format to Codex format and back. + */ +function createCodexOAuthModel( + model: string, + oauthToken: string, +): LanguageModel | null { + // Convert to normalized Codex model ID + const codexModelId = toCodexModelId(model) + if (!codexModelId) { + // Unknown model - fall back to Codebuff backend + return null + } + + // Extract the ChatGPT account ID from the JWT token + // This is REQUIRED for the chatgpt-account-id header + const accountId = extractAccountIdFromToken(oauthToken) + if (!accountId) { + // If we can't extract account ID, return null to fall back to Codebuff backend + // This shouldn't happen with valid tokens, but provides a safety net + return null + } + + // Use OpenAICompatibleChatLanguageModel with custom fetch that transforms + // OpenAI chat format to/from Codex format + return new OpenAICompatibleChatLanguageModel(codexModelId, { + provider: 'codex-oauth', + // URL doesn't matter - our custom fetch ignores it and calls the Codex endpoint directly + url: () => `${CHATGPT_BACKEND_API_URL}/codex/responses`, + headers: () => ({}), // Headers are set in custom fetch + fetch: createCodexFetch(oauthToken, accountId, codexModelId), + supportsStructuredOutputs: false, + }) +} + /** * Create a model that routes through the Codebuff backend. * This is the existing behavior - requests go to Codebuff backend which forwards to OpenRouter. diff --git a/sdk/src/index.ts b/sdk/src/index.ts index fa8f405c7..1d240a384 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -85,4 +85,11 @@ export { promptAiSdkStream, promptAiSdkStructured, } from './impl/llm' -export { resetClaudeOAuthRateLimit } from './impl/model-provider' +export { resetClaudeOAuthRateLimit, resetCodexOAuthRateLimit } from './impl/model-provider' +export { + getCodexOAuthCredentials, + saveCodexOAuthCredentials, + clearCodexOAuthCredentials, + isCodexOAuthValid, + type CodexOAuthCredentials, +} from './credentials'