import { ExtensionContext } from "vscode" import { z, ZodError } from "zod" import { providerSettingsSchema, ApiConfigMeta } from "../../schemas" import { Mode, modes } from "../../shared/modes" import { telemetryService } from "../../services/telemetry/TelemetryService" const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() }) type ProviderSettingsWithId = z.infer export const providerProfilesSchema = z.object({ currentApiConfigName: z.string(), apiConfigs: z.record(z.string(), providerSettingsWithIdSchema), modeApiConfigs: z.record(z.string(), z.string()).optional(), migrations: z .object({ rateLimitSecondsMigrated: 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 }, } 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 // 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 } // Initialize with default values isDirty = true } if (!providerProfiles.migrations.rateLimitSecondsMigrated) { await this.migrateRateLimitSeconds(providerProfiles) providerProfiles.migrations.rateLimitSecondsMigrated = 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) { console.log( `[MigrateRateLimitSeconds] Applying rate limit ${rateLimitSeconds}s to profile: ${name}`, ) apiConfig.rateLimitSeconds = rateLimitSeconds } } console.log(`[MigrateRateLimitSeconds] migration complete`) } catch (error) { console.error(`[MigrateRateLimitSeconds] Failed to migrate rate limit settings:`, 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) { 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 providerProfiles.apiConfigs[name] = { ...config, id: config.id || existingId || this.generateId() } await this.store(providerProfiles) }) } catch (error) { throw new Error(`Failed to save config: ${error}`) } } /** * Load a config by name and set it as the current config. */ public async loadConfig(name: string) { try { return await this.lock(async () => { const providerProfiles = await this.load() const providerSettings = providerProfiles.apiConfigs[name] if (!providerSettings) { throw new Error(`Config '${name}' not found`) } providerProfiles.currentApiConfigName = name await this.store(providerProfiles) return providerSettings }) } catch (error) { throw new Error(`Failed to load config: ${error}`) } } /** * Load a config by ID and set it as the current config. */ public async loadConfigById(id: string) { try { return await this.lock(async () => { const providerProfiles = await this.load() const providerSettings = Object.entries(providerProfiles.apiConfigs).find( ([_, apiConfig]) => apiConfig.id === id, ) if (!providerSettings) { throw new Error(`Config with ID '${id}' not found`) } const [name, apiConfig] = providerSettings providerProfiles.currentApiConfigName = name await this.store(providerProfiles) return { config: apiConfig, name } }) } catch (error) { throw new Error(`Failed to load config by ID: ${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() const { modeApiConfigs = {} } = 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 () => providerProfilesSchema.parse(await this.load())) } 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) return content ? providerProfilesSchema.parse(JSON.parse(content)) : this.defaultProviderProfiles } catch (error) { if (error instanceof ZodError) { telemetryService.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}`) } } }