mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
8a5b3467c5
commit
b3cc32e43d
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
47
src/server/services/openaiOfficialProvider.ts
Normal file
47
src/server/services/openaiOfficialProvider.ts
Normal 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,
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user