mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Merge branch 'main' into HEAD
This commit is contained in:
commit
d6cd0f1fc7
@ -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(<ModelSelector runtimeKey="session-openai-effort" />)
|
||||
|
||||
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 () => {
|
||||
|
||||
@ -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<ModelSelectorHandle, Props>(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<ModelSelectorHandle, Props>(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<ModelSelectorHandle, Props>(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<ModelSelectorHandle, Props>(function Mod
|
||||
return (
|
||||
<button
|
||||
key={`${choice.providerId ?? 'official'}:${model.id}`}
|
||||
onClick={() => 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<ModelSelectorHandle, Props>(function Mod
|
||||
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
|
||||
{t('model.effort')}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{EFFORT_OPTIONS.map((opt) => {
|
||||
<div className={`grid gap-1.5 ${runtimeEffortOptions.length === 5 ? 'grid-cols-5' : 'grid-cols-4'}`}>
|
||||
{runtimeEffortOptions.map((opt) => {
|
||||
const isSelected = opt.value === selectedRuntimeEffort
|
||||
return (
|
||||
<button
|
||||
|
||||
@ -6,32 +6,64 @@ export const BUILT_IN_PROVIDER_IDS = [
|
||||
CLAUDE_OFFICIAL_PROVIDER_ID,
|
||||
OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
] as const
|
||||
export const OPENAI_OFFICIAL_DEFAULT_MODEL_ID = 'gpt-5.3-codex'
|
||||
export const OPENAI_OFFICIAL_DEFAULT_MODEL_ID = 'gpt-5.6-sol'
|
||||
export const OPENAI_OFFICIAL_PROVIDER_NAME = 'ChatGPT Official'
|
||||
|
||||
export const OPENAI_OFFICIAL_MODELS: ModelInfo[] = [
|
||||
{
|
||||
id: OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
|
||||
name: 'GPT-5.6-Sol',
|
||||
description: 'Latest frontier agentic coding model',
|
||||
context: '353400',
|
||||
defaultReasoningEffort: 'low',
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.6-terra',
|
||||
name: 'GPT-5.6-Terra',
|
||||
description: 'Balanced agentic coding model for everyday work',
|
||||
context: '353400',
|
||||
defaultReasoningEffort: 'medium',
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.6-luna',
|
||||
name: 'GPT-5.6-Luna',
|
||||
description: 'Fast and affordable agentic coding model',
|
||||
context: '353400',
|
||||
defaultReasoningEffort: 'medium',
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.3-codex',
|
||||
name: 'GPT-5.3 Codex',
|
||||
description: 'Best for coding and agentic work',
|
||||
context: '',
|
||||
defaultReasoningEffort: 'medium',
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
name: 'GPT-5.4',
|
||||
description: 'Strong general-purpose model',
|
||||
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'],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4-mini',
|
||||
name: 'GPT-5.4 Mini',
|
||||
description: 'Fastest for quick tasks',
|
||||
context: '',
|
||||
defaultReasoningEffort: 'medium',
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
|
||||
},
|
||||
]
|
||||
|
||||
@ -1002,6 +1002,7 @@ export const en = {
|
||||
'settings.general.effort.low': 'Low',
|
||||
'settings.general.effort.medium': 'Medium',
|
||||
'settings.general.effort.high': 'High',
|
||||
'settings.general.effort.xhigh': 'X-High',
|
||||
'settings.general.effort.max': 'Max',
|
||||
'settings.general.thinkingTitle': 'Thinking Mode',
|
||||
'settings.general.thinkingDescription': 'Controls whether new sessions start with model thinking enabled. When off, compatible providers such as DeepSeek receive an explicit non-thinking parameter.',
|
||||
|
||||
@ -1004,6 +1004,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'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 などの対応プロバイダーには明示的に非思考パラメーターが渡されます。',
|
||||
|
||||
@ -1004,6 +1004,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'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 같은 호환 공급자에 명시적인 비사고 매개변수가 전달됩니다.',
|
||||
|
||||
@ -1004,6 +1004,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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 等相容供應商會收到顯式非思考模式引數。',
|
||||
|
||||
@ -1004,6 +1004,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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 等兼容供应商会收到显式非思考模式参数。',
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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__',
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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<string, string>
|
||||
|
||||
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 })
|
||||
|
||||
@ -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<void>((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<void>((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()}`
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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<ApiModelInfo[]> {
|
||||
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<ApiModelInfo[]> {
|
||||
if (!hasOpenAIAuthLogin()) {
|
||||
return []
|
||||
}
|
||||
|
||||
return buildOpenAIModelList()
|
||||
return getOpenAIModelList()
|
||||
}
|
||||
|
||||
function getStandaloneModelList(): ApiModelInfo[] {
|
||||
async function getStandaloneModelList(): Promise<ApiModelInfo[]> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
@ -238,7 +250,7 @@ async function handleCurrentModel(req: Request): Promise<Response> {
|
||||
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<Response> {
|
||||
|
||||
// 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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<string, unknown>
|
||||
|
||||
|
||||
@ -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<string>()
|
||||
const prewarmedSessions = new Set<string>()
|
||||
const prewarmIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
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<WebSocketData>,
|
||||
@ -799,7 +805,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.',
|
||||
@ -2649,6 +2658,28 @@ type RuntimeSettings = {
|
||||
providerId?: string | null
|
||||
}
|
||||
|
||||
async function getDefaultOpenAIReasoningEffort(modelId: string): Promise<string> {
|
||||
const catalog = await getOpenAICodexModelCatalog()
|
||||
return getOpenAIModelCatalogEntry(modelId, catalog)?.defaultReasoningEffort ?? 'medium'
|
||||
}
|
||||
|
||||
async function isRuntimeEffortSupported(
|
||||
providerId: string | null | undefined,
|
||||
modelId: string,
|
||||
effort: string,
|
||||
): Promise<boolean> {
|
||||
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 }>,
|
||||
@ -2695,12 +2726,20 @@ async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings>
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
@ -2738,11 +2777,11 @@ async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
|
||||
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) {
|
||||
@ -2756,6 +2795,10 @@ async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
|
||||
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 =
|
||||
@ -2776,7 +2819,9 @@ async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
|
||||
|
||||
function resolveDesktopThinkingMode(
|
||||
settings: Record<string, unknown>,
|
||||
providerId?: string | null,
|
||||
): 'disabled' | undefined {
|
||||
if (isOpenAIOfficialProviderId(providerId)) return undefined
|
||||
return settings.alwaysThinkingEnabled === false ? 'disabled' : undefined
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<Record<string, unknown>> = []
|
||||
const fetchOverride: typeof fetch = async (_input, init) => {
|
||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
106
src/services/openaiAuth/modelCatalog.test.ts
Normal file
106
src/services/openaiAuth/modelCatalog.test.ts
Normal file
@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
169
src/services/openaiAuth/modelCatalog.ts
Normal file
169
src/services/openaiAuth/modelCatalog.ts
Normal file
@ -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<OpenAIModelCatalogEntry[]> {
|
||||
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<OpenAIModelCatalogEntry[]> {
|
||||
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
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user