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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 22:35:30 +08:00
parent 8a5b3467c5
commit b3cc32e43d
6 changed files with 138 additions and 5 deletions

View File

@ -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

View File

@ -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()

View File

@ -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,
}

View File

@ -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

View File

@ -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<SavedProvider> {
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<void> {
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,

View File

@ -23,6 +23,12 @@ export const ProviderAuthStrategySchema = z.enum([
])
export type ProviderAuthStrategy = z.infer<typeof ProviderAuthStrategySchema>
export const ProviderRuntimeKindSchema = z.enum([
'anthropic_compatible',
'openai_oauth',
])
export type ProviderRuntimeKind = z.infer<typeof ProviderRuntimeKindSchema>
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(),