Compare commits

..

6 Commits

Author SHA1 Message Date
claude-code-best
1058b7e643 feat: 修复 Codex 模型映射并添加登录后模型配置面板
- configs.ts: 将 codex 字段从 Anthropic 模型名改为实际 OpenAI 模型名
  (opus→gpt-5.4, sonnet→gpt-5.4-mini, haiku→gpt-5.4-mini, opus47→gpt-5.5)
- modelMapping.ts: 移除不存在的 gpt-5.4-nano,修复 haiku 映射,添加 opus47
- ConsoleOAuthFlow.tsx: OAuth 成功后显示模型配置面板,可编辑三种模型名称
- 已登录用户再次选择 Codex 时跳过 OAuth 直接进入模型配置
- Ctrl+R 快捷键清除登录状态并重新 OAuth
- modelOptions.ts: codex provider 支持 CODEX_DEFAULT_*_MODEL 环境变量

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 23:38:56 +08:00
claude-code-best
d091dd8bae feat: 添加 Codex 模型 provider 完整实现
- 新增 codex API 客户端、流适配、消息/工具转换、模型映射
- 支持 CODEX_API_KEY 和 CODEX_ACCESS_TOKEN 双认证 fallback
- 集成到 claude.ts 调度链和 Langfuse 可观测性
- 包含模型映射单元测试(16 cases)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:48:17 +08:00
claude-code-best
4427a6c6db feat: 注册 codex modelType 并添加 /provider codex 切换
- providers.ts: 添加 codex 到 APIProvider 类型和路由
- provider.ts: /provider codex 切换,含 CODEX_API_KEY 检查
- configs.ts: 所有 12 个模型配置添加 codex 字段
- status.tsx: 状态栏显示 Codex API
- managedEnvConstants.ts: 注册 CODEX_* 环境变量

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:23:09 +08:00
claude-code-best
13799b5058 fix: 将 modelType 从 openai-responses 改为 codex 并注册枚举值
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:06:34 +08:00
claude-code-best
cd59a88d44 feat: 集成 ChatGPT OAuth 订阅登录到 /login UI
添加 Codex ChatGPT 菜单项、OAuth 等待界面、手动 code 输入支持。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:58:30 +08:00
claude-code-best
bc4a2f1281 feat: 添加 ChatGPT OAuth 订阅登录流程
基于 OpenAI Codex CLI 官方实现,支持 PKCE 流程和手动 code 输入。
API key 交换为非致命步骤,兼容无 organization 的个人账户。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:49:42 +08:00
26 changed files with 1170 additions and 1520 deletions

View File

@@ -45,7 +45,7 @@ describe('resolveCodexModel', () => {
})
test('maps known haiku model via DEFAULT_MODEL_MAP', () => {
expect(resolveCodexModel('claude-haiku-4-5-20251001')).toBe('gpt-5.4-nano')
expect(resolveCodexModel('claude-haiku-4-5-20251001')).toBe('gpt-5.4-mini')
})
test('maps known opus model via DEFAULT_MODEL_MAP', () => {
@@ -58,7 +58,7 @@ describe('resolveCodexModel', () => {
})
test('maps legacy haiku models', () => {
expect(resolveCodexModel('claude-3-5-haiku-20241022')).toBe('gpt-5.4-nano')
expect(resolveCodexModel('claude-3-5-haiku-20241022')).toBe('gpt-5.4-mini')
})
test('maps legacy opus models', () => {
@@ -67,7 +67,7 @@ describe('resolveCodexModel', () => {
})
test('uses family default for unrecognized haiku model', () => {
expect(resolveCodexModel('claude-haiku-99')).toBe('gpt-5.4-nano')
expect(resolveCodexModel('claude-haiku-99')).toBe('gpt-5.4-mini')
})
test('uses family default for unrecognized sonnet model', () => {

View File

@@ -12,15 +12,16 @@ const DEFAULT_MODEL_MAP: Record<string, string> = {
'claude-opus-4-1-20250805': 'gpt-5.4',
'claude-opus-4-5-20251101': 'gpt-5.4',
'claude-opus-4-6': 'gpt-5.4',
'claude-haiku-4-5-20251001': 'gpt-5.4-nano',
'claude-3-5-haiku-20241022': 'gpt-5.4-nano',
'claude-opus-4-7': 'gpt-5.5',
'claude-haiku-4-5-20251001': 'gpt-5.4-mini',
'claude-3-5-haiku-20241022': 'gpt-5.4-mini',
}
/**
* Default model for each family when an exact match is not in DEFAULT_MODEL_MAP.
*/
const DEFAULT_FAMILY_MAP: Record<string, string> = {
haiku: 'gpt-5.4-nano',
haiku: 'gpt-5.4-mini',
sonnet: 'gpt-5.4-mini',
opus: 'gpt-5.4',
}

View File

@@ -15,8 +15,6 @@ function getEnvVarForProvider(provider: string): string {
return 'CLAUDE_CODE_USE_FOUNDRY'
case 'gemini':
return 'CLAUDE_CODE_USE_GEMINI'
case 'codex':
return 'CLAUDE_CODE_USE_CODEX'
case 'grok':
return 'CLAUDE_CODE_USE_GROK'
default:
@@ -53,7 +51,6 @@ const call: LocalCommandCall = async (args, context) => {
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_CODEX
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GROK
return {
@@ -97,18 +94,6 @@ const call: LocalCommandCall = async (args, context) => {
}
}
if (arg === 'codex') {
const mergedEnv = getMergedEnv()
const hasKey = !!mergedEnv.CODEX_API_KEY
if (!hasKey) {
updateSettingsForSource('userSettings', { modelType: 'codex' })
return {
type: 'text',
value: `Switched to OpenAI Responses provider.\nWarning: Missing env var: CODEX_API_KEY\nConfigure via /login, settings.json env, or set manually.`,
}
}
}
// Check env vars when switching to grok (including settings.env)
if (arg === 'grok') {
const mergedEnv = getMergedEnv()
@@ -136,6 +121,19 @@ 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 || mergedEnv.CODEX_ACCESS_TOKEN)
if (!hasKey) {
updateSettingsForSource('userSettings', { modelType: 'codex' })
return {
type: 'text',
value: `Switched to Codex provider.\nWarning: No CODEX_API_KEY or CODEX_ACCESS_TOKEN found.\nUse /login (ChatGPT Subscription) or set manually.`,
}
}
}
// Handle different provider types
// - 'anthropic', 'openai', 'gemini' are stored in settings.json (persistent)
// - 'bedrock', 'vertex', 'foundry' are env-only (do NOT touch settings.json)
@@ -145,18 +143,13 @@ const call: LocalCommandCall = async (args, context) => {
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_CODEX
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GROK
// Update settings.json
delete process.env.CLAUDE_CODE_USE_CODEX
updateSettingsForSource('userSettings', { modelType: arg })
// Ensure settings.env gets applied to process.env
applyConfigEnvironmentVariables()
const message =
arg === 'codex' && !getMergedEnv().CODEX_IMGBB_API_KEY
? `API provider set to ${arg}.\nOptional: set CODEX_IMGBB_API_KEY to enable local image uploads for image understanding.`
: `API provider set to ${arg}.`
return { type: 'text', value: message }
return { type: 'text', value: `API provider set to ${arg}.` }
} else {
// Cloud providers: set env vars only, do NOT touch settings.json
delete process.env.CLAUDE_CODE_USE_OPENAI

View File

@@ -10,6 +10,7 @@ import { useKeybinding } from '../keybindings/useKeybinding.js'
import { getSSLErrorHint } from '@ant/model-provider'
import { sendNotification } from '../services/notifier.js'
import { OAuthService } from '../services/oauth/index.js'
import { performOpenAICodexLogin, parseManualCodeInput } from '../services/oauth/openai-codex.js'
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'
import { logError } from '../utils/log.js'
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'
@@ -55,14 +56,20 @@ type OAuthStatus =
opusModel: string
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'
} // Gemini Generate Content API platform
| { state: 'codex_oauth_waiting'; url: string } // ChatGPT OAuth browser login in progress
| { state: 'codex_oauth_start' } // Trigger ChatGPT OAuth flow
| {
state: 'codex_responses_api'
baseUrl: string
apiKey: string
model: string
imgbbApiKey: string
activeField: 'base_url' | 'api_key' | 'model' | 'imgbb_api_key'
} // Codex / Responses API platform
state: 'codex_models'
haikuModel: string
sonnetModel: string
opusModel: string
activeField: 'haiku_model' | 'sonnet_model' | 'opus_model'
codexResult: {
apiKey: string | null
accessToken: string
refreshToken: string
}
} // Codex model name configuration after OAuth success
| { state: 'ready_to_start' } // Flow started, waiting for browser to open
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
| { state: 'creating_api_key' } // Got access token, creating API key
@@ -116,6 +123,13 @@ export function ConsoleOAuthFlow({
const [showPastePrompt, setShowPastePrompt] = useState(false)
const [urlCopied, setUrlCopied] = useState(false)
// Codex ChatGPT OAuth states
const [showCodexPastePrompt, setShowCodexPastePrompt] = useState(false)
const [codexUrlCopied, setCodexUrlCopied] = useState(false)
const [codexPastedCode, setCodexPastedCode] = useState('')
const [codexPastedCursor, setCodexPastedCursor] = useState(0)
const codexManualCodeResolveRef = useRef<((code: string) => void) | null>(null)
const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1
// Log forced login method on mount
@@ -194,6 +208,39 @@ export function ConsoleOAuthFlow({
}
}, [pastedCode, oauthStatus, showPastePrompt, urlCopied])
// Codex OAuth: copy URL on 'c'
useEffect(() => {
if (
codexPastedCode === 'c' &&
oauthStatus.state === 'codex_oauth_waiting' &&
showCodexPastePrompt &&
!codexUrlCopied
) {
const url = (oauthStatus as { state: 'codex_oauth_waiting'; url: string }).url
void setClipboard(url).then(raw => {
if (raw) process.stdout.write(raw)
setCodexUrlCopied(true)
setTimeout(setCodexUrlCopied, 2000, false)
})
setCodexPastedCode('')
}
}, [codexPastedCode, oauthStatus, showCodexPastePrompt, codexUrlCopied])
// Codex OAuth: submit pasted code
const handleCodexPasteSubmit = useCallback((value: string) => {
const code = parseManualCodeInput(value)
if (!code) {
setOAuthStatus({
state: 'error',
message: 'Invalid code. Paste the full redirect URL or just the authorization code.',
toRetry: oauthStatus as any,
})
return
}
codexManualCodeResolveRef.current?.(code)
codexManualCodeResolveRef.current = null
}, [oauthStatus])
async function handleSubmitCode(value: string, url: string) {
try {
// Expecting format "authorizationCode#state" from the authorization callback URL
@@ -309,6 +356,52 @@ export function ConsoleOAuthFlow({
}
}, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID])
const startCodexOAuth = useCallback(async () => {
setShowCodexPastePrompt(false)
setCodexUrlCopied(false)
setCodexPastedCode('')
setCodexPastedCursor(0)
let manualCodeResolve: ((code: string) => void) | null = null
const manualCodePromise = new Promise<string>(resolve => {
manualCodeResolve = resolve
})
codexManualCodeResolveRef.current = manualCodeResolve
try {
const result = await performOpenAICodexLogin({
onUrl: url => {
setOAuthStatus({ state: 'codex_oauth_waiting', url })
setTimeout(setShowCodexPastePrompt, 3000, true)
},
manualCode: manualCodePromise,
})
// Transition to model configuration panel with defaults
setOAuthStatus({
state: 'codex_models',
haikuModel: process.env.CODEX_DEFAULT_HAIKU_MODEL || 'gpt-5.4-mini',
sonnetModel: process.env.CODEX_DEFAULT_SONNET_MODEL || 'gpt-5.4-mini',
opusModel: process.env.CODEX_DEFAULT_OPUS_MODEL || 'gpt-5.5',
activeField: 'haiku_model',
codexResult: {
apiKey: result.apiKey,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
},
})
} catch (err) {
logError(err as Error)
setOAuthStatus({
state: 'error',
message: (err as Error).message,
toRetry: { state: 'idle' },
})
} finally {
codexManualCodeResolveRef.current = null
}
}, [onDone])
const pendingOAuthStartRef = useRef(false)
useEffect(() => {
@@ -324,6 +417,19 @@ export function ConsoleOAuthFlow({
}
}, [oauthStatus.state, startOAuth])
const pendingCodexOAuthRef = useRef(false)
useEffect(() => {
if (
oauthStatus.state === 'codex_oauth_start' &&
!pendingCodexOAuthRef.current
) {
pendingCodexOAuthRef.current = true
void startCodexOAuth().finally(() => {
pendingCodexOAuthRef.current = false
})
}
}, [oauthStatus.state, startCodexOAuth])
// Auto-exit for setup-token mode
useEffect(() => {
if (mode === 'setup-token' && oauthStatus.state === 'success') {
@@ -342,6 +448,20 @@ export function ConsoleOAuthFlow({
}
}, [mode, oauthStatus, loginWithClaudeAi, onDone])
// Cancel codex OAuth with Escape
useKeybinding(
'confirm:no',
() => {
setShowCodexPastePrompt(false)
setCodexPastedCode('')
setOAuthStatus({ state: 'idle' })
},
{
context: 'Confirmation',
isActive: oauthStatus.state === 'codex_oauth_waiting',
},
)
// Cleanup OAuth service when component unmounts
useEffect(() => {
return () => {
@@ -407,6 +527,13 @@ export function ConsoleOAuthFlow({
setOAuthStatus={setOAuthStatus}
setLoginWithClaudeAi={setLoginWithClaudeAi}
onDone={onDone}
showCodexPastePrompt={showCodexPastePrompt}
codexUrlCopied={codexUrlCopied}
codexPastedCode={codexPastedCode}
setCodexPastedCode={setCodexPastedCode}
codexPastedCursor={codexPastedCursor}
setCodexPastedCursor={setCodexPastedCursor}
handleCodexPasteSubmit={handleCodexPasteSubmit}
/>
</Box>
</Box>
@@ -428,6 +555,14 @@ type OAuthStatusMessageProps = {
handleSubmitCode: (value: string, url: string) => void
setOAuthStatus: (status: OAuthStatus) => void
setLoginWithClaudeAi: (value: boolean) => void
// Codex ChatGPT OAuth props
showCodexPastePrompt: boolean
codexUrlCopied: boolean
codexPastedCode: string
setCodexPastedCode: (value: string) => void
codexPastedCursor: number
setCodexPastedCursor: (offset: number) => void
handleCodexPasteSubmit: (value: string) => void
}
function OAuthStatusMessage({
@@ -445,6 +580,13 @@ function OAuthStatusMessage({
setOAuthStatus,
setLoginWithClaudeAi,
onDone,
showCodexPastePrompt,
codexUrlCopied,
codexPastedCode,
setCodexPastedCode,
codexPastedCursor,
setCodexPastedCursor,
handleCodexPasteSubmit,
}: OAuthStatusMessageProps): React.ReactNode {
switch (oauthStatus.state) {
case 'idle':
@@ -464,7 +606,7 @@ function OAuthStatusMessage({
{
label: (
<Text>
Anthropic Compatible -{' '}
Anthropic Compatible ·{' '}
<Text dimColor>Configure your own API endpoint</Text>
{'\n'}
</Text>
@@ -474,7 +616,7 @@ function OAuthStatusMessage({
{
label: (
<Text>
OpenAI Compatible -{' '}
OpenAI Compatible ·{' '}
<Text dimColor>
Ollama, DeepSeek, vLLM, One API, etc.
</Text>
@@ -486,17 +628,17 @@ function OAuthStatusMessage({
{
label: (
<Text>
Codex Responses API -{' '}
<Text dimColor>OpenAI Codex via Responses API</Text>
OpenAI Codex (ChatGPT Subscription) -{' '}
<Text dimColor>Login with ChatGPT Plus/Pro</Text>
{'\n'}
</Text>
),
value: 'codex_responses_api',
value: 'codex_chatgpt',
},
{
label: (
<Text>
Gemini API -{' '}
Gemini API ·{' '}
<Text dimColor>Google Gemini native REST/SSE</Text>
{'\n'}
</Text>
@@ -506,7 +648,7 @@ function OAuthStatusMessage({
{
label: (
<Text>
Claude account with subscription -{' '}
Claude account with subscription ·{' '}
<Text dimColor>Pro, Max, Team, or Enterprise</Text>
{process.env.USER_TYPE === 'ant' && (
<Text>
@@ -527,7 +669,7 @@ function OAuthStatusMessage({
{
label: (
<Text>
Anthropic Console account -{' '}
Anthropic Console account ·{' '}
<Text dimColor>API usage billing</Text>
{'\n'}
</Text>
@@ -537,7 +679,7 @@ function OAuthStatusMessage({
{
label: (
<Text>
3rd-party platform -{' '}
3rd-party platform ·{' '}
<Text dimColor>
Amazon Bedrock, Microsoft Foundry, or Vertex AI
</Text>
@@ -570,6 +712,39 @@ function OAuthStatusMessage({
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
activeField: 'base_url',
})
} else if (value === 'codex_chatgpt') {
logEvent('tengu_codex_chatgpt_selected', {})
// Skip OAuth if already authenticated — go straight to model config
const settings = getSettings_DEPRECATED()
const hasToken = !!(
process.env.CODEX_ACCESS_TOKEN ||
settings?.env?.CODEX_ACCESS_TOKEN
)
if (hasToken) {
setOAuthStatus({
state: 'codex_models',
haikuModel:
process.env.CODEX_DEFAULT_HAIKU_MODEL ||
settings?.env?.CODEX_DEFAULT_HAIKU_MODEL ||
'gpt-5.4-mini',
sonnetModel:
process.env.CODEX_DEFAULT_SONNET_MODEL ||
settings?.env?.CODEX_DEFAULT_SONNET_MODEL ||
'gpt-5.4-mini',
opusModel:
process.env.CODEX_DEFAULT_OPUS_MODEL ||
settings?.env?.CODEX_DEFAULT_OPUS_MODEL ||
'gpt-5.5',
activeField: 'haiku_model',
codexResult: {
apiKey: process.env.CODEX_API_KEY || null,
accessToken: process.env.CODEX_ACCESS_TOKEN || '',
refreshToken: process.env.CODEX_REFRESH_TOKEN || '',
},
})
} else {
setOAuthStatus({ state: 'codex_oauth_start' })
}
} else if (value === 'gemini_api') {
logEvent('tengu_gemini_api_selected', {})
setOAuthStatus({
@@ -581,16 +756,6 @@ function OAuthStatusMessage({
opusModel: process.env.GEMINI_DEFAULT_OPUS_MODEL ?? '',
activeField: 'base_url',
})
} else if (value === 'codex_responses_api') {
logEvent('tengu_codex_responses_api_selected', {})
setOAuthStatus({
state: 'codex_responses_api',
baseUrl: process.env.CODEX_BASE_URL ?? '',
apiKey: process.env.CODEX_API_KEY ?? '',
model: process.env.CODEX_MODEL ?? '',
imgbbApiKey: process.env.CODEX_IMGBB_API_KEY ?? '',
activeField: 'base_url',
})
} else if (value === 'platform') {
logEvent('tengu_oauth_platform_selected', {})
setOAuthStatus({ state: 'platform_setup' })
@@ -825,7 +990,7 @@ function OAuthStatusMessage({
{renderRow('opus_model', 'Opus ')}
</Box>
<Text dimColor>
/Tab to switch - Enter on last field to save - Esc to go back
/Tab to switch · Enter on last field to save · Esc to go back
</Text>
</Box>
)
@@ -1064,7 +1229,7 @@ function OAuthStatusMessage({
{renderOpenAIRow('opus_model', 'Opus ')}
</Box>
<Text dimColor>
/Tab to switch - Enter on last field to save - Esc to go back
/Tab to switch · Enter on last field to save · Esc to go back
</Text>
</Box>
)
@@ -1297,259 +1462,288 @@ function OAuthStatusMessage({
{renderGeminiRow('opus_model', 'Opus ')}
</Box>
<Text dimColor>
/Tab to switch - Enter on last field to save - Esc to go back
/Tab to switch · Enter on last field to save · Esc to go back
</Text>
</Box>
)
}
case 'codex_responses_api':
{
type CodexField = 'base_url' | 'api_key' | 'model' | 'imgbb_api_key'
const CODEX_FIELDS: CodexField[] = [
'base_url',
'api_key',
'model',
'imgbb_api_key',
]
const cp = oauthStatus as {
state: 'codex_responses_api'
activeField: CodexField
baseUrl: string
apiKey: string
model: string
imgbbApiKey: string
case 'codex_oauth_waiting': {
const { url } = oauthStatus as { state: 'codex_oauth_waiting'; url: string }
const codexPasteColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1
return (
<Box flexDirection="column" gap={1}>
{!showCodexPastePrompt && (
<Box>
<Spinner />
<Text>Opening browser for ChatGPT login...</Text>
</Box>
)}
{showCodexPastePrompt && (
<Box flexDirection="column" gap={1}>
<Box paddingX={1}>
<Text dimColor>
Browser didn&apos;t open? Use the url below to sign in{' '}
</Text>
{codexUrlCopied ? (
<Text color="success">(Copied!)</Text>
) : (
<Text dimColor>
<KeyboardShortcutHint shortcut="c" action="copy" parens />
</Text>
)}
</Box>
<Link url={url}>
<Text dimColor>{url}</Text>
</Link>
</Box>
)}
{showCodexPastePrompt && (
<Box>
<Text>{PASTE_HERE_MSG}</Text>
<TextInput
value={codexPastedCode}
onChange={setCodexPastedCode}
onSubmit={handleCodexPasteSubmit}
cursorOffset={codexPastedCursor}
onChangeCursorOffset={setCodexPastedCursor}
columns={codexPasteColumns}
mask="*"
/>
</Box>
)}
<Text dimColor>
Press <Text bold>Esc</Text> to cancel
</Text>
</Box>
)
}
case 'codex_models': {
type CodexField = 'haiku_model' | 'sonnet_model' | 'opus_model'
const CODEX_FIELDS: CodexField[] = ['haiku_model', 'sonnet_model', 'opus_model']
const cm = oauthStatus as {
state: 'codex_models'
activeField: CodexField
haikuModel: string
sonnetModel: string
opusModel: string
codexResult: { apiKey: string | null; accessToken: string; refreshToken: string }
}
const { activeField, haikuModel, sonnetModel, opusModel, codexResult } = cm
const codexDisplayValues: Record<CodexField, string> = {
haiku_model: haikuModel,
sonnet_model: sonnetModel,
opus_model: opusModel,
}
const [codexModelInput, setCodexModelInput] = useState(
() => codexDisplayValues[activeField],
)
const [codexModelCursor, setCodexModelCursor] = useState(
() => codexDisplayValues[activeField].length,
)
const buildCodexModelState = useCallback(
(field: CodexField, value: string, newActive?: CodexField) => {
const s = {
state: 'codex_models' as const,
activeField: newActive ?? activeField,
haikuModel,
sonnetModel,
opusModel,
codexResult,
}
switch (field) {
case 'haiku_model':
return { ...s, haikuModel: value }
case 'sonnet_model':
return { ...s, sonnetModel: value }
case 'opus_model':
return { ...s, opusModel: value }
}
},
[activeField, haikuModel, sonnetModel, opusModel, codexResult],
)
const doCodexModelSave = useCallback(() => {
const finalVals = { ...codexDisplayValues, [activeField]: codexModelInput }
const env: Record<string, string | undefined> = {
CODEX_API_KEY: codexResult.apiKey ?? undefined,
CODEX_ACCESS_TOKEN: codexResult.accessToken,
CODEX_REFRESH_TOKEN: codexResult.refreshToken,
CODEX_LOGIN_METHOD: 'chatgpt_subscription',
CODEX_DEFAULT_HAIKU_MODEL: finalVals.haiku_model,
CODEX_DEFAULT_SONNET_MODEL: finalVals.sonnet_model,
CODEX_DEFAULT_OPUS_MODEL: finalVals.opus_model,
}
const { activeField, baseUrl, apiKey, model, imgbbApiKey } = cp
const codexDisplayValues: Record<CodexField, string> = {
base_url: baseUrl,
api_key: apiKey,
model,
imgbb_api_key: imgbbApiKey,
}
const [codexInputValue, setCodexInputValue] = useState(
() => codexDisplayValues[activeField],
)
const [codexInputCursorOffset, setCodexInputCursorOffset] = useState(
() => codexDisplayValues[activeField].length,
)
const buildCodexState = useCallback(
(field: CodexField, value: string, newActive?: CodexField) => {
const state = {
state: 'codex_responses_api' as const,
activeField: newActive ?? activeField,
baseUrl,
apiKey,
model,
imgbbApiKey,
}
switch (field) {
case 'base_url':
return { ...state, baseUrl: value }
case 'api_key':
return { ...state, apiKey: value }
case 'model':
return { ...state, model: value }
case 'imgbb_api_key':
return { ...state, imgbbApiKey: value }
}
},
[activeField, apiKey, baseUrl, imgbbApiKey, model],
)
const doCodexSave = useCallback(() => {
const finalVals = {
...codexDisplayValues,
[activeField]: codexInputValue,
}
if (!finalVals.base_url || !finalVals.api_key || !finalVals.model) {
setOAuthStatus({
state: 'error',
message:
'Codex setup requires CODEX_BASE_URL, CODEX_API_KEY, and CODEX_MODEL.',
toRetry: {
state: 'codex_responses_api',
baseUrl: finalVals.base_url,
apiKey: finalVals.api_key,
model: finalVals.model,
imgbbApiKey: finalVals.imgbb_api_key,
activeField,
},
})
return
}
try {
new URL(finalVals.base_url)
} catch {
setOAuthStatus({
state: 'error',
message:
'Invalid base URL: please enter a full URL including protocol (e.g., https://code.ylsagi.com/codex)',
toRetry: {
state: 'codex_responses_api',
baseUrl: finalVals.base_url,
apiKey: finalVals.api_key,
model: finalVals.model,
imgbbApiKey: finalVals.imgbb_api_key,
activeField: 'base_url',
},
})
return
}
const env: Record<string, string | undefined> = {
CODEX_BASE_URL: finalVals.base_url,
CODEX_API_KEY: finalVals.api_key,
CODEX_MODEL: finalVals.model,
CODEX_IMGBB_API_KEY: finalVals.imgbb_api_key || undefined,
}
const { error } = updateSettingsForSource('userSettings', {
modelType: 'codex' as any,
env,
} as any)
if (error) {
setOAuthStatus({
state: 'error',
message: `Failed to save: ${error.message}`,
toRetry: {
state: 'codex_responses_api',
baseUrl: finalVals.base_url,
apiKey: finalVals.api_key,
model: finalVals.model,
imgbbApiKey: finalVals.imgbb_api_key,
activeField,
},
})
return
}
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
const { error } = updateSettingsForSource('userSettings', {
modelType: 'codex' as any,
env,
} as any)
if (error) {
setOAuthStatus({
state: 'error',
message: 'Failed to save settings. Please try again.',
toRetry: {
state: 'codex_models',
haikuModel: finalVals.haiku_model,
sonnetModel: finalVals.sonnet_model,
opusModel: finalVals.opus_model,
activeField: 'haiku_model',
codexResult,
},
})
} else {
for (const [k, v] of Object.entries(env)) {
if (v !== undefined) {
process.env[k] = v
}
}
setOAuthStatus({ state: 'success' })
void onDone()
}, [activeField, codexDisplayValues, codexInputValue, 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,
buildCodexState,
codexDisplayValues,
codexInputValue,
doCodexSave,
])
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 prev = CODEX_FIELDS[idx - 1]!
setOAuthStatus(buildCodexState(activeField, codexInputValue, prev))
setCodexInputValue(codexDisplayValues[prev] ?? '')
setCodexInputCursorOffset((codexDisplayValues[prev] ?? '').length)
}
},
{ context: 'FormField' },
)
useKeybinding(
'confirm:no',
() => {
setOAuthStatus({ state: 'idle' })
},
{ context: 'Confirmation' },
)
const codexColumns = useTerminalSize().columns - 20
const renderCodexRow = (
field: CodexField,
label: string,
opts?: { mask?: boolean },
) => {
const active = activeField === field
const value = codexDisplayValues[field]
return (
<Box>
<Text
backgroundColor={active ? 'suggestion' : undefined}
color={active ? 'inverseText' : undefined}
>
{` ${label} `}
</Text>
<Text> </Text>
{active ? (
<TextInput
value={codexInputValue}
onChange={setCodexInputValue}
onSubmit={handleCodexEnter}
cursorOffset={codexInputCursorOffset}
onChangeCursorOffset={setCodexInputCursorOffset}
columns={codexColumns}
mask={opts?.mask ? '*' : undefined}
focus={true}
/>
) : value ? (
<Text color="success">
{opts?.mask
? value.slice(0, 8) + '\u00b7'.repeat(Math.max(0, value.length - 8))
: value}
</Text>
) : null}
</Box>
)
}
}, [activeField, codexModelInput, codexDisplayValues, codexResult, setOAuthStatus, onDone])
const handleCodexModelEnter = useCallback(() => {
const idx = CODEX_FIELDS.indexOf(activeField)
if (idx === CODEX_FIELDS.length - 1) {
setOAuthStatus(buildCodexModelState(activeField, codexModelInput))
doCodexModelSave()
} else {
const next = CODEX_FIELDS[idx + 1]!
setOAuthStatus(buildCodexModelState(activeField, codexModelInput, next))
setCodexModelInput(codexDisplayValues[next] ?? '')
setCodexModelCursor((codexDisplayValues[next] ?? '').length)
}
}, [
activeField,
codexModelInput,
buildCodexModelState,
doCodexModelSave,
codexDisplayValues,
setOAuthStatus,
])
useKeybinding(
'tabs:next',
() => {
const idx = CODEX_FIELDS.indexOf(activeField)
if (idx < CODEX_FIELDS.length - 1) {
setOAuthStatus(
buildCodexModelState(activeField, codexModelInput, CODEX_FIELDS[idx + 1]),
)
setCodexModelInput(codexDisplayValues[CODEX_FIELDS[idx + 1]!] ?? '')
setCodexModelCursor((codexDisplayValues[CODEX_FIELDS[idx + 1]!] ?? '').length)
}
},
{ context: 'FormField' },
)
useKeybinding(
'tabs:previous',
() => {
const idx = CODEX_FIELDS.indexOf(activeField)
if (idx > 0) {
setOAuthStatus(
buildCodexModelState(activeField, codexModelInput, CODEX_FIELDS[idx - 1]),
)
setCodexModelInput(codexDisplayValues[CODEX_FIELDS[idx - 1]!] ?? '')
setCodexModelCursor((codexDisplayValues[CODEX_FIELDS[idx - 1]!] ?? '').length)
}
},
{ context: 'FormField' },
)
useKeybinding(
'confirm:no',
() => {
setOAuthStatus({ state: 'idle' })
},
{ context: 'Confirmation' },
)
// Ctrl+D: clear codex login state and re-login
useKeybinding(
'oauth:codex-relogin',
() => {
// Clear codex credentials from process.env
delete process.env.CODEX_ACCESS_TOKEN
delete process.env.CODEX_REFRESH_TOKEN
delete process.env.CODEX_API_KEY
delete process.env.CODEX_LOGIN_METHOD
delete process.env.CODEX_DEFAULT_HAIKU_MODEL
delete process.env.CODEX_DEFAULT_SONNET_MODEL
delete process.env.CODEX_DEFAULT_OPUS_MODEL
// Clear from settings.json
updateSettingsForSource('userSettings', {
modelType: undefined,
env: {
CODEX_ACCESS_TOKEN: undefined,
CODEX_REFRESH_TOKEN: undefined,
CODEX_API_KEY: undefined,
CODEX_LOGIN_METHOD: undefined,
CODEX_DEFAULT_HAIKU_MODEL: undefined,
CODEX_DEFAULT_SONNET_MODEL: undefined,
CODEX_DEFAULT_OPUS_MODEL: undefined,
},
} as any)
// Restart OAuth flow
setOAuthStatus({ state: 'codex_oauth_start' })
},
{ context: 'FormField' },
)
const codexModelColumns = useTerminalSize().columns - 20
const renderCodexModelRow = (
field: CodexField,
label: string,
) => {
const active = activeField === field
const val = codexDisplayValues[field]
return (
<Box flexDirection="column" gap={1}>
<Text bold>Codex Responses API Setup</Text>
<Text dimColor>
Configure a Codex-compatible Responses API endpoint. ImgBB is optional
and enables local image uploads for image understanding.
</Text>
<Box flexDirection="column" gap={1}>
{renderCodexRow('base_url', 'Base URL ')}
{renderCodexRow('api_key', 'API Key ', { mask: true })}
{renderCodexRow('model', 'Model ')}
{renderCodexRow('imgbb_api_key', 'ImgBB Key', { mask: true })}
</Box>
<Text dimColor>
/Tab to switch - Enter on last field to save - Esc to go back
<Box>
<Text
backgroundColor={active ? 'suggestion' : undefined}
color={active ? 'inverseText' : undefined}
>
{` ${label} `}
</Text>
<Text> </Text>
{active ? (
<TextInput
value={codexModelInput}
onChange={setCodexModelInput}
onSubmit={handleCodexModelEnter}
cursorOffset={codexModelCursor}
onChangeCursorOffset={setCodexModelCursor}
columns={codexModelColumns}
focus={true}
/>
) : val ? (
<Text color="success">{val}</Text>
) : null}
</Box>
)
}
return (
<Box flexDirection="column" gap={1}>
<Text bold>Codex Model Configuration</Text>
<Text dimColor>
ChatGPT login successful. Configure model names (press Enter on last field to save).
</Text>
<Box flexDirection="column" gap={1}>
{renderCodexModelRow('haiku_model', 'Haiku ')}
{renderCodexModelRow('sonnet_model', 'Sonnet ')}
{renderCodexModelRow('opus_model', 'Opus ')}
</Box>
<Text dimColor>
/Tab to switch · Enter on last field to save · Ctrl+R to re-login · Esc to go back
</Text>
</Box>
)
}
case 'platform_setup':
return (
<Box flexDirection="column" gap={1} marginTop={1}>
@@ -1570,19 +1764,19 @@ function OAuthStatusMessage({
<Box flexDirection="column" marginTop={1}>
<Text bold>Documentation:</Text>
<Text>
- Amazon Bedrock:{' '}
· Amazon Bedrock:{' '}
<Link url="https://code.claude.com/docs/en/amazon-bedrock">
https://code.claude.com/docs/en/amazon-bedrock
</Link>
</Text>
<Text>
- Microsoft Foundry:{' '}
· Microsoft Foundry:{' '}
<Link url="https://code.claude.com/docs/en/microsoft-foundry">
https://code.claude.com/docs/en/microsoft-foundry
</Link>
</Text>
<Text>
- Vertex AI:{' '}
· Vertex AI:{' '}
<Link url="https://code.claude.com/docs/en/google-vertex-ai">
https://code.claude.com/docs/en/google-vertex-ai
</Link>

View File

@@ -15,7 +15,6 @@ import { normalizeApiKeyForConfig } from '../utils/authPortable.js'
import { getCustomApiKeyStatus } from '../utils/config.js'
import { env } from '../utils/env.js'
import { isRunningOnHomespace } from '../utils/envUtils.js'
import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'
import { PreflightStep } from '../utils/preflightChecks.js'
import type { ThemeSetting } from '../utils/theme.js'
import { ApproveApiKey } from './ApproveApiKey.js'
@@ -75,9 +74,7 @@ export function Onboarding({ onDone }: Props): React.ReactNode {
goToNextStep()
}
const exitState = useExitOnCtrlCDWithKeybindings(() =>
gracefulShutdownSync(0),
)
const exitState = useExitOnCtrlCDWithKeybindings()
// Define all onboarding steps
const themeStep = (

View File

@@ -75,12 +75,9 @@ export function ThemePicker({
},
{ context: 'ThemePicker' },
)
// When onboarding owns exit handling, keep this hook inactive so its
// ThemePicker-scoped keybindings don't swallow the parent Global handler.
// Always call the hook to follow React rules, but conditionally assign the exit handler
const exitState = useExitOnCtrlCDWithKeybindings(
undefined,
undefined,
!skipExitHandling,
skipExitHandling ? () => {} : undefined,
)
const themeOptions: { label: string; value: ThemeSetting }[] = [

View File

@@ -156,6 +156,8 @@ export const DEFAULT_BINDINGS: KeybindingBlock[] = [
'shift+tab': 'tabs:previous',
up: 'tabs:previous',
down: 'tabs:next',
// Re-login: clear codex credentials and restart OAuth
'ctrl+r': 'oauth:codex-relogin',
},
},
{

View File

@@ -109,6 +109,8 @@ export const KEYBINDING_ACTIONS = [
// Tabs navigation actions
'tabs:next',
'tabs:previous',
// OAuth re-login action (codex model config panel)
'oauth:codex-relogin',
// Transcript viewer actions
'transcript:toggleShowAll',
'transcript:exit',

View File

@@ -1,407 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { createAssistantMessage, createUserMessage } from '../../../../utils/messages.js'
import { anthropicMessagesToCodexInput, anthropicToolsToCodex } from '@ant/model-provider'
describe('anthropicMessagesToCodexInput', () => {
test('replays assistant tool calls and user tool results in order', async () => {
const assistant = createAssistantMessage({
content: [
'I will inspect the file.',
{
type: 'tool_use',
id: 'tool_1',
name: 'Read',
input: { file_path: 'README.md' },
},
'Then I will summarize.',
] as any,
})
const user = createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: 'tool_1',
content: [
{ type: 'text', text: 'file contents' },
{ type: 'text', text: 'second line' },
],
},
'Please continue.',
] as any,
})
const items = await anthropicMessagesToCodexInput([assistant, user])
expect(items).toHaveLength(5)
expect(items[0]).toMatchObject({
type: 'message',
role: 'assistant',
})
expect(items[0]).not.toHaveProperty('id')
expect(items[0]).not.toHaveProperty('status')
expect(items[1]).toMatchObject({
type: 'function_call',
call_id: 'tool_1',
name: 'Read',
arguments: '{"file_path":"README.md"}',
})
expect(items[1]).not.toHaveProperty('id')
expect(items[1]).not.toHaveProperty('status')
expect(items[2]).toMatchObject({
type: 'message',
role: 'assistant',
})
expect(items[2]).not.toHaveProperty('id')
expect(items[2]).not.toHaveProperty('status')
expect(items[3]).toMatchObject({
type: 'function_call_output',
call_id: 'tool_1',
output: [
{ type: 'input_text', text: 'file contents' },
{ type: 'input_text', text: 'second line' },
],
})
expect(items[3]).not.toHaveProperty('id')
expect(items[3]).not.toHaveProperty('status')
expect(items[4]).toMatchObject({
type: 'message',
role: 'user',
})
})
test('normalizes tool call ids consistently across assistant replay and tool results', async () => {
const assistant = createAssistantMessage({
content: [
{
type: 'tool_use',
id: ' tool 1 / weird ',
name: 'Read',
input: { file_path: 'README.md' },
},
] as any,
})
const user = createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: ' tool 1 / weird ',
content: 'ok',
},
] as any,
})
const items = await anthropicMessagesToCodexInput([assistant, user])
expect(items[0]).toMatchObject({
type: 'function_call',
call_id: 'tool_1_weird',
})
expect(items[1]).toMatchObject({
type: 'function_call_output',
call_id: 'tool_1_weird',
output: 'ok',
})
})
test('creates a deterministic fallback tool call id when assistant replay is missing one', async () => {
const assistant = createAssistantMessage({
content: [
{
type: 'tool_use',
id: '',
name: 'Read',
input: { file_path: 'README.md' },
},
] as any,
})
const items = await anthropicMessagesToCodexInput([assistant])
expect(items[0]).toMatchObject({
type: 'function_call',
name: 'Read',
arguments: '{"file_path":"README.md"}',
})
expect((items[0] as any).call_id).toMatch(/^call_[a-f0-9]{24}$/)
})
test('degrades unsupported user media blocks to text placeholders', async () => {
const user = createUserMessage({
content: [
{ type: 'text', text: 'Inspect the attachment.' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'abc',
},
},
] as any,
})
const items = await anthropicMessagesToCodexInput([user])
expect(items).toEqual([
{
type: 'message',
role: 'user',
content: [
{
type: 'input_text',
text:
'Inspect the attachment.\n[Image omitted: codex gateway currently requires remote image URLs. Configure CODEX_IMGBB_API_KEY to auto-convert local images.]',
},
],
},
])
})
test('passes through remote image URLs for user messages', async () => {
const user = createUserMessage({
content: [
{ type: 'text', text: 'Read the image.' },
{
type: 'image',
source: {
type: 'url',
url: 'https://example.com/vision.png',
},
},
] as any,
})
const items = await anthropicMessagesToCodexInput([user])
expect(items).toEqual([
{
type: 'message',
role: 'user',
content: [
{
type: 'input_text',
text: 'Read the image.',
},
{
type: 'input_image',
image_url: 'https://example.com/vision.png',
detail: 'high',
},
],
},
])
})
test('converts base64 user images through the configured inline resolver', async () => {
const user = createUserMessage({
content: [
{ type: 'text', text: 'Read the image.' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'abc',
},
},
] as any,
})
const items = await anthropicMessagesToCodexInput([user], {
resolveBase64ImageUrl: async (data, mediaType) =>
data === 'abc' && mediaType === 'image/png'
? 'https://example.com/inline-uploaded.png'
: null,
})
expect(items).toEqual([
{
type: 'message',
role: 'user',
content: [
{
type: 'input_text',
text: 'Read the image.',
},
{
type: 'input_image',
image_url: 'https://example.com/inline-uploaded.png',
detail: 'high',
},
],
},
])
})
test('passes through remote image URLs inside tool results', async () => {
const assistant = createAssistantMessage({
content: [
{
type: 'tool_use',
id: 'tool_vision',
name: 'Read',
input: { file_path: '/tmp/screenshot.png' },
},
] as any,
})
const user = createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: 'tool_vision',
content: [
{ type: 'text', text: 'Screenshot attached.' },
{
type: 'image',
source: {
type: 'url',
url: 'https://example.com/tool-screenshot.png',
},
},
],
},
] as any,
})
const items = await anthropicMessagesToCodexInput([assistant, user])
expect(items[1]).toEqual({
type: 'function_call_output',
call_id: 'tool_vision',
output: [
{ type: 'input_text', text: 'Screenshot attached.' },
{
type: 'input_image',
image_url: 'https://example.com/tool-screenshot.png',
detail: 'high',
},
],
})
})
test('degrades unsupported tool result images to text placeholders', async () => {
const assistant = createAssistantMessage({
content: [
{
type: 'tool_use',
id: 'tool_vision',
name: 'Read',
input: { file_path: '/tmp/screenshot.png' },
},
] as any,
})
const user = createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: 'tool_vision',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'abc',
},
},
],
},
] as any,
})
const items = await anthropicMessagesToCodexInput([assistant, user])
expect(items[1]).toEqual({
type: 'function_call_output',
call_id: 'tool_vision',
output:
'[Image omitted: codex gateway currently requires remote image URLs. Configure CODEX_IMGBB_API_KEY to auto-convert local images.]',
})
})
test('converts base64 tool result images through the configured inline resolver', async () => {
const assistant = createAssistantMessage({
content: [
{
type: 'tool_use',
id: 'tool_vision',
name: 'Read',
input: { file_path: '/tmp/screenshot.png' },
},
] as any,
})
const user = createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: 'tool_vision',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'abc',
},
},
],
},
] as any,
})
const items = await anthropicMessagesToCodexInput([assistant, user], {
resolveBase64ImageUrl: async (data, mediaType) =>
data === 'abc' && mediaType === 'image/png'
? 'https://example.com/tool-inline-uploaded.png'
: null,
})
expect(items[1]).toEqual({
type: 'function_call_output',
call_id: 'tool_vision',
output: [
{
type: 'input_image',
image_url: 'https://example.com/tool-inline-uploaded.png',
detail: 'high',
},
],
})
})
})
describe('anthropicToolsToCodex', () => {
test('converts only client function tools', () => {
const tools = anthropicToolsToCodex([
{
name: 'Read',
description: 'Read a file',
input_schema: {
type: 'object',
properties: {
file_path: { type: 'string' },
},
},
strict: true,
} as any,
{
type: 'advisor_20260301',
} as any,
])
expect(tools).toEqual([
{
type: 'function',
name: 'Read',
description: 'Read a file',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string' },
},
},
strict: true,
},
])
})
})

View File

@@ -1,103 +0,0 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
getCodexConfigurationError,
normalizeCodexError,
} from '../errors.js'
const originalCodexApiKey = process.env.CODEX_API_KEY
afterEach(() => {
if (originalCodexApiKey === undefined) {
delete process.env.CODEX_API_KEY
} else {
process.env.CODEX_API_KEY = originalCodexApiKey
}
})
describe('getCodexConfigurationError', () => {
test('reports missing CODEX_API_KEY clearly', () => {
delete process.env.CODEX_API_KEY
expect(getCodexConfigurationError()).toEqual({
content:
'Missing CODEX_API_KEY. Configure it in settings or your environment before using the codex provider.',
error: 'authentication_failed',
})
})
test('returns null when CODEX_API_KEY is present', () => {
process.env.CODEX_API_KEY = 'test-key'
expect(getCodexConfigurationError()).toBeNull()
})
})
describe('normalizeCodexError', () => {
test('maps authentication failures', () => {
expect(
normalizeCodexError({
status: 401,
message: 'invalid_api_key',
}),
).toEqual({
content:
'Codex authentication failed (401). Verify CODEX_API_KEY and CODEX_BASE_URL.',
error: 'authentication_failed',
})
})
test('maps missing endpoint failures', () => {
expect(
normalizeCodexError({
status: 404,
message: 'Not Found',
}),
).toEqual({
content:
'Codex endpoint not found (404). Verify CODEX_BASE_URL points to a Responses API root.',
error: 'invalid_request',
})
})
test('maps rate limits', () => {
expect(
normalizeCodexError({
status: 429,
message: 'Too Many Requests',
}),
).toEqual({
content:
'Codex rate limit reached (429). Retry shortly or reduce request volume.',
error: 'rate_limit',
})
})
test('maps upstream gateway 502 errors', () => {
expect(
normalizeCodexError({
status: 502,
message: 'Upstream request failed',
}),
).toEqual({
content:
'Codex gateway returned 502 Upstream request failed. This usually means a transient gateway issue or incomplete Responses API compatibility during tool replay.',
error: 'server_error',
})
})
test('passes through Codex preflight errors as invalid requests', () => {
expect(
normalizeCodexError(new Error('Codex preflight: input must be an array.')),
).toEqual({
content: 'Codex preflight: input must be an array.',
error: 'invalid_request',
})
})
test('falls back to generic API error text', () => {
expect(normalizeCodexError(new Error('socket hang up'))).toEqual({
content: 'API Error: socket hang up',
error: 'unknown',
})
})
})

View File

@@ -1,103 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { uploadCodexBase64Image } from '../imageUpload.js'
describe('codex image upload', () => {
const originalFetch = globalThis.fetch
const originalImgbbApiKey = process.env.CODEX_IMGBB_API_KEY
const originalUploadTimeout = process.env.CODEX_IMAGE_UPLOAD_TIMEOUT_MS
const originalLegacyTimeout = process.env.CODEX_IMAGE_URL_TIMEOUT_MS
beforeEach(() => {
process.env.CODEX_IMGBB_API_KEY = 'imgbb-test-key'
delete process.env.CODEX_IMAGE_UPLOAD_TIMEOUT_MS
delete process.env.CODEX_IMAGE_URL_TIMEOUT_MS
})
afterEach(() => {
globalThis.fetch = originalFetch
if (originalImgbbApiKey === undefined) {
delete process.env.CODEX_IMGBB_API_KEY
} else {
process.env.CODEX_IMGBB_API_KEY = originalImgbbApiKey
}
if (originalUploadTimeout === undefined) {
delete process.env.CODEX_IMAGE_UPLOAD_TIMEOUT_MS
} else {
process.env.CODEX_IMAGE_UPLOAD_TIMEOUT_MS = originalUploadTimeout
}
if (originalLegacyTimeout === undefined) {
delete process.env.CODEX_IMAGE_URL_TIMEOUT_MS
} else {
process.env.CODEX_IMAGE_URL_TIMEOUT_MS = originalLegacyTimeout
}
})
test('uploads inline base64 images to ImgBB and caches the result', async () => {
let fetchCalls = 0
globalThis.fetch = (async (input: string | URL | Request) => {
fetchCalls += 1
expect(String(input)).toBe(
'https://api.imgbb.com/1/upload?key=imgbb-test-key',
)
return new Response(
JSON.stringify({ data: { url: 'https://i.ibb.co/base64.png' } }),
{ status: 200 },
)
}) as unknown as typeof fetch
const first = await uploadCodexBase64Image('YWJj', 'image/png')
const second = await uploadCodexBase64Image('YWJj', 'image/png')
expect(first).toBe('https://i.ibb.co/base64.png')
expect(second).toBe('https://i.ibb.co/base64.png')
expect(fetchCalls).toBe(1)
})
test('prefers ImgBB derived variants before the raw url', async () => {
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
data: {
url: 'https://i.ibb.co/raw/base64.png',
image: { url: 'https://i.ibb.co/image/base64.png' },
thumb: { url: 'https://i.ibb.co/thumb/base64.png' },
medium: { url: 'https://i.ibb.co/medium/base64.png' },
},
}),
{ status: 200 },
)) as unknown as typeof fetch
const url = await uploadCodexBase64Image('ZGVm', 'image/png')
expect(url).toBe('https://i.ibb.co/medium/base64.png')
})
test('prefers the new upload timeout env name over the legacy one', async () => {
let aborted = false
process.env.CODEX_IMAGE_UPLOAD_TIMEOUT_MS = '1'
process.env.CODEX_IMAGE_URL_TIMEOUT_MS = '1000'
globalThis.fetch = (async (
_input: string | URL | Request,
init?: RequestInit,
) => {
const signal = init?.signal
if (!(signal instanceof AbortSignal)) {
throw new Error('Expected AbortSignal')
}
await new Promise<void>(resolve => {
signal.addEventListener('abort', () => {
aborted = true
resolve()
})
})
throw new Error('aborted')
}) as unknown as typeof fetch
const url = await uploadCodexBase64Image('Z2hp', 'image/png')
expect(url).toBeNull()
expect(aborted).toBe(true)
})
})

View File

@@ -1,51 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { sanitizeCodexRequest } from '../preflight.js'
describe('sanitizeCodexRequest', () => {
test('normalizes function call ids and tool names', () => {
const request = sanitizeCodexRequest({
model: 'gpt-5.4',
input: [
{
type: 'function_call',
call_id: ' tool 1 / weird ',
name: ' Read ',
arguments: '{}',
},
] as any,
tools: [
{
type: 'function',
name: ' Read ',
parameters: null,
},
] as any,
} as any)
expect(request.input?.[0]).toMatchObject({
type: 'function_call',
call_id: 'tool_1_weird',
name: 'Read',
})
expect(request.tools?.[0]).toMatchObject({
type: 'function',
name: 'Read',
parameters: {},
})
})
test('rejects invalid function_call_output without call_id', () => {
expect(() =>
sanitizeCodexRequest({
model: 'gpt-5.4',
input: [
{
type: 'function_call_output',
call_id: ' ',
output: 'ok',
},
] as any,
} as any),
).toThrow('Codex preflight: function_call_output.call_id is required.')
})
})

View File

@@ -1,451 +0,0 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import type { Response, ResponseStreamEvent } from 'openai/resources/responses/responses.mjs'
import { asSystemPrompt } from '../../../../utils/systemPromptType.js'
type StreamRun = {
events?: ResponseStreamEvent[]
finalResponse?: Response
error?: unknown
}
let streamRuns: StreamRun[] = []
let createRuns: StreamRun[] = []
let lastRequestBody: any
let lastCreateRequestBody: any
function makeResponse(overrides: Partial<Response> = {}): Response {
return {
id: 'resp_test',
object: 'response',
created_at: 0,
status: 'completed',
model: 'gpt-5.4',
output: [],
parallel_tool_calls: false,
store: false,
temperature: 1,
tool_choice: 'auto',
top_p: 1,
truncation: 'disabled',
usage: {
input_tokens: 12,
output_tokens: 8,
total_tokens: 20,
input_tokens_details: {
cached_tokens: 0,
},
output_tokens_details: {
reasoning_tokens: 0,
},
},
...overrides,
} as Response
}
function makeStream(run: StreamRun) {
return {
async *[Symbol.asyncIterator]() {
for (const event of run.events ?? []) {
yield event
}
},
finalResponse: async () => {
if (run.error) {
throw run.error
}
return run.finalResponse ?? makeResponse()
},
}
}
function makeCreateStream(run: StreamRun) {
return {
async *[Symbol.asyncIterator]() {
if (run.error) {
throw run.error
}
for (const event of run.events ?? []) {
yield event
}
},
}
}
mock.module('../client.js', () => ({
getCodexClient: () => ({
responses: {
stream: (body: any) => {
lastRequestBody = body
const run = streamRuns.shift()
if (!run) {
throw new Error('unexpected stream call')
}
if (run.error && !run.events) {
throw run.error
}
return makeStream(run)
},
create: async (body: any) => {
lastCreateRequestBody = body
const run = createRuns.shift()
if (!run) {
throw new Error('unexpected create call')
}
return makeCreateStream(run)
},
},
}),
}))
// Mock only model resolution — conversion functions can use real implementations
// since the client mock controls API responses.
mock.module('@ant/model-provider', () => {
// Import the real module to preserve conversion functions
const real = require('@ant/model-provider')
return {
...real,
resolveCodexModel: () => 'gpt-5.4',
resolveCodexMaxTokens: () => 4096,
}
})
mock.module('../../../../utils/context.js', () => ({
MODEL_CONTEXT_WINDOW_DEFAULT: 200_000,
COMPACT_MAX_OUTPUT_TOKENS: 20_000,
CAPPED_DEFAULT_MAX_TOKENS: 8_000,
ESCALATED_MAX_TOKENS: 64_000,
is1mContextDisabled: () => false,
has1mContext: () => false,
modelSupports1M: () => false,
getContextWindowForModel: () => 200_000,
getSonnet1mExpTreatmentEnabled: () => false,
calculateContextPercentages: () => ({}),
getModelMaxOutputTokens: () => ({ upperLimit: 4096 }),
getMaxThinkingTokensForModel: () => 0,
}))
mock.module('../../../../utils/api.js', () => ({
toolToAPISchema: async () => ({}),
appendSystemContext: () => {},
prependUserContext: () => {},
logAPIPrefix: () => {},
splitSysPromptPrefix: () => ({ prefix: '', rest: [] }),
logContextMetrics: async () => {},
normalizeToolInput: (input: any) => input,
normalizeToolInputForAPI: (input: any) => input,
}))
mock.module('src/utils/debug.ts', () => ({
getMinDebugLogLevel: () => 'debug' as const,
isDebugMode: () => false,
enableDebugLogging: () => false,
getDebugFilter: () => null,
isDebugToStdErr: () => false,
getDebugFilePath: () => null as string | null,
setHasFormattedOutput: () => {},
getHasFormattedOutput: () => false,
flushDebugLogs: async () => {},
logForDebugging: () => {},
getDebugLogPath: () => '/tmp/mock-debug.log',
logAntError: () => {},
}))
mock.module('../../../../services/langfuse/tracing.js', () => ({
createTrace: () => null,
recordLLMObservation: () => {},
recordToolObservation: () => {},
createToolBatchSpan: () => null,
endToolBatchSpan: () => {},
createSubagentTrace: () => null,
createChildSpan: () => null,
endTrace: () => {},
}))
mock.module('../../../../services/langfuse/convert.js', () => ({
convertMessagesToLangfuse: () => [],
convertOutputToLangfuse: () => [],
convertToolsToLangfuse: () => [],
}))
async function runQuery(
nextStreamRuns: StreamRun[],
nextCreateRuns: StreamRun[] = [],
systemPrompt = asSystemPrompt([]),
) {
streamRuns = [...nextStreamRuns]
createRuns = [...nextCreateRuns]
const { queryModelCodex } = await import('../index.js')
const assistantMessages: any[] = []
const streamEvents: any[] = []
const options: any = {
model: 'gpt-5.4',
agents: [],
querySource: 'main_loop',
getToolPermissionContext: async () => ({
alwaysAllow: [],
alwaysDeny: [],
needsPermission: [],
mode: 'default',
isBypassingPermissions: false,
}),
}
for await (const item of queryModelCodex(
[],
systemPrompt,
[],
new AbortController().signal,
options,
)) {
if (item.type === 'assistant') {
assistantMessages.push(item)
} else if (item.type === 'stream_event') {
streamEvents.push(item)
}
}
return { assistantMessages, streamEvents }
}
describe('queryModelCodex streaming fallback', () => {
const originalCodexApiKey = process.env.CODEX_API_KEY
beforeEach(() => {
process.env.CODEX_API_KEY = 'test-key'
})
afterEach(() => {
streamRuns = []
createRuns = []
lastRequestBody = undefined
lastCreateRequestBody = undefined
if (originalCodexApiKey === undefined) {
delete process.env.CODEX_API_KEY
} else {
process.env.CODEX_API_KEY = originalCodexApiKey
}
})
test('builds the final assistant text from streamed blocks when final snapshots are empty', async () => {
const response = makeResponse()
const events: ResponseStreamEvent[] = [
{ type: 'response.created', response } as any,
{
type: 'response.output_item.added',
output_index: 0,
item: {
type: 'message',
id: 'msg_1',
role: 'assistant',
content: [],
status: 'in_progress',
},
} as any,
{
type: 'response.output_text.delta',
output_index: 0,
item_id: 'msg_1',
delta: 'hello',
} as any,
{
type: 'response.output_text.done',
output_index: 0,
item_id: 'msg_1',
text: 'hello world',
} as any,
{ type: 'response.completed', response } as any,
]
const { assistantMessages, streamEvents } = await runQuery([
{ events, finalResponse: response },
])
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0].message.content).toEqual([
{ type: 'text', text: 'hello world' },
])
expect(assistantMessages[0].message.stop_reason).toBe('end_turn')
expect(
streamEvents.find((item: any) => item.event.type === 'message_delta')?.event.delta
.stop_reason,
).toBe('end_turn')
})
test('builds tool_use blocks from streamed arguments when final snapshots are empty', async () => {
const response = makeResponse()
const events: ResponseStreamEvent[] = [
{ type: 'response.created', response } as any,
{
type: 'response.output_item.added',
output_index: 0,
item: {
type: 'function_call',
id: 'fc_1',
call_id: 'call_1',
name: 'Read',
arguments: '',
status: 'in_progress',
},
} as any,
{
type: 'response.function_call_arguments.delta',
output_index: 0,
item_id: 'fc_1',
delta: '{"file_path":"README.md"}',
} as any,
{
type: 'response.function_call_arguments.done',
output_index: 0,
item_id: 'fc_1',
arguments: '{"file_path":"README.md"}',
} as any,
{ type: 'response.completed', response } as any,
]
const { assistantMessages, streamEvents } = await runQuery([
{ events, finalResponse: response },
])
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0].message.content).toEqual([
{
type: 'tool_use',
id: 'call_1',
name: 'Read',
input: { file_path: 'README.md' },
},
])
expect(assistantMessages[0].message.stop_reason).toBe('tool_use')
expect(
streamEvents.find((item: any) => item.event.type === 'message_delta')?.event.delta
.stop_reason,
).toBe('tool_use')
})
test('sends system prompt via top-level instructions instead of system messages', async () => {
const response = makeResponse({
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'ok' }],
status: 'completed',
} as any,
],
output_text: 'ok',
})
const events: ResponseStreamEvent[] = [
{ type: 'response.created', response } as any,
{ type: 'response.completed', response } as any,
]
await runQuery(
[{ events, finalResponse: response }],
[],
asSystemPrompt(['system one', 'system two']),
)
expect(lastRequestBody.instructions).toBe('system one\n\nsystem two')
expect(lastRequestBody.input).toEqual([])
})
test('continues incomplete responses and aggregates usage across attempts', async () => {
const incompleteResponse = makeResponse({
status: 'incomplete',
incomplete_details: { reason: 'max_output_tokens' } as any,
usage: {
input_tokens: 10,
output_tokens: 4,
total_tokens: 14,
input_tokens_details: { cached_tokens: 1 },
output_tokens_details: { reasoning_tokens: 0 },
} as any,
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'hello ' }],
status: 'incomplete',
} as any,
],
})
const completedResponse = makeResponse({
usage: {
input_tokens: 20,
output_tokens: 6,
total_tokens: 26,
input_tokens_details: { cached_tokens: 2 },
output_tokens_details: { reasoning_tokens: 0 },
} as any,
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'world' }],
status: 'completed',
} as any,
],
})
const { assistantMessages } = await runQuery([
{
events: [
{ type: 'response.created', response: incompleteResponse } as any,
{ type: 'response.incomplete', response: incompleteResponse } as any,
],
finalResponse: incompleteResponse,
},
{
events: [
{ type: 'response.created', response: completedResponse } as any,
{ type: 'response.completed', response: completedResponse } as any,
],
finalResponse: completedResponse,
},
])
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0].message.content).toEqual([
{ type: 'text', text: 'hello world' },
])
expect(assistantMessages[0].message.usage).toMatchObject({
input_tokens: 30,
output_tokens: 10,
cache_read_input_tokens: 3,
})
})
test('falls back to responses.create(stream:true) when helper streaming fails', async () => {
const fallbackResponse = makeResponse({
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'fallback ok' }],
status: 'completed',
} as any,
],
})
const { assistantMessages } = await runQuery(
[{ error: new Error('helper stream failed') }],
[
{
events: [
{ type: 'response.created', response: fallbackResponse } as any,
{ type: 'response.completed', response: fallbackResponse } as any,
],
},
],
)
expect(lastCreateRequestBody.stream).toBe(true)
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0].message.content).toEqual([
{ type: 'text', text: 'fallback ok' },
])
})
})

View File

@@ -30,7 +30,7 @@ export function getCodexClient(options?: {
return cachedClient
}
const apiKey = process.env.CODEX_API_KEY || ''
const apiKey = process.env.CODEX_API_KEY || process.env.CODEX_ACCESS_TOKEN || ''
const baseURL = process.env.CODEX_BASE_URL || DEFAULT_CODEX_BASE_URL
const baseFetch = options?.fetchOverride ?? (globalThis.fetch as typeof fetch)
const wrappedFetch = wrapFetchForUsage(baseFetch)

View File

@@ -47,10 +47,10 @@ function readErrorMessage(error: unknown): string {
}
export function getCodexConfigurationError(): NormalizedCodexError | null {
if (!process.env.CODEX_API_KEY) {
if (!process.env.CODEX_API_KEY && !process.env.CODEX_ACCESS_TOKEN) {
return {
content:
'Missing CODEX_API_KEY. Configure it in settings or your environment before using the codex provider.',
'Missing CODEX_API_KEY or CODEX_ACCESS_TOKEN. Use /login (ChatGPT Subscription) or set manually.',
error: 'authentication_failed',
}
}
@@ -70,8 +70,9 @@ export function normalizeCodexError(error: unknown): NormalizedCodexError {
}
if (status === 401 || status === 403) {
return {
content: `Codex authentication failed (${status}). Verify CODEX_API_KEY and CODEX_BASE_URL.`,
content: `Codex authentication failed (${status}). ${message}`,
error: 'authentication_failed',
}
}

View File

@@ -57,7 +57,7 @@ const PROVIDER_GENERATION_NAMES: Record<string, string> = {
vertex: 'ChatVertexAnthropic',
foundry: 'ChatFoundry',
openai: 'ChatOpenAI',
'codex': 'ChatOpenAIResponses',
codex: 'ChatCodex',
'codex-chatgpt': 'ChatCodex',
gemini: 'ChatGoogleGenerativeAI',
grok: 'ChatXAI',

View File

@@ -0,0 +1,238 @@
import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test'
import {
_internal,
performOpenAICodexLogin,
} from '../openai-codex.js'
describe('openai-codex OAuth', () => {
describe('constants', () => {
test('has correct OAuth endpoints', () => {
expect(_internal.CLIENT_ID).toBe('app_EMoamEEZ73f0CkXaXp7hrann')
expect(_internal.AUTHORIZE_URL).toBe('https://auth.openai.com/oauth/authorize')
expect(_internal.TOKEN_URL).toBe('https://auth.openai.com/oauth/token')
expect(_internal.REDIRECT_URI).toBe('http://localhost:1455/auth/callback')
expect(_internal.SCOPE).toBe('openid profile email offline_access api.connectors.read api.connectors.invoke')
})
})
describe('buildAuthorizeUrl', () => {
test('builds correct authorize URL with all parameters', () => {
const url = _internal.buildAuthorizeUrl('test-challenge', 'test-state')
const parsed = new URL(url)
expect(parsed.origin + parsed.pathname).toBe('https://auth.openai.com/oauth/authorize')
expect(parsed.searchParams.get('response_type')).toBe('code')
expect(parsed.searchParams.get('client_id')).toBe(_internal.CLIENT_ID)
expect(parsed.searchParams.get('redirect_uri')).toBe(_internal.REDIRECT_URI)
expect(parsed.searchParams.get('scope')).toBe(_internal.SCOPE)
expect(parsed.searchParams.get('code_challenge')).toBe('test-challenge')
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256')
expect(parsed.searchParams.get('state')).toBe('test-state')
expect(parsed.searchParams.get('id_token_add_organizations')).toBe('true')
expect(parsed.searchParams.get('codex_cli_simplified_flow')).toBe('true')
expect(parsed.searchParams.get('originator')).toBe('claude-code')
})
test('uses custom redirect URI when provided', () => {
const url = _internal.buildAuthorizeUrl('challenge', 'state', 'http://localhost:9999/custom')
const parsed = new URL(url)
expect(parsed.searchParams.get('redirect_uri')).toBe('http://localhost:9999/custom')
})
})
describe('decodeJwt', () => {
test('decodes valid JWT payload', () => {
// Create a minimal JWT: header.payload.signature
const payload = Buffer.from(
JSON.stringify({
'https://api.openai.com/auth': { chatgpt_account_id: 'acc_12345' },
sub: 'user_123',
}),
).toString('base64url')
const token = `eyJhbGciOiJSUzI1NiJ9.${payload}.signature`
const result = _internal.decodeJwt(token)
expect(result).not.toBeNull()
expect(result?.['https://api.openai.com/auth']?.chatgpt_account_id).toBe('acc_12345')
})
test('returns null for invalid JWT', () => {
expect(_internal.decodeJwt('not-a-jwt')).toBeNull()
expect(_internal.decodeJwt('a.b')).toBeNull()
expect(_internal.decodeJwt('')).toBeNull()
})
})
describe('getAccountId', () => {
test('extracts account ID from valid token', () => {
const payload = Buffer.from(
JSON.stringify({
'https://api.openai.com/auth': { chatgpt_account_id: 'acc_test123' },
}),
).toString('base64url')
const token = `header.${payload}.sig`
expect(_internal.getAccountId(token)).toBe('acc_test123')
})
test('returns null when account ID is missing', () => {
const payload = Buffer.from(JSON.stringify({ sub: 'user_123' })).toString('base64url')
const token = `header.${payload}.sig`
expect(_internal.getAccountId(token)).toBeNull()
})
test('returns null for empty account ID', () => {
const payload = Buffer.from(
JSON.stringify({
'https://api.openai.com/auth': { chatgpt_account_id: '' },
}),
).toString('base64url')
const token = `header.${payload}.sig`
expect(_internal.getAccountId(token)).toBeNull()
})
test('returns null for invalid token', () => {
expect(_internal.getAccountId('invalid')).toBeNull()
})
})
describe('exchangeCodeForTokens', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
test('exchanges code for tokens successfully', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
id_token: 'id_token_value',
access_token: 'access_value',
refresh_token: 'refresh_value',
expires_in: 3600,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
) as any
const result = await _internal.exchangeCodeForTokens('auth_code', 'verifier')
expect(result.access_token).toBe('access_value')
expect(result.refresh_token).toBe('refresh_value')
expect(result.id_token).toBe('id_token_value')
})
test('throws on non-200 response', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response('Unauthorized', { status: 401 }),
),
) as any
await expect(
_internal.exchangeCodeForTokens('bad_code', 'verifier'),
).rejects.toThrow('Token exchange failed (401)')
})
test('throws when response missing fields', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ access_token: 'only_access' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
) as any
await expect(
_internal.exchangeCodeForTokens('code', 'verifier'),
).rejects.toThrow('missing required fields')
})
test('sends correct request body', async () => {
let capturedBody: string | null = null
globalThis.fetch = mock((url: string, opts: any) => {
capturedBody = opts.body
return Promise.resolve(
new Response(
JSON.stringify({
id_token: 'id',
access_token: 'acc',
refresh_token: 'ref',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
}) as any
await _internal.exchangeCodeForTokens('test_code', 'test_verifier', 'http://localhost:1455/auth/callback')
const params = new URLSearchParams(capturedBody!)
expect(params.get('grant_type')).toBe('authorization_code')
expect(params.get('client_id')).toBe(_internal.CLIENT_ID)
expect(params.get('code')).toBe('test_code')
expect(params.get('code_verifier')).toBe('test_verifier')
expect(params.get('redirect_uri')).toBe('http://localhost:1455/auth/callback')
})
})
describe('obtainApiKey', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
test('exchanges id_token for API key', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({ access_token: 'sk-api-key-12345' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
) as any
const apiKey = await _internal.obtainApiKey('id_token_value')
expect(apiKey).toBe('sk-api-key-12345')
})
test('throws on non-200 response', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response('Forbidden', { status: 403 }),
),
) as any
await expect(
_internal.obtainApiKey('bad_token'),
).rejects.toThrow('API key exchange failed (403)')
})
test('sends correct token exchange parameters', async () => {
let capturedBody: string | null = null
globalThis.fetch = mock((url: string, opts: any) => {
capturedBody = opts.body
return Promise.resolve(
new Response(
JSON.stringify({ access_token: 'key' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
}) as any
await _internal.obtainApiKey('test_id_token')
const params = new URLSearchParams(capturedBody!)
expect(params.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:token-exchange')
expect(params.get('client_id')).toBe(_internal.CLIENT_ID)
expect(params.get('requested_token')).toBe('openai-api-key')
expect(params.get('subject_token')).toBe('test_id_token')
expect(params.get('subject_token_type')).toBe('urn:ietf:params:oauth:token-type:id_token')
})
})
})

View File

@@ -0,0 +1,373 @@
/**
* OpenAI Codex (ChatGPT) OAuth flow
*
* Implements the browser-based OAuth login for ChatGPT subscription access.
* Based on the official OpenAI Codex CLI implementation (codex-rs/login/src/server.rs).
*
* Flow:
* 1. Generate PKCE codes + state
* 2. Start local HTTP server on port 1455
* 3. Open browser to OpenAI authorize URL
* 4. Handle callback → exchange code for tokens
* 5. Token exchange: id_token → API key
*/
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http'
import { generateCodeVerifier, generateCodeChallenge, generateState } from './crypto.js'
import { openBrowser } from '../../utils/browser.js'
// ─── Constants ───────────────────────────────────────────────────────────────
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
const AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize'
const TOKEN_URL = 'https://auth.openai.com/oauth/token'
const DEFAULT_PORT = 1455
const CALLBACK_PATH = '/auth/callback'
const REDIRECT_URI = `http://localhost:${DEFAULT_PORT}${CALLBACK_PATH}`
const SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke'
const JWT_CLAIM_PATH = 'https://api.openai.com/auth'
// ─── Types ───────────────────────────────────────────────────────────────────
export type CodexOAuthResult = {
apiKey: string | null
accessToken: string
refreshToken: string
accountId: string
}
type TokenResponse = {
id_token: string
access_token: string
refresh_token: string
expires_in?: number
}
type ExchangeResponse = {
access_token: string
}
type JwtPayload = {
[JWT_CLAIM_PATH]?: {
chatgpt_account_id?: string
}
[key: string]: unknown
}
// ─── JWT helpers ─────────────────────────────────────────────────────────────
function decodeJwt(token: string): JwtPayload | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const payload = parts[1] ?? ''
const decoded = Buffer.from(payload, 'base64url').toString('utf-8')
return JSON.parse(decoded) as JwtPayload
} catch {
return null
}
}
function getAccountId(token: string): string | null {
const payload = decodeJwt(token)
const accountId = payload?.[JWT_CLAIM_PATH]?.chatgpt_account_id
return typeof accountId === 'string' && accountId.length > 0 ? accountId : null
}
// ─── URL building ────────────────────────────────────────────────────────────
function buildAuthorizeUrl(
codeChallenge: string,
state: string,
redirectUri: string = REDIRECT_URI,
): 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', redirectUri)
url.searchParams.set('scope', SCOPE)
url.searchParams.set('code_challenge', codeChallenge)
url.searchParams.set('code_challenge_method', 'S256')
url.searchParams.set('state', state)
url.searchParams.set('id_token_add_organizations', 'true')
url.searchParams.set('codex_cli_simplified_flow', 'true')
url.searchParams.set('originator', 'claude-code')
return url.toString()
}
// ─── Token exchange ──────────────────────────────────────────────────────────
async function exchangeCodeForTokens(
code: string,
codeVerifier: string,
redirectUri: string = REDIRECT_URI,
): Promise<TokenResponse> {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
code,
code_verifier: codeVerifier,
redirect_uri: redirectUri,
}),
})
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`Token exchange failed (${response.status}): ${text}`)
}
const json = (await response.json()) as TokenResponse
if (!json.access_token || !json.refresh_token) {
throw new Error('Token response missing required fields')
}
return json
}
async function obtainApiKey(idToken: string): Promise<string> {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
client_id: CLIENT_ID,
requested_token: 'openai-api-key',
subject_token: idToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
}),
})
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`API key exchange failed (${response.status}): ${text}`)
}
const json = (await response.json()) as ExchangeResponse
if (!json.access_token) {
throw new Error('API key exchange response missing access_token')
}
return json.access_token
}
// ─── HTML responses ──────────────────────────────────────────────────────────
const SUCCESS_HTML = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Login Successful</title>
<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
.card{text-align:center;padding:2rem;border-radius:12px;background:#16213e;box-shadow:0 4px 24px rgba(0,0,0,.3)}
h1{color:#4ade80;font-size:1.5rem}p{color:#94a3b8;margin-top:.5rem}</style></head>
<body><div class="card"><h1>Authentication Complete</h1><p>You can close this window.</p></div></body></html>`
const ERROR_HTML = (msg: string) => `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Login Error</title>
<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
.card{text-align:center;padding:2rem;border-radius:12px;background:#16213e;box-shadow:0 4px 24px rgba(0,0,0,.3)}
h1{color:#f87171;font-size:1.5rem}p{color:#94a3b8;margin-top:.5rem}</style></head>
<body><div class="card"><h1>Authentication Failed</h1><p>${msg}</p></div></body></html>`
// ─── Local callback server ──────────────────────────────────────────────────
function startCallbackServer(
state: string,
port: number,
): Promise<{
waitForCode: () => Promise<string>
close: () => void
}> {
let settlePromise: ((code: string) => void) | ((error: Error) => void) | null = null
const codePromise = new Promise<string>((resolve, reject) => {
settlePromise = resolve
// Also store reject for error cases
;(settlePromise as any).__reject = reject
})
const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {
try {
const url = new URL(req.url || '', `http://localhost:${port}`)
if (url.pathname !== CALLBACK_PATH) {
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(ERROR_HTML('Not found'))
return
}
// Check for OAuth error
const error = url.searchParams.get('error')
if (error) {
const desc = url.searchParams.get('error_description') ?? error
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(ERROR_HTML(desc))
;((settlePromise as any).__reject as (e: Error) => void)?.(new Error(`OAuth error: ${desc}`))
return
}
if (url.searchParams.get('state') !== state) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(ERROR_HTML('State mismatch'))
;((settlePromise as any).__reject as (e: Error) => void)?.(new Error('State mismatch'))
return
}
const code = url.searchParams.get('code')
if (!code) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(ERROR_HTML('Missing authorization code'))
;((settlePromise as any).__reject as (e: Error) => void)?.(new Error('Missing authorization code'))
return
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(SUCCESS_HTML)
;(settlePromise as (code: string) => void)?.(code)
} catch {
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(ERROR_HTML('Internal error'))
}
})
return new Promise((resolve, reject) => {
server.listen(port, '127.0.0.1', () => {
resolve({
waitForCode: () => codePromise,
close: () => {
server.close()
server.removeAllListeners()
},
})
})
server.on('error', (err: Error & { code?: string }) => {
reject(new Error(`Failed to start callback server on port ${port}: ${err.message}`))
})
})
}
// ─── Manual code parsing ────────────────────────────────────────────────────
/**
* Parse manual user input to extract an authorization code.
* Accepts:
* - A full redirect URL: http://localhost:1455/auth/callback?code=XXX&state=YYY
* - A raw authorization code: XXX
* - code#state format: XXX#YYY
*/
export function parseManualCodeInput(input: string): string | null {
const value = input.trim()
if (!value) return null
// Try as URL
try {
const url = new URL(value)
const code = url.searchParams.get('code')
return code ?? null
} catch {
// Not a URL, continue
}
// Try code#state format — return just the code part
if (value.includes('#')) {
const [code] = value.split('#', 2)
return code ?? null
}
// Return as raw code
return value
}
// ─── Public API ──────────────────────────────────────────────────────────────
export type CodexLoginOptions = {
/** Called with the authorize URL when the flow starts */
onUrl: (url: string) => void
/** Optional: provide a manual authorization code (headless fallback) */
manualCode?: Promise<string>
}
/**
* Perform the complete OpenAI Codex OAuth login flow.
*
* 1. Starts local callback server on port 1455
* 2. Opens browser to OpenAI authorize URL
* 3. Exchanges authorization code for tokens
* 4. Performs token exchange to obtain an API key
* 5. Returns the API key and token information
*/
export async function performOpenAICodexLogin(
options: CodexLoginOptions,
): Promise<CodexOAuthResult> {
const { onUrl, manualCode } = options
// Step 1: Generate PKCE + state
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
const state = generateState()
// Step 2: Build authorize URL
const authUrl = buildAuthorizeUrl(codeChallenge, state)
onUrl(authUrl)
// Step 3: Start callback server
const server = await startCallbackServer(state, DEFAULT_PORT)
try {
// Step 4: Open browser
await openBrowser(authUrl)
// Step 5: Wait for code (from callback or manual input)
let code: string
if (manualCode) {
// Race between browser callback and manual input
const result = await Promise.race([
server.waitForCode().then(c => ({ source: 'callback' as const, code: c })),
manualCode.then(c => ({ source: 'manual' as const, code: c })),
])
code = result.code
} else {
code = await server.waitForCode()
}
// Step 6: Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier)
// Step 7: Extract account ID
const accountId = getAccountId(tokens.id_token)
if (!accountId) {
throw new Error('Failed to extract ChatGPT account ID from token')
}
// Step 8: Exchange id_token for API key (non-fatal: some accounts lack org, returning null)
let apiKey: string | null = null
try {
apiKey = await obtainApiKey(tokens.id_token)
} catch {
// API key exchange may fail if the ID token lacks organization_id.
// This is expected for some account types — login still succeeds.
}
return {
apiKey,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
accountId,
}
} finally {
server.close()
}
}
// Export helpers for testing
export const _internal = {
CLIENT_ID,
AUTHORIZE_URL,
TOKEN_URL,
REDIRECT_URI,
SCOPE,
buildAuthorizeUrl,
decodeJwt,
getAccountId,
exchangeCodeForTokens,
obtainApiKey,
}

View File

@@ -117,12 +117,9 @@ export function isAnthropicAuthEnabled(): boolean {
isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_CODEX) ||
(settings as any).modelType === 'openai' ||
(settings as any).modelType === 'codex' ||
(settings as any).modelType === 'gemini' ||
!!process.env.OPENAI_BASE_URL ||
!!process.env.CODEX_BASE_URL ||
!!process.env.GEMINI_BASE_URL
const apiKeyHelper = settings.apiKeyHelper
const hasExternalAuthToken =

View File

@@ -22,8 +22,8 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_CODEX',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_CODEX',
// Endpoint config (base URLs, project/resource identifiers)
'ANTHROPIC_BASE_URL',
'ANTHROPIC_BEDROCK_BASE_URL',
@@ -31,8 +31,8 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
'ANTHROPIC_FOUNDRY_BASE_URL',
'ANTHROPIC_FOUNDRY_RESOURCE',
'ANTHROPIC_VERTEX_PROJECT_ID',
'CODEX_BASE_URL',
'GEMINI_BASE_URL',
'CODEX_BASE_URL',
// Region routing (per-model VERTEX_REGION_CLAUDE_* handled by prefix below)
'CLOUD_ML_REGION',
// Auth
@@ -44,12 +44,8 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
'CODEX_API_KEY',
'CODEX_LOGIN_METHOD',
'CODEX_IMGBB_API_KEY',
'CODEX_IMAGE_UPLOAD_TIMEOUT_MS',
'CODEX_IMAGE_URL_TIMEOUT_MS',
'GEMINI_API_KEY',
'CODEX_API_KEY',
// Model defaults — often set to provider-specific ID formats
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
@@ -81,23 +77,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
'OPENAI_DEFAULT_SONNET_MODEL_NAME',
'OPENAI_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
'OPENAI_SMALL_FAST_MODEL',
'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',
'ANTHROPIC_SMALL_FAST_MODEL',
'CODEX_IMAGE_UPLOAD_TIMEOUT_MS',
'CODEX_IMAGE_URL_TIMEOUT_MS',
'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION',
'CLAUDE_CODE_SUBAGENT_MODEL',
'GEMINI_MODEL',
@@ -115,6 +95,17 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
'GEMINI_DEFAULT_SONNET_MODEL_DESCRIPTION',
'GEMINI_DEFAULT_SONNET_MODEL_NAME',
'GEMINI_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
// Codex provider specific
'CODEX_BASE_URL',
'CODEX_API_KEY',
'CODEX_MODEL',
'CODEX_DEFAULT_HAIKU_MODEL',
'CODEX_DEFAULT_SONNET_MODEL',
'CODEX_DEFAULT_OPUS_MODEL',
'CODEX_IMGBB_API_KEY',
'CODEX_LOGIN_METHOD',
'CODEX_ACCESS_TOKEN',
'CODEX_REFRESH_TOKEN',
])
const PROVIDER_MANAGED_ENV_PREFIXES = [
@@ -197,20 +188,6 @@ export const SAFE_ENV_VARS = new Set([
'OPENAI_DEFAULT_SONNET_MODEL_DESCRIPTION',
'OPENAI_DEFAULT_SONNET_MODEL_NAME',
'OPENAI_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
'CODEX_MODEL',
'CODEX_SMALL_FAST_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',
'ANTHROPIC_FOUNDRY_API_KEY',
'ANTHROPIC_MODEL',
'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION',
@@ -236,9 +213,9 @@ export const SAFE_ENV_VARS = new Set([
'CLAUDE_CODE_SUBAGENT_MODEL',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_CODEX',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_CODEX',
'GEMINI_MODEL',
'GEMINI_SMALL_FAST_MODEL',
'GEMINI_DEFAULT_HAIKU_MODEL',
@@ -253,6 +230,11 @@ 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_SONNET_MODEL',
'CODEX_DEFAULT_OPUS_MODEL',
'CODEX_IMGBB_API_KEY',
'DISABLE_AUTOUPDATER',
'DISABLE_BUG_COMMAND',
'DISABLE_COST_WARNINGS',

View File

@@ -1,7 +1,7 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { mock } from "bun:test";
let mockedModelType: "gemini" | "codex" | undefined;
let mockedModelType: "gemini" | undefined;
mock.module("../../settings/settings.js", () => ({
getInitialSettings: () =>
@@ -18,7 +18,6 @@ describe("getAPIProvider", () => {
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
"CLAUDE_CODE_USE_OPENAI",
"CLAUDE_CODE_USE_CODEX",
] as const;
const savedEnv: Record<string, string | undefined> = {};
@@ -53,11 +52,6 @@ describe("getAPIProvider", () => {
expect(getAPIProvider()).toBe("gemini");
});
test('returns "codex" when modelType is codex', () => {
mockedModelType = "codex";
expect(getAPIProvider()).toBe("codex");
});
test("modelType takes precedence over environment variables", () => {
mockedModelType = "gemini";
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
@@ -69,11 +63,6 @@ describe("getAPIProvider", () => {
expect(getAPIProvider()).toBe("gemini");
});
test('returns "codex" when CLAUDE_CODE_USE_CODEX is set', () => {
process.env.CLAUDE_CODE_USE_CODEX = "1";
expect(getAPIProvider()).toBe("codex");
});
test('returns "bedrock" when CLAUDE_CODE_USE_BEDROCK is set', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
expect(getAPIProvider()).toBe("bedrock");

View File

@@ -12,8 +12,8 @@ export const CLAUDE_3_7_SONNET_CONFIG = {
vertex: 'claude-3-7-sonnet@20250219',
foundry: 'claude-3-7-sonnet',
openai: 'claude-3-7-sonnet-20250219',
'codex': 'claude-3-7-sonnet-20250219',
gemini: 'claude-3-7-sonnet-20250219',
codex: 'gpt-5.4-mini',
grok: 'claude-3-7-sonnet-20250219',
} as const satisfies ModelConfig
@@ -23,8 +23,8 @@ export const CLAUDE_3_5_V2_SONNET_CONFIG = {
vertex: 'claude-3-5-sonnet-v2@20241022',
foundry: 'claude-3-5-sonnet',
openai: 'claude-3-5-sonnet-20241022',
'codex': 'claude-3-5-sonnet-20241022',
gemini: 'claude-3-5-sonnet-20241022',
codex: 'gpt-5.4-mini',
grok: 'claude-3-5-sonnet-20241022',
} as const satisfies ModelConfig
@@ -34,8 +34,8 @@ export const CLAUDE_3_5_HAIKU_CONFIG = {
vertex: 'claude-3-5-haiku@20241022',
foundry: 'claude-3-5-haiku',
openai: 'claude-3-5-haiku-20241022',
'codex': 'claude-3-5-haiku-20241022',
gemini: 'claude-3-5-haiku-20241022',
codex: 'gpt-5.4-mini',
grok: 'claude-3-5-haiku-20241022',
} as const satisfies ModelConfig
@@ -45,8 +45,8 @@ export const CLAUDE_HAIKU_4_5_CONFIG = {
vertex: 'claude-haiku-4-5@20251001',
foundry: 'claude-haiku-4-5',
openai: 'claude-haiku-4-5-20251001',
'codex': 'claude-haiku-4-5-20251001',
gemini: 'claude-haiku-4-5-20251001',
codex: 'gpt-5.4-mini',
grok: 'claude-haiku-4-5-20251001',
} as const satisfies ModelConfig
@@ -56,8 +56,8 @@ export const CLAUDE_SONNET_4_CONFIG = {
vertex: 'claude-sonnet-4@20250514',
foundry: 'claude-sonnet-4',
openai: 'claude-sonnet-4-20250514',
'codex': 'claude-sonnet-4-20250514',
gemini: 'claude-sonnet-4-20250514',
codex: 'gpt-5.4-mini',
grok: 'claude-sonnet-4-20250514',
} as const satisfies ModelConfig
@@ -67,8 +67,8 @@ export const CLAUDE_SONNET_4_5_CONFIG = {
vertex: 'claude-sonnet-4-5@20250929',
foundry: 'claude-sonnet-4-5',
openai: 'claude-sonnet-4-5-20250929',
'codex': 'claude-sonnet-4-5-20250929',
gemini: 'claude-sonnet-4-5-20250929',
codex: 'gpt-5.4-mini',
grok: 'claude-sonnet-4-5-20250929',
} as const satisfies ModelConfig
@@ -78,8 +78,8 @@ export const CLAUDE_OPUS_4_CONFIG = {
vertex: 'claude-opus-4@20250514',
foundry: 'claude-opus-4',
openai: 'claude-opus-4-20250514',
'codex': 'claude-opus-4-20250514',
gemini: 'claude-opus-4-20250514',
codex: 'gpt-5.4',
grok: 'claude-opus-4-20250514',
} as const satisfies ModelConfig
@@ -89,8 +89,8 @@ export const CLAUDE_OPUS_4_1_CONFIG = {
vertex: 'claude-opus-4-1@20250805',
foundry: 'claude-opus-4-1',
openai: 'claude-opus-4-1-20250805',
'codex': 'claude-opus-4-1-20250805',
gemini: 'claude-opus-4-1-20250805',
codex: 'gpt-5.4',
grok: 'claude-opus-4-1-20250805',
} as const satisfies ModelConfig
@@ -100,8 +100,8 @@ export const CLAUDE_OPUS_4_5_CONFIG = {
vertex: 'claude-opus-4-5@20251101',
foundry: 'claude-opus-4-5',
openai: 'claude-opus-4-5-20251101',
'codex': 'claude-opus-4-5-20251101',
gemini: 'claude-opus-4-5-20251101',
codex: 'gpt-5.4',
grok: 'claude-opus-4-5-20251101',
} as const satisfies ModelConfig
@@ -111,8 +111,8 @@ export const CLAUDE_OPUS_4_6_CONFIG = {
vertex: 'claude-opus-4-6',
foundry: 'claude-opus-4-6',
openai: 'claude-opus-4-6',
'codex': 'claude-opus-4-6',
gemini: 'claude-opus-4-6',
codex: 'gpt-5.4',
grok: 'claude-opus-4-6',
} as const satisfies ModelConfig
@@ -122,8 +122,8 @@ export const CLAUDE_OPUS_4_7_CONFIG = {
vertex: 'claude-opus-4-7',
foundry: 'claude-opus-4-7',
openai: 'claude-opus-4-7',
'codex': 'claude-opus-4-7',
gemini: 'claude-opus-4-7',
codex: 'gpt-5.5',
grok: 'claude-opus-4-7',
} as const satisfies ModelConfig
@@ -133,8 +133,8 @@ export const CLAUDE_SONNET_4_6_CONFIG = {
vertex: 'claude-sonnet-4-6',
foundry: 'claude-sonnet-4-6',
openai: 'claude-sonnet-4-6',
'codex': 'claude-sonnet-4-6',
gemini: 'claude-sonnet-4-6',
codex: 'gpt-5.4-mini',
grok: 'claude-sonnet-4-6',
} as const satisfies ModelConfig

View File

@@ -83,7 +83,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)
@@ -93,13 +95,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,
@@ -132,7 +138,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)
@@ -142,13 +150,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,
@@ -232,7 +244,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
@@ -241,13 +255,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,

View File

@@ -481,10 +481,3 @@ describe("gemini settings", () => {
expect(result.success).toBe(true);
});
});
describe("codex settings", () => {
test("accepts codex modelType", () => {
const result = SettingsSchema().safeParse({ modelType: "codex" });
expect(result.success).toBe(true);
});
});

View File

@@ -369,11 +369,11 @@ export const SettingsSchema = lazySchema(() =>
.optional()
.describe('Tool usage permissions configuration'),
modelType: z
.enum(['anthropic', 'openai', 'codex', '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, "codex" uses the OpenAI Responses 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 "codex", configure CODEX_API_KEY, CODEX_BASE_URL, and CODEX_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 OpenAI Responses API via ChatGPT subscription or API key. ' +
'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 and optional CODEX_BASE_URL.',
),
model: z
.string()

View File

@@ -342,7 +342,7 @@ export function buildAPIProviderProperties(): Property[] {
gemini: 'Gemini API',
grok: 'Grok API',
openai: 'OpenAI API',
'codex': 'OpenAI Responses API',
codex: 'Codex API',
}[apiProvider]
properties.push({
label: 'API provider',
@@ -445,18 +445,6 @@ export function buildAPIProviderProperties(): Property[] {
label: 'OpenAI base URL',
value: openaiBaseUrl,
})
} else if (apiProvider === 'codex') {
const codexBaseUrl = process.env.CODEX_BASE_URL
properties.push({
label: 'OpenAI Responses base URL',
value: codexBaseUrl,
})
properties.push({
label: 'Codex image upload',
value: process.env.CODEX_IMGBB_API_KEY
? 'ImgBB'
: 'Not configured',
})
}
const proxyUrl = getProxyUrl()