From bc4a2f1281c055c539e97109f6c9bb0783de5304 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sun, 26 Apr 2026 21:49:42 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20ChatGPT=20OAut?= =?UTF-8?q?h=20=E8=AE=A2=E9=98=85=E7=99=BB=E5=BD=95=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 OpenAI Codex CLI 官方实现,支持 PKCE 流程和手动 code 输入。 API key 交换为非致命步骤,兼容无 organization 的个人账户。 Co-Authored-By: Claude Opus 4.7 --- .../oauth/__tests__/openai-codex.test.ts | 238 +++++++++++ src/services/oauth/openai-codex.ts | 373 ++++++++++++++++++ 2 files changed, 611 insertions(+) create mode 100644 src/services/oauth/__tests__/openai-codex.test.ts create mode 100644 src/services/oauth/openai-codex.ts diff --git a/src/services/oauth/__tests__/openai-codex.test.ts b/src/services/oauth/__tests__/openai-codex.test.ts new file mode 100644 index 000000000..19b22b40b --- /dev/null +++ b/src/services/oauth/__tests__/openai-codex.test.ts @@ -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') + }) + }) +}) diff --git a/src/services/oauth/openai-codex.ts b/src/services/oauth/openai-codex.ts new file mode 100644 index 000000000..39af8e8c4 --- /dev/null +++ b/src/services/oauth/openai-codex.ts @@ -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 { + 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 { + 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 = ` +Login Successful + +

Authentication Complete

You can close this window.

` + +const ERROR_HTML = (msg: string) => ` +Login Error + +

Authentication Failed

${msg}

` + +// ─── Local callback server ────────────────────────────────────────────────── + +function startCallbackServer( + state: string, + port: number, +): Promise<{ + waitForCode: () => Promise + close: () => void +}> { + let settlePromise: ((code: string) => void) | ((error: Error) => void) | null = null + + const codePromise = new Promise((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 +} + +/** + * 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 { + 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, +} From cd59a88d4412be29f75abadd0b4ebba2ef33cfc3 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sun, 26 Apr 2026 21:58:30 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=20ChatGPT=20OAut?= =?UTF-8?q?h=20=E8=AE=A2=E9=98=85=E7=99=BB=E5=BD=95=E5=88=B0=20/login=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加 Codex ChatGPT 菜单项、OAuth 等待界面、手动 code 输入支持。 Co-Authored-By: Claude Opus 4.7 --- src/components/ConsoleOAuthFlow.tsx | 214 ++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index 7b973fe75..236834401 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -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,6 +56,8 @@ 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: '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 @@ -108,6 +111,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 @@ -186,6 +196,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 @@ -301,6 +344,64 @@ 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(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, + }) + + const env: Record = { + CODEX_API_KEY: result.apiKey ?? undefined, + CODEX_ACCESS_TOKEN: result.accessToken, + CODEX_REFRESH_TOKEN: result.refreshToken, + CODEX_LOGIN_METHOD: 'chatgpt_subscription', + } + updateSettingsForSource('userSettings', { + modelType: 'openai-responses' as any, + env, + } as any) + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + process.env[key] = value + } + } + + setOAuthStatus({ state: 'success' }) + void sendNotification( + { + message: 'OpenAI Codex (ChatGPT) login successful', + notificationType: 'auth_success', + }, + terminal, + ) + onDone() + } 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(() => { @@ -316,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') { @@ -334,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 () => { @@ -399,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} /> @@ -420,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({ @@ -437,6 +580,13 @@ function OAuthStatusMessage({ setOAuthStatus, setLoginWithClaudeAi, onDone, + showCodexPastePrompt, + codexUrlCopied, + codexPastedCode, + setCodexPastedCode, + codexPastedCursor, + setCodexPastedCursor, + handleCodexPasteSubmit, }: OAuthStatusMessageProps): React.ReactNode { switch (oauthStatus.state) { case 'idle': @@ -475,6 +625,16 @@ function OAuthStatusMessage({ ), value: 'openai_chat_api', }, + { + label: ( + + OpenAI Codex (ChatGPT Subscription) -{' '} + Login with ChatGPT Plus/Pro + {'\n'} + + ), + value: 'codex_chatgpt', + }, { label: ( @@ -552,6 +712,9 @@ function OAuthStatusMessage({ opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '', activeField: 'base_url', }) + } else if (value === 'codex_chatgpt') { + logEvent('tengu_codex_chatgpt_selected', {}) + setOAuthStatus({ state: 'codex_oauth_start' }) } else if (value === 'gemini_api') { logEvent('tengu_gemini_api_selected', {}) setOAuthStatus({ @@ -1275,6 +1438,57 @@ function OAuthStatusMessage({ ) } + case 'codex_oauth_waiting': { + const { url } = oauthStatus as { state: 'codex_oauth_waiting'; url: string } + const codexPasteColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1 + return ( + + {!showCodexPastePrompt && ( + + + Opening browser for ChatGPT login... + + )} + {showCodexPastePrompt && ( + + + + Browser didn't open? Use the url below to sign in{' '} + + {codexUrlCopied ? ( + (Copied!) + ) : ( + + + + )} + + + {url} + + + )} + {showCodexPastePrompt && ( + + {PASTE_HERE_MSG} + + + )} + + Press Esc to cancel + + + ) + } + case 'platform_setup': return ( From 13799b5058e5622172a05637d8ccc6603b4335c8 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sun, 26 Apr 2026 22:06:34 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E5=B0=86=20modelType=20=E4=BB=8E=20?= =?UTF-8?q?openai-responses=20=E6=94=B9=E4=B8=BA=20codex=20=E5=B9=B6?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E6=9E=9A=E4=B8=BE=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- src/components/ConsoleOAuthFlow.tsx | 2 +- src/utils/settings/types.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index 236834401..1270f84e9 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -372,7 +372,7 @@ export function ConsoleOAuthFlow({ CODEX_LOGIN_METHOD: 'chatgpt_subscription', } updateSettingsForSource('userSettings', { - modelType: 'openai-responses' as any, + modelType: 'codex', env, } as any) for (const [key, value] of Object.entries(env)) { diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index e7b0bbfb5..520cbd5ea 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -369,11 +369,11 @@ export const SettingsSchema = lazySchema(() => .optional() .describe('Tool usage permissions configuration'), modelType: z - .enum(['anthropic', 'openai', 'gemini', 'grok']) + .enum(['anthropic', 'openai', 'gemini', 'grok', 'codex']) .optional() .describe( - 'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API, "gemini" uses the Gemini API, and "grok" uses the xAI Grok API (OpenAI-compatible). ' + - 'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL. When set to "gemini", configure GEMINI_API_KEY and optional GEMINI_BASE_URL. When set to "grok", configure GROK_API_KEY (or XAI_API_KEY), optional GROK_BASE_URL, GROK_MODEL, and GROK_MODEL_MAP.', + 'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API, "gemini" uses the Gemini API, "grok" uses the xAI Grok API (OpenAI-compatible), and "codex" uses the 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() From 4427a6c6dbe00e9826620ea75717df22911d2f09 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sun, 26 Apr 2026 22:23:09 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=E6=B3=A8=E5=86=8C=20codex=20modelT?= =?UTF-8?q?ype=20=E5=B9=B6=E6=B7=BB=E5=8A=A0=20/provider=20codex=20?= =?UTF-8?q?=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - providers.ts: 添加 codex 到 APIProvider 类型和路由 - provider.ts: /provider codex 切换,含 CODEX_API_KEY 检查 - configs.ts: 所有 12 个模型配置添加 codex 字段 - status.tsx: 状态栏显示 Codex API - managedEnvConstants.ts: 注册 CODEX_* 环境变量 Co-Authored-By: Claude Opus 4.7 --- src/commands/provider.ts | 22 ++++++++++++++++++---- src/utils/managedEnvConstants.ts | 20 ++++++++++++++++++++ src/utils/model/configs.ts | 12 ++++++++++++ src/utils/model/providers.ts | 3 +++ src/utils/status.tsx | 1 + 5 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/commands/provider.ts b/src/commands/provider.ts index 19b19c021..eae2e2dd2 100644 --- a/src/commands/provider.ts +++ b/src/commands/provider.ts @@ -63,6 +63,7 @@ const call: LocalCommandCall = async (args, context) => { const validProviders = [ 'anthropic', 'openai', + 'codex', 'gemini', 'grok', 'bedrock', @@ -120,10 +121,23 @@ 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 + if (!hasKey) { + updateSettingsForSource('userSettings', { modelType: 'codex' }) + return { + type: 'text', + value: `Switched to Codex provider.\nWarning: Missing env var: CODEX_API_KEY\nConfigure it via /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) - if (arg === 'anthropic' || arg === 'openai' || arg === 'gemini' || arg === 'grok') { + if (arg === 'anthropic' || arg === 'openai' || arg === 'codex' || arg === 'gemini' || arg === 'grok') { // Clear any cloud provider env vars to avoid conflicts delete process.env.CLAUDE_CODE_USE_BEDROCK delete process.env.CLAUDE_CODE_USE_VERTEX @@ -131,7 +145,7 @@ const call: LocalCommandCall = async (args, context) => { delete process.env.CLAUDE_CODE_USE_OPENAI delete process.env.CLAUDE_CODE_USE_GEMINI delete process.env.CLAUDE_CODE_USE_GROK - // Update settings.json + delete process.env.CLAUDE_CODE_USE_CODEX updateSettingsForSource('userSettings', { modelType: arg }) // Ensure settings.env gets applied to process.env applyConfigEnvironmentVariables() @@ -157,9 +171,9 @@ const provider = { type: 'local', name: 'provider', description: - 'Switch API provider (anthropic/openai/gemini/grok/bedrock/vertex/foundry)', + 'Switch API provider (anthropic/openai/codex/gemini/grok/bedrock/vertex/foundry)', aliases: ['api'], - argumentHint: '[anthropic|openai|gemini|grok|bedrock|vertex|foundry|unset]', + argumentHint: '[anthropic|openai|codex|gemini|grok|bedrock|vertex|foundry|unset]', supportsNonInteractive: true, load: () => Promise.resolve({ call }), } satisfies Command diff --git a/src/utils/managedEnvConstants.ts b/src/utils/managedEnvConstants.ts index d1976c114..688e794c5 100644 --- a/src/utils/managedEnvConstants.ts +++ b/src/utils/managedEnvConstants.ts @@ -23,6 +23,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_CODEX', // Endpoint config (base URLs, project/resource identifiers) 'ANTHROPIC_BASE_URL', 'ANTHROPIC_BEDROCK_BASE_URL', @@ -31,6 +32,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'ANTHROPIC_FOUNDRY_RESOURCE', 'ANTHROPIC_VERTEX_PROJECT_ID', 'GEMINI_BASE_URL', + 'CODEX_BASE_URL', // Region routing (per-model VERTEX_REGION_CLAUDE_* handled by prefix below) 'CLOUD_ML_REGION', // Auth @@ -43,6 +45,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'CLAUDE_CODE_SKIP_VERTEX_AUTH', 'CLAUDE_CODE_SKIP_FOUNDRY_AUTH', 'GEMINI_API_KEY', + 'CODEX_API_KEY', // Model defaults — often set to provider-specific ID formats 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', @@ -92,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 = [ @@ -201,6 +215,7 @@ export const SAFE_ENV_VARS = new Set([ 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_GEMINI', 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_CODEX', 'GEMINI_MODEL', 'GEMINI_SMALL_FAST_MODEL', 'GEMINI_DEFAULT_HAIKU_MODEL', @@ -215,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', diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index 58d157d9c..3a1bbf617 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -13,6 +13,7 @@ export const CLAUDE_3_7_SONNET_CONFIG = { foundry: 'claude-3-7-sonnet', openai: 'claude-3-7-sonnet-20250219', gemini: 'claude-3-7-sonnet-20250219', + codex: 'claude-3-7-sonnet-20250219', grok: 'claude-3-7-sonnet-20250219', } as const satisfies ModelConfig @@ -23,6 +24,7 @@ export const CLAUDE_3_5_V2_SONNET_CONFIG = { foundry: 'claude-3-5-sonnet', openai: 'claude-3-5-sonnet-20241022', gemini: 'claude-3-5-sonnet-20241022', + codex: 'claude-3-5-sonnet-20241022', grok: 'claude-3-5-sonnet-20241022', } as const satisfies ModelConfig @@ -33,6 +35,7 @@ export const CLAUDE_3_5_HAIKU_CONFIG = { foundry: 'claude-3-5-haiku', openai: 'claude-3-5-haiku-20241022', gemini: 'claude-3-5-haiku-20241022', + codex: 'claude-3-5-haiku-20241022', grok: 'claude-3-5-haiku-20241022', } as const satisfies ModelConfig @@ -43,6 +46,7 @@ export const CLAUDE_HAIKU_4_5_CONFIG = { foundry: 'claude-haiku-4-5', openai: 'claude-haiku-4-5-20251001', gemini: 'claude-haiku-4-5-20251001', + codex: 'claude-haiku-4-5-20251001', grok: 'claude-haiku-4-5-20251001', } as const satisfies ModelConfig @@ -53,6 +57,7 @@ export const CLAUDE_SONNET_4_CONFIG = { foundry: 'claude-sonnet-4', openai: 'claude-sonnet-4-20250514', gemini: 'claude-sonnet-4-20250514', + codex: 'claude-sonnet-4-20250514', grok: 'claude-sonnet-4-20250514', } as const satisfies ModelConfig @@ -63,6 +68,7 @@ export const CLAUDE_SONNET_4_5_CONFIG = { foundry: 'claude-sonnet-4-5', openai: 'claude-sonnet-4-5-20250929', gemini: 'claude-sonnet-4-5-20250929', + codex: 'claude-sonnet-4-5-20250929', grok: 'claude-sonnet-4-5-20250929', } as const satisfies ModelConfig @@ -73,6 +79,7 @@ export const CLAUDE_OPUS_4_CONFIG = { foundry: 'claude-opus-4', openai: 'claude-opus-4-20250514', gemini: 'claude-opus-4-20250514', + codex: 'claude-opus-4-20250514', grok: 'claude-opus-4-20250514', } as const satisfies ModelConfig @@ -83,6 +90,7 @@ export const CLAUDE_OPUS_4_1_CONFIG = { foundry: 'claude-opus-4-1', openai: 'claude-opus-4-1-20250805', gemini: 'claude-opus-4-1-20250805', + codex: 'claude-opus-4-1-20250805', grok: 'claude-opus-4-1-20250805', } as const satisfies ModelConfig @@ -93,6 +101,7 @@ export const CLAUDE_OPUS_4_5_CONFIG = { foundry: 'claude-opus-4-5', openai: 'claude-opus-4-5-20251101', gemini: 'claude-opus-4-5-20251101', + codex: 'claude-opus-4-5-20251101', grok: 'claude-opus-4-5-20251101', } as const satisfies ModelConfig @@ -103,6 +112,7 @@ export const CLAUDE_OPUS_4_6_CONFIG = { foundry: 'claude-opus-4-6', openai: 'claude-opus-4-6', gemini: 'claude-opus-4-6', + codex: 'claude-opus-4-6', grok: 'claude-opus-4-6', } as const satisfies ModelConfig @@ -113,6 +123,7 @@ export const CLAUDE_OPUS_4_7_CONFIG = { foundry: 'claude-opus-4-7', openai: 'claude-opus-4-7', gemini: 'claude-opus-4-7', + codex: 'claude-opus-4-7', grok: 'claude-opus-4-7', } as const satisfies ModelConfig @@ -123,6 +134,7 @@ export const CLAUDE_SONNET_4_6_CONFIG = { foundry: 'claude-sonnet-4-6', openai: 'claude-sonnet-4-6', gemini: 'claude-sonnet-4-6', + codex: 'claude-sonnet-4-6', grok: 'claude-sonnet-4-6', } as const satisfies ModelConfig diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index 79572d42e..d04f88f70 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -8,12 +8,14 @@ export type APIProvider = | 'vertex' | 'foundry' | 'openai' + | 'codex' | 'gemini' | 'grok' export function getAPIProvider(): APIProvider { const modelType = getInitialSettings().modelType if (modelType === 'openai') return 'openai' + if (modelType === 'codex') return 'codex' if (modelType === 'gemini') return 'gemini' if (modelType === 'grok') return 'grok' @@ -22,6 +24,7 @@ export function getAPIProvider(): APIProvider { if (isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)) return 'foundry' if (isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) return 'openai' + if (isEnvTruthy(process.env.CLAUDE_CODE_USE_CODEX)) return 'codex' if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) return 'gemini' if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GROK)) return 'grok' diff --git a/src/utils/status.tsx b/src/utils/status.tsx index b4cb087c2..f45a1436e 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -342,6 +342,7 @@ export function buildAPIProviderProperties(): Property[] { gemini: 'Gemini API', grok: 'Grok API', openai: 'OpenAI API', + codex: 'Codex API', }[apiProvider] properties.push({ label: 'API provider', From d091dd8baeec8797c22035f138d80bd6162983f2 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sun, 26 Apr 2026 22:48:17 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Codex=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=20provider=20=E5=AE=8C=E6=95=B4=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 codex API 客户端、流适配、消息/工具转换、模型映射 - 支持 CODEX_API_KEY 和 CODEX_ACCESS_TOKEN 双认证 fallback - 集成到 claude.ts 调度链和 Langfuse 可观测性 - 包含模型映射单元测试(16 cases) Co-Authored-By: Claude Opus 4.7 --- packages/@ant/model-provider/src/index.ts | 7 + .../codex/__tests__/modelMapping.test.ts | 94 +++ .../src/providers/codex/callIds.ts | 31 + .../src/providers/codex/convertMessages.ts | 392 ++++++++++ .../src/providers/codex/convertTools.ts | 39 + .../src/providers/codex/modelMapping.ts | 85 +++ src/commands/provider.ts | 4 +- src/services/api/claude.ts | 6 + src/services/api/codex/client.ts | 57 ++ src/services/api/codex/errors.ts | 115 +++ src/services/api/codex/imageUpload.ts | 132 ++++ src/services/api/codex/index.ts | 304 ++++++++ src/services/api/codex/preflight.ts | 151 ++++ src/services/api/codex/streaming.ts | 681 ++++++++++++++++++ src/services/langfuse/tracing.ts | 2 + 15 files changed, 2098 insertions(+), 2 deletions(-) create mode 100644 packages/@ant/model-provider/src/providers/codex/__tests__/modelMapping.test.ts create mode 100644 packages/@ant/model-provider/src/providers/codex/callIds.ts create mode 100644 packages/@ant/model-provider/src/providers/codex/convertMessages.ts create mode 100644 packages/@ant/model-provider/src/providers/codex/convertTools.ts create mode 100644 packages/@ant/model-provider/src/providers/codex/modelMapping.ts create mode 100644 src/services/api/codex/client.ts create mode 100644 src/services/api/codex/errors.ts create mode 100644 src/services/api/codex/imageUpload.ts create mode 100644 src/services/api/codex/index.ts create mode 100644 src/services/api/codex/preflight.ts create mode 100644 src/services/api/codex/streaming.ts diff --git a/packages/@ant/model-provider/src/index.ts b/packages/@ant/model-provider/src/index.ts index a4acf428c..6f0ccdbaf 100644 --- a/packages/@ant/model-provider/src/index.ts +++ b/packages/@ant/model-provider/src/index.ts @@ -61,3 +61,10 @@ export { anthropicMessagesToOpenAI } from './shared/openaiConvertMessages.js' export type { ConvertMessagesOptions } from './shared/openaiConvertMessages.js' export { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from './shared/openaiConvertTools.js' export { adaptOpenAIStreamToAnthropic } from './shared/openaiStreamAdapter.js' + +// Codex provider utilities +export { normalizeCodexCallId, resolveCodexCallId, createCodexFallbackCallId } from './providers/codex/callIds.js' +export { resolveCodexModel, resolveCodexMaxTokens } from './providers/codex/modelMapping.js' +export { anthropicMessagesToCodexInput } from './providers/codex/convertMessages.js' +export type { CodexImageConversionOptions } from './providers/codex/convertMessages.js' +export { anthropicToolsToCodex } from './providers/codex/convertTools.js' diff --git a/packages/@ant/model-provider/src/providers/codex/__tests__/modelMapping.test.ts b/packages/@ant/model-provider/src/providers/codex/__tests__/modelMapping.test.ts new file mode 100644 index 000000000..cbd7be7d9 --- /dev/null +++ b/packages/@ant/model-provider/src/providers/codex/__tests__/modelMapping.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { resolveCodexModel } from '../modelMapping.js' + +describe('resolveCodexModel', () => { + const originalEnv = { + CODEX_MODEL: process.env.CODEX_MODEL, + CODEX_DEFAULT_HAIKU_MODEL: process.env.CODEX_DEFAULT_HAIKU_MODEL, + CODEX_DEFAULT_SONNET_MODEL: process.env.CODEX_DEFAULT_SONNET_MODEL, + CODEX_DEFAULT_OPUS_MODEL: process.env.CODEX_DEFAULT_OPUS_MODEL, + } + + beforeEach(() => { + delete process.env.CODEX_MODEL + delete process.env.CODEX_DEFAULT_HAIKU_MODEL + delete process.env.CODEX_DEFAULT_SONNET_MODEL + delete process.env.CODEX_DEFAULT_OPUS_MODEL + }) + + afterEach(() => { + Object.assign(process.env, originalEnv) + }) + + test('CODEX_MODEL env var overrides all', () => { + process.env.CODEX_MODEL = 'my-custom-model' + expect(resolveCodexModel('claude-sonnet-4-6')).toBe('my-custom-model') + }) + + test('CODEX_DEFAULT_SONNET_MODEL overrides default map', () => { + process.env.CODEX_DEFAULT_SONNET_MODEL = 'my-sonnet' + expect(resolveCodexModel('claude-sonnet-4-6')).toBe('my-sonnet') + }) + + test('CODEX_DEFAULT_HAIKU_MODEL overrides default map', () => { + process.env.CODEX_DEFAULT_HAIKU_MODEL = 'my-haiku' + expect(resolveCodexModel('claude-haiku-4-5-20251001')).toBe('my-haiku') + }) + + test('CODEX_DEFAULT_OPUS_MODEL overrides default map', () => { + process.env.CODEX_DEFAULT_OPUS_MODEL = 'my-opus' + expect(resolveCodexModel('claude-opus-4-6')).toBe('my-opus') + }) + + test('maps known sonnet model via DEFAULT_MODEL_MAP', () => { + expect(resolveCodexModel('claude-sonnet-4-6')).toBe('gpt-5.4-mini') + }) + + test('maps known haiku model via DEFAULT_MODEL_MAP', () => { + expect(resolveCodexModel('claude-haiku-4-5-20251001')).toBe('gpt-5.4-nano') + }) + + test('maps known opus model via DEFAULT_MODEL_MAP', () => { + expect(resolveCodexModel('claude-opus-4-6')).toBe('gpt-5.4') + }) + + test('maps legacy sonnet models', () => { + expect(resolveCodexModel('claude-sonnet-4-20250514')).toBe('gpt-5.4-mini') + expect(resolveCodexModel('claude-3-5-sonnet-20241022')).toBe('gpt-5.4-mini') + }) + + test('maps legacy haiku models', () => { + expect(resolveCodexModel('claude-3-5-haiku-20241022')).toBe('gpt-5.4-nano') + }) + + test('maps legacy opus models', () => { + expect(resolveCodexModel('claude-opus-4-20250514')).toBe('gpt-5.4') + expect(resolveCodexModel('claude-opus-4-5-20251101')).toBe('gpt-5.4') + }) + + test('uses family default for unrecognized haiku model', () => { + expect(resolveCodexModel('claude-haiku-99')).toBe('gpt-5.4-nano') + }) + + test('uses family default for unrecognized sonnet model', () => { + expect(resolveCodexModel('claude-sonnet-99')).toBe('gpt-5.4-mini') + }) + + test('uses family default for unrecognized opus model', () => { + expect(resolveCodexModel('claude-opus-99')).toBe('gpt-5.4') + }) + + test('passes through unknown model name without family', () => { + expect(resolveCodexModel('some-random-model')).toBe('some-random-model') + }) + + test('strips [1m] suffix', () => { + expect(resolveCodexModel('claude-sonnet-4-6[1m]')).toBe('gpt-5.4-mini') + }) + + test('CODEX_MODEL takes precedence over family-specific vars', () => { + process.env.CODEX_MODEL = 'global-override' + process.env.CODEX_DEFAULT_SONNET_MODEL = 'family-override' + expect(resolveCodexModel('claude-sonnet-4-6')).toBe('global-override') + }) +}) diff --git a/packages/@ant/model-provider/src/providers/codex/callIds.ts b/packages/@ant/model-provider/src/providers/codex/callIds.ts new file mode 100644 index 000000000..9e21ff2f8 --- /dev/null +++ b/packages/@ant/model-provider/src/providers/codex/callIds.ts @@ -0,0 +1,31 @@ +import { createHash } from 'crypto' + +const MAX_CODEX_CALL_ID_LENGTH = 96 + +export function normalizeCodexCallId(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + + const sanitized = value + .trim() + .replace(/\s+/g, '_') + .replace(/[^A-Za-z0-9._:-]/g, '_') + .replace(/_+/g, '_') + .slice(0, MAX_CODEX_CALL_ID_LENGTH) + + return sanitized.length > 0 ? sanitized : null +} + +export function createCodexFallbackCallId(seed: string): string { + const hash = createHash('sha1') + .update(seed.length > 0 ? seed : 'codex-call') + .digest('hex') + .slice(0, 24) + + return `call_${hash}` +} + +export function resolveCodexCallId(value: unknown, seed: string): string { + return normalizeCodexCallId(value) ?? createCodexFallbackCallId(seed) +} diff --git a/packages/@ant/model-provider/src/providers/codex/convertMessages.ts b/packages/@ant/model-provider/src/providers/codex/convertMessages.ts new file mode 100644 index 000000000..5bb8b1d31 --- /dev/null +++ b/packages/@ant/model-provider/src/providers/codex/convertMessages.ts @@ -0,0 +1,392 @@ +import type { + ResponseFunctionToolCallOutputItem, + ResponseInputImage, + ResponseInputItem, + ResponseInputText, +} from 'openai/resources/responses/responses.mjs' +import type { Message } from '../../types/index.js' +import { + normalizeCodexCallId, + resolveCodexCallId, +} from './callIds.js' + +type ContentBlock = { + type: string + text?: string + source?: { + type?: string + data?: string + media_type?: string + url?: string + } +} + +type ToolUseLikeBlock = { + type: 'tool_use' + id: string + name: string + input: unknown +} + +type ToolResultLikeBlock = { + type: 'tool_result' + tool_use_id: string + content?: string | ReadonlyArray +} + +export type CodexImageConversionOptions = { + resolveBase64ImageUrl?: ( + data: string, + mediaType?: string, + ) => Promise +} + +type CodexCallIdState = { + byOriginalId: Map + sequence: number +} + +function createInputText(text: string): ResponseInputText { + return { + type: 'input_text', + text, + } +} + +function createInputImage(imageUrl: string): ResponseInputImage { + return { + type: 'input_image', + image_url: imageUrl, + detail: 'high', + } +} + +function getUnsupportedBlockText(type: string): string | null { + switch (type) { + case 'image': + return '[Image omitted: codex gateway currently requires remote image URLs. Configure CODEX_IMGBB_API_KEY to auto-convert local images.]' + case 'document': + return '[Document omitted: codex gateway does not support document replay.]' + default: + return null + } +} + +function getImageUrl(block: ContentBlock): string | null { + const source = block.source + if (!source) { + return null + } + + if (source.type === 'url' && typeof source.url === 'string' && source.url.length > 0) { + return source.url + } + + return null +} + +async function resolveImageUrl( + block: ContentBlock, + options: CodexImageConversionOptions, +): Promise { + const directUrl = getImageUrl(block) + if (directUrl) { + return directUrl + } + + if (block.source?.type !== 'base64') { + return null + } + + if (options.resolveBase64ImageUrl && typeof block.source.data === 'string') { + const uploadedUrl = await options.resolveBase64ImageUrl( + block.source.data, + block.source.media_type, + ) + if (uploadedUrl) { + return uploadedUrl + } + } + return null +} + +async function convertBlocksToInputContent( + content: ReadonlyArray, + options: CodexImageConversionOptions, +): Promise> { + const output: Array = [] + + for (const block of content) { + if (block.type === 'text' && block.text) { + output.push(createInputText(block.text)) + continue + } + + if (block.type === 'image') { + const imageUrl = await resolveImageUrl(block, options) + if (imageUrl) { + output.push(createInputImage(imageUrl)) + continue + } + } + + const fallback = getUnsupportedBlockText(block.type) + if (fallback) { + output.push(createInputText(fallback)) + } + } + + return output +} + +async function convertToolResultOutput( + content: string | ReadonlyArray | undefined, + options: CodexImageConversionOptions, +): Promise { + if (!content) { + return '' + } + + if (typeof content === 'string') { + return content + } + + const output = await convertBlocksToInputContent(content, options) + + if (output.length === 0) { + return '' + } + + if (output.length === 1 && output[0].type === 'input_text') { + return output[0].text + } + + return output +} + +function pushUserMessage( + items: ResponseInputItem[], + textParts: string[], + imageUrls: string[] = [], +): void { + const text = textParts.join('\n').trim() + if (text.length === 0 && imageUrls.length === 0) { + return + } + + items.push({ + type: 'message', + role: 'user', + content: [ + ...(text.length > 0 ? [createInputText(text)] : []), + ...imageUrls.map(createInputImage), + ], + } as unknown as ResponseInputItem) +} + +function pushAssistantMessage( + items: ResponseInputItem[], + textParts: string[], +): void { + const text = textParts.join('\n').trim() + if (text.length === 0) { + return + } + + items.push({ + type: 'message', + role: 'assistant', + content: [ + { + type: 'output_text', + text, + annotations: [], + }, + ], + } as unknown as ResponseInputItem) +} + +function stringifyToolInput(input: unknown): string { + if (typeof input === 'string') { + return input + } + + try { + return JSON.stringify(input ?? {}) + } catch { + return '{}' + } +} + +function createCodexCallIdState(): CodexCallIdState { + return { + byOriginalId: new Map(), + sequence: 0, + } +} + +function resolveAssistantCallId( + block: ToolUseLikeBlock, + state: CodexCallIdState, +): string { + const originalId = typeof block.id === 'string' ? block.id : '' + const seed = `${block.name}:${stringifyToolInput(block.input)}:${state.sequence}` + const callId = resolveCodexCallId(originalId, seed) + + if (originalId.length > 0) { + state.byOriginalId.set(originalId, callId) + } + state.sequence += 1 + + return callId +} + +function resolveToolResultCallId( + toolUseId: unknown, + state: CodexCallIdState, +): string | null { + if (typeof toolUseId !== 'string') { + return null + } + + return state.byOriginalId.get(toolUseId) ?? normalizeCodexCallId(toolUseId) +} + +async function convertUserContentToInputItems( + items: ResponseInputItem[], + content: ReadonlyArray, + options: CodexImageConversionOptions, + callIdState: CodexCallIdState, +): Promise { + const textParts: string[] = [] + const imageUrls: string[] = [] + + for (const block of content) { + if (typeof block === 'string') { + textParts.push(block) + continue + } + + if (block.type === 'tool_result') { + pushUserMessage(items, textParts, imageUrls) + textParts.length = 0 + imageUrls.length = 0 + + const toolResultBlock = block as ToolResultLikeBlock + const callId = resolveToolResultCallId( + toolResultBlock.tool_use_id, + callIdState, + ) + if (!callId) { + continue + } + + items.push({ + type: 'function_call_output', + call_id: callId, + output: await convertToolResultOutput(toolResultBlock.content, options), + }) + continue + } + + if (block.type === 'text' && block.text) { + textParts.push(block.text) + continue + } + + if (block.type === 'image') { + const imageUrl = await resolveImageUrl(block, options) + if (imageUrl) { + imageUrls.push(imageUrl) + continue + } + } + + const fallback = getUnsupportedBlockText(block.type) + if (fallback) { + textParts.push(fallback) + } + } + + pushUserMessage(items, textParts, imageUrls) +} + +function convertAssistantContentToInputItems( + items: ResponseInputItem[], + content: ReadonlyArray, + callIdState: CodexCallIdState, +): void { + const textParts: string[] = [] + + for (const block of content) { + if (typeof block === 'string') { + textParts.push(block) + continue + } + + if (block.type === 'tool_use') { + pushAssistantMessage(items, textParts) + textParts.length = 0 + + const toolUseBlock = block as unknown as ToolUseLikeBlock + items.push({ + type: 'function_call', + call_id: resolveAssistantCallId(toolUseBlock, callIdState), + name: toolUseBlock.name, + arguments: stringifyToolInput(toolUseBlock.input), + }) + continue + } + + if (block.type === 'text' && block.text) { + textParts.push(block.text) + } + } + + pushAssistantMessage(items, textParts) +} + +export async function anthropicMessagesToCodexInput( + messages: Message[], + options: CodexImageConversionOptions = {}, +): Promise { + const items: ResponseInputItem[] = [] + const callIdState = createCodexCallIdState() + + for (const message of messages) { + if (message.type !== 'user' && message.type !== 'assistant') { + continue + } + + const apiMessage = message.message + if (!apiMessage?.content) { + continue + } + + if (typeof apiMessage.content === 'string') { + if (message.type === 'user') { + pushUserMessage(items, [apiMessage.content]) + } else { + pushAssistantMessage(items, [apiMessage.content]) + } + continue + } + + if (message.type === 'user') { + await convertUserContentToInputItems( + items, + apiMessage.content as ReadonlyArray, + options, + callIdState, + ) + } else { + convertAssistantContentToInputItems( + items, + apiMessage.content as ReadonlyArray, + callIdState, + ) + } + } + + return items +} diff --git a/packages/@ant/model-provider/src/providers/codex/convertTools.ts b/packages/@ant/model-provider/src/providers/codex/convertTools.ts new file mode 100644 index 000000000..0a0dd11e2 --- /dev/null +++ b/packages/@ant/model-provider/src/providers/codex/convertTools.ts @@ -0,0 +1,39 @@ +import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import type { Tool as CodexTool } from 'openai/resources/responses/responses.mjs' + +function isClientFunctionTool( + tool: BetaToolUnion, +): tool is BetaToolUnion & { + name: string + description?: string + input_schema?: { [key: string]: unknown } + strict?: boolean + defer_loading?: boolean +} { + const value = tool as unknown as Record + return typeof value.name === 'string' +} + +export function anthropicToolsToCodex( + tools: BetaToolUnion[], +): CodexTool[] { + return tools.flatMap(tool => { + const value = tool as unknown as Record + if ( + value.type === 'advisor_20260301' || + value.type === 'computer_20250124' || + !isClientFunctionTool(tool) + ) { + return [] + } + + return [{ + type: 'function', + name: tool.name, + description: tool.description, + parameters: tool.input_schema ?? {}, + strict: tool.strict ?? null, + ...(tool.defer_loading && { defer_loading: true }), + }] + }) +} diff --git a/packages/@ant/model-provider/src/providers/codex/modelMapping.ts b/packages/@ant/model-provider/src/providers/codex/modelMapping.ts new file mode 100644 index 000000000..3bee191b4 --- /dev/null +++ b/packages/@ant/model-provider/src/providers/codex/modelMapping.ts @@ -0,0 +1,85 @@ +/** + * Default mapping from Anthropic model names to Codex (OpenAI Responses API) model names. + * Used only when CODEX_DEFAULT_{FAMILY}_MODEL env vars are not set. + */ +const DEFAULT_MODEL_MAP: Record = { + 'claude-sonnet-4-20250514': 'gpt-5.4-mini', + 'claude-sonnet-4-5-20250929': 'gpt-5.4-mini', + 'claude-sonnet-4-6': 'gpt-5.4-mini', + 'claude-3-7-sonnet-20250219': 'gpt-5.4-mini', + 'claude-3-5-sonnet-20241022': 'gpt-5.4-mini', + 'claude-opus-4-20250514': 'gpt-5.4', + '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', +} + +/** + * Default model for each family when an exact match is not in DEFAULT_MODEL_MAP. + */ +const DEFAULT_FAMILY_MAP: Record = { + haiku: 'gpt-5.4-nano', + sonnet: 'gpt-5.4-mini', + opus: 'gpt-5.4', +} + +function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null { + if (/haiku/i.test(model)) return 'haiku' + if (/opus/i.test(model)) return 'opus' + if (/sonnet/i.test(model)) return 'sonnet' + return null +} + +/** + * Resolve the Codex (OpenAI Responses API) model name for a given Anthropic model. + * + * Priority: + * 1. CODEX_MODEL env var (override all) + * 2. CODEX_DEFAULT_{FAMILY}_MODEL env var (e.g. CODEX_DEFAULT_SONNET_MODEL) + * 3. DEFAULT_MODEL_MAP lookup (exact Anthropic model name match) + * 4. DEFAULT_FAMILY_MAP lookup (family-based default) + * 5. Pass through original model name + */ +export function resolveCodexModel(model: string): string { + if (process.env.CODEX_MODEL) { + return process.env.CODEX_MODEL + } + + const cleanModel = model.replace(/\[1m\]$/, '') + const family = getModelFamily(cleanModel) + if (family) { + const familyOverride = process.env[`CODEX_DEFAULT_${family.toUpperCase()}_MODEL`] + if (familyOverride) { + return familyOverride + } + } + + const mapped = DEFAULT_MODEL_MAP[cleanModel] + if (mapped) { + return mapped + } + + if (family) { + return DEFAULT_FAMILY_MAP[family] + } + + return cleanModel +} + +export function resolveCodexMaxTokens( + upperLimit: number, + maxOutputTokensOverride?: number, +): number { + return ( + maxOutputTokensOverride ?? + (process.env.CODEX_MAX_TOKENS + ? parseInt(process.env.CODEX_MAX_TOKENS, 10) || undefined + : undefined) ?? + (process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS + ? parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, 10) || undefined + : undefined) ?? + upperLimit + ) +} diff --git a/src/commands/provider.ts b/src/commands/provider.ts index eae2e2dd2..e6a839dd6 100644 --- a/src/commands/provider.ts +++ b/src/commands/provider.ts @@ -124,12 +124,12 @@ const call: LocalCommandCall = async (args, context) => { // Check env vars when switching to codex (including settings.env) if (arg === 'codex') { const mergedEnv = getMergedEnv() - const hasKey = !!mergedEnv.CODEX_API_KEY + const hasKey = !!(mergedEnv.CODEX_API_KEY || mergedEnv.CODEX_ACCESS_TOKEN) if (!hasKey) { updateSettingsForSource('userSettings', { modelType: 'codex' }) return { type: 'text', - value: `Switched to Codex provider.\nWarning: Missing env var: CODEX_API_KEY\nConfigure it via /login (ChatGPT Subscription) or set manually.`, + value: `Switched to Codex provider.\nWarning: No CODEX_API_KEY or CODEX_ACCESS_TOKEN found.\nUse /login (ChatGPT Subscription) or set manually.`, } } } diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 720370db6..4e7d096c5 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -1347,6 +1347,12 @@ async function* queryModel( return } + if (getAPIProvider() === 'codex') { + const { queryModelCodex } = await import('./codex/index.js') + yield* queryModelCodex(messagesForAPI, systemPrompt, filteredTools, signal, options) + return + } + if (getAPIProvider() === 'gemini') { const { queryModelGemini } = await import('./gemini/index.js') yield* queryModelGemini( diff --git a/src/services/api/codex/client.ts b/src/services/api/codex/client.ts new file mode 100644 index 000000000..f1eb25639 --- /dev/null +++ b/src/services/api/codex/client.ts @@ -0,0 +1,57 @@ +import OpenAI from 'openai' +import { openaiAdapter } from 'src/services/providerUsage/adapters/openai.js' +import { updateProviderBuckets } from 'src/services/providerUsage/store.js' +import { getProxyFetchOptions } from 'src/utils/proxy.js' + +export const DEFAULT_CODEX_BASE_URL = 'https://api.openai.com/v1' + +let cachedClient: OpenAI | null = null + +function wrapFetchForUsage(base: typeof fetch): typeof fetch { + const wrapped = async ( + ...args: Parameters + ): Promise => { + const res = await base(...args) + try { + updateProviderBuckets('codex', openaiAdapter.parseHeaders(res.headers)) + } catch { + // Usage tracking must not affect the request path. + } + return res + } + return wrapped as unknown as typeof fetch +} + +export function getCodexClient(options?: { + maxRetries?: number + fetchOverride?: typeof fetch +}): OpenAI { + if (cachedClient && !options?.fetchOverride) { + return cachedClient + } + + 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) + + const client = new OpenAI({ + apiKey, + baseURL, + maxRetries: options?.maxRetries ?? 0, + timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10), + dangerouslyAllowBrowser: true, + fetchOptions: getProxyFetchOptions({ forAnthropicAPI: false }), + fetch: wrappedFetch, + }) + + if (!options?.fetchOverride) { + cachedClient = client + } + + return client +} + +export function clearCodexClientCache(): void { + cachedClient = null +} diff --git a/src/services/api/codex/errors.ts b/src/services/api/codex/errors.ts new file mode 100644 index 000000000..a6b384f10 --- /dev/null +++ b/src/services/api/codex/errors.ts @@ -0,0 +1,115 @@ +import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js' + +type CodexErrorLike = { + status?: unknown + message?: unknown + error?: { + message?: unknown + } +} + +export type NormalizedCodexError = { + content: string + error: SDKAssistantMessageError +} + +function readErrorStatus(error: unknown): number | null { + if ( + typeof error === 'object' && + error !== null && + typeof (error as CodexErrorLike).status === 'number' + ) { + return (error as CodexErrorLike).status as number + } + + return null +} + +function readErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.length > 0) { + return error.message + } + + if (typeof error === 'object' && error !== null) { + const value = error as CodexErrorLike + if (typeof value.message === 'string' && value.message.length > 0) { + return value.message + } + if ( + typeof value.error?.message === 'string' && + value.error.message.length > 0 + ) { + return value.error.message + } + } + + return String(error) +} + +export function getCodexConfigurationError(): NormalizedCodexError | null { + if (!process.env.CODEX_API_KEY && !process.env.CODEX_ACCESS_TOKEN) { + return { + content: + 'Missing CODEX_API_KEY or CODEX_ACCESS_TOKEN. Use /login (ChatGPT Subscription) or set manually.', + error: 'authentication_failed', + } + } + + return null +} + +export function normalizeCodexError(error: unknown): NormalizedCodexError { + const status = readErrorStatus(error) + const message = readErrorMessage(error) + + if (/^Codex preflight:/i.test(message)) { + return { + content: message, + error: 'invalid_request', + } + } + + if (status === 401 || status === 403) { + + return { + content: `Codex authentication failed (${status}). ${message}`, + error: 'authentication_failed', + } + } + + if (status === 404) { + return { + content: + 'Codex endpoint not found (404). Verify CODEX_BASE_URL points to a Responses API root.', + error: 'invalid_request', + } + } + + if (status === 429) { + return { + content: + 'Codex rate limit reached (429). Retry shortly or reduce request volume.', + error: 'rate_limit', + } + } + + if (status === 502 && /upstream request failed/i.test(message)) { + return { + 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', + } + } + + if (status !== null && status >= 500) { + return { + content: `Codex server error (${status}): ${message}`, + error: 'server_error', + } + } + + return { + content: `API Error: ${message}`, + error: 'unknown', + } +} diff --git a/src/services/api/codex/imageUpload.ts b/src/services/api/codex/imageUpload.ts new file mode 100644 index 000000000..fef8abdc4 --- /dev/null +++ b/src/services/api/codex/imageUpload.ts @@ -0,0 +1,132 @@ +import { createHash } from 'crypto' +import { logForDebugging } from '../../../utils/debug.js' + +const resolvedImageUrls = new Map() +const DEFAULT_TIMEOUT_MS = 30_000 +const IMGBB_UPLOAD_URL = 'https://api.imgbb.com/1/upload' + +type ImgbbVariant = { + url?: unknown +} + +type ImgbbPayload = { + data?: { + url?: unknown + display_url?: unknown + image?: ImgbbVariant + medium?: ImgbbVariant + thumb?: ImgbbVariant + } +} + +function getUploadTimeoutMs(): number { + const raw = + process.env.CODEX_IMAGE_UPLOAD_TIMEOUT_MS ?? + process.env.CODEX_IMAGE_URL_TIMEOUT_MS + if (!raw) { + return DEFAULT_TIMEOUT_MS + } + + const parsed = Number.parseInt(raw, 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS +} + +function getCacheKey(prefix: string, value: string): string { + return `${prefix}:${createHash('sha256').update(value).digest('hex')}` +} + +function getImgbbApiKey(): string | null { + const apiKey = process.env.CODEX_IMGBB_API_KEY?.trim() + return apiKey && apiKey.length > 0 ? apiKey : null +} + +function pickImgbbImageUrl(payload: ImgbbPayload): string | null { + const candidates = [ + payload.data?.medium?.url, + payload.data?.thumb?.url, + payload.data?.image?.url, + payload.data?.url, + payload.data?.display_url, + ] + + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.length > 0) { + return candidate + } + } + + return null +} + +async function withTimeout( + run: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), getUploadTimeoutMs()) + + try { + return await run(controller.signal) + } finally { + clearTimeout(timeout) + } +} + +async function uploadToImgbb( + base64Image: string, +): Promise { + const apiKey = getImgbbApiKey() + if (!apiKey) { + return null + } + + try { + const url = await withTimeout(async signal => { + const body = new FormData() + body.append('image', base64Image) + + const response = await fetch(`${IMGBB_UPLOAD_URL}?key=${encodeURIComponent(apiKey)}`, { + method: 'POST', + body, + signal, + }) + + if (!response.ok) { + logForDebugging( + `[Codex] ImgBB upload failed: ${response.status} ${response.statusText}`, + ) + return null + } + + return pickImgbbImageUrl((await response.json()) as ImgbbPayload) + }) + + if (!url) { + logForDebugging('[Codex] ImgBB upload produced no usable URL.') + return null + } + + return url + } catch (error) { + logForDebugging(`[Codex] Failed to upload image to ImgBB: ${error}`) + return null + } +} + +export async function uploadCodexBase64Image( + data: string, + mediaType: string = 'image/png', +): Promise { + const cacheKey = getCacheKey('base64', `${mediaType}:${data}`) + const cached = resolvedImageUrls.get(cacheKey) + if (cached) { + return cached + } + + const url = await uploadToImgbb(data) + if (!url) { + return null + } + + resolvedImageUrls.set(cacheKey, url) + return url +} diff --git a/src/services/api/codex/index.ts b/src/services/api/codex/index.ts new file mode 100644 index 000000000..81e9ecfb5 --- /dev/null +++ b/src/services/api/codex/index.ts @@ -0,0 +1,304 @@ +import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import type { + Response, + ResponseCreateParamsNonStreaming, +} from 'openai/resources/responses/responses.mjs' +import { appendFileSync } from 'fs' +import type { SystemPrompt } from '../../../utils/systemPromptType.js' +import type { + AssistantMessage, + Message, + StreamEvent, + SystemAPIErrorMessage, +} from '../../../types/message.js' +import type { Tools } from '../../../Tool.js' +import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js' +import { toolToAPISchema } from '../../../utils/api.js' +import { + createAssistantAPIErrorMessage, + normalizeMessagesForAPI, +} from '../../../utils/messages.js' +import { logForDebugging } from '../../../utils/debug.js' +import { getModelMaxOutputTokens } from '../../../utils/context.js' +import type { Options } from '../claude.js' +import { recordLLMObservation } from '../../../services/langfuse/tracing.js' +import { + convertMessagesToLangfuse, + convertOutputToLangfuse, + convertToolsToLangfuse, +} from '../../../services/langfuse/convert.js' +import { + anthropicMessagesToCodexInput, + anthropicToolsToCodex, + resolveCodexMaxTokens, + resolveCodexModel, +} from '@ant/model-provider' +import { getCodexClient } from './client.js' +import { uploadCodexBase64Image } from './imageUpload.js' +import { + getCodexConfigurationError, + normalizeCodexError, +} from './errors.js' +import { sanitizeCodexRequest } from './preflight.js' +import { + addCodexUsage, + type CodexStreamResult, + type CodexUsage, + rawAssistantBlocksToAssistantMessage, + type RawAssistantBlock, + streamCodexAttempt, +} from './streaming.js' + +const MAX_CODEX_CONTINUATIONS = 3 + +function dumpCodexPayload( + body: ResponseCreateParamsNonStreaming, +): void { + const path = process.env.CODEX_DEBUG_PAYLOADS + if (!path) { + return + } + + appendFileSync( + path, + `${JSON.stringify({ timestamp: new Date().toISOString(), body }, null, 2)}\n`, + ) +} + +function appendRawAssistantBlocks( + target: RawAssistantBlock[], + source: RawAssistantBlock[], +): void { + for (const block of source) { + const lastBlock = target.at(-1) + + if (lastBlock?.type === 'text' && block.type === 'text') { + lastBlock.text += block.text + continue + } + + if ( + lastBlock?.type === 'tool_use' && + block.type === 'tool_use' && + lastBlock.id === block.id && + lastBlock.name === block.name && + block.input.startsWith(lastBlock.input) + ) { + lastBlock.input = block.input + continue + } + + target.push({ ...block }) + } +} + +export async function* queryModelCodex( + messages: Message[], + systemPrompt: SystemPrompt, + tools: Tools, + signal: AbortSignal, + options: Options, +): AsyncGenerator< + StreamEvent | AssistantMessage | SystemAPIErrorMessage, + void +> { + try { + const configurationError = getCodexConfigurationError() + if (configurationError) { + yield createAssistantAPIErrorMessage({ + content: configurationError.content, + apiError: 'api_error', + error: configurationError.error, + }) + return + } + + const model = resolveCodexModel(options.model) + const messagesForAPI = normalizeMessagesForAPI(messages, tools) + const toolSchemas = await Promise.all( + tools.map(tool => + toolToAPISchema(tool, { + getToolPermissionContext: options.getToolPermissionContext, + tools, + agents: options.agents, + allowedAgentTypes: options.allowedAgentTypes, + model: options.model, + }), + ), + ) + const codexTools = anthropicToolsToCodex(toolSchemas as BetaToolUnion[]) + const { upperLimit } = getModelMaxOutputTokens(model) + const maxTokens = resolveCodexMaxTokens( + upperLimit, + options.maxOutputTokensOverride, + ) + + const client = getCodexClient({ + maxRetries: 0, + fetchOverride: options.fetchOverride as typeof fetch | undefined, + }) + const start = Date.now() + const collectedMessages: AssistantMessage[] = [] + let totalUsage: CodexUsage = { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + } + + const aggregateBlocks: RawAssistantBlock[] = [] + let replayMessages = messagesForAPI + let partialMessage: AssistantMessage['message'] | undefined + let finalResponse: Response | undefined + let terminalIncompleteResponse: Response | undefined + + for ( + let attempt = 0; + attempt <= MAX_CODEX_CONTINUATIONS; + attempt += 1 + ) { + const input = await anthropicMessagesToCodexInput(replayMessages, { + resolveBase64ImageUrl: uploadCodexBase64Image, + }) + const requestBody = sanitizeCodexRequest({ + model, + input, + store: false, + parallel_tool_calls: false, + max_output_tokens: maxTokens, + ...(systemPrompt.length > 0 && { + instructions: systemPrompt.join('\n\n'), + }), + ...(codexTools.length > 0 && { + tools: codexTools, + }), + ...(options.temperatureOverride !== undefined && { + temperature: options.temperatureOverride, + }), + } satisfies ResponseCreateParamsNonStreaming) + + if (attempt === 0) { + logForDebugging( + `[Codex] Calling model=${model}, inputItems=${input.length}, tools=${codexTools.length}`, + ) + dumpCodexPayload(requestBody) + } else { + logForDebugging( + `[Codex] Continuing incomplete response attempt ${attempt}/${MAX_CODEX_CONTINUATIONS}`, + ) + } + + const attemptStream = streamCodexAttempt({ + client, + requestBody, + signal, + start, + emitPrimaryEvents: attempt === 0, + }) + + let attemptResult: CodexStreamResult | undefined + while (true) { + const next = await attemptStream.next() + if (next.done) { + attemptResult = next.value + break + } + yield next.value + } + + if (!attemptResult?.response) { + continue + } + + partialMessage = partialMessage ?? attemptResult.partialMessage + finalResponse = attemptResult.response + terminalIncompleteResponse = attemptResult.incompleteResponse + totalUsage = addCodexUsage(totalUsage, attemptResult.response) + + if (attemptResult.assistantBlocks.length === 0) { + break + } + + appendRawAssistantBlocks(aggregateBlocks, attemptResult.assistantBlocks) + + const shouldContinue = + attemptResult.incompleteResponse !== undefined && + attempt < MAX_CODEX_CONTINUATIONS + + if (!shouldContinue) { + break + } + + const continuationMessage = rawAssistantBlocksToAssistantMessage( + attemptResult.assistantBlocks, + attemptResult.response, + tools, + options.agentId, + ) + replayMessages = [...replayMessages, continuationMessage] + } + + if (finalResponse) { + if (aggregateBlocks.length === 0) { + yield createAssistantAPIErrorMessage({ + content: 'Codex returned an empty streamed response.', + apiError: 'api_error', + error: 'unknown', + }) + return + } + + const assistantMessage = rawAssistantBlocksToAssistantMessage( + aggregateBlocks, + finalResponse, + tools, + options.agentId, + ) + assistantMessage.message.usage = totalUsage as any + collectedMessages.push(assistantMessage) + yield assistantMessage + + recordLLMObservation(options.langfuseTrace ?? null, { + model, + provider: process.env.CODEX_LOGIN_METHOD === 'chatgpt_subscription' + ? 'codex-chatgpt' + : 'codex', + input: convertMessagesToLangfuse(messagesForAPI, systemPrompt), + output: convertOutputToLangfuse(collectedMessages), + usage: totalUsage, + startTime: new Date(start), + endTime: new Date(), + completionStartTime: + partialMessage !== undefined ? new Date(start) : undefined, + tools: convertToolsToLangfuse(toolSchemas as unknown[]), + }) + } else { + yield createAssistantAPIErrorMessage({ + content: 'Codex returned an empty streamed response.', + apiError: 'api_error', + error: 'unknown', + }) + return + } + + if ( + terminalIncompleteResponse?.incomplete_details?.reason === + 'max_output_tokens' + ) { + yield createAssistantAPIErrorMessage({ + content: `Output truncated: response exceeded the ${maxTokens} token limit. Set CODEX_MAX_TOKENS or CLAUDE_CODE_MAX_OUTPUT_TOKENS to override.`, + apiError: 'max_output_tokens', + error: 'max_output_tokens' as unknown as SDKAssistantMessageError, + }) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const normalizedError = normalizeCodexError(error) + logForDebugging(`[Codex] Error: ${errorMessage}`, { level: 'error' }) + yield createAssistantAPIErrorMessage({ + content: normalizedError.content, + apiError: 'api_error', + error: normalizedError.error, + }) + } +} diff --git a/src/services/api/codex/preflight.ts b/src/services/api/codex/preflight.ts new file mode 100644 index 000000000..2c6ec9b8b --- /dev/null +++ b/src/services/api/codex/preflight.ts @@ -0,0 +1,151 @@ +import type { + ResponseCreateParamsNonStreaming, + ResponseCreateParamsStreaming, + ResponseInputItem, + Tool, +} from 'openai/resources/responses/responses.mjs' +import { normalizeCodexCallId } from '@ant/model-provider' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function assertString(value: unknown, label: string): string { + if (typeof value !== 'string') { + throw new Error(`Codex preflight: ${label} must be a string.`) + } + + return value +} + +function sanitizeMessageItem(item: Record): ResponseInputItem { + const role = assertString(item.role, 'message.role') + const content = item.content + + if ((role !== 'user' && role !== 'assistant') || !Array.isArray(content)) { + throw new Error('Codex preflight: message items require role and content array.') + } + + return item as unknown as ResponseInputItem +} + +function sanitizeFunctionCallItem(item: Record): ResponseInputItem { + const callId = normalizeCodexCallId(item.call_id) + const name = assertString(item.name, 'function_call.name').trim() + const argumentsValue = item.arguments + + if (!callId) { + throw new Error('Codex preflight: function_call.call_id is required.') + } + if (name.length === 0) { + throw new Error('Codex preflight: function_call.name is required.') + } + if (typeof argumentsValue !== 'string') { + throw new Error('Codex preflight: function_call.arguments must be a string.') + } + + return { + ...item, + call_id: callId, + name, + arguments: argumentsValue, + } as ResponseInputItem +} + +function sanitizeFunctionCallOutputItem( + item: Record, +): ResponseInputItem { + const callId = normalizeCodexCallId(item.call_id) + const output = item.output + + if (!callId) { + throw new Error('Codex preflight: function_call_output.call_id is required.') + } + if ( + typeof output !== 'string' && + !(Array.isArray(output) && output.every(part => isRecord(part))) + ) { + throw new Error( + 'Codex preflight: function_call_output.output must be a string or content array.', + ) + } + + return { + ...item, + call_id: callId, + } as ResponseInputItem +} + +function sanitizeInputItem(item: unknown): ResponseInputItem { + if (!isRecord(item) || typeof item.type !== 'string') { + throw new Error('Codex preflight: each input item requires a type.') + } + + switch (item.type) { + case 'message': + return sanitizeMessageItem(item) + case 'function_call': + return sanitizeFunctionCallItem(item) + case 'function_call_output': + return sanitizeFunctionCallOutputItem(item) + default: + throw new Error(`Codex preflight: unsupported input item type "${item.type}".`) + } +} + +function sanitizeTool(tool: unknown): Tool { + if (!isRecord(tool) || tool.type !== 'function') { + throw new Error('Codex preflight: only function tools are supported.') + } + + const name = assertString(tool.name, 'tool.name').trim() + const parameters = isRecord(tool.parameters) ? tool.parameters : {} + + if (name.length === 0) { + throw new Error('Codex preflight: tool.name is required.') + } + + return { + ...tool, + type: 'function', + name, + parameters, + } as Tool +} + +export function sanitizeCodexRequest( + request: ResponseCreateParamsNonStreaming, +): ResponseCreateParamsNonStreaming { + if (typeof request.model !== 'string' || request.model.trim().length === 0) { + throw new Error('Codex preflight: model is required.') + } + + if ( + request.instructions !== undefined && + request.instructions !== null && + typeof request.instructions !== 'string' + ) { + throw new Error('Codex preflight: instructions must be a string.') + } + + if (!Array.isArray(request.input)) { + throw new Error('Codex preflight: input must be an array.') + } + + return { + ...request, + model: request.model.trim(), + instructions: request.instructions?.trim() || undefined, + input: request.input.map(sanitizeInputItem), + tools: request.tools?.map(sanitizeTool), + } +} + +export function toStreamingCodexRequest( + request: ResponseCreateParamsNonStreaming, +): ResponseCreateParamsStreaming { + return { + ...request, + stream: true, + } +} diff --git a/src/services/api/codex/streaming.ts b/src/services/api/codex/streaming.ts new file mode 100644 index 000000000..f8a27c0c6 --- /dev/null +++ b/src/services/api/codex/streaming.ts @@ -0,0 +1,681 @@ +import { randomUUID } from 'crypto' +import type { + Response, + ResponseCreateParamsNonStreaming, + ResponseFunctionToolCall, + ResponseOutputItem, + ResponseOutputMessage, + ResponseStreamEvent, +} from 'openai/resources/responses/responses.mjs' +import type { AssistantMessage, StreamEvent } from '../../../types/message.js' +import type { Tools } from '../../../Tool.js' +import { + createAssistantMessage, + normalizeContentFromAPI, +} from '../../../utils/messages.js' +import { getCodexClient } from './client.js' +import { resolveCodexCallId } from '@ant/model-provider' +import { toStreamingCodexRequest } from './preflight.js' + +export type RawAssistantBlock = + | { type: 'text'; text: string } + | { type: 'tool_use'; id: string; name: string; input: string } + +export type CodexUsage = { + input_tokens: number + output_tokens: number + cache_creation_input_tokens: number + cache_read_input_tokens: number +} + +export type CodexStreamResult = { + response?: Response + incompleteResponse?: Response + partialMessage?: AssistantMessage['message'] + assistantBlocks: RawAssistantBlock[] +} + +type CodexStreamState = { + contentBlocks: Record + completedBlocks: Array + partialMessage?: AssistantMessage['message'] + finalResponse?: Response + incompleteResponse?: Response + failedResponse?: Response +} + +export function getCodexUsage( + response: Pick | null | undefined, +): CodexUsage { + return { + input_tokens: response?.usage?.input_tokens ?? 0, + output_tokens: response?.usage?.output_tokens ?? 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: + response?.usage?.input_tokens_details.cached_tokens ?? 0, + } +} + +export function addCodexUsage( + total: CodexUsage, + response: Pick | null | undefined, +): CodexUsage { + const usage = getCodexUsage(response) + + return { + input_tokens: total.input_tokens + usage.input_tokens, + output_tokens: total.output_tokens + usage.output_tokens, + cache_creation_input_tokens: + total.cache_creation_input_tokens + usage.cache_creation_input_tokens, + cache_read_input_tokens: + total.cache_read_input_tokens + usage.cache_read_input_tokens, + } +} + +function createPartialAssistantMessage( + response: Response, +): AssistantMessage['message'] { + return { + id: response.id, + type: 'message', + role: 'assistant', + content: [], + model: response.model, + stop_reason: null, + stop_sequence: null, + usage: getCodexUsage(response) as any, + } as AssistantMessage['message'] +} + +function createToolUseBlock( + item: Partial & { id?: string }, +): RawAssistantBlock { + return { + type: 'tool_use', + id: resolveCodexCallId( + item.call_id ?? item.id, + `tool:${item.name ?? ''}:${item.arguments ?? ''}:${item.id ?? ''}`, + ), + name: item.name ?? '', + input: item.arguments ?? '', + } +} + +function getCompletedTextFromItem(item: ResponseOutputItem): string | null { + if (item.type !== 'message' || item.role !== 'assistant') { + return null + } + + for (const content of (item as ResponseOutputMessage).content) { + if (content.type === 'output_text' && content.text.length > 0) { + return content.text + } + if (content.type === 'refusal' && content.refusal.length > 0) { + return content.refusal + } + } + + return null +} + +function getCompletedAssistantBlocks( + blocks: Array, +): RawAssistantBlock[] { + return blocks.filter( + (block): block is RawAssistantBlock => block !== undefined, + ) +} + +function getCodexStopReason( + response: Pick, + blocks: RawAssistantBlock[], +): string { + if (response.incomplete_details?.reason === 'max_output_tokens') { + return 'max_tokens' + } + + return blocks.some(block => block.type === 'tool_use') ? 'tool_use' : 'end_turn' +} + +function emitTrailingTextDelta( + output: StreamEvent[], + index: number, + currentText: string, + finalText: string, +): void { + if (!finalText.startsWith(currentText)) { + return + } + + const delta = finalText.slice(currentText.length) + if (delta.length === 0) { + return + } + + output.push({ + type: 'stream_event', + event: { + type: 'content_block_delta', + index, + delta: { + type: 'text_delta', + text: delta, + }, + } as any, + } as StreamEvent) +} + +function emitTrailingToolDelta( + output: StreamEvent[], + index: number, + currentInput: string, + finalInput: string, +): void { + if (!finalInput.startsWith(currentInput)) { + return + } + + const delta = finalInput.slice(currentInput.length) + if (delta.length === 0) { + return + } + + output.push({ + type: 'stream_event', + event: { + type: 'content_block_delta', + index, + delta: { + type: 'input_json_delta', + partial_json: delta, + }, + } as any, + } as StreamEvent) +} + +function responseToRawAssistantBlocks(response: Response): RawAssistantBlock[] { + const blocks: RawAssistantBlock[] = [] + + for (const item of response.output) { + if (item.type === 'function_call') { + const functionCall = item as ResponseFunctionToolCall + blocks.push({ + type: 'tool_use', + id: resolveCodexCallId( + functionCall.call_id, + `output:${functionCall.name}:${functionCall.arguments}`, + ), + name: functionCall.name, + input: functionCall.arguments, + }) + continue + } + + if (item.type !== 'message' || item.role !== 'assistant') { + continue + } + + for (const content of (item as ResponseOutputMessage).content) { + if (content.type === 'output_text' && content.text.length > 0) { + blocks.push({ + type: 'text', + text: content.text, + }) + } else if (content.type === 'refusal' && content.refusal.length > 0) { + blocks.push({ + type: 'text', + text: content.refusal, + }) + } + } + } + + if ( + blocks.length === 0 && + typeof response.output_text === 'string' && + response.output_text.length > 0 + ) { + blocks.push({ + type: 'text', + text: response.output_text, + }) + } + + return blocks +} + +export function rawAssistantBlocksToAssistantMessage( + rawBlocks: RawAssistantBlock[], + response: Pick, + tools: Tools, + agentId?: string, +): AssistantMessage { + const content = normalizeContentFromAPI( + rawBlocks as any, + tools, + agentId as any, + ) + + const assistantMessage = createAssistantMessage({ + content: content as any, + usage: { + input_tokens: response.usage?.input_tokens ?? 0, + output_tokens: response.usage?.output_tokens ?? 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: + response.usage?.input_tokens_details.cached_tokens ?? 0, + } as any, + }) + + assistantMessage.message.id = response.id + assistantMessage.message.model = response.model + assistantMessage.message.stop_reason = getCodexStopReason(response, rawBlocks) as any + assistantMessage.message.stop_sequence = null + assistantMessage.uuid = randomUUID() + assistantMessage.timestamp = new Date().toISOString() + + return assistantMessage +} + +function handleCodexStreamEvent(params: { + event: ResponseStreamEvent + partialMessage: AssistantMessage['message'] | undefined + contentBlocks: Record + completedBlocks: Array + start: number +}): { + output: StreamEvent[] + partialMessage: AssistantMessage['message'] | undefined + finalResponse?: Response + failedResponse?: Response + incompleteResponse?: Response +} { + const { event, start } = params + const output: StreamEvent[] = [] + const contentBlocks = params.contentBlocks + const completedBlocks = params.completedBlocks + let partialMessage = params.partialMessage + let finalResponse: Response | undefined + let failedResponse: Response | undefined + let incompleteResponse: Response | undefined + + const ensureMessageStart = (response: Response): void => { + if (partialMessage) { + return + } + + partialMessage = createPartialAssistantMessage(response) + output.push({ + type: 'stream_event', + event: { + type: 'message_start', + message: partialMessage, + } as any, + ttftMs: Date.now() - start, + } as StreamEvent) + } + + const ensureTextBlock = (index: number): RawAssistantBlock => { + const existing = contentBlocks[index] + if (existing) { + return existing + } + + const block: RawAssistantBlock = { type: 'text', text: '' } + contentBlocks[index] = block + output.push({ + type: 'stream_event', + event: { + type: 'content_block_start', + index, + content_block: { type: 'text', text: '' }, + } as any, + } as StreamEvent) + return block + } + + const ensureToolUseBlock = ( + index: number, + item?: Partial & { id?: string }, + ): RawAssistantBlock => { + const existing = contentBlocks[index] + if (existing) { + return existing + } + + const block = createToolUseBlock(item ?? {}) + contentBlocks[index] = block + const toolBlock = block as Extract + output.push({ + type: 'stream_event', + event: { + type: 'content_block_start', + index, + content_block: { + type: 'tool_use', + id: toolBlock.id, + name: toolBlock.name, + input: '', + }, + } as any, + } as StreamEvent) + return block + } + + const emitCompletedBlock = (index: number): void => { + const block = contentBlocks[index] + if (!block) { + return + } + completedBlocks[index] = { ...block } + output.push({ + type: 'stream_event', + event: { + type: 'content_block_stop', + index, + } as any, + } as StreamEvent) + delete contentBlocks[index] + } + + switch (event.type) { + case 'response.created': + case 'response.in_progress': + ensureMessageStart(event.response) + break + case 'response.output_item.added': + if (event.item.type === 'function_call') { + ensureToolUseBlock(event.output_index, event.item) + } else if (event.item.type === 'message' && event.item.role === 'assistant') { + ensureTextBlock(event.output_index) + } + break + case 'response.output_text.delta': + case 'response.refusal.delta': { + const block = ensureTextBlock(event.output_index) + if (block.type === 'text') { + block.text += event.delta + } + output.push({ + type: 'stream_event', + event: { + type: 'content_block_delta', + index: event.output_index, + delta: { + type: 'text_delta', + text: event.delta, + }, + } as any, + } as StreamEvent) + break + } + case 'response.function_call_arguments.delta': { + const block = ensureToolUseBlock(event.output_index, { id: event.item_id }) + if (block.type === 'tool_use') { + block.input += event.delta + } + output.push({ + type: 'stream_event', + event: { + type: 'content_block_delta', + index: event.output_index, + delta: { + type: 'input_json_delta', + partial_json: event.delta, + }, + } as any, + } as StreamEvent) + break + } + case 'response.output_text.done': + case 'response.refusal.done': { + const block = ensureTextBlock(event.output_index) + const finalText = event.type === 'response.output_text.done' + ? event.text + : event.refusal + if (block.type === 'text') { + emitTrailingTextDelta(output, event.output_index, block.text, finalText) + block.text = finalText + } + emitCompletedBlock(event.output_index) + break + } + case 'response.function_call_arguments.done': { + const block = ensureToolUseBlock(event.output_index, { + id: event.item_id, + name: event.name, + }) + if (block.type === 'tool_use') { + if (event.name) { + block.name = event.name + } + emitTrailingToolDelta(output, event.output_index, block.input, event.arguments) + block.input = event.arguments + } + emitCompletedBlock(event.output_index) + break + } + case 'response.output_item.done': + if ( + event.item.type === 'message' && + event.item.role === 'assistant' && + contentBlocks[event.output_index] + ) { + const finalText = getCompletedTextFromItem(event.item) + if (finalText !== null) { + const block = contentBlocks[event.output_index] + if (block.type === 'text') { + emitTrailingTextDelta(output, event.output_index, block.text, finalText) + block.text = finalText + } + } + emitCompletedBlock(event.output_index) + } else if ( + event.item.type === 'function_call' && + contentBlocks[event.output_index] + ) { + const block = contentBlocks[event.output_index] + if (block.type === 'tool_use') { + block.id = resolveCodexCallId( + event.item.call_id, + `done:${event.item.name}:${event.item.arguments}:${event.item.id}`, + ) + block.name = event.item.name + emitTrailingToolDelta( + output, + event.output_index, + block.input, + event.item.arguments, + ) + block.input = event.item.arguments + } + emitCompletedBlock(event.output_index) + } + break + case 'response.completed': + case 'response.incomplete': { + ensureMessageStart(event.response) + if (event.type === 'response.completed') { + finalResponse = event.response + } else { + incompleteResponse = event.response + } + const assistantBlocks = getCompletedAssistantBlocks(completedBlocks) + output.push({ + type: 'stream_event', + event: { + type: 'message_delta', + delta: { + stop_reason: getCodexStopReason(event.response, assistantBlocks), + stop_sequence: null, + }, + usage: getCodexUsage(event.response), + } as any, + } as StreamEvent) + output.push({ + type: 'stream_event', + event: { + type: 'message_stop', + } as any, + } as StreamEvent) + break + } + case 'response.failed': + failedResponse = event.response + break + case 'error': + throw new Error(event.message) + } + + return { + output, + partialMessage, + finalResponse, + failedResponse, + incompleteResponse, + } +} + +function selectResponse( + state: CodexStreamState, + streamedResponse?: Response, +): CodexStreamResult { + const response = + [streamedResponse, state.finalResponse, state.incompleteResponse, state.failedResponse] + .find( + candidate => + candidate !== undefined && + responseToRawAssistantBlocks(candidate).length > 0, + ) ?? + streamedResponse ?? + state.finalResponse ?? + state.incompleteResponse ?? + state.failedResponse + + return { + response, + incompleteResponse: state.incompleteResponse, + partialMessage: state.partialMessage, + assistantBlocks: + response !== undefined && responseToRawAssistantBlocks(response).length > 0 + ? responseToRawAssistantBlocks(response) + : getCompletedAssistantBlocks(state.completedBlocks), + } +} + +async function consumeCodexStream( + events: AsyncIterable, + start: number, +): Promise { + const state: CodexStreamState = { + contentBlocks: {}, + completedBlocks: [], + } + + for await (const event of events) { + const handled = handleCodexStreamEvent({ + event, + partialMessage: state.partialMessage, + contentBlocks: state.contentBlocks, + completedBlocks: state.completedBlocks, + start, + }) + + state.partialMessage = handled.partialMessage + state.finalResponse = handled.finalResponse ?? state.finalResponse + state.incompleteResponse = + handled.incompleteResponse ?? state.incompleteResponse + state.failedResponse = handled.failedResponse ?? state.failedResponse + } + + return state +} + +export async function* streamCodexAttempt(params: { + client: ReturnType + requestBody: ResponseCreateParamsNonStreaming + signal: AbortSignal + start: number + emitPrimaryEvents?: boolean +}): AsyncGenerator { + let primaryError: unknown + let primaryResult: CodexStreamResult | undefined + + try { + const stream = params.client.responses.stream( + params.requestBody as unknown as Parameters< + typeof params.client.responses.stream + >[0], + { signal: params.signal }, + ) + + const state: CodexStreamState = { + contentBlocks: {}, + completedBlocks: [], + } + + for await (const event of stream) { + const handled = handleCodexStreamEvent({ + event, + partialMessage: state.partialMessage, + contentBlocks: state.contentBlocks, + completedBlocks: state.completedBlocks, + start: params.start, + }) + + state.partialMessage = handled.partialMessage + state.finalResponse = handled.finalResponse ?? state.finalResponse + state.incompleteResponse = + handled.incompleteResponse ?? state.incompleteResponse + state.failedResponse = handled.failedResponse ?? state.failedResponse + + if (params.emitPrimaryEvents !== false) { + yield* handled.output + } + } + + let streamedResponse: Response | undefined + try { + streamedResponse = await stream.finalResponse() + } catch { + streamedResponse = undefined + } + + primaryResult = selectResponse(state, streamedResponse) + if (primaryResult.assistantBlocks.length > 0 || primaryResult.response) { + return primaryResult + } + } catch (error) { + primaryError = error + } + + try { + const fallbackStream = await params.client.responses.create( + toStreamingCodexRequest(params.requestBody), + { signal: params.signal }, + ) + + const fallbackState = await consumeCodexStream( + fallbackStream as AsyncIterable, + params.start, + ) + const fallbackResult = selectResponse(fallbackState) + + if (fallbackResult.assistantBlocks.length > 0 || fallbackResult.response) { + return fallbackResult + } + } catch (fallbackError) { + if (primaryError) { + throw primaryError + } + throw fallbackError + } + + if (primaryError) { + throw primaryError + } + + return primaryResult ?? { + assistantBlocks: [], + } +} diff --git a/src/services/langfuse/tracing.ts b/src/services/langfuse/tracing.ts index da6ed00d1..52f3fc0c6 100644 --- a/src/services/langfuse/tracing.ts +++ b/src/services/langfuse/tracing.ts @@ -57,6 +57,8 @@ const PROVIDER_GENERATION_NAMES: Record = { vertex: 'ChatVertexAnthropic', foundry: 'ChatFoundry', openai: 'ChatOpenAI', + codex: 'ChatCodex', + 'codex-chatgpt': 'ChatCodex', gemini: 'ChatGoogleGenerativeAI', grok: 'ChatXAI', } From 1058b7e64395da789f9a43a52f0c398bd8f39e6e Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sun, 26 Apr 2026 23:38:56 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=20Codex=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=98=A0=E5=B0=84=E5=B9=B6=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=90=8E=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - configs.ts: 将 codex 字段从 Anthropic 模型名改为实际 OpenAI 模型名 (opus→gpt-5.4, sonnet→gpt-5.4-mini, haiku→gpt-5.4-mini, opus47→gpt-5.5) - modelMapping.ts: 移除不存在的 gpt-5.4-nano,修复 haiku 映射,添加 opus47 - ConsoleOAuthFlow.tsx: OAuth 成功后显示模型配置面板,可编辑三种模型名称 - 已登录用户再次选择 Codex 时跳过 OAuth 直接进入模型配置 - Ctrl+R 快捷键清除登录状态并重新 OAuth - modelOptions.ts: codex provider 支持 CODEX_DEFAULT_*_MODEL 环境变量 Co-Authored-By: Claude Opus 4.7 --- .../codex/__tests__/modelMapping.test.ts | 6 +- .../src/providers/codex/modelMapping.ts | 7 +- src/components/ConsoleOAuthFlow.tsx | 305 ++++++++++++++++-- src/keybindings/defaultBindings.ts | 2 + src/keybindings/schema.ts | 2 + src/utils/model/configs.ts | 24 +- src/utils/model/modelOptions.ts | 36 ++- 7 files changed, 330 insertions(+), 52 deletions(-) diff --git a/packages/@ant/model-provider/src/providers/codex/__tests__/modelMapping.test.ts b/packages/@ant/model-provider/src/providers/codex/__tests__/modelMapping.test.ts index cbd7be7d9..6a7711a73 100644 --- a/packages/@ant/model-provider/src/providers/codex/__tests__/modelMapping.test.ts +++ b/packages/@ant/model-provider/src/providers/codex/__tests__/modelMapping.test.ts @@ -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', () => { diff --git a/packages/@ant/model-provider/src/providers/codex/modelMapping.ts b/packages/@ant/model-provider/src/providers/codex/modelMapping.ts index 3bee191b4..ea70b8d7c 100644 --- a/packages/@ant/model-provider/src/providers/codex/modelMapping.ts +++ b/packages/@ant/model-provider/src/providers/codex/modelMapping.ts @@ -12,15 +12,16 @@ const DEFAULT_MODEL_MAP: Record = { '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 = { - haiku: 'gpt-5.4-nano', + haiku: 'gpt-5.4-mini', sonnet: 'gpt-5.4-mini', opus: 'gpt-5.4', } diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index 1270f84e9..58e7cc5fe 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -58,6 +58,18 @@ type OAuthStatus = } // 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_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 @@ -365,31 +377,19 @@ export function ConsoleOAuthFlow({ manualCode: manualCodePromise, }) - const env: Record = { - CODEX_API_KEY: result.apiKey ?? undefined, - CODEX_ACCESS_TOKEN: result.accessToken, - CODEX_REFRESH_TOKEN: result.refreshToken, - CODEX_LOGIN_METHOD: 'chatgpt_subscription', - } - updateSettingsForSource('userSettings', { - modelType: 'codex', - env, - } as any) - for (const [key, value] of Object.entries(env)) { - if (value !== undefined) { - process.env[key] = value - } - } - - setOAuthStatus({ state: 'success' }) - void sendNotification( - { - message: 'OpenAI Codex (ChatGPT) login successful', - notificationType: 'auth_success', + // 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, }, - terminal, - ) - onDone() + }) } catch (err) { logError(err as Error) setOAuthStatus({ @@ -714,7 +714,37 @@ function OAuthStatusMessage({ }) } else if (value === 'codex_chatgpt') { logEvent('tengu_codex_chatgpt_selected', {}) - setOAuthStatus({ state: 'codex_oauth_start' }) + // 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({ @@ -1489,6 +1519,231 @@ function OAuthStatusMessage({ ) } + 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 = { + 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 = { + 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 { 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, 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 ( + + + {` ${label} `} + + + {active ? ( + + ) : val ? ( + {val} + ) : null} + + ) + } + + return ( + + Codex Model Configuration + + ChatGPT login successful. Configure model names (press Enter on last field to save). + + + {renderCodexModelRow('haiku_model', 'Haiku ')} + {renderCodexModelRow('sonnet_model', 'Sonnet ')} + {renderCodexModelRow('opus_model', 'Opus ')} + + + ↑↓/Tab to switch · Enter on last field to save · Ctrl+R to re-login · Esc to go back + + + ) + } + case 'platform_setup': return ( diff --git a/src/keybindings/defaultBindings.ts b/src/keybindings/defaultBindings.ts index 1d9ef10e2..9e1063ac7 100644 --- a/src/keybindings/defaultBindings.ts +++ b/src/keybindings/defaultBindings.ts @@ -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', }, }, { diff --git a/src/keybindings/schema.ts b/src/keybindings/schema.ts index 83e6fb28d..231e7d106 100644 --- a/src/keybindings/schema.ts +++ b/src/keybindings/schema.ts @@ -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', diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index 3a1bbf617..0cd23aa86 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -13,7 +13,7 @@ export const CLAUDE_3_7_SONNET_CONFIG = { foundry: 'claude-3-7-sonnet', openai: 'claude-3-7-sonnet-20250219', gemini: 'claude-3-7-sonnet-20250219', - codex: 'claude-3-7-sonnet-20250219', + codex: 'gpt-5.4-mini', grok: 'claude-3-7-sonnet-20250219', } as const satisfies ModelConfig @@ -24,7 +24,7 @@ export const CLAUDE_3_5_V2_SONNET_CONFIG = { foundry: 'claude-3-5-sonnet', openai: 'claude-3-5-sonnet-20241022', gemini: 'claude-3-5-sonnet-20241022', - codex: 'claude-3-5-sonnet-20241022', + codex: 'gpt-5.4-mini', grok: 'claude-3-5-sonnet-20241022', } as const satisfies ModelConfig @@ -35,7 +35,7 @@ export const CLAUDE_3_5_HAIKU_CONFIG = { foundry: 'claude-3-5-haiku', openai: 'claude-3-5-haiku-20241022', gemini: 'claude-3-5-haiku-20241022', - codex: 'claude-3-5-haiku-20241022', + codex: 'gpt-5.4-mini', grok: 'claude-3-5-haiku-20241022', } as const satisfies ModelConfig @@ -46,7 +46,7 @@ export const CLAUDE_HAIKU_4_5_CONFIG = { foundry: 'claude-haiku-4-5', openai: 'claude-haiku-4-5-20251001', gemini: 'claude-haiku-4-5-20251001', - codex: 'claude-haiku-4-5-20251001', + codex: 'gpt-5.4-mini', grok: 'claude-haiku-4-5-20251001', } as const satisfies ModelConfig @@ -57,7 +57,7 @@ export const CLAUDE_SONNET_4_CONFIG = { foundry: 'claude-sonnet-4', openai: 'claude-sonnet-4-20250514', gemini: 'claude-sonnet-4-20250514', - codex: 'claude-sonnet-4-20250514', + codex: 'gpt-5.4-mini', grok: 'claude-sonnet-4-20250514', } as const satisfies ModelConfig @@ -68,7 +68,7 @@ export const CLAUDE_SONNET_4_5_CONFIG = { foundry: 'claude-sonnet-4-5', openai: 'claude-sonnet-4-5-20250929', gemini: 'claude-sonnet-4-5-20250929', - codex: 'claude-sonnet-4-5-20250929', + codex: 'gpt-5.4-mini', grok: 'claude-sonnet-4-5-20250929', } as const satisfies ModelConfig @@ -79,7 +79,7 @@ export const CLAUDE_OPUS_4_CONFIG = { foundry: 'claude-opus-4', openai: 'claude-opus-4-20250514', gemini: 'claude-opus-4-20250514', - codex: 'claude-opus-4-20250514', + codex: 'gpt-5.4', grok: 'claude-opus-4-20250514', } as const satisfies ModelConfig @@ -90,7 +90,7 @@ export const CLAUDE_OPUS_4_1_CONFIG = { foundry: 'claude-opus-4-1', openai: 'claude-opus-4-1-20250805', gemini: 'claude-opus-4-1-20250805', - codex: 'claude-opus-4-1-20250805', + codex: 'gpt-5.4', grok: 'claude-opus-4-1-20250805', } as const satisfies ModelConfig @@ -101,7 +101,7 @@ export const CLAUDE_OPUS_4_5_CONFIG = { foundry: 'claude-opus-4-5', openai: 'claude-opus-4-5-20251101', gemini: 'claude-opus-4-5-20251101', - codex: 'claude-opus-4-5-20251101', + codex: 'gpt-5.4', grok: 'claude-opus-4-5-20251101', } as const satisfies ModelConfig @@ -112,7 +112,7 @@ export const CLAUDE_OPUS_4_6_CONFIG = { foundry: 'claude-opus-4-6', openai: 'claude-opus-4-6', gemini: 'claude-opus-4-6', - codex: 'claude-opus-4-6', + codex: 'gpt-5.4', grok: 'claude-opus-4-6', } as const satisfies ModelConfig @@ -123,7 +123,7 @@ export const CLAUDE_OPUS_4_7_CONFIG = { foundry: 'claude-opus-4-7', openai: 'claude-opus-4-7', gemini: 'claude-opus-4-7', - codex: 'claude-opus-4-7', + codex: 'gpt-5.5', grok: 'claude-opus-4-7', } as const satisfies ModelConfig @@ -134,7 +134,7 @@ export const CLAUDE_SONNET_4_6_CONFIG = { foundry: 'claude-sonnet-4-6', openai: 'claude-sonnet-4-6', gemini: 'claude-sonnet-4-6', - codex: 'claude-sonnet-4-6', + codex: 'gpt-5.4-mini', grok: 'claude-sonnet-4-6', } as const satisfies ModelConfig diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index 754963955..7e4e4d55a 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -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,