mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-07 02:58:15 +00:00
Cloud: support syncing provider profiles from the cloud (#6540)
This commit is contained in:
parent
f7b2bb0384
commit
8353ca2519
5 changed files with 747 additions and 11 deletions
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>[] = [
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
@ -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
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { ExtensionContext } from "vscode"
|
|||
|
||||
import type { ProviderSettings } from "@roo-code/types"
|
||||
|
||||
import { ProviderSettingsManager, ProviderProfiles } from "../ProviderSettingsManager"
|
||||
import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager"
|
||||
|
||||
// Mock VSCode ExtensionContext
|
||||
const mockSecrets = {
|
||||
|
|
@ -678,4 +678,447 @@ describe("ProviderSettingsManager", () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("syncCloudProfiles", () => {
|
||||
it("should add new cloud profiles without secret keys", async () => {
|
||||
const existingConfig: ProviderProfiles = {
|
||||
currentApiConfigName: "default",
|
||||
apiConfigs: {
|
||||
default: { id: "default-id" },
|
||||
},
|
||||
cloudProfileIds: [],
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const cloudProfiles = {
|
||||
"cloud-profile": {
|
||||
id: "cloud-id-1",
|
||||
apiProvider: "anthropic" as const,
|
||||
apiKey: "secret-key", // This should be removed
|
||||
apiModelId: "claude-3-opus-20240229",
|
||||
},
|
||||
}
|
||||
|
||||
const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
|
||||
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.activeProfileChanged).toBe(false)
|
||||
expect(result.activeProfileId).toBe("")
|
||||
|
||||
const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
|
||||
expect(storedConfig.apiConfigs["cloud-profile"]).toEqual({
|
||||
id: "cloud-id-1",
|
||||
apiProvider: "anthropic",
|
||||
apiModelId: "claude-3-opus-20240229",
|
||||
// apiKey should be removed
|
||||
})
|
||||
expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"])
|
||||
})
|
||||
|
||||
it("should update existing cloud profiles by ID, preserving secret keys", async () => {
|
||||
const existingConfig: ProviderProfiles = {
|
||||
currentApiConfigName: "default",
|
||||
apiConfigs: {
|
||||
default: { id: "default-id" },
|
||||
"existing-cloud": {
|
||||
id: "cloud-id-1",
|
||||
apiProvider: "anthropic" as const,
|
||||
apiKey: "existing-secret",
|
||||
apiModelId: "claude-3-haiku-20240307",
|
||||
},
|
||||
},
|
||||
cloudProfileIds: ["cloud-id-1"],
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const cloudProfiles = {
|
||||
"updated-name": {
|
||||
id: "cloud-id-1",
|
||||
apiProvider: "anthropic" as const,
|
||||
apiKey: "new-secret", // Should be ignored
|
||||
apiModelId: "claude-3-opus-20240229",
|
||||
},
|
||||
}
|
||||
|
||||
const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
|
||||
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.activeProfileChanged).toBe(false)
|
||||
expect(result.activeProfileId).toBe("")
|
||||
|
||||
const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
|
||||
expect(storedConfig.apiConfigs["updated-name"]).toEqual({
|
||||
id: "cloud-id-1",
|
||||
apiProvider: "anthropic",
|
||||
apiKey: "existing-secret", // Preserved
|
||||
apiModelId: "claude-3-opus-20240229", // Updated
|
||||
})
|
||||
expect(storedConfig.apiConfigs["existing-cloud"]).toBeUndefined()
|
||||
expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"])
|
||||
})
|
||||
|
||||
it("should delete cloud profiles not in the new cloud profiles", async () => {
|
||||
const existingConfig: ProviderProfiles = {
|
||||
currentApiConfigName: "default",
|
||||
apiConfigs: {
|
||||
default: { id: "default-id" },
|
||||
"cloud-profile-1": { id: "cloud-id-1", apiProvider: "anthropic" as const },
|
||||
"cloud-profile-2": { id: "cloud-id-2", apiProvider: "openai" as const },
|
||||
},
|
||||
cloudProfileIds: ["cloud-id-1", "cloud-id-2"],
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const cloudProfiles = {
|
||||
"cloud-profile-1": {
|
||||
id: "cloud-id-1",
|
||||
apiProvider: "anthropic" as const,
|
||||
},
|
||||
// cloud-profile-2 is missing, should be deleted
|
||||
}
|
||||
|
||||
const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
|
||||
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.activeProfileChanged).toBe(false)
|
||||
expect(result.activeProfileId).toBe("")
|
||||
|
||||
const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
|
||||
expect(storedConfig.apiConfigs["cloud-profile-1"]).toBeDefined()
|
||||
expect(storedConfig.apiConfigs["cloud-profile-2"]).toBeUndefined()
|
||||
expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"])
|
||||
})
|
||||
|
||||
it("should rename existing non-cloud profile when cloud profile has same name", async () => {
|
||||
const existingConfig: ProviderProfiles = {
|
||||
currentApiConfigName: "default",
|
||||
apiConfigs: {
|
||||
default: { id: "default-id" },
|
||||
"conflict-name": { id: "local-id", apiProvider: "openai" as const },
|
||||
},
|
||||
cloudProfileIds: [],
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const cloudProfiles = {
|
||||
"conflict-name": {
|
||||
id: "cloud-id-1",
|
||||
apiProvider: "anthropic" as const,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
|
||||
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.activeProfileChanged).toBe(false)
|
||||
expect(result.activeProfileId).toBe("")
|
||||
|
||||
const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
|
||||
expect(storedConfig.apiConfigs["conflict-name"]).toEqual({
|
||||
id: "cloud-id-1",
|
||||
apiProvider: "anthropic",
|
||||
})
|
||||
expect(storedConfig.apiConfigs["conflict-name_local"]).toEqual({
|
||||
id: "local-id",
|
||||
apiProvider: "openai",
|
||||
})
|
||||
expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"])
|
||||
})
|
||||
|
||||
it("should handle multiple naming conflicts with incremental suffixes", async () => {
|
||||
const existingConfig: ProviderProfiles = {
|
||||
currentApiConfigName: "default",
|
||||
apiConfigs: {
|
||||
default: { id: "default-id" },
|
||||
"conflict-name": { id: "local-id-1", apiProvider: "openai" as const },
|
||||
"conflict-name_local": { id: "local-id-2", apiProvider: "vertex" as const },
|
||||
},
|
||||
cloudProfileIds: [],
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const cloudProfiles = {
|
||||
"conflict-name": {
|
||||
id: "cloud-id-1",
|
||||
apiProvider: "anthropic" as const,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
|
||||
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.activeProfileChanged).toBe(false)
|
||||
expect(result.activeProfileId).toBe("")
|
||||
|
||||
const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
|
||||
expect(storedConfig.apiConfigs["conflict-name"]).toEqual({
|
||||
id: "cloud-id-1",
|
||||
apiProvider: "anthropic",
|
||||
})
|
||||
expect(storedConfig.apiConfigs["conflict-name_1"]).toEqual({
|
||||
id: "local-id-1",
|
||||
apiProvider: "openai",
|
||||
})
|
||||
expect(storedConfig.apiConfigs["conflict-name_local"]).toEqual({
|
||||
id: "local-id-2",
|
||||
apiProvider: "vertex",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle empty cloud profiles by deleting all cloud-managed profiles", async () => {
|
||||
const existingConfig: ProviderProfiles = {
|
||||
currentApiConfigName: "default",
|
||||
apiConfigs: {
|
||||
default: { id: "default-id" },
|
||||
"cloud-profile-1": { id: "cloud-id-1", apiProvider: "anthropic" as const },
|
||||
"cloud-profile-2": { id: "cloud-id-2", apiProvider: "openai" as const },
|
||||
},
|
||||
cloudProfileIds: ["cloud-id-1", "cloud-id-2"],
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const cloudProfiles = {}
|
||||
|
||||
const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
|
||||
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.activeProfileChanged).toBe(false)
|
||||
expect(result.activeProfileId).toBe("")
|
||||
|
||||
const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
|
||||
expect(storedConfig.apiConfigs["cloud-profile-1"]).toBeUndefined()
|
||||
expect(storedConfig.apiConfigs["cloud-profile-2"]).toBeUndefined()
|
||||
expect(storedConfig.apiConfigs["default"]).toBeDefined()
|
||||
expect(storedConfig.cloudProfileIds).toEqual([])
|
||||
})
|
||||
|
||||
it("should skip cloud profiles without IDs", async () => {
|
||||
const existingConfig: ProviderProfiles = {
|
||||
currentApiConfigName: "default",
|
||||
apiConfigs: {
|
||||
default: { id: "default-id" },
|
||||
},
|
||||
cloudProfileIds: [],
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const cloudProfiles = {
|
||||
"valid-profile": {
|
||||
id: "cloud-id-1",
|
||||
apiProvider: "anthropic" as const,
|
||||
},
|
||||
"invalid-profile": {
|
||||
// Missing id
|
||||
apiProvider: "openai" as const,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
|
||||
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.activeProfileChanged).toBe(false)
|
||||
expect(result.activeProfileId).toBe("")
|
||||
|
||||
const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
|
||||
expect(storedConfig.apiConfigs["valid-profile"]).toBeDefined()
|
||||
expect(storedConfig.apiConfigs["invalid-profile"]).toBeUndefined()
|
||||
expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"])
|
||||
})
|
||||
|
||||
it("should handle complex sync scenario with multiple operations", async () => {
|
||||
const existingConfig: ProviderProfiles = {
|
||||
currentApiConfigName: "default",
|
||||
apiConfigs: {
|
||||
default: { id: "default-id" },
|
||||
"keep-cloud": { id: "cloud-id-1", apiProvider: "anthropic" as const, apiKey: "secret1" },
|
||||
"delete-cloud": { id: "cloud-id-2", apiProvider: "openai" as const },
|
||||
"rename-me": { id: "local-id", apiProvider: "vertex" as const },
|
||||
},
|
||||
cloudProfileIds: ["cloud-id-1", "cloud-id-2"],
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const cloudProfiles = {
|
||||
"updated-keep": {
|
||||
id: "cloud-id-1",
|
||||
apiProvider: "anthropic" as const,
|
||||
apiKey: "new-secret", // Should be ignored
|
||||
apiModelId: "claude-3-opus-20240229",
|
||||
},
|
||||
"rename-me": {
|
||||
id: "cloud-id-3",
|
||||
apiProvider: "openai" as const,
|
||||
},
|
||||
// delete-cloud is missing (should be deleted)
|
||||
// new profile
|
||||
"new-cloud": {
|
||||
id: "cloud-id-4",
|
||||
apiProvider: "vertex" as const,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
|
||||
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.activeProfileChanged).toBe(false)
|
||||
expect(result.activeProfileId).toBe("")
|
||||
|
||||
const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
|
||||
|
||||
// Check deletions
|
||||
expect(storedConfig.apiConfigs["delete-cloud"]).toBeUndefined()
|
||||
expect(storedConfig.apiConfigs["keep-cloud"]).toBeUndefined()
|
||||
|
||||
// Check updates
|
||||
expect(storedConfig.apiConfigs["updated-keep"]).toEqual({
|
||||
id: "cloud-id-1",
|
||||
apiProvider: "anthropic",
|
||||
apiKey: "secret1", // preserved
|
||||
apiModelId: "claude-3-opus-20240229",
|
||||
})
|
||||
|
||||
// Check renames
|
||||
expect(storedConfig.apiConfigs["rename-me_local"]).toEqual({
|
||||
id: "local-id",
|
||||
apiProvider: "vertex",
|
||||
})
|
||||
expect(storedConfig.apiConfigs["rename-me"]).toEqual({
|
||||
id: "cloud-id-3",
|
||||
apiProvider: "openai",
|
||||
})
|
||||
|
||||
// Check new additions
|
||||
expect(storedConfig.apiConfigs["new-cloud"]).toEqual({
|
||||
id: "cloud-id-4",
|
||||
apiProvider: "vertex",
|
||||
})
|
||||
|
||||
expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1", "cloud-id-3", "cloud-id-4"])
|
||||
})
|
||||
|
||||
it("should throw error if secrets storage fails", async () => {
|
||||
mockSecrets.get.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
currentApiConfigName: "default",
|
||||
apiConfigs: { default: { id: "default-id" } },
|
||||
cloudProfileIds: [],
|
||||
}),
|
||||
)
|
||||
mockSecrets.store.mockRejectedValue(new Error("Storage failed"))
|
||||
|
||||
await expect(providerSettingsManager.syncCloudProfiles({})).rejects.toThrow(
|
||||
"Failed to sync cloud profiles: Error: Failed to write provider profiles to secrets: Error: Storage failed",
|
||||
)
|
||||
})
|
||||
|
||||
it("should track active profile changes when active profile is updated", async () => {
|
||||
const existingConfig: ProviderProfiles = {
|
||||
currentApiConfigName: "active-profile",
|
||||
apiConfigs: {
|
||||
"active-profile": {
|
||||
id: "active-id",
|
||||
apiProvider: "anthropic" as const,
|
||||
apiKey: "old-key",
|
||||
},
|
||||
},
|
||||
cloudProfileIds: ["active-id"],
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const cloudProfiles = {
|
||||
"active-profile": {
|
||||
id: "active-id",
|
||||
apiProvider: "anthropic" as const,
|
||||
apiModelId: "claude-3-opus-20240229", // Updated setting
|
||||
},
|
||||
}
|
||||
|
||||
const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles, "active-profile")
|
||||
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.activeProfileChanged).toBe(true)
|
||||
expect(result.activeProfileId).toBe("active-id")
|
||||
})
|
||||
|
||||
it("should track active profile changes when active profile is deleted", async () => {
|
||||
const existingConfig: ProviderProfiles = {
|
||||
currentApiConfigName: "active-profile",
|
||||
apiConfigs: {
|
||||
"active-profile": { id: "active-id", apiProvider: "anthropic" as const },
|
||||
"backup-profile": { id: "backup-id", apiProvider: "openai" as const },
|
||||
},
|
||||
cloudProfileIds: ["active-id"],
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const cloudProfiles = {} // Active profile deleted
|
||||
|
||||
const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles, "active-profile")
|
||||
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.activeProfileChanged).toBe(true)
|
||||
expect(result.activeProfileId).toBe("backup-id") // Should switch to first available
|
||||
})
|
||||
|
||||
it("should create default profile when all profiles are deleted", async () => {
|
||||
const existingConfig: ProviderProfiles = {
|
||||
currentApiConfigName: "only-profile",
|
||||
apiConfigs: {
|
||||
"only-profile": { id: "only-id", apiProvider: "anthropic" as const },
|
||||
},
|
||||
cloudProfileIds: ["only-id"],
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const cloudProfiles = {} // All profiles deleted
|
||||
|
||||
const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles, "only-profile")
|
||||
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.activeProfileChanged).toBe(true)
|
||||
expect(result.activeProfileId).toBeTruthy() // Should have new default profile ID
|
||||
|
||||
const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
|
||||
expect(storedConfig.apiConfigs["default"]).toBeDefined()
|
||||
expect(storedConfig.apiConfigs["default"].id).toBe(result.activeProfileId)
|
||||
})
|
||||
|
||||
it("should not mark active profile as changed when it's not affected", async () => {
|
||||
const existingConfig: ProviderProfiles = {
|
||||
currentApiConfigName: "local-profile",
|
||||
apiConfigs: {
|
||||
"local-profile": { id: "local-id", apiProvider: "anthropic" as const },
|
||||
"cloud-profile": { id: "cloud-id", apiProvider: "openai" as const },
|
||||
},
|
||||
cloudProfileIds: ["cloud-id"],
|
||||
}
|
||||
|
||||
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
|
||||
|
||||
const cloudProfiles = {
|
||||
"cloud-profile": {
|
||||
id: "cloud-id",
|
||||
apiProvider: "openai" as const,
|
||||
apiModelId: "gpt-4", // Updated cloud profile
|
||||
},
|
||||
}
|
||||
|
||||
const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles, "local-profile")
|
||||
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.activeProfileChanged).toBe(false)
|
||||
expect(result.activeProfileId).toBe("local-id")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type ProviderSettings,
|
||||
type RooCodeSettings,
|
||||
type ProviderSettingsEntry,
|
||||
type ProviderSettingsWithId,
|
||||
type TelemetryProperties,
|
||||
type TelemetryPropertiesProvider,
|
||||
type CodeActionId,
|
||||
|
|
@ -153,6 +154,76 @@ export class ClineProvider
|
|||
})
|
||||
|
||||
this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager)
|
||||
|
||||
// Initialize cloud profile sync
|
||||
this.initializeCloudProfileSync().catch((error) => {
|
||||
this.log(`Failed to initialize cloud profile sync: ${error}`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize cloud profile synchronization
|
||||
*/
|
||||
private async initializeCloudProfileSync() {
|
||||
try {
|
||||
// Check if authenticated and sync profiles
|
||||
if (CloudService.hasInstance() && CloudService.instance.isAuthenticated()) {
|
||||
await this.syncCloudProfiles()
|
||||
}
|
||||
|
||||
// Set up listener for future updates
|
||||
if (CloudService.hasInstance()) {
|
||||
CloudService.instance.on("settings-updated", this.handleCloudSettingsUpdate)
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Error in initializeCloudProfileSync: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cloud settings updates
|
||||
*/
|
||||
private handleCloudSettingsUpdate = async () => {
|
||||
try {
|
||||
await this.syncCloudProfiles()
|
||||
} catch (error) {
|
||||
this.log(`Error handling cloud settings update: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize cloud profiles with local profiles
|
||||
*/
|
||||
private async syncCloudProfiles() {
|
||||
try {
|
||||
const settings = CloudService.instance.getOrganizationSettings()
|
||||
if (!settings?.providerProfiles) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentApiConfigName = this.getGlobalState("currentApiConfigName")
|
||||
const result = await this.providerSettingsManager.syncCloudProfiles(
|
||||
settings.providerProfiles,
|
||||
currentApiConfigName,
|
||||
)
|
||||
|
||||
if (result.hasChanges) {
|
||||
// Update list
|
||||
await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig())
|
||||
|
||||
if (result.activeProfileChanged && result.activeProfileId) {
|
||||
// Reload full settings for new active profile
|
||||
const profile = await this.providerSettingsManager.getProfile({
|
||||
id: result.activeProfileId,
|
||||
})
|
||||
await this.activateProviderProfile({ name: profile.name })
|
||||
}
|
||||
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Error syncing cloud profiles: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Adds a new Cline instance to clineStack, marking the start of a new task.
|
||||
|
|
@ -282,6 +353,11 @@ export class ClineProvider
|
|||
|
||||
this.clearWebviewResources()
|
||||
|
||||
// Clean up cloud service event listener
|
||||
if (CloudService.hasInstance()) {
|
||||
CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate)
|
||||
}
|
||||
|
||||
while (this.disposables.length) {
|
||||
const x = this.disposables.pop()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue