Roo-Code/src/core/config/ProviderSettingsManager.ts
Chris Estreich 7820b7517a
Roo Code Cloud (#4069)
Co-authored-by: John Richmond <5629+jr@users.noreply.github.com>
2025-05-28 20:43:30 -07:00

489 lines
14 KiB
TypeScript

import { ExtensionContext } from "vscode"
import { z, ZodError } from "zod"
import {
type ProviderSettingsEntry,
providerSettingsSchema,
providerSettingsSchemaDiscriminated,
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { Mode, modes } from "../../shared/modes"
const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() })
const discriminatedProviderSettingsWithIdSchema = providerSettingsSchemaDiscriminated.and(
z.object({ id: z.string().optional() }),
)
type ProviderSettingsWithId = z.infer<typeof providerSettingsWithIdSchema>
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(),
diffSettingsMigrated: z.boolean().optional(),
openAiHeadersMigrated: z.boolean().optional(),
})
.optional(),
})
export type ProviderProfiles = z.infer<typeof providerProfilesSchema>
export class ProviderSettingsManager {
private static readonly SCOPE_PREFIX = "roo_cline_config_"
private readonly defaultConfigId = this.generateId()
private readonly defaultModeApiConfigs: Record<string, string> = 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
},
}
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<T>(cb: () => Promise<T>) {
const next = this._lock.then(cb)
this._lock = next.catch(() => {}) as Promise<void>
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,
} // 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 (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<number>("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<boolean>("diffEnabled")
fuzzyMatchThreshold = await this.context.globalState.get<number>("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)
}
}
/**
* List all available configs with metadata.
*/
public async listConfig(): Promise<ProviderSettingsEntry[]> {
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<string> {
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 = providerSettingsSchemaDiscriminated.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<ProviderSettingsWithId & { name: string }> {
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<ProviderSettingsWithId & { name: string }> {
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<ProviderProfiles> {
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<string, ProviderSettingsWithId>,
)
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}`)
}
}
}