mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
Compare commits
6 Commits
pr/Kaxtrel
...
codex-subs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1058b7e643 | ||
|
|
d091dd8bae | ||
|
|
4427a6c6db | ||
|
|
13799b5058 | ||
|
|
cd59a88d44 | ||
|
|
bc4a2f1281 |
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 }[] = [
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
238
src/services/oauth/__tests__/openai-codex.test.ts
Normal file
238
src/services/oauth/__tests__/openai-codex.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
373
src/services/oauth/openai-codex.ts
Normal file
373
src/services/oauth/openai-codex.ts
Normal 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,
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user