From b3cc32e43d380076bb367bfdeb32f2dcb4ea501f 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 22:35:30 +0800 Subject: [PATCH] Add a typed ChatGPT provider runtime ChatGPT OAuth needs a stable built-in provider identity and a runtime kind that survives provider index normalization. This keeps normal saved providers unchanged while letting the desktop treat openai-official as an active provider without storing secrets in providers.json. Constraint: providers.json migration must preserve activeId=openai-official even when providers[] is empty Constraint: ChatGPT Official metadata must stay token-free in the provider index Rejected: Treat ChatGPT Official as a normal openai_responses provider row | it would require persisting a fake saved provider and would still lose activeId during migration Confidence: high Scope-risk: narrow Directive: Do not teach later OpenAI OAuth work to depend on providers[] containing the built-in provider Tested: bun test src/server/__tests__/providers.test.ts --test-name-pattern "ChatGPT Official provider metadata" Tested: bun test src/server/__tests__/providers.test.ts Tested: cd desktop && bun run lint Tested: git diff --check --- desktop/src/types/provider.ts | 5 ++ src/server/__tests__/providers.test.ts | 37 +++++++++++++++ src/server/services/openaiOfficialProvider.ts | 47 +++++++++++++++++++ .../services/persistentStorageMigrations.ts | 6 ++- src/server/services/providerService.ts | 39 +++++++++++++-- src/server/types/provider.ts | 9 ++++ 6 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 src/server/services/openaiOfficialProvider.ts diff --git a/desktop/src/types/provider.ts b/desktop/src/types/provider.ts index a5ae0dc2..8057d9ba 100644 --- a/desktop/src/types/provider.ts +++ b/desktop/src/types/provider.ts @@ -9,6 +9,8 @@ export type ProviderAuthStrategy = | 'dual_same_token' | 'dual_dummy' +export type ProviderRuntimeKind = 'anthropic_compatible' | 'openai_oauth' + export type ModelMapping = { main: string haiku: string @@ -26,6 +28,7 @@ export type SavedProvider = { authStrategy?: ProviderAuthStrategy baseUrl: string apiFormat: ApiFormat + runtimeKind?: ProviderRuntimeKind models: ModelMapping autoCompactWindow?: number modelContextWindows?: ModelContextWindows @@ -39,6 +42,7 @@ export type CreateProviderInput = { authStrategy?: ProviderAuthStrategy baseUrl: string apiFormat?: ApiFormat + runtimeKind?: ProviderRuntimeKind models: ModelMapping autoCompactWindow?: number modelContextWindows?: ModelContextWindows @@ -51,6 +55,7 @@ export type UpdateProviderInput = { authStrategy?: ProviderAuthStrategy baseUrl?: string apiFormat?: ApiFormat + runtimeKind?: ProviderRuntimeKind models?: ModelMapping autoCompactWindow?: number | null modelContextWindows?: ModelContextWindows | null diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index b08e561c..23bc26c6 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -278,6 +278,43 @@ describe('ProviderService', () => { expect(fetched.name).toBe(added.name) }) + describe('ChatGPT Official provider metadata', () => { + test('normalizes the built-in ChatGPT provider as an active provider id', async () => { + await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) + await fs.writeFile( + path.join(tmpDir, 'cc-haha', 'providers.json'), + JSON.stringify({ activeId: 'openai-official', providers: [] }), + 'utf-8', + ) + + const svc = new ProviderService() + const result = await svc.listProviders() + + expect(result.activeId).toBe('openai-official') + expect(result.providers).toEqual([]) + }) + + test('returns built-in ChatGPT provider metadata without persisting secrets', async () => { + const svc = new ProviderService() + const provider = await svc.getProvider('openai-official') + + expect(provider).toMatchObject({ + id: 'openai-official', + presetId: 'openai-official', + name: 'ChatGPT Official', + apiKey: '', + 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', + }, + }) + }) + }) + test('should throw 404 for non-existent id', async () => { const svc = new ProviderService() diff --git a/src/server/services/openaiOfficialProvider.ts b/src/server/services/openaiOfficialProvider.ts new file mode 100644 index 00000000..8a8a0192 --- /dev/null +++ b/src/server/services/openaiOfficialProvider.ts @@ -0,0 +1,47 @@ +import { OPENAI_CODEX_API_ENDPOINT } from '../../services/openaiAuth/client.js' +import { + OPENAI_DEFAULT_HAIKU_MODEL, + OPENAI_DEFAULT_MAIN_MODEL, + OPENAI_DEFAULT_SONNET_MODEL, + getOpenAIContextWindowForModel, +} from '../../services/openaiAuth/models.js' +import type { SavedProvider } from '../types/provider.js' + +export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official' +export const OPENAI_OFFICIAL_PROVIDER_NAME = 'ChatGPT Official' +export const OPENAI_OAUTH_PROVIDER_ENV_KEY = 'CC_HAHA_OPENAI_OAUTH_PROVIDER' +export const OPENAI_CODEX_OAUTH_FILE_ENV_KEY = 'OPENAI_CODEX_OAUTH_FILE' + +export function isOpenAIOfficialProviderId( + id: string | null | undefined, +): boolean { + return id === OPENAI_OFFICIAL_PROVIDER_ID +} + +const openAIModels: SavedProvider['models'] = { + main: OPENAI_DEFAULT_MAIN_MODEL, + haiku: OPENAI_DEFAULT_HAIKU_MODEL, + sonnet: OPENAI_DEFAULT_SONNET_MODEL, + opus: OPENAI_DEFAULT_MAIN_MODEL, +} + +const modelContextWindows = Object.fromEntries( + Object.values(openAIModels) + .map((model) => [model, getOpenAIContextWindowForModel(model)] as const) + .filter((entry): entry is readonly [string, number] => entry[1] !== null), +) + +export const OPENAI_OFFICIAL_PROVIDER: SavedProvider = { + id: OPENAI_OFFICIAL_PROVIDER_ID, + presetId: OPENAI_OFFICIAL_PROVIDER_ID, + name: OPENAI_OFFICIAL_PROVIDER_NAME, + apiKey: '', + authStrategy: 'dual_dummy', + baseUrl: new URL('/backend-api/codex', OPENAI_CODEX_API_ENDPOINT) + .toString() + .replace(/\/+$/, ''), + apiFormat: 'openai_responses', + runtimeKind: 'openai_oauth', + models: openAIModels, + modelContextWindows, +} diff --git a/src/server/services/persistentStorageMigrations.ts b/src/server/services/persistentStorageMigrations.ts index f126659f..2a956a6a 100644 --- a/src/server/services/persistentStorageMigrations.ts +++ b/src/server/services/persistentStorageMigrations.ts @@ -3,6 +3,7 @@ import * as os from 'os' import * as path from 'path' import { randomBytes } from 'node:crypto' import { normalizeLegacyDeepSeekManagedEnv } from '../../utils/providerManagedEnvCompat.js' +import { isOpenAIOfficialProviderId } from './openaiOfficialProvider.js' export const CURRENT_PROVIDER_INDEX_SCHEMA_VERSION = 1 @@ -136,7 +137,10 @@ function migrateProvidersIndex(value: unknown): JsonObject { : typeof _legacyActiveProviderId === 'string' ? _legacyActiveProviderId : null - const activeId = rawActiveId && providers.some((provider) => provider.id === rawActiveId) + const activeId = rawActiveId && ( + providers.some((provider) => provider.id === rawActiveId) || + isOpenAIOfficialProviderId(rawActiveId) + ) ? rawActiveId : null diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index 2d4202f0..a9783bfd 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -19,6 +19,10 @@ import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesTo import type { AnthropicRequest, AnthropicResponse } from '../proxy/transform/types.js' import { PROVIDER_PRESETS } from '../config/providerPresets.js' import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js' +import { + OPENAI_OFFICIAL_PROVIDER, + isOpenAIOfficialProviderId, +} from './openaiOfficialProvider.js' import { CURRENT_PROVIDER_INDEX_SCHEMA_VERSION, ensurePersistentStorageUpgraded, @@ -75,30 +79,49 @@ function isProviderModels(value: unknown): value is SavedProvider['models'] { function isSavedProvider(value: unknown): value is SavedProvider { if (!isRecord(value)) return false + const runtimeKind = value.runtimeKind return ( typeof value.id === 'string' && typeof value.presetId === 'string' && typeof value.name === 'string' && typeof value.apiKey === 'string' && typeof value.baseUrl === 'string' && + ( + runtimeKind === undefined || + runtimeKind === 'anthropic_compatible' || + runtimeKind === 'openai_oauth' + ) && isProviderModels(value.models) ) } +function normalizeSavedProvider(provider: SavedProvider): SavedProvider { + return { + ...provider, + apiFormat: provider.apiFormat ?? 'anthropic', + runtimeKind: provider.runtimeKind ?? 'anthropic_compatible', + } +} + function normalizeProvidersIndex(value: unknown): ProvidersIndex | null { if (!isRecord(value) || !Array.isArray(value.providers)) { return null } const { activeProviderId: _legacyActiveProviderId, ...rest } = value - const providers = value.providers.filter(isSavedProvider) + const providers = value.providers + .filter(isSavedProvider) + .map((provider) => normalizeSavedProvider(provider)) const rawActiveId = typeof value.activeId === 'string' ? value.activeId : typeof _legacyActiveProviderId === 'string' ? _legacyActiveProviderId : null - const activeId = rawActiveId && providers.some((provider) => provider.id === rawActiveId) + const activeId = rawActiveId && ( + providers.some((provider) => provider.id === rawActiveId) || + isOpenAIOfficialProviderId(rawActiveId) + ) ? rawActiveId : null @@ -241,6 +264,10 @@ export class ProviderService { } async getProvider(id: string): Promise { + if (isOpenAIOfficialProviderId(id)) { + return OPENAI_OFFICIAL_PROVIDER + } + const index = await this.readIndex() const provider = index.providers.find((p) => p.id === id) if (!provider) throw ApiError.notFound(`Provider not found: ${id}`) @@ -258,6 +285,7 @@ export class ProviderService { ...(input.authStrategy !== undefined && { authStrategy: input.authStrategy }), baseUrl: input.baseUrl, apiFormat: input.apiFormat ?? 'anthropic', + runtimeKind: input.runtimeKind ?? 'anthropic_compatible', models: input.models, ...(input.autoCompactWindow !== undefined && { autoCompactWindow: input.autoCompactWindow }), ...(input.modelContextWindows !== undefined && { modelContextWindows: input.modelContextWindows }), @@ -282,6 +310,7 @@ export class ProviderService { ...(input.authStrategy !== undefined && { authStrategy: input.authStrategy }), ...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }), ...(input.apiFormat !== undefined && { apiFormat: input.apiFormat }), + ...(input.runtimeKind !== undefined && { runtimeKind: input.runtimeKind }), ...(input.models !== undefined && { models: input.models }), ...(typeof input.autoCompactWindow === 'number' && { autoCompactWindow: input.autoCompactWindow }), ...(input.modelContextWindows !== undefined && input.modelContextWindows !== null && { modelContextWindows: input.modelContextWindows }), @@ -321,7 +350,9 @@ export class ProviderService { async activateProvider(id: string): Promise { const index = await this.readIndex() - const provider = index.providers.find((p) => p.id === id) + const provider = isOpenAIOfficialProviderId(id) + ? OPENAI_OFFICIAL_PROVIDER + : index.providers.find((p) => p.id === id) if (!provider) throw ApiError.notFound(`Provider not found: ${id}`) index.activeId = id @@ -507,7 +538,7 @@ export class ProviderService { const index = await this.readIndex() if (!index.activeId) return null - const provider = index.providers.find((p) => p.id === index.activeId) + const provider = await this.getProvider(index.activeId).catch(() => null) if (!provider) return null return { baseUrl: provider.baseUrl, diff --git a/src/server/types/provider.ts b/src/server/types/provider.ts index a3022036..3ddf303f 100644 --- a/src/server/types/provider.ts +++ b/src/server/types/provider.ts @@ -23,6 +23,12 @@ export const ProviderAuthStrategySchema = z.enum([ ]) export type ProviderAuthStrategy = z.infer +export const ProviderRuntimeKindSchema = z.enum([ + 'anthropic_compatible', + 'openai_oauth', +]) +export type ProviderRuntimeKind = z.infer + export const ModelMappingSchema = z.object({ main: z.string(), haiku: z.string(), @@ -44,6 +50,7 @@ export const SavedProviderSchema = z.object({ authStrategy: ProviderAuthStrategySchema.optional(), baseUrl: z.string(), apiFormat: ApiFormatSchema.default('anthropic'), + runtimeKind: ProviderRuntimeKindSchema.default('anthropic_compatible'), models: ModelMappingSchema, autoCompactWindow: AutoCompactWindowSchema.optional(), modelContextWindows: ModelContextWindowsSchema.optional(), @@ -63,6 +70,7 @@ export const CreateProviderSchema = z.object({ authStrategy: ProviderAuthStrategySchema.optional(), baseUrl: z.string(), apiFormat: ApiFormatSchema.default('anthropic'), + runtimeKind: ProviderRuntimeKindSchema.default('anthropic_compatible'), models: ModelMappingSchema, autoCompactWindow: AutoCompactWindowSchema.optional(), modelContextWindows: ModelContextWindowsSchema.optional(), @@ -75,6 +83,7 @@ export const UpdateProviderSchema = z.object({ authStrategy: ProviderAuthStrategySchema.optional(), baseUrl: z.string().optional(), apiFormat: ApiFormatSchema.optional(), + runtimeKind: ProviderRuntimeKindSchema.optional(), models: ModelMappingSchema.optional(), autoCompactWindow: AutoCompactWindowSchema.nullable().optional(), modelContextWindows: ModelContextWindowsSchema.nullable().optional(),