feat: add organization default provider settings support

This commit is contained in:
Roo Code 2025-07-25 19:42:05 +00:00
parent d62a260576
commit 83b3cc3a99
9 changed files with 284 additions and 3 deletions

View file

@ -4,6 +4,7 @@ import type {
CloudUserInfo,
TelemetryEvent,
OrganizationAllowList,
OrganizationSettings,
ClineMessage,
ShareVisibility,
} from "@roo-code/types"
@ -174,6 +175,11 @@ export class CloudService {
return this.settingsService!.getAllowList()
}
public getOrganizationSettings(): OrganizationSettings | undefined {
this.ensureInitialized()
return this.settingsService!.getSettings()
}
// TelemetryClient
public captureEvent(event: TelemetryEvent): void {

View file

@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest"
import { organizationSettingsSchema } from "../cloud.js"
describe("organizationSettingsSchema", () => {
it("should accept valid organization settings with defaultProviderSettings", () => {
const validSettings = {
version: 1,
defaultSettings: {},
allowList: {
allowAll: false,
providers: {
anthropic: {
allowAll: true,
models: [],
},
},
},
defaultProviderSettings: {
anthropic: {
apiProvider: "anthropic" as const,
apiKey: "test-key",
apiModelId: "claude-3-5-sonnet-20241022",
},
openai: {
apiProvider: "openai" as const,
openAiApiKey: "test-key",
openAiModelId: "gpt-4",
},
},
}
const result = organizationSettingsSchema.safeParse(validSettings)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.defaultProviderSettings).toEqual(validSettings.defaultProviderSettings)
}
})
it("should accept organization settings without defaultProviderSettings", () => {
const validSettings = {
version: 1,
defaultSettings: {},
allowList: {
allowAll: true,
providers: {},
},
}
const result = organizationSettingsSchema.safeParse(validSettings)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.defaultProviderSettings).toBeUndefined()
}
})
it("should reject invalid provider names in defaultProviderSettings", () => {
const invalidSettings = {
version: 1,
defaultSettings: {},
allowList: {
allowAll: true,
providers: {},
},
defaultProviderSettings: {
"invalid-provider": {
apiProvider: "invalid-provider",
apiKey: "test-key",
},
},
}
const result = organizationSettingsSchema.safeParse(invalidSettings)
expect(result.success).toBe(false)
})
})

View file

@ -1,6 +1,7 @@
import { z } from "zod"
import { globalSettingsSchema } from "./global-settings.js"
import { providerNamesSchema, providerSettingsSchemaDiscriminated } from "./provider-settings.js"
/**
* CloudUserInfo
@ -110,6 +111,7 @@ export const organizationSettingsSchema = z.object({
cloudSettings: organizationCloudSettingsSchema.optional(),
defaultSettings: organizationDefaultSettingsSchema,
allowList: organizationAllowListSchema,
defaultProviderSettings: z.record(providerNamesSchema, providerSettingsSchemaDiscriminated).optional(),
})
export type OrganizationSettings = z.infer<typeof organizationSettingsSchema>
@ -133,6 +135,7 @@ export const ORGANIZATION_DEFAULT: OrganizationSettings = {
},
defaultSettings: {},
allowList: ORGANIZATION_ALLOW_ALL,
defaultProviderSettings: {},
} as const
/**

View file

@ -1453,6 +1453,17 @@ export class ClineProvider
const currentMode = mode ?? defaultModeSlug
const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode)
// Get organization settings including default provider settings
let organizationDefaultProviderSettings: Record<string, any> = {}
try {
const orgSettings = await CloudService.instance.getOrganizationSettings()
organizationDefaultProviderSettings = orgSettings?.defaultProviderSettings || {}
} catch (error) {
console.error(
`[getStateToPostToWebview] failed to get organization settings: ${error instanceof Error ? error.message : String(error)}`,
)
}
return {
version: this.context.extension?.packageJSON?.version ?? "",
apiConfiguration,
@ -1541,6 +1552,7 @@ export class ClineProvider
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
sharingEnabled: sharingEnabled ?? false,
organizationAllowList,
organizationDefaultProviderSettings,
condensingApiConfigId,
customCondensingPrompt,
codebaseIndexModels: codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES,

View file

@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import * as vscode from "vscode"
import { CloudService } from "@roo-code/cloud"
import { webviewMessageHandler } from "../webviewMessageHandler"
import { ClineProvider } from "../ClineProvider"
import { ProviderSettings } from "@roo-code/types"
// Mock CloudService
vi.mock("@roo-code/cloud", () => ({
CloudService: {
instance: {
getOrganizationSettings: vi.fn(),
},
},
}))
describe("webviewMessageHandler - Organization Defaults", () => {
let mockProvider: any
let mockMarketplaceManager: any
beforeEach(() => {
// Reset mocks
vi.clearAllMocks()
// Create mock provider
mockProvider = {
log: vi.fn(),
upsertProviderProfile: vi.fn(),
postMessageToWebview: vi.fn(),
getState: vi.fn().mockResolvedValue({
apiConfiguration: {},
currentApiConfigName: "test-config",
}),
}
// Create mock marketplace manager
mockMarketplaceManager = {}
})
it("should apply organization default settings when creating a new profile", async () => {
// Mock organization settings with defaults
const orgDefaults = {
anthropic: {
apiProvider: "anthropic" as const,
anthropicApiKey: "org-default-key",
apiModelId: "claude-3-opus-20240229",
temperature: 0.7,
},
}
vi.mocked(CloudService.instance.getOrganizationSettings).mockResolvedValue({
version: 1,
defaultSettings: {},
allowList: { allowAll: true, providers: {} },
defaultProviderSettings: orgDefaults,
})
// Send upsertApiConfiguration message
const message = {
type: "upsertApiConfiguration" as const,
text: "new-profile",
apiConfiguration: {
apiProvider: "anthropic",
anthropicApiKey: "user-key", // User-provided key should take precedence
// temperature is not provided, so org default should be used
} as ProviderSettings,
}
await webviewMessageHandler(mockProvider, message, mockMarketplaceManager)
// Verify that upsertProviderProfile was called with merged settings
expect(mockProvider.upsertProviderProfile).toHaveBeenCalledWith("new-profile", {
apiProvider: "anthropic",
anthropicApiKey: "user-key", // User value takes precedence
apiModelId: "claude-3-opus-20240229", // From org defaults
temperature: 0.7, // From org defaults
})
})
it("should handle missing organization settings gracefully", async () => {
// Mock CloudService to throw an error
vi.mocked(CloudService.instance.getOrganizationSettings).mockRejectedValue(new Error("Not authenticated"))
// Send upsertApiConfiguration message
const message = {
type: "upsertApiConfiguration" as const,
text: "new-profile",
apiConfiguration: {
apiProvider: "anthropic",
anthropicApiKey: "user-key",
} as ProviderSettings,
}
await webviewMessageHandler(mockProvider, message, mockMarketplaceManager)
// Verify that error was logged
expect(mockProvider.log).toHaveBeenCalledWith(expect.stringContaining("Failed to get organization defaults"))
// Verify that upsertProviderProfile was still called with original settings
expect(mockProvider.upsertProviderProfile).toHaveBeenCalledWith("new-profile", {
apiProvider: "anthropic",
anthropicApiKey: "user-key",
})
})
it("should not apply defaults for a different provider", async () => {
// Mock organization settings with defaults for anthropic
const orgDefaults = {
anthropic: {
apiProvider: "anthropic" as const,
anthropicApiKey: "org-default-key",
apiModelId: "claude-3-opus-20240229",
},
}
vi.mocked(CloudService.instance.getOrganizationSettings).mockResolvedValue({
version: 1,
defaultSettings: {},
allowList: { allowAll: true, providers: {} },
defaultProviderSettings: orgDefaults,
})
// Send upsertApiConfiguration message for openai provider
const message = {
type: "upsertApiConfiguration" as const,
text: "new-profile",
apiConfiguration: {
apiProvider: "openai",
openAiApiKey: "user-key",
} as ProviderSettings,
}
await webviewMessageHandler(mockProvider, message, mockMarketplaceManager)
// Verify that only the user-provided settings were used
expect(mockProvider.upsertProviderProfile).toHaveBeenCalledWith("new-profile", {
apiProvider: "openai",
openAiApiKey: "user-key",
})
})
})

View file

@ -1474,7 +1474,28 @@ export const webviewMessageHandler = async (
break
case "upsertApiConfiguration":
if (message.text && message.apiConfiguration) {
await provider.upsertProviderProfile(message.text, message.apiConfiguration)
// Get organization default settings
let organizationDefaults: Partial<ProviderSettings> = {}
try {
const orgSettings = await CloudService.instance.getOrganizationSettings()
const selectedProvider = message.apiConfiguration.apiProvider
if (orgSettings?.defaultProviderSettings && selectedProvider) {
organizationDefaults = orgSettings.defaultProviderSettings[selectedProvider] || {}
}
} catch (error) {
provider.log(
`[upsertApiConfiguration] Failed to get organization defaults: ${error instanceof Error ? error.message : String(error)}`,
)
}
// Merge organization defaults with the provided configuration
// User-provided values take precedence over organization defaults
const mergedConfiguration: ProviderSettings = {
...organizationDefaults,
...message.apiConfiguration,
}
await provider.upsertProviderProfile(message.text, mergedConfiguration)
}
break
case "renameApiConfiguration":

View file

@ -10,6 +10,7 @@ import type {
OrganizationAllowList,
CloudUserInfo,
ShareVisibility,
ProviderName,
} from "@roo-code/types"
import { GitCommit } from "../utils/git"
@ -302,6 +303,7 @@ export type ExtensionState = Pick<
cloudApiUrl?: string
sharingEnabled: boolean
organizationAllowList: OrganizationAllowList
organizationDefaultProviderSettings?: Partial<Record<ProviderName, ProviderSettings>>
autoCondenseContext: boolean
autoCondenseContextPercent: number

View file

@ -106,7 +106,7 @@ const ApiOptions = ({
setErrorMessage,
}: ApiOptionsProps) => {
const { t } = useAppTranslation()
const { organizationAllowList } = useExtensionState()
const { organizationAllowList, organizationDefaultProviderSettings } = useExtensionState()
const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => {
const headers = apiConfiguration?.openAiHeaders || {}
@ -246,6 +246,23 @@ const ApiOptions = ({
(value: ProviderName) => {
setApiConfigurationField("apiProvider", value)
// Apply organization default settings if available
const orgDefaults = organizationDefaultProviderSettings?.[value]
if (orgDefaults) {
// Apply each default setting from the organization
Object.entries(orgDefaults).forEach(([key, defaultValue]) => {
// Skip apiProvider as we've already set it
if (key === "apiProvider") return
// Only apply defaults if the current value is undefined or empty
const currentValue = apiConfiguration[key as keyof ProviderSettings]
if (!currentValue || (typeof currentValue === "string" && currentValue.trim() === "")) {
setApiConfigurationField(key as keyof ProviderSettings, defaultValue)
}
})
}
// It would be much easier to have a single attribute that stores
// the modelId, but we have a separate attribute for each of
// OpenRouter, Glama, Unbound, and Requesty.
@ -311,7 +328,7 @@ const ApiOptions = ({
)
}
},
[setApiConfigurationField, apiConfiguration],
[setApiConfigurationField, apiConfiguration, organizationDefaultProviderSettings],
)
const modelValidationError = useMemo(() => {

View file

@ -8,6 +8,7 @@ import {
type ExperimentId,
type OrganizationAllowList,
ORGANIZATION_ALLOW_ALL,
type ProviderName,
} from "@roo-code/types"
import { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata } from "@roo/ExtensionMessage"
@ -34,6 +35,7 @@ export interface ExtensionStateContextType extends ExtensionState {
filePaths: string[]
openedTabs: Array<{ label: string; isActive: boolean; path?: string }>
organizationAllowList: OrganizationAllowList
organizationDefaultProviderSettings: Partial<Record<ProviderName, ProviderSettings>>
cloudIsAuthenticated: boolean
sharingEnabled: boolean
maxConcurrentFileReads?: number
@ -219,6 +221,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
cloudIsAuthenticated: false,
sharingEnabled: false,
organizationAllowList: ORGANIZATION_ALLOW_ALL,
organizationDefaultProviderSettings: {},
autoCondenseContext: true,
autoCondenseContextPercent: 100,
profileThresholds: {},
@ -379,6 +382,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
screenshotQuality: state.screenshotQuality,
routerModels: extensionRouterModels,
cloudIsAuthenticated: state.cloudIsAuthenticated ?? false,
organizationDefaultProviderSettings: state.organizationDefaultProviderSettings ?? {},
marketplaceItems,
marketplaceInstalledMetadata,
profileThresholds: state.profileThresholds ?? {},