Skip to content
Open
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ export interface WebviewMessage {
| "currentApiConfigName"
| "saveApiConfiguration"
| "upsertApiConfiguration"
| "updateProfileModel"
| "deleteApiConfiguration"
| "loadApiConfiguration"
| "loadApiConfigurationById"
Expand Down
17 changes: 17 additions & 0 deletions src/core/config/ProviderSettingsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,23 @@
}
}

/**
* Remove the API config mapping for a specific mode.
*/
public async clearModeConfig(mode: Mode) {
try {

Check warning on line 530 in src/core/config/ProviderSettingsManager.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/config/ProviderSettingsManager.ts:530: NoCoverage BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
return await this.lock(async () => {

Check warning on line 531 in src/core/config/ProviderSettingsManager.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/config/ProviderSettingsManager.ts:531: NoCoverage BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
const providerProfiles = await this.load()
if (providerProfiles.modeApiConfigs && mode in providerProfiles.modeApiConfigs) {

Check warning on line 533 in src/core/config/ProviderSettingsManager.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/config/ProviderSettingsManager.ts:533: 4 mutation test gaps; example: NoCoverage ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
delete providerProfiles.modeApiConfigs[mode]
await this.store(providerProfiles)
}
})
} catch (error) {
throw new Error(`Failed to clear mode config: ${error}`)

Check warning on line 539 in src/core/config/ProviderSettingsManager.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/config/ProviderSettingsManager.ts:539: NoCoverage StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
}
}

/**
* Get the API config ID for a specific mode.
*/
Expand Down
243 changes: 200 additions & 43 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@
DEFAULT_MODES,
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
getModelId,
modelIdKeysByProvider,
isRetiredProvider,
providerIdentifiers,
PROVIDER_SETTINGS_KEYS,
} from "@roo-code/types"
import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock"
import { TaskRegistry } from "../task/TaskRegistry"
Expand Down Expand Up @@ -193,6 +195,13 @@
includeTaskHistory?: boolean
}

const RESET_ONLY_KEYS: readonly string[] = [

Check warning on line 198 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:198: Survived ArrayDeclaration mutant (replacement: []). See the job summary for the complete list and resolution guidance.
"awsCustomArn",

Check warning on line 199 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:199: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
"reasoningEffort",

Check warning on line 200 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:200: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
"modelMaxTokens",

Check warning on line 201 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:201: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
"modelMaxThinkingTokens",

Check warning on line 202 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:202: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
]

export class ClineProvider
extends EventEmitter<TaskProviderEvents>
implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike
Expand Down Expand Up @@ -1876,49 +1885,9 @@
activate: boolean = true,
): Promise<string | undefined> {
try {
return await this.enqueueProviderProfileMutation(async (signal) => {
// TODO: Do we need to be calling `activateProfile`? It's not
// clear to me what the source of truth should be; in some cases
// we rely on the `ContextProxy`'s data store and in other cases
// we rely on the `ProviderSettingsManager`'s data store. It might
// be simpler to unify these two.
const id = await this.providerSettingsManager.saveConfig(name, providerSettings)

if (signal.aborted) return id

if (activate) {
const { mode } = await this.getState()

// These promises do the following:
// 1. Adds or updates the list of provider profiles.
// 2. Sets the current provider profile.
// 3. Sets the current mode's provider profile.
// 4. Copies the provider settings to the context.
//
// Note: 1, 2, and 4 can be done in one `ContextProxy` call:
// this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... })
// We should probably switch to that and verify that it works.
// I left the original implementation in just to be safe.
await Promise.all([
this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
this.updateGlobalState("currentApiConfigName", name),
this.providerSettingsManager.setModeConfig(mode, id),
this.contextProxy.setProviderSettings(providerSettings),
])

// Change the provider for the current task.
// TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`).
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })

// Keep the current task's sticky provider profile in sync with the newly-activated profile.
await this.persistStickyProviderProfileToCurrentTask(name)
} else {
await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig())
}

await this.postStateToWebview()
return id
})
return await this.enqueueProviderProfileMutation((signal) =>
this.upsertProviderProfileUnlocked(name, providerSettings, activate, signal),
)
} catch (error) {
this.log(
`Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
Expand All @@ -1929,6 +1898,194 @@
}
}

private async upsertProviderProfileUnlocked(
name: string,
providerSettings: ProviderSettings,
activate: boolean,
signal: AbortSignal,
rollback?: { previous: ProviderSettings; isStillApplied: () => Promise<boolean> },
): Promise<string> {
// TODO: Do we need to be calling `activateProfile`? It's not
// clear to me what the source of truth should be; in some cases
// we rely on the `ContextProxy`'s data store and in other cases
// we rely on the `ProviderSettingsManager`'s data store. It might
// be simpler to unify these two.
const id = await this.providerSettingsManager.saveConfig(name, providerSettings)

// A timed-out mutation must leave neither a half-applied profile nor stale activation behind.
// The restore is skipped when a newer mutation has since changed the saved values, so a late
// rollback can never overwrite it.
const restore = async (): Promise<void> => {
if (rollback && (await rollback.isStillApplied())) {
await this.providerSettingsManager.saveConfig(name, rollback.previous)
}
}
const abandon = async (): Promise<string> => {
await restore()
return id
}

if (signal.aborted) return abandon()

Check warning on line 1928 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:1928: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.

if (activate) {
const { mode } = await this.getState()
const listApiConfigMeta = await this.providerSettingsManager.listConfig()

if (signal.aborted) return abandon()

// These promises do the following:
// 1. Adds or updates the list of provider profiles.
// 2. Sets the current provider profile.
// 3. Sets the current mode's provider profile.
// 4. Copies the provider settings to the context.
//
// Note: 1, 2, and 4 can be done in one `ContextProxy` call:
// this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... })
// We should probably switch to that and verify that it works.
// I left the original implementation in just to be safe.
const previousActivation = rollback && {
listApiConfigMeta: this.contextProxy.getValues().listApiConfigMeta,
currentApiConfigName: this.contextProxy.getValues().currentApiConfigName,
modeConfigId: await this.providerSettingsManager.getModeConfigId(mode),
Comment thread
daewoongoh marked this conversation as resolved.
}

if (signal.aborted) return abandon()

// allSettled so every started write has finished before the queue can advance.
const writes = await Promise.allSettled([
this.updateGlobalState("listApiConfigMeta", listApiConfigMeta),
this.updateGlobalState("currentApiConfigName", name),
this.providerSettingsManager.setModeConfig(mode, id),
this.contextProxy.setProviderSettings(providerSettings),
])
const failed = writes.find((write): write is PromiseRejectedResult => write.status === "rejected")

if (failed) {
// Each rollback step runs even if the other fails, and neither may mask the original error.
const logRollbackError = (error: unknown) =>
this.log(`Profile rollback failed: ${error instanceof Error ? error.message : String(error)}`)
await restore().catch(logRollbackError)
// A newer profile switch that already took over the activation must not be undone.
const ownsActivation = this.contextProxy.getValues().currentApiConfigName === name
if (rollback && previousActivation && ownsActivation) {
const {
listApiConfigMeta: prevMeta,
currentApiConfigName: prevName,
modeConfigId,
} = previousActivation
await Promise.allSettled([
this.contextProxy.setProviderSettings(rollback.previous),
this.updateGlobalState("listApiConfigMeta", prevMeta),
this.updateGlobalState("currentApiConfigName", prevName),
modeConfigId
? this.providerSettingsManager.setModeConfig(mode, modeConfigId)
: this.providerSettingsManager.clearModeConfig(mode),
]).then((results) =>
results.forEach((result) => result.status === "rejected" && logRollbackError(result.reason)),
)
}
throw failed.reason
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// The writes above are committed, so there is nothing to roll back; just skip the follow-up work.
if (signal.aborted) return id

// Change the provider for the current task.
// TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`).
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })

// Keep the current task's sticky provider profile in sync with the newly-activated profile.
await this.persistStickyProviderProfileToCurrentTask(name)
} else {
await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig())
}

await this.postStateToWebview()
return id
}

/**
* Applies a model selection to a stored profile. Everything runs inside one queued
* mutation so a selection made against a profile that has since been switched away
* from is dropped instead of saved and reactivated. A `null` patch value clears the field.
*/
async updateProfileModel(name: string, expectedProvider: string, patch: object): Promise<void> {
try {
await this.enqueueProviderProfileMutation(async (signal) => {
// Mirrors the profile name the webview is shown (see getStateToPostToWebview).
const task = this.getCurrentTask()
const { currentApiConfigName, organizationAllowList } = await this.getState()
const visibleProfileName = task ? task.taskApiConfigName : currentApiConfigName

if (visibleProfileName !== name) {
this.log(`Ignoring model update for profile '${name}': active profile is '${visibleProfileName}'`)
return
}

const { name: _name, id, ...stored } = await this.providerSettingsManager.getProfile({ name })

if (signal.aborted) return

// A profile without an explicit provider is treated as OpenRouter, matching the chat ModelSelector.
const storedProvider = stored.apiProvider ?? providerIdentifiers.openrouter

if (storedProvider !== expectedProvider) {
this.log(
`Ignoring model update for profile '${name}': provider is '${storedProvider}', expected '${expectedProvider}'`,
)
return
}

// Only model selection and its side-effect resets may be patched (see handleModelChangeSideEffects).
// The only model field is the stored provider's own, so e.g. the LM Studio draft model, which the
// allow-list does not check, cannot be changed.
const providerModelKey: string | undefined =
storedProvider === providerIdentifiers.openai
? "openAiModelId"
: modelIdKeysByProvider[storedProvider as keyof typeof modelIdKeysByProvider]
const allowedKeys: ReadonlySet<string> = new Set(
providerModelKey ? [providerModelKey, ...RESET_ONLY_KEYS] : RESET_ONLY_KEYS,
)
const merged: Record<string, unknown> = { ...stored, id }
for (const [key, value] of Object.entries(patch)) {
// The provider is never patchable, otherwise the expectedProvider guard could be bypassed.
if (key === "apiProvider" || !allowedKeys.has(key)) {
continue
}
if (value !== null && typeof value !== "string" && typeof value !== "number") {
continue
}
// These are only ever cleared when the model changes. Setting them would bypass the
// allow-list (the Bedrock ARN) or accept unvalidated token limits and reasoning effort.
if (RESET_ONLY_KEYS.includes(key) && value !== null && !(key === "awsCustomArn" && value === "")) {
continue
}
merged[key] = value === null ? undefined : value
}

// The webview filters models, but this message can be sent by anything; enforce the allow-list here.
if (!ProfileValidator.isProfileAllowed(merged as ProviderSettings, organizationAllowList)) {
this.log(`Ignoring model update for profile '${name}': violates the organization allow-list`)
vscode.window.showErrorMessage(t("common:errors.violated_organization_allowlist"))
return
}

const previous: Record<string, unknown> = { ...stored, id }
const isStillApplied = async (): Promise<boolean> => {
const current: Record<string, unknown> = await this.providerSettingsManager.getProfile({ name })
return Object.keys(merged).every((key) => key === "id" || current[key] === merged[key])
}
await this.upsertProviderProfileUnlocked(name, merged as ProviderSettings, true, signal, {
previous: previous as ProviderSettings,
isStillApplied,
})
})
} catch (error) {
this.log(`Error updating profile model: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
vscode.window.showErrorMessage(t("common:errors.save_api_config"))
}
}

async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) {
const globalSettings = this.contextProxy.getValues()
let profileToActivate: string | undefined = globalSettings.currentApiConfigName
Expand Down
Loading
Loading