mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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
368 lines
11 KiB
TypeScript
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
|
|
}
|