From 0bd0e0962bd183fcd4e7fb671da2e6ae1e069069 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 26 Jan 2026 18:49:56 -0800 Subject: [PATCH 1/4] initial commit --- cli/src/commands/command-registry.ts | 10 + cli/src/commands/router.ts | 18 + cli/src/components/codex-connect-banner.tsx | 187 ++++ cli/src/components/input-mode-banner.tsx | 2 + cli/src/data/slash-commands.ts | 6 + cli/src/utils/codex-oauth.ts | 470 +++++++++ cli/src/utils/input-modes.ts | 9 + common/src/constants/analytics-events.ts | 5 + common/src/constants/codex-oauth.ts | 152 +++ scripts/test-codex-api.ts | 246 +++++ scripts/test-codex-messages.ts | 291 ++++++ scripts/test-codex-oauth.ts | 258 +++++ .../__tests__/codex-message-transform.test.ts | 970 ++++++++++++++++++ sdk/src/credentials.ts | 240 ++++- sdk/src/env.ts | 9 + sdk/src/impl/llm.ts | 164 ++- sdk/src/impl/model-provider.ts | 623 ++++++++++- sdk/src/index.ts | 9 +- 18 files changed, 3657 insertions(+), 12 deletions(-) create mode 100644 cli/src/components/codex-connect-banner.tsx create mode 100644 cli/src/utils/codex-oauth.ts create mode 100644 common/src/constants/codex-oauth.ts create mode 100644 scripts/test-codex-api.ts create mode 100644 scripts/test-codex-messages.ts create mode 100644 scripts/test-codex-oauth.ts create mode 100644 sdk/src/__tests__/codex-message-transform.test.ts 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..daa00883c --- /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 [isDisconnectHovered, setIsDisconnectHovered] = useState(false) + const [isConnectHovered, setIsConnectHovered] = useState(false) + + // Check initial connection status and auto-open browser if not connected + useEffect(() => { + const status = getCodexOAuthStatus() + if (status.connected) { + setFlowState('connected') + } else { + // Automatically start OAuth flow when not connected + setFlowState('waiting-for-code') + startOAuthFlowWithCallback((callbackStatus, message) => { + if (callbackStatus === 'success') { + setFlowState('connected') + } else if (callbackStatus === 'error') { + setError(message ?? 'Authorization failed') + setFlowState('error') + } + }).catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to start OAuth flow') + setFlowState('error') + }) + } + + // Cleanup: stop the callback server when the component unmounts + return () => { + stopCallbackServer() + } + }, []) + + const handleConnect = async () => { + try { + setFlowState('waiting-for-code') + await startOAuthFlowWithCallback((callbackStatus, message) => { + if (callbackStatus === 'success') { + setFlowState('connected') + } else if (callbackStatus === 'error') { + setError(message ?? 'Authorization failed') + setFlowState('error') + } + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to start OAuth flow') + setFlowState('error') + } + } + + const handleDisconnect = () => { + 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 + + 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..afb222501 --- /dev/null +++ b/cli/src/utils/codex-oauth.ts @@ -0,0 +1,470 @@ +/** + * 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_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 +const OAUTH_CALLBACK_PORT = 1455 + +/** + * 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

+

${errorMessage}

+
+

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

+
+
+ +` +} + +// 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 and state during the OAuth flow +let pendingCodeVerifier: string | null = null +let pendingState: 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 and state for later use + pendingCodeVerifier = codeVerifier + pendingState = state + + // 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) => { + 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) + } + }) + + callbackServer.listen(OAUTH_CALLBACK_PORT, async () => { + onStatusChange?.('waiting') + try { + await open(authUrl) + } catch { + // Browser open failed, but server is running - user can manually open the URL + } + }) + }) +} + +/** + * Open the browser to start OAuth flow (legacy - for manual code entry). + * @deprecated Use startOAuthFlowWithCallback instead for automatic callback handling. + */ +export async function openOAuthInBrowser(): Promise { + const { authUrl, codeVerifier } = startOAuthFlow() + await open(authUrl) + return codeVerifier +} + +/** + * 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 come with a state parameter + const code = authorizationCode.trim().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('https://auth.openai.com/oauth/token', { + 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 and state + pendingCodeVerifier = null + pendingState = 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..4781c6998 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: 'paste authorization code here...', + 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..712ff5bd6 --- /dev/null +++ b/common/src/constants/codex-oauth.ts @@ -0,0 +1,152 @@ +/** + * 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' + +// OpenAI API endpoint for direct calls (standard API, not ChatGPT backend) +export const OPENAI_API_BASE_URL = 'https://api.openai.com' + +// 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. + * Matches models in the CODEX_MODEL_MAP or with openai/ prefix. + */ +export function isOpenAIModel(model: string): boolean { + // Check if it's in the model map + if (CODEX_MODEL_MAP[model]) { + return true + } + // Check if it has openai/ prefix + if (model.startsWith('openai/')) { + return true + } + // Check if it's a known Codex model + const modelId = model.startsWith('openai/') ? model.slice(7) : model + return (CODEX_OAUTH_MODELS as readonly string[]).includes(modelId) +} + +/** + * Normalize a model ID to the Codex API format. + * Uses the model map for known models, with fallback pattern matching. + */ +export function toCodexModelId(model: string): string { + // Check the mapping table first + const mapped = CODEX_MODEL_MAP[model] + if (mapped) { + return mapped + } + + // Strip provider prefix if present + const modelId = model.includes('/') ? model.split('/').pop()! : model + + // Check again without prefix + const mappedWithoutPrefix = CODEX_MODEL_MAP[modelId] + if (mappedWithoutPrefix) { + return mappedWithoutPrefix + } + + // Pattern-based fallback for unknown models + const normalized = modelId.toLowerCase() + + // GPT-5.2 Codex + if (normalized.includes('gpt-5.2-codex') || normalized.includes('gpt 5.2 codex')) { + return 'gpt-5.2-codex' + } + // GPT-5.2 + if (normalized.includes('gpt-5.2') || normalized.includes('gpt 5.2')) { + return 'gpt-5.2' + } + // GPT-5.1 Codex Max + if (normalized.includes('gpt-5.1-codex-max') || normalized.includes('codex-max')) { + return 'gpt-5.1-codex-max' + } + // GPT-5.1 Codex Mini + if (normalized.includes('gpt-5.1-codex-mini') || normalized.includes('codex-mini')) { + return 'gpt-5.1-codex-mini' + } + // GPT-5.1 Codex + if (normalized.includes('gpt-5.1-codex') || normalized.includes('gpt 5.1 codex')) { + return 'gpt-5.1-codex' + } + // GPT-5.1 + if (normalized.includes('gpt-5.1') || normalized.includes('gpt 5.1')) { + return 'gpt-5.1' + } + // Any codex model defaults to gpt-5.1-codex + if (normalized.includes('codex')) { + return 'gpt-5.1-codex' + } + // GPT-5 family defaults to gpt-5.1 + if (normalized.includes('gpt-5') || normalized.includes('gpt 5')) { + return 'gpt-5.1' + } + + // Default fallback + return 'gpt-5.1' +} + +/** + * @deprecated Use toCodexModelId instead + */ +export function toOpenAIModelId(openrouterModel: string): string { + return toCodexModelId(openrouterModel) +} 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..b47259de0 --- /dev/null +++ b/sdk/src/__tests__/codex-message-transform.test.ts @@ -0,0 +1,970 @@ +import { describe, expect, test } from 'bun:test' + +import { + transformMessagesToCodexInput, + transformCodexEventToOpenAI, + extractAccountIdFromToken, + type ChatMessage, + type ToolCallState, + type ImageUrlContentPart, +} from '../impl/model-provider' + +/** + * 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 = (): ToolCallState => ({ + currentToolCallId: null, + currentToolCallIndex: 0, + }) + + 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..3cd33fa81 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 } 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,13 +23,24 @@ 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) => { @@ -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. @@ -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)) +} + +/** + * 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( + 'https://auth.openai.com/oauth/token', + { + 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/llm.ts b/sdk/src/impl/llm.ts index 4b74c1613..3dedd7d8b 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 ?? {} diff --git a/sdk/src/impl/model-provider.ts b/sdk/src/impl/model-provider.ts index 71e33ca49..0f2f755a7 100644 --- a/sdk/src/impl/model-provider.ts +++ b/sdk/src/impl/model-provider.ts @@ -16,13 +16,18 @@ 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 type { LanguageModel } from 'ai' @@ -68,6 +73,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 +185,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 +197,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 +213,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 +234,30 @@ export async function getModelForRequest(params: ModelRequestParams): Promise + tool_calls?: Array<{ + id: string + type: string + function: { + name: string + arguments: string + } + }> + tool_call_id?: string + name?: string +} + +/** + * 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.map(p => (p as TextContentPart).text).filter(Boolean).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 +} + +/** + * State for tracking tool calls during streaming (per-request) + */ +export interface ToolCallState { + currentToolCallId: string | null + currentToolCallIndex: number +} + +/** + * 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 { + // 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: 'gpt-5.2', + 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: 'gpt-5.2', + 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: 'gpt-5.2', + 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 || 'gpt-5.2', + 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 +} + +/** + * Create a custom fetch function that transforms OpenAI chat format to Codex format. + * The ChatGPT Codex backend expects a different request body format than the standard OpenAI API. + */ +function createCodexFetch( + oauthToken: string, + accountId: string, + modelId: string, +): typeof globalThis.fetch { + const customFetch = async ( + input: RequestInfo | URL, + init?: RequestInit, + ): 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) + + // Transform from OpenAI chat format to Codex format + const codexBody: Record = { + model: modelId, + // Transform messages to Codex input format + input: transformMessagesToCodexInput(originalBody.messages || []), + // Codex-specific required fields + store: false, // ChatGPT backend REQUIRES store=false + stream: true, // Always stream + // Reasoning configuration + reasoning: { + effort: 'medium', + summary: 'auto', + }, + // Text verbosity + text: { + verbosity: 'medium', + }, + // Include reasoning in response + 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, + // Preserve any additional properties + ...(fn.strict !== undefined && { strict: fn.strict }), + } + } + // Already in Codex format or unknown format, pass through + return tool + }) + } + + // Extract system message for instructions (required by Codex API) + const systemMessage = (originalBody.messages || []).find( + (m: { role: string }) => m.role === 'system', + ) + if (systemMessage) { + codexBody.instructions = + typeof systemMessage.content === 'string' + ? systemMessage.content + : systemMessage.content + ?.map((p: { text?: string }) => p.text) + .filter(Boolean) + .join('\n') || 'You are a helpful assistant.' + // Remove system message from input (it's now in instructions) + codexBody.input = transformMessagesToCodexInput( + (originalBody.messages || []).filter( + (m: { role: string }) => m.role !== 'system', + ), + ) + } else { + // Codex API REQUIRES instructions field - provide default if no system message + codexBody.instructions = 'You are a helpful assistant.' + } + + 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', + 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, + } + + 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() + if (!trimmed) continue + + // Parse the SSE data line + const data = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed + 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) continue + + const data = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed + 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) + + // 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' From d95b16dde67bbe6cce30e1186cafabc7b34f2089 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 26 Jan 2026 19:11:56 -0800 Subject: [PATCH 2/4] Reviewer fixes --- cli/src/utils/codex-oauth.ts | 3 +- common/src/constants/codex-oauth.ts | 80 ++++++------------- .../__tests__/codex-message-transform.test.ts | 3 +- sdk/src/credentials.ts | 6 +- sdk/src/impl/llm.ts | 2 + sdk/src/impl/model-provider.ts | 16 +++- 6 files changed, 45 insertions(+), 65 deletions(-) diff --git a/cli/src/utils/codex-oauth.ts b/cli/src/utils/codex-oauth.ts index afb222501..e46019681 100644 --- a/cli/src/utils/codex-oauth.ts +++ b/cli/src/utils/codex-oauth.ts @@ -355,7 +355,8 @@ export function startOAuthFlowWithCallback( } }) - callbackServer.listen(OAUTH_CALLBACK_PORT, async () => { + // 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) diff --git a/common/src/constants/codex-oauth.ts b/common/src/constants/codex-oauth.ts index 712ff5bd6..fb40b8efe 100644 --- a/common/src/constants/codex-oauth.ts +++ b/common/src/constants/codex-oauth.ts @@ -68,85 +68,53 @@ export const CODEX_MODEL_MAP: Record = { /** * Check if a model is an OpenAI model that can use Codex OAuth. - * Matches models in the CODEX_MODEL_MAP or with openai/ prefix. + * 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 { - // Check if it's in the model map + // 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 if it has openai/ prefix - if (model.startsWith('openai/')) { - return true + // Check without provider prefix + if (model.includes('/')) { + const modelId = model.split('/').pop()! + if (CODEX_MODEL_MAP[modelId]) { + return true + } } - // Check if it's a known Codex model - const modelId = model.startsWith('openai/') ? model.slice(7) : model - return (CODEX_OAUTH_MODELS as readonly string[]).includes(modelId) + return false } /** * Normalize a model ID to the Codex API format. - * Uses the model map for known models, with fallback pattern matching. + * 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 { +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 - const modelId = model.includes('/') ? model.split('/').pop()! : model - - // Check again without prefix - const mappedWithoutPrefix = CODEX_MODEL_MAP[modelId] - if (mappedWithoutPrefix) { - return mappedWithoutPrefix - } - - // Pattern-based fallback for unknown models - const normalized = modelId.toLowerCase() - - // GPT-5.2 Codex - if (normalized.includes('gpt-5.2-codex') || normalized.includes('gpt 5.2 codex')) { - return 'gpt-5.2-codex' - } - // GPT-5.2 - if (normalized.includes('gpt-5.2') || normalized.includes('gpt 5.2')) { - return 'gpt-5.2' - } - // GPT-5.1 Codex Max - if (normalized.includes('gpt-5.1-codex-max') || normalized.includes('codex-max')) { - return 'gpt-5.1-codex-max' - } - // GPT-5.1 Codex Mini - if (normalized.includes('gpt-5.1-codex-mini') || normalized.includes('codex-mini')) { - return 'gpt-5.1-codex-mini' - } - // GPT-5.1 Codex - if (normalized.includes('gpt-5.1-codex') || normalized.includes('gpt 5.1 codex')) { - return 'gpt-5.1-codex' - } - // GPT-5.1 - if (normalized.includes('gpt-5.1') || normalized.includes('gpt 5.1')) { - return 'gpt-5.1' - } - // Any codex model defaults to gpt-5.1-codex - if (normalized.includes('codex')) { - return 'gpt-5.1-codex' - } - // GPT-5 family defaults to gpt-5.1 - if (normalized.includes('gpt-5') || normalized.includes('gpt 5')) { - return 'gpt-5.1' + // 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 + } } - // Default fallback - return 'gpt-5.1' + // Unknown model - return null to signal fallback to Codebuff backend + return null } /** * @deprecated Use toCodexModelId instead */ -export function toOpenAIModelId(openrouterModel: string): string { +export function toOpenAIModelId(openrouterModel: string): string | null { return toCodexModelId(openrouterModel) } diff --git a/sdk/src/__tests__/codex-message-transform.test.ts b/sdk/src/__tests__/codex-message-transform.test.ts index b47259de0..cdb378c22 100644 --- a/sdk/src/__tests__/codex-message-transform.test.ts +++ b/sdk/src/__tests__/codex-message-transform.test.ts @@ -653,9 +653,10 @@ describe('transformMessagesToCodexInput', () => { // ============================================================================ describe('transformCodexEventToOpenAI', () => { - const createToolCallState = (): ToolCallState => ({ + const createToolCallState = (modelId = 'gpt-5.1'): ToolCallState => ({ currentToolCallId: null, currentToolCallIndex: 0, + modelId, }) describe('text delta events', () => { diff --git a/sdk/src/credentials.ts b/sdk/src/credentials.ts index 3cd33fa81..d0054818a 100644 --- a/sdk/src/credentials.ts +++ b/sdk/src/credentials.ts @@ -45,7 +45,7 @@ const credentialsFileSchema = z.object({ const ensureDirectoryExistsSync = (dir: string) => { if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) } } @@ -180,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 }) } /** @@ -391,7 +391,7 @@ export const saveCodexOAuthCredentials = ( codexOAuth: credentials, } - fs.writeFileSync(credentialsPath, JSON.stringify(updatedData, null, 2)) + fs.writeFileSync(credentialsPath, JSON.stringify(updatedData, null, 2), { mode: 0o600 }) } /** diff --git a/sdk/src/impl/llm.ts b/sdk/src/impl/llm.ts index 3dedd7d8b..c99fbbd79 100644 --- a/sdk/src/impl/llm.ts +++ b/sdk/src/impl/llm.ts @@ -749,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) @@ -806,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 0f2f755a7..0b479541d 100644 --- a/sdk/src/impl/model-provider.ts +++ b/sdk/src/impl/model-provider.ts @@ -551,6 +551,7 @@ export function transformMessagesToCodexInput( export interface ToolCallState { currentToolCallId: string | null currentToolCallIndex: number + modelId: string } /** @@ -566,13 +567,15 @@ 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: 'gpt-5.2', + model: modelId, choices: [ { index: 0, @@ -595,7 +598,7 @@ export function transformCodexEventToOpenAI( id: 'chatcmpl-codex', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: 'gpt-5.2', + model: modelId, choices: [ { index: 0, @@ -626,7 +629,7 @@ export function transformCodexEventToOpenAI( id: 'chatcmpl-codex', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: 'gpt-5.2', + model: modelId, choices: [ { index: 0, @@ -669,7 +672,7 @@ export function transformCodexEventToOpenAI( id: response?.id || 'chatcmpl-codex', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: response?.model || 'gpt-5.2', + model: (response?.model as string) || modelId, choices: [ { index: 0, @@ -808,6 +811,7 @@ function createCodexFetch( const toolCallState: ToolCallState = { currentToolCallId: null, currentToolCallIndex: 0, + modelId, } const transformStream = new TransformStream({ @@ -887,6 +891,10 @@ function createCodexOAuthModel( ): 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 From 6ce6c0b6cd9f7dbef7e3d92fa1ed430fa625bbb4 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 26 Jan 2026 21:45:21 -0800 Subject: [PATCH 3/4] Reviewer fixes 2 --- cli/src/components/codex-connect-banner.tsx | 21 +- cli/src/utils/codex-oauth.ts | 27 +- cli/src/utils/input-modes.ts | 2 +- .../__tests__/codex-message-transform.test.ts | 2 +- sdk/src/impl/codex-transform.ts | 328 ++++++++++++++++++ sdk/src/impl/model-provider.ts | 328 +----------------- 6 files changed, 377 insertions(+), 331 deletions(-) create mode 100644 sdk/src/impl/codex-transform.ts diff --git a/cli/src/components/codex-connect-banner.tsx b/cli/src/components/codex-connect-banner.tsx index daa00883c..ef9254b9b 100644 --- a/cli/src/components/codex-connect-banner.tsx +++ b/cli/src/components/codex-connect-banner.tsx @@ -24,6 +24,7 @@ export const CodexConnectBanner = () => { 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) @@ -41,6 +42,8 @@ export const CodexConnectBanner = () => { } else if (callbackStatus === 'error') { setError(message ?? 'Authorization failed') setFlowState('error') + } else if (callbackStatus === 'waiting' && message) { + setManualUrl(message) } }).catch((err) => { setError(err instanceof Error ? err.message : 'Failed to start OAuth flow') @@ -57,12 +60,15 @@ export const CodexConnectBanner = () => { const handleConnect = async () => { try { setFlowState('waiting-for-code') + setManualUrl(null) await startOAuthFlowWithCallback((callbackStatus, message) => { if (callbackStatus === 'success') { setFlowState('connected') } else if (callbackStatus === 'error') { setError(message ?? 'Authorization failed') setFlowState('error') + } else if (callbackStatus === 'waiting' && message) { + setManualUrl(message) } }) } catch (err) { @@ -128,10 +134,17 @@ export const CodexConnectBanner = () => { Waiting for authorization - - Sign in with your OpenAI account in the browser. The authorization - will complete automatically. - + {manualUrl ? ( + + Could not open browser. Open this URL manually:{' '} + {manualUrl} + + ) : ( + + Sign in with your OpenAI account in the browser. The authorization + will complete automatically. + + )} ) diff --git a/cli/src/utils/codex-oauth.ts b/cli/src/utils/codex-oauth.ts index e46019681..7bc5e956f 100644 --- a/cli/src/utils/codex-oauth.ts +++ b/cli/src/utils/codex-oauth.ts @@ -188,7 +188,7 @@ function getErrorPage(errorMessage: string): string {

Authorization Failed

-

${errorMessage}

+

${escapeHtml(errorMessage)}

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

@@ -197,6 +197,15 @@ function getErrorPage(errorMessage: string): string { ` } +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 @@ -361,7 +370,8 @@ export function startOAuthFlowWithCallback( try { await open(authUrl) } catch { - // Browser open failed, but server is running - user can manually open the URL + // Browser open failed - surface the URL so the user can open it manually + onStatusChange?.('waiting', authUrl) } }) }) @@ -391,8 +401,17 @@ export async function exchangeCodeForTokens( ) } - // The authorization code might come with a state parameter - const code = authorizationCode.trim().split('#')[0] + // 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 diff --git a/cli/src/utils/input-modes.ts b/cli/src/utils/input-modes.ts index 4781c6998..d3f255b7c 100644 --- a/cli/src/utils/input-modes.ts +++ b/cli/src/utils/input-modes.ts @@ -110,7 +110,7 @@ export const INPUT_MODE_CONFIGS: Record = { 'connect:codex': { icon: '๐Ÿ”—', color: 'info', - placeholder: 'paste authorization code here...', + placeholder: 'waiting for browser authorization...', widthAdjustment: 3, // emoji width + padding showAgentModeToggle: false, disableSlashSuggestions: true, diff --git a/sdk/src/__tests__/codex-message-transform.test.ts b/sdk/src/__tests__/codex-message-transform.test.ts index cdb378c22..7c223177a 100644 --- a/sdk/src/__tests__/codex-message-transform.test.ts +++ b/sdk/src/__tests__/codex-message-transform.test.ts @@ -7,7 +7,7 @@ import { type ChatMessage, type ToolCallState, type ImageUrlContentPart, -} from '../impl/model-provider' +} from '../impl/codex-transform' /** * Unit tests for Codex OAuth message transformation functions. diff --git a/sdk/src/impl/codex-transform.ts b/sdk/src/impl/codex-transform.ts new file mode 100644 index 000000000..bb216c8c9 --- /dev/null +++ b/sdk/src/impl/codex-transform.ts @@ -0,0 +1,328 @@ +/** + * 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.map(p => (p as TextContentPart).text).filter(Boolean).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/model-provider.ts b/sdk/src/impl/model-provider.ts index 0b479541d..60687100b 100644 --- a/sdk/src/impl/model-provider.ts +++ b/sdk/src/impl/model-provider.ts @@ -30,7 +30,14 @@ import { WEBSITE_URL } from '../constants' 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 @@ -368,327 +375,6 @@ function createAnthropicOAuthModel( return anthropic(anthropicModelId) as unknown as LanguageModel } -/** - * 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 - } -} - -/** - * 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 -} - -/** - * 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.map(p => (p as TextContentPart).text).filter(Boolean).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 -} - -/** - * State for tracking tool calls during streaming (per-request) - */ -export interface ToolCallState { - currentToolCallId: string | null - currentToolCallIndex: number - modelId: string -} - -/** - * 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 -} - /** * Create a custom fetch function that transforms OpenAI chat format to Codex format. * The ChatGPT Codex backend expects a different request body format than the standard OpenAI API. From d0fea22bfa82f1266a0dfbe575c4fefe1bbd6c4e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 26 Jan 2026 22:23:49 -0800 Subject: [PATCH 4/4] review 3 --- cli/src/components/codex-connect-banner.tsx | 59 +++++++----------- cli/src/utils/codex-oauth.ts | 27 +++------ common/src/constants/codex-oauth.ts | 9 --- sdk/src/credentials.ts | 4 +- sdk/src/impl/codex-transform.ts | 5 +- sdk/src/impl/model-provider.ts | 67 +++++++++------------ 6 files changed, 67 insertions(+), 104 deletions(-) diff --git a/cli/src/components/codex-connect-banner.tsx b/cli/src/components/codex-connect-banner.tsx index ef9254b9b..397fdec3b 100644 --- a/cli/src/components/codex-connect-banner.tsx +++ b/cli/src/components/codex-connect-banner.tsx @@ -28,27 +28,33 @@ export const CodexConnectBanner = () => { 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 { - // Automatically start OAuth flow when not connected - setFlowState('waiting-for-code') - startOAuthFlowWithCallback((callbackStatus, message) => { - if (callbackStatus === 'success') { - setFlowState('connected') - } else if (callbackStatus === 'error') { - setError(message ?? 'Authorization failed') - setFlowState('error') - } else if (callbackStatus === 'waiting' && message) { - setManualUrl(message) - } - }).catch((err) => { - setError(err instanceof Error ? err.message : 'Failed to start OAuth flow') - setFlowState('error') - }) + startFlow() } // Cleanup: stop the callback server when the component unmounts @@ -57,27 +63,8 @@ export const CodexConnectBanner = () => { } }, []) - const handleConnect = async () => { - try { - setFlowState('waiting-for-code') - setManualUrl(null) - await startOAuthFlowWithCallback((callbackStatus, message) => { - if (callbackStatus === 'success') { - setFlowState('connected') - } else if (callbackStatus === 'error') { - setError(message ?? 'Authorization failed') - setFlowState('error') - } else if (callbackStatus === 'waiting' && message) { - setManualUrl(message) - } - }) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to start OAuth flow') - setFlowState('error') - } - } - const handleDisconnect = () => { + stopCallbackServer() disconnectCodexOAuth() setFlowState('not-connected') } @@ -159,7 +146,7 @@ export const CodexConnectBanner = () => { Use your ChatGPT Plus/Pro subscription ยท