Skip to content

Commit 83b3cc3

Browse files
committed
feat: add organization default provider settings support
1 parent d62a260 commit 83b3cc3

File tree

9 files changed

+284
-3
lines changed

9 files changed

+284
-3
lines changed

packages/cloud/src/CloudService.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
CloudUserInfo,
55
TelemetryEvent,
66
OrganizationAllowList,
7+
OrganizationSettings,
78
ClineMessage,
89
ShareVisibility,
910
} from "@roo-code/types"
@@ -174,6 +175,11 @@ export class CloudService {
174175
return this.settingsService!.getAllowList()
175176
}
176177

178+
public getOrganizationSettings(): OrganizationSettings | undefined {
179+
this.ensureInitialized()
180+
return this.settingsService!.getSettings()
181+
}
182+
177183
// TelemetryClient
178184

179185
public captureEvent(event: TelemetryEvent): void {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect } from "vitest"
2+
import { organizationSettingsSchema } from "../cloud.js"
3+
4+
describe("organizationSettingsSchema", () => {
5+
it("should accept valid organization settings with defaultProviderSettings", () => {
6+
const validSettings = {
7+
version: 1,
8+
defaultSettings: {},
9+
allowList: {
10+
allowAll: false,
11+
providers: {
12+
anthropic: {
13+
allowAll: true,
14+
models: [],
15+
},
16+
},
17+
},
18+
defaultProviderSettings: {
19+
anthropic: {
20+
apiProvider: "anthropic" as const,
21+
apiKey: "test-key",
22+
apiModelId: "claude-3-5-sonnet-20241022",
23+
},
24+
openai: {
25+
apiProvider: "openai" as const,
26+
openAiApiKey: "test-key",
27+
openAiModelId: "gpt-4",
28+
},
29+
},
30+
}
31+
32+
const result = organizationSettingsSchema.safeParse(validSettings)
33+
expect(result.success).toBe(true)
34+
if (result.success) {
35+
expect(result.data.defaultProviderSettings).toEqual(validSettings.defaultProviderSettings)
36+
}
37+
})
38+
39+
it("should accept organization settings without defaultProviderSettings", () => {
40+
const validSettings = {
41+
version: 1,
42+
defaultSettings: {},
43+
allowList: {
44+
allowAll: true,
45+
providers: {},
46+
},
47+
}
48+
49+
const result = organizationSettingsSchema.safeParse(validSettings)
50+
expect(result.success).toBe(true)
51+
if (result.success) {
52+
expect(result.data.defaultProviderSettings).toBeUndefined()
53+
}
54+
})
55+
56+
it("should reject invalid provider names in defaultProviderSettings", () => {
57+
const invalidSettings = {
58+
version: 1,
59+
defaultSettings: {},
60+
allowList: {
61+
allowAll: true,
62+
providers: {},
63+
},
64+
defaultProviderSettings: {
65+
"invalid-provider": {
66+
apiProvider: "invalid-provider",
67+
apiKey: "test-key",
68+
},
69+
},
70+
}
71+
72+
const result = organizationSettingsSchema.safeParse(invalidSettings)
73+
expect(result.success).toBe(false)
74+
})
75+
})

packages/types/src/cloud.ts

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

33
import { globalSettingsSchema } from "./global-settings.js"
4+
import { providerNamesSchema, providerSettingsSchemaDiscriminated } from "./provider-settings.js"
45

56
/**
67
* CloudUserInfo
@@ -110,6 +111,7 @@ export const organizationSettingsSchema = z.object({
110111
cloudSettings: organizationCloudSettingsSchema.optional(),
111112
defaultSettings: organizationDefaultSettingsSchema,
112113
allowList: organizationAllowListSchema,
114+
defaultProviderSettings: z.record(providerNamesSchema, providerSettingsSchemaDiscriminated).optional(),
113115
})
114116

115117
export type OrganizationSettings = z.infer<typeof organizationSettingsSchema>
@@ -133,6 +135,7 @@ export const ORGANIZATION_DEFAULT: OrganizationSettings = {
133135
},
134136
defaultSettings: {},
135137
allowList: ORGANIZATION_ALLOW_ALL,
138+
defaultProviderSettings: {},
136139
} as const
137140

138141
/**

src/core/webview/ClineProvider.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1453,6 +1453,17 @@ export class ClineProvider
14531453
const currentMode = mode ?? defaultModeSlug
14541454
const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode)
14551455

1456+
// Get organization settings including default provider settings
1457+
let organizationDefaultProviderSettings: Record<string, any> = {}
1458+
try {
1459+
const orgSettings = await CloudService.instance.getOrganizationSettings()
1460+
organizationDefaultProviderSettings = orgSettings?.defaultProviderSettings || {}
1461+
} catch (error) {
1462+
console.error(
1463+
`[getStateToPostToWebview] failed to get organization settings: ${error instanceof Error ? error.message : String(error)}`,
1464+
)
1465+
}
1466+
14561467
return {
14571468
version: this.context.extension?.packageJSON?.version ?? "",
14581469
apiConfiguration,
@@ -1541,6 +1552,7 @@ export class ClineProvider
15411552
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
15421553
sharingEnabled: sharingEnabled ?? false,
15431554
organizationAllowList,
1555+
organizationDefaultProviderSettings,
15441556
condensingApiConfigId,
15451557
customCondensingPrompt,
15461558
codebaseIndexModels: codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES,
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import * as vscode from "vscode"
3+
import { CloudService } from "@roo-code/cloud"
4+
import { webviewMessageHandler } from "../webviewMessageHandler"
5+
import { ClineProvider } from "../ClineProvider"
6+
import { ProviderSettings } from "@roo-code/types"
7+
8+
// Mock CloudService
9+
vi.mock("@roo-code/cloud", () => ({
10+
CloudService: {
11+
instance: {
12+
getOrganizationSettings: vi.fn(),
13+
},
14+
},
15+
}))
16+
17+
describe("webviewMessageHandler - Organization Defaults", () => {
18+
let mockProvider: any
19+
let mockMarketplaceManager: any
20+
21+
beforeEach(() => {
22+
// Reset mocks
23+
vi.clearAllMocks()
24+
25+
// Create mock provider
26+
mockProvider = {
27+
log: vi.fn(),
28+
upsertProviderProfile: vi.fn(),
29+
postMessageToWebview: vi.fn(),
30+
getState: vi.fn().mockResolvedValue({
31+
apiConfiguration: {},
32+
currentApiConfigName: "test-config",
33+
}),
34+
}
35+
36+
// Create mock marketplace manager
37+
mockMarketplaceManager = {}
38+
})
39+
40+
it("should apply organization default settings when creating a new profile", async () => {
41+
// Mock organization settings with defaults
42+
const orgDefaults = {
43+
anthropic: {
44+
apiProvider: "anthropic" as const,
45+
anthropicApiKey: "org-default-key",
46+
apiModelId: "claude-3-opus-20240229",
47+
temperature: 0.7,
48+
},
49+
}
50+
51+
vi.mocked(CloudService.instance.getOrganizationSettings).mockResolvedValue({
52+
version: 1,
53+
defaultSettings: {},
54+
allowList: { allowAll: true, providers: {} },
55+
defaultProviderSettings: orgDefaults,
56+
})
57+
58+
// Send upsertApiConfiguration message
59+
const message = {
60+
type: "upsertApiConfiguration" as const,
61+
text: "new-profile",
62+
apiConfiguration: {
63+
apiProvider: "anthropic",
64+
anthropicApiKey: "user-key", // User-provided key should take precedence
65+
// temperature is not provided, so org default should be used
66+
} as ProviderSettings,
67+
}
68+
69+
await webviewMessageHandler(mockProvider, message, mockMarketplaceManager)
70+
71+
// Verify that upsertProviderProfile was called with merged settings
72+
expect(mockProvider.upsertProviderProfile).toHaveBeenCalledWith("new-profile", {
73+
apiProvider: "anthropic",
74+
anthropicApiKey: "user-key", // User value takes precedence
75+
apiModelId: "claude-3-opus-20240229", // From org defaults
76+
temperature: 0.7, // From org defaults
77+
})
78+
})
79+
80+
it("should handle missing organization settings gracefully", async () => {
81+
// Mock CloudService to throw an error
82+
vi.mocked(CloudService.instance.getOrganizationSettings).mockRejectedValue(new Error("Not authenticated"))
83+
84+
// Send upsertApiConfiguration message
85+
const message = {
86+
type: "upsertApiConfiguration" as const,
87+
text: "new-profile",
88+
apiConfiguration: {
89+
apiProvider: "anthropic",
90+
anthropicApiKey: "user-key",
91+
} as ProviderSettings,
92+
}
93+
94+
await webviewMessageHandler(mockProvider, message, mockMarketplaceManager)
95+
96+
// Verify that error was logged
97+
expect(mockProvider.log).toHaveBeenCalledWith(expect.stringContaining("Failed to get organization defaults"))
98+
99+
// Verify that upsertProviderProfile was still called with original settings
100+
expect(mockProvider.upsertProviderProfile).toHaveBeenCalledWith("new-profile", {
101+
apiProvider: "anthropic",
102+
anthropicApiKey: "user-key",
103+
})
104+
})
105+
106+
it("should not apply defaults for a different provider", async () => {
107+
// Mock organization settings with defaults for anthropic
108+
const orgDefaults = {
109+
anthropic: {
110+
apiProvider: "anthropic" as const,
111+
anthropicApiKey: "org-default-key",
112+
apiModelId: "claude-3-opus-20240229",
113+
},
114+
}
115+
116+
vi.mocked(CloudService.instance.getOrganizationSettings).mockResolvedValue({
117+
version: 1,
118+
defaultSettings: {},
119+
allowList: { allowAll: true, providers: {} },
120+
defaultProviderSettings: orgDefaults,
121+
})
122+
123+
// Send upsertApiConfiguration message for openai provider
124+
const message = {
125+
type: "upsertApiConfiguration" as const,
126+
text: "new-profile",
127+
apiConfiguration: {
128+
apiProvider: "openai",
129+
openAiApiKey: "user-key",
130+
} as ProviderSettings,
131+
}
132+
133+
await webviewMessageHandler(mockProvider, message, mockMarketplaceManager)
134+
135+
// Verify that only the user-provided settings were used
136+
expect(mockProvider.upsertProviderProfile).toHaveBeenCalledWith("new-profile", {
137+
apiProvider: "openai",
138+
openAiApiKey: "user-key",
139+
})
140+
})
141+
})

src/core/webview/webviewMessageHandler.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1474,7 +1474,28 @@ export const webviewMessageHandler = async (
14741474
break
14751475
case "upsertApiConfiguration":
14761476
if (message.text && message.apiConfiguration) {
1477-
await provider.upsertProviderProfile(message.text, message.apiConfiguration)
1477+
// Get organization default settings
1478+
let organizationDefaults: Partial<ProviderSettings> = {}
1479+
try {
1480+
const orgSettings = await CloudService.instance.getOrganizationSettings()
1481+
const selectedProvider = message.apiConfiguration.apiProvider
1482+
if (orgSettings?.defaultProviderSettings && selectedProvider) {
1483+
organizationDefaults = orgSettings.defaultProviderSettings[selectedProvider] || {}
1484+
}
1485+
} catch (error) {
1486+
provider.log(
1487+
`[upsertApiConfiguration] Failed to get organization defaults: ${error instanceof Error ? error.message : String(error)}`,
1488+
)
1489+
}
1490+
1491+
// Merge organization defaults with the provided configuration
1492+
// User-provided values take precedence over organization defaults
1493+
const mergedConfiguration: ProviderSettings = {
1494+
...organizationDefaults,
1495+
...message.apiConfiguration,
1496+
}
1497+
1498+
await provider.upsertProviderProfile(message.text, mergedConfiguration)
14781499
}
14791500
break
14801501
case "renameApiConfiguration":

src/shared/ExtensionMessage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
OrganizationAllowList,
1111
CloudUserInfo,
1212
ShareVisibility,
13+
ProviderName,
1314
} from "@roo-code/types"
1415

1516
import { GitCommit } from "../utils/git"
@@ -302,6 +303,7 @@ export type ExtensionState = Pick<
302303
cloudApiUrl?: string
303304
sharingEnabled: boolean
304305
organizationAllowList: OrganizationAllowList
306+
organizationDefaultProviderSettings?: Partial<Record<ProviderName, ProviderSettings>>
305307

306308
autoCondenseContext: boolean
307309
autoCondenseContextPercent: number

webview-ui/src/components/settings/ApiOptions.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ const ApiOptions = ({
106106
setErrorMessage,
107107
}: ApiOptionsProps) => {
108108
const { t } = useAppTranslation()
109-
const { organizationAllowList } = useExtensionState()
109+
const { organizationAllowList, organizationDefaultProviderSettings } = useExtensionState()
110110

111111
const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => {
112112
const headers = apiConfiguration?.openAiHeaders || {}
@@ -246,6 +246,23 @@ const ApiOptions = ({
246246
(value: ProviderName) => {
247247
setApiConfigurationField("apiProvider", value)
248248

249+
// Apply organization default settings if available
250+
const orgDefaults = organizationDefaultProviderSettings?.[value]
251+
252+
if (orgDefaults) {
253+
// Apply each default setting from the organization
254+
Object.entries(orgDefaults).forEach(([key, defaultValue]) => {
255+
// Skip apiProvider as we've already set it
256+
if (key === "apiProvider") return
257+
258+
// Only apply defaults if the current value is undefined or empty
259+
const currentValue = apiConfiguration[key as keyof ProviderSettings]
260+
if (!currentValue || (typeof currentValue === "string" && currentValue.trim() === "")) {
261+
setApiConfigurationField(key as keyof ProviderSettings, defaultValue)
262+
}
263+
})
264+
}
265+
249266
// It would be much easier to have a single attribute that stores
250267
// the modelId, but we have a separate attribute for each of
251268
// OpenRouter, Glama, Unbound, and Requesty.
@@ -311,7 +328,7 @@ const ApiOptions = ({
311328
)
312329
}
313330
},
314-
[setApiConfigurationField, apiConfiguration],
331+
[setApiConfigurationField, apiConfiguration, organizationDefaultProviderSettings],
315332
)
316333

317334
const modelValidationError = useMemo(() => {

0 commit comments

Comments
 (0)