From 1ff563d4b0b4d9daec4ff856aff14afc4652785b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 23:39:31 +0800 Subject: [PATCH] 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 --- .../src/constants/openaiOfficialProvider.ts | 2 + desktop/src/stores/providerStore.test.ts | 37 +++++++++++- desktop/src/stores/providerStore.ts | 17 ++++-- src/server/__tests__/settings.test.ts | 59 +++++++++++++++++++ src/server/api/models.ts | 47 +++++++++++---- 5 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 desktop/src/constants/openaiOfficialProvider.ts diff --git a/desktop/src/constants/openaiOfficialProvider.ts b/desktop/src/constants/openaiOfficialProvider.ts new file mode 100644 index 00000000..fce7d9e8 --- /dev/null +++ b/desktop/src/constants/openaiOfficialProvider.ts @@ -0,0 +1,2 @@ +export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official' +export const OPENAI_OFFICIAL_DEFAULT_MODEL_ID = 'gpt-5.3-codex' diff --git a/desktop/src/stores/providerStore.test.ts b/desktop/src/stores/providerStore.test.ts index 0d533450..7d16a7c0 100644 --- a/desktop/src/stores/providerStore.test.ts +++ b/desktop/src/stores/providerStore.test.ts @@ -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() + }) }) diff --git a/desktop/src/stores/providerStore.ts b/desktop/src/stores/providerStore.ts index 77c6624c..585ce6f9 100644 --- a/desktop/src/stores/providerStore.ts +++ b/desktop/src/stores/providerStore.ts @@ -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((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 () => { diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index a38a651b..87f89b59 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -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) diff --git a/src/server/api/models.ts b/src/server/api/models.ts index eadb2867..c602958c 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -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 { 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 { 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 { 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 { 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)