Skip to content

Commit 8353ca2

Browse files
authored
Cloud: support syncing provider profiles from the cloud (#6540)
1 parent f7b2bb0 commit 8353ca2

File tree

5 files changed

+747
-11
lines changed

5 files changed

+747
-11
lines changed

packages/types/src/cloud.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { z } from "zod"
22

33
import { globalSettingsSchema } from "./global-settings.js"
44
import { mcpMarketplaceItemSchema } from "./marketplace.js"
5+
import { discriminatedProviderSettingsWithIdSchema } from "./provider-settings.js"
56

67
/**
78
* CloudUserInfo
@@ -114,6 +115,7 @@ export const organizationSettingsSchema = z.object({
114115
hiddenMcps: z.array(z.string()).optional(),
115116
hideMarketplaceMcps: z.boolean().optional(),
116117
mcps: z.array(mcpMarketplaceItemSchema).optional(),
118+
providerProfiles: z.record(z.string(), discriminatedProviderSettingsWithIdSchema).optional(),
117119
})
118120

119121
export type OrganizationSettings = z.infer<typeof organizationSettingsSchema>

packages/types/src/provider-settings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,13 @@ export const providerSettingsSchema = z.object({
327327
})
328328

329329
export type ProviderSettings = z.infer<typeof providerSettingsSchema>
330+
331+
export const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() })
332+
export const discriminatedProviderSettingsWithIdSchema = providerSettingsSchemaDiscriminated.and(
333+
z.object({ id: z.string().optional() }),
334+
)
335+
export type ProviderSettingsWithId = z.infer<typeof providerSettingsWithIdSchema>
336+
330337
export const PROVIDER_SETTINGS_KEYS = providerSettingsSchema.keyof().options
331338

332339
export const MODEL_ID_KEYS: Partial<keyof ProviderSettings>[] = [

src/core/config/ProviderSettingsManager.ts

Lines changed: 218 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,30 @@
11
import { ExtensionContext } from "vscode"
22
import { z, ZodError } from "zod"
3+
import deepEqual from "fast-deep-equal"
34

45
import {
5-
type ProviderSettingsEntry,
6-
providerSettingsSchema,
7-
providerSettingsSchemaDiscriminated,
6+
type ProviderSettingsWithId,
7+
providerSettingsWithIdSchema,
8+
discriminatedProviderSettingsWithIdSchema,
9+
isSecretStateKey,
10+
ProviderSettingsEntry,
811
DEFAULT_CONSECUTIVE_MISTAKE_LIMIT,
912
} from "@roo-code/types"
1013
import { TelemetryService } from "@roo-code/telemetry"
1114

1215
import { Mode, modes } from "../../shared/modes"
1316

14-
const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() })
15-
const discriminatedProviderSettingsWithIdSchema = providerSettingsSchemaDiscriminated.and(
16-
z.object({ id: z.string().optional() }),
17-
)
18-
19-
type ProviderSettingsWithId = z.infer<typeof providerSettingsWithIdSchema>
17+
export interface SyncCloudProfilesResult {
18+
hasChanges: boolean
19+
activeProfileChanged: boolean
20+
activeProfileId: string
21+
}
2022

2123
export const providerProfilesSchema = z.object({
2224
currentApiConfigName: z.string(),
2325
apiConfigs: z.record(z.string(), providerSettingsWithIdSchema),
2426
modeApiConfigs: z.record(z.string(), z.string()).optional(),
27+
cloudProfileIds: z.array(z.string()).optional(),
2528
migrations: z
2629
.object({
2730
rateLimitSecondsMigrated: z.boolean().optional(),
@@ -304,7 +307,7 @@ export class ProviderSettingsManager {
304307
const id = config.id || existingId || this.generateId()
305308

306309
// Filter out settings from other providers.
307-
const filteredConfig = providerSettingsSchemaDiscriminated.parse(config)
310+
const filteredConfig = discriminatedProviderSettingsWithIdSchema.parse(config)
308311
providerProfiles.apiConfigs[name] = { ...filteredConfig, id }
309312
await this.store(providerProfiles)
310313
return id
@@ -529,4 +532,209 @@ export class ProviderSettingsManager {
529532
throw new Error(`Failed to write provider profiles to secrets: ${error}`)
530533
}
531534
}
535+
536+
private findUniqueProfileName(baseName: string, existingNames: Set<string>): string {
537+
if (!existingNames.has(baseName)) {
538+
return baseName
539+
}
540+
541+
// Try _local first
542+
const localName = `${baseName}_local`
543+
if (!existingNames.has(localName)) {
544+
return localName
545+
}
546+
547+
// Try _1, _2, etc.
548+
let counter = 1
549+
let candidateName: string
550+
do {
551+
candidateName = `${baseName}_${counter}`
552+
counter++
553+
} while (existingNames.has(candidateName))
554+
555+
return candidateName
556+
}
557+
558+
public async syncCloudProfiles(
559+
cloudProfiles: Record<string, ProviderSettingsWithId>,
560+
currentActiveProfileName?: string,
561+
): Promise<SyncCloudProfilesResult> {
562+
try {
563+
return await this.lock(async () => {
564+
const providerProfiles = await this.load()
565+
const changedProfiles: string[] = []
566+
const existingNames = new Set(Object.keys(providerProfiles.apiConfigs))
567+
568+
let activeProfileChanged = false
569+
let activeProfileId = ""
570+
571+
if (currentActiveProfileName && providerProfiles.apiConfigs[currentActiveProfileName]) {
572+
activeProfileId = providerProfiles.apiConfigs[currentActiveProfileName].id || ""
573+
}
574+
575+
const currentCloudIds = new Set(providerProfiles.cloudProfileIds || [])
576+
const newCloudIds = new Set(
577+
Object.values(cloudProfiles)
578+
.map((p) => p.id)
579+
.filter((id): id is string => Boolean(id)),
580+
)
581+
582+
// Step 1: Delete profiles that are cloud-managed but not in the new cloud profiles
583+
for (const [name, profile] of Object.entries(providerProfiles.apiConfigs)) {
584+
if (profile.id && currentCloudIds.has(profile.id) && !newCloudIds.has(profile.id)) {
585+
// Check if we're deleting the active profile
586+
if (name === currentActiveProfileName) {
587+
activeProfileChanged = true
588+
activeProfileId = "" // Clear the active profile ID since it's being deleted
589+
}
590+
delete providerProfiles.apiConfigs[name]
591+
changedProfiles.push(name)
592+
existingNames.delete(name)
593+
}
594+
}
595+
596+
// Step 2: Process each cloud profile
597+
for (const [cloudName, cloudProfile] of Object.entries(cloudProfiles)) {
598+
if (!cloudProfile.id) {
599+
continue // Skip profiles without IDs
600+
}
601+
602+
// Find existing profile with matching ID
603+
const existingEntry = Object.entries(providerProfiles.apiConfigs).find(
604+
([_, profile]) => profile.id === cloudProfile.id,
605+
)
606+
607+
if (existingEntry) {
608+
// Step 3: Update existing profile
609+
const [existingName, existingProfile] = existingEntry
610+
611+
// Check if this is the active profile
612+
const isActiveProfile = existingName === currentActiveProfileName
613+
614+
// Merge settings, preserving secret keys
615+
const updatedProfile: ProviderSettingsWithId = { ...cloudProfile }
616+
for (const [key, value] of Object.entries(existingProfile)) {
617+
if (isSecretStateKey(key) && value !== undefined) {
618+
;(updatedProfile as any)[key] = value
619+
}
620+
}
621+
622+
// Check if the profile actually changed using deepEqual
623+
const profileChanged = !deepEqual(existingProfile, updatedProfile)
624+
625+
// Handle name change
626+
if (existingName !== cloudName) {
627+
// Remove old entry
628+
delete providerProfiles.apiConfigs[existingName]
629+
existingNames.delete(existingName)
630+
631+
// Handle name conflict
632+
let finalName = cloudName
633+
if (existingNames.has(cloudName)) {
634+
// There's a conflict - rename the existing non-cloud profile
635+
const conflictingProfile = providerProfiles.apiConfigs[cloudName]
636+
if (conflictingProfile.id !== cloudProfile.id) {
637+
const newName = this.findUniqueProfileName(cloudName, existingNames)
638+
providerProfiles.apiConfigs[newName] = conflictingProfile
639+
existingNames.add(newName)
640+
changedProfiles.push(newName)
641+
}
642+
delete providerProfiles.apiConfigs[cloudName]
643+
existingNames.delete(cloudName)
644+
}
645+
646+
// Add updated profile with new name
647+
providerProfiles.apiConfigs[finalName] = updatedProfile
648+
existingNames.add(finalName)
649+
changedProfiles.push(finalName)
650+
if (existingName !== finalName) {
651+
changedProfiles.push(existingName) // Mark old name as changed (deleted)
652+
}
653+
654+
// If this was the active profile, mark it as changed
655+
if (isActiveProfile) {
656+
activeProfileChanged = true
657+
activeProfileId = cloudProfile.id || ""
658+
}
659+
} else if (profileChanged) {
660+
// Same name, but profile content changed - update in place
661+
providerProfiles.apiConfigs[existingName] = updatedProfile
662+
changedProfiles.push(existingName)
663+
664+
// If this was the active profile and settings changed, mark it as changed
665+
if (isActiveProfile) {
666+
activeProfileChanged = true
667+
activeProfileId = cloudProfile.id || ""
668+
}
669+
}
670+
// If name is the same and profile hasn't changed, do nothing
671+
} else {
672+
// Step 4: Add new cloud profile
673+
let finalName = cloudName
674+
675+
// Handle name conflict with existing non-cloud profile
676+
if (existingNames.has(cloudName)) {
677+
const existingProfile = providerProfiles.apiConfigs[cloudName]
678+
if (existingProfile.id !== cloudProfile.id) {
679+
// Rename the existing profile
680+
const newName = this.findUniqueProfileName(cloudName, existingNames)
681+
providerProfiles.apiConfigs[newName] = existingProfile
682+
existingNames.add(newName)
683+
changedProfiles.push(newName)
684+
685+
// Remove the old entry
686+
delete providerProfiles.apiConfigs[cloudName]
687+
existingNames.delete(cloudName)
688+
}
689+
}
690+
691+
// Add the new cloud profile (without secret keys)
692+
const newProfile: ProviderSettingsWithId = { ...cloudProfile }
693+
// Remove any secret keys from cloud profile
694+
for (const key of Object.keys(newProfile)) {
695+
if (isSecretStateKey(key)) {
696+
delete (newProfile as any)[key]
697+
}
698+
}
699+
700+
providerProfiles.apiConfigs[finalName] = newProfile
701+
existingNames.add(finalName)
702+
changedProfiles.push(finalName)
703+
}
704+
}
705+
706+
// Step 5: Handle case where all profiles might be deleted
707+
if (Object.keys(providerProfiles.apiConfigs).length === 0 && changedProfiles.length > 0) {
708+
// Create a default profile only if we have changed profiles
709+
const defaultProfile = { id: this.generateId() }
710+
providerProfiles.apiConfigs["default"] = defaultProfile
711+
activeProfileChanged = true
712+
activeProfileId = defaultProfile.id || ""
713+
changedProfiles.push("default")
714+
}
715+
716+
// Step 6: If active profile was deleted, find a replacement
717+
if (activeProfileChanged && !activeProfileId) {
718+
const firstProfile = Object.values(providerProfiles.apiConfigs)[0]
719+
if (firstProfile?.id) {
720+
activeProfileId = firstProfile.id
721+
}
722+
}
723+
724+
// Step 7: Update cloudProfileIds
725+
providerProfiles.cloudProfileIds = Array.from(newCloudIds)
726+
727+
// Save the updated profiles
728+
await this.store(providerProfiles)
729+
730+
return {
731+
hasChanges: changedProfiles.length > 0,
732+
activeProfileChanged,
733+
activeProfileId,
734+
}
735+
})
736+
} catch (error) {
737+
throw new Error(`Failed to sync cloud profiles: ${error}`)
738+
}
739+
}
532740
}

0 commit comments

Comments
 (0)