import { ExtensionContext } from "vscode" import { z, ZodError } from "zod" import deepEqual from "fast-deep-equal" import { type ProviderSettingsWithId, providerSettingsWithIdSchema, discriminatedProviderSettingsWithIdSchema, isSecretStateKey, ProviderSettingsEntry, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Mode, modes } from "../../shared/modes" export interface SyncCloudProfilesResult { hasChanges: boolean activeProfileChanged: boolean activeProfileId: string } export const providerProfilesSchema = z.object({ currentApiConfigName: z.string(), apiConfigs: z.record(z.string(), providerSettingsWithIdSchema), modeApiConfigs: z.record(z.string(), z.string()).optional(), cloudProfileIds: z.array(z.string()).optional(), migrations: z .object({ rateLimitSecondsMigrated: z.boolean().optional(), diffSettingsMigrated: z.boolean().optional(), openAiHeadersMigrated: z.boolean().optional(), consecutiveMistakeLimitMigrated: z.boolean().optional(), todoListEnabledMigrated: z.boolean().optional(), }) .optional(), }) export type ProviderProfiles = z.infer export class ProviderSettingsManager { private static readonly SCOPE_PREFIX = "roo_cline_config_" private readonly defaultConfigId = this.generateId() private readonly defaultModeApiConfigs: Record = Object.fromEntries( modes.map((mode) => [mode.slug, this.defaultConfigId]), ) private readonly defaultProviderProfiles: ProviderProfiles = { currentApiConfigName: "default", apiConfigs: { default: { id: this.defaultConfigId } }, modeApiConfigs: this.defaultModeApiConfigs, migrations: { rateLimitSecondsMigrated: true, // Mark as migrated on fresh installs diffSettingsMigrated: true, // Mark as migrated on fresh installs openAiHeadersMigrated: true, // Mark as migrated on fresh installs consecutiveMistakeLimitMigrated: true, // Mark as migrated on fresh installs todoListEnabledMigrated: true, // Mark as migrated on fresh installs }, } private readonly context: ExtensionContext constructor(context: ExtensionContext) { this.context = context // TODO: We really shouldn't have async methods in the constructor. this.initialize().catch(console.error) } public generateId() { return Math.random().toString(36).substring(2, 15) } // Synchronize readConfig/writeConfig operations to avoid data loss. private _lock = Promise.resolve() private lock(cb: () => Promise) { const next = this._lock.then(cb) this._lock = next.catch(() => {}) as Promise return next } /** * Initialize config if it doesn't exist and run migrations. */ public async initialize() { try { return await this.lock(async () => { const providerProfiles = await this.load() if (!providerProfiles) { await this.store(this.defaultProviderProfiles) return } let isDirty = false // Migrate existing installs to have per-mode API config map if (!providerProfiles.modeApiConfigs) { // Use the currently selected config for all modes initially const currentName = providerProfiles.currentApiConfigName const seedId = providerProfiles.apiConfigs[currentName]?.id ?? Object.values(providerProfiles.apiConfigs)[0]?.id ?? this.defaultConfigId providerProfiles.modeApiConfigs = Object.fromEntries(modes.map((m) => [m.slug, seedId])) isDirty = true } // Ensure all configs have IDs. for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) { if (!apiConfig.id) { apiConfig.id = this.generateId() isDirty = true } } // Ensure migrations field exists if (!providerProfiles.migrations) { providerProfiles.migrations = { rateLimitSecondsMigrated: false, diffSettingsMigrated: false, openAiHeadersMigrated: false, consecutiveMistakeLimitMigrated: false, todoListEnabledMigrated: false, } // Initialize with default values isDirty = true } if (!providerProfiles.migrations.rateLimitSecondsMigrated) { await this.migrateRateLimitSeconds(providerProfiles) providerProfiles.migrations.rateLimitSecondsMigrated = true isDirty = true } if (!providerProfiles.migrations.diffSettingsMigrated) { await this.migrateDiffSettings(providerProfiles) providerProfiles.migrations.diffSettingsMigrated = true isDirty = true } if (!providerProfiles.migrations.openAiHeadersMigrated) { await this.migrateOpenAiHeaders(providerProfiles) providerProfiles.migrations.openAiHeadersMigrated = true isDirty = true } if (!providerProfiles.migrations.consecutiveMistakeLimitMigrated) { await this.migrateConsecutiveMistakeLimit(providerProfiles) providerProfiles.migrations.consecutiveMistakeLimitMigrated = true isDirty = true } if (!providerProfiles.migrations.todoListEnabledMigrated) { await this.migrateTodoListEnabled(providerProfiles) providerProfiles.migrations.todoListEnabledMigrated = true isDirty = true } if (isDirty) { await this.store(providerProfiles) } }) } catch (error) { throw new Error(`Failed to initialize config: ${error}`) } } private async migrateRateLimitSeconds(providerProfiles: ProviderProfiles) { try { let rateLimitSeconds: number | undefined try { rateLimitSeconds = await this.context.globalState.get("rateLimitSeconds") } catch (error) { console.error("[MigrateRateLimitSeconds] Error getting global rate limit:", error) } if (rateLimitSeconds === undefined) { // Failed to get the existing value, use the default. rateLimitSeconds = 0 } for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) { if (apiConfig.rateLimitSeconds === undefined) { apiConfig.rateLimitSeconds = rateLimitSeconds } } } catch (error) { console.error(`[MigrateRateLimitSeconds] Failed to migrate rate limit settings:`, error) } } private async migrateDiffSettings(providerProfiles: ProviderProfiles) { try { let diffEnabled: boolean | undefined let fuzzyMatchThreshold: number | undefined try { diffEnabled = await this.context.globalState.get("diffEnabled") fuzzyMatchThreshold = await this.context.globalState.get("fuzzyMatchThreshold") } catch (error) { console.error("[MigrateDiffSettings] Error getting global diff settings:", error) } if (diffEnabled === undefined) { // Failed to get the existing value, use the default. diffEnabled = true } if (fuzzyMatchThreshold === undefined) { // Failed to get the existing value, use the default. fuzzyMatchThreshold = 1.0 } for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) { if (apiConfig.diffEnabled === undefined) { apiConfig.diffEnabled = diffEnabled } if (apiConfig.fuzzyMatchThreshold === undefined) { apiConfig.fuzzyMatchThreshold = fuzzyMatchThreshold } } } catch (error) { console.error(`[MigrateDiffSettings] Failed to migrate diff settings:`, error) } } private async migrateOpenAiHeaders(providerProfiles: ProviderProfiles) { try { for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) { // Use type assertion to access the deprecated property safely const configAny = apiConfig as any // Check if openAiHostHeader exists but openAiHeaders doesn't if ( configAny.openAiHostHeader && (!apiConfig.openAiHeaders || Object.keys(apiConfig.openAiHeaders || {}).length === 0) ) { // Create the headers object with the Host value apiConfig.openAiHeaders = { Host: configAny.openAiHostHeader } // Delete the old property to prevent re-migration // This prevents the header from reappearing after deletion configAny.openAiHostHeader = undefined } } } catch (error) { console.error(`[MigrateOpenAiHeaders] Failed to migrate OpenAI headers:`, error) } } private async migrateConsecutiveMistakeLimit(providerProfiles: ProviderProfiles) { try { for (const [name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) { if (apiConfig.consecutiveMistakeLimit == null) { apiConfig.consecutiveMistakeLimit = DEFAULT_CONSECUTIVE_MISTAKE_LIMIT } } } catch (error) { console.error(`[MigrateConsecutiveMistakeLimit] Failed to migrate consecutive mistake limit:`, error) } } private async migrateTodoListEnabled(providerProfiles: ProviderProfiles) { try { for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) { if (apiConfig.todoListEnabled === undefined) { apiConfig.todoListEnabled = true } } } catch (error) { console.error(`[MigrateTodoListEnabled] Failed to migrate todo list enabled setting:`, error) } } /** * List all available configs with metadata. */ public async listConfig(): Promise { try { return await this.lock(async () => { const providerProfiles = await this.load() return Object.entries(providerProfiles.apiConfigs).map(([name, apiConfig]) => ({ name, id: apiConfig.id || "", apiProvider: apiConfig.apiProvider, })) }) } catch (error) { throw new Error(`Failed to list configs: ${error}`) } } /** * Save a config with the given name. * Preserves the ID from the input 'config' object if it exists, * otherwise generates a new one (for creation scenarios). */ public async saveConfig(name: string, config: ProviderSettingsWithId): Promise { try { return await this.lock(async () => { const providerProfiles = await this.load() // Preserve the existing ID if this is an update to an existing config. const existingId = providerProfiles.apiConfigs[name]?.id const id = config.id || existingId || this.generateId() // Filter out settings from other providers. const filteredConfig = discriminatedProviderSettingsWithIdSchema.parse(config) providerProfiles.apiConfigs[name] = { ...filteredConfig, id } await this.store(providerProfiles) return id }) } catch (error) { throw new Error(`Failed to save config: ${error}`) } } public async getProfile( params: { name: string } | { id: string }, ): Promise { try { return await this.lock(async () => { const providerProfiles = await this.load() let name: string let providerSettings: ProviderSettingsWithId if ("name" in params) { name = params.name if (!providerProfiles.apiConfigs[name]) { throw new Error(`Config with name '${name}' not found`) } providerSettings = providerProfiles.apiConfigs[name] } else { const id = params.id const entry = Object.entries(providerProfiles.apiConfigs).find( ([_, apiConfig]) => apiConfig.id === id, ) if (!entry) { throw new Error(`Config with ID '${id}' not found`) } name = entry[0] providerSettings = entry[1] } return { name, ...providerSettings } }) } catch (error) { throw new Error(`Failed to get profile: ${error instanceof Error ? error.message : error}`) } } /** * Activate a profile by name or ID. */ public async activateProfile( params: { name: string } | { id: string }, ): Promise { const { name, ...providerSettings } = await this.getProfile(params) try { return await this.lock(async () => { const providerProfiles = await this.load() providerProfiles.currentApiConfigName = name await this.store(providerProfiles) return { name, ...providerSettings } }) } catch (error) { throw new Error(`Failed to activate profile: ${error instanceof Error ? error.message : error}`) } } /** * Delete a config by name. */ public async deleteConfig(name: string) { try { return await this.lock(async () => { const providerProfiles = await this.load() if (!providerProfiles.apiConfigs[name]) { throw new Error(`Config '${name}' not found`) } if (Object.keys(providerProfiles.apiConfigs).length === 1) { throw new Error(`Cannot delete the last remaining configuration`) } delete providerProfiles.apiConfigs[name] await this.store(providerProfiles) }) } catch (error) { throw new Error(`Failed to delete config: ${error}`) } } /** * Check if a config exists by name. */ public async hasConfig(name: string) { try { return await this.lock(async () => { const providerProfiles = await this.load() return name in providerProfiles.apiConfigs }) } catch (error) { throw new Error(`Failed to check config existence: ${error}`) } } /** * Set the API config for a specific mode. */ public async setModeConfig(mode: Mode, configId: string) { try { return await this.lock(async () => { const providerProfiles = await this.load() // Ensure the per-mode config map exists if (!providerProfiles.modeApiConfigs) { providerProfiles.modeApiConfigs = {} } // Assign the chosen config ID to this mode providerProfiles.modeApiConfigs[mode] = configId await this.store(providerProfiles) }) } catch (error) { throw new Error(`Failed to set mode config: ${error}`) } } /** * Get the API config ID for a specific mode. */ public async getModeConfigId(mode: Mode) { try { return await this.lock(async () => { const { modeApiConfigs } = await this.load() return modeApiConfigs?.[mode] }) } catch (error) { throw new Error(`Failed to get mode config: ${error}`) } } public async export() { try { return await this.lock(async () => { const profiles = providerProfilesSchema.parse(await this.load()) const configs = profiles.apiConfigs for (const name in configs) { // Avoid leaking properties from other providers. configs[name] = discriminatedProviderSettingsWithIdSchema.parse(configs[name]) } return profiles }) } catch (error) { throw new Error(`Failed to export provider profiles: ${error}`) } } public async import(providerProfiles: ProviderProfiles) { try { return await this.lock(() => this.store(providerProfiles)) } catch (error) { throw new Error(`Failed to import provider profiles: ${error}`) } } /** * Reset provider profiles by deleting them from secrets. */ public async resetAllConfigs() { return await this.lock(async () => { await this.context.secrets.delete(this.secretsKey) }) } private get secretsKey() { return `${ProviderSettingsManager.SCOPE_PREFIX}api_config` } private async load(): Promise { try { const content = await this.context.secrets.get(this.secretsKey) if (!content) { return this.defaultProviderProfiles } const providerProfiles = providerProfilesSchema .extend({ apiConfigs: z.record(z.string(), z.any()), }) .parse(JSON.parse(content)) const apiConfigs = Object.entries(providerProfiles.apiConfigs).reduce( (acc, [key, apiConfig]) => { const result = providerSettingsWithIdSchema.safeParse(apiConfig) return result.success ? { ...acc, [key]: result.data } : acc }, {} as Record, ) return { ...providerProfiles, apiConfigs: Object.fromEntries( Object.entries(apiConfigs).filter(([_, apiConfig]) => apiConfig !== null), ), } } catch (error) { if (error instanceof ZodError) { TelemetryService.instance.captureSchemaValidationError({ schemaName: "ProviderProfiles", error, }) } throw new Error(`Failed to read provider profiles from secrets: ${error}`) } } private async store(providerProfiles: ProviderProfiles) { try { await this.context.secrets.store(this.secretsKey, JSON.stringify(providerProfiles, null, 2)) } catch (error) { throw new Error(`Failed to write provider profiles to secrets: ${error}`) } } private findUniqueProfileName(baseName: string, existingNames: Set): string { if (!existingNames.has(baseName)) { return baseName } // Try _local first const localName = `${baseName}_local` if (!existingNames.has(localName)) { return localName } // Try _1, _2, etc. let counter = 1 let candidateName: string do { candidateName = `${baseName}_${counter}` counter++ } while (existingNames.has(candidateName)) return candidateName } public async syncCloudProfiles( cloudProfiles: Record, currentActiveProfileName?: string, ): Promise { try { return await this.lock(async () => { const providerProfiles = await this.load() const changedProfiles: string[] = [] const existingNames = new Set(Object.keys(providerProfiles.apiConfigs)) let activeProfileChanged = false let activeProfileId = "" if (currentActiveProfileName && providerProfiles.apiConfigs[currentActiveProfileName]) { activeProfileId = providerProfiles.apiConfigs[currentActiveProfileName].id || "" } const currentCloudIds = new Set(providerProfiles.cloudProfileIds || []) const newCloudIds = new Set( Object.values(cloudProfiles) .map((p) => p.id) .filter((id): id is string => Boolean(id)), ) // Step 1: Delete profiles that are cloud-managed but not in the new cloud profiles for (const [name, profile] of Object.entries(providerProfiles.apiConfigs)) { if (profile.id && currentCloudIds.has(profile.id) && !newCloudIds.has(profile.id)) { // Check if we're deleting the active profile if (name === currentActiveProfileName) { activeProfileChanged = true activeProfileId = "" // Clear the active profile ID since it's being deleted } delete providerProfiles.apiConfigs[name] changedProfiles.push(name) existingNames.delete(name) } } // Step 2: Process each cloud profile for (const [cloudName, cloudProfile] of Object.entries(cloudProfiles)) { if (!cloudProfile.id) { continue // Skip profiles without IDs } // Find existing profile with matching ID const existingEntry = Object.entries(providerProfiles.apiConfigs).find( ([_, profile]) => profile.id === cloudProfile.id, ) if (existingEntry) { // Step 3: Update existing profile const [existingName, existingProfile] = existingEntry // Check if this is the active profile const isActiveProfile = existingName === currentActiveProfileName // Merge settings, preserving secret keys const updatedProfile: ProviderSettingsWithId = { ...cloudProfile } for (const [key, value] of Object.entries(existingProfile)) { if (isSecretStateKey(key) && value !== undefined) { ;(updatedProfile as any)[key] = value } } // Check if the profile actually changed using deepEqual const profileChanged = !deepEqual(existingProfile, updatedProfile) // Handle name change if (existingName !== cloudName) { // Remove old entry delete providerProfiles.apiConfigs[existingName] existingNames.delete(existingName) // Handle name conflict let finalName = cloudName if (existingNames.has(cloudName)) { // There's a conflict - rename the existing non-cloud profile const conflictingProfile = providerProfiles.apiConfigs[cloudName] if (conflictingProfile.id !== cloudProfile.id) { const newName = this.findUniqueProfileName(cloudName, existingNames) providerProfiles.apiConfigs[newName] = conflictingProfile existingNames.add(newName) changedProfiles.push(newName) } delete providerProfiles.apiConfigs[cloudName] existingNames.delete(cloudName) } // Add updated profile with new name providerProfiles.apiConfigs[finalName] = updatedProfile existingNames.add(finalName) changedProfiles.push(finalName) if (existingName !== finalName) { changedProfiles.push(existingName) // Mark old name as changed (deleted) } // If this was the active profile, mark it as changed if (isActiveProfile) { activeProfileChanged = true activeProfileId = cloudProfile.id || "" } } else if (profileChanged) { // Same name, but profile content changed - update in place providerProfiles.apiConfigs[existingName] = updatedProfile changedProfiles.push(existingName) // If this was the active profile and settings changed, mark it as changed if (isActiveProfile) { activeProfileChanged = true activeProfileId = cloudProfile.id || "" } } // If name is the same and profile hasn't changed, do nothing } else { // Step 4: Add new cloud profile let finalName = cloudName // Handle name conflict with existing non-cloud profile if (existingNames.has(cloudName)) { const existingProfile = providerProfiles.apiConfigs[cloudName] if (existingProfile.id !== cloudProfile.id) { // Rename the existing profile const newName = this.findUniqueProfileName(cloudName, existingNames) providerProfiles.apiConfigs[newName] = existingProfile existingNames.add(newName) changedProfiles.push(newName) // Remove the old entry delete providerProfiles.apiConfigs[cloudName] existingNames.delete(cloudName) } } // Add the new cloud profile (without secret keys) const newProfile: ProviderSettingsWithId = { ...cloudProfile } // Remove any secret keys from cloud profile for (const key of Object.keys(newProfile)) { if (isSecretStateKey(key)) { delete (newProfile as any)[key] } } providerProfiles.apiConfigs[finalName] = newProfile existingNames.add(finalName) changedProfiles.push(finalName) } } // Step 5: Handle case where all profiles might be deleted if (Object.keys(providerProfiles.apiConfigs).length === 0 && changedProfiles.length > 0) { // Create a default profile only if we have changed profiles const defaultProfile = { id: this.generateId() } providerProfiles.apiConfigs["default"] = defaultProfile activeProfileChanged = true activeProfileId = defaultProfile.id || "" changedProfiles.push("default") } // Step 6: If active profile was deleted, find a replacement if (activeProfileChanged && !activeProfileId) { const firstProfile = Object.values(providerProfiles.apiConfigs)[0] if (firstProfile?.id) { activeProfileId = firstProfile.id } } // Step 7: Update cloudProfileIds providerProfiles.cloudProfileIds = Array.from(newCloudIds) // Save the updated profiles await this.store(providerProfiles) return { hasChanges: changedProfiles.length > 0, activeProfileChanged, activeProfileId, } }) } catch (error) { throw new Error(`Failed to sync cloud profiles: ${error}`) } } }