cc-haha/src/server/services/persistentStorageMigrations.ts
程序员阿江(Relakkes) b3cc32e43d 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
2026-05-18 22:35:30 +08:00

368 lines
11 KiB
TypeScript

import * as fs from 'fs/promises'
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
type MigrationReport = {
migratedEntries: string[]
failures: string[]
}
type JsonObject = Record<string, unknown>
type LegacyProviderModel = {
id: string
name?: string
}
type LegacyRootProvider = {
id: string
name: string
baseUrl: string
apiKey: string
models: LegacyProviderModel[]
isActive?: boolean
notes?: string
}
let migrationPromise: Promise<MigrationReport> | null = null
let migrationConfigDir: string | null = null
function getConfigDir(): string {
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
}
function isRecord(value: unknown): value is JsonObject {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function isProviderModels(value: unknown): value is JsonObject {
return (
isRecord(value) &&
typeof value.main === 'string' &&
typeof value.haiku === 'string' &&
typeof value.sonnet === 'string' &&
typeof value.opus === 'string'
)
}
function isSavedProvider(value: unknown): value is JsonObject {
return (
isRecord(value) &&
typeof value.id === 'string' &&
typeof value.presetId === 'string' &&
typeof value.name === 'string' &&
typeof value.apiKey === 'string' &&
typeof value.baseUrl === 'string' &&
isProviderModels(value.models)
)
}
function isLegacyProviderModel(value: unknown): value is LegacyProviderModel {
return isRecord(value) && typeof value.id === 'string'
}
function isLegacyRootProvider(value: unknown): value is LegacyRootProvider {
return (
isRecord(value) &&
typeof value.id === 'string' &&
typeof value.name === 'string' &&
typeof value.baseUrl === 'string' &&
typeof value.apiKey === 'string' &&
Array.isArray(value.models) &&
value.models.every(isLegacyProviderModel)
)
}
function errnoCode(error: unknown): string | undefined {
return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code
: undefined
}
function stableStringify(value: unknown): string {
return JSON.stringify(value, null, 2) + '\n'
}
async function readJsonFile(filePath: string): Promise<{ missing: boolean; value: unknown; raw: string }> {
try {
const raw = await fs.readFile(filePath, 'utf-8')
return { missing: false, value: JSON.parse(raw), raw }
} catch (error) {
if (errnoCode(error) === 'ENOENT') {
return { missing: true, value: undefined, raw: '' }
}
throw error
}
}
async function backupFile(filePath: string, suffix: string): Promise<void> {
const backupPath = `${filePath}.${suffix}-${Date.now()}-${randomBytes(3).toString('hex')}`
await fs.copyFile(filePath, backupPath)
}
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true })
const tmpPath = `${filePath}.tmp.${Date.now()}-${randomBytes(3).toString('hex')}`
try {
await fs.writeFile(tmpPath, stableStringify(value), 'utf-8')
await fs.rename(tmpPath, filePath)
} catch (error) {
await fs.unlink(tmpPath).catch(() => {})
throw error
}
}
async function quarantineMalformedFile(filePath: string): Promise<void> {
const invalidPath = `${filePath}.invalid-${Date.now()}-${randomBytes(3).toString('hex')}`
await fs.rename(filePath, invalidPath)
}
function migrateProvidersIndex(value: unknown): JsonObject {
if (!isRecord(value) || !Array.isArray(value.providers)) {
return {
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
activeId: null,
providers: [],
}
}
const { activeProviderId: _legacyActiveProviderId, ...rest } = value
const providers = value.providers.filter(isSavedProvider)
const rawActiveId =
typeof value.activeId === 'string'
? value.activeId
: typeof _legacyActiveProviderId === 'string'
? _legacyActiveProviderId
: null
const activeId = rawActiveId && (
providers.some((provider) => provider.id === rawActiveId) ||
isOpenAIOfficialProviderId(rawActiveId)
)
? rawActiveId
: null
return {
...rest,
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
activeId,
providers,
}
}
function migrateManagedSettings(value: unknown): JsonObject {
if (!isRecord(value)) return {}
if (value.env !== undefined && !isRecord(value.env)) {
return { ...value, env: {} }
}
if (isRecord(value.env)) {
const { env, changed } = normalizeLegacyDeepSeekManagedEnv(value.env as Record<string, string>)
if (changed) return { ...value, env }
}
return value
}
async function migrateJsonEntry(
filePath: string,
entryName: string,
report: MigrationReport,
migrate: (value: unknown) => JsonObject,
): Promise<void> {
try {
const current = await readJsonFile(filePath)
if (current.missing) return
const migrated = migrate(current.value)
if (stableStringify(migrated) === stableStringify(current.value)) return
await backupFile(filePath, 'bak-before-migration')
await writeJsonFile(filePath, migrated)
report.migratedEntries.push(entryName)
} catch (error) {
if (error instanceof SyntaxError) {
try {
await quarantineMalformedFile(filePath)
await writeJsonFile(filePath, {})
report.migratedEntries.push(entryName)
return
} catch (recoveryError) {
report.failures.push(`${entryName}: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`)
return
}
}
report.failures.push(`${entryName}: ${error instanceof Error ? error.message : String(error)}`)
}
}
function legacyProviderModelId(
provider: LegacyRootProvider,
preferredModelId: unknown,
): string {
if (
typeof preferredModelId === 'string' &&
provider.models.some((model) => model.id === preferredModelId)
) {
return preferredModelId
}
return provider.models[0]?.id ?? ''
}
function migrateLegacyRootProvidersConfig(value: unknown): JsonObject | null {
if (!isRecord(value) || !Array.isArray(value.providers)) {
return null
}
const providers = value.providers
.filter(isLegacyRootProvider)
.map((provider) => {
const main = legacyProviderModelId(provider, value.activeModel)
return {
id: provider.id,
presetId: 'custom',
name: provider.name,
apiKey: provider.apiKey,
baseUrl: provider.baseUrl,
apiFormat: 'anthropic',
models: {
main,
haiku: main,
sonnet: main,
opus: main,
},
...(provider.notes !== undefined && { notes: provider.notes }),
}
})
if (providers.length === 0) {
return null
}
const activeLegacyProvider = value.providers
.filter(isLegacyRootProvider)
.find((provider) =>
provider.isActive === true ||
(typeof value.activeModel === 'string' &&
provider.models.some((model) => model.id === value.activeModel)),
)
const activeId =
activeLegacyProvider && providers.some((provider) => provider.id === activeLegacyProvider.id)
? activeLegacyProvider.id
: null
return {
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
activeId,
providers,
}
}
function buildManagedSettingsForMigratedProvider(provider: JsonObject | undefined): JsonObject | null {
if (!provider || !isProviderModels(provider.models)) return null
const apiKey = typeof provider.apiKey === 'string' ? provider.apiKey : ''
const baseUrl = typeof provider.baseUrl === 'string' ? provider.baseUrl : ''
if (!apiKey || !baseUrl) return null
return {
env: {
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_AUTH_TOKEN: apiKey,
ANTHROPIC_MODEL: provider.models.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet,
ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.opus,
},
}
}
async function migrateLegacyRootProviders(
configDir: string,
ccHahaDir: string,
report: MigrationReport,
): Promise<void> {
const targetPath = path.join(ccHahaDir, 'providers.json')
try {
await fs.access(targetPath)
return
} catch (error) {
if (errnoCode(error) !== 'ENOENT') {
report.failures.push(`cc-haha/providers.json: ${error instanceof Error ? error.message : String(error)}`)
return
}
}
const legacyPath = path.join(configDir, 'providers.json')
try {
const legacy = await readJsonFile(legacyPath)
if (legacy.missing) return
const migrated = migrateLegacyRootProvidersConfig(legacy.value)
if (!migrated) return
await writeJsonFile(targetPath, migrated)
report.migratedEntries.push('providers.json -> cc-haha/providers.json')
const settingsPath = path.join(ccHahaDir, 'settings.json')
const settings = await readJsonFile(settingsPath).catch(() => ({ missing: false, value: undefined, raw: '' }))
if (!settings.missing) return
const activeId = typeof migrated.activeId === 'string' ? migrated.activeId : null
const activeProvider = Array.isArray(migrated.providers)
? migrated.providers.find((provider) => isRecord(provider) && provider.id === activeId)
: undefined
const managedSettings = buildManagedSettingsForMigratedProvider(
isRecord(activeProvider) ? activeProvider : undefined,
)
if (managedSettings) {
await writeJsonFile(settingsPath, managedSettings)
report.migratedEntries.push('providers.json -> cc-haha/settings.json')
}
} catch (error) {
if (error instanceof SyntaxError) {
report.failures.push(`providers.json: ${error.message}`)
return
}
report.failures.push(`providers.json: ${error instanceof Error ? error.message : String(error)}`)
}
}
async function runPersistentStorageMigrations(configDir: string): Promise<MigrationReport> {
const report: MigrationReport = { migratedEntries: [], failures: [] }
const ccHahaDir = path.join(configDir, 'cc-haha')
await migrateLegacyRootProviders(configDir, ccHahaDir, report)
await migrateJsonEntry(
path.join(ccHahaDir, 'providers.json'),
'cc-haha/providers.json',
report,
migrateProvidersIndex,
)
await migrateJsonEntry(
path.join(ccHahaDir, 'settings.json'),
'cc-haha/settings.json',
report,
migrateManagedSettings,
)
return report
}
export function ensurePersistentStorageUpgraded(): Promise<MigrationReport> {
const configDir = getConfigDir()
if (!migrationPromise || migrationConfigDir !== configDir) {
migrationConfigDir = configDir
migrationPromise = runPersistentStorageMigrations(configDir)
}
return migrationPromise
}
export function resetPersistentStorageMigrationsForTests(): void {
migrationPromise = null
migrationConfigDir = null
}