Repository navigation
feat(chat): add inline model selector to chat composer #1953
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
59c5af3
57ff5d9
567369c
f5770c9
cae6900
f444d9a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,9 +55,17 @@ | |
| DEFAULT_MODES, | ||
| DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, | ||
| getModelId, | ||
| modelIdKeysByProvider, | ||
| isRetiredProvider, | ||
| providerIdentifiers, | ||
| } from "@roo-code/types" | ||
|
|
||
| const RESET_ONLY_KEYS: readonly string[] = [ | ||
|
Check warning on line 63 in src/core/webview/ClineProvider.ts
|
||
| "awsCustomArn", | ||
|
Check warning on line 64 in src/core/webview/ClineProvider.ts
|
||
| "reasoningEffort", | ||
|
Check warning on line 65 in src/core/webview/ClineProvider.ts
|
||
| "modelMaxTokens", | ||
|
Check warning on line 66 in src/core/webview/ClineProvider.ts
|
||
| "modelMaxThinkingTokens", | ||
|
Check warning on line 67 in src/core/webview/ClineProvider.ts
|
||
| ] | ||
| import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" | ||
| import { TaskRegistry } from "../task/TaskRegistry" | ||
| import { TaskScheduler } from "../task/TaskScheduler" | ||
|
|
@@ -251,19 +259,31 @@ | |
| private taskHistoryStoreInitialized = false | ||
| public static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds | ||
| private providerProfileMutationQueue = Promise.resolve() | ||
| private providerProfileMutationAbortController = new AbortController() | ||
| private historyTaskCreationQueue = Promise.resolve() | ||
|
|
||
| private runDelegationTransition<T>(parentTaskId: string, fn: () => Promise<T>): Promise<T> { | ||
| return runDelegationTransition(ClineProvider.delegationTransitionLocks, parentTaskId, fn) | ||
| } | ||
|
|
||
| private enqueueProviderProfileMutation<T>(fn: (signal: AbortSignal) => Promise<T>): Promise<T> { | ||
| if (this._disposed) { | ||
| return Promise.reject(new Error("ClineProvider is disposed")) | ||
|
Check warning on line 271 in src/core/webview/ClineProvider.ts
|
||
| } | ||
|
|
||
| const controller = new AbortController() | ||
| const onProviderDispose = () => controller.abort() | ||
| this.providerProfileMutationAbortController.signal.addEventListener("abort", onProviderDispose, { once: true }) | ||
|
|
||
| // Run fn after either outcome so a rejected mutation never poisons the queue. | ||
| const run = this.providerProfileMutationQueue.then( | ||
| () => fn(controller.signal), | ||
| () => fn(controller.signal), | ||
| ) | ||
| const run = this.providerProfileMutationQueue | ||
| .then( | ||
| () => fn(controller.signal), | ||
| () => fn(controller.signal), | ||
| ) | ||
| .finally(() => { | ||
| this.providerProfileMutationAbortController.signal.removeEventListener("abort", onProviderDispose) | ||
| }) | ||
| const callerResult = this.withProviderProfileMutationTimeout(run, () => { | ||
| controller.abort() | ||
| this.log("Provider profile mutation timed out; aborting in-flight mutation") | ||
|
|
@@ -286,9 +306,8 @@ | |
| }, | ||
| ) | ||
|
|
||
| // Advance from the timeout-bounded result. Each fn checks its AbortSignal before | ||
| // writing state, so advancing the queue on timeout cannot produce stale overwrites. | ||
| this.providerProfileMutationQueue = callerResult.then( | ||
| // Keep serialization in place until the timed-out mutation and its rollback settle. | ||
| this.providerProfileMutationQueue = run.then( | ||
| () => undefined, | ||
| () => undefined, | ||
| ) | ||
|
|
@@ -840,6 +859,7 @@ | |
|
|
||
| this._disposed = true | ||
| this._postStateToWebviewThrottled.cancel() | ||
| this.providerProfileMutationAbortController.abort() | ||
|
Check warning on line 862 in src/core/webview/ClineProvider.ts
|
||
| this.log("Disposing ClineProvider...") | ||
|
|
||
| // Reject any tasks still waiting for a scheduler permit so they don't | ||
|
|
@@ -1930,6 +1950,175 @@ | |
| } | ||
| } | ||
|
|
||
| private getOrganizationAllowListForProfileMutation() { | ||
| if (!CloudService.hasInstance()) { | ||
| return ORGANIZATION_ALLOW_ALL | ||
| } | ||
|
|
||
| try { | ||
| const cloudService = CloudService.instance | ||
| if (!cloudService.isAuthenticated()) { | ||
| return ORGANIZATION_ALLOW_ALL | ||
| } | ||
| return cloudService.getOrganizationSettings()?.allowList | ||
| } catch (error) { | ||
| this.log( | ||
| `Unable to read organization allow-list for model update: ${error instanceof Error ? error.message : String(error)}`, | ||
| ) | ||
| return undefined | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 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. | ||
| */ | ||
| async updateProfileModel(name: string, expectedProvider: string, patch: Record<string, unknown>): Promise<void> { | ||
| if (this._disposed) { | ||
| return | ||
| } | ||
|
|
||
| try { | ||
| await this.enqueueProviderProfileMutation(async (signal) => { | ||
| if (signal.aborted || this._disposed) return | ||
|
|
||
| // Mirrors the profile name the webview is shown (see getStateToPostToWebview). | ||
| const task = this.getCurrentTask() | ||
| const { currentApiConfigName, organizationAllowList: stateOrganizationAllowList } = | ||
| 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 || this._disposed) 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). | ||
| 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, apiProvider: storedProvider } | ||
| for (const [key, value] of Object.entries(patch)) { | ||
| // The provider is never patchable. | ||
| if (key === "apiProvider" || !allowedKeys.has(key)) { | ||
| continue | ||
| } | ||
| if (value !== null && typeof value !== "string" && typeof value !== "number") { | ||
| continue | ||
| } | ||
| if (RESET_ONLY_KEYS.includes(key) && value !== null && !(key === "awsCustomArn" && value === "")) { | ||
| continue | ||
| } | ||
| merged[key] = value === null ? undefined : value | ||
| } | ||
|
|
||
| const authoritativeOrganizationAllowList = this.getOrganizationAllowListForProfileMutation() | ||
| if (!authoritativeOrganizationAllowList) { | ||
| this.log(`Ignoring model update for profile '${name}': organization allow-list is unavailable`) | ||
| vscode.window.showErrorMessage(t("common:errors.violated_organization_allowlist")) | ||
| return | ||
| } | ||
| if ( | ||
| !ProfileValidator.isProfileAllowed(merged as ProviderSettings, authoritativeOrganizationAllowList) | ||
| ) { | ||
| this.log( | ||
| `Ignoring model update for profile '${name}': violates authoritative organization allow-list`, | ||
| ) | ||
| vscode.window.showErrorMessage(t("common:errors.violated_organization_allowlist")) | ||
| return | ||
| } | ||
| if ( | ||
| !stateOrganizationAllowList.allowAll && | ||
| !ProfileValidator.isProfileAllowed(merged as ProviderSettings, stateOrganizationAllowList) | ||
| ) { | ||
| this.log(`Ignoring model update for profile '${name}': violates state organization allow-list`) | ||
| vscode.window.showErrorMessage(t("common:errors.violated_organization_allowlist")) | ||
| return | ||
| } | ||
|
|
||
| let savedConfig = false | ||
| let shouldRollbackContext = false | ||
| const originalContextSettings: ProviderSettings = { ...stored, id } as ProviderSettings | ||
| try { | ||
| await this.providerSettingsManager.saveConfig(name, merged as ProviderSettings) | ||
| savedConfig = true | ||
|
|
||
| if (signal.aborted || this._disposed) { | ||
| throw new Error("Provider profile mutation aborted") | ||
| } | ||
|
|
||
| await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) | ||
| if (name === currentApiConfigName) { | ||
| shouldRollbackContext = true | ||
| await this.contextProxy.setProviderSettings(merged as ProviderSettings) | ||
| } | ||
|
|
||
| if (signal.aborted || this._disposed) { | ||
| throw new Error("Provider profile mutation aborted") | ||
| } | ||
| } catch (updateError) { | ||
| if (savedConfig) { | ||
| try { | ||
| await this.providerSettingsManager.saveConfig(name, originalContextSettings) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Protect rollback from saves made by another provider instance. Each 🤖 Prompt for AI Agents |
||
| await this.updateGlobalState( | ||
| "listApiConfigMeta", | ||
| await this.providerSettingsManager.listConfig(), | ||
| ) | ||
| } catch (rollbackError) { | ||
| this.log( | ||
| `Failed to rollback profile '${name}' after update failure: ${ | ||
| rollbackError instanceof Error ? rollbackError.message : String(rollbackError) | ||
| }`, | ||
| ) | ||
| } | ||
| } | ||
| if (shouldRollbackContext) { | ||
| try { | ||
| await this.contextProxy.setProviderSettings(originalContextSettings) | ||
| } catch (rollbackError) { | ||
| this.log( | ||
| `Failed to rollback context settings for '${name}' after update failure: ${ | ||
| rollbackError instanceof Error ? rollbackError.message : String(rollbackError) | ||
| }`, | ||
| ) | ||
| } | ||
| } | ||
| throw updateError | ||
| } | ||
|
|
||
| if (signal.aborted || this._disposed) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Recheck the current task before applying the saved model. This check covers disposal, but not a task change during the awaited save and context updates. A delegation can replace the current task without using the profile mutation queue. 🤖 Prompt for AI Agents |
||
| return | ||
| } | ||
|
|
||
| this.updateTaskApiHandlerIfNeeded(merged as ProviderSettings, { forceRebuild: true }) | ||
| await this.persistStickyProviderProfileToCurrentTask(name) | ||
| await this.postStateToWebview() | ||
| }) | ||
| } 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Prevent queued upserts from saving after disposal.
When disposal aborts a queued mutation,
enqueueProviderProfileMutationstill runs its callback.upsertProviderProfilecallssaveConfigbefore checking the signal. If an upsert waits behind another mutation when the provider is disposed, it can write a profile after disposal. Check cancellation before starting each queued callback, and keep the check before the upsert save. As per path instructions, “Check listeners, resources, and providers are disposed without stale state or duplicate work.”🤖 Prompt for AI Agents
Source: Path instructions