Skip to content
Closed
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
6 changes: 6 additions & 0 deletions packages/cloud/src/CloudService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
CloudUserInfo,
TelemetryEvent,
OrganizationAllowList,
OrganizationSettings,
ClineMessage,
ShareVisibility,
} from "@roo-code/types"
Expand Down Expand Up @@ -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 {
Expand Down
75 changes: 75 additions & 0 deletions packages/types/src/__tests__/cloud-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
3 changes: 3 additions & 0 deletions packages/types/src/cloud.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod"

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

/**
* CloudUserInfo
Expand Down Expand Up @@ -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>
Expand All @@ -133,6 +135,7 @@ export const ORGANIZATION_DEFAULT: OrganizationSettings = {
},
defaultSettings: {},
allowList: ORGANIZATION_ALLOW_ALL,
defaultProviderSettings: {},
} as const

/**
Expand Down
12 changes: 12 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When getOrganizationSettings() fails, the error is logged but organizationDefaultProviderSettings remains an empty object. This silent failure might be confusing for users who expect defaults to be applied.

Consider:

  1. Adding a user-visible notification when organization defaults fail to load
  2. Or propagating partial data if some defaults are available
  3. Or adding a flag to indicate whether defaults were successfully loaded

This would help users understand why their expected defaults aren't being applied.

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,
Expand Down Expand Up @@ -1541,6 +1552,7 @@ export class ClineProvider
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
sharingEnabled: sharingEnabled ?? false,
organizationAllowList,
organizationDefaultProviderSettings,
condensingApiConfigId,
customCondensingPrompt,
codebaseIndexModels: codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES,
Expand Down
141 changes: 141 additions & 0 deletions src/core/webview/__tests__/webviewMessageHandler-orgDefaults.spec.ts
Original file line number Diff line number Diff line change
@@ -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",
})
})
})
23 changes: 22 additions & 1 deletion src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
2 changes: 2 additions & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
OrganizationAllowList,
CloudUserInfo,
ShareVisibility,
ProviderName,
} from "@roo-code/types"

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

autoCondenseContext: boolean
autoCondenseContextPercent: number
Expand Down
21 changes: 19 additions & 2 deletions webview-ui/src/components/settings/ApiOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {}
Expand Down Expand Up @@ -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]) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential race condition here. When the provider changes, defaults are applied immediately in the same function, but the provider change might trigger other async operations (like fetching available models).

Consider using a useEffect hook to apply defaults after the provider change has been processed:

useEffect(() => {
  const orgDefaults = organizationDefaultProviderSettings?.[apiConfiguration.apiProvider];
  if (orgDefaults && isNewProfile) { // You'd need to track if this is a new profile
    // Apply defaults here
  }
}, [apiConfiguration.apiProvider, organizationDefaultProviderSettings]);

This would ensure defaults are applied at the right time in the component lifecycle.

// 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() === "")) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid using '!currentValue' to check if a field is empty. This check will override valid falsy values (like 0 or false). Instead, explicitly check for undefined or null (e.g., currentValue === undefined || currentValue === null || (typeof currentValue === 'string' && currentValue.trim() === '')).

Suggested change
if (!currentValue || (typeof currentValue === "string" && currentValue.trim() === "")) {
if (currentValue === undefined || currentValue === null || (typeof currentValue === "string" && currentValue.trim() === "")) {

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This falsy value check will incorrectly override valid falsy values. For example, if an organization sets temperature: 0 or a boolean setting to false, these valid values would be replaced with defaults.

Consider using a more explicit check:

if (currentValue === undefined || currentValue === null || (typeof currentValue === "string" && currentValue.trim() === "")) {

This issue was already raised by ellipsis-dev[bot] but hasn't been addressed yet.

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.
Expand Down Expand Up @@ -311,7 +328,7 @@ const ApiOptions = ({
)
}
},
[setApiConfigurationField, apiConfiguration],
[setApiConfigurationField, apiConfiguration, organizationDefaultProviderSettings],
)

const modelValidationError = useMemo(() => {
Expand Down
Loading