diff --git a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png index e8497070ef..3791a71175 100644 Binary files a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png and b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png differ diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index f83800774b..51671d895c 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -480,6 +480,7 @@ export interface WebviewMessage { | "currentApiConfigName" | "saveApiConfiguration" | "upsertApiConfiguration" + | "updateProfileModel" | "deleteApiConfiguration" | "loadApiConfiguration" | "loadApiConfigurationById" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index cdd0975c5d..3c68944687 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -56,6 +56,7 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, isRetiredProvider, + modelIdKeys, providerIdentifiers, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" @@ -191,6 +192,22 @@ function scheduleTask( }) } +// Keys a model-selector patch may touch: model IDs plus settings reset on model change. +const PROFILE_MODEL_ID_PATCH_KEYS: ReadonlySet = new Set(modelIdKeys) + +const BEDROCK_CUSTOM_ARN_MODEL_ID = "custom-arn" + +// These keys can only be cleared. Setting `awsCustomArn` would let a patch invoke a Bedrock +// resource that the organization allow-list never checked, because the allow-list sees `apiModelId`. +const PROFILE_MODEL_RESET_ONLY_KEYS: ReadonlySet = new Set([ + "reasoningEffort", + "modelMaxTokens", + "modelMaxThinkingTokens", + "awsCustomArn", + "awsCustomArnBaseModelId", + "awsModelContextWindow", +]) + type GetStateOptions = { includeTaskHistory?: boolean } @@ -258,7 +275,15 @@ export class ClineProvider return runDelegationTransition(ClineProvider.delegationTransitionLocks, parentTaskId, fn) } - private enqueueProviderProfileMutation(fn: (signal: AbortSignal) => Promise): Promise { + /** + * With `runToCompletion`, a timeout only rejects the caller: the mutation is not aborted and the + * queue advances once it settles. Use it for multi-write mutations that must not stop halfway + * (storage saved but not activated) or overlap with the next mutation. + */ + private enqueueProviderProfileMutation( + fn: (signal: AbortSignal) => Promise, + options: { runToCompletion?: boolean } = {}, + ): Promise { const controller = new AbortController() // Run fn after either outcome so a rejected mutation never poisons the queue. const run = this.providerProfileMutationQueue.then( @@ -266,6 +291,11 @@ export class ClineProvider () => fn(controller.signal), ) const callerResult = this.withProviderProfileMutationTimeout(run, () => { + if (options.runToCompletion) { + this.log("Provider profile mutation timed out; waiting for it to finish before the next mutation") + return + } + controller.abort() this.log("Provider profile mutation timed out; aborting in-flight mutation") }) @@ -289,7 +319,7 @@ export class ClineProvider // 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( + this.providerProfileMutationQueue = (options.runToCompletion ? run : callerResult).then( () => undefined, () => undefined, ) @@ -1859,49 +1889,10 @@ export class ClineProvider activate: boolean = true, ): Promise { 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.persistProviderProfile(name, providerSettings, activate, signal), + { runToCompletion: true }, + ) } catch (error) { this.log( `Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, @@ -1912,6 +1903,167 @@ export class ClineProvider } } + /** + * Applies a model change to the latest stored profile inside the mutation queue, + * so a stale webview snapshot cannot overwrite newer profile settings. + * A `null` patch value clears the key. + */ + async updateProfileModel(name: string, expectedProvider: string, patch: Record): Promise { + let partialState = false + + try { + await this.enqueueProviderProfileMutation( + async (signal) => { + const { name: _name, ...stored } = await this.providerSettingsManager.getProfile({ name }) + + // Unset provider (e.g. the seeded default profile) resolves to anthropic, matching getState(). + const storedProvider = stored.apiProvider ?? providerIdentifiers.anthropic + + if (storedProvider !== expectedProvider) { + throw new Error( + `Profile "${name}" provider changed from "${expectedProvider}" to "${storedProvider}"`, + ) + } + + const merged: Record = { ...stored, apiProvider: storedProvider } + + for (const [key, value] of Object.entries(patch)) { + if (PROFILE_MODEL_ID_PATCH_KEYS.has(key)) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Key "${key}" must be a non-empty string in a model patch`) + } + + merged[key] = value + } else if (PROFILE_MODEL_RESET_ONLY_KEYS.has(key)) { + if (value !== null && value !== undefined) { + throw new Error(`Key "${key}" can only be cleared in a model patch`) + } + + delete merged[key] + } else { + throw new Error(`Key "${key}" is not allowed in a model patch`) + } + } + + if (storedProvider === providerIdentifiers.bedrock) { + if (merged.apiModelId === BEDROCK_CUSTOM_ARN_MODEL_ID) { + // The allow-list only sees apiModelId, so a patch must not select an ARN it cannot supply. + if (!merged.awsCustomArn) { + throw new Error( + `Model "${BEDROCK_CUSTOM_ARN_MODEL_ID}" requires an existing custom ARN`, + ) + } + } else { + // A retained ARN would override the allow-listed model, so drop it with its context fields. + for (const key of PROFILE_MODEL_RESET_ONLY_KEYS) { + if (key.startsWith("aws")) delete merged[key] + } + } + } + + const { organizationAllowList } = await this.getState() + + if (!ProfileValidator.isProfileAllowed(merged as ProviderSettings, organizationAllowList)) { + throw new OrganizationAllowListViolationError( + t("common:errors.violated_organization_allowlist"), + ) + } + + if (signal.aborted) return + + try { + await this.persistProviderProfile(name, merged as ProviderSettings, true, signal) + } catch (persistError) { + // Saving and activating are separate writes. Put the previous profile back so + // storage, context settings, and the task do not disagree about the model. + try { + await this.persistProviderProfile(name, stored as ProviderSettings, true, signal) + } catch (restoreError) { + partialState = true + this.log( + `Error restoring profile after failed model update: ${JSON.stringify(restoreError, Object.getOwnPropertyNames(restoreError), 2)}`, + ) + } + + throw persistError + } + }, + { runToCompletion: true }, + ) + } catch (error) { + this.log(`Error update profile model: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + + vscode.window.showErrorMessage( + partialState + ? t("common:errors.update_profile_model_partial") + : error instanceof OrganizationAllowListViolationError + ? error.message + : t("common:errors.create_api_config"), + ) + } + } + + private async persistProviderProfile( + name: string, + providerSettings: ProviderSettings, + activate: boolean, + signal: AbortSignal, + ): Promise { + // 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() + + if (signal.aborted) return id + + // 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. + // `allSettled` so a caller that rolls back after a failure never races a write still in flight. + const activationResults = await Promise.allSettled([ + this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + this.updateGlobalState("currentApiConfigName", name), + this.providerSettingsManager.setModeConfig(mode, id), + this.contextProxy.setProviderSettings(providerSettings), + ]) + const failedActivation = activationResults.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ) + + if (failedActivation) throw failedActivation.reason + + 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()) + } + + if (signal.aborted) return id + + await this.postStateToWebview() + return id + } + async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) { const globalSettings = this.contextProxy.getValues() let profileToActivate: string | undefined = globalSettings.currentApiConfigName diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 4561d688fe..3121fe1046 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -428,6 +428,397 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { }) }) + describe("updateProfileModel", () => { + const storedProfile = { + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + rateLimitSeconds: 5, + reasoningEffort: "high", + } + + beforeEach(() => { + provider["providerSettingsManager"].getProfile = vi.fn().mockResolvedValue(storedProfile) + vi.mocked(vscode.window.showErrorMessage).mockClear() + }) + + test("merges the patch into the latest stored profile, keeping newer settings and clearing null keys", async () => { + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "anthropic/claude", + reasoningEffort: null, + }) + + const saveConfig = vi.mocked(provider["providerSettingsManager"].saveConfig) + expect(saveConfig).toHaveBeenCalledTimes(1) + const [name, saved] = saveConfig.mock.calls[0] + expect(name).toBe("test-config") + expect(saved).toMatchObject({ + id: "test-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "anthropic/claude", + rateLimitSeconds: 5, + }) + expect(saved).not.toHaveProperty("reasoningEffort") + expect(saved).not.toHaveProperty("name") + }) + + test.each([ + ["reasoningEffort", "high"], + ["modelMaxTokens", 4096], + ["modelMaxThinkingTokens", 2048], + ["awsCustomArn", "arn:aws:bedrock:us-east-1:123456789012:inference-profile/old"], + ["awsCustomArnBaseModelId", "anthropic.claude-3-sonnet-20240229-v1:0"], + ["awsModelContextWindow", 200000], + ])("clears the reset-only key %s when the patch sets it to null", async (key, storedValue) => { + provider["providerSettingsManager"].getProfile = vi + .fn() + .mockResolvedValue({ ...storedProfile, [key]: storedValue }) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "anthropic/claude", + [key]: null, + }) + + const saveConfig = vi.mocked(provider["providerSettingsManager"].saveConfig) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + expect(saveConfig).toHaveBeenCalledTimes(1) + const saved = saveConfig.mock.calls[0][1] + expect(saved).toMatchObject({ openRouterModelId: "anthropic/claude" }) + expect(saved).not.toHaveProperty(key) + }) + + test("rebuilds the current task API handler with the new model", async () => { + const mockTask = new Task({ ...defaultTaskOptions }) + mockTask.api = { + getModel: vi.fn().mockReturnValue({ id: "openai/gpt-4", info: { contextWindow: 128000 } }), + } as unknown as typeof mockTask.api + await provider.addClineToStack(mockTask) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "anthropic/claude", + }) + + expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ openRouterModelId: "anthropic/claude" }), + ) + }) + + test("rejects the update when the stored provider differs from the expected provider", async () => { + await provider.updateProfileModel("test-config", providerIdentifiers.anthropic, { + apiModelId: "claude-sonnet-4-5", + }) + + expect(provider["providerSettingsManager"].saveConfig).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + + test("treats a stored profile without apiProvider as anthropic and persists that provider", async () => { + const { apiProvider: _apiProvider, ...unsetProfile } = storedProfile + provider["providerSettingsManager"].getProfile = vi.fn().mockResolvedValue(unsetProfile) + + await provider.updateProfileModel("test-config", providerIdentifiers.anthropic, { + apiModelId: "claude-sonnet-4-5", + }) + + const saveConfig = vi.mocked(provider["providerSettingsManager"].saveConfig) + expect(saveConfig).toHaveBeenCalledTimes(1) + expect(saveConfig.mock.calls[0][1]).toMatchObject({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-sonnet-4-5", + }) + }) + + test("rejects patch keys outside the model and reset allow-list", async () => { + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "anthropic/claude", + openRouterApiKey: "stolen", + }) + + expect(provider["providerSettingsManager"].saveConfig).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + + test("only allows reset keys to be cleared, so a patch cannot set a Bedrock custom ARN", async () => { + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "anthropic/claude", + awsCustomArn: "arn:aws:bedrock:us-east-1:123456789012:inference-profile/other-model", + }) + + expect(provider["providerSettingsManager"].saveConfig).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + + describe("Bedrock custom ARN invariant", () => { + const arn = "arn:aws:bedrock:us-east-1:123456789012:inference-profile/unvetted" + const bedrockWithArn = { + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.bedrock, + apiModelId: "custom-arn", + awsCustomArn: arn, + awsCustomArnBaseModelId: "anthropic.claude-3-sonnet-20240229-v1:0", + awsModelContextWindow: 200000, + awsRegion: "us-east-1", + } + + beforeEach(() => { + provider["providerSettingsManager"].getProfile = vi.fn().mockResolvedValue(bedrockWithArn) + }) + + test("drops the retained ARN and its context fields when the patch omits them", async () => { + await provider.updateProfileModel("test-config", providerIdentifiers.bedrock, { + apiModelId: "anthropic.claude-3-haiku-20240307-v1:0", + }) + + const saveConfig = vi.mocked(provider["providerSettingsManager"].saveConfig) + expect(saveConfig).toHaveBeenCalledTimes(1) + const saved = saveConfig.mock.calls[0][1] + expect(saved).toMatchObject({ + apiModelId: "anthropic.claude-3-haiku-20240307-v1:0", + awsRegion: "us-east-1", + }) + expect(saved).not.toHaveProperty("awsCustomArn") + expect(saved).not.toHaveProperty("awsCustomArnBaseModelId") + expect(saved).not.toHaveProperty("awsModelContextWindow") + }) + + test("keeps the ARN while custom-arn stays selected", async () => { + await provider.updateProfileModel("test-config", providerIdentifiers.bedrock, { + apiModelId: "custom-arn", + }) + + expect(vi.mocked(provider["providerSettingsManager"].saveConfig).mock.calls[0][1]).toMatchObject({ + apiModelId: "custom-arn", + awsCustomArn: arn, + }) + }) + + test("rejects selecting custom-arn when the profile has no ARN", async () => { + provider["providerSettingsManager"].getProfile = vi.fn().mockResolvedValue({ + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.bedrock, + apiModelId: "anthropic.claude-3-haiku-20240307-v1:0", + }) + + await provider.updateProfileModel("test-config", providerIdentifiers.bedrock, { + apiModelId: "custom-arn", + }) + + expect(provider["providerSettingsManager"].saveConfig).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + }) + + test("rejects an empty or non-string model id", async () => { + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { openRouterModelId: "" }) + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { openRouterModelId: 42 }) + + expect(provider["providerSettingsManager"].saveConfig).not.toHaveBeenCalled() + }) + + test("restores the previous profile when activation fails after the new profile was saved", async () => { + const manager = provider["providerSettingsManager"] + vi.mocked(manager.setModeConfig).mockRejectedValueOnce(new Error("activation failed")) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "anthropic/claude", + }) + + const saved = vi.mocked(manager.saveConfig).mock.calls.map(([, config]) => config.openRouterModelId) + expect(saved).toEqual(["anthropic/claude", "openai/gpt-4"]) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.create_api_config") + }) + + test("reports a partial state when the previous profile cannot be restored", async () => { + const manager = provider["providerSettingsManager"] + vi.mocked(manager.setModeConfig).mockRejectedValue(new Error("activation failed")) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "anthropic/claude", + }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.update_profile_model_partial") + }) + + test("finishes activation after the caller timed out instead of leaving a saved but inactive profile", async () => { + vi.useFakeTimers() + + try { + const realGetState = provider.getState.bind(provider) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let calls = 0 + vi.spyOn(provider, "getState").mockImplementation(async () => { + // 1st call: allow-list check. 2nd call: activation inside persistProviderProfile. + if (++calls === 2) await gate + return realGetState() + }) + + const update = provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "anthropic/claude", + }) + + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS + 1) + await update + expect(provider["providerSettingsManager"].setModeConfig).not.toHaveBeenCalled() + + release() + await vi.advanceTimersByTimeAsync(0) + + const manager = provider["providerSettingsManager"] + expect(manager.setModeConfig).toHaveBeenCalledTimes(1) + expect(vi.mocked(manager.saveConfig).mock.calls.map(([, config]) => config.openRouterModelId)).toEqual([ + "anthropic/claude", + ]) + } finally { + vi.useRealTimers() + } + }) + + test("upsertProviderProfile finishes activation after the caller timed out", async () => { + vi.useFakeTimers() + + try { + const realGetState = provider.getState.bind(provider) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let calls = 0 + vi.spyOn(provider, "getState").mockImplementation(async () => { + // 1st call: activation inside persistProviderProfile. + if (++calls === 1) await gate + return realGetState() + }) + + const upsert = provider.upsertProviderProfile( + "test-config", + { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "anthropic/claude" }, + true, + ) + + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS + 1) + await expect(upsert).resolves.toBeUndefined() + + const manager = provider["providerSettingsManager"] + expect(manager.setModeConfig).not.toHaveBeenCalled() + + release() + await vi.advanceTimersByTimeAsync(0) + + expect(manager.saveConfig).toHaveBeenCalledTimes(1) + expect(manager.setModeConfig).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + test("holds the next profile mutation until a timed-out model update settles", async () => { + vi.useFakeTimers() + + try { + const realGetState = provider.getState.bind(provider) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let calls = 0 + vi.spyOn(provider, "getState").mockImplementation(async () => { + if (++calls === 2) await gate + return realGetState() + }) + const manager = provider["providerSettingsManager"] + manager.activateProfile = vi.fn().mockResolvedValue({ + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4.1-mini", + }) + + const update = provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "anthropic/claude", + }) + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS + 1) + await update + + const next = provider.activateProviderProfile({ name: "other-profile" }) + await vi.advanceTimersByTimeAsync(0) + expect(manager.activateProfile).not.toHaveBeenCalled() + + release() + await vi.advanceTimersByTimeAsync(0) + await next + + expect(manager.activateProfile).toHaveBeenCalledTimes(1) + // One write set from the model update, one from the queued activation. + expect(manager.setModeConfig).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + test("waits for every activation write before restoring the previous profile", async () => { + const manager = provider["providerSettingsManager"] + const events: string[] = [] + let releaseContext!: () => void + const contextWrite = new Promise((resolve) => { + releaseContext = resolve + }) + vi.mocked(manager.setModeConfig).mockImplementationOnce(async () => { + events.push("activation:failed") + throw new Error("activation failed") + }) + vi.spyOn(provider.contextProxy, "setProviderSettings").mockImplementationOnce(async () => { + await contextWrite + events.push("context:written") + }) + vi.mocked(manager.saveConfig).mockImplementation(async (_name, config) => { + events.push(`save:${config.openRouterModelId}`) + return "test-id" + }) + + const update = provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "anthropic/claude", + }) + await vi.waitFor(() => expect(events).toContain("activation:failed")) + expect(events).not.toContain("save:openai/gpt-4") + + releaseContext() + await update + + expect(events).toEqual([ + "save:anthropic/claude", + "activation:failed", + "context:written", + "save:openai/gpt-4", + ]) + }) + + test("rejects a model that the organization allow-list forbids", async () => { + const realGetState = provider.getState.bind(provider) + vi.spyOn(provider, "getState").mockImplementation(async () => ({ + ...(await realGetState()), + organizationAllowList: { + allowAll: false, + providers: { + [providerIdentifiers.openrouter]: { allowAll: false, models: ["openai/gpt-4"] }, + }, + }, + })) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "anthropic/claude", + }) + + expect(provider["providerSettingsManager"].saveConfig).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + }) + describe("activateProviderProfile", () => { test("serializes provider profile mutations without interleaving", async () => { const events: string[] = [] diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 99050b26c7..9283a61172 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -2962,3 +2962,44 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() }) }) + +describe("updateProfileModel", () => { + let updateProfileModel: Mock + + beforeEach(() => { + vi.clearAllMocks() + updateProfileModel = vi.fn().mockResolvedValue(undefined) + mockClineProvider.updateProfileModel = updateProfileModel + }) + + it("delegates a valid message to the provider", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateProfileModel", + text: "my-profile", + values: { + expectedProvider: providerIdentifiers.openrouter, + patch: { openRouterModelId: "a/b", reasoningEffort: null }, + }, + }) + + expect(updateProfileModel).toHaveBeenCalledWith("my-profile", providerIdentifiers.openrouter, { + openRouterModelId: "a/b", + reasoningEffort: null, + }) + }) + + it.each([ + ["missing profile name", { values: { expectedProvider: providerIdentifiers.openrouter, patch: {} } }], + ["missing expectedProvider", { text: "p", values: { patch: {} } }], + ["non-object patch", { text: "p", values: { expectedProvider: providerIdentifiers.openrouter, patch: "x" } }], + ["array patch", { text: "p", values: { expectedProvider: providerIdentifiers.openrouter, patch: [] } }], + ["missing values", { text: "p" }], + ])("ignores a malformed message: %s", async (_label, partial) => { + await webviewMessageHandler(mockClineProvider, { + type: "updateProfileModel", + ...partial, + } as unknown as Parameters[1]) + + expect(updateProfileModel).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5d10588feb..be2f107bd5 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2408,6 +2408,20 @@ export const webviewMessageHandler = async ( await provider.upsertProviderProfile(message.text, message.apiConfiguration) } break + case "updateProfileModel": { + const { expectedProvider, patch } = message.values ?? {} + + if ( + message.text && + typeof expectedProvider === "string" && + patch && + typeof patch === "object" && + !Array.isArray(patch) + ) { + await provider.updateProfileModel(message.text, expectedProvider, patch) + } + break + } case "renameApiConfiguration": if (message.values && message.apiConfiguration) { try { diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index a85c19cec4..579cd6b7d7 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -48,6 +48,7 @@ "search_commits": "Ha fallat la cerca de commits", "save_api_config": "Ha fallat el desament de la configuració de l'API", "create_api_config": "Ha fallat la creació de la configuració de l'API", + "update_profile_model_partial": "No s'ha pogut actualitzar el model ni restaurar la configuració d'API anterior. Comprova la configuració d'API a Configuració.", "rename_api_config": "Ha fallat el canvi de nom de la configuració de l'API", "load_api_config": "Ha fallat la càrrega de la configuració de l'API", "delete_api_config": "Ha fallat l'eliminació de la configuració de l'API", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 9f2b6d2056..3d8175a77e 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -44,6 +44,7 @@ "search_commits": "Fehler beim Suchen von Commits", "save_api_config": "Fehler beim Speichern der API-Konfiguration", "create_api_config": "Fehler beim Erstellen der API-Konfiguration", + "update_profile_model_partial": "Das Modell konnte nicht aktualisiert und die vorherige API-Konfiguration nicht wiederhergestellt werden. Überprüfe deine API-Konfiguration in den Einstellungen.", "rename_api_config": "Fehler beim Umbenennen der API-Konfiguration", "load_api_config": "Fehler beim Laden der API-Konfiguration", "delete_api_config": "Fehler beim Löschen der API-Konfiguration", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 507780366a..a7d616028f 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -44,6 +44,7 @@ "search_commits": "Failed to search commits", "save_api_config": "Failed to save api configuration", "create_api_config": "Failed to create api configuration", + "update_profile_model_partial": "Failed to update the model and could not restore the previous API configuration. Check your API configuration in Settings.", "rename_api_config": "Failed to rename api configuration", "load_api_config": "Failed to load api configuration", "delete_api_config": "Failed to delete api configuration", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index f646f5c4f3..2eb6848f11 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -44,6 +44,7 @@ "search_commits": "Error al buscar commits", "save_api_config": "Error al guardar la configuración de API", "create_api_config": "Error al crear la configuración de API", + "update_profile_model_partial": "No se pudo actualizar el modelo ni restaurar la configuración de API anterior. Revisa tu configuración de API en Ajustes.", "rename_api_config": "Error al renombrar la configuración de API", "load_api_config": "Error al cargar la configuración de API", "delete_api_config": "Error al eliminar la configuración de API", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 0e5f59ca1e..b6b65a88de 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -44,6 +44,7 @@ "search_commits": "Erreur lors de la recherche des commits", "save_api_config": "Erreur lors de l'enregistrement de la configuration API", "create_api_config": "Erreur lors de la création de la configuration API", + "update_profile_model_partial": "Impossible de mettre à jour le modèle et de restaurer la configuration d'API précédente. Vérifie ta configuration d'API dans les paramètres.", "rename_api_config": "Erreur lors du renommage de la configuration API", "load_api_config": "Erreur lors du chargement de la configuration API", "delete_api_config": "Erreur lors de la suppression de la configuration API", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 59cf0f0657..f69b5adccf 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -44,6 +44,7 @@ "search_commits": "कमिट्स खोजने में विफल", "save_api_config": "API कॉन्फ़िगरेशन सहेजने में विफल", "create_api_config": "API कॉन्फ़िगरेशन बनाने में विफल", + "update_profile_model_partial": "मॉडल अपडेट नहीं हो सका और पिछला API कॉन्फ़िगरेशन भी बहाल नहीं हो सका। सेटिंग्स में अपना API कॉन्फ़िगरेशन जाँचें।", "rename_api_config": "API कॉन्फ़िगरेशन का नाम बदलने में विफल", "load_api_config": "API कॉन्फ़िगरेशन लोड करने में विफल", "delete_api_config": "API कॉन्फ़िगरेशन हटाने में विफल", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index 87ce78d209..e401612bf3 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -44,6 +44,7 @@ "search_commits": "Gagal mencari commit", "save_api_config": "Gagal menyimpan konfigurasi api", "create_api_config": "Gagal membuat konfigurasi api", + "update_profile_model_partial": "Gagal memperbarui model dan tidak dapat memulihkan konfigurasi API sebelumnya. Periksa konfigurasi API Anda di Pengaturan.", "rename_api_config": "Gagal mengganti nama konfigurasi api", "load_api_config": "Gagal memuat konfigurasi api", "delete_api_config": "Gagal menghapus konfigurasi api", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index ce7ffce090..aeb984940a 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -44,6 +44,7 @@ "search_commits": "Errore durante la ricerca dei commit", "save_api_config": "Errore durante il salvataggio della configurazione API", "create_api_config": "Errore durante la creazione della configurazione API", + "update_profile_model_partial": "Impossibile aggiornare il modello e ripristinare la configurazione API precedente. Controlla la configurazione API nelle impostazioni.", "rename_api_config": "Errore durante la ridenominazione della configurazione API", "load_api_config": "Errore durante il caricamento della configurazione API", "delete_api_config": "Errore durante l'eliminazione della configurazione API", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index d412e8f8e0..a7ec0c4de3 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -44,6 +44,7 @@ "search_commits": "コミットの検索に失敗しました", "save_api_config": "API設定の保存に失敗しました", "create_api_config": "API設定の作成に失敗しました", + "update_profile_model_partial": "モデルを更新できず、以前の API 設定も復元できませんでした。設定で API 設定を確認してください。", "rename_api_config": "API設定の名前変更に失敗しました", "load_api_config": "API設定の読み込みに失敗しました", "delete_api_config": "API設定の削除に失敗しました", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index ff2fcb10d9..906652ac14 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -44,6 +44,7 @@ "search_commits": "커밋 검색에 실패했습니다", "save_api_config": "API 구성 저장에 실패했습니다", "create_api_config": "API 구성 생성에 실패했습니다", + "update_profile_model_partial": "모델을 업데이트하지 못했고 이전 API 구성도 복원하지 못했습니다. 설정에서 API 구성을 확인하세요.", "rename_api_config": "API 구성 이름 변경에 실패했습니다", "load_api_config": "API 구성 로드에 실패했습니다", "delete_api_config": "API 구성 삭제에 실패했습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index e8b639af5d..34cdbbbe89 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -44,6 +44,7 @@ "search_commits": "Zoeken naar commits mislukt", "save_api_config": "Opslaan van API-configuratie mislukt", "create_api_config": "Aanmaken van API-configuratie mislukt", + "update_profile_model_partial": "Het model kon niet worden bijgewerkt en de vorige API-configuratie kon niet worden hersteld. Controleer je API-configuratie in de instellingen.", "rename_api_config": "Hernoemen van API-configuratie mislukt", "load_api_config": "Laden van API-configuratie mislukt", "delete_api_config": "Verwijderen van API-configuratie mislukt", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 73e2293a10..2a743fc009 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -44,6 +44,7 @@ "search_commits": "Nie udało się wyszukać commitów", "save_api_config": "Nie udało się zapisać konfiguracji API", "create_api_config": "Nie udało się utworzyć konfiguracji API", + "update_profile_model_partial": "Nie udało się zaktualizować modelu ani przywrócić poprzedniej konfiguracji API. Sprawdź konfigurację API w ustawieniach.", "rename_api_config": "Nie udało się zmienić nazwy konfiguracji API", "load_api_config": "Nie udało się załadować konfiguracji API", "delete_api_config": "Nie udało się usunąć konfiguracji API", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 82654929b4..eaf1cae06b 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -48,6 +48,7 @@ "search_commits": "Falha ao pesquisar commits", "save_api_config": "Falha ao salvar a configuração da API", "create_api_config": "Falha ao criar a configuração da API", + "update_profile_model_partial": "Não foi possível atualizar o modelo nem restaurar a configuração de API anterior. Verifique sua configuração de API nas configurações.", "rename_api_config": "Falha ao renomear a configuração da API", "load_api_config": "Falha ao carregar a configuração da API", "delete_api_config": "Falha ao excluir a configuração da API", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 5857b1290f..6f48365ec1 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -44,6 +44,7 @@ "search_commits": "Не удалось выполнить поиск коммитов", "save_api_config": "Не удалось сохранить конфигурацию API", "create_api_config": "Не удалось создать конфигурацию API", + "update_profile_model_partial": "Не удалось обновить модель и восстановить предыдущую конфигурацию API. Проверьте конфигурацию API в настройках.", "rename_api_config": "Не удалось переименовать конфигурацию API", "load_api_config": "Не удалось загрузить конфигурацию API", "delete_api_config": "Не удалось удалить конфигурацию API", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 48faf7d03c..b5c543c405 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -44,6 +44,7 @@ "search_commits": "Taahhütler aranamadı", "save_api_config": "API yapılandırması kaydedilemedi", "create_api_config": "API yapılandırması oluşturulamadı", + "update_profile_model_partial": "Model güncellenemedi ve önceki API yapılandırması geri yüklenemedi. Ayarlar'da API yapılandırmanı kontrol et.", "rename_api_config": "API yapılandırmasının adı değiştirilemedi", "load_api_config": "API yapılandırması yüklenemedi", "delete_api_config": "API yapılandırması silinemedi", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 5651e0eb3b..cb2ed8e9b8 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -44,6 +44,7 @@ "search_commits": "Không thể tìm kiếm các commit", "save_api_config": "Không thể lưu cấu hình API", "create_api_config": "Không thể tạo cấu hình API", + "update_profile_model_partial": "Không thể cập nhật mô hình và không thể khôi phục cấu hình API trước đó. Hãy kiểm tra cấu hình API trong Cài đặt.", "rename_api_config": "Không thể đổi tên cấu hình API", "load_api_config": "Không thể tải cấu hình API", "delete_api_config": "Không thể xóa cấu hình API", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index e5ecf86da2..08cfe736b5 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -49,6 +49,7 @@ "search_commits": "搜索提交失败", "save_api_config": "保存API配置失败", "create_api_config": "创建API配置失败", + "update_profile_model_partial": "无法更新模型,也无法恢复之前的 API 配置。请在设置中检查 API 配置。", "rename_api_config": "重命名API配置失败", "load_api_config": "加载API配置失败", "delete_api_config": "删除API配置失败", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index e70c464da0..92fdbaeb96 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -44,6 +44,7 @@ "search_commits": "搜尋提交失敗", "save_api_config": "儲存 API 設定失敗", "create_api_config": "建立 API 設定失敗", + "update_profile_model_partial": "無法更新模型,也無法還原先前的 API 設定。請在設定中檢查 API 設定。", "rename_api_config": "重新命名 API 設定失敗", "load_api_config": "載入 API 設定失敗", "delete_api_config": "刪除 API 設定失敗", diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index a78f3f0f8e..61419487cc 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -27,6 +27,7 @@ import { StandardTooltip } from "@src/components/ui" import Thumbnails from "../common/Thumbnails" import { ModeSelector } from "./ModeSelector" import { ApiConfigSelector } from "./ApiConfigSelector" +import { ModelSelector } from "./ModelSelector" import { AutoApproveDropdown } from "./AutoApproveDropdown" import { MAX_IMAGES_PER_MESSAGE } from "./constants" import ContextMenu from "./ContextMenu" @@ -92,6 +93,7 @@ export const ChatTextArea = forwardRef( const { filePaths, openedTabs, + apiConfiguration, currentApiConfigName, listApiConfigMeta, customModes, @@ -1319,6 +1321,12 @@ export const ChatTextArea = forwardRef( lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} onToggleLockApiConfig={handleToggleLockApiConfig} /> +
diff --git a/webview-ui/src/components/chat/ModelSelector.tsx b/webview-ui/src/components/chat/ModelSelector.tsx new file mode 100644 index 0000000000..a42e4c9d2a --- /dev/null +++ b/webview-ui/src/components/chat/ModelSelector.tsx @@ -0,0 +1,320 @@ +import React from "react" +import { Fzf } from "fzf" +import { Check, X } from "lucide-react" + +import { + type ProviderSettings, + type ProviderName, + type ModelInfo, + providerIdentifiers, + isRetiredProvider, +} from "@roo-code/types" + +import { cn } from "@/lib/utils" +import { enabledSelectorTriggerClassName, selectorTriggerClassName } from "@/components/ui/selectorTriggerStyles" +import { useRooPortal } from "@/components/ui/hooks/useRooPortal" +import { useRouterModels } from "@/components/ui/hooks/useRouterModels" +import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" +import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { vscode } from "@/utils/vscode" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { + isStaticModelProvider, + getStaticModelsForProvider, + getProviderModelConfig, + handleModelChangeSideEffects, +} from "@/components/settings/utils/providerModelConfig" +import { filterModels } from "@/components/settings/utils/organizationFilters" + +import { IconButton } from "./IconButton" + +const SEARCH_THRESHOLD = 6 + +const BEDROCK_CUSTOM_ARN_MODEL_ID = "custom-arn" + +// Providers whose model can only be picked via a bespoke Settings UI +// (freeform text, local model discovery, or a compound vendor/family selector) +// rather than a simple id list. +const UNSUPPORTED_QUICK_PICK_PROVIDERS: ProviderName[] = [ + providerIdentifiers.openai, + providerIdentifiers.ollama, + providerIdentifiers.lmstudio, + providerIdentifiers.vscodeLm, +] + +function getModelIdField(provider: ProviderName): string | undefined { + return getProviderModelConfig(provider)?.field +} + +function useModelListForProvider(provider: ProviderName | undefined, apiConfiguration?: ProviderSettings) { + const isStatic = provider ? isStaticModelProvider(provider) : false + const field = provider ? getModelIdField(provider) : undefined + const isUnsupported = !provider || !field || UNSUPPORTED_QUICK_PICK_PROVIDERS.includes(provider) + const isRouterProvider = !isStatic && !isUnsupported + + const routerModels = useRouterModels({ provider, enabled: isRouterProvider }) + const routerModelsData = routerModels.data as Record> | undefined + const isLoading = isRouterProvider && routerModels.isLoading + + return React.useMemo(() => { + if (!provider || !field || isUnsupported) { + return { isUnsupported: true as const, field: undefined, models: null, isLoading: false } + } + + const models = isStatic + ? getStaticModelsForProvider(provider, undefined, apiConfiguration) + : (routerModelsData?.[provider] ?? null) + + return { isUnsupported: false as const, field, models, isLoading } + }, [provider, field, isUnsupported, isStatic, apiConfiguration, routerModelsData, isLoading]) +} + +interface ModelSelectorProps { + apiConfiguration?: ProviderSettings + disabled?: boolean + title: string + triggerClassName?: string +} + +export const ModelSelector = ({ + apiConfiguration, + disabled = false, + title, + triggerClassName = "", +}: ModelSelectorProps) => { + const [open, setOpen] = React.useState(false) + const [searchValue, setSearchValue] = React.useState("") + const searchInputRef = React.useRef(null) + const portalContainer = useRooPortal("roo-portal") + const { t } = useAppTranslation() + const { organizationAllowList, currentApiConfigName } = useExtensionState() + + const providerRaw = apiConfiguration?.apiProvider || providerIdentifiers.openrouter + const provider: ProviderName | undefined = isRetiredProvider(providerRaw) ? undefined : providerRaw + + const { id: selectedModelId, info: selectedModelInfo } = useSelectedModel(apiConfiguration) + const { isUnsupported, field, models, isLoading } = useModelListForProvider(provider, apiConfiguration) + + const filteredModelRecord = React.useMemo( + () => filterModels(models, provider, organizationAllowList), + [models, provider, organizationAllowList], + ) + + const modelIds = React.useMemo(() => { + if (!filteredModelRecord) return [] + return Object.entries(filteredModelRecord) + .filter(([id, info]) => { + if (id === selectedModelId) return true + // The host cannot set awsCustomArn, so picking this here would save a model id without an ARN. + if (id === BEDROCK_CUSTOM_ARN_MODEL_ID) return false + return !info.deprecated + }) + .map(([id]) => id) + .sort((a, b) => a.localeCompare(b)) + }, [filteredModelRecord, selectedModelId]) + + const searchableItems = React.useMemo( + () => + modelIds.map((id) => ({ + id, + searchStr: [id, filteredModelRecord?.[id]?.displayName].filter(Boolean).join(" "), + })), + [modelIds, filteredModelRecord], + ) + + const fzfInstance = React.useMemo( + () => new Fzf(searchableItems, { selector: (item) => item.searchStr }), + [searchableItems], + ) + + const filteredModelIds = React.useMemo(() => { + if (!searchValue) return modelIds + return fzfInstance.find(searchValue).map((result) => result.item.id) + }, [modelIds, searchValue, fzfInstance]) + + const triggerLabel = selectedModelInfo?.displayName ?? selectedModelId ?? "" + + const openSettings = React.useCallback(() => { + vscode.postMessage({ type: "switchTab", tab: "settings" }) + setOpen(false) + }, []) + + // The host merges this patch into the latest stored profile, so a stale + // snapshot cannot overwrite newer settings. `null` clears a key. + const handleModelChange = React.useCallback( + (modelIdField: string, modelId: string) => { + if (!provider) return + + const patch: Record = { [modelIdField]: modelId } + + handleModelChangeSideEffects(provider, modelId, (f, v) => { + // The host accepts only `null` to clear a key, but the shared helper clears Bedrock fields with "". + patch[f] = v === undefined || v === "" ? null : v + }) + + vscode.postMessage({ + type: "updateProfileModel", + text: currentApiConfigName, + values: { expectedProvider: provider, patch }, + }) + }, + [currentApiConfigName, provider], + ) + + const handleSelect = React.useCallback( + (modelId: string) => { + if (!field) return + if (modelId !== selectedModelId) handleModelChange(field, modelId) + setOpen(false) + setSearchValue("") + }, + [field, handleModelChange, selectedModelId], + ) + + const onClearSearch = React.useCallback(() => { + setSearchValue("") + searchInputRef.current?.focus() + }, []) + + const onOpenChange = React.useCallback((isOpen: boolean) => { + setOpen(isOpen) + if (!isOpen) setSearchValue("") + }, []) + + React.useEffect(() => { + if (open && searchInputRef.current) { + searchInputRef.current.focus() + } + }, [open]) + + const triggerClasses = cn( + "inline-flex items-center relative whitespace-nowrap px-1.5 py-1 text-xs", + selectorTriggerClassName, + disabled ? "opacity-50 cursor-not-allowed" : enabledSelectorTriggerClassName, + triggerClassName, + ) + + if (isUnsupported) { + return ( + + + + ) + } + + const showSearch = modelIds.length > SEARCH_THRESHOLD + + return ( + + + + {triggerLabel} + + + +
+ {showSearch ? ( +
+ setSearchValue(e.target.value)} + placeholder={t("chat:modelSelector.searchPlaceholder")} + className="w-full h-8 px-2 py-1 text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded focus:outline-0" + data-testid="model-search-input" + /> + {searchValue.length > 0 && ( +
+ +
+ )} +
+ ) : ( +
+

+ {t("chat:modelSelector.description")} +

+
+ )} + +
+ {isLoading ? ( +
+ {t("chat:modelSelector.loading")} +
+ ) : filteredModelIds.length === 0 ? ( +
+ {t("common:ui.no_results")} +
+ ) : ( +
+ {filteredModelIds.map((modelId) => { + const isSelected = modelId === selectedModelId + const modelInfo = filteredModelRecord?.[modelId] + return ( + + ) + })} +
+ )} +
+ +
+
+ +
+
+ {showSearch && ( + + + + )} +

+ {t("chat:modelSelector.title")} +

+
+
+
+
+
+ ) +} diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx index 9c447fe011..69ad3e675c 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx @@ -14,14 +14,7 @@ for (const theme of visualThemes) { await expect(editor).toBeVisible() await expect(story).toHaveScreenshot(`chat-composer-resting-${theme.name}.png`) - await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) - for ( - let index = 0; - index < 10 && !(await editor.evaluate((element) => element === document.activeElement)); - index++ - ) { - await page.keyboard.press("Tab") - } + await editor.focus() await expect(editor).toBeFocused() await expect(story).toHaveScreenshot(`chat-composer-focus-${theme.name}.png`) await expectBoundedLayout(page, story, { diff --git a/webview-ui/src/components/chat/__tests__/ModelSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ModelSelector.spec.tsx new file mode 100644 index 0000000000..d4dd381015 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ModelSelector.spec.tsx @@ -0,0 +1,392 @@ +import { render, screen, fireEvent } from "@/utils/test-utils" + +import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" + +import { ModelSelector } from "../ModelSelector" + +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +const extensionState = vi.hoisted(() => ({ + organizationAllowList: { allowAll: true, providers: {} } as Record, + currentApiConfigName: "default", +})) + +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => extensionState, +})) + +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("@/components/ui/hooks/useRooPortal", () => ({ + useRooPortal: () => document.body, +})) + +const routerModelsResult = vi.hoisted(() => ({ + value: { data: undefined, isLoading: false } as { data: unknown; isLoading: boolean }, + calls: [] as unknown[], +})) + +vi.mock("@/components/ui/hooks/useRouterModels", () => ({ + useRouterModels: (opts: unknown) => { + routerModelsResult.calls.push(opts) + return routerModelsResult.value + }, +})) + +const staticModelsOverride = vi.hoisted(() => ({ value: undefined as Record | undefined })) + +vi.mock("@/components/settings/utils/providerModelConfig", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getStaticModelsForProvider: (...args: Parameters) => + staticModelsOverride.value ?? actual.getStaticModelsForProvider(...args), + } +}) + +const selectedModelOverride = vi.hoisted(() => ({ + value: undefined as { id: string; info?: { displayName?: string } } | undefined, +})) + +vi.mock("@/components/ui/hooks/useSelectedModel", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSelectedModel: (...args: Parameters) => + selectedModelOverride.value ?? actual.useSelectedModel(...args), + } +}) + +import { vscode } from "@/utils/vscode" + +const base = { contextWindow: 1000, supportsPromptCache: false } + +describe("ModelSelector", () => { + const anthropicConfig: ProviderSettings = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-sonnet-4-5", + } + + beforeEach(() => { + vi.clearAllMocks() + staticModelsOverride.value = undefined + selectedModelOverride.value = undefined + routerModelsResult.value = { data: undefined, isLoading: false } + routerModelsResult.calls = [] + extensionState.organizationAllowList = { allowAll: true, providers: {} } + }) + + test("shows the currently selected model on the trigger for a static-model provider", () => { + render() + + expect(screen.getByTestId("model-selector-trigger")).toHaveTextContent("claude-sonnet-4-5") + }) + + test("disables the trigger when disabled", () => { + render() + + expect(screen.getByTestId("model-selector-trigger")).toBeDisabled() + }) + + test("shows a search bar since anthropic has more than 6 models and filters the list", () => { + render() + + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + expect(screen.getByTestId("model-search-input")).toBeInTheDocument() + const allItems = screen.getAllByTestId("model-selector-item") + expect(allItems.length).toBeGreaterThan(6) + + fireEvent.change(screen.getByTestId("model-search-input"), { target: { value: "claude-opus-4-6" } }) + + const filteredItems = screen.getAllByTestId("model-selector-item") + expect(filteredItems.length).toBeLessThan(allItems.length) + }) + + test("shows the description instead of a search bar when there are 6 or fewer models", () => { + staticModelsOverride.value = { "model-a": base, "model-b": base } + + render() + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + expect(screen.queryByTestId("model-search-input")).not.toBeInTheDocument() + expect(screen.getByText("chat:modelSelector.description")).toBeInTheDocument() + expect(screen.getAllByTestId("model-selector-item")).toHaveLength(2) + }) + + test("selecting a model patches the latest profile via updateProfileModel and closes the popover", () => { + render() + + fireEvent.click(screen.getByTestId("model-selector-trigger")) + fireEvent.change(screen.getByTestId("model-search-input"), { target: { value: "claude-opus-4-6" } }) + + fireEvent.click(screen.getAllByTestId("model-selector-item")[0]) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "updateProfileModel", + text: "default", + values: { + expectedProvider: providerIdentifiers.anthropic, + patch: expect.objectContaining({ + apiModelId: "claude-opus-4-6", + reasoningEffort: null, + modelMaxTokens: null, + modelMaxThinkingTokens: null, + }), + }, + }) + expect(screen.queryByTestId("model-search-input")).not.toBeInTheDocument() + }) + + test("selecting a Bedrock model clears the ARN fields with null, never an empty string", () => { + staticModelsOverride.value = { "model-a": base, "model-b": base } + const bedrockConfig: ProviderSettings = { + apiProvider: providerIdentifiers.bedrock, + apiModelId: "model-a", + awsCustomArn: "arn:aws:bedrock:us-east-1:123456789012:inference-profile/old", + } + render() + + fireEvent.click(screen.getByTestId("model-selector-trigger")) + fireEvent.click(screen.getAllByTestId("model-selector-item")[1]) + + const [message] = vi.mocked(vscode.postMessage).mock.calls[0] + const patch: Record = message.values?.patch ?? {} + expect(patch).toMatchObject({ + apiModelId: "model-b", + awsCustomArn: null, + awsCustomArnBaseModelId: null, + awsModelContextWindow: null, + }) + expect(Object.values(patch)).not.toContain("") + }) + + describe("display names", () => { + const named = { + "model-a": { ...base, displayName: "Alpha Fast" }, + "model-b": { ...base, displayName: "Beta Deep" }, + } + + test("shows the selected model's display name instead of its id on the trigger", () => { + selectedModelOverride.value = { id: "model-a", info: { displayName: "Alpha Fast" } } + + render() + + const trigger = screen.getByTestId("model-selector-trigger") + expect(trigger).toHaveTextContent("Alpha Fast") + expect(trigger).not.toHaveTextContent("model-a") + }) + + test("lists display names and finds a model by searching its display name", () => { + staticModelsOverride.value = { + ...named, + "model-c": { ...base, displayName: "Gamma" }, + "model-d": { ...base, displayName: "Delta" }, + "model-e": { ...base, displayName: "Epsilon" }, + "model-f": { ...base, displayName: "Zeta" }, + "model-g": { ...base, displayName: "Eta" }, + } + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + expect(screen.getByText("Beta Deep")).toBeInTheDocument() + + fireEvent.change(screen.getByTestId("model-search-input"), { target: { value: "Beta Deep" } }) + + const items = screen.getAllByTestId("model-selector-item") + expect(items).toHaveLength(1) + expect(items[0]).toHaveTextContent("Beta Deep") + + fireEvent.click(items[0]) + expect(vi.mocked(vscode.postMessage).mock.calls[0][0].values?.patch).toMatchObject({ + apiModelId: "model-b", + }) + }) + }) + + test("hides the Bedrock custom-arn pseudo-model unless it is already selected", () => { + staticModelsOverride.value = { "model-a": base, "custom-arn": base } + const bedrockConfig: ProviderSettings = { apiProvider: providerIdentifiers.bedrock, apiModelId: "model-a" } + const { unmount } = render() + + fireEvent.click(screen.getByTestId("model-selector-trigger")) + expect(screen.getAllByTestId("model-selector-item")).toHaveLength(1) + expect(screen.queryByText("custom-arn")).not.toBeInTheDocument() + unmount() + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + expect(screen.getAllByTestId("model-selector-item")).toHaveLength(2) + }) + + test("model items are real buttons so keyboard users can select them", () => { + staticModelsOverride.value = { "model-a": base, "model-b": base } + + render() + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + for (const item of screen.getAllByTestId("model-selector-item")) { + expect(item.tagName).toBe("BUTTON") + } + }) + + test("re-selecting the active model does not post a config update", () => { + render() + + fireEvent.click(screen.getByTestId("model-selector-trigger")) + fireEvent.change(screen.getByTestId("model-search-input"), { target: { value: "claude-sonnet-4-5" } }) + const items = screen.getAllByTestId("model-selector-item") + const active = items.find((el) => el.querySelector(".lucide-check"))! + fireEvent.click(active) + + expect(vscode.postMessage).not.toHaveBeenCalled() + expect(screen.queryByTestId("model-search-input")).not.toBeInTheDocument() + }) + + test("hides deprecated models except the currently selected one", () => { + staticModelsOverride.value = { + "model-active": { ...base, deprecated: true }, + "model-old": { ...base, deprecated: true }, + "model-new": base, + } + + render( + , + ) + + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + const labels = screen.getAllByTestId("model-selector-item").map((el) => el.textContent) + expect(labels).toEqual(["model-active", "model-new"]) + }) + + test("hides models excluded by the organization allow-list", () => { + staticModelsOverride.value = { "model-a": base, "model-b": base, "model-c": base } + extensionState.organizationAllowList = { + allowAll: false, + providers: { [providerIdentifiers.anthropic]: { allowAll: false, models: ["model-a", "model-c"] } }, + } + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + const labels = screen.getAllByTestId("model-selector-item").map((el) => el.textContent) + expect(labels).toEqual(["model-a", "model-c"]) + }) + + describe("dynamic-model providers", () => { + const openRouterConfig: ProviderSettings = { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "vendor/model-one", + } + + test("lists router models and selects through openRouterModelId", () => { + routerModelsResult.value = { + isLoading: false, + data: { openrouter: { "vendor/model-one": base, "vendor/model-two": base } }, + } + + render() + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + const items = screen.getAllByTestId("model-selector-item") + expect(items.map((el) => el.textContent)).toEqual(["vendor/model-one", "vendor/model-two"]) + + fireEvent.click(items[1]) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "updateProfileModel", + text: "default", + values: { + expectedProvider: providerIdentifiers.openrouter, + patch: expect.objectContaining({ openRouterModelId: "vendor/model-two" }), + }, + }) + }) + + test("shows a loading state, not 'no results', while router models load", () => { + routerModelsResult.value = { data: undefined, isLoading: true } + + render() + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + expect(screen.getByText("chat:modelSelector.loading")).toBeInTheDocument() + expect(screen.queryByText("common:ui.no_results")).not.toBeInTheDocument() + }) + + test("shows 'no results' when the router returns no models", () => { + routerModelsResult.value = { data: { openrouter: {} }, isLoading: false } + + render() + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + expect(screen.getByText("common:ui.no_results")).toBeInTheDocument() + expect(screen.queryByText("chat:modelSelector.loading")).not.toBeInTheDocument() + }) + + test("shows 'no results' when the router request failed", () => { + routerModelsResult.value = { data: undefined, isLoading: false } + + render() + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + expect(screen.getByText("common:ui.no_results")).toBeInTheDocument() + }) + + test("does not request router models for providers without a model field", () => { + render( + , + ) + + expect(routerModelsResult.calls.length).toBeGreaterThan(0) + expect(routerModelsResult.calls.every((opts) => (opts as { enabled: boolean }).enabled === false)).toBe( + true, + ) + expect(screen.getByTestId("model-selector-unsupported-trigger")).toBeInTheDocument() + }) + }) + + test("falls back to a degraded trigger for providers without a simple model list, and jumps to Settings on click", () => { + render( + , + ) + + expect(screen.queryByTestId("model-selector-trigger")).not.toBeInTheDocument() + const unsupportedTrigger = screen.getByTestId("model-selector-unsupported-trigger") + expect(unsupportedTrigger).toBeInTheDocument() + + fireEvent.click(unsupportedTrigger) + + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "switchTab", tab: "settings" }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-dark.png index 4de566f736..b829f28078 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-dark.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast-light.png index f5c759fad8..4d815fe6eb 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast-light.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast.png index 465ffc0263..dbe2d65f2d 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-light.png index 1f238a4619..1cb9115d04 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-light.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png index fad9aa30b9..b829f28078 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast-light.png index f5c759fad8..4d815fe6eb 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast-light.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png index 465ffc0263..dbe2d65f2d 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png index 1f238a4619..1cb9115d04 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png differ diff --git a/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts new file mode 100644 index 0000000000..3744d9bc28 --- /dev/null +++ b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts @@ -0,0 +1,86 @@ +import { RouterModelsMessageType, providerIdentifiers } from "@roo-code/types" + +import { fetchRouterModels } from "../useRouterModels" + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +describe("fetchRouterModels", () => { + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + const handlerOf = (spy: { mock: { calls: unknown[][] } }) => + spy.mock.calls.find((args) => args[0] === "message")?.[1] as EventListener + + test("resolves with the router models of the matching provider and removes its listener", async () => { + const addSpy = vi.spyOn(window, "addEventListener") + const removeSpy = vi.spyOn(window, "removeEventListener") + + const promise = fetchRouterModels(providerIdentifiers.openrouter) + const handler = handlerOf(addSpy) + + handler( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.routerModels, + values: { provider: providerIdentifiers.openrouter }, + routerModels: { [providerIdentifiers.openrouter]: {} }, + }, + }), + ) + + await expect(promise).resolves.toEqual({ [providerIdentifiers.openrouter]: {} }) + expect(removeSpy).toHaveBeenCalledWith("message", handler) + }) + + test("on abort, rejects, removes the same message listener, and clears the timeout", async () => { + vi.useFakeTimers() + const addSpy = vi.spyOn(window, "addEventListener") + const removeSpy = vi.spyOn(window, "removeEventListener") + const controller = new AbortController() + + const promise = fetchRouterModels(providerIdentifiers.openrouter, controller.signal) + const handler = handlerOf(addSpy) + expect(typeof handler).toBe("function") + expect(vi.getTimerCount()).toBe(1) + + controller.abort() + + await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + expect(removeSpy).toHaveBeenCalledWith("message", handler) + expect(vi.getTimerCount()).toBe(0) + }) + + test("rejects at once without adding a listener when the signal is already aborted", async () => { + vi.useFakeTimers() + const addSpy = vi.spyOn(window, "addEventListener") + const controller = new AbortController() + controller.abort() + + await expect(fetchRouterModels(providerIdentifiers.openrouter, controller.signal)).rejects.toMatchObject({ + name: "AbortError", + }) + expect(handlerOf(addSpy)).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + }) + + test("on timeout, rejects and removes the same message listener", async () => { + vi.useFakeTimers() + const addSpy = vi.spyOn(window, "addEventListener") + const removeSpy = vi.spyOn(window, "removeEventListener") + + const promise = fetchRouterModels(providerIdentifiers.openrouter) + const handler = handlerOf(addSpy) + const assertion = expect(promise).rejects.toThrow("timed out") + + await vi.advanceTimersByTimeAsync(10_000) + + await assertion + expect(removeSpy).toHaveBeenCalledWith("message", handler) + }) +}) diff --git a/webview-ui/src/components/ui/hooks/useRouterModels.ts b/webview-ui/src/components/ui/hooks/useRouterModels.ts index a7d6b36725..7557562583 100644 --- a/webview-ui/src/components/ui/hooks/useRouterModels.ts +++ b/webview-ui/src/components/ui/hooks/useRouterModels.ts @@ -14,14 +14,21 @@ type UseRouterModelsOptions = { enabled?: boolean // gate fetching entirely } -export const fetchRouterModels = async (provider?: string) => +export const fetchRouterModels = async (provider?: string, signal?: AbortSignal) => new Promise((resolve, reject) => { const cleanup = () => { + signal?.removeEventListener("abort", onAbort) if (typeof window !== "undefined") { window.removeEventListener("message", handler) } } + const onAbort = () => { + clearTimeout(timeout) + cleanup() + reject(new DOMException("Router models request aborted", "AbortError")) + } + const timeout = setTimeout(() => { cleanup() reject(new Error("Router models request timed out")) @@ -50,6 +57,12 @@ export const fetchRouterModels = async (provider?: string) => } } + if (signal?.aborted) { + onAbort() + return + } + + signal?.addEventListener("abort", onAbort, { once: true }) window.addEventListener("message", handler) if (provider) { vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels, values: { provider } }) @@ -62,7 +75,7 @@ export const useRouterModels = (opts: UseRouterModelsOptions = {}) => { const provider = opts.provider || undefined return useQuery({ queryKey: [RouterModelsMessageType.routerModels, provider || allRouterModelsProvider], - queryFn: () => fetchRouterModels(provider), + queryFn: ({ signal }) => fetchRouterModels(provider, signal), enabled: opts.enabled !== false, }) } diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 9b53c0528a..712d906891 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -113,6 +113,15 @@ }, "selectMode": "Selecciona el mode d'interacció", "selectApiConfig": "Seleccioneu la configuració de l'API", + "selectModel": "Selecciona el model", + "modelSelector": { + "title": "Models", + "settings": "Configuració del model", + "description": "Tria el model que s'utilitzarà per a aquesta configuració d'API.", + "searchPlaceholder": "Cerca models...", + "unsupportedTooltip": "El model d'aquest proveïdor es configura a Configuració", + "loading": "Carregant models..." + }, "lockApiConfigAcrossModes": "Bloqueja la configuració de l'API a tots els modes en aquest espai de treball", "unlockApiConfigAcrossModes": "La configuració de l'API està bloquejada a tots els modes en aquest espai de treball (fes clic per desbloquejar)", "enhancePrompt": "Millora la sol·licitud amb context addicional", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 8bdd0185fa..f6872a2df9 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -113,6 +113,15 @@ }, "selectMode": "Interaktionsmodus auswählen", "selectApiConfig": "API-Konfiguration auswählen", + "selectModel": "Modell auswählen", + "modelSelector": { + "title": "Modelle", + "settings": "Modelleinstellungen", + "description": "Wähle das Modell für diese API-Konfiguration aus.", + "searchPlaceholder": "Modelle suchen...", + "unsupportedTooltip": "Das Modell dieses Anbieters wird in den Einstellungen konfiguriert", + "loading": "Modelle werden geladen..." + }, "lockApiConfigAcrossModes": "API-Konfiguration für alle Modi in diesem Arbeitsbereich sperren", "unlockApiConfigAcrossModes": "API-Konfiguration ist für alle Modi in diesem Arbeitsbereich gesperrt (klicke zum Entsperren)", "enhancePrompt": "Prompt mit zusätzlichem Kontext verbessern", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 450b2ccc2b..0ab9f28e71 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -140,6 +140,7 @@ }, "selectMode": "Select mode for interaction", "selectApiConfig": "Select API configuration", + "selectModel": "Select model", "lockApiConfigAcrossModes": "Lock API configuration across all modes in this workspace", "unlockApiConfigAcrossModes": "API configuration is locked across all modes in this workspace (click to unlock)", "enhancePrompt": "Enhance prompt with additional context", @@ -151,6 +152,14 @@ "searchPlaceholder": "Search modes...", "noResults": "No results found" }, + "modelSelector": { + "title": "Models", + "settings": "Model Settings", + "description": "Choose the model used for this API configuration.", + "searchPlaceholder": "Search models...", + "unsupportedTooltip": "This provider's model is configured in Settings", + "loading": "Loading models..." + }, "enhancePromptDescription": "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.", "addImages": "Add images to message", "sendMessage": "Send message", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 32308f83be..f66e4c139c 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -113,6 +113,15 @@ }, "selectMode": "Seleccionar modo de interacción", "selectApiConfig": "Seleccionar configuración de API", + "selectModel": "Seleccionar modelo", + "modelSelector": { + "title": "Modelos", + "settings": "Configuración del modelo", + "description": "Elige el modelo que se usará para esta configuración de API.", + "searchPlaceholder": "Buscar modelos...", + "unsupportedTooltip": "El modelo de este proveedor se configura en Ajustes", + "loading": "Cargando modelos..." + }, "lockApiConfigAcrossModes": "Bloquear la configuración de API en todos los modos de este espacio de trabajo", "unlockApiConfigAcrossModes": "La configuración de API está bloqueada en todos los modos de este espacio de trabajo (clic para desbloquear)", "enhancePrompt": "Mejorar el mensaje con contexto adicional", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index dfd6668d0c..db537ffb17 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -113,6 +113,15 @@ }, "selectMode": "Sélectionner le mode d'interaction", "selectApiConfig": "Sélectionner la configuration de l'API", + "selectModel": "Sélectionner le modèle", + "modelSelector": { + "title": "Modèles", + "settings": "Paramètres du modèle", + "description": "Choisissez le modèle utilisé pour cette configuration d'API.", + "searchPlaceholder": "Rechercher des modèles...", + "unsupportedTooltip": "Le modèle de ce fournisseur se configure dans les Paramètres", + "loading": "Chargement des modèles..." + }, "lockApiConfigAcrossModes": "Verrouiller la configuration API pour tous les modes dans cet espace de travail", "unlockApiConfigAcrossModes": "La configuration API est verrouillée pour tous les modes dans cet espace de travail (cliquer pour déverrouiller)", "enhancePrompt": "Améliorer la requête avec un contexte supplémentaire", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 2cebbebcc7..b2bc1f6f41 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -113,6 +113,15 @@ }, "selectMode": "इंटरैक्शन मोड चुनें", "selectApiConfig": "एपीआई कॉन्फ़िगरेशन का चयन करें", + "selectModel": "मॉडल चुनें", + "modelSelector": { + "title": "मॉडल", + "settings": "मॉडल सेटिंग्स", + "description": "इस API कॉन्फ़िगरेशन के लिए उपयोग किया जाने वाला मॉडल चुनें।", + "searchPlaceholder": "मॉडल खोजें...", + "unsupportedTooltip": "इस प्रदाता का मॉडल सेटिंग्स में कॉन्फ़िगर किया जाता है", + "loading": "मॉडल लोड हो रहे हैं..." + }, "lockApiConfigAcrossModes": "इस कार्यक्षेत्र में सभी मोड के लिए API कॉन्फ़िगरेशन लॉक करें", "unlockApiConfigAcrossModes": "इस कार्यक्षेत्र में सभी मोड के लिए API कॉन्फ़िगरेशन लॉक है (अनलॉक करने के लिए क्लिक करें)", "enhancePrompt": "अतिरिक्त संदर्भ के साथ प्रॉम्प्ट बढ़ाएँ", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 52e5e233d1..92f86658d8 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -143,6 +143,15 @@ }, "selectMode": "Pilih mode untuk interaksi", "selectApiConfig": "Pilih konfigurasi API", + "selectModel": "Pilih model", + "modelSelector": { + "title": "Model", + "settings": "Pengaturan Model", + "description": "Pilih model yang digunakan untuk konfigurasi API ini.", + "searchPlaceholder": "Cari model...", + "unsupportedTooltip": "Model penyedia ini dikonfigurasi di Pengaturan", + "loading": "Memuat model..." + }, "lockApiConfigAcrossModes": "Kunci konfigurasi API di semua mode dalam workspace ini", "unlockApiConfigAcrossModes": "Konfigurasi API terkunci di semua mode dalam workspace ini (klik untuk membuka kunci)", "enhancePrompt": "Tingkatkan prompt dengan konteks tambahan", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index cdefd8650c..3d13635264 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -113,6 +113,15 @@ }, "selectMode": "Seleziona modalità di interazione", "selectApiConfig": "Seleziona la configurazione API", + "selectModel": "Seleziona modello", + "modelSelector": { + "title": "Modelli", + "settings": "Impostazioni del modello", + "description": "Scegli il modello da usare per questa configurazione API.", + "searchPlaceholder": "Cerca modelli...", + "unsupportedTooltip": "Il modello di questo provider si configura nelle Impostazioni", + "loading": "Caricamento dei modelli..." + }, "lockApiConfigAcrossModes": "Blocca la configurazione API per tutte le modalità in questo workspace", "unlockApiConfigAcrossModes": "La configurazione API è bloccata per tutte le modalità in questo workspace (clicca per sbloccare)", "enhancePrompt": "Migliora prompt con contesto aggiuntivo", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 6a26d36de3..59a54be128 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -113,6 +113,15 @@ }, "selectMode": "対話モードを選択", "selectApiConfig": "API構成を選択", + "selectModel": "モデルを選択", + "modelSelector": { + "title": "モデル", + "settings": "モデル設定", + "description": "この API 設定で使用するモデルを選択します。", + "searchPlaceholder": "モデルを検索...", + "unsupportedTooltip": "このプロバイダーのモデルは設定で構成します", + "loading": "モデルを読み込み中..." + }, "lockApiConfigAcrossModes": "このワークスペースのすべてのモードでAPI構成をロック", "unlockApiConfigAcrossModes": "このワークスペースのすべてのモードでAPI構成がロックされています(クリックで解除)", "enhancePrompt": "追加コンテキストでプロンプトを強化", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 84063cf64d..4f6c197de6 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -113,6 +113,7 @@ }, "selectMode": "상호작용 모드 선택", "selectApiConfig": "API 구성 선택", + "selectModel": "모델 선택", "lockApiConfigAcrossModes": "이 워크스페이스의 모든 모드에서 API 구성 잠금", "unlockApiConfigAcrossModes": "이 워크스페이스의 모든 모드에서 API 구성이 잠겨 있습니다 (클릭하여 해제)", "enhancePrompt": "추가 컨텍스트로 프롬프트 향상", @@ -134,6 +135,14 @@ "searchPlaceholder": "모드 검색...", "noResults": "결과를 찾을 수 없습니다" }, + "modelSelector": { + "title": "모델", + "settings": "모델 설정", + "description": "이 API 구성에서 사용할 모델을 선택하세요.", + "searchPlaceholder": "모델 검색...", + "unsupportedTooltip": "이 공급자의 모델은 설정에서 구성합니다", + "loading": "모델 로드 중..." + }, "errorReadingFile": "파일 읽기 오류", "noValidImages": "처리된 유효한 이미지가 없습니다", "separator": "구분자", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 703fd36ced..3d3dae3858 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -113,6 +113,15 @@ }, "selectMode": "Selecteer modus voor interactie", "selectApiConfig": "Selecteer API-configuratie", + "selectModel": "Model selecteren", + "modelSelector": { + "title": "Modellen", + "settings": "Modelinstellingen", + "description": "Kies het model dat voor deze API-configuratie wordt gebruikt.", + "searchPlaceholder": "Modellen zoeken...", + "unsupportedTooltip": "Het model van deze provider wordt in de Instellingen geconfigureerd", + "loading": "Modellen laden..." + }, "lockApiConfigAcrossModes": "API-configuratie vergrendelen voor alle modi in deze werkruimte", "unlockApiConfigAcrossModes": "API-configuratie is vergrendeld voor alle modi in deze werkruimte (klik om te ontgrendelen)", "enhancePrompt": "Prompt verbeteren met extra context", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 5affcc1bff..ab8b21a533 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -113,6 +113,15 @@ }, "selectMode": "Wybierz tryb interakcji", "selectApiConfig": "Wybierz konfigurację API", + "selectModel": "Wybierz model", + "modelSelector": { + "title": "Modele", + "settings": "Ustawienia modelu", + "description": "Wybierz model używany w tej konfiguracji API.", + "searchPlaceholder": "Szukaj modeli...", + "unsupportedTooltip": "Model tego dostawcy jest konfigurowany w Ustawieniach", + "loading": "Ładowanie modeli..." + }, "lockApiConfigAcrossModes": "Zablokuj konfigurację API dla wszystkich trybów w tym obszarze roboczym", "unlockApiConfigAcrossModes": "Konfiguracja API jest zablokowana dla wszystkich trybów w tym obszarze roboczym (kliknij, aby odblokować)", "enhancePrompt": "Ulepsz podpowiedź dodatkowym kontekstem", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index cecf0b5036..2d250c1d71 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -113,6 +113,15 @@ }, "selectMode": "Selecionar modo de interação", "selectApiConfig": "Selecionar configuração da API", + "selectModel": "Selecionar modelo", + "modelSelector": { + "title": "Modelos", + "settings": "Configurações do modelo", + "description": "Escolha o modelo usado nesta configuração de API.", + "searchPlaceholder": "Pesquisar modelos...", + "unsupportedTooltip": "O modelo deste provedor é configurado nas Configurações", + "loading": "Carregando modelos..." + }, "lockApiConfigAcrossModes": "Bloquear configuração da API em todos os modos neste workspace", "unlockApiConfigAcrossModes": "A configuração da API está bloqueada em todos os modos neste workspace (clique para desbloquear)", "enhancePrompt": "Aprimorar prompt com contexto adicional", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index ed0a95674d..e90107fd5b 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -113,6 +113,15 @@ }, "selectMode": "Выберите режим взаимодействия", "selectApiConfig": "Выберите конфигурацию API", + "selectModel": "Выбрать модель", + "modelSelector": { + "title": "Модели", + "settings": "Настройки модели", + "description": "Выберите модель для этой конфигурации API.", + "searchPlaceholder": "Поиск моделей...", + "unsupportedTooltip": "Модель этого провайдера настраивается в Настройках", + "loading": "Загрузка моделей..." + }, "lockApiConfigAcrossModes": "Заблокировать конфигурацию API для всех режимов в этом рабочем пространстве", "unlockApiConfigAcrossModes": "Конфигурация API заблокирована для всех режимов в этом рабочем пространстве (нажми, чтобы разблокировать)", "enhancePrompt": "Улучшить запрос с дополнительным контекстом", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index fcc6f058df..c8e2690e40 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -113,6 +113,15 @@ }, "selectMode": "Etkileşim modunu seçin", "selectApiConfig": "API yapılandırmasını seçin", + "selectModel": "Model seç", + "modelSelector": { + "title": "Modeller", + "settings": "Model Ayarları", + "description": "Bu API yapılandırması için kullanılacak modeli seçin.", + "searchPlaceholder": "Model ara...", + "unsupportedTooltip": "Bu sağlayıcının modeli Ayarlar'da yapılandırılır", + "loading": "Modeller yükleniyor..." + }, "lockApiConfigAcrossModes": "Bu çalışma alanındaki tüm modlarda API yapılandırmasını kilitle", "unlockApiConfigAcrossModes": "Bu çalışma alanındaki tüm modlarda API yapılandırması kilitli (kilidi açmak için tıkla)", "enhancePrompt": "Ek bağlamla istemi geliştir", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 2327b381c0..afed79bedc 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -113,6 +113,15 @@ }, "selectMode": "Chọn chế độ tương tác", "selectApiConfig": "Chọn cấu hình API", + "selectModel": "Chọn mô hình", + "modelSelector": { + "title": "Mô hình", + "settings": "Cài đặt mô hình", + "description": "Chọn mô hình được dùng cho cấu hình API này.", + "searchPlaceholder": "Tìm kiếm mô hình...", + "unsupportedTooltip": "Mô hình của nhà cung cấp này được cấu hình trong Cài đặt", + "loading": "Đang tải mô hình..." + }, "lockApiConfigAcrossModes": "Khóa cấu hình API cho tất cả chế độ trong workspace này", "unlockApiConfigAcrossModes": "Cấu hình API đã bị khóa cho tất cả chế độ trong workspace này (nhấn để mở khóa)", "enhancePrompt": "Nâng cao yêu cầu với ngữ cảnh bổ sung", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index faaa65a3a6..801fbcc06f 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -113,6 +113,15 @@ }, "selectMode": "选择交互模式", "selectApiConfig": "选择 API 配置", + "selectModel": "选择模型", + "modelSelector": { + "title": "模型", + "settings": "模型设置", + "description": "选择此 API 配置使用的模型。", + "searchPlaceholder": "搜索模型...", + "unsupportedTooltip": "此提供商的模型需在设置中配置", + "loading": "正在加载模型..." + }, "lockApiConfigAcrossModes": "锁定此工作区所有模式的 API 配置", "unlockApiConfigAcrossModes": "此工作区所有模式的 API 配置已锁定(点击解锁)", "enhancePrompt": "增强提示词", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 3aa891af55..3e2f36119c 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -140,6 +140,15 @@ }, "selectMode": "選擇互動模式", "selectApiConfig": "選取 API 設定", + "selectModel": "選擇模型", + "modelSelector": { + "title": "模型", + "settings": "模型設定", + "description": "選擇此 API 設定使用的模型。", + "searchPlaceholder": "搜尋模型...", + "unsupportedTooltip": "此供應商的模型需在設定中配置", + "loading": "正在載入模型..." + }, "lockApiConfigAcrossModes": "鎖定此工作區所有模式的 API 設定", "unlockApiConfigAcrossModes": "此工作區所有模式的 API 設定已鎖定(點擊解鎖)", "enhancePrompt": "使用額外內容強化提示詞",