diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index 84c318c8..ffc012d5 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -323,12 +323,16 @@ describe('ModelSelector', () => { name: 'GPT-5.3 Codex', description: 'Best for coding and agentic work', context: '', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], }, { id: 'gpt-5.5', name: 'GPT-5.5', description: 'Latest general-purpose model', context: '', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], }, ] const setSessionRuntime = vi.fn() @@ -363,13 +367,77 @@ describe('ModelSelector', () => { expect(useSessionRuntimeStore.getState().selections['session-openai']).toEqual({ providerId: OPENAI_OFFICIAL_PROVIDER_ID, modelId: 'gpt-5.5', - effortLevel: 'max', + effortLevel: 'medium', }) expect(setSessionRuntime).toHaveBeenCalledWith('session-openai', { providerId: OPENAI_OFFICIAL_PROVIDER_ID, modelId: 'gpt-5.5', + effortLevel: 'medium', + }) + }) + + it('uses each ChatGPT model reasoning catalog and resets unsupported effort to its default', async () => { + const openAIModels: ModelInfo[] = [ + { + id: 'gpt-5.6-sol', + name: 'GPT-5.6-Sol', + description: 'Frontier model', + context: '353400', + defaultReasoningEffort: 'low', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + }, + { + id: 'gpt-5.5', + name: 'GPT-5.5', + description: 'General model', + context: '258400', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + }, + ] + useHahaOpenAIOAuthStore.setState({ + status: { loggedIn: true, expiresAt: null, email: null, accountId: null }, + fetchStatus: async () => {}, + }) + useSettingsStore.setState({ + locale: 'en', + availableModels: openAIModels, + currentModel: openAIModels[0], + activeProviderName: 'ChatGPT Official', effortLevel: 'max', }) + useProviderStore.setState({ + providers: [], + activeId: OPENAI_OFFICIAL_PROVIDER_ID, + hasLoadedProviders: true, + isLoading: true, + }) + useSessionRuntimeStore.getState().setSelection('session-openai-effort', { + providerId: OPENAI_OFFICIAL_PROVIDER_ID, + modelId: 'gpt-5.6-sol', + effortLevel: 'max', + }) + + render() + + await clickByRole(/GPT-5\.6-Sol/i) + await clickByRole(/GPT-5\.5/) + + expect(useSessionRuntimeStore.getState().selections['session-openai-effort']).toEqual({ + providerId: OPENAI_OFFICIAL_PROVIDER_ID, + modelId: 'gpt-5.5', + effortLevel: 'medium', + }) + + await clickByRole(/GPT-5\.5/i) + expect(screen.queryByRole('button', { name: /^Max$/ })).not.toBeInTheDocument() + await clickByRole(/^X-High$/) + + expect(useSessionRuntimeStore.getState().selections['session-openai-effort']).toEqual({ + providerId: OPENAI_OFFICIAL_PROVIDER_ID, + modelId: 'gpt-5.5', + effortLevel: 'xhigh', + }) }) it('hides official provider sections when OAuth is not logged in', async () => { diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index 2b505dc4..d1afa6dc 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -12,7 +12,7 @@ import { DRAFT_RUNTIME_SELECTION_KEY, useSessionRuntimeStore } from '../../store import { useSettingsStore } from '../../stores/settingsStore' import type { SavedProvider } from '../../types/provider' import type { RuntimeSelection } from '../../types/runtime' -import type { EffortLevel, ModelInfo } from '../../types/settings' +import type { ModelInfo, ReasoningEffortLevel } from '../../types/settings' import { useMobileViewport } from '../../hooks/useMobileViewport' import { isDesktopRuntime } from '../../lib/desktopRuntime' import { resolveDefaultRuntimeSelection } from '../../lib/runtimeSelection' @@ -182,10 +182,11 @@ export const ModelSelector = forwardRef(function Mod const requestedProvidersRef = useRef(false) const requestedOAuthStatusRef = useRef(false) - const EFFORT_OPTIONS: { value: EffortLevel; label: string }[] = [ + const EFFORT_OPTIONS: { value: ReasoningEffortLevel; label: string }[] = [ { value: 'low', label: t('settings.general.effort.low') }, { value: 'medium', label: t('settings.general.effort.medium') }, { value: 'high', label: t('settings.general.effort.high') }, + { value: 'xhigh', label: t('settings.general.effort.xhigh') }, { value: 'max', label: t('settings.general.effort.max') }, ] @@ -344,7 +345,13 @@ export const ModelSelector = forwardRef(function Mod const buttonProviderLabel = isRuntimeScoped ? selectedProviderChoice?.providerName ?? activeProviderName ?? t('settings.providers.officialName') : null - const selectedRuntimeEffort = activeRuntimeSelection?.effortLevel ?? effortLevel + const selectedRuntimeEffort = activeRuntimeSelection?.effortLevel + ?? selectedRuntimeModel?.defaultReasoningEffort + ?? effortLevel + const supportedRuntimeEfforts = selectedRuntimeModel?.supportedReasoningEfforts + const runtimeEffortOptions = supportedRuntimeEfforts?.length + ? EFFORT_OPTIONS.filter((option) => supportedRuntimeEfforts.includes(option.value)) + : EFFORT_OPTIONS.filter((option) => option.value !== 'xhigh') const handleRuntimeSelect = (selection: RuntimeSelection) => { onRuntimeSelectionChange?.(selection) @@ -357,7 +364,7 @@ export const ModelSelector = forwardRef(function Mod setOpen(false) } - const handleRuntimeEffortSelect = (level: EffortLevel) => { + const handleRuntimeEffortSelect = (level: ReasoningEffortLevel) => { if (!activeRuntimeSelection) return handleRuntimeSelect({ ...activeRuntimeSelection, @@ -397,11 +404,20 @@ export const ModelSelector = forwardRef(function Mod return ( handleRuntimeSelect({ - providerId: choice.providerId, - modelId: model.id, - effortLevel: selectedRuntimeEffort, - })} + onClick={() => { + const supportedEfforts = model.supportedReasoningEfforts + const explicitEffort = activeRuntimeSelection?.effortLevel + const nextEffort = supportedEfforts?.length + ? explicitEffort && supportedEfforts.includes(explicitEffort) + ? explicitEffort + : model.defaultReasoningEffort ?? supportedEfforts[0] + : explicitEffort ?? effortLevel + handleRuntimeSelect({ + providerId: choice.providerId, + modelId: model.id, + ...(nextEffort ? { effortLevel: nextEffort } : {}), + }) + }} className={` w-full rounded-lg border px-3 text-left transition-colors ${isMobileBrowser ? 'min-h-[56px] py-3' : 'py-2.5'} @@ -492,8 +508,8 @@ export const ModelSelector = forwardRef(function Mod {t('model.effort')} - - {EFFORT_OPTIONS.map((opt) => { + + {runtimeEffortOptions.map((opt) => { const isSelected = opt.value === selectedRuntimeEffort return ( = { 'settings.general.effort.low': '低', 'settings.general.effort.medium': '中', 'settings.general.effort.high': '高', + 'settings.general.effort.xhigh': '最高', 'settings.general.effort.max': '最大', 'settings.general.thinkingTitle': '思考モード', 'settings.general.thinkingDescription': '新しいセッションをモデルの思考を有効にして開始するかどうかを制御します。オフの場合、DeepSeek などの対応プロバイダーには明示的に非思考パラメーターが渡されます。', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 5dd77dbb..82461a2f 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1004,6 +1004,7 @@ export const kr: Record = { 'settings.general.effort.low': '낮음', 'settings.general.effort.medium': '보통', 'settings.general.effort.high': '높음', + 'settings.general.effort.xhigh': '매우 높음', 'settings.general.effort.max': '최대', 'settings.general.thinkingTitle': '사고 모드', 'settings.general.thinkingDescription': '새 세션을 모델 사고를 사용으로 시작할지 제어합니다. 꺼져 있으면 DeepSeek 같은 호환 공급자에 명시적인 비사고 매개변수가 전달됩니다.', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 0d647bef..7d576962 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1004,6 +1004,7 @@ export const zh: Record = { 'settings.general.effort.low': '低', 'settings.general.effort.medium': '中', 'settings.general.effort.high': '高', + 'settings.general.effort.xhigh': '極高', 'settings.general.effort.max': '最大', 'settings.general.thinkingTitle': '思考模式', 'settings.general.thinkingDescription': '控制新會話是否啟用模型思考。關閉後,DeepSeek 等相容供應商會收到顯式非思考模式引數。', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 1f601d58..32482a37 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1004,6 +1004,7 @@ export const zh: Record = { 'settings.general.effort.low': '低', 'settings.general.effort.medium': '中', 'settings.general.effort.high': '高', + 'settings.general.effort.xhigh': '极高', 'settings.general.effort.max': '最大', 'settings.general.thinkingTitle': '思考模式', 'settings.general.thinkingDescription': '控制新会话是否启用模型思考。关闭后,DeepSeek 等兼容供应商会收到显式非思考模式参数。', diff --git a/desktop/src/lib/persistenceMigrations.test.ts b/desktop/src/lib/persistenceMigrations.test.ts index 06a97757..1b166f4d 100644 --- a/desktop/src/lib/persistenceMigrations.test.ts +++ b/desktop/src/lib/persistenceMigrations.test.ts @@ -72,14 +72,14 @@ describe('desktop persistence migrations', () => { window.localStorage.setItem('unrelated-user-key', 'keep') window.localStorage.setItem('cc-haha-session-runtime', JSON.stringify({ good: { providerId: null, modelId: 'claude-sonnet' }, - alsoGood: { providerId: 'provider-1', modelId: 'gpt-5.4' }, + alsoGood: { providerId: 'openai-official', modelId: 'gpt-5.6-sol', effortLevel: 'xhigh' }, bad: { providerId: 'provider-2' }, })) runDesktopPersistenceMigrations() expect(JSON.parse(window.localStorage.getItem('cc-haha-session-runtime') || '{}')).toEqual({ - alsoGood: { providerId: 'provider-1', modelId: 'gpt-5.4' }, + alsoGood: { providerId: 'openai-official', modelId: 'gpt-5.6-sol', effortLevel: 'xhigh' }, good: { providerId: null, modelId: 'claude-sonnet' }, }) expect(window.localStorage.getItem('unrelated-user-key')).toBe('keep') diff --git a/desktop/src/lib/persistenceMigrations.ts b/desktop/src/lib/persistenceMigrations.ts index f2006b8d..57b63278 100644 --- a/desktop/src/lib/persistenceMigrations.ts +++ b/desktop/src/lib/persistenceMigrations.ts @@ -19,7 +19,7 @@ const TAB_STORAGE_KEY = 'cc-haha-open-tabs' const SESSION_RUNTIME_STORAGE_KEY = 'cc-haha-session-runtime' const THEME_STORAGE_KEY = 'cc-haha-theme' const LOCALE_STORAGE_KEY = 'cc-haha-locale' -const EFFORT_LEVELS = ['low', 'medium', 'high', 'max'] +const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] const PERSISTED_SPECIAL_TAB_TYPES = ['settings', 'scheduled', 'market', 'traces'] as const const PERSISTED_SPECIAL_TAB_IDS: Record<(typeof PERSISTED_SPECIAL_TAB_TYPES)[number], string> = { settings: '__settings__', diff --git a/desktop/src/stores/providerStore.test.ts b/desktop/src/stores/providerStore.test.ts index 20795170..fa1f98ae 100644 --- a/desktop/src/stores/providerStore.test.ts +++ b/desktop/src/stores/providerStore.test.ts @@ -164,7 +164,7 @@ describe('providerStore runtime refresh', () => { const { useProviderStore } = await import('./providerStore') await useProviderStore.getState().activateProvider('openai-official') - expect(settingsSetModelMock).toHaveBeenCalledWith('gpt-5.3-codex') + expect(settingsSetModelMock).toHaveBeenCalledWith('gpt-5.6-sol') expect(settingsFetchAllMock).toHaveBeenCalled() }) diff --git a/desktop/src/types/runtime.ts b/desktop/src/types/runtime.ts index c47f815e..4efc0b47 100644 --- a/desktop/src/types/runtime.ts +++ b/desktop/src/types/runtime.ts @@ -1,7 +1,7 @@ -import type { EffortLevel } from './settings' +import type { ReasoningEffortLevel } from './settings' export type RuntimeSelection = { providerId: string | null modelId: string - effortLevel?: EffortLevel + effortLevel?: ReasoningEffortLevel } diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 2b3f3af9..63c938cc 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -3,6 +3,7 @@ export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions' | 'dontAsk' export type EffortLevel = 'low' | 'medium' | 'high' | 'max' +export type ReasoningEffortLevel = EffortLevel | 'xhigh' export const THEME_MODES = ['white', 'light', 'dark'] as const export type ThemeMode = (typeof THEME_MODES)[number] @@ -102,6 +103,8 @@ export type ModelInfo = { name: string description: string context: string + defaultReasoningEffort?: ReasoningEffortLevel + supportedReasoningEfforts?: ReasoningEffortLevel[] } export type UserSettings = { diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index ca8987b6..108c143a 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -739,8 +739,8 @@ describe('ConversationService', () => { expect(env.OPENAI_CODEX_OAUTH_FILE).toBe( path.join(tmpDir, 'cc-haha', 'openai-oauth.json'), ) - expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex') - expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4') + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol') + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.6-terra') expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1') expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined() expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() @@ -749,6 +749,25 @@ describe('ConversationService', () => { expect(env.ANTHROPIC_BASE_URL).toBeUndefined() }) + test('buildChildEnv passes OpenAI-native effort without leaking Claude effort state', async () => { + const originalEffort = process.env.CC_HAHA_OPENAI_REASONING_EFFORT + process.env.CC_HAHA_OPENAI_REASONING_EFFORT = 'stale-parent-effort' + try { + const service = new ConversationService() as any + const env = (await service.buildChildEnv('/tmp', undefined, { + providerId: 'openai-official', + model: 'gpt-5.6-sol', + effort: 'xhigh', + })) as Record + + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol') + expect(env.CC_HAHA_OPENAI_REASONING_EFFORT).toBe('xhigh') + } finally { + if (originalEffort === undefined) delete process.env.CC_HAHA_OPENAI_REASONING_EFFORT + else process.env.CC_HAHA_OPENAI_REASONING_EFFORT = originalEffort + } + }) + test('buildChildEnv does not leak inherited CLAUDE_CODE_OAUTH_TOKEN when official token is unavailable', async () => { const ccHahaDir = path.join(tmpDir, 'cc-haha') await fs.mkdir(ccHahaDir, { recursive: true }) diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 1585f1f6..a1ce4001 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -333,6 +333,19 @@ describe('ConversationService', () => { ]) }) + it('should keep OpenAI-native reasoning controls out of Claude CLI args', () => { + const svc = new ConversationService() + expect((svc as any).getRuntimeArgs({ + providerId: 'openai-official', + model: 'gpt-5.6-sol', + effort: 'xhigh', + thinking: 'disabled', + })).toEqual([ + '--model', + 'gpt-5.6-sol', + ]) + }) + it('should send thinking token controls to active CLI sessions', () => { const svc = new ConversationService() as any const sent: string[] = [] @@ -4179,8 +4192,11 @@ describe('WebSocket Chat Integration', () => { sessionId, options: { providerId: 'openai-official', + model: 'gpt-5.6-sol', + effort: 'low', }, }) + expect(startCalls[0]?.options?.thinking).toBeUndefined() expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true) await expect(providerService.listProviders()).resolves.toMatchObject({ activeId: 'openai-official', @@ -4192,6 +4208,100 @@ describe('WebSocket Chat Integration', () => { } }, 20_000) + it('should accept xhigh for GPT-5.6 and pass it to the OpenAI runtime', async () => { + const providerService = new ProviderService() + await providerService.activateProvider('openai-official') + + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir: process.cwd() }), + }) + const { sessionId } = await createRes.json() as { sessionId: string } + const originalStartSession = conversationService.startSession.bind(conversationService) + const startCalls: Array<{ options?: { model?: string; effort?: string; providerId?: string | null } }> = [] + conversationService.startSession = (async function patchedStartSession( + sid: string, + workDir: string, + sdkUrl: string, + options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null }, + ) { + startCalls.push({ options }) + return originalStartSession(sid, workDir, sdkUrl, options) + }) as typeof conversationService.startSession + + try { + await new Promise((resolve, reject) => { + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + const timeout = setTimeout(() => { + ws.close() + reject(new Error('Timed out waiting for GPT-5.6 xhigh runtime turn')) + }, 10_000) + + ws.onmessage = (event) => { + const message = JSON.parse(event.data as string) + if (message.type === 'connected') { + ws.send(JSON.stringify({ + type: 'set_runtime_config', + providerId: 'openai-official', + modelId: 'gpt-5.6-sol', + effortLevel: 'xhigh', + })) + ws.send(JSON.stringify({ type: 'user_message', content: 'use xhigh' })) + } else if (message.type === 'error') { + clearTimeout(timeout) + ws.close() + reject(new Error(message.message)) + } else if (message.type === 'message_complete') { + clearTimeout(timeout) + ws.close() + resolve() + } + } + ws.onerror = () => reject(new Error('WebSocket failed for GPT-5.6 xhigh runtime')) + }) + + expect(startCalls[0]?.options).toMatchObject({ + providerId: 'openai-official', + model: 'gpt-5.6-sol', + effort: 'xhigh', + }) + } finally { + conversationService.startSession = originalStartSession + conversationService.stopSession(sessionId) + await providerService.activateOfficial() + } + }, 20_000) + + it('should reject a reasoning effort that the selected ChatGPT model does not support', async () => { + const sessionId = `chat-openai-invalid-effort-${crypto.randomUUID()}` + await new Promise((resolve, reject) => { + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + const timeout = setTimeout(() => { + ws.close() + reject(new Error('Timed out waiting for invalid OpenAI effort rejection')) + }, 5_000) + + ws.onmessage = (event) => { + const message = JSON.parse(event.data as string) + if (message.type === 'connected') { + ws.send(JSON.stringify({ + type: 'set_runtime_config', + providerId: 'openai-official', + modelId: 'gpt-5.5', + effortLevel: 'max', + })) + } else if (message.type === 'error') { + clearTimeout(timeout) + expect(message).toMatchObject({ code: 'RUNTIME_CONFIG_INVALID' }) + ws.close() + resolve() + } + } + ws.onerror = () => reject(new Error('WebSocket failed for invalid OpenAI effort')) + }) + }, 10_000) + it('should resume streaming to a reconnected client during an active turn', async () => { await withMockStreamDelay(150, async () => { const sessionId = `chat-reconnect-${crypto.randomUUID()}` diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 60e41dbd..c82a564b 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -395,10 +395,10 @@ describe('ProviderService', () => { apiFormat: 'openai_responses', runtimeKind: 'openai_oauth', models: { - main: 'gpt-5.3-codex', - haiku: 'gpt-5.4-mini', - sonnet: 'gpt-5.4', - opus: 'gpt-5.3-codex', + main: 'gpt-5.6-sol', + haiku: 'gpt-5.6-luna', + sonnet: 'gpt-5.6-terra', + opus: 'gpt-5.6-sol', }, }) }) @@ -416,12 +416,15 @@ describe('ProviderService', () => { expect(env.OPENAI_CODEX_OAUTH_FILE).toBe( path.join(tmpDir, 'cc-haha', 'openai-oauth.json'), ) - expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex') - expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini') - expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4') - expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex') + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol') + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.6-luna') + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.6-terra') + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.6-sol') expect(typeof env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS).toBe('string') expect(JSON.parse(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toEqual({ + 'gpt-5.6-sol': 353_400, + 'gpt-5.6-terra': 353_400, + 'gpt-5.6-luna': 353_400, 'gpt-5.3-codex': 258_400, 'gpt-5.4': 950_000, 'gpt-5.5': 258_400, diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index e6c05adc..a2639c11 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -12,6 +12,9 @@ import { handleSettingsApi } from '../api/settings.js' import { handleModelsApi } from '../api/models.js' import { handleStatusApi, resetUsage, addUsage } from '../api/status.js' import { ProviderService } from '../services/providerService.js' +import { + clearOpenAICodexModelCatalogCache, +} from '../../services/openaiAuth/modelCatalog.js' import { clearOpenAIOAuthTokenCache, } from '../../services/openaiAuth/storage.js' @@ -630,8 +633,14 @@ describe('Models API', () => { expiresAt: Date.now() + 60_000, }) + const originalFetch = globalThis.fetch + clearOpenAICodexModelCatalogCache() + globalThis.fetch = async () => new Response('offline', { status: 503 }) const { req, url, segments } = makeRequest('GET', '/api/models') - const res = await handleModelsApi(req, url, segments) + const res = await handleModelsApi(req, url, segments).finally(() => { + globalThis.fetch = originalFetch + clearOpenAICodexModelCatalogCache() + }) expect(res.status).toBe(200) const body = await res.json() @@ -639,6 +648,7 @@ describe('Models API', () => { expect(ids).toContain('deepseek-v4-pro') expect(ids).toContain('deepseek-v4-flash') + expect(ids).toContain('gpt-5.6-sol') expect(ids).toContain('gpt-5.3-codex') expect(ids).toContain('gpt-5.4') expect(ids).toContain('gpt-5.4-mini') @@ -767,11 +777,19 @@ describe('Models API', () => { name: 'ChatGPT Official', }) expect(body.models.map((model) => model.id)).toEqual([ + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', 'gpt-5.3-codex', 'gpt-5.4', 'gpt-5.5', 'gpt-5.4-mini', ]) + expect(body.models[0]).toMatchObject({ + id: 'gpt-5.6-sol', + defaultReasoningEffort: 'low', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + }) }) it('PUT /api/models/current should persist GPT model to managed settings when ChatGPT Official is active', async () => { @@ -879,6 +897,9 @@ describe('Model Options', () => { const labels = options.map(option => option.label) expect(values).toContain('gpt-5.3-codex') + expect(values).toContain('gpt-5.6-sol') + expect(values).toContain('gpt-5.6-terra') + expect(values).toContain('gpt-5.6-luna') expect(values).toContain('gpt-5.4') expect(values).toContain('gpt-5.4-mini') expect(labels).toContain('deepseek-v4-pro') diff --git a/src/server/api/models.ts b/src/server/api/models.ts index 73755672..b7c5ae0b 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -13,7 +13,11 @@ import { ProviderService } from '../services/providerService.js' import { attributionHeaderEnvForModel } from '../services/attributionHeaderPolicy.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' import { hasOpenAIAuthLogin } from '../../utils/auth.js' -import { OPENAI_CODEX_MODEL_CATALOG } from '../../services/openaiAuth/models.js' +import { getOpenAICodexModelCatalog } from '../../services/openaiAuth/modelCatalog.js' +import { + OPENAI_DEFAULT_MAIN_MODEL, + type OpenAIModelCatalogEntry, +} from '../../services/openaiAuth/models.js' import { OPENAI_OFFICIAL_PROVIDER_ID, OPENAI_OFFICIAL_PROVIDER_NAME, @@ -56,6 +60,8 @@ type ApiModelInfo = { name: string description: string context: string + defaultReasoningEffort?: string + supportedReasoningEfforts?: string[] } function addUniqueModel( @@ -115,15 +121,21 @@ function buildProviderModelList(models: { return modelList } -function buildOpenAIModelList(): ApiModelInfo[] { - return OPENAI_CODEX_MODEL_CATALOG.map(model => ({ +function buildOpenAIModelList(catalog: OpenAIModelCatalogEntry[]): ApiModelInfo[] { + return catalog.map(model => ({ id: model.value, name: model.label, description: model.description, - context: '', + context: model.contextWindow ? String(model.contextWindow) : '', + defaultReasoningEffort: model.defaultReasoningEffort, + supportedReasoningEfforts: model.supportedReasoningEfforts, })) } +async function getOpenAIModelList(): Promise { + return buildOpenAIModelList(await getOpenAICodexModelCatalog()) +} + function getEnvConfiguredAnthropicModels(): ApiModelInfo[] { return buildProviderModelList({ main: process.env.ANTHROPIC_MODEL?.trim() || '', @@ -133,22 +145,22 @@ function getEnvConfiguredAnthropicModels(): ApiModelInfo[] { }) } -function getOpenAIAuthModels(): ApiModelInfo[] { +async function getOpenAIAuthModels(): Promise { if (!hasOpenAIAuthLogin()) { return [] } - return buildOpenAIModelList() + return getOpenAIModelList() } -function getStandaloneModelList(): ApiModelInfo[] { +async function getStandaloneModelList(): Promise { const models = [...getEnvConfiguredAnthropicModels()] if (models.length === 0) { models.push(...DEFAULT_MODELS) } - for (const model of getOpenAIAuthModels()) { + for (const model of await getOpenAIAuthModels()) { addUniqueModel(models, model) } @@ -201,7 +213,7 @@ async function handleModelsList(): Promise { const { providers, activeId } = await providerService.listProviders() if (isOpenAIOfficialProviderId(activeId)) { return Response.json({ - models: buildOpenAIModelList(), + models: await getOpenAIModelList(), provider: { id: OPENAI_OFFICIAL_PROVIDER_ID, name: OPENAI_OFFICIAL_PROVIDER_NAME, @@ -217,7 +229,7 @@ async function handleModelsList(): Promise { provider: { id: activeProvider.id, name: activeProvider.name }, }) } - return Response.json({ models: getStandaloneModelList(), provider: null }) + return Response.json({ models: await getStandaloneModelList(), provider: null }) } async function handleCurrentModel(req: Request): Promise { @@ -238,7 +250,7 @@ async function handleCurrentModel(req: Request): Promise { let currentModelName: string if (isOpenAIProviderActive) { - currentModelId = explicitModel || env.ANTHROPIC_MODEL || 'gpt-5.3-codex' + currentModelId = explicitModel || env.ANTHROPIC_MODEL || OPENAI_DEFAULT_MAIN_MODEL currentModelName = currentModelId } else if (activeProvider) { // Provider is active — only use the provider-managed cc-haha settings. @@ -262,10 +274,10 @@ async function handleCurrentModel(req: Request): Promise { // Build available models for name lookup const availableModels = isOpenAIProviderActive - ? buildOpenAIModelList() + ? await getOpenAIModelList() : activeProvider ? buildProviderModelList(activeProvider.models) - : getStandaloneModelList() + : await getStandaloneModelList() const modelEntry = availableModels.find((m) => m.id === lookupId) || availableModels.find((m) => m.id === currentModelId) diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index ec6cdfc6..7688e19a 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -13,7 +13,12 @@ import { ProviderService } from './providerService.js' import { OPENAI_CODEX_OAUTH_FILE_ENV_KEY, OPENAI_OAUTH_PROVIDER_ENV_KEY, + isOpenAIOfficialProviderId, } from './openaiOfficialProvider.js' +import { + OPENAI_CODEX_REASONING_EFFORT_ENV_KEY, + isOpenAIReasoningEffort, +} from '../../services/openaiAuth/models.js' import { sessionService } from './sessionService.js' import { diagnosticsService } from './diagnosticsService.js' import { @@ -1043,11 +1048,11 @@ export class ConversationService { args.push('--model', options.model) } - if (options?.effort) { + if (options?.effort && !isOpenAIOfficialProviderId(options.providerId)) { args.push('--effort', options.effort) } - if (options?.thinking) { + if (options?.thinking && !isOpenAIOfficialProviderId(options.providerId)) { args.push('--thinking', options.thinking) } @@ -1085,6 +1090,7 @@ export class ConversationService { 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', OPENAI_OAUTH_PROVIDER_ENV_KEY, OPENAI_CODEX_OAUTH_FILE_ENV_KEY, + OPENAI_CODEX_REASONING_EFFORT_ENV_KEY, ] as const const cleanEnv = await getProcessEnvWithTerminalShellEnvironment() @@ -1209,6 +1215,12 @@ export class ConversationService { // 否则 CLI 会忽略 provider 的 AUTH_TOKEN、错误地走 OAuth 打到第三方 // endpoint。详见 src/utils/auth.ts isManagedOAuthContext()。 ...(explicitProviderEnv ?? {}), + ...( + isOpenAIOfficialProviderId(options?.providerId) && + isOpenAIReasoningEffort(options?.effort) + ? { [OPENAI_CODEX_REASONING_EFFORT_ENV_KEY]: options.effort } + : {} + ), ...networkEnv, ...(this.shouldMarkManagedOAuth(options?.providerId) ? await this.buildOfficialOAuthEnv() diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 86338d33..964daa91 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -312,7 +312,7 @@ const VALID_SESSION_PERMISSION_MODES = new Set([ 'bypassPermissions', 'dontAsk', ]) -const VALID_SESSION_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']) +const VALID_SESSION_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']) type ContentBlock = Record diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 351eaf9d..7c7e9282 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -18,6 +18,12 @@ import { sessionService } from '../services/sessionService.js' import { SettingsService } from '../services/settingsService.js' import { ProviderService } from '../services/providerService.js' import { isOpenAIOfficialProviderId } from '../services/openaiOfficialProvider.js' +import { getOpenAICodexModelCatalog } from '../../services/openaiAuth/modelCatalog.js' +import { + OPENAI_DEFAULT_MAIN_MODEL, + getOpenAIModelCatalogEntry, + isOpenAIReasoningEffort, +} from '../../services/openaiAuth/models.js' import { diagnosticsService } from '../services/diagnosticsService.js' import { buildConversationTitleInput, @@ -111,7 +117,7 @@ const prewarmPendingSessions = new Set() const prewarmedSessions = new Set() const prewarmIdleTimers = new Map>() const DEFAULT_PREWARM_IDLE_TIMEOUT_MS = 5 * 60_000 -const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']) +const VALID_CLAUDE_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']) async function sendRepositoryStartupStatus( ws: ServerWebSocket, @@ -795,7 +801,10 @@ async function handleSetRuntimeConfig( } const effortLevel = typeof message.effortLevel === 'string' ? message.effortLevel.trim() : undefined - if (effortLevel !== undefined && !VALID_EFFORT_LEVELS.has(effortLevel)) { + if ( + effortLevel !== undefined && + !(await isRuntimeEffortSupported(message.providerId, modelId, effortLevel)) + ) { sendMessage(ws, { type: 'error', message: 'Runtime effort selection is invalid.', @@ -2611,6 +2620,28 @@ type RuntimeSettings = { providerId?: string | null } +async function getDefaultOpenAIReasoningEffort(modelId: string): Promise { + const catalog = await getOpenAICodexModelCatalog() + return getOpenAIModelCatalogEntry(modelId, catalog)?.defaultReasoningEffort ?? 'medium' +} + +async function isRuntimeEffortSupported( + providerId: string | null | undefined, + modelId: string, + effort: string, +): Promise { + if (!isOpenAIOfficialProviderId(providerId)) { + return VALID_CLAUDE_EFFORT_LEVELS.has(effort) + } + if (!isOpenAIReasoningEffort(effort)) { + return false + } + + const catalog = await getOpenAICodexModelCatalog() + const model = getOpenAIModelCatalogEntry(modelId, catalog) + return !model || model.supportedReasoningEfforts.includes(effort) +} + function isKnownRuntimeProviderId( providerId: string, providers: Array<{ id: string }>, @@ -2657,12 +2688,20 @@ async function getRuntimeSettings(sessionId?: string): Promise } const userSettings = await settingsService.getUserSettings() - const thinking = resolveDesktopThinkingMode(userSettings) + const thinking = resolveDesktopThinkingMode( + userSettings, + runtimeOverride.providerId, + ) + const effort = runtimeOverride.effort ?? ( + isOpenAIOfficialProviderId(runtimeOverride.providerId) + ? await getDefaultOpenAIReasoningEffort(runtimeOverride.modelId) + : undefined + ) return { permissionMode: sessionPermissionMode ?? await settingsService.getPermissionMode().catch(() => undefined), model: runtimeOverride.modelId, - effort: runtimeOverride.effort, + effort, thinking, providerId: runtimeOverride.providerId, } @@ -2700,11 +2739,11 @@ async function getDefaultRuntimeSettings(): Promise { typeof modelSettings.modelContext === 'string' && modelSettings.modelContext.trim() ? modelSettings.modelContext : undefined - const effort = + let effort = typeof userSettings.effort === 'string' && userSettings.effort.trim() ? userSettings.effort : undefined - const thinking = resolveDesktopThinkingMode(userSettings) + const thinking = resolveDesktopThinkingMode(userSettings, resolvedActiveId) let model: string | undefined if (resolvedActiveId) { @@ -2718,6 +2757,10 @@ async function getDefaultRuntimeSettings(): Promise { model = baseModel if (modelContext) model += `:${modelContext}` } + if (isOpenAIOfficialProviderId(resolvedActiveId)) { + model = model || OPENAI_DEFAULT_MAIN_MODEL + effort = await getDefaultOpenAIReasoningEffort(model) + } } else { // No provider — pass model normally const baseModel = @@ -2738,7 +2781,9 @@ async function getDefaultRuntimeSettings(): Promise { function resolveDesktopThinkingMode( settings: Record, + providerId?: string | null, ): 'disabled' | undefined { + if (isOpenAIOfficialProviderId(providerId)) return undefined return settings.alwaysThinkingEnabled === false ? 'disabled' : undefined } diff --git a/src/services/openaiAuth/client.ts b/src/services/openaiAuth/client.ts index eb0765e5..de3853a2 100644 --- a/src/services/openaiAuth/client.ts +++ b/src/services/openaiAuth/client.ts @@ -11,9 +11,12 @@ export const OPENAI_AUTH_ISSUER = 'https://auth.openai.com' export const OPENAI_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' export const OPENAI_CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses' +export const OPENAI_CODEX_CLIENT_VERSION = '0.144.0' +export const OPENAI_CODEX_ORIGINATOR = 'codex_cli_rs' export const OPENAI_CODEX_OAUTH_PORT = 1455 export const OPENAI_CODEX_REDIRECT_PATH = '/auth/callback' -export const OPENAI_CODEX_TOKEN_USER_AGENT = 'codex-cli/0.91.0' +export const OPENAI_CODEX_TOKEN_USER_AGENT = + `codex-cli/${OPENAI_CODEX_CLIENT_VERSION}` const DEFAULT_TOKEN_LIFETIME_MS = 3600 * 1000 const OPENAI_TOKEN_ERROR_BODY_LIMIT = 500 diff --git a/src/services/openaiAuth/fetch.test.ts b/src/services/openaiAuth/fetch.test.ts index ace92bb7..a88b41e2 100644 --- a/src/services/openaiAuth/fetch.test.ts +++ b/src/services/openaiAuth/fetch.test.ts @@ -4,15 +4,19 @@ import * as os from 'os' import * as path from 'path' import { OPENAI_CODEX_API_ENDPOINT } from './client.js' import { buildOpenAICodexFetch } from './fetch.js' +import { OPENAI_CODEX_REASONING_EFFORT_ENV_KEY } from './models.js' import { clearOpenAIOAuthTokenCache } from './storage.js' describe('buildOpenAICodexFetch', () => { let tmpDir: string let originalTokenFile: string | undefined + let originalReasoningEffort: string | undefined beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openai-codex-fetch-')) originalTokenFile = process.env.OPENAI_CODEX_OAUTH_FILE + originalReasoningEffort = process.env[OPENAI_CODEX_REASONING_EFFORT_ENV_KEY] + delete process.env[OPENAI_CODEX_REASONING_EFFORT_ENV_KEY] process.env.OPENAI_CODEX_OAUTH_FILE = path.join(tmpDir, 'openai-oauth.json') clearOpenAIOAuthTokenCache() await fs.writeFile( @@ -34,6 +38,11 @@ describe('buildOpenAICodexFetch', () => { } else { process.env.OPENAI_CODEX_OAUTH_FILE = originalTokenFile } + if (originalReasoningEffort === undefined) { + delete process.env[OPENAI_CODEX_REASONING_EFFORT_ENV_KEY] + } else { + process.env[OPENAI_CODEX_REASONING_EFFORT_ENV_KEY] = originalReasoningEffort + } clearOpenAIOAuthTokenCache() await fs.rm(tmpDir, { recursive: true, force: true }) }) @@ -80,7 +89,9 @@ describe('buildOpenAICodexFetch', () => { expect(upstreamCalls[0].url).toBe(OPENAI_CODEX_API_ENDPOINT) expect(upstreamCalls[0].headers.authorization).toBe('Bearer access-for-chatgpt') expect(upstreamCalls[0].headers['chatgpt-account-id']).toBe('acct_fetch') + expect(upstreamCalls[0].headers.originator).toBe('codex_cli_rs') expect(upstreamCalls[0].body.model).toBe('gpt-5.5') + expect(upstreamCalls[0].body.reasoning).toEqual({ effort: 'medium' }) expect(response.status).toBe(200) await expect(response.json()).resolves.toMatchObject({ type: 'message', @@ -130,4 +141,53 @@ describe('buildOpenAICodexFetch', () => { content: [{ type: 'text', text: 'streamed ok' }], }) }) + + test('applies model defaults and preserves native xhigh/max efforts on the final request', async () => { + const upstreamBodies: Array> = [] + const fetchOverride: typeof fetch = async (_input, init) => { + const body = JSON.parse(String(init?.body)) as Record + upstreamBodies.push(body) + return Response.json({ + id: `resp_${upstreamBodies.length}`, + object: 'response', + created_at: 1_779_118_000, + model: body.model, + status: 'completed', + output: [{ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'ok' }], + }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }) + } + const openAIFetch = buildOpenAICodexFetch(fetchOverride, 'test') + + const send = async (model: string, effort?: string) => { + if (effort) process.env[OPENAI_CODEX_REASONING_EFFORT_ENV_KEY] = effort + else delete process.env[OPENAI_CODEX_REASONING_EFFORT_ENV_KEY] + await openAIFetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: JSON.stringify({ + model, + max_tokens: 64, + messages: [{ role: 'user', content: 'Say ok' }], + }), + }) + } + + await send('gpt-5.6-sol') + await send('gpt-5.6-terra') + await send('gpt-5.6-sol', 'xhigh') + await send('gpt-5.6-luna', 'max') + await send('gpt-5.5', 'max') + + expect(upstreamBodies.map((body) => body.reasoning)).toEqual([ + { effort: 'low' }, + { effort: 'medium' }, + { effort: 'xhigh' }, + { effort: 'max' }, + { effort: 'medium' }, + ]) + }) }) diff --git a/src/services/openaiAuth/fetch.ts b/src/services/openaiAuth/fetch.ts index 35f44a27..36772ae3 100644 --- a/src/services/openaiAuth/fetch.ts +++ b/src/services/openaiAuth/fetch.ts @@ -1,8 +1,16 @@ import type { ClientOptions } from '@anthropic-ai/sdk' import { randomUUID } from 'crypto' -import { OPENAI_CODEX_API_ENDPOINT } from './client.js' +import { + OPENAI_CODEX_API_ENDPOINT, + OPENAI_CODEX_ORIGINATOR, + OPENAI_CODEX_TOKEN_USER_AGENT, +} from './client.js' import { ensureFreshOpenAITokens } from './index.js' -import { resolveOpenAICodexModel } from './models.js' +import { + OPENAI_CODEX_REASONING_EFFORT_ENV_KEY, + resolveOpenAICodexModel, + resolveOpenAIReasoningEffort, +} from './models.js' import { getOpenAIOAuthTokens } from './storage.js' import { anthropicToOpenaiResponses } from '../../server/proxy/transform/anthropicToOpenaiResponses.js' import { openaiResponsesToAnthropic } from '../../server/proxy/transform/openaiResponsesToAnthropic.js' @@ -37,8 +45,16 @@ export function buildOpenAICodexFetch( ...originalBody, model: mappedModel, }) + const reasoningEffort = resolveOpenAIReasoningEffort( + mappedModel, + process.env[OPENAI_CODEX_REASONING_EFFORT_ENV_KEY], + ) const upstreamBody = { ...transformedBody, + reasoning: { + ...(transformedBody.reasoning ?? {}), + effort: reasoningEffort, + }, stream: true, } @@ -52,6 +68,8 @@ export function buildOpenAICodexFetch( const headers = new Headers() headers.set('Content-Type', 'application/json') headers.set('Authorization', `Bearer ${tokens.accessToken}`) + headers.set('originator', OPENAI_CODEX_ORIGINATOR) + headers.set('User-Agent', OPENAI_CODEX_TOKEN_USER_AGENT) if (tokens.accountId) { headers.set('ChatGPT-Account-Id', tokens.accountId) } diff --git a/src/services/openaiAuth/modelCatalog.test.ts b/src/services/openaiAuth/modelCatalog.test.ts new file mode 100644 index 00000000..a819f8ca --- /dev/null +++ b/src/services/openaiAuth/modelCatalog.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { OPENAI_CODEX_CLIENT_VERSION } from './client.js' +import { + clearOpenAICodexModelCatalogCache, + fetchOpenAICodexModelCatalog, + getOpenAICodexModelCatalog, +} from './modelCatalog.js' +import { clearOpenAIOAuthTokenCache } from './storage.js' + +describe('OpenAI Codex model catalog', () => { + let tmpDir: string + let originalTokenFile: string | undefined + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openai-model-catalog-')) + originalTokenFile = process.env.OPENAI_CODEX_OAUTH_FILE + process.env.OPENAI_CODEX_OAUTH_FILE = path.join(tmpDir, 'openai-oauth.json') + await fs.writeFile( + process.env.OPENAI_CODEX_OAUTH_FILE, + JSON.stringify({ + accessToken: 'catalog-access-token', + refreshToken: 'catalog-refresh-token', + expiresAt: Date.now() + 60 * 60_000, + accountId: 'acct_catalog', + }), + 'utf8', + ) + clearOpenAIOAuthTokenCache() + clearOpenAICodexModelCatalogCache() + }) + + afterEach(async () => { + if (originalTokenFile === undefined) delete process.env.OPENAI_CODEX_OAUTH_FILE + else process.env.OPENAI_CODEX_OAUTH_FILE = originalTokenFile + clearOpenAIOAuthTokenCache() + clearOpenAICodexModelCatalogCache() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + test('loads the account model list with auth and removes unsupported product-only efforts', async () => { + let requestUrl = '' + let requestHeaders = new Headers() + const models = await fetchOpenAICodexModelCatalog(async (input, init) => { + requestUrl = String(input) + requestHeaders = new Headers(init?.headers) + return Response.json({ + models: [ + { + slug: 'gpt-next-account-only', + display_name: 'GPT Next', + description: 'Account-scoped model.', + default_reasoning_level: 'xhigh', + supported_reasoning_levels: [ + { effort: 'low' }, + { effort: 'xhigh' }, + { effort: 'ultra' }, + ], + visibility: 'list', + supported_in_api: false, + context_window: 400_000, + effective_context_window_percent: 90, + }, + { + slug: 'hidden-model', + visibility: 'hide', + supported_in_api: true, + supported_reasoning_levels: [], + }, + ], + }) + }) + + expect(new URL(requestUrl).searchParams.get('client_version')).toBe( + OPENAI_CODEX_CLIENT_VERSION, + ) + expect(requestHeaders.get('Authorization')).toBe('Bearer catalog-access-token') + expect(requestHeaders.get('ChatGPT-Account-Id')).toBe('acct_catalog') + expect(requestHeaders.get('originator')).toBe('codex_cli_rs') + expect(models).toEqual([ + { + value: 'gpt-next-account-only', + label: 'GPT Next', + description: 'Account-scoped model', + defaultReasoningEffort: 'xhigh', + supportedReasoningEfforts: ['low', 'xhigh'], + contextWindow: 360_000, + }, + ]) + }) + + test('falls back to the bundled GPT-5.6 catalog when the endpoint fails', async () => { + const models = await getOpenAICodexModelCatalog({ + forceRefresh: true, + fetchOverride: async () => new Response('unavailable', { status: 503 }), + }) + + expect(models.slice(0, 3).map((model) => model.value)).toEqual([ + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', + ]) + }) +}) diff --git a/src/services/openaiAuth/modelCatalog.ts b/src/services/openaiAuth/modelCatalog.ts new file mode 100644 index 00000000..37f54a95 --- /dev/null +++ b/src/services/openaiAuth/modelCatalog.ts @@ -0,0 +1,169 @@ +import { + OPENAI_CODEX_API_ENDPOINT, + OPENAI_CODEX_CLIENT_VERSION, + OPENAI_CODEX_ORIGINATOR, + OPENAI_CODEX_TOKEN_USER_AGENT, +} from './client.js' +import { ensureFreshOpenAITokens } from './index.js' +import { getOpenAIOAuthTokens } from './storage.js' +import { + OPENAI_CODEX_EFFECTIVE_CONTEXT_PERCENT, + OPENAI_CODEX_MODEL_CATALOG, + getOpenAIModelCatalogEntry, + isOpenAIReasoningEffort, + type OpenAIModelCatalogEntry, +} from './models.js' + +export const OPENAI_CODEX_MODELS_ENDPOINT = new URL( + 'models', + `${OPENAI_CODEX_API_ENDPOINT.replace(/\/responses$/, '')}/`, +).toString() + +const MODEL_CATALOG_TTL_MS = 5 * 60_000 +const MODEL_CATALOG_TIMEOUT_MS = 5_000 +let cachedCatalog: { + accountKey: string + expiresAt: number + models: OpenAIModelCatalogEntry[] +} | null = null + +type RemoteReasoningLevel = { + effort?: unknown +} + +type RemoteModelInfo = { + slug?: unknown + display_name?: unknown + description?: unknown + default_reasoning_level?: unknown + supported_reasoning_levels?: unknown + visibility?: unknown + context_window?: unknown + effective_context_window_percent?: unknown +} + +function normalizeRemoteModel(model: RemoteModelInfo): OpenAIModelCatalogEntry | null { + if ( + typeof model.slug !== 'string' || + !model.slug.trim() || + model.visibility !== 'list' + ) { + return null + } + + const fallback = getOpenAIModelCatalogEntry(model.slug) + const supportedReasoningEfforts = Array.isArray(model.supported_reasoning_levels) + ? model.supported_reasoning_levels + .map((level) => (level as RemoteReasoningLevel)?.effort) + .filter(isOpenAIReasoningEffort) + : [] + const defaultReasoningEffort = isOpenAIReasoningEffort( + model.default_reasoning_level, + ) + ? model.default_reasoning_level + : fallback?.defaultReasoningEffort ?? supportedReasoningEfforts[0] ?? 'medium' + const contextWindow = + typeof model.context_window === 'number' && Number.isFinite(model.context_window) + ? Math.floor( + model.context_window * + (typeof model.effective_context_window_percent === 'number' + ? model.effective_context_window_percent + : OPENAI_CODEX_EFFECTIVE_CONTEXT_PERCENT) / + 100, + ) + : fallback?.contextWindow + + return { + value: model.slug, + label: + typeof model.display_name === 'string' && model.display_name.trim() + ? model.display_name + : fallback?.label ?? model.slug, + description: + typeof model.description === 'string' + ? model.description.replace(/\.$/, '') + : fallback?.description ?? '', + defaultReasoningEffort, + supportedReasoningEfforts: + supportedReasoningEfforts.length > 0 + ? supportedReasoningEfforts + : fallback?.supportedReasoningEfforts ?? ['low', 'medium', 'high'], + ...(contextWindow ? { contextWindow } : {}), + } +} + +export async function fetchOpenAICodexModelCatalog( + fetchOverride: typeof fetch = globalThis.fetch, +): Promise { + const tokens = await ensureFreshOpenAITokens() + if (!tokens) { + throw new Error('OpenAI OAuth token is unavailable') + } + + const url = new URL(OPENAI_CODEX_MODELS_ENDPOINT) + url.searchParams.set('client_version', OPENAI_CODEX_CLIENT_VERSION) + const headers = new Headers({ + Accept: 'application/json', + Authorization: `Bearer ${tokens.accessToken}`, + originator: OPENAI_CODEX_ORIGINATOR, + 'User-Agent': OPENAI_CODEX_TOKEN_USER_AGENT, + }) + if (tokens.accountId) { + headers.set('ChatGPT-Account-Id', tokens.accountId) + } + + const response = await fetchOverride(url, { + method: 'GET', + headers, + signal: AbortSignal.timeout(MODEL_CATALOG_TIMEOUT_MS), + }) + if (!response.ok) { + throw new Error(`OpenAI models endpoint returned HTTP ${response.status}`) + } + + const body = (await response.json()) as { models?: unknown } + if (!Array.isArray(body.models)) { + throw new Error('OpenAI models endpoint returned an invalid response') + } + + return body.models + .map((model) => normalizeRemoteModel(model as RemoteModelInfo)) + .filter((model): model is OpenAIModelCatalogEntry => model !== null) +} + +export async function getOpenAICodexModelCatalog(options?: { + fetchOverride?: typeof fetch + forceRefresh?: boolean +}): Promise { + const tokens = getOpenAIOAuthTokens() + const accountKey = tokens + ? tokens.accountId ?? tokens.email ?? 'authenticated-default' + : 'logged-out' + if ( + !options?.forceRefresh && + cachedCatalog && + cachedCatalog.accountKey === accountKey && + cachedCatalog.expiresAt > Date.now() + ) { + return cachedCatalog.models + } + + try { + const models = await fetchOpenAICodexModelCatalog(options?.fetchOverride) + if (models.length === 0) { + throw new Error('OpenAI models endpoint returned no visible models') + } + cachedCatalog = { + accountKey, + expiresAt: Date.now() + MODEL_CATALOG_TTL_MS, + models, + } + return models + } catch { + return OPENAI_CODEX_MODEL_CATALOG + } +} + +export function clearOpenAICodexModelCatalogCache(): void { + cachedCatalog = null +} diff --git a/src/services/openaiAuth/models.test.ts b/src/services/openaiAuth/models.test.ts index b8bcf9b6..a9fc511f 100644 --- a/src/services/openaiAuth/models.test.ts +++ b/src/services/openaiAuth/models.test.ts @@ -1,12 +1,16 @@ import { describe, expect, test } from 'bun:test' import { + OPENAI_CODEX_FRONTIER_EFFECTIVE_CONTEXT_WINDOW, + OPENAI_CODEX_MODEL_CATALOG, OPENAI_CODEX_LARGE_EFFECTIVE_CONTEXT_WINDOW, OPENAI_CODEX_SPARK_EFFECTIVE_CONTEXT_WINDOW, OPENAI_CODEX_STANDARD_EFFECTIVE_CONTEXT_WINDOW, OPENAI_DEFAULT_MAIN_MODEL, getOpenAICodexContextWindowForModel, + getOpenAIModelDisplayName, isOpenAIResponsesModel, resolveOpenAICodexModel, + resolveOpenAIReasoningEffort, } from './models.js' describe('openai auth model resolution', () => { @@ -24,6 +28,9 @@ describe('openai auth model resolution', () => { }) test('maps Codex OAuth GPT models to effective Codex context windows', () => { + expect(getOpenAICodexContextWindowForModel('gpt-5.6-sol')).toBe( + OPENAI_CODEX_FRONTIER_EFFECTIVE_CONTEXT_WINDOW, + ) expect(getOpenAICodexContextWindowForModel('gpt-5.5')).toBe( OPENAI_CODEX_STANDARD_EFFECTIVE_CONTEXT_WINDOW, ) @@ -40,4 +47,18 @@ describe('openai auth model resolution', () => { OPENAI_CODEX_SPARK_EFFECTIVE_CONTEXT_WINDOW, ) }) + + test('exposes GPT-5.6 family metadata and model-native reasoning defaults', () => { + expect(OPENAI_CODEX_MODEL_CATALOG.slice(0, 3).map((model) => model.value)).toEqual([ + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', + ]) + expect(getOpenAIModelDisplayName('gpt-5.6-sol')).toBe('GPT-5.6-Sol') + expect(resolveOpenAIReasoningEffort('gpt-5.6-sol', undefined)).toBe('low') + expect(resolveOpenAIReasoningEffort('gpt-5.6-terra', undefined)).toBe('medium') + expect(resolveOpenAIReasoningEffort('gpt-5.6-luna', 'max')).toBe('max') + expect(resolveOpenAIReasoningEffort('gpt-5.5', 'max')).toBe('medium') + expect(resolveOpenAIReasoningEffort('gpt-5.5', 'xhigh')).toBe('xhigh') + }) }) diff --git a/src/services/openaiAuth/models.ts b/src/services/openaiAuth/models.ts index 1222d6dd..cf3ce189 100644 --- a/src/services/openaiAuth/models.ts +++ b/src/services/openaiAuth/models.ts @@ -1,8 +1,9 @@ -export const OPENAI_DEFAULT_MAIN_MODEL = 'gpt-5.3-codex' -export const OPENAI_DEFAULT_SONNET_MODEL = 'gpt-5.4' -export const OPENAI_DEFAULT_HAIKU_MODEL = 'gpt-5.4-mini' +export const OPENAI_DEFAULT_MAIN_MODEL = 'gpt-5.6-sol' +export const OPENAI_DEFAULT_SONNET_MODEL = 'gpt-5.6-terra' +export const OPENAI_DEFAULT_HAIKU_MODEL = 'gpt-5.6-luna' export const OPENAI_CODEX_EFFECTIVE_CONTEXT_PERCENT = 95 export const OPENAI_CODEX_STANDARD_CONTEXT_WINDOW = 272_000 +export const OPENAI_CODEX_FRONTIER_CONTEXT_WINDOW = 372_000 export const OPENAI_CODEX_LARGE_CONTEXT_WINDOW = 1_000_000 export const OPENAI_CODEX_SPARK_CONTEXT_WINDOW = 128_000 export const OPENAI_CODEX_STANDARD_EFFECTIVE_CONTEXT_WINDOW = Math.floor( @@ -13,6 +14,10 @@ export const OPENAI_CODEX_LARGE_EFFECTIVE_CONTEXT_WINDOW = Math.floor( (OPENAI_CODEX_LARGE_CONTEXT_WINDOW * OPENAI_CODEX_EFFECTIVE_CONTEXT_PERCENT) / 100, ) +export const OPENAI_CODEX_FRONTIER_EFFECTIVE_CONTEXT_WINDOW = Math.floor( + (OPENAI_CODEX_FRONTIER_CONTEXT_WINDOW * OPENAI_CODEX_EFFECTIVE_CONTEXT_PERCENT) / + 100, +) export const OPENAI_CODEX_SPARK_EFFECTIVE_CONTEXT_WINDOW = Math.floor( (OPENAI_CODEX_SPARK_CONTEXT_WINDOW * OPENAI_CODEX_EFFECTIVE_CONTEXT_PERCENT) / 100, @@ -23,35 +28,137 @@ export type OpenAIModelCatalogEntry = { label: string description: string descriptionForModel?: string + defaultReasoningEffort: OpenAIReasoningEffort + supportedReasoningEfforts: OpenAIReasoningEffort[] + contextWindow?: number } +export const OPENAI_REASONING_EFFORTS = [ + 'low', + 'medium', + 'high', + 'xhigh', + 'max', +] as const + +export type OpenAIReasoningEffort = (typeof OPENAI_REASONING_EFFORTS)[number] + +export const OPENAI_CODEX_REASONING_EFFORT_ENV_KEY = + 'CC_HAHA_OPENAI_REASONING_EFFORT' + +const GPT_5_6_REASONING_EFFORTS: OpenAIReasoningEffort[] = [ + 'low', + 'medium', + 'high', + 'xhigh', + 'max', +] + +const GPT_5_5_REASONING_EFFORTS: OpenAIReasoningEffort[] = [ + 'low', + 'medium', + 'high', + 'xhigh', +] + export const OPENAI_CODEX_MODEL_CATALOG: OpenAIModelCatalogEntry[] = [ { value: OPENAI_DEFAULT_MAIN_MODEL, - label: 'GPT-5.3 Codex', - description: 'Best for coding and agentic work', - descriptionForModel: 'GPT-5.3 Codex - best for coding and agentic work', + label: 'GPT-5.6-Sol', + description: 'Latest frontier agentic coding model', + descriptionForModel: 'GPT-5.6-Sol - latest frontier agentic coding model', + defaultReasoningEffort: 'low', + supportedReasoningEfforts: GPT_5_6_REASONING_EFFORTS, + contextWindow: OPENAI_CODEX_FRONTIER_EFFECTIVE_CONTEXT_WINDOW, }, { value: OPENAI_DEFAULT_SONNET_MODEL, + label: 'GPT-5.6-Terra', + description: 'Balanced agentic coding model for everyday work', + descriptionForModel: 'GPT-5.6-Terra - balanced agentic coding model', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: GPT_5_6_REASONING_EFFORTS, + contextWindow: OPENAI_CODEX_FRONTIER_EFFECTIVE_CONTEXT_WINDOW, + }, + { + value: OPENAI_DEFAULT_HAIKU_MODEL, + label: 'GPT-5.6-Luna', + description: 'Fast and affordable agentic coding model', + descriptionForModel: 'GPT-5.6-Luna - fast and affordable agentic coding model', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: GPT_5_6_REASONING_EFFORTS, + contextWindow: OPENAI_CODEX_FRONTIER_EFFECTIVE_CONTEXT_WINDOW, + }, + { + value: 'gpt-5.3-codex', + label: 'GPT-5.3 Codex', + description: 'Best for coding and agentic work', + descriptionForModel: 'GPT-5.3 Codex - best for coding and agentic work', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + contextWindow: OPENAI_CODEX_STANDARD_EFFECTIVE_CONTEXT_WINDOW, + }, + { + value: 'gpt-5.4', label: 'GPT-5.4', description: 'Strong general-purpose model', descriptionForModel: 'GPT-5.4 - strong general-purpose model', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: GPT_5_5_REASONING_EFFORTS, + contextWindow: OPENAI_CODEX_STANDARD_EFFECTIVE_CONTEXT_WINDOW, }, { value: 'gpt-5.5', label: 'GPT-5.5', description: 'Latest general-purpose model', descriptionForModel: 'GPT-5.5 - latest general-purpose model', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: GPT_5_5_REASONING_EFFORTS, + contextWindow: OPENAI_CODEX_STANDARD_EFFECTIVE_CONTEXT_WINDOW, }, { - value: OPENAI_DEFAULT_HAIKU_MODEL, + value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', description: 'Fastest for quick tasks', descriptionForModel: 'GPT-5.4 Mini - fastest for quick tasks', + defaultReasoningEffort: 'medium', + supportedReasoningEfforts: GPT_5_5_REASONING_EFFORTS, + contextWindow: OPENAI_CODEX_STANDARD_EFFECTIVE_CONTEXT_WINDOW, }, ] +export function isOpenAIReasoningEffort( + value: unknown, +): value is OpenAIReasoningEffort { + return ( + typeof value === 'string' && + (OPENAI_REASONING_EFFORTS as readonly string[]).includes(value) + ) +} + +export function getOpenAIModelCatalogEntry( + model: string, + catalog: OpenAIModelCatalogEntry[] = OPENAI_CODEX_MODEL_CATALOG, +): OpenAIModelCatalogEntry | undefined { + const normalized = model.trim().toLowerCase() + return catalog.find((entry) => entry.value.toLowerCase() === normalized) +} + +export function resolveOpenAIReasoningEffort( + model: string, + requestedEffort: unknown, +): OpenAIReasoningEffort { + const entry = getOpenAIModelCatalogEntry(model) + if ( + isOpenAIReasoningEffort(requestedEffort) && + (!entry || entry.supportedReasoningEfforts.includes(requestedEffort)) + ) { + return requestedEffort + } + + return entry?.defaultReasoningEffort ?? 'medium' +} + export function isOpenAIResponsesModel(model: string): boolean { const normalized = model.trim().toLowerCase() return normalized.startsWith('gpt-') || /^o\d/.test(normalized) @@ -94,6 +201,12 @@ export function getOpenAIModelDisplayName(model: string): string | null { switch (model.trim().toLowerCase()) { case 'gpt-5.3-codex': return 'GPT-5.3 Codex' + case 'gpt-5.6-sol': + return 'GPT-5.6-Sol' + case 'gpt-5.6-terra': + return 'GPT-5.6-Terra' + case 'gpt-5.6-luna': + return 'GPT-5.6-Luna' case 'gpt-5.3-codex-spark': return 'GPT-5.3 Codex Spark' case 'gpt-5.5': @@ -125,6 +238,15 @@ export function getOpenAICodexContextWindowForModel( // Codex OAuth follows the Codex app model catalog, not the public API model // context limits. The catalog applies effective_context_window_percent=95, // and the runtime /context display reports this effective window. + if ( + normalized === 'gpt-5.6-sol' || + normalized === 'gpt-5.6-terra' || + normalized === 'gpt-5.6-luna' || + normalized === 'gpt-5.6' + ) { + return OPENAI_CODEX_FRONTIER_EFFECTIVE_CONTEXT_WINDOW + } + if ( normalized === 'gpt-5.4' || normalized === 'gpt-5.4-pro'