From c7cb3d8f93194825c90c8d2310cbaff79f983831 Mon Sep 17 00:00:00 2001 From: Bill Date: Fri, 8 May 2026 20:35:34 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20/login=E6=94=AF=E6=8C=81codex?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/buddy/__tests__/companion.test.ts | 26 + src/buddy/companion.ts | 29 +- src/commands/effort/effort.tsx | 2 +- src/commands/logout/index.ts | 2 +- src/commands/logout/logout.tsx | 24 +- src/commands/provider.ts | 3 +- src/components/ConsoleOAuthFlow.tsx | 136 ++++- src/components/ModelPicker.tsx | 44 +- .../openai/__tests__/responsesAdapter.test.ts | 27 + src/services/api/openai/chatgptAuth.ts | 361 +++++++++++++ src/services/api/openai/index.ts | 89 +++- src/services/api/openai/responsesAdapter.ts | 480 ++++++++++++++++++ src/utils/effort.ts | 36 ++ src/utils/managedEnvConstants.ts | 3 +- src/utils/model/chatgptModels.ts | 52 ++ src/utils/model/model.ts | 17 + src/utils/model/modelOptions.ts | 26 + 17 files changed, 1318 insertions(+), 39 deletions(-) create mode 100644 src/buddy/__tests__/companion.test.ts create mode 100644 src/services/api/openai/__tests__/responsesAdapter.test.ts create mode 100644 src/services/api/openai/chatgptAuth.ts create mode 100644 src/services/api/openai/responsesAdapter.ts create mode 100644 src/utils/model/chatgptModels.ts diff --git a/src/buddy/__tests__/companion.test.ts b/src/buddy/__tests__/companion.test.ts new file mode 100644 index 0000000000..06a98c9116 --- /dev/null +++ b/src/buddy/__tests__/companion.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test' +import { inferLegacyCompanionBones } from '../companion.js' + +describe('inferLegacyCompanionBones', () => { + test('infers species and rarity from legacy seedless companion text', () => { + expect( + inferLegacyCompanionBones({ + name: 'Biscuit', + personality: 'A common mushroom of few words.', + }), + ).toEqual({ + species: 'mushroom', + rarity: 'common', + }) + }) + + test('does not override seeded companions', () => { + expect( + inferLegacyCompanionBones({ + name: 'Spore', + personality: 'A common mushroom of few words.', + seed: 'rehatch-1', + }), + ).toEqual({}) + }) +}) diff --git a/src/buddy/companion.ts b/src/buddy/companion.ts index a0fa798cd1..8569fef5b0 100644 --- a/src/buddy/companion.ts +++ b/src/buddy/companion.ts @@ -2,6 +2,7 @@ import { getGlobalConfig } from '../utils/config.js' import { type Companion, type CompanionBones, + type CompanionSoul, EYES, HATS, RARITIES, @@ -125,12 +126,36 @@ export function companionUserId(): string { return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon' } +const WORD_BOUNDARY = '[^a-z0-9]+' + +function hasWord(text: string, word: string): boolean { + return new RegExp(`(^|${WORD_BOUNDARY})${word}($|${WORD_BOUNDARY})`).test( + text, + ) +} + +export function inferLegacyCompanionBones( + stored: CompanionSoul, +): Partial> { + if (stored.seed) return {} + const text = `${stored.name} ${stored.personality}`.toLowerCase() + const inferred: Partial> = {} + const species = SPECIES.find(species => hasWord(text, species)) + const rarity = RARITIES.find(rarity => hasWord(text, rarity)) + if (species) inferred.species = species + if (rarity) inferred.rarity = rarity + return inferred +} + // Regenerate bones from seed or userId, merge with stored soul. export function getCompanion(): Companion | undefined { const stored = getGlobalConfig().companion if (!stored) return undefined const seed = stored.seed ?? companionUserId() const { bones } = rollWithSeed(seed) - // bones last so stale bones fields in old-format configs get overridden - return { ...stored, ...bones } + const legacyBones = inferLegacyCompanionBones(stored) + // Seeded companions use regenerated bones. Legacy seedless companions may + // have species/rarity embedded in their generated soul text; keep that + // visible identity coherent when the userId-derived roll drifts. + return { ...stored, ...bones, ...legacyBones } } diff --git a/src/commands/effort/effort.tsx b/src/commands/effort/effort.tsx index 87890bd121..805901c8a5 100644 --- a/src/commands/effort/effort.tsx +++ b/src/commands/effort/effort.tsx @@ -155,7 +155,7 @@ export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, arg if (COMMON_HELP_ARGS.includes(args)) { onDone( - 'Usage: /effort [low|medium|high|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6/4.7, DeepSeek V4 Pro)\n- auto: Use the default effort level for your model', + 'Usage: /effort [low|medium|high|xhigh|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- xhigh: Extra high reasoning for supported models, including ChatGPT Codex models\n- max: Maximum capability with deepest reasoning where supported (Opus 4.6/4.7, DeepSeek V4 Pro); maps to xhigh for ChatGPT Codex models\n- auto: Use the default effort level for your model', ); return; } diff --git a/src/commands/logout/index.ts b/src/commands/logout/index.ts index 9805064710..a2bb16ef8b 100644 --- a/src/commands/logout/index.ts +++ b/src/commands/logout/index.ts @@ -4,7 +4,7 @@ import { isEnvTruthy } from '../../utils/envUtils.js' export default { type: 'local-jsx', name: 'logout', - description: 'Sign out from your Anthropic account', + description: 'Sign out from your configured account', isEnabled: () => !isEnvTruthy(process.env.DISABLE_LOGOUT_COMMAND), load: () => import('./logout.js'), } satisfies Command diff --git a/src/commands/logout/logout.tsx b/src/commands/logout/logout.tsx index ea9a23f02c..78eafc0cdb 100644 --- a/src/commands/logout/logout.tsx +++ b/src/commands/logout/logout.tsx @@ -6,11 +6,13 @@ import { getGroveNoticeConfig, getGroveSettings } from '../../services/api/grove import { clearPolicyLimitsCache } from '../../services/policyLimits/index.js'; // flushTelemetry is loaded lazily to avoid pulling in ~1.1MB of OpenTelemetry at startup import { clearRemoteManagedSettingsCache } from '../../services/remoteManagedSettings/index.js'; +import { removeChatGPTAuth } from '../../services/api/openai/chatgptAuth.js'; import { getClaudeAIOAuthTokens, removeApiKey } from '../../utils/auth.js'; import { clearBetasCaches } from '../../utils/betas.js'; import { saveGlobalConfig } from '../../utils/config.js'; import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'; import { getSecureStorage } from '../../utils/secureStorage/index.js'; +import { getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js'; import { clearToolSchemaCache } from '../../utils/toolSchemaCache.js'; import { resetUserCache } from '../../utils/user.js'; @@ -20,6 +22,8 @@ export async function performLogout({ clearOnboarding = false }): Promise await flushTelemetry(); await removeApiKey(); + await removeChatGPTAuth(); + clearChatGPTSettingsAuthMode(); // Wipe all secure storage data on logout const secureStorage = getSecureStorage(); @@ -44,6 +48,24 @@ export async function performLogout({ clearOnboarding = false }): Promise }); } +function clearChatGPTSettingsAuthMode(): void { + delete process.env.OPENAI_AUTH_MODE; + const userSettings = getSettingsForSource('userSettings') ?? {}; + const env = userSettings.env ?? {}; + const hasOpenAICompatibleConfig = + Boolean(env.OPENAI_API_KEY ?? process.env.OPENAI_API_KEY) && + Boolean(env.OPENAI_BASE_URL ?? process.env.OPENAI_BASE_URL); + const settingsUpdate: Parameters[1] = { + ...(userSettings.modelType === 'openai' && !hasOpenAICompatibleConfig + ? { modelType: undefined } + : {}), + env: { + OPENAI_AUTH_MODE: undefined, + } as unknown as Record, + }; + updateSettingsForSource('userSettings', settingsUpdate); +} + // clearing anything memoized that must be invalidated when user/session/auth changes export async function clearAuthRelatedCaches(): Promise { // Clear the OAuth token cache @@ -70,7 +92,7 @@ export async function clearAuthRelatedCaches(): Promise { export async function call(): Promise { await performLogout({ clearOnboarding: true }); - const message = Successfully logged out from your Anthropic account.; + const message = Successfully logged out. setTimeout(() => { gracefulShutdownSync(0, 'logout'); diff --git a/src/commands/provider.ts b/src/commands/provider.ts index 5d12f74573..d471bd104f 100644 --- a/src/commands/provider.ts +++ b/src/commands/provider.ts @@ -81,9 +81,10 @@ const call: LocalCommandCall = async (args, _context) => { // Check env vars when switching to openai (including settings.env) if (arg === 'openai') { const mergedEnv = getMergedEnv() + const hasChatGPTAuth = mergedEnv.OPENAI_AUTH_MODE === 'chatgpt' const hasKey = !!mergedEnv.OPENAI_API_KEY const hasUrl = !!mergedEnv.OPENAI_BASE_URL - if (!hasKey || !hasUrl) { + if (!hasChatGPTAuth && (!hasKey || !hasUrl)) { updateSettingsForSource('userSettings', { modelType: 'openai' }) const missing = [] if (!hasKey) missing.push('OPENAI_API_KEY') diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index 9ca4641b3c..29b81ff47d 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -9,9 +9,14 @@ import { setClipboard, useTerminalNotification, Box, Link, Text, KeyboardShortcu import { useKeybinding } from '../keybindings/useKeybinding.js'; import { getSSLErrorHint } from '@ant/model-provider'; import { sendNotification } from '../services/notifier.js'; +import { + completeChatGPTDeviceLogin, + requestChatGPTDeviceCode, + type ChatGPTDeviceCode, +} from '../services/api/openai/chatgptAuth.js'; import { OAuthService } from '../services/oauth/index.js'; import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'; - +import { openBrowser } from '../utils/browser.js'; import { logError } from '../utils/log.js'; import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; import { Select } from './CustomSelect/select.js'; @@ -46,6 +51,11 @@ type OAuthStatus = opusModel: string; activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; } // OpenAI Chat Completions API platform + | { + state: 'chatgpt_subscription'; + phase: 'requesting' | 'waiting'; + deviceCode?: ChatGPTDeviceCode; + } // ChatGPT account subscription via Codex OAuth device flow | { state: 'gemini_api'; baseUrl: string; @@ -445,6 +455,16 @@ function OAuthStatusMessage({ ), value: 'openai_chat_api', }, + { + label: ( + + ChatGPT account with subscription ·{' '} + Plus, Pro, Business, Edu, or Enterprise + {'\n'} + + ), + value: 'chatgpt_subscription', + }, { label: ( @@ -515,6 +535,12 @@ function OAuthStatusMessage({ opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '', activeField: 'base_url', }); + } else if (value === 'chatgpt_subscription') { + logEvent('tengu_chatgpt_subscription_selected', {}); + setOAuthStatus({ + state: 'chatgpt_subscription', + phase: 'requesting', + }); } else if (value === 'gemini_api') { logEvent('tengu_gemini_api_selected', {}); setOAuthStatus({ @@ -807,7 +833,9 @@ function OAuthStatusMessage({ const doOpenAISave = useCallback(() => { const finalVals = { ...openaiDisplayValues, [activeField]: openaiInputValue }; - const env: Record = {}; + const env: Record = { + OPENAI_AUTH_MODE: undefined, + }; // Validate base_url if provided if (finalVals.base_url) { @@ -836,10 +864,11 @@ function OAuthStatusMessage({ if (finalVals.haiku_model) env.OPENAI_DEFAULT_HAIKU_MODEL = finalVals.haiku_model; if (finalVals.sonnet_model) env.OPENAI_DEFAULT_SONNET_MODEL = finalVals.sonnet_model; if (finalVals.opus_model) env.OPENAI_DEFAULT_OPUS_MODEL = finalVals.opus_model; - const { error } = updateSettingsForSource('userSettings', { - modelType: 'openai' as any, - env, - } as any); + const settingsUpdate: Parameters[1] = { + modelType: 'openai', + env: env as unknown as Record, + }; + const { error } = updateSettingsForSource('userSettings', settingsUpdate); if (error) { setOAuthStatus({ state: 'error', @@ -855,7 +884,13 @@ function OAuthStatusMessage({ }, }); } else { - for (const [k, v] of Object.entries(env)) process.env[k] = v; + for (const [k, v] of Object.entries(env)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } setOAuthStatus({ state: 'success' }); void onDone(); } @@ -953,6 +988,93 @@ function OAuthStatusMessage({ ); } + case 'chatgpt_subscription': { + const status = oauthStatus as { + state: 'chatgpt_subscription'; + phase: 'requesting' | 'waiting'; + deviceCode?: ChatGPTDeviceCode; + }; + const startedRef = useRef(false); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + let cancelled = false; + const controller = new AbortController(); + async function runLogin() { + try { + const deviceCode = await requestChatGPTDeviceCode(); + if (cancelled) return; + setOAuthStatus({ + state: 'chatgpt_subscription', + phase: 'waiting', + deviceCode, + }); + void openBrowser(deviceCode.verificationUrl); + await completeChatGPTDeviceLogin(deviceCode, controller.signal); + if (cancelled) return; + const env: Record = { + OPENAI_AUTH_MODE: 'chatgpt', + }; + const settingsUpdate: Parameters[1] = { + modelType: 'openai', + env, + }; + const { error } = updateSettingsForSource('userSettings', settingsUpdate); + if (error) { + throw new Error('Failed to save settings. Please try again.'); + } + for (const [k, v] of Object.entries(env)) process.env[k] = v; + setOAuthStatus({ state: 'success' }); + void onDone(); + } catch (err) { + if (cancelled) return; + setOAuthStatus({ + state: 'error', + message: (err as Error).message, + toRetry: { + state: 'chatgpt_subscription', + phase: 'requesting', + }, + }); + } + } + void runLogin(); + return () => { + cancelled = true; + controller.abort(); + }; + }, [setOAuthStatus, onDone]); + + return ( + + ChatGPT Account Setup + {status.phase === 'requesting' && ( + + + Requesting sign-in code… + + )} + {status.phase === 'waiting' && status.deviceCode && ( + + Open this link and sign in with your ChatGPT account: + + {status.deviceCode.verificationUrl} + + + Enter code: {status.deviceCode.userCode} + + + + Waiting for ChatGPT authorization… + + + )} + Esc to go back. Device codes expire after 15 minutes. + + ); + } + case 'gemini_api': { type GeminiField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; const GEMINI_FIELDS: GeminiField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index cfe56eb837..6f13653258 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -22,6 +22,7 @@ import { getDefaultEffortForModel, modelSupportsEffort, modelSupportsMaxEffort, + modelSupportsXhighEffort, resolvePickerEffortPersistence, toPersistableEffort, } from '../utils/effort.js'; @@ -146,11 +147,19 @@ export function ModelPicker({ focusedValue !== NO_PREFERENCE && marked1MValues.has(focusedValue.replace(/\[1m\]/i, '')); const focusedSupportsEffort = focusedModel ? modelSupportsEffort(focusedModel) : false; + const focusedSupportsXhigh = focusedModel ? modelSupportsXhighEffort(focusedModel) : false; const focusedSupportsMax = focusedModel ? modelSupportsMaxEffort(focusedModel) : false; const focusedDefaultEffort = getDefaultEffortLevelForOption(focusedValue); - // Clamp display when 'max' is selected but the focused model doesn't support it. + // Clamp display when selected effort isn't supported by the focused model. // resolveAppliedEffort() does the same downgrade at API-send time. - const displayEffort = effort === 'max' && !focusedSupportsMax ? 'high' : effort; + const displayEffort = + effort === 'max' && !focusedSupportsMax + ? focusedSupportsXhigh + ? 'xhigh' + : 'high' + : effort === 'xhigh' && !focusedSupportsXhigh + ? 'high' + : effort; const handleFocus = useCallback( (value: string) => { @@ -166,10 +175,22 @@ export function ModelPicker({ const handleCycleEffort = useCallback( (direction: 'left' | 'right') => { if (!focusedSupportsEffort) return; - setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedSupportsMax)); + setEffort(prev => + cycleEffortLevel( + prev ?? focusedDefaultEffort, + direction, + focusedSupportsXhigh, + focusedSupportsMax, + ), + ); setHasToggledEffort(true); }, - [focusedSupportsEffort, focusedSupportsMax, focusedDefaultEffort], + [ + focusedSupportsEffort, + focusedSupportsXhigh, + focusedSupportsMax, + focusedDefaultEffort, + ], ); useKeybindings( @@ -333,8 +354,19 @@ function EffortLevelIndicator({ effort }: { effort?: EffortLevel }): React.React return {effortLevelToSymbol(effort ?? 'low')}; } -function cycleEffortLevel(current: EffortLevel, direction: 'left' | 'right', includeMax: boolean): EffortLevel { - const levels: EffortLevel[] = includeMax ? ['low', 'medium', 'high', 'max'] : ['low', 'medium', 'high']; +function cycleEffortLevel( + current: EffortLevel, + direction: 'left' | 'right', + includeXhigh: boolean, + includeMax: boolean, +): EffortLevel { + const levels: EffortLevel[] = [ + 'low', + 'medium', + 'high', + ...(includeXhigh ? (['xhigh'] as const) : []), + ...(includeMax ? (['max'] as const) : []), + ]; // If the current level isn't in the cycle (e.g. 'max' after switching to a // non-Opus model), clamp to 'high'. const idx = levels.indexOf(current); diff --git a/src/services/api/openai/__tests__/responsesAdapter.test.ts b/src/services/api/openai/__tests__/responsesAdapter.test.ts new file mode 100644 index 0000000000..4dc48420d5 --- /dev/null +++ b/src/services/api/openai/__tests__/responsesAdapter.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test' +import { buildResponsesRequest } from '../responsesAdapter.js' + +describe('buildResponsesRequest', () => { + test('includes reasoning effort for ChatGPT Responses requests', () => { + const request = buildResponsesRequest({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'hello' }], + tools: [], + toolChoice: undefined, + reasoningEffort: 'xhigh', + }) + + expect(request.reasoning).toEqual({ effort: 'xhigh' }) + }) + + test('does not include unsupported max_output_tokens parameter', () => { + const request = buildResponsesRequest({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'hello' }], + tools: [], + toolChoice: undefined, + }) as Record + + expect('max_output_tokens' in request).toBe(false) + }) +}) diff --git a/src/services/api/openai/chatgptAuth.ts b/src/services/api/openai/chatgptAuth.ts new file mode 100644 index 0000000000..5bb5c2341f --- /dev/null +++ b/src/services/api/openai/chatgptAuth.ts @@ -0,0 +1,361 @@ +import { chmod, mkdir, readFile, unlink, writeFile } from 'fs/promises' +import { homedir } from 'os' +import { join } from 'path' +import { logForDebugging } from 'src/utils/debug.js' + +const ISSUER = 'https://auth.openai.com' +const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' +const AUTH_FILE = 'openai-chatgpt-auth.json' +const REFRESH_SKEW_MS = 5 * 60 * 1000 + +export type ChatGPTDeviceCode = { + verificationUrl: string + userCode: string + deviceAuthId: string + intervalSeconds: number +} + +export type ChatGPTAuthTokens = { + idToken: string + accessToken: string + refreshToken: string + accountId?: string + lastRefresh?: string +} + +export type ChatGPTAuth = { + accessToken: string + accountId?: string +} + +type StoredAuthFile = { + auth_mode?: string + tokens?: { + id_token?: string + access_token?: string + refresh_token?: string + account_id?: string + } + last_refresh?: string +} + +function authFilePath(): string { + return join(getClaudeConfigHomeDirLocal(), AUTH_FILE) +} + +function getClaudeConfigHomeDirLocal(): string { + return ( + process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude') + ).normalize('NFC') +} + +function codexAuthFilePath(): string { + return join( + process.env.CODEX_HOME ?? join(process.env.HOME ?? '', '.codex'), + 'auth.json', + ) +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function parseJSONRecord(text: string): Record | null { + try { + const value = JSON.parse(text) as unknown + return value && typeof value === 'object' + ? (value as Record) + : null + } catch { + return null + } +} + +function decodeJwtPayload(token: string): Record | null { + const [, payload] = token.split('.') + if (!payload) return null + try { + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized.padEnd( + normalized.length + ((4 - (normalized.length % 4)) % 4), + '=', + ) + const json = Buffer.from(padded, 'base64').toString('utf8') + return parseJSONRecord(json) + } catch { + return null + } +} + +function getOpenAIAuthClaims(token: string): Record { + const payload = decodeJwtPayload(token) + const nested = payload?.['https://api.openai.com/auth'] + if (nested && typeof nested === 'object') { + return nested as Record + } + return payload ?? {} +} + +function getTokenExpiryMs(token: string): number | null { + const payload = decodeJwtPayload(token) + const exp = payload?.exp + return typeof exp === 'number' ? exp * 1000 : null +} + +function extractAccountId(tokens: { + idToken?: string + accessToken?: string + accountId?: string +}): string | undefined { + if (tokens.accountId) return tokens.accountId + for (const token of [tokens.idToken, tokens.accessToken]) { + if (!token) continue + const claims = getOpenAIAuthClaims(token) + const accountId = + asString(claims.chatgpt_account_id) ?? + asString(claims.chatgpt_account_user_id) ?? + asString(claims.account_id) + if (accountId) return accountId + } + return undefined +} + +async function readStoredAuth(path: string): Promise { + try { + const raw = await readFile(path, 'utf8') + const parsed = JSON.parse(raw) as StoredAuthFile + const tokens = parsed.tokens + const idToken = tokens?.id_token + const accessToken = tokens?.access_token + const refreshToken = tokens?.refresh_token + if (!idToken || !accessToken || !refreshToken) return null + return { + idToken, + accessToken, + refreshToken, + accountId: extractAccountId({ + idToken, + accessToken, + accountId: tokens.account_id, + }), + lastRefresh: parsed.last_refresh, + } + } catch { + return null + } +} + +async function saveStoredAuth(tokens: ChatGPTAuthTokens): Promise { + const path = authFilePath() + await mkdir(getClaudeConfigHomeDirLocal(), { recursive: true }) + const body: StoredAuthFile = { + auth_mode: 'chatgpt', + tokens: { + id_token: tokens.idToken, + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + account_id: extractAccountId(tokens), + }, + last_refresh: new Date().toISOString(), + } + await writeFile(path, `${JSON.stringify(body, null, 2)}\n`, { + mode: 0o600, + }) + await chmod(path, 0o600).catch(() => undefined) +} + +async function postJSON( + url: string, + body: Record, +): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + throw new Error(`ChatGPT auth request failed (${res.status})`) + } + return (await res.json()) as T +} + +async function postForm(url: string, body: URLSearchParams): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error( + `ChatGPT token request failed (${res.status})${text ? `: ${text}` : ''}`, + ) + } + return (await res.json()) as T +} + +export async function requestChatGPTDeviceCode(): Promise { + type UserCodeResponse = { + device_auth_id: string + user_code?: string + usercode?: string + interval?: string | number + } + const data = await postJSON( + `${ISSUER}/api/accounts/deviceauth/usercode`, + { client_id: CLIENT_ID }, + ) + const userCode = data.user_code ?? data.usercode + if (!data.device_auth_id || !userCode) { + throw new Error('ChatGPT auth response did not include a device code') + } + const interval = + typeof data.interval === 'number' + ? data.interval + : Number.parseInt(data.interval ?? '5', 10) + return { + verificationUrl: `${ISSUER}/codex/device`, + userCode, + deviceAuthId: data.device_auth_id, + intervalSeconds: Number.isFinite(interval) && interval > 0 ? interval : 5, + } +} + +async function pollForAuthorizationCode( + deviceCode: ChatGPTDeviceCode, + signal?: AbortSignal, +): Promise<{ authorizationCode: string; codeVerifier: string }> { + type TokenPollResponse = { + authorization_code: string + code_verifier: string + } + const started = Date.now() + while (Date.now() - started < 15 * 60 * 1000) { + if (signal?.aborted) throw new Error('ChatGPT login cancelled') + const res = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + device_auth_id: deviceCode.deviceAuthId, + user_code: deviceCode.userCode, + }), + signal, + }) + if (res.ok) { + const data = (await res.json()) as TokenPollResponse + return { + authorizationCode: data.authorization_code, + codeVerifier: data.code_verifier, + } + } + if (res.status !== 403 && res.status !== 404) { + throw new Error(`ChatGPT device auth failed (${res.status})`) + } + await new Promise(resolve => + setTimeout(resolve, deviceCode.intervalSeconds * 1000), + ) + } + throw new Error('ChatGPT device auth timed out after 15 minutes') +} + +async function exchangeAuthorizationCode(params: { + authorizationCode: string + codeVerifier: string +}): Promise { + type TokenResponse = { + id_token: string + access_token: string + refresh_token: string + } + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: params.authorizationCode, + redirect_uri: `${ISSUER}/deviceauth/callback`, + client_id: CLIENT_ID, + code_verifier: params.codeVerifier, + }) + const data = await postForm(`${ISSUER}/oauth/token`, body) + return { + idToken: data.id_token, + accessToken: data.access_token, + refreshToken: data.refresh_token, + accountId: extractAccountId({ + idToken: data.id_token, + accessToken: data.access_token, + }), + } +} + +async function refreshTokens( + tokens: ChatGPTAuthTokens, +): Promise { + type TokenResponse = { + id_token: string + access_token: string + refresh_token?: string + } + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: tokens.refreshToken, + client_id: CLIENT_ID, + scope: + 'openid profile email offline_access api.connectors.read api.connectors.invoke', + }) + const data = await postForm(`${ISSUER}/oauth/token`, body) + return { + idToken: data.id_token, + accessToken: data.access_token, + refreshToken: data.refresh_token ?? tokens.refreshToken, + accountId: extractAccountId({ + idToken: data.id_token, + accessToken: data.access_token, + accountId: tokens.accountId, + }), + } +} + +export async function completeChatGPTDeviceLogin( + deviceCode: ChatGPTDeviceCode, + signal?: AbortSignal, +): Promise { + const code = await pollForAuthorizationCode(deviceCode, signal) + const tokens = await exchangeAuthorizationCode(code) + await saveStoredAuth(tokens) + return tokens +} + +export function isChatGPTAuthEnabled(): boolean { + return process.env.OPENAI_AUTH_MODE === 'chatgpt' +} + +export async function removeChatGPTAuth(): Promise { + await unlink(authFilePath()).catch(error => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + }) +} + +export async function getValidChatGPTAuth(): Promise { + let tokens = await readStoredAuth(authFilePath()) + if (!tokens) { + tokens = await readStoredAuth(codexAuthFilePath()) + if (tokens) { + logForDebugging('[OpenAI] Using ChatGPT auth from Codex auth.json') + } + } + if (!tokens) { + throw new Error( + 'ChatGPT account is not logged in. Run /login and select ChatGPT account with subscription.', + ) + } + const expiresAt = getTokenExpiryMs(tokens.accessToken) + if (expiresAt !== null && expiresAt <= Date.now() + REFRESH_SKEW_MS) { + tokens = await refreshTokens(tokens) + await saveStoredAuth(tokens) + } + return { + accessToken: tokens.accessToken, + accountId: tokens.accountId ?? extractAccountId(tokens), + } +} diff --git a/src/services/api/openai/index.ts b/src/services/api/openai/index.ts index 5ac47ab4e7..85c37b0327 100644 --- a/src/services/api/openai/index.ts +++ b/src/services/api/openai/index.ts @@ -17,6 +17,13 @@ import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI, } from '@ant/model-provider' +import { isChatGPTAuthEnabled } from './chatgptAuth.js' +import { + adaptResponsesStreamToAnthropic, + buildResponsesRequest, + createChatGPTResponsesStream, + type ResponsesReasoningEffort, +} from './responsesAdapter.js' import { normalizeMessagesForAPI } from '../../../utils/messages.js' import { toolToAPISchema } from '../../../utils/api.js' import { @@ -62,6 +69,29 @@ import { TOOL_SEARCH_TOOL_NAME, } from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js' +function convertToResponsesReasoningEffort( + effortValue: unknown, +): ResponsesReasoningEffort | undefined { + if (effortValue === 'low') return 'low' + if (effortValue === 'medium') return 'medium' + if (effortValue === 'high') return 'high' + if (effortValue === 'xhigh' || effortValue === 'max') return 'xhigh' + if (typeof effortValue === 'number') return 'high' + return undefined +} + +function getChatGPTResponsesReasoningEffort( + effortValue: unknown, +): ResponsesReasoningEffort | undefined { + const envOverride = process.env.CLAUDE_CODE_EFFORT_LEVEL?.toLowerCase() + if (envOverride === 'auto' || envOverride === 'unset') return undefined + return ( + convertToResponsesReasoningEffort(envOverride) ?? + convertToResponsesReasoningEffort(effortValue) ?? + 'medium' + ) +} + /** * Mirrors the Anthropic request path's deferred-tool announcement for OpenAI. * @@ -269,6 +299,9 @@ export async function* queryModelOpenAI( ) const openaiTools = anthropicToolsToOpenAI(standardTools) const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice) + const reasoningEffort = getChatGPTResponsesReasoningEffort( + options.effortValue, + ) // 9. Log tool filtering details if (useToolSearch) { @@ -307,32 +340,50 @@ export async function* queryModelOpenAI( options.maxOutputTokensOverride, ) - // 11. Get client - const client = getOpenAIClient({ - maxRetries: 0, - fetchOverride: options.fetchOverride as unknown as typeof fetch, - source: options.querySource, - }) - logForDebugging( `[OpenAI] Calling model=${openaiModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}, thinking=${enableThinking}`, ) - // 12. Call OpenAI API with streaming - const requestBody = buildOpenAIRequestBody({ - model: openaiModel, - messages: openaiMessages, - tools: openaiTools, - toolChoice: openaiToolChoice, - enableThinking, - maxTokens, - temperatureOverride: options.temperatureOverride, - }) - const stream = await client.chat.completions.create(requestBody, { signal }) + // 11. Call OpenAI API with streaming. ChatGPT subscription auth uses the + // Codex Responses backend; API-key/OpenAI-compatible auth keeps the + // existing Chat Completions adapter. + const adaptedStream = isChatGPTAuthEnabled() + ? adaptResponsesStreamToAnthropic( + await createChatGPTResponsesStream({ + request: buildResponsesRequest({ + model: openaiModel, + messages: openaiMessages, + tools: openaiTools, + toolChoice: openaiToolChoice, + reasoningEffort, + }), + signal, + fetchOverride: options.fetchOverride as unknown as typeof fetch, + }), + openaiModel, + ) + : adaptOpenAIStreamToAnthropic( + await getOpenAIClient({ + maxRetries: 0, + fetchOverride: options.fetchOverride as unknown as typeof fetch, + source: options.querySource, + }).chat.completions.create( + buildOpenAIRequestBody({ + model: openaiModel, + messages: openaiMessages, + tools: openaiTools, + toolChoice: openaiToolChoice, + enableThinking, + maxTokens, + temperatureOverride: options.temperatureOverride, + }), + { signal }, + ), + openaiModel, + ) // 12. Convert OpenAI stream to Anthropic events, then process into // AssistantMessage + StreamEvent (matching the Anthropic path behavior) - const adaptedStream = adaptOpenAIStreamToAnthropic(stream, openaiModel) // Accumulate content blocks and usage, same as the Anthropic path in claude.ts const contentBlocks: Record = {} diff --git a/src/services/api/openai/responsesAdapter.ts b/src/services/api/openai/responsesAdapter.ts new file mode 100644 index 0000000000..c074eed5b4 --- /dev/null +++ b/src/services/api/openai/responsesAdapter.ts @@ -0,0 +1,480 @@ +import { randomUUID } from 'crypto' +import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import { getValidChatGPTAuth } from './chatgptAuth.js' + +type ResponsesInputItem = Record +type ResponsesTool = Record +export type ResponsesReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' + +type ResponsesRequest = { + model: string + stream: true + store: false + input: ResponsesInputItem[] + instructions?: string + tools?: ResponsesTool[] + tool_choice?: unknown + reasoning?: { effort: ResponsesReasoningEffort } + parallel_tool_calls?: boolean +} + +type AnthropicUsage = { + input_tokens: number + output_tokens: number + cache_creation_input_tokens: number + cache_read_input_tokens: number +} + +function textFromContent(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content + .map(part => { + if (!part || typeof part !== 'object') return '' + const record = part as Record + if (typeof record.text === 'string') return record.text + return '' + }) + .filter(Boolean) + .join('\n') +} + +function convertUserContent(content: unknown): unknown { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return textFromContent(content) + const result: Array> = [] + for (const part of content) { + if (!part || typeof part !== 'object') continue + const record = part as Record + if (record.type === 'text' && typeof record.text === 'string') { + result.push({ type: 'input_text', text: record.text }) + } else if (record.type === 'image_url') { + const imageUrl = record.image_url as Record | undefined + if (typeof imageUrl?.url === 'string') { + result.push({ type: 'input_image', image_url: imageUrl.url }) + } + } + } + return result.length > 0 ? result : textFromContent(content) +} + +function convertMessagesToResponsesInput(messages: unknown[]): { + input: ResponsesInputItem[] + instructions?: string +} { + const input: ResponsesInputItem[] = [] + const instructions: string[] = [] + + for (const message of messages) { + if (!message || typeof message !== 'object') continue + const record = message as Record + const role = record.role + + if (role === 'system' || role === 'developer') { + const text = textFromContent(record.content) + if (text) instructions.push(text) + continue + } + + if (role === 'tool') { + const callId = record.tool_call_id + if (typeof callId === 'string') { + input.push({ + type: 'function_call_output', + call_id: callId, + output: textFromContent(record.content), + }) + } + continue + } + + if (role === 'assistant') { + const text = textFromContent(record.content) + if (text) { + input.push({ role: 'assistant', content: text }) + } + const toolCalls = record.tool_calls + if (Array.isArray(toolCalls)) { + for (const toolCall of toolCalls) { + if (!toolCall || typeof toolCall !== 'object') continue + const tc = toolCall as Record + const fn = tc.function as Record | undefined + const id = typeof tc.id === 'string' ? tc.id : undefined + const name = typeof fn?.name === 'string' ? fn.name : undefined + if (!id || !name) continue + input.push({ + type: 'function_call', + call_id: id, + name, + arguments: typeof fn?.arguments === 'string' ? fn.arguments : '{}', + }) + } + } + continue + } + + if (role === 'user') { + input.push({ + role: 'user', + content: convertUserContent(record.content), + }) + } + } + + return { + input, + instructions: + instructions.length > 0 ? instructions.join('\n\n') : undefined, + } +} + +function convertToolsToResponses(tools: unknown[]): ResponsesTool[] { + const result: ResponsesTool[] = [] + for (const tool of tools) { + if (!tool || typeof tool !== 'object') continue + const record = tool as Record + const fn = record.function as Record | undefined + const name = typeof fn?.name === 'string' ? fn.name : undefined + if (!name) continue + result.push({ + type: 'function', + name, + description: typeof fn?.description === 'string' ? fn.description : '', + parameters: + fn?.parameters && typeof fn.parameters === 'object' + ? fn.parameters + : { type: 'object', properties: {} }, + strict: false, + }) + } + return result +} + +function convertToolChoiceToResponses(toolChoice: unknown): unknown { + if (toolChoice === 'required') return 'required' + if (toolChoice === 'auto') return 'auto' + if (!toolChoice || typeof toolChoice !== 'object') return toolChoice + const record = toolChoice as Record + const fn = record.function as Record | undefined + if (record.type === 'function' && typeof fn?.name === 'string') { + return { type: 'function', name: fn.name } + } + return toolChoice +} + +export function buildResponsesRequest(params: { + model: string + messages: unknown[] + tools: unknown[] + toolChoice: unknown + reasoningEffort?: ResponsesReasoningEffort +}): ResponsesRequest { + const { input, instructions } = convertMessagesToResponsesInput( + params.messages, + ) + const tools = convertToolsToResponses(params.tools) + return { + model: params.model, + stream: true, + store: false, + input, + ...(instructions ? { instructions } : {}), + ...(tools.length > 0 ? { tools } : {}), + ...(params.toolChoice + ? { tool_choice: convertToolChoiceToResponses(params.toolChoice) } + : {}), + ...(params.reasoningEffort + ? { reasoning: { effort: params.reasoningEffort } } + : {}), + parallel_tool_calls: true, + } +} + +async function* parseSSE( + response: Response, +): AsyncGenerator, void> { + if (!response.body) throw new Error('ChatGPT response did not include a body') + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + let splitAt = buffer.indexOf('\n\n') + while (splitAt >= 0) { + const frame = buffer.slice(0, splitAt) + buffer = buffer.slice(splitAt + 2) + const data = frame + .split(/\r?\n/) + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trimStart()) + .join('\n') + if (data && data !== '[DONE]') { + const parsed = JSON.parse(data) as unknown + if (parsed && typeof parsed === 'object') { + yield parsed as Record + } + } + splitAt = buffer.indexOf('\n\n') + } + } +} + +function extractUsage( + response: Record | undefined, +): AnthropicUsage { + const usage = response?.usage as Record | undefined + const inputDetails = usage?.input_tokens_details as + | Record + | undefined + return { + input_tokens: + typeof usage?.input_tokens === 'number' ? usage.input_tokens : 0, + output_tokens: + typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: + typeof inputDetails?.cached_tokens === 'number' + ? inputDetails.cached_tokens + : 0, + } +} + +function mapStopReason(response: Record | undefined): string { + if (response?.status === 'incomplete') return 'max_tokens' + return 'end_turn' +} + +export async function* adaptResponsesStreamToAnthropic( + stream: AsyncIterable>, + model: string, +): AsyncGenerator { + const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 24)}` + const toolBlocks = new Map< + number, + { contentIndex: number; open: boolean; name: string; id: string } + >() + let started = false + let currentContentIndex = -1 + let textBlockOpen = false + let thinkingBlockOpen = false + + const ensureStarted = async function* () { + if (started) return + started = true + yield { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + content: [], + model, + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + } as unknown as BetaRawMessageStreamEvent + } + + for await (const event of stream) { + for await (const startedEvent of ensureStarted()) yield startedEvent + const type = event.type + + if (type === 'response.output_text.delta') { + if (!textBlockOpen) { + if (thinkingBlockOpen) { + yield { + type: 'content_block_stop', + index: currentContentIndex, + } as BetaRawMessageStreamEvent + thinkingBlockOpen = false + } + currentContentIndex++ + textBlockOpen = true + yield { + type: 'content_block_start', + index: currentContentIndex, + content_block: { type: 'text', text: '' }, + } as BetaRawMessageStreamEvent + } + yield { + type: 'content_block_delta', + index: currentContentIndex, + delta: { type: 'text_delta', text: String(event.delta ?? '') }, + } as BetaRawMessageStreamEvent + continue + } + + if (type === 'response.reasoning_text.delta') { + if (!thinkingBlockOpen) { + if (textBlockOpen) { + yield { + type: 'content_block_stop', + index: currentContentIndex, + } as BetaRawMessageStreamEvent + textBlockOpen = false + } + currentContentIndex++ + thinkingBlockOpen = true + yield { + type: 'content_block_start', + index: currentContentIndex, + content_block: { type: 'thinking', thinking: '', signature: '' }, + } as BetaRawMessageStreamEvent + } + yield { + type: 'content_block_delta', + index: currentContentIndex, + delta: { type: 'thinking_delta', thinking: String(event.delta ?? '') }, + } as BetaRawMessageStreamEvent + continue + } + + if (type === 'response.output_item.added') { + const item = event.item as Record | undefined + const outputIndex = + typeof event.output_index === 'number' ? event.output_index : -1 + if (item?.type === 'function_call' && outputIndex >= 0) { + if (textBlockOpen) { + yield { + type: 'content_block_stop', + index: currentContentIndex, + } as BetaRawMessageStreamEvent + textBlockOpen = false + } + if (thinkingBlockOpen) { + yield { + type: 'content_block_stop', + index: currentContentIndex, + } as BetaRawMessageStreamEvent + thinkingBlockOpen = false + } + currentContentIndex++ + const id = String(item.call_id ?? item.id ?? `call_${outputIndex}`) + const name = String(item.name ?? '') + toolBlocks.set(outputIndex, { + contentIndex: currentContentIndex, + open: true, + name, + id, + }) + yield { + type: 'content_block_start', + index: currentContentIndex, + content_block: { type: 'tool_use', id, name, input: {} }, + } as BetaRawMessageStreamEvent + } + continue + } + + if (type === 'response.function_call_arguments.delta') { + const outputIndex = + typeof event.output_index === 'number' ? event.output_index : -1 + const block = toolBlocks.get(outputIndex) + if (block) { + yield { + type: 'content_block_delta', + index: block.contentIndex, + delta: { + type: 'input_json_delta', + partial_json: String(event.delta ?? ''), + }, + } as BetaRawMessageStreamEvent + } + continue + } + + if (type === 'response.output_item.done') { + const outputIndex = + typeof event.output_index === 'number' ? event.output_index : -1 + const block = toolBlocks.get(outputIndex) + if (block?.open) { + yield { + type: 'content_block_stop', + index: block.contentIndex, + } as BetaRawMessageStreamEvent + block.open = false + } + continue + } + + if (type === 'response.error') { + const error = event.error as Record | undefined + throw new Error(String(error?.message ?? 'ChatGPT Responses API error')) + } + + if (type === 'response.failed') { + const response = event.response as Record | undefined + const error = response?.error as Record | undefined + throw new Error(String(error?.message ?? 'ChatGPT Responses API failed')) + } + + if (type === 'response.completed' || type === 'response.incomplete') { + if (textBlockOpen) { + yield { + type: 'content_block_stop', + index: currentContentIndex, + } as BetaRawMessageStreamEvent + textBlockOpen = false + } + if (thinkingBlockOpen) { + yield { + type: 'content_block_stop', + index: currentContentIndex, + } as BetaRawMessageStreamEvent + thinkingBlockOpen = false + } + const response = event.response as Record | undefined + yield { + type: 'message_delta', + delta: { stop_reason: mapStopReason(response), stop_sequence: null }, + usage: extractUsage(response), + } as unknown as BetaRawMessageStreamEvent + yield { type: 'message_stop' } as BetaRawMessageStreamEvent + } + } +} + +export async function createChatGPTResponsesStream(params: { + request: ResponsesRequest + signal: AbortSignal + fetchOverride?: typeof fetch +}): Promise>> { + const auth = await getValidChatGPTAuth() + const fetchFn = params.fetchOverride ?? (globalThis.fetch as typeof fetch) + const headers: Record = { + Authorization: `Bearer ${auth.accessToken}`, + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + 'OpenAI-Beta': 'responses=experimental', + Origin: 'https://chatgpt.com', + Referer: 'https://chatgpt.com/', + originator: 'claude-code-best', + } + if (auth.accountId) { + headers['ChatGPT-Account-Id'] = auth.accountId + } + const response = await fetchFn( + 'https://chatgpt.com/backend-api/codex/responses', + { + method: 'POST', + headers, + body: JSON.stringify(params.request), + signal: params.signal, + }, + ) + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error( + `ChatGPT Responses API request failed (${response.status})${text ? `: ${text.slice(0, 500)}` : ''}`, + ) + } + return parseSSE(response) +} diff --git a/src/utils/effort.ts b/src/utils/effort.ts index e4c32d74bd..9a7824a865 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -9,6 +9,10 @@ import { isEnvTruthy } from './envUtils.js' import type { EffortLevel } from 'src/entrypoints/sdk/runtimeTypes.js' import { resolveAntModel } from './model/antModels.js' import { getAntModelOverrideConfig } from './model/antModels.js' +import { + isChatGPTAuthMode, + isChatGPTCodexReasoningModel, +} from './model/chatgptModels.js' export type { EffortLevel } @@ -32,6 +36,13 @@ export function modelSupportsEffort(model: string): boolean { if (supported3P !== undefined) { return supported3P } + if ( + getAPIProvider() === 'openai' && + isChatGPTAuthMode() && + isChatGPTCodexReasoningModel(model) + ) { + return true + } // Supported by a subset of Claude 4 models if ( m.includes('opus-4-7') || @@ -87,6 +98,13 @@ export function modelSupportsXhighEffort(model: string): boolean { if (supported3P !== undefined) { return supported3P } + if ( + getAPIProvider() === 'openai' && + isChatGPTAuthMode() && + isChatGPTCodexReasoningModel(model) + ) { + return true + } if (model.toLowerCase().includes('opus-4-7')) { return true } @@ -200,6 +218,16 @@ export function resolveAppliedEffort( if (resolved === 'xhigh' && !modelSupportsXhighEffort(model)) { return 'high' } + // OpenAI Responses uses xhigh as its highest public reasoning effort. + // Keep /effort max usable as a familiar alias in ChatGPT subscription mode. + if ( + resolved === 'max' && + getAPIProvider() === 'openai' && + isChatGPTAuthMode() && + modelSupportsXhighEffort(model) + ) { + return 'xhigh' + } // API rejects 'max' on non-Opus-4.6 models — downgrade to 'high'. if (resolved === 'max' && !modelSupportsMaxEffort(model)) { return 'high' @@ -347,6 +375,14 @@ export function getDefaultEffortForModel( // the model launch DRI and research. Default effort is a sensitive setting // that can greatly affect model quality and bashing. + if ( + getAPIProvider() === 'openai' && + isChatGPTAuthMode() && + isChatGPTCodexReasoningModel(model) + ) { + return 'medium' + } + // Default effort on Opus 4.6 to medium for Pro. // Max/Team also get medium when the tengu_grey_step2 config is enabled. if ( diff --git a/src/utils/managedEnvConstants.ts b/src/utils/managedEnvConstants.ts index d1976c1146..a8ef5714c2 100644 --- a/src/utils/managedEnvConstants.ts +++ b/src/utils/managedEnvConstants.ts @@ -12,7 +12,7 @@ * config vars (endpoint, project, region, auth) do. * * Note: OpenAI provider uses OPENAI_* env vars (OPENAI_API_KEY, OPENAI_BASE_URL, - * OPENAI_MODEL, OPENAI_DEFAULT_*_MODEL, OPENAI_SMALL_FAST_MODEL) which are all + * OPENAI_MODEL, OPENAI_AUTH_MODE, OPENAI_DEFAULT_*_MODEL, OPENAI_SMALL_FAST_MODEL) which are all * provider-managed to keep routing config isolated from Anthropic settings. */ const PROVIDER_MANAGED_ENV_VARS = new Set([ @@ -58,6 +58,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'ANTHROPIC_DEFAULT_SONNET_MODEL_NAME', 'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', // OpenAI provider specific + 'OPENAI_AUTH_MODE', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', diff --git a/src/utils/model/chatgptModels.ts b/src/utils/model/chatgptModels.ts new file mode 100644 index 0000000000..8136765454 --- /dev/null +++ b/src/utils/model/chatgptModels.ts @@ -0,0 +1,52 @@ +export type ChatGPTCodexModelOption = { + value: string + label: string + description: string +} + +export const CHATGPT_CODEX_DEFAULT_MODEL = 'gpt-5.5' +export const CHATGPT_CODEX_FAST_MODEL = 'gpt-5.4-mini' + +export const CHATGPT_CODEX_MODEL_OPTIONS: ChatGPTCodexModelOption[] = [ + { + value: 'gpt-5.5', + label: 'GPT-5.5', + description: 'Frontier model for complex coding, research, and real-world work', + }, + { + value: 'gpt-5.4', + label: 'GPT-5.4', + description: 'Strong model for everyday coding', + }, + { + value: 'gpt-5.4-mini', + label: 'GPT-5.4-Mini', + description: 'Small, fast, and cost-efficient model for simpler coding tasks', + }, + { + value: 'gpt-5.3-codex', + label: 'GPT-5.3-Codex', + description: 'Coding-optimized model', + }, + { + value: 'gpt-5.3-codex-spark', + label: 'GPT-5.3-Codex-Spark', + description: 'Ultra-fast coding model', + }, + { + value: 'gpt-5.2', + label: 'GPT-5.2', + description: 'Optimized for professional work and long-running agents', + }, +] + +export function isChatGPTAuthMode(): boolean { + return process.env.OPENAI_AUTH_MODE === 'chatgpt' +} + +export function isChatGPTCodexReasoningModel(model: string): boolean { + const normalized = model.toLowerCase().replace(/\[1m\]$/, '') + return CHATGPT_CODEX_MODEL_OPTIONS.some( + option => option.value.toLowerCase() === normalized, + ) +} diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index a43d101bb4..385212f51b 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -29,6 +29,11 @@ import { LIGHTNING_BOLT } from '../../constants/figures.js' import { isModelAllowed } from './modelAllowlist.js' import { type ModelAlias, isModelAlias } from './aliases.js' import { capitalize } from '../stringUtils.js' +import { + CHATGPT_CODEX_DEFAULT_MODEL, + CHATGPT_CODEX_FAST_MODEL, + isChatGPTAuthMode, +} from './chatgptModels.js' export type ModelShortName = string export type ModelName = string @@ -36,6 +41,9 @@ export type ModelSetting = ModelName | ModelAlias | null export function getSmallFastModel(): ModelName { const provider = getAPIProvider() + if (provider === 'openai' && isChatGPTAuthMode()) { + return process.env.OPENAI_SMALL_FAST_MODEL ?? CHATGPT_CODEX_FAST_MODEL + } // Provider-specific small fast model if (provider === 'openai' && process.env.OPENAI_SMALL_FAST_MODEL) { return process.env.OPENAI_SMALL_FAST_MODEL @@ -115,6 +123,9 @@ export function getBestModel(): ModelName { // @[MODEL LAUNCH]: Update the default Opus model (3P providers may lag so keep defaults unchanged). export function getDefaultOpusModel(): ModelName { const provider = getAPIProvider() + if (provider === 'openai' && isChatGPTAuthMode()) { + return CHATGPT_CODEX_DEFAULT_MODEL + } // For OpenAI provider, check OPENAI_DEFAULT_OPUS_MODEL first if (provider === 'openai' && process.env.OPENAI_DEFAULT_OPUS_MODEL) { return process.env.OPENAI_DEFAULT_OPUS_MODEL @@ -140,6 +151,9 @@ export function getDefaultOpusModel(): ModelName { // @[MODEL LAUNCH]: Update the default Sonnet model (3P providers may lag so keep defaults unchanged). export function getDefaultSonnetModel(): ModelName { const provider = getAPIProvider() + if (provider === 'openai' && isChatGPTAuthMode()) { + return CHATGPT_CODEX_DEFAULT_MODEL + } // For OpenAI provider, check OPENAI_DEFAULT_SONNET_MODEL first if (provider === 'openai' && process.env.OPENAI_DEFAULT_SONNET_MODEL) { return process.env.OPENAI_DEFAULT_SONNET_MODEL @@ -162,6 +176,9 @@ export function getDefaultSonnetModel(): ModelName { // @[MODEL LAUNCH]: Update the default Haiku model (3P providers may lag so keep defaults unchanged). export function getDefaultHaikuModel(): ModelName { const provider = getAPIProvider() + if (provider === 'openai' && isChatGPTAuthMode()) { + return CHATGPT_CODEX_FAST_MODEL + } // For OpenAI provider, check OPENAI_DEFAULT_HAIKU_MODEL first if (provider === 'openai' && process.env.OPENAI_DEFAULT_HAIKU_MODEL) { return process.env.OPENAI_DEFAULT_HAIKU_MODEL diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index 7e9bb5f11f..48dbee22f3 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -33,6 +33,11 @@ import { } from './model.js' import { has1mContext } from '../context.js' import { getGlobalConfig } from '../config.js' +import { + CHATGPT_CODEX_DEFAULT_MODEL, + CHATGPT_CODEX_MODEL_OPTIONS, + isChatGPTAuthMode, +} from './chatgptModels.js' // @[MODEL LAUNCH]: Update all the available and default model option strings below. @@ -336,6 +341,23 @@ function getOpusPlanOption(): ModelOption { } } +function getChatGPTCodexModelOptions(): ModelOption[] { + return [ + { + value: null, + label: 'Default (recommended)', + description: `Use the default ChatGPT Codex model (currently ${CHATGPT_CODEX_DEFAULT_MODEL})`, + descriptionForModel: `Default ChatGPT Codex model (currently ${CHATGPT_CODEX_DEFAULT_MODEL})`, + }, + ...CHATGPT_CODEX_MODEL_OPTIONS.map(model => ({ + value: model.value, + label: model.label, + description: model.description, + descriptionForModel: `${model.description} (${model.value})`, + })), + ] +} + // @[MODEL LAUNCH]: Update the model picker lists below to include/reorder options for the new model. // Each user tier (ant, Max/Team Premium, Pro/Team Standard/Enterprise, PAYG 1P, PAYG 3P) has its own list. function getModelOptionsBase(fastMode = false): ModelOption[] { @@ -357,6 +379,10 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { ] } + if (getAPIProvider() === 'openai' && isChatGPTAuthMode()) { + return getChatGPTCodexModelOptions() + } + if (isClaudeAISubscriber()) { if (isMaxSubscriber() || isTeamPremiumSubscriber()) { // Max and Team Premium users: Default = Opus 4.7 1M (merged), plus Opus 4.6 1M From b52c10ddb90af659b6c53ece9a5a93ffce902afe Mon Sep 17 00:00:00 2001 From: Bill Date: Sat, 9 May 2026 16:21:07 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DCI=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E6=A3=80=E6=9F=A5=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/logout/logout.tsx | 6 ++---- src/components/ConsoleOAuthFlow.tsx | 3 +-- src/components/ModelPicker.tsx | 14 ++------------ src/utils/model/chatgptModels.ts | 6 ++++-- 4 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/commands/logout/logout.tsx b/src/commands/logout/logout.tsx index 78eafc0cdb..b5b604de0d 100644 --- a/src/commands/logout/logout.tsx +++ b/src/commands/logout/logout.tsx @@ -56,9 +56,7 @@ function clearChatGPTSettingsAuthMode(): void { Boolean(env.OPENAI_API_KEY ?? process.env.OPENAI_API_KEY) && Boolean(env.OPENAI_BASE_URL ?? process.env.OPENAI_BASE_URL); const settingsUpdate: Parameters[1] = { - ...(userSettings.modelType === 'openai' && !hasOpenAICompatibleConfig - ? { modelType: undefined } - : {}), + ...(userSettings.modelType === 'openai' && !hasOpenAICompatibleConfig ? { modelType: undefined } : {}), env: { OPENAI_AUTH_MODE: undefined, } as unknown as Record, @@ -92,7 +90,7 @@ export async function clearAuthRelatedCaches(): Promise { export async function call(): Promise { await performLogout({ clearOnboarding: true }); - const message = Successfully logged out. + const message = Successfully logged out.; setTimeout(() => { gracefulShutdownSync(0, 'logout'); diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index 29b81ff47d..084cdf2d05 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -458,8 +458,7 @@ function OAuthStatusMessage({ { label: ( - ChatGPT account with subscription ·{' '} - Plus, Pro, Business, Edu, or Enterprise + ChatGPT account with subscription · Plus, Pro, Business, Edu, or Enterprise {'\n'} ), diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index 6f13653258..2e878cb98b 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -176,21 +176,11 @@ export function ModelPicker({ (direction: 'left' | 'right') => { if (!focusedSupportsEffort) return; setEffort(prev => - cycleEffortLevel( - prev ?? focusedDefaultEffort, - direction, - focusedSupportsXhigh, - focusedSupportsMax, - ), + cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedSupportsXhigh, focusedSupportsMax), ); setHasToggledEffort(true); }, - [ - focusedSupportsEffort, - focusedSupportsXhigh, - focusedSupportsMax, - focusedDefaultEffort, - ], + [focusedSupportsEffort, focusedSupportsXhigh, focusedSupportsMax, focusedDefaultEffort], ); useKeybindings( diff --git a/src/utils/model/chatgptModels.ts b/src/utils/model/chatgptModels.ts index 8136765454..4521b9ebff 100644 --- a/src/utils/model/chatgptModels.ts +++ b/src/utils/model/chatgptModels.ts @@ -11,7 +11,8 @@ export const CHATGPT_CODEX_MODEL_OPTIONS: ChatGPTCodexModelOption[] = [ { value: 'gpt-5.5', label: 'GPT-5.5', - description: 'Frontier model for complex coding, research, and real-world work', + description: + 'Frontier model for complex coding, research, and real-world work', }, { value: 'gpt-5.4', @@ -21,7 +22,8 @@ export const CHATGPT_CODEX_MODEL_OPTIONS: ChatGPTCodexModelOption[] = [ { value: 'gpt-5.4-mini', label: 'GPT-5.4-Mini', - description: 'Small, fast, and cost-efficient model for simpler coding tasks', + description: + 'Small, fast, and cost-efficient model for simpler coding tasks', }, { value: 'gpt-5.3-codex',