Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/types/src/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from "zod"

import { globalSettingsSchema } from "./global-settings.js"
import { mcpMarketplaceItemSchema } from "./marketplace.js"
import { discriminatedProviderSettingsWithIdSchema } from "./provider-settings.js"

/**
* CloudUserInfo
Expand Down Expand Up @@ -114,6 +115,7 @@ export const organizationSettingsSchema = z.object({
hiddenMcps: z.array(z.string()).optional(),
hideMarketplaceMcps: z.boolean().optional(),
mcps: z.array(mcpMarketplaceItemSchema).optional(),
providerProfiles: z.record(z.string(), discriminatedProviderSettingsWithIdSchema).optional(),
})

export type OrganizationSettings = z.infer<typeof organizationSettingsSchema>
Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,13 @@ export const providerSettingsSchema = z.object({
})

export type ProviderSettings = z.infer<typeof providerSettingsSchema>

export const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() })
export const discriminatedProviderSettingsWithIdSchema = providerSettingsSchemaDiscriminated.and(
z.object({ id: z.string().optional() }),
)
export type ProviderSettingsWithId = z.infer<typeof providerSettingsWithIdSchema>

export const PROVIDER_SETTINGS_KEYS = providerSettingsSchema.keyof().options

export const MODEL_ID_KEYS: Partial<keyof ProviderSettings>[] = [
Expand Down
228 changes: 218 additions & 10 deletions src/core/config/ProviderSettingsManager.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
import { ExtensionContext } from "vscode"
import { z, ZodError } from "zod"
import deepEqual from "fast-deep-equal"

import {
type ProviderSettingsEntry,
providerSettingsSchema,
providerSettingsSchemaDiscriminated,
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"

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 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(),
Expand Down Expand Up @@ -304,7 +307,7 @@ export class ProviderSettingsManager {
const id = config.id || existingId || this.generateId()

// Filter out settings from other providers.
const filteredConfig = providerSettingsSchemaDiscriminated.parse(config)
const filteredConfig = discriminatedProviderSettingsWithIdSchema.parse(config)
providerProfiles.apiConfigs[name] = { ...filteredConfig, id }
await this.store(providerProfiles)
return id
Expand Down Expand Up @@ -529,4 +532,209 @@ export class ProviderSettingsManager {
throw new Error(`Failed to write provider profiles to secrets: ${error}`)
}
}

private findUniqueProfileName(baseName: string, existingNames: Set<string>): 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<string, ProviderSettingsWithId>,
currentActiveProfileName?: string,
): Promise<SyncCloudProfilesResult> {
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}`)
}
}
}
Loading