diff --git a/packages/@ant/model-provider/src/index.ts b/packages/@ant/model-provider/src/index.ts index 6f2b1a56ce..4eed64c9ce 100644 --- a/packages/@ant/model-provider/src/index.ts +++ b/packages/@ant/model-provider/src/index.ts @@ -25,6 +25,7 @@ export * from './types/index.js' // Provider model mappings export { resolveOpenAIModel } from './providers/openai/modelMapping.js' export { resolveGrokModel } from './providers/grok/modelMapping.js' +export { resolveCodexModel } from './providers/codex/modelMapping.js' export { resolveGeminiModel } from './providers/gemini/modelMapping.js' // Gemini provider utilities diff --git a/packages/@ant/model-provider/src/providers/codex/__tests__/modelMapping.test.ts b/packages/@ant/model-provider/src/providers/codex/__tests__/modelMapping.test.ts new file mode 100644 index 0000000000..63d30d9b26 --- /dev/null +++ b/packages/@ant/model-provider/src/providers/codex/__tests__/modelMapping.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { resolveCodexModel } from '../modelMapping.js' + +describe('resolveCodexModel', () => { + const savedCodexModel = process.env.CODEX_MODEL + + afterEach(() => { + if (savedCodexModel === undefined) { + delete process.env.CODEX_MODEL + } else { + process.env.CODEX_MODEL = savedCodexModel + } + }) + + test('uses CODEX_MODEL as the default for family aliases', () => { + process.env.CODEX_MODEL = 'deepseek-v4-pro[1m]' + + expect(resolveCodexModel('sonnet')).toBe('deepseek-v4-pro[1m]') + }) + + test('does not let CODEX_MODEL override an explicit model selection', () => { + process.env.CODEX_MODEL = 'deepseek-v4-pro[1m]' + + expect(resolveCodexModel('gpt-5.5')).toBe('gpt-5.5') + }) +}) diff --git a/packages/@ant/model-provider/src/providers/codex/modelMapping.ts b/packages/@ant/model-provider/src/providers/codex/modelMapping.ts new file mode 100644 index 0000000000..aa33fed39f --- /dev/null +++ b/packages/@ant/model-provider/src/providers/codex/modelMapping.ts @@ -0,0 +1,69 @@ +/** + * Default mapping from Anthropic model names to Codex model names. + * + * Users can override per-family via CODEX_DEFAULT_{FAMILY}_MODEL env vars. + * CODEX_MODEL is a default model used only when the caller did not select a + * concrete model. + */ +const DEFAULT_MODEL_MAP: Record = { + 'claude-sonnet-4-20250514': 'claude-sonnet-4-20250514', + 'claude-sonnet-4-5-20250929': 'claude-sonnet-4-5-20250929', + 'claude-sonnet-4-6': 'claude-sonnet-4-6', + 'claude-opus-4-20250514': 'claude-opus-4-20250514', + 'claude-opus-4-1-20250805': 'claude-opus-4-1-20250805', + 'claude-opus-4-5-20251101': 'claude-opus-4-5-20251101', + 'claude-opus-4-6': 'claude-opus-4-6', + 'claude-opus-4-7': 'claude-opus-4-7', + 'claude-haiku-4-5-20251001': 'claude-haiku-4-5-20251001', + 'claude-3-5-haiku-20241022': 'claude-3-5-haiku-20241022', + 'claude-3-7-sonnet-20250219': 'claude-3-7-sonnet-20250219', + 'claude-3-5-sonnet-20241022': 'claude-3-5-sonnet-20241022', +} + +const DEFAULT_FAMILY_MAP: Record = { + opus: 'claude-opus-4-7', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', +} + +function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null { + if (/haiku/i.test(model)) return 'haiku' + if (/opus/i.test(model)) return 'opus' + if (/sonnet/i.test(model)) return 'sonnet' + return null +} + +/** + * Resolve the Codex model name for a given Anthropic model. + */ +export function resolveCodexModel(anthropicModel: string): string { + if ( + process.env.CODEX_MODEL && + ['sonnet', 'opus', 'haiku'].includes(anthropicModel) + ) { + return process.env.CODEX_MODEL + } + + const cleanModel = anthropicModel.replace(/\[1m\]$/, '') + const family = getModelFamily(cleanModel) + + if (family) { + const codexEnvVar = `CODEX_DEFAULT_${family.toUpperCase()}_MODEL` + const codexOverride = process.env[codexEnvVar] + if (codexOverride) return codexOverride + + const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL` + const anthropicOverride = process.env[anthropicEnvVar] + if (anthropicOverride) return anthropicOverride + } + + if (DEFAULT_MODEL_MAP[cleanModel]) { + return DEFAULT_MODEL_MAP[cleanModel] + } + + if (family && DEFAULT_FAMILY_MAP[family]) { + return DEFAULT_FAMILY_MAP[family] + } + + return cleanModel +} diff --git a/src/commands/logout/logout.tsx b/src/commands/logout/logout.tsx index b5b604de0d..9ea1f21b61 100644 --- a/src/commands/logout/logout.tsx +++ b/src/commands/logout/logout.tsx @@ -50,15 +50,19 @@ export async function performLogout({ clearOnboarding = false }): Promise function clearChatGPTSettingsAuthMode(): void { delete process.env.OPENAI_AUTH_MODE; + delete process.env.CODEX_AUTH_MODE; const userSettings = getSettingsForSource('userSettings') ?? {}; const env = userSettings.env ?? {}; const hasOpenAICompatibleConfig = Boolean(env.OPENAI_API_KEY ?? process.env.OPENAI_API_KEY) && Boolean(env.OPENAI_BASE_URL ?? process.env.OPENAI_BASE_URL); + const hasCodexCompatibleConfig = Boolean(env.CODEX_API_KEY ?? process.env.CODEX_API_KEY); const settingsUpdate: Parameters[1] = { ...(userSettings.modelType === 'openai' && !hasOpenAICompatibleConfig ? { modelType: undefined } : {}), + ...(userSettings.modelType === 'codex' && !hasCodexCompatibleConfig ? { modelType: undefined } : {}), env: { OPENAI_AUTH_MODE: undefined, + CODEX_AUTH_MODE: undefined, } as unknown as Record, }; updateSettingsForSource('userSettings', settingsUpdate); diff --git a/src/commands/provider.ts b/src/commands/provider.ts index d471bd104f..54c5c80c33 100644 --- a/src/commands/provider.ts +++ b/src/commands/provider.ts @@ -17,6 +17,8 @@ function getEnvVarForProvider(provider: string): string { return 'CLAUDE_CODE_USE_GEMINI' case 'grok': return 'CLAUDE_CODE_USE_GROK' + case 'codex': + return 'CLAUDE_CODE_USE_CODEX' default: throw new Error(`Unknown provider: ${provider}`) } @@ -55,6 +57,7 @@ const call: LocalCommandCall = async (args, _context) => { delete process.env.CLAUDE_CODE_USE_OPENAI delete process.env.CLAUDE_CODE_USE_GEMINI delete process.env.CLAUDE_CODE_USE_GROK + delete process.env.CLAUDE_CODE_USE_CODEX return { type: 'text', value: 'API provider cleared (will use environment variables).', @@ -67,6 +70,7 @@ const call: LocalCommandCall = async (args, _context) => { 'openai', 'gemini', 'grok', + 'codex', 'bedrock', 'vertex', 'foundry', @@ -109,6 +113,20 @@ const call: LocalCommandCall = async (args, _context) => { } } + // Check env vars when switching to codex (including settings.env) + if (arg === 'codex') { + const mergedEnv = getMergedEnv() + const hasKey = !!mergedEnv.CODEX_API_KEY + const hasChatGPTAuth = mergedEnv.CODEX_AUTH_MODE === 'chatgpt' + if (!hasKey && !hasChatGPTAuth) { + updateSettingsForSource('userSettings', { modelType: 'codex' }) + return { + type: 'text', + value: `Switched to Codex provider.\nWarning: Missing env var: CODEX_API_KEY\nConfigure it via /login or set manually.`, + } + } + } + // Check env vars when switching to gemini (including settings.env) if (arg === 'gemini') { const mergedEnv = getMergedEnv() @@ -130,7 +148,8 @@ const call: LocalCommandCall = async (args, _context) => { arg === 'anthropic' || arg === 'openai' || arg === 'gemini' || - arg === 'grok' + arg === 'grok' || + arg === 'codex' ) { // Clear any cloud provider env vars to avoid conflicts delete process.env.CLAUDE_CODE_USE_BEDROCK @@ -139,6 +158,7 @@ const call: LocalCommandCall = async (args, _context) => { delete process.env.CLAUDE_CODE_USE_OPENAI delete process.env.CLAUDE_CODE_USE_GEMINI delete process.env.CLAUDE_CODE_USE_GROK + delete process.env.CLAUDE_CODE_USE_CODEX // Update settings.json updateSettingsForSource('userSettings', { modelType: arg }) // Ensure settings.env gets applied to process.env @@ -151,6 +171,7 @@ const call: LocalCommandCall = async (args, _context) => { delete process.env.OPENAI_BASE_URL delete process.env.CLAUDE_CODE_USE_GEMINI delete process.env.CLAUDE_CODE_USE_GROK + delete process.env.CLAUDE_CODE_USE_CODEX process.env[getEnvVarForProvider(arg)] = '1' // Do not modify settings.json - cloud providers controlled solely by env vars applyConfigEnvironmentVariables() @@ -165,9 +186,10 @@ const provider = { type: 'local', name: 'provider', description: - 'Switch API provider (anthropic/openai/gemini/grok/bedrock/vertex/foundry)', + 'Switch API provider (anthropic/openai/gemini/grok/codex/bedrock/vertex/foundry)', aliases: ['api'], - argumentHint: '[anthropic|openai|gemini|grok|bedrock|vertex|foundry|unset]', + argumentHint: + '[anthropic|openai|gemini|grok|codex|bedrock|vertex|foundry|unset]', supportsNonInteractive: true, load: () => Promise.resolve({ call }), } satisfies Command diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index 084cdf2d05..65828f1be7 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -51,6 +51,13 @@ type OAuthStatus = opusModel: string; activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; } // OpenAI Chat Completions API platform + | { + state: 'codex_api'; + baseUrl: string; + apiKey: string; + model: string; + activeField: 'base_url' | 'api_key' | 'model'; + } // Codex Responses API platform | { state: 'chatgpt_subscription'; phase: 'requesting' | 'waiting'; @@ -383,6 +390,7 @@ export function ConsoleOAuthFlow({ setOAuthStatus={setOAuthStatus} setLoginWithClaudeAi={setLoginWithClaudeAi} onDone={onDone} + currentModelType={settings.modelType} /> @@ -404,6 +412,7 @@ type OAuthStatusMessageProps = { handleSubmitCode: (value: string, url: string) => void; setOAuthStatus: (status: OAuthStatus) => void; setLoginWithClaudeAi: (value: boolean) => void; + currentModelType?: string; }; function OAuthStatusMessage({ @@ -421,6 +430,7 @@ function OAuthStatusMessage({ setOAuthStatus, setLoginWithClaudeAi, onDone, + currentModelType, }: OAuthStatusMessageProps): React.ReactNode { switch (oauthStatus.state) { case 'idle': @@ -455,6 +465,15 @@ function OAuthStatusMessage({ ), value: 'openai_chat_api', }, + { + label: ( + + Codex Responses API · Endpoints that accept /v1/responses + {'\n'} + + ), + value: 'codex_api', + }, { label: ( @@ -534,6 +553,15 @@ function OAuthStatusMessage({ opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '', activeField: 'base_url', }); + } else if (value === 'codex_api') { + logEvent('tengu_codex_api_selected', {}); + setOAuthStatus({ + state: 'codex_api', + baseUrl: process.env.CODEX_RESPONSES_URL ?? process.env.CODEX_BASE_URL ?? '', + apiKey: process.env.CODEX_API_KEY ?? '', + model: process.env.CODEX_MODEL ?? '', + activeField: 'base_url', + }); } else if (value === 'chatgpt_subscription') { logEvent('tengu_chatgpt_subscription_selected', {}); setOAuthStatus({ @@ -777,6 +805,205 @@ function OAuthStatusMessage({ ); } + case 'codex_api': { + type CodexField = 'base_url' | 'api_key' | 'model'; + const CODEX_FIELDS: CodexField[] = ['base_url', 'api_key', 'model']; + const codex = oauthStatus as { + state: 'codex_api'; + activeField: CodexField; + baseUrl: string; + apiKey: string; + model: string; + }; + const { activeField, baseUrl, apiKey, model } = codex; + const codexDisplayValues: Record = { + base_url: baseUrl, + api_key: apiKey, + model, + }; + + const [codexInputValue, setCodexInputValue] = useState(() => codexDisplayValues[activeField]); + const [codexInputCursorOffset, setCodexInputCursorOffset] = useState( + () => codexDisplayValues[activeField].length, + ); + + const buildCodexState = useCallback( + (field: CodexField, value: string, newActive?: CodexField) => { + const s = { + state: 'codex_api' as const, + activeField: newActive ?? activeField, + baseUrl, + apiKey, + model, + }; + switch (field) { + case 'base_url': + return { ...s, baseUrl: value }; + case 'api_key': + return { ...s, apiKey: value }; + case 'model': + return { ...s, model: value }; + } + }, + [activeField, baseUrl, apiKey, model], + ); + + const doCodexSave = useCallback(() => { + const finalVals = { ...codexDisplayValues, [activeField]: codexInputValue }; + const env: Record = { + CODEX_AUTH_MODE: undefined, + CODEX_BASE_URL: undefined, + CODEX_RESPONSES_URL: undefined, + }; + + if (finalVals.base_url) { + try { + new URL(finalVals.base_url); + } catch { + setOAuthStatus({ + state: 'error', + message: 'Invalid base URL: please enter a full URL including protocol (e.g., https://api.example.com)', + toRetry: { + state: 'codex_api', + baseUrl: '', + apiKey: finalVals.api_key ?? '', + model: finalVals.model ?? '', + activeField: 'base_url', + }, + }); + return; + } + if (/\/responses(?:\/compact)?\/?$/.test(finalVals.base_url)) { + env.CODEX_RESPONSES_URL = finalVals.base_url; + } else { + env.CODEX_BASE_URL = finalVals.base_url; + } + } + + if (finalVals.api_key) env.CODEX_API_KEY = finalVals.api_key; + if (finalVals.model) env.CODEX_MODEL = finalVals.model; + + const settingsUpdate: Parameters[1] = { + modelType: 'codex', + env: env as unknown as Record, + }; + const { error } = updateSettingsForSource('userSettings', settingsUpdate); + if (error) { + setOAuthStatus({ + state: 'error', + message: 'Failed to save settings. Please try again.', + toRetry: { + state: 'codex_api', + baseUrl: finalVals.base_url ?? '', + apiKey: finalVals.api_key ?? '', + model: finalVals.model ?? '', + activeField: 'base_url', + }, + }); + } else { + for (const [k, v] of Object.entries(env)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + setOAuthStatus({ state: 'success' }); + void onDone(); + } + }, [activeField, codexInputValue, codexDisplayValues, setOAuthStatus, onDone]); + + const handleCodexEnter = useCallback(() => { + const idx = CODEX_FIELDS.indexOf(activeField); + if (idx === CODEX_FIELDS.length - 1) { + setOAuthStatus(buildCodexState(activeField, codexInputValue)); + doCodexSave(); + } else { + const next = CODEX_FIELDS[idx + 1]!; + setOAuthStatus(buildCodexState(activeField, codexInputValue, next)); + setCodexInputValue(codexDisplayValues[next] ?? ''); + setCodexInputCursorOffset((codexDisplayValues[next] ?? '').length); + } + }, [activeField, codexInputValue, buildCodexState, doCodexSave, codexDisplayValues, setOAuthStatus]); + + useKeybinding( + 'tabs:next', + () => { + const idx = CODEX_FIELDS.indexOf(activeField); + if (idx < CODEX_FIELDS.length - 1) { + const next = CODEX_FIELDS[idx + 1]!; + setOAuthStatus(buildCodexState(activeField, codexInputValue, next)); + setCodexInputValue(codexDisplayValues[next] ?? ''); + setCodexInputCursorOffset((codexDisplayValues[next] ?? '').length); + } + }, + { context: 'FormField' }, + ); + useKeybinding( + 'tabs:previous', + () => { + const idx = CODEX_FIELDS.indexOf(activeField); + if (idx > 0) { + const previous = CODEX_FIELDS[idx - 1]!; + setOAuthStatus(buildCodexState(activeField, codexInputValue, previous)); + setCodexInputValue(codexDisplayValues[previous] ?? ''); + setCodexInputCursorOffset((codexDisplayValues[previous] ?? '').length); + } + }, + { context: 'FormField' }, + ); + useKeybinding( + 'confirm:no', + () => { + setOAuthStatus({ state: 'idle' }); + }, + { context: 'Confirmation' }, + ); + + const columns = useTerminalSize().columns - 20; + + const renderCodexRow = (field: CodexField, label: string, opts?: { mask?: boolean }) => { + const active = activeField === field; + const val = codexDisplayValues[field]; + return ( + + + {` ${label} `} + + + {active ? ( + + ) : val ? ( + + {opts?.mask ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) : val} + + ) : null} + + ); + }; + + return ( + + Codex Responses API Setup + + {renderCodexRow('base_url', 'Base URL')} + {renderCodexRow('api_key', 'API Key ', { mask: true })} + {renderCodexRow('model', 'Model ')} + + Base URL may be /v1 or /v1/responses · Enter on Model to save · Esc to go back + + ); + } + case 'openai_chat_api': { type OpenAIField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; const OPENAI_FIELDS: OpenAIField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; @@ -1012,18 +1239,31 @@ function OAuthStatusMessage({ void openBrowser(deviceCode.verificationUrl); await completeChatGPTDeviceLogin(deviceCode, controller.signal); if (cancelled) return; - const env: Record = { - OPENAI_AUTH_MODE: 'chatgpt', - }; + const useCodexProvider = currentModelType === 'codex'; + const env: Record = useCodexProvider + ? { + CODEX_AUTH_MODE: 'chatgpt', + OPENAI_AUTH_MODE: undefined, + } + : { + OPENAI_AUTH_MODE: 'chatgpt', + CODEX_AUTH_MODE: undefined, + }; const settingsUpdate: Parameters[1] = { - modelType: 'openai', - env, + modelType: useCodexProvider ? 'codex' : 'openai', + env: env as unknown as Record, }; const { error } = updateSettingsForSource('userSettings', settingsUpdate); if (error) { throw new Error('Failed to save settings. Please try again.'); } - for (const [k, v] of Object.entries(env)) process.env[k] = v; + for (const [k, v] of Object.entries(env)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } setOAuthStatus({ state: 'success' }); void onDone(); } catch (err) { @@ -1043,7 +1283,7 @@ function OAuthStatusMessage({ cancelled = true; controller.abort(); }; - }, [setOAuthStatus, onDone]); + }, [setOAuthStatus, onDone, currentModelType]); return ( diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 1d4b4435df..d16ef699ea 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -1371,6 +1371,18 @@ async function* queryModel( return } + if (getAPIProvider() === 'codex') { + const { queryModelCodex } = await import('./codex/index.js') + yield* queryModelCodex( + messagesForAPI, + systemPrompt, + filteredTools, + signal, + options, + ) + return + } + // Instrumentation: Track message count after normalization logEvent('tengu_api_after_normalize', { postNormalizedMessageCount: messagesForAPI.length, diff --git a/src/services/api/codex/__tests__/client.test.ts b/src/services/api/codex/__tests__/client.test.ts new file mode 100644 index 0000000000..98fb36d10e --- /dev/null +++ b/src/services/api/codex/__tests__/client.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import type { ResponsesRequest } from '../../openai/responsesAdapter.js' +import { createCodexResponsesStream } from '../client.js' + +function restoreCodexEnv(env: { + CODEX_API_KEY?: string + CODEX_BASE_URL?: string + CODEX_RESPONSES_URL?: string + CODEX_AUTH_MODE?: string + CLAUDE_CONFIG_DIR?: string +}): void { + if (env.CODEX_API_KEY === undefined) { + delete process.env.CODEX_API_KEY + } else { + process.env.CODEX_API_KEY = env.CODEX_API_KEY + } + if (env.CODEX_BASE_URL === undefined) { + delete process.env.CODEX_BASE_URL + } else { + process.env.CODEX_BASE_URL = env.CODEX_BASE_URL + } + if (env.CODEX_RESPONSES_URL === undefined) { + delete process.env.CODEX_RESPONSES_URL + } else { + process.env.CODEX_RESPONSES_URL = env.CODEX_RESPONSES_URL + } + if (env.CODEX_AUTH_MODE === undefined) { + delete process.env.CODEX_AUTH_MODE + } else { + process.env.CODEX_AUTH_MODE = env.CODEX_AUTH_MODE + } + if (env.CLAUDE_CONFIG_DIR === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = env.CLAUDE_CONFIG_DIR + } +} + +const request: ResponsesRequest = { + model: 'gpt-5.5', + stream: true, + store: false, + input: [{ role: 'user', content: 'hello' }], +} + +function fakeJwt(payload: Record): string { + const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url') + return `header.${encoded}.signature` +} + +describe('createCodexResponsesStream', () => { + test('posts a native Responses payload to /v1/responses', async () => { + const env = { + CODEX_API_KEY: process.env.CODEX_API_KEY, + CODEX_BASE_URL: process.env.CODEX_BASE_URL, + CODEX_RESPONSES_URL: process.env.CODEX_RESPONSES_URL, + CODEX_AUTH_MODE: process.env.CODEX_AUTH_MODE, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + } + let url = '' + let body = '' + let authorization = '' + try { + process.env.CODEX_API_KEY = 'test-key' + process.env.CODEX_BASE_URL = 'https://example.test/v1' + delete process.env.CODEX_RESPONSES_URL + delete process.env.CODEX_AUTH_MODE + + await createCodexResponsesStream({ + request, + signal: new AbortController().signal, + fetchOverride: (async ( + input: Parameters[0], + init: Parameters[1], + ) => { + url = String(input) + body = String(init?.body) + authorization = + (init?.headers as Record | undefined) + ?.Authorization ?? '' + return new Response('data: [DONE]\n\n', { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) + }) as unknown as typeof fetch, + }) + + const payload = JSON.parse(body) as Record + expect(url).toBe('https://example.test/v1/responses') + expect(payload.input).toEqual([{ role: 'user', content: 'hello' }]) + expect('messages' in payload).toBe(false) + expect(authorization).toBe('Bearer test-key') + } finally { + restoreCodexEnv(env) + } + }) + + test('does not append /responses twice', async () => { + const env = { + CODEX_API_KEY: process.env.CODEX_API_KEY, + CODEX_BASE_URL: process.env.CODEX_BASE_URL, + CODEX_RESPONSES_URL: process.env.CODEX_RESPONSES_URL, + CODEX_AUTH_MODE: process.env.CODEX_AUTH_MODE, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + } + let url = '' + try { + delete process.env.CODEX_API_KEY + process.env.CODEX_BASE_URL = 'https://example.test/v1/responses' + delete process.env.CODEX_RESPONSES_URL + delete process.env.CODEX_AUTH_MODE + + await createCodexResponsesStream({ + request, + signal: new AbortController().signal, + fetchOverride: (async (input: Parameters[0]) => { + url = String(input) + return new Response('data: [DONE]\n\n', { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) + }) as unknown as typeof fetch, + }) + + expect(url).toBe('https://example.test/v1/responses') + } finally { + restoreCodexEnv(env) + } + }) + + test('uses ChatGPT login auth when CODEX_AUTH_MODE=chatgpt', async () => { + const env = { + CODEX_API_KEY: process.env.CODEX_API_KEY, + CODEX_BASE_URL: process.env.CODEX_BASE_URL, + CODEX_RESPONSES_URL: process.env.CODEX_RESPONSES_URL, + CODEX_AUTH_MODE: process.env.CODEX_AUTH_MODE, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + } + const configDir = await mkdtemp(join(tmpdir(), 'codex-auth-test-')) + let url = '' + let authorization = '' + let accountId = '' + try { + process.env.CLAUDE_CONFIG_DIR = configDir + process.env.CODEX_AUTH_MODE = 'chatgpt' + delete process.env.CODEX_API_KEY + const accessToken = fakeJwt({ + exp: Math.floor(Date.now() / 1000) + 3600, + 'https://api.openai.com/auth': { chatgpt_account_id: 'acct_123' }, + }) + await writeFile( + join(configDir, 'openai-chatgpt-auth.json'), + JSON.stringify({ + auth_mode: 'chatgpt', + tokens: { + id_token: fakeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 }), + access_token: accessToken, + refresh_token: 'refresh-token', + }, + }), + ) + + await createCodexResponsesStream({ + request, + signal: new AbortController().signal, + fetchOverride: (async ( + input: Parameters[0], + init: Parameters[1], + ) => { + url = String(input) + authorization = + (init?.headers as Record | undefined) + ?.Authorization ?? '' + accountId = + (init?.headers as Record | undefined)?.[ + 'ChatGPT-Account-Id' + ] ?? '' + return new Response('data: [DONE]\n\n', { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) + }) as unknown as typeof fetch, + }) + + expect(url).toBe('https://chatgpt.com/backend-api/codex/responses') + expect(authorization).toBe(`Bearer ${accessToken}`) + expect(accountId).toBe('acct_123') + } finally { + restoreCodexEnv(env) + await rm(configDir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/services/api/codex/client.ts b/src/services/api/codex/client.ts new file mode 100644 index 0000000000..4470a82023 --- /dev/null +++ b/src/services/api/codex/client.ts @@ -0,0 +1,74 @@ +import { getProxyFetchOptions } from 'src/utils/proxy.js' +import type { ResponsesRequest } from '../openai/responsesAdapter.js' +import { + createChatGPTResponsesStream, + parseSSE, +} from '../openai/responsesAdapter.js' + +/** + * Codex client — sends native Responses API requests. + * + * Environment variables: + * + * CODEX_API_KEY: Required. API key for the Codex endpoint. + * CODEX_BASE_URL: Optional. Base URL (e.g. http://localhost:11434/v1). + * Defaults to https://api.openai.com/v1. + * CODEX_RESPONSES_URL: Optional. Full Responses API URL. Takes precedence + * over CODEX_BASE_URL. + */ + +const DEFAULT_BASE_URL = 'https://api.openai.com/v1' + +function getCodexResponsesUrl(): string { + if (process.env.CODEX_RESPONSES_URL) { + return process.env.CODEX_RESPONSES_URL + } + + const url = new URL(process.env.CODEX_BASE_URL || DEFAULT_BASE_URL) + const pathname = url.pathname.replace(/\/+$/, '') + if ( + pathname.endsWith('/responses') || + pathname.endsWith('/responses/compact') + ) { + return url.toString() + } + url.pathname = `${pathname}/responses` + return url.toString() +} + +export async function createCodexResponsesStream(params: { + request: ResponsesRequest + signal: AbortSignal + fetchOverride?: typeof fetch +}): Promise>> { + if (process.env.CODEX_AUTH_MODE === 'chatgpt') { + return createChatGPTResponsesStream(params) + } + + const apiKey = process.env.CODEX_API_KEY || '' + const fetchFn = params.fetchOverride ?? (globalThis.fetch as typeof fetch) + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + 'OpenAI-Beta': 'responses=experimental', + originator: 'claude-code-best', + } + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}` + } + + const response = await fetchFn(getCodexResponsesUrl(), { + ...(getProxyFetchOptions({ forAnthropicAPI: false }) as RequestInit), + method: 'POST', + headers, + body: JSON.stringify(params.request), + signal: params.signal, + }) + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error( + `Codex Responses API request failed (${response.status})${text ? `: ${text.slice(0, 500)}` : ''}`, + ) + } + return parseSSE(response) +} diff --git a/src/services/api/codex/index.ts b/src/services/api/codex/index.ts new file mode 100644 index 0000000000..0bbe89864d --- /dev/null +++ b/src/services/api/codex/index.ts @@ -0,0 +1,230 @@ +import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import type { SystemPrompt } from '../../../utils/systemPromptType.js' +import type { + Message, + StreamEvent, + SystemAPIErrorMessage, + AssistantMessage, +} from '../../../types/message.js' +import type { Tools } from '../../../Tool.js' +import { createCodexResponsesStream } from './client.js' +import { updateOpenAIUsage } from '../openai/openaiShared.js' +import { + adaptResponsesStreamToAnthropic, + buildResponsesRequest, +} from '../openai/responsesAdapter.js' +import { + anthropicMessagesToOpenAI, + anthropicToolsToOpenAI, + anthropicToolChoiceToOpenAI, + resolveCodexModel, +} from '@ant/model-provider' +import { normalizeMessagesForAPI } from '../../../utils/messages.js' +import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js' +import { toolToAPISchema } from '../../../utils/api.js' +import { logForDebugging } from '../../../utils/debug.js' +import { addToTotalSessionCost } from '../../../cost-tracker.js' +import { calculateUSDCost } from '../../../utils/modelCost.js' +import { recordLLMObservation } from '../../../services/langfuse/tracing.js' +import { + convertMessagesToLangfuse, + convertOutputToLangfuse, + convertToolsToLangfuse, +} from '../../../services/langfuse/convert.js' +import type { Options } from '../claude.js' +import { randomUUID } from 'crypto' +import { + createAssistantAPIErrorMessage, + normalizeContentFromAPI, +} from '../../../utils/messages.js' + +/** + * Codex query path. Codex-compatible endpoints accept the Responses API + * contract, so this path converts Claude messages into an OpenAI-shaped + * intermediate form, then builds a native /v1/responses payload. + */ +export async function* queryModelCodex( + messages: Message[], + systemPrompt: SystemPrompt, + tools: Tools, + signal: AbortSignal, + options: Options, +): AsyncGenerator< + StreamEvent | AssistantMessage | SystemAPIErrorMessage, + void +> { + try { + const codexModel = resolveCodexModel(options.model) + const messagesForAPI = normalizeMessagesForAPI(messages, tools) + + const toolSchemas = await Promise.all( + tools.map(tool => + toolToAPISchema(tool, { + getToolPermissionContext: options.getToolPermissionContext, + tools, + agents: options.agents, + allowedAgentTypes: options.allowedAgentTypes, + model: options.model, + }), + ), + ) + const standardTools = toolSchemas.filter( + (t): t is BetaToolUnion & { type: string } => { + const anyT = t as unknown as Record + return ( + anyT.type !== 'advisor_20260301' && anyT.type !== 'computer_20250124' + ) + }, + ) + + const openaiMessages = anthropicMessagesToOpenAI( + messagesForAPI, + systemPrompt, + ) + const openaiTools = anthropicToolsToOpenAI(standardTools) + const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice) + + logForDebugging( + `[Codex] Calling model=${codexModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}`, + ) + + const adaptedStream = adaptResponsesStreamToAnthropic( + await createCodexResponsesStream({ + request: buildResponsesRequest({ + model: codexModel, + messages: openaiMessages, + tools: openaiTools, + toolChoice: openaiToolChoice, + }), + signal, + fetchOverride: options.fetchOverride as typeof fetch | undefined, + }), + codexModel, + ) + + const contentBlocks: Record = {} + const collectedMessages: AssistantMessage[] = [] + let partialMessage: any + let usage = { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + } + let ttftMs = 0 + const start = Date.now() + + for await (const event of adaptedStream) { + switch (event.type) { + case 'message_start': { + partialMessage = (event as any).message + ttftMs = Date.now() - start + if ((event as any).message?.usage) { + usage = updateOpenAIUsage(usage, (event as any).message.usage) + } + break + } + case 'content_block_start': { + const idx = (event as any).index + const cb = (event as any).content_block + if (cb.type === 'tool_use') { + contentBlocks[idx] = { ...cb, input: '' } + } else if (cb.type === 'text') { + contentBlocks[idx] = { ...cb, text: '' } + } else if (cb.type === 'thinking') { + contentBlocks[idx] = { ...cb, thinking: '', signature: '' } + } else { + contentBlocks[idx] = { ...cb } + } + break + } + case 'content_block_delta': { + const idx = (event as any).index + const delta = (event as any).delta + const block = contentBlocks[idx] + if (!block) break + if (delta.type === 'text_delta') { + block.text = (block.text || '') + delta.text + } else if (delta.type === 'input_json_delta') { + block.input = (block.input || '') + delta.partial_json + } else if (delta.type === 'thinking_delta') { + block.thinking = (block.thinking || '') + delta.thinking + } else if (delta.type === 'signature_delta') { + block.signature = delta.signature + } + break + } + case 'content_block_stop': { + const idx = (event as any).index + const block = contentBlocks[idx] + if (!block || !partialMessage) break + + const m: AssistantMessage = { + message: { + ...partialMessage, + content: normalizeContentFromAPI([block], tools, options.agentId), + }, + requestId: undefined, + type: 'assistant', + uuid: randomUUID(), + timestamp: new Date().toISOString(), + } + collectedMessages.push(m) + yield m + break + } + case 'message_delta': { + const deltaUsage = (event as any).usage + if (deltaUsage) { + usage = updateOpenAIUsage(usage, deltaUsage) + } + break + } + case 'message_stop': + break + } + + if ( + event.type === 'message_stop' && + usage.input_tokens + usage.output_tokens > 0 + ) { + const costUSD = calculateUSDCost(codexModel, usage as any) + addToTotalSessionCost(costUSD, usage as any, options.model) + } + + yield { + type: 'stream_event', + event, + ...(event.type === 'message_start' ? { ttftMs } : undefined), + } as StreamEvent + } + + // Record LLM observation in Langfuse (no-op if not configured) + recordLLMObservation(options.langfuseTrace ?? null, { + model: codexModel, + provider: 'codex', + input: convertMessagesToLangfuse(messagesForAPI, systemPrompt), + output: convertOutputToLangfuse(collectedMessages), + usage: { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cache_creation_input_tokens: usage.cache_creation_input_tokens, + cache_read_input_tokens: usage.cache_read_input_tokens, + }, + startTime: new Date(start), + endTime: new Date(), + completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined, + tools: convertToolsToLangfuse(toolSchemas as unknown[]), + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logForDebugging(`[Codex] Error: ${errorMessage}`, { level: 'error' }) + yield createAssistantAPIErrorMessage({ + content: `API Error: ${errorMessage}`, + apiError: 'api_error', + error: (error instanceof Error + ? error + : new Error(String(error))) as unknown as SDKAssistantMessageError, + }) + } +} diff --git a/src/services/api/openai/responsesAdapter.ts b/src/services/api/openai/responsesAdapter.ts index c074eed5b4..779120312a 100644 --- a/src/services/api/openai/responsesAdapter.ts +++ b/src/services/api/openai/responsesAdapter.ts @@ -6,7 +6,7 @@ type ResponsesInputItem = Record type ResponsesTool = Record export type ResponsesReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' -type ResponsesRequest = { +export type ResponsesRequest = { model: string stream: true store: false @@ -190,10 +190,12 @@ export function buildResponsesRequest(params: { } } -async function* parseSSE( +export async function* parseSSE( response: Response, ): AsyncGenerator, void> { - if (!response.body) throw new Error('ChatGPT response did not include a body') + if (!response.body) { + throw new Error('Responses API response did not include a body') + } const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' diff --git a/src/utils/managedEnvConstants.ts b/src/utils/managedEnvConstants.ts index 2dcb71969a..cc8aec0732 100644 --- a/src/utils/managedEnvConstants.ts +++ b/src/utils/managedEnvConstants.ts @@ -80,6 +80,25 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'CLAUDE_CODE_SUBAGENT_MODEL', 'GEMINI_MODEL', 'GEMINI_SMALL_FAST_MODEL', + // Codex provider specific + 'CODEX_AUTH_MODE', + 'CODEX_API_KEY', + 'CODEX_BASE_URL', + 'CODEX_RESPONSES_URL', + 'CODEX_MODEL', + 'CODEX_DEFAULT_HAIKU_MODEL', + 'CODEX_DEFAULT_HAIKU_MODEL_DESCRIPTION', + 'CODEX_DEFAULT_HAIKU_MODEL_NAME', + 'CODEX_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES', + 'CODEX_DEFAULT_OPUS_MODEL', + 'CODEX_DEFAULT_OPUS_MODEL_DESCRIPTION', + 'CODEX_DEFAULT_OPUS_MODEL_NAME', + 'CODEX_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', + 'CODEX_DEFAULT_SONNET_MODEL', + 'CODEX_DEFAULT_SONNET_MODEL_DESCRIPTION', + 'CODEX_DEFAULT_SONNET_MODEL_NAME', + 'CODEX_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', + 'CODEX_SMALL_FAST_MODEL', // Gemini provider specific - separate from Anthropic/OpenAI 'GEMINI_DEFAULT_HAIKU_MODEL', 'GEMINI_DEFAULT_HAIKU_MODEL_DESCRIPTION', @@ -216,6 +235,19 @@ export const SAFE_ENV_VARS = new Set([ 'GEMINI_DEFAULT_SONNET_MODEL_DESCRIPTION', 'GEMINI_DEFAULT_SONNET_MODEL_NAME', 'GEMINI_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', + // Codex provider specific + 'CODEX_DEFAULT_HAIKU_MODEL', + 'CODEX_DEFAULT_HAIKU_MODEL_DESCRIPTION', + 'CODEX_DEFAULT_HAIKU_MODEL_NAME', + 'CODEX_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES', + 'CODEX_DEFAULT_OPUS_MODEL', + 'CODEX_DEFAULT_OPUS_MODEL_DESCRIPTION', + 'CODEX_DEFAULT_OPUS_MODEL_NAME', + 'CODEX_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', + 'CODEX_DEFAULT_SONNET_MODEL', + 'CODEX_DEFAULT_SONNET_MODEL_DESCRIPTION', + 'CODEX_DEFAULT_SONNET_MODEL_NAME', + 'CODEX_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', 'DISABLE_AUTOUPDATER', 'DISABLE_BUG_COMMAND', 'DISABLE_COST_WARNINGS', diff --git a/src/utils/model/__tests__/codexModelOptions.test.ts b/src/utils/model/__tests__/codexModelOptions.test.ts new file mode 100644 index 0000000000..fc269b9c03 --- /dev/null +++ b/src/utils/model/__tests__/codexModelOptions.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +const { getModelOptions } = await import('../modelOptions.js') +const { getDefaultSonnetModel } = await import('../model.js') + +describe('Codex model options', () => { + const savedEnv = { + CLAUDE_CODE_USE_CODEX: process.env.CLAUDE_CODE_USE_CODEX, + CODEX_MODEL: process.env.CODEX_MODEL, + OPENAI_AUTH_MODE: process.env.OPENAI_AUTH_MODE, + CODEX_AUTH_MODE: process.env.CODEX_AUTH_MODE, + } + + afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + }) + + test('shows GPT Codex models even when CODEX_MODEL sets a custom default', () => { + process.env.CLAUDE_CODE_USE_CODEX = '1' + process.env.CODEX_MODEL = 'deepseek-v4-pro[1m]' + delete process.env.OPENAI_AUTH_MODE + delete process.env.CODEX_AUTH_MODE + + const values = getModelOptions().map(option => option.value) + expect(values).toContain('gpt-5.5') + expect(values).toContain('gpt-5.4') + expect(values).toContain('deepseek-v4-pro[1m]') + }) + + test('uses CODEX_MODEL as the default model when no explicit model is selected', () => { + process.env.CLAUDE_CODE_USE_CODEX = '1' + process.env.CODEX_MODEL = 'deepseek-v4-pro[1m]' + + expect(getDefaultSonnetModel()).toBe('deepseek-v4-pro[1m]') + }) +}) diff --git a/src/utils/model/__tests__/providers.test.ts b/src/utils/model/__tests__/providers.test.ts index 6790a3e6f9..c4edf24325 100644 --- a/src/utils/model/__tests__/providers.test.ts +++ b/src/utils/model/__tests__/providers.test.ts @@ -12,6 +12,7 @@ describe('getAPIProvider', () => { 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_GROK', + 'CLAUDE_CODE_USE_CODEX', ] as const const savedEnv: Record = {} @@ -91,6 +92,15 @@ describe('getAPIProvider', () => { expect(getAPIProvider({})).toBe('bedrock') }) + test('returns "codex" when modelType is codex', () => { + expect(getAPIProvider({ modelType: 'codex' })).toBe('codex') + }) + + test('returns "codex" when CLAUDE_CODE_USE_CODEX is set', () => { + process.env.CLAUDE_CODE_USE_CODEX = '1' + expect(getAPIProvider({})).toBe('codex') + }) + test('"0" is not truthy', () => { process.env.CLAUDE_CODE_USE_BEDROCK = '0' expect(getAPIProvider({})).toBe('firstParty') diff --git a/src/utils/model/chatgptModels.ts b/src/utils/model/chatgptModels.ts index 4521b9ebff..cf46b1d3ee 100644 --- a/src/utils/model/chatgptModels.ts +++ b/src/utils/model/chatgptModels.ts @@ -42,8 +42,13 @@ export const CHATGPT_CODEX_MODEL_OPTIONS: ChatGPTCodexModelOption[] = [ }, ] -export function isChatGPTAuthMode(): boolean { - return process.env.OPENAI_AUTH_MODE === 'chatgpt' +export function isChatGPTAuthMode(provider?: 'openai' | 'codex'): boolean { + if (provider === 'openai') return process.env.OPENAI_AUTH_MODE === 'chatgpt' + if (provider === 'codex') return process.env.CODEX_AUTH_MODE === 'chatgpt' + return ( + process.env.OPENAI_AUTH_MODE === 'chatgpt' || + process.env.CODEX_AUTH_MODE === 'chatgpt' + ) } export function isChatGPTCodexReasoningModel(model: string): boolean { diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index 58d157d9cd..3ed0748604 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -14,6 +14,7 @@ export const CLAUDE_3_7_SONNET_CONFIG = { openai: 'claude-3-7-sonnet-20250219', gemini: 'claude-3-7-sonnet-20250219', grok: 'claude-3-7-sonnet-20250219', + codex: 'claude-3-7-sonnet-20250219', } as const satisfies ModelConfig export const CLAUDE_3_5_V2_SONNET_CONFIG = { @@ -24,6 +25,7 @@ export const CLAUDE_3_5_V2_SONNET_CONFIG = { openai: 'claude-3-5-sonnet-20241022', gemini: 'claude-3-5-sonnet-20241022', grok: 'claude-3-5-sonnet-20241022', + codex: 'claude-3-5-sonnet-20241022', } as const satisfies ModelConfig export const CLAUDE_3_5_HAIKU_CONFIG = { @@ -34,6 +36,7 @@ export const CLAUDE_3_5_HAIKU_CONFIG = { openai: 'claude-3-5-haiku-20241022', gemini: 'claude-3-5-haiku-20241022', grok: 'claude-3-5-haiku-20241022', + codex: 'claude-3-5-haiku-20241022', } as const satisfies ModelConfig export const CLAUDE_HAIKU_4_5_CONFIG = { @@ -44,6 +47,7 @@ export const CLAUDE_HAIKU_4_5_CONFIG = { openai: 'claude-haiku-4-5-20251001', gemini: 'claude-haiku-4-5-20251001', grok: 'claude-haiku-4-5-20251001', + codex: 'claude-haiku-4-5-20251001', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_CONFIG = { @@ -54,6 +58,7 @@ export const CLAUDE_SONNET_4_CONFIG = { openai: 'claude-sonnet-4-20250514', gemini: 'claude-sonnet-4-20250514', grok: 'claude-sonnet-4-20250514', + codex: 'claude-sonnet-4-20250514', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_5_CONFIG = { @@ -64,6 +69,7 @@ export const CLAUDE_SONNET_4_5_CONFIG = { openai: 'claude-sonnet-4-5-20250929', gemini: 'claude-sonnet-4-5-20250929', grok: 'claude-sonnet-4-5-20250929', + codex: 'claude-sonnet-4-5-20250929', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_CONFIG = { @@ -74,6 +80,7 @@ export const CLAUDE_OPUS_4_CONFIG = { openai: 'claude-opus-4-20250514', gemini: 'claude-opus-4-20250514', grok: 'claude-opus-4-20250514', + codex: 'claude-opus-4-20250514', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_1_CONFIG = { @@ -84,6 +91,7 @@ export const CLAUDE_OPUS_4_1_CONFIG = { openai: 'claude-opus-4-1-20250805', gemini: 'claude-opus-4-1-20250805', grok: 'claude-opus-4-1-20250805', + codex: 'claude-opus-4-1-20250805', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_5_CONFIG = { @@ -94,6 +102,7 @@ export const CLAUDE_OPUS_4_5_CONFIG = { openai: 'claude-opus-4-5-20251101', gemini: 'claude-opus-4-5-20251101', grok: 'claude-opus-4-5-20251101', + codex: 'claude-opus-4-5-20251101', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_6_CONFIG = { @@ -104,6 +113,7 @@ export const CLAUDE_OPUS_4_6_CONFIG = { openai: 'claude-opus-4-6', gemini: 'claude-opus-4-6', grok: 'claude-opus-4-6', + codex: 'claude-opus-4-6', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_7_CONFIG = { @@ -114,6 +124,7 @@ export const CLAUDE_OPUS_4_7_CONFIG = { openai: 'claude-opus-4-7', gemini: 'claude-opus-4-7', grok: 'claude-opus-4-7', + codex: 'claude-opus-4-7', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_6_CONFIG = { @@ -124,6 +135,7 @@ export const CLAUDE_SONNET_4_6_CONFIG = { openai: 'claude-sonnet-4-6', gemini: 'claude-sonnet-4-6', grok: 'claude-sonnet-4-6', + codex: 'claude-sonnet-4-6', } as const satisfies ModelConfig // @[MODEL LAUNCH]: Register the new config here. diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index 385212f51b..2916468cf1 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -41,7 +41,10 @@ export type ModelSetting = ModelName | ModelAlias | null export function getSmallFastModel(): ModelName { const provider = getAPIProvider() - if (provider === 'openai' && isChatGPTAuthMode()) { + if (provider === 'codex') { + return process.env.CODEX_SMALL_FAST_MODEL ?? CHATGPT_CODEX_FAST_MODEL + } + if (provider === 'openai' && isChatGPTAuthMode(provider)) { return process.env.OPENAI_SMALL_FAST_MODEL ?? CHATGPT_CODEX_FAST_MODEL } // Provider-specific small fast model @@ -123,7 +126,10 @@ export function getBestModel(): ModelName { // @[MODEL LAUNCH]: Update the default Opus model (3P providers may lag so keep defaults unchanged). export function getDefaultOpusModel(): ModelName { const provider = getAPIProvider() - if (provider === 'openai' && isChatGPTAuthMode()) { + if (provider === 'codex') { + return process.env.CODEX_MODEL ?? CHATGPT_CODEX_DEFAULT_MODEL + } + if (provider === 'openai' && isChatGPTAuthMode(provider)) { return CHATGPT_CODEX_DEFAULT_MODEL } // For OpenAI provider, check OPENAI_DEFAULT_OPUS_MODEL first @@ -151,7 +157,10 @@ export function getDefaultOpusModel(): ModelName { // @[MODEL LAUNCH]: Update the default Sonnet model (3P providers may lag so keep defaults unchanged). export function getDefaultSonnetModel(): ModelName { const provider = getAPIProvider() - if (provider === 'openai' && isChatGPTAuthMode()) { + if (provider === 'codex') { + return process.env.CODEX_MODEL ?? CHATGPT_CODEX_DEFAULT_MODEL + } + if (provider === 'openai' && isChatGPTAuthMode(provider)) { return CHATGPT_CODEX_DEFAULT_MODEL } // For OpenAI provider, check OPENAI_DEFAULT_SONNET_MODEL first @@ -176,7 +185,10 @@ export function getDefaultSonnetModel(): ModelName { // @[MODEL LAUNCH]: Update the default Haiku model (3P providers may lag so keep defaults unchanged). export function getDefaultHaikuModel(): ModelName { const provider = getAPIProvider() - if (provider === 'openai' && isChatGPTAuthMode()) { + if (provider === 'codex') { + return process.env.CODEX_SMALL_FAST_MODEL ?? CHATGPT_CODEX_FAST_MODEL + } + if (provider === 'openai' && isChatGPTAuthMode(provider)) { return CHATGPT_CODEX_FAST_MODEL } // For OpenAI provider, check OPENAI_DEFAULT_HAIKU_MODEL first diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index 48dbee22f3..0fc7cd0c0a 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -88,7 +88,9 @@ function getCustomSonnetOption(): ModelOption | undefined { ? process.env.OPENAI_DEFAULT_SONNET_MODEL : provider === 'gemini' ? process.env.GEMINI_DEFAULT_SONNET_MODEL - : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL + : provider === 'codex' + ? process.env.CODEX_DEFAULT_SONNET_MODEL + : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL // When a 3P user has a custom sonnet model string, show it directly if (is3P && customSonnetModel) { const is1m = has1mContext(customSonnetModel) @@ -98,13 +100,17 @@ function getCustomSonnetOption(): ModelOption | undefined { ? process.env.OPENAI_DEFAULT_SONNET_MODEL_NAME : provider === 'gemini' ? process.env.GEMINI_DEFAULT_SONNET_MODEL_NAME - : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME + : provider === 'codex' + ? process.env.CODEX_DEFAULT_SONNET_MODEL_NAME + : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME const descEnv = provider === 'openai' ? process.env.OPENAI_DEFAULT_SONNET_MODEL_DESCRIPTION : provider === 'gemini' ? process.env.GEMINI_DEFAULT_SONNET_MODEL_DESCRIPTION - : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION + : provider === 'codex' + ? process.env.CODEX_DEFAULT_SONNET_MODEL_DESCRIPTION + : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION return { value: 'sonnet', label: nameEnv ?? customSonnetModel, @@ -137,7 +143,9 @@ function getCustomOpusOption(): ModelOption | undefined { ? process.env.OPENAI_DEFAULT_OPUS_MODEL : provider === 'gemini' ? process.env.GEMINI_DEFAULT_OPUS_MODEL - : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL + : provider === 'codex' + ? process.env.CODEX_DEFAULT_OPUS_MODEL + : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL // When a 3P user has a custom opus model string, show it directly if (is3P && customOpusModel) { const is1m = has1mContext(customOpusModel) @@ -147,13 +155,17 @@ function getCustomOpusOption(): ModelOption | undefined { ? process.env.OPENAI_DEFAULT_OPUS_MODEL_NAME : provider === 'gemini' ? process.env.GEMINI_DEFAULT_OPUS_MODEL_NAME - : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME + : provider === 'codex' + ? process.env.CODEX_DEFAULT_OPUS_MODEL_NAME + : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME const descEnv = provider === 'openai' ? process.env.OPENAI_DEFAULT_OPUS_MODEL_DESCRIPTION : provider === 'gemini' ? process.env.GEMINI_DEFAULT_OPUS_MODEL_DESCRIPTION - : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION + : provider === 'codex' + ? process.env.CODEX_DEFAULT_OPUS_MODEL_DESCRIPTION + : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION return { value: 'opus', label: nameEnv ?? customOpusModel, @@ -228,7 +240,9 @@ function getCustomHaikuOption(): ModelOption | undefined { ? process.env.OPENAI_DEFAULT_HAIKU_MODEL : provider === 'gemini' ? process.env.GEMINI_DEFAULT_HAIKU_MODEL - : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL + : provider === 'codex' + ? process.env.CODEX_DEFAULT_HAIKU_MODEL + : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL // When a 3P user has a custom haiku model string, show it directly if (is3P && customHaikuModel) { // Use appropriate NAME/DESCRIPTION env vars based on provider @@ -237,13 +251,17 @@ function getCustomHaikuOption(): ModelOption | undefined { ? process.env.OPENAI_DEFAULT_HAIKU_MODEL_NAME : provider === 'gemini' ? process.env.GEMINI_DEFAULT_HAIKU_MODEL_NAME - : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME + : provider === 'codex' + ? process.env.CODEX_DEFAULT_HAIKU_MODEL_NAME + : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME const descEnv = provider === 'openai' ? process.env.OPENAI_DEFAULT_HAIKU_MODEL_DESCRIPTION : provider === 'gemini' ? process.env.GEMINI_DEFAULT_HAIKU_MODEL_DESCRIPTION - : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION + : provider === 'codex' + ? process.env.CODEX_DEFAULT_HAIKU_MODEL_DESCRIPTION + : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION return { value: 'haiku', label: nameEnv ?? customHaikuModel, @@ -342,7 +360,7 @@ function getOpusPlanOption(): ModelOption { } function getChatGPTCodexModelOptions(): ModelOption[] { - return [ + const options: ModelOption[] = [ { value: null, label: 'Default (recommended)', @@ -356,6 +374,20 @@ function getChatGPTCodexModelOptions(): ModelOption[] { descriptionForModel: `${model.description} (${model.value})`, })), ] + + if ( + getAPIProvider() === 'codex' && + process.env.CODEX_MODEL && + !options.some(option => option.value === process.env.CODEX_MODEL) + ) { + options.push({ + value: process.env.CODEX_MODEL, + label: process.env.CODEX_MODEL, + description: 'Custom default Codex model', + }) + } + + return options } // @[MODEL LAUNCH]: Update the model picker lists below to include/reorder options for the new model. @@ -379,7 +411,12 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { ] } - if (getAPIProvider() === 'openai' && isChatGPTAuthMode()) { + const provider = getAPIProvider() + if (provider === 'codex') { + return getChatGPTCodexModelOptions() + } + + if (provider === 'openai' && isChatGPTAuthMode(provider)) { return getChatGPTCodexModelOptions() } diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index d4784da844..124dbffec6 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -11,6 +11,7 @@ export type APIProvider = | 'openai' | 'gemini' | 'grok' + | 'codex' export function getAPIProvider( settings: Pick = getInitialSettings(), @@ -19,6 +20,7 @@ export function getAPIProvider( if (modelType === 'openai') return 'openai' if (modelType === 'gemini') return 'gemini' if (modelType === 'grok') return 'grok' + if (modelType === 'codex') return 'codex' if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)) return 'bedrock' if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)) return 'vertex' @@ -27,6 +29,7 @@ export function getAPIProvider( if (isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) return 'openai' if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) return 'gemini' if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GROK)) return 'grok' + if (isEnvTruthy(process.env.CLAUDE_CODE_USE_CODEX)) return 'codex' return 'firstParty' } diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 678eb5c76e..a97812f9cb 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -366,11 +366,11 @@ export const SettingsSchema = lazySchema(() => .optional() .describe('Tool usage permissions configuration'), modelType: z - .enum(['anthropic', 'openai', 'gemini', 'grok']) + .enum(['anthropic', 'openai', 'gemini', 'grok', 'codex']) .optional() .describe( - 'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API, "gemini" uses the Gemini API, and "grok" uses the xAI Grok API (OpenAI-compatible). ' + - 'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL. When set to "gemini", configure GEMINI_API_KEY and optional GEMINI_BASE_URL. When set to "grok", configure GROK_API_KEY (or XAI_API_KEY), optional GROK_BASE_URL, GROK_MODEL, and GROK_MODEL_MAP.', + 'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API, "gemini" uses the Gemini API, "grok" uses the xAI Grok API (OpenAI-compatible), and "codex" uses the Codex v1/responses API. ' + + 'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL. When set to "gemini", configure GEMINI_API_KEY and optional GEMINI_BASE_URL. When set to "grok", configure GROK_API_KEY (or XAI_API_KEY), optional GROK_BASE_URL, GROK_MODEL, and GROK_MODEL_MAP. When set to "codex", configure CODEX_API_KEY, optional CODEX_BASE_URL or CODEX_RESPONSES_URL, and CODEX_MODEL.', ), model: z .string() diff --git a/src/utils/status.tsx b/src/utils/status.tsx index 794206696c..8b2b7a91a3 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -301,6 +301,7 @@ export function buildAPIProviderProperties(): Property[] { gemini: 'Gemini API', grok: 'Grok API', openai: 'OpenAI API', + codex: 'Codex API', }[apiProvider]; properties.push({ label: 'API provider',