Expose ChatGPT Official model catalog

When ChatGPT Official is active, the model API now returns the OpenAI Codex catalog and reads or writes the selected GPT model from cc-haha managed settings. Desktop provider activation also recognizes the built-in provider id and resets the current model to the OpenAI default instead of looking for a saved provider record.

Constraint: ChatGPT Official is a built-in provider and is not present in providers.json providers[].

Rejected: Reuse the four-slot provider model list | it hides GPT-5.5 and misrepresents the OpenAI catalog as Anthropic slot names.

Confidence: high

Scope-risk: moderate

Tested: bun test src/server/__tests__/settings.test.ts

Tested: bun test src/server/__tests__/providers.test.ts --test-name-pattern "ChatGPT Official|getProviderForProxy"

Tested: bun test src/server/__tests__/conversation-service.test.ts --test-name-pattern "ChatGPT Official|session-scoped provider"

Tested: cd desktop && bun run test src/stores/providerStore.test.ts

Tested: git diff --check
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 23:39:31 +08:00
parent 67a64c7f80
commit 1ff563d4b0
5 changed files with 145 additions and 17 deletions

View File

@ -0,0 +1,2 @@
export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official'
export const OPENAI_OFFICIAL_DEFAULT_MODEL_ID = 'gpt-5.3-codex'

View File

@ -7,6 +7,8 @@ const {
runtimeStoreState,
setSessionRuntimeMock,
setSelectionMock,
settingsSetModelMock,
settingsFetchAllMock,
} = vi.hoisted(() => ({
providersApiMock: {
list: vi.fn(),
@ -32,6 +34,8 @@ const {
},
setSessionRuntimeMock: vi.fn(),
setSelectionMock: vi.fn(),
settingsSetModelMock: vi.fn(),
settingsFetchAllMock: vi.fn(),
}))
vi.mock('../api/providers', () => ({
@ -59,8 +63,8 @@ vi.mock('./sessionRuntimeStore', () => ({
vi.mock('./settingsStore', () => ({
useSettingsStore: {
getState: () => ({
setModel: vi.fn(),
fetchAll: vi.fn(),
setModel: settingsSetModelMock,
fetchAll: settingsFetchAllMock,
}),
},
}))
@ -147,4 +151,33 @@ describe('providerStore runtime refresh', () => {
expect(setSelectionMock).not.toHaveBeenCalled()
expect(setSessionRuntimeMock).not.toHaveBeenCalled()
})
it('sets the OpenAI default model when activating built-in ChatGPT Official', async () => {
providersApiMock.activate.mockResolvedValue({ ok: true })
providersApiMock.list.mockResolvedValue({
providers: [],
activeId: 'openai-official',
})
const { useProviderStore } = await import('./providerStore')
await useProviderStore.getState().activateProvider('openai-official')
expect(settingsSetModelMock).toHaveBeenCalledWith('gpt-5.3-codex')
expect(settingsFetchAllMock).toHaveBeenCalled()
})
it('sets the provider main model when activating a saved provider', async () => {
const provider = makeProvider()
providersApiMock.activate.mockResolvedValue({ ok: true })
providersApiMock.list.mockResolvedValue({
providers: [provider],
activeId: provider.id,
})
const { useProviderStore } = await import('./providerStore')
await useProviderStore.getState().activateProvider(provider.id)
expect(settingsSetModelMock).toHaveBeenCalledWith('model-main')
expect(settingsFetchAllMock).toHaveBeenCalled()
})
})

View File

@ -6,6 +6,10 @@ import { useChatStore } from './chatStore'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
import { useSettingsStore } from './settingsStore'
import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog'
import {
OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
OPENAI_OFFICIAL_PROVIDER_ID,
} from '../constants/openaiOfficialProvider'
import type {
SavedProvider,
CreateProviderInput,
@ -145,12 +149,17 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
await get().fetchProviders()
// 更新默认 provider 时,同步刷新默认 model避免 settings.json 里残留
// 旧 provider 的 model id 导致默认选择指向不存在的模型。
const provider = get().providers.find((p) => p.id === id)
if (provider) {
const settings = useSettingsStore.getState()
await settings.setModel(provider.models.main)
const settings = useSettingsStore.getState()
if (id === OPENAI_OFFICIAL_PROVIDER_ID) {
await settings.setModel(OPENAI_OFFICIAL_DEFAULT_MODEL_ID)
await settings.fetchAll()
return
}
const provider = get().providers.find((p) => p.id === id)
if (!provider) return
await settings.setModel(provider.models.main)
await settings.fetchAll()
},
activateOfficial: async () => {

View File

@ -625,6 +625,65 @@ describe('Models API', () => {
expect(globalSettings.model).toBeUndefined()
})
it('GET /api/models should return the OpenAI model catalog when ChatGPT Official is active', async () => {
const providerSvc = new ProviderService()
await providerSvc.activateProvider('openai-official')
const { req, url, segments } = makeRequest('GET', '/api/models')
const res = await handleModelsApi(req, url, segments)
expect(res.status).toBe(200)
const body = await res.json() as {
models: Array<{ id: string; name: string }>
provider: { id: string; name: string } | null
}
expect(body.provider).toEqual({
id: 'openai-official',
name: 'ChatGPT Official',
})
expect(body.models.map((model) => model.id)).toEqual([
'gpt-5.3-codex',
'gpt-5.4',
'gpt-5.5',
'gpt-5.4-mini',
])
})
it('PUT /api/models/current should persist GPT model to managed settings when ChatGPT Official is active', async () => {
const settingsSvc = new SettingsService()
const providerSvc = new ProviderService()
await settingsSvc.updateUserSettings({ model: 'claude-haiku-4-5' })
await providerSvc.activateProvider('openai-official')
const { req, url, segments } = makeRequest('PUT', '/api/models/current', {
modelId: 'gpt-5.5',
})
const res = await handleModelsApi(req, url, segments)
expect(res.status).toBe(200)
const managedSettings = await providerSvc.getManagedSettings()
expect(managedSettings.model).toBe('gpt-5.5')
const globalSettings = await settingsSvc.getUserSettings()
expect(globalSettings.model).toBe('claude-haiku-4-5')
})
it('GET /api/models/current should read current GPT model from managed settings when ChatGPT Official is active', async () => {
const providerSvc = new ProviderService()
await providerSvc.activateProvider('openai-official')
await providerSvc.updateManagedSettings({ model: 'gpt-5.5' })
const { req, url, segments } = makeRequest('GET', '/api/models/current')
const res = await handleModelsApi(req, url, segments)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.model).toMatchObject({
id: 'gpt-5.5',
name: 'GPT-5.5',
})
})
it('GET /api/effort should return default effort level', async () => {
const { req, url, segments } = makeRequest('GET', '/api/effort')
const res = await handleModelsApi(req, url, segments)

View File

@ -13,6 +13,11 @@ import { ProviderService } from '../services/providerService.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 {
OPENAI_OFFICIAL_PROVIDER_ID,
OPENAI_OFFICIAL_PROVIDER_NAME,
isOpenAIOfficialProviderId,
} from '../services/openaiOfficialProvider.js'
// ─── Fallback models (used when no provider is configured) ────────────────────
@ -109,6 +114,15 @@ function buildProviderModelList(models: {
return modelList
}
function buildOpenAIModelList(): ApiModelInfo[] {
return OPENAI_CODEX_MODEL_CATALOG.map(model => ({
id: model.value,
name: model.label,
description: model.description,
context: '',
}))
}
function getEnvConfiguredAnthropicModels(): ApiModelInfo[] {
return buildProviderModelList({
main: process.env.ANTHROPIC_MODEL?.trim() || '',
@ -123,12 +137,7 @@ function getOpenAIAuthModels(): ApiModelInfo[] {
return []
}
return OPENAI_CODEX_MODEL_CATALOG.map(model => ({
id: model.value,
name: model.label,
description: model.description,
context: '',
}))
return buildOpenAIModelList()
}
function getStandaloneModelList(): ApiModelInfo[] {
@ -189,6 +198,16 @@ export async function handleModelsApi(
async function handleModelsList(): Promise<Response> {
const { providers, activeId } = await providerService.listProviders()
if (isOpenAIOfficialProviderId(activeId)) {
return Response.json({
models: buildOpenAIModelList(),
provider: {
id: OPENAI_OFFICIAL_PROVIDER_ID,
name: OPENAI_OFFICIAL_PROVIDER_NAME,
},
})
}
const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null
if (activeProvider) {
const modelList = buildProviderModelList(activeProvider.models)
@ -204,8 +223,9 @@ async function handleCurrentModel(req: Request): Promise<Response> {
if (req.method === 'GET') {
// Build the full model list: prefer active provider's models, fall back to defaults
const { providers, activeId } = await providerService.listProviders()
const isOpenAIProviderActive = isOpenAIOfficialProviderId(activeId)
const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null
const settings = activeProvider
const settings = activeProvider || isOpenAIProviderActive
? await providerService.getManagedSettings()
: await settingsService.getUserSettings()
const explicitModel = (settings.model as string) || ''
@ -216,7 +236,10 @@ async function handleCurrentModel(req: Request): Promise<Response> {
let currentModelId: string
let currentModelName: string
if (activeProvider) {
if (isOpenAIProviderActive) {
currentModelId = explicitModel || env.ANTHROPIC_MODEL || 'gpt-5.3-codex'
currentModelName = currentModelId
} else if (activeProvider) {
// Provider is active — only use the provider-managed cc-haha settings.
// This avoids leaking global ~/.claude/settings.json model choices into
// the active provider flow.
@ -237,9 +260,11 @@ async function handleCurrentModel(req: Request): Promise<Response> {
const lookupId = contextTier ? `${currentModelId}:${contextTier}` : currentModelId
// Build available models for name lookup
const availableModels = activeProvider
? buildProviderModelList(activeProvider.models)
: getStandaloneModelList()
const availableModels = isOpenAIProviderActive
? buildOpenAIModelList()
: activeProvider
? buildProviderModelList(activeProvider.models)
: getStandaloneModelList()
const modelEntry = availableModels.find((m) => m.id === lookupId)
|| availableModels.find((m) => m.id === currentModelId)