From 8b87eebc2096cefb883e87b76b345fe84b2a1b78 Mon Sep 17 00:00:00 2001 From: seeones Date: Sat, 10 Oct 2026 17:20:40 +0800 Subject: [PATCH 01/12] feat(chat): add chat model selector in input bar --- .../electron-chat-dark-sidebar.png | Bin 30321 -> 30026 bytes src/core/webview/ClineProvider.ts | 44 +- .../ClineProvider.apiHandlerRebuild.spec.ts | 36 + .../webview/__tests__/ClineProvider.spec.ts | 8 +- .../__tests__/webviewMessageHandler.spec.ts | 51 ++ src/core/webview/webviewMessageHandler.ts | 71 +- .../src/components/chat/ChatModelSelector.tsx | 261 ++++++++ .../src/components/chat/ChatTextArea.tsx | 6 + .../chat/__tests__/ChatModelSelector.spec.tsx | 381 +++++++++++ .../chat/__tests__/ChatTextArea.visual.tsx | 12 +- .../chat-composer-focus-dark.png | Bin 9166 -> 9945 bytes ...hat-composer-focus-high-contrast-light.png | Bin 9606 -> 10270 bytes .../chat-composer-focus-high-contrast.png | Bin 9624 -> 10477 bytes .../chat-composer-focus-light.png | Bin 9326 -> 9973 bytes .../chat-composer-resting-dark.png | Bin 9162 -> 9945 bytes ...t-composer-resting-high-contrast-light.png | Bin 9606 -> 10270 bytes .../chat-composer-resting-high-contrast.png | Bin 9624 -> 10477 bytes .../chat-composer-resting-light.png | Bin 9326 -> 9973 bytes .../__tests__/useChatModelSelector.spec.tsx | 625 ++++++++++++++++++ .../chat/hooks/useChatModelSelector.ts | 401 +++++++++++ webview-ui/src/i18n/locales/ca/chat.json | 4 + webview-ui/src/i18n/locales/de/chat.json | 4 + webview-ui/src/i18n/locales/en/chat.json | 4 + webview-ui/src/i18n/locales/es/chat.json | 4 + webview-ui/src/i18n/locales/fr/chat.json | 4 + webview-ui/src/i18n/locales/hi/chat.json | 4 + webview-ui/src/i18n/locales/id/chat.json | 4 + webview-ui/src/i18n/locales/it/chat.json | 4 + webview-ui/src/i18n/locales/ja/chat.json | 4 + webview-ui/src/i18n/locales/ko/chat.json | 4 + webview-ui/src/i18n/locales/nl/chat.json | 4 + webview-ui/src/i18n/locales/pl/chat.json | 4 + webview-ui/src/i18n/locales/pt-BR/chat.json | 4 + webview-ui/src/i18n/locales/ru/chat.json | 4 + webview-ui/src/i18n/locales/tr/chat.json | 4 + webview-ui/src/i18n/locales/vi/chat.json | 4 + webview-ui/src/i18n/locales/zh-CN/chat.json | 4 + webview-ui/src/i18n/locales/zh-TW/chat.json | 4 + 38 files changed, 1951 insertions(+), 17 deletions(-) create mode 100644 webview-ui/src/components/chat/ChatModelSelector.tsx create mode 100644 webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx create mode 100644 webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx create mode 100644 webview-ui/src/components/chat/hooks/useChatModelSelector.ts 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 e8497070ef3440fe4a3bbb3ef49f9a1de9d4c4d9..3c03cbac035d27aa3820b5de342f0cf07ad05074 100644 GIT binary patch delta 10520 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 { + // Snapshot the active profile state so that a partial failure can be + // rolled back instead of leaving the persisted store and the in-memory + // context (global state + provider settings) out of sync. + const previousProviderSettings = this.contextProxy.getProviderSettings() + const previousCurrentApiConfigName = this.contextProxy.getValue("currentApiConfigName") + // Snapshot the active mode's profile binding so it too can be restored if a + // later write in the activation sequence fails. + let previousModeConfigId: string | undefined + let mutatedMode: Mode | undefined + let didMutateState = false + try { return await this.enqueueProviderProfileMutation(async (signal) => { // TODO: Do we need to be calling `activateProfile`? It's not @@ -1890,6 +1901,8 @@ export class ClineProvider if (activate) { const { mode } = await this.getState() + previousModeConfigId = await this.providerSettingsManager.getModeConfigId(mode) + mutatedMode = mode // These promises do the following: // 1. Adds or updates the list of provider profiles. @@ -1901,8 +1914,10 @@ export class ClineProvider // this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... }) // We should probably switch to that and verify that it works. // I left the original implementation in just to be safe. + const listApiConfig = await this.providerSettingsManager.listConfig() + didMutateState = true await Promise.all([ - this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + this.updateGlobalState("listApiConfigMeta", listApiConfig), this.updateGlobalState("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), @@ -1915,13 +1930,38 @@ export class ClineProvider // 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()) + const listApiConfig = await this.providerSettingsManager.listConfig() + didMutateState = true + await this.updateGlobalState("listApiConfigMeta", listApiConfig) } await this.postStateToWebview() return id }) } catch (error) { + // Restore the previously-persisted profile state so a failure does not + // leave the context pointing at a half-written profile. The provider + // settings store (`saveConfig`) is itself atomic, so only the context + // copy needs restoring. + if (didMutateState) { + try { + this.updateTaskApiHandlerIfNeeded(previousProviderSettings, { forceRebuild: true }) + await this.contextProxy.setProviderSettings(previousProviderSettings) + await this.updateGlobalState("currentApiConfigName", previousCurrentApiConfigName) + if (mutatedMode && previousModeConfigId) { + await this.providerSettingsManager.setModeConfig(mutatedMode, previousModeConfigId) + } + await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) + await this.postStateToWebview() + } catch (rollbackError) { + this.log( + `Failed to roll back provider profile state: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + } + this.log( `Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 4561d688fe..9e5fa3f67b 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -426,6 +426,42 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { // Should not call buildApiHandler when there's no task expect(buildApiHandlerMock).not.toHaveBeenCalled() }) + + test("rolls back in-memory provider state when a failure occurs after mutation", async () => { + // Seed a known previously-active state. + await provider["contextProxy"].setProviderSettings({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + }) + await provider["updateGlobalState"]("currentApiConfigName", "previous-config") + + // Fail the first state broadcast — which happens only after the + // in-memory state has already been mutated — to simulate a partial + // failure. The rollback's own broadcast should still succeed. + const postSpy = vi + .spyOn(provider, "postStateToWebview") + .mockRejectedValueOnce(new Error("broadcast failed")) + + const result = await provider.upsertProviderProfile( + "new-config", + { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-3-5-sonnet-20241022", + }, + true, + ) + + // The half-applied profile must not survive: the previous settings and + // profile name are restored so the store and in-memory context stay in sync. + expect(result).toBeUndefined() + expect(provider["contextProxy"].getProviderSettings()).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + }) + expect(provider["contextProxy"].getValue("currentApiConfigName")).toBe("previous-config") + + postSpy.mockRestore() + }) }) describe("activateProviderProfile", () => { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 7c85a6b372..3eb8057df0 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2632,6 +2632,7 @@ describe("ClineProvider", () => { ]), saveConfig: vi.fn().mockResolvedValue("test-id"), setModeConfig: vi.fn(), + getModeConfigId: vi.fn().mockResolvedValue(undefined), } as any // Update API configuration @@ -3401,6 +3402,7 @@ describe("ClineProvider", () => { ;(provider as any).providerSettingsManager = { setModeConfig: vi.fn(), saveConfig: vi.fn().mockResolvedValue(undefined), + getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() .mockResolvedValue([ @@ -3446,6 +3448,7 @@ describe("ClineProvider", () => { ;(provider as any).providerSettingsManager = { setModeConfig: vi.fn(), saveConfig: vi.fn().mockResolvedValue(undefined), + getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() .mockResolvedValue([ @@ -3475,11 +3478,12 @@ describe("ClineProvider", () => { ) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.create_api_config") - // Verify state was still updated + // The partial failure is rolled back: the persisted profile list is + // re-read and the in-memory state is restored rather than left pointing + // at a half-applied profile. expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [ { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]) - expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config") }) test("handles successful saveApiConfiguration", async () => { diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index dcd70f92f1..0d40968407 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -169,6 +169,57 @@ describe("webviewMessageHandler - theme fixture probes", () => { }) }) +describe("webviewMessageHandler - organization allowlist enforcement", () => { + // Provider narrowed to a single model; anything else is disallowed. + const restrictiveAllowList = { + allowAll: false, + providers: { + [providerIdentifiers.anthropic]: { allowAll: false, models: ["claude-sonnet-4-20250514"] }, + }, + } + + const setUpsertSpy = (spy: ReturnType) => { + ;(mockClineProvider as unknown as { upsertProviderProfile: ReturnType }).upsertProviderProfile = + spy + } + + beforeEach(() => { + vi.clearAllMocks() + mockClineProvider.getState = vi.fn().mockResolvedValue({ organizationAllowList: restrictiveAllowList }) + }) + + it("rejects a disallowed model on upsertApiConfiguration without persisting", async () => { + const upsertProviderProfile = vi.fn().mockResolvedValue("id") + setUpsertSpy(upsertProviderProfile) + + await webviewMessageHandler(mockClineProvider, { + type: "upsertApiConfiguration", + text: "profile", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-opus-4-20250514" }, + }) + + expect(upsertProviderProfile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + + it("allows an allow-listed model on upsertApiConfiguration", async () => { + const upsertProviderProfile = vi.fn().mockResolvedValue("id") + setUpsertSpy(upsertProviderProfile) + + await webviewMessageHandler(mockClineProvider, { + type: "upsertApiConfiguration", + text: "profile", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-sonnet-4-20250514" }, + }) + + expect(upsertProviderProfile).toHaveBeenCalledWith( + "profile", + expect.objectContaining({ apiModelId: "claude-sonnet-4-20250514" }), + ) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) +}) + import { t } from "../../../i18n" vi.mock("vscode", () => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 193540455b..bf45690a9e 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -30,6 +30,7 @@ import { RouterModelsMessageType, VsCodeLmModelsMessageType, isTelemetryOptedIn, + ORGANIZATION_ALLOW_ALL, } from "@roo-code/types" import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" @@ -43,6 +44,8 @@ import { ClineProvider } from "./ClineProvider" import { findOriginalContent } from "./stripOriginalContent" import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler" import { generateErrorDiagnostics } from "./diagnosticsHandler" +import { ProfileValidator } from "../../shared/ProfileValidator" +import { OrganizationAllowListViolationError } from "../../utils/errors" import { handleRequestSkills, handleCreateSkill, @@ -1407,6 +1410,7 @@ export const webviewMessageHandler = async ( type: OllamaModelsMessageType.ollamaModels, ollamaModels: {}, error: errorMsg, + requestId: message.requestId, }) break } @@ -1416,7 +1420,11 @@ export const webviewMessageHandler = async ( // Always post a response so the webview refresh status can // transition out of "loading" — even when no models are found. - await provider.postMessageToWebview({ type: OllamaModelsMessageType.ollamaModels, ollamaModels }) + await provider.postMessageToWebview({ + type: OllamaModelsMessageType.ollamaModels, + ollamaModels, + requestId: message.requestId, + }) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) provider.log(`[requestOllamaModels] Failed to read models for ${logBaseUrl}: ${errorMsg}`) @@ -1424,6 +1432,7 @@ export const webviewMessageHandler = async ( type: OllamaModelsMessageType.ollamaModels, ollamaModels: {}, error: errorMsg, + requestId: message.requestId, }) } break @@ -1451,6 +1460,7 @@ export const webviewMessageHandler = async ( await provider.postMessageToWebview({ type: LmStudioModelsMessageType.lmStudioModels, lmStudioModels: lmStudioModels, + requestId: message.requestId, }) } } catch (error) { @@ -1476,14 +1486,22 @@ export const webviewMessageHandler = async ( message?.values?.openAiHeaders, ) - await provider.postMessageToWebview({ type: OpenAiModelsMessageType.openAiModels, openAiModels }) + await provider.postMessageToWebview({ + type: OpenAiModelsMessageType.openAiModels, + openAiModels, + requestId: message.requestId, + }) } break case VsCodeLmModelsMessageType.requestVsCodeLmModels: const vsCodeLmModels = await getVsCodeLmModels() // TODO: Cache like we do for OpenRouter, etc? - await provider.postMessageToWebview({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels }) + await provider.postMessageToWebview({ + type: VsCodeLmModelsMessageType.vsCodeLmModels, + vsCodeLmModels, + requestId: message.requestId, + }) break case "openImage": await openImage(message.text!, { values: message.values }) @@ -2291,6 +2309,20 @@ export const webviewMessageHandler = async ( case "saveApiConfiguration": if (message.text && message.apiConfiguration) { try { + // Enforce the organization allowlist at the persistence + // boundary, not only in the webview, so a crafted or stale + // profile update cannot bypass the policy. + const { organizationAllowList } = await provider.getState() + if ( + !ProfileValidator.isProfileAllowed( + message.apiConfiguration, + organizationAllowList ?? ORGANIZATION_ALLOW_ALL, + ) + ) { + throw new OrganizationAllowListViolationError( + t("common:errors.violated_organization_allowlist"), + ) + } await provider.providerSettingsManager.saveConfig(message.text, message.apiConfiguration) const listApiConfig = await provider.providerSettingsManager.listConfig() await updateGlobalState("listApiConfigMeta", listApiConfig) @@ -2298,13 +2330,42 @@ export const webviewMessageHandler = async ( provider.log( `Error save api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) - vscode.window.showErrorMessage(t("common:errors.save_api_config")) + vscode.window.showErrorMessage( + error instanceof OrganizationAllowListViolationError + ? error.message + : t("common:errors.save_api_config"), + ) } } break case "upsertApiConfiguration": if (message.text && message.apiConfiguration) { - await provider.upsertProviderProfile(message.text, message.apiConfiguration) + try { + // Enforce the organization allowlist at the persistence + // boundary, not only in the webview, so a crafted or stale + // profile update cannot bypass the policy. + const { organizationAllowList } = await provider.getState() + if ( + !ProfileValidator.isProfileAllowed( + message.apiConfiguration, + organizationAllowList ?? ORGANIZATION_ALLOW_ALL, + ) + ) { + throw new OrganizationAllowListViolationError( + t("common:errors.violated_organization_allowlist"), + ) + } + await provider.upsertProviderProfile(message.text, message.apiConfiguration) + } catch (error) { + provider.log( + `Error upsert api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + vscode.window.showErrorMessage( + error instanceof OrganizationAllowListViolationError + ? error.message + : t("common:errors.save_api_config"), + ) + } } break case "renameApiConfiguration": diff --git a/webview-ui/src/components/chat/ChatModelSelector.tsx b/webview-ui/src/components/chat/ChatModelSelector.tsx new file mode 100644 index 0000000000..bff20e2057 --- /dev/null +++ b/webview-ui/src/components/chat/ChatModelSelector.tsx @@ -0,0 +1,261 @@ +import { useMemo, useState, useCallback, useRef, type KeyboardEvent } from "react" + +import { cn } from "@/lib/utils" +import { useRooPortal } from "@/components/ui/hooks/useRooPortal" +import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { vscode } from "@/utils/vscode" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" +import { filterModels } from "@/components/settings/utils/organizationFilters" +import { useChatModelSelector } from "./hooks/useChatModelSelector" + +interface ChatModelSelectorProps { + disabled?: boolean + title: string + triggerClassName?: string +} + +export const ChatModelSelector = ({ disabled = false, title, triggerClassName = "" }: ChatModelSelectorProps) => { + const { t } = useAppTranslation() + const { apiConfiguration, organizationAllowList, currentApiConfigName } = useExtensionState() + const { id: selectedModelId } = useSelectedModel(apiConfiguration) + const { provider, models, modelIdKey, defaultModelId, isLoading, valueTransform, displayTransform } = + useChatModelSelector() + + const [open, setOpen] = useState(false) + const [searchValue, setSearchValue] = useState("") + const searchInputRef = useRef(null) + const portalContainer = useRooPortal("roo-portal") + + // Filter deprecated models but always keep the currently selected one visible. + const modelIds = useMemo(() => { + const filteredModels = filterModels(models, provider, organizationAllowList) + const available = Object.entries(filteredModels ?? {}) + .filter(([modelId, modelInfo]) => { + if (modelId === selectedModelId) return true + return !modelInfo.deprecated + }) + .map(([modelId]) => modelId) + .sort((a, b) => a.localeCompare(b)) + return available + }, [models, provider, organizationAllowList, selectedModelId]) + + // Resolve the display value (custom transform for compound config values like VSCode LM). + // The trigger label must reflect the persisted selection, never the transient search + // text — otherwise typing in the popover search box rewrites the trigger/highlight. + const displayValue = useMemo(() => { + if (displayTransform && modelIdKey) { + const storedValue = apiConfiguration?.[modelIdKey] + return storedValue ? displayTransform(storedValue) : undefined + } + return selectedModelId || undefined + }, [displayTransform, modelIdKey, apiConfiguration, selectedModelId]) + + const filteredModelIds = useMemo(() => { + if (!searchValue) return modelIds + const q = searchValue.toLowerCase() + return modelIds.filter((id) => id.toLowerCase().includes(q)) + }, [modelIds, searchValue]) + + // Whether org policy permits an arbitrary/custom model id for this provider. + // Only providers that are explicitly `allowAll` may use free-form ids; when a + // provider is narrowed to an allow-list we must not expose the custom-model + // escape hatch (the options list is already filtered by `filterModels`). + const canUseCustomModel = useMemo(() => { + if (!organizationAllowList || organizationAllowList.allowAll) { + return true + } + if (!provider) { + return false + } + return organizationAllowList.providers[provider]?.allowAll === true + }, [organizationAllowList, provider]) + + // Defense-in-depth: reject a selection that the organization policy disallows, + // regardless of how it was produced (option click, keyboard, or custom entry). + const isModelAllowed = useCallback( + (modelId: string): boolean => { + if (!organizationAllowList || organizationAllowList.allowAll) { + return true + } + if (!provider) { + return false + } + const providerConfig = organizationAllowList.providers[provider] + if (!providerConfig) { + return false + } + if (providerConfig.allowAll) { + return true + } + return providerConfig.models?.includes(modelId) ?? false + }, + [organizationAllowList, provider], + ) + + const onSelect = useCallback( + (modelId: string) => { + if (!modelId || !modelIdKey || !apiConfiguration || !currentApiConfigName) { + return + } + + if (!isModelAllowed(modelId)) { + return + } + + setOpen(false) + setSearchValue("") + + // Transform the model id for storage if needed (e.g. VSCode LM selector object). + const valueToStore = valueTransform ? valueTransform(modelId) : modelId + + // Persist the change to the current API configuration profile. The + // backend will save the profile, activate it and broadcast the + // updated apiConfiguration back to the webview. + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration: { + ...apiConfiguration, + [modelIdKey]: valueToStore, + }, + }) + }, + [modelIdKey, apiConfiguration, valueTransform, currentApiConfigName, isModelAllowed], + ) + + const onClearSearch = useCallback(() => { + setSearchValue("") + searchInputRef.current?.focus() + }, []) + + const onKeyDown = (event: KeyboardEvent) => { + if (event.nativeEvent.isComposing) return + + const options = Array.from(event.currentTarget.querySelectorAll("[data-model-option]")) + const currentIndex = options.findIndex((option) => option === event.target) + const isSearchInput = event.target === searchInputRef.current + if (!isSearchInput && currentIndex === -1) return + + if (isSearchInput && event.key === "Enter") { + event.preventDefault() + options[0]?.click() + return + } + + let nextIndex: number + switch (event.key) { + case "ArrowDown": + nextIndex = currentIndex + 1 + break + case "ArrowUp": + nextIndex = currentIndex === -1 ? options.length - 1 : currentIndex - 1 + break + case "Home": + case "End": + if (isSearchInput) return + nextIndex = event.key === "Home" ? 0 : options.length - 1 + break + default: + return + } + event.preventDefault() + if (nextIndex < 0 || nextIndex >= options.length) { + searchInputRef.current?.focus() + } else { + options[nextIndex]?.focus() + } + } + + return ( + + + + + {isLoading ? "…" : displayValue || defaultModelId || t("chat:selectModel")} + + + + +
+
+ setSearchValue(e.target.value)} + placeholder={t("chat:searchModel")} + 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" + autoFocus + /> + {searchValue.length > 0 && ( +
+
+ )} +
+ + {modelIds.length === 0 && !isLoading && ( +
{t("chat:modelListEmpty")}
+ )} + + {modelIds.length > 0 && ( +
+ {filteredModelIds.map((modelId) => ( + + ))} +
+ )} + + {searchValue && canUseCustomModel && !modelIds.includes(searchValue) && ( + + )} +
+
+
+ ) +} diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index a78f3f0f8e..4fea41b578 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 { ChatModelSelector } from "./ChatModelSelector" import { AutoApproveDropdown } from "./AutoApproveDropdown" import { MAX_IMAGES_PER_MESSAGE } from "./constants" import ContextMenu from "./ContextMenu" @@ -1319,6 +1320,11 @@ export const ChatTextArea = forwardRef( lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} onToggleLockApiConfig={handleToggleLockApiConfig} /> +
diff --git a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx new file mode 100644 index 0000000000..a98f681915 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx @@ -0,0 +1,381 @@ +import type { ReactNode } from "react" +import userEvent from "@testing-library/user-event" +import { render, screen, fireEvent } from "@/utils/test-utils" +import { providerIdentifiers } from "@roo-code/types" +import { vscode } from "@/utils/vscode" + +import { ChatModelSelector } from "../ChatModelSelector" +import { useChatModelSelector } from "../hooks/useChatModelSelector" + +// Mock the dependencies +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("@/components/ui/hooks/useRooPortal", () => ({ + useRooPortal: () => document.body, +})) + +// Mock the ExtensionStateContext (configurable per test) +const { mockUseExtensionState } = vi.hoisted(() => ({ + mockUseExtensionState: vi.fn(), +})) + +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: (...args: unknown[]) => mockUseExtensionState(...args), +})) + +// Mock useSelectedModel +vi.mock("@/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ id: "claude-opus-4-20250514", info: undefined }), +})) + +// Mock useChatModelSelector hook +vi.mock("../hooks/useChatModelSelector", () => ({ + useChatModelSelector: vi.fn(), +})) + +// Exercise the real popover's focus management and Escape handling. +vi.mock("@/components/ui", async () => ({ + ...(await import("@/components/ui/popover")), + StandardTooltip: ({ children }: { children: ReactNode }) => <>{children}, +})) + +const mockUseChatModelSelector = useChatModelSelector as ReturnType + +const anthropicModels = { + "claude-opus-4-20250514": { + maxTokens: 32000, + contextWindow: 200000, + supportsPromptCache: true, + supportsImages: true, + }, + "claude-sonnet-4-20250514": { + maxTokens: 32000, + contextWindow: 200000, + supportsPromptCache: true, + supportsImages: true, + }, +} + +describe("ChatModelSelector", () => { + const defaultProps = { + title: "Select model", + } + + const focusDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "focus")! + + beforeAll(() => { + // The global FAST compatibility mock makes focus a no-op. Borrow JSDOM's + // native implementation from a fresh realm for keyboard interaction tests. + const iframe = document.createElement("iframe") + document.body.appendChild(iframe) + const nativeFocus = iframe.contentDocument!.createElement("button").focus + Object.defineProperty(HTMLElement.prototype, "focus", { + configurable: true, + writable: true, + value: nativeFocus, + }) + iframe.remove() + }) + + afterAll(() => { + Object.defineProperty(HTMLElement.prototype, "focus", focusDescriptor) + }) + + beforeEach(() => { + vi.clearAllMocks() + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-20250514", + }, + organizationAllowList: { allowAll: true, providers: {} }, + currentApiConfigName: "default", + }) + mockUseChatModelSelector.mockReturnValue({ + provider: providerIdentifiers.anthropic, + models: anthropicModels, + modelIdKey: "apiModelId", + defaultModelId: "claude-sonnet-4-20250514", + isLoading: false, + }) + }) + + test("renders the trigger with the current model id", () => { + render() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + expect(trigger).toBeInTheDocument() + expect(trigger).toHaveTextContent("claude-opus-4-20250514") + }) + + test("disables the trigger when disabled prop is true", () => { + render() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + expect(trigger).toBeDisabled() + }) + + test("renders model list when opened", () => { + render() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + fireEvent.click(trigger) + + // Use testids instead of text queries because the selected model id also + // appears in the trigger label. + expect(screen.getByTestId("chat-model-option-claude-opus-4-20250514")).toBeInTheDocument() + expect(screen.getByTestId("chat-model-option-claude-sonnet-4-20250514")).toBeInTheDocument() + }) + + test("selecting a model posts upsertApiConfiguration message", () => { + render() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + fireEvent.click(trigger) + + const option = screen.getByTestId("chat-model-option-claude-sonnet-4-20250514") + fireEvent.click(option) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-sonnet-4-20250514", + }, + }) + }) + + test("supports custom model via search", () => { + render() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + fireEvent.click(trigger) + + // Type in the search box. + const searchInput = screen.getByPlaceholderText("chat:searchModel") + fireEvent.change(searchInput, { target: { value: "claude-3-5-haiku" } }) + + const customOption = screen.getByTestId("chat-model-use-custom") + fireEvent.click(customOption) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-3-5-haiku", + }, + }) + }) + + test("clears the search query via the clear button", () => { + render() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + fireEvent.click(trigger) + + const searchInput = screen.getByPlaceholderText("chat:searchModel") as HTMLInputElement + fireEvent.change(searchInput, { target: { value: "claude-3" } }) + expect(searchInput.value).toBe("claude-3") + + const clearButton = screen.getByRole("button", { name: "chat:clearSearch" }) + fireEvent.click(clearButton) + + expect((screen.getByPlaceholderText("chat:searchModel") as HTMLInputElement).value).toBe("") + }) + + test("does not post a message when modelIdKey becomes unset", () => { + const { rerender } = render() + fireEvent.click(screen.getByTestId("chat-model-selector-trigger")) + mockUseChatModelSelector.mockReturnValue({ + provider: providerIdentifiers.anthropic, + models: anthropicModels, + modelIdKey: undefined, + defaultModelId: undefined, + isLoading: false, + }) + rerender() + + expect(screen.getByTestId("chat-model-selector-trigger")).toBeDisabled() + fireEvent.click(screen.getByTestId("chat-model-option-claude-opus-4-20250514")) + expect(vscode.postMessage).not.toHaveBeenCalled() + }) + + test("does not post a message when apiConfiguration is missing and modelIdKey is set", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: undefined, + organizationAllowList: { allowAll: true, providers: {} }, + currentApiConfigName: "default", + }) + render() + const trigger = screen.getByTestId("chat-model-selector-trigger") + expect(trigger).toBeEnabled() + fireEvent.click(trigger) + fireEvent.click(screen.getByTestId("chat-model-option-claude-opus-4-20250514")) + expect(vscode.postMessage).not.toHaveBeenCalled() + }) + + test("opens, navigates options, and selects a model using the keyboard", async () => { + const user = userEvent.setup() + render() + await user.tab() + await user.keyboard("{Enter}") + expect(screen.getByRole("textbox")).toHaveFocus() + await user.keyboard("{ArrowDown}") + expect(screen.getByTestId("chat-model-option-claude-opus-4-20250514")).toHaveFocus() + await user.keyboard("{End}") + expect(screen.getByRole("button", { name: "claude-sonnet-4-20250514" })).toHaveFocus() + await user.keyboard("{Home}{ArrowUp}") + expect(screen.getByRole("textbox")).toHaveFocus() + await user.keyboard("{ArrowUp}{Enter}") + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + apiConfiguration: expect.objectContaining({ apiModelId: "claude-sonnet-4-20250514" }), + }), + ) + expect(screen.queryByRole("textbox")).not.toBeInTheDocument() + expect(screen.getByTestId("chat-model-selector-trigger")).toHaveFocus() + }) + + test("selects the first filtered model with Enter from search", async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByTestId("chat-model-selector-trigger")) + await user.keyboard("sonnet{Enter}") + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + apiConfiguration: expect.objectContaining({ apiModelId: "claude-sonnet-4-20250514" }), + }), + ) + }) + + test.each(["{Enter}", "{ArrowDown} "])("selects a custom model with %s", async (keys) => { + const user = userEvent.setup() + render() + await user.click(screen.getByTestId("chat-model-selector-trigger")) + await user.keyboard(`custom-model${keys}`) + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + apiConfiguration: expect.objectContaining({ apiModelId: "custom-model" }), + }), + ) + }) + + test("clears search by keyboard and dismisses the popover with Escape", async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByTestId("chat-model-selector-trigger")) + await user.keyboard("sonnet") + await user.tab() + expect(screen.getByRole("button", { name: "chat:clearSearch" })).toHaveFocus() + await user.keyboard(" ") + expect(screen.getByRole("textbox")).toHaveValue("") + expect(screen.getByRole("textbox")).toHaveFocus() + await user.keyboard("{Escape}") + expect(screen.queryByRole("textbox")).not.toBeInTheDocument() + expect(screen.getByTestId("chat-model-selector-trigger")).toHaveFocus() + expect(vscode.postMessage).not.toHaveBeenCalled() + }) + + test("renders the display-transformed value for compound configs (e.g. VSCode LM)", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.vscodeLm, + vsCodeLmModelSelector: { vendor: "copilot", family: "gpt-4o" }, + }, + organizationAllowList: { allowAll: true, providers: {} }, + currentApiConfigName: "default", + }) + mockUseChatModelSelector.mockReturnValue({ + provider: providerIdentifiers.vscodeLm, + models: { "copilot/gpt-4o": { maxTokens: 1, contextWindow: 1 } }, + modelIdKey: "vsCodeLmModelSelector", + defaultModelId: undefined, + isLoading: false, + displayTransform: (value: unknown) => { + const selector = value as { vendor?: string; family?: string } + return selector.vendor && selector.family ? `${selector.vendor}/${selector.family}` : "" + }, + }) + + render() + + // The trigger shows the display-transformed value instead of the raw model id. + const trigger = screen.getByTestId("chat-model-selector-trigger") + expect(trigger).toHaveTextContent("copilot/gpt-4o") + }) + + test("disables the trigger when currentApiConfigName is missing", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-20250514", + }, + organizationAllowList: { allowAll: true, providers: {} }, + currentApiConfigName: undefined, + }) + + render() + + // Without a resolved profile name the upsert would be dropped, so the + // control must not be interactive — and must not look interactive. + expect(screen.getByTestId("chat-model-selector-trigger")).toBeDisabled() + }) + + test("hides the custom-model escape hatch when the provider is restricted", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-20250514", + }, + organizationAllowList: { + allowAll: false, + providers: { + [providerIdentifiers.anthropic]: { allowAll: false, models: ["claude-sonnet-4-20250514"] }, + }, + }, + currentApiConfigName: "default", + }) + + render() + fireEvent.click(screen.getByTestId("chat-model-selector-trigger")) + fireEvent.change(screen.getByPlaceholderText("chat:searchModel"), { target: { value: "gpt-999" } }) + + // The provider is narrowed to an allow-list, so free-form ids are not offered. + expect(screen.queryByTestId("chat-model-use-custom")).not.toBeInTheDocument() + }) + + test("filters out models that the organization policy disallows", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-20250514", + }, + organizationAllowList: { + allowAll: false, + providers: { + [providerIdentifiers.anthropic]: { allowAll: false, models: ["claude-sonnet-4-20250514"] }, + }, + }, + currentApiConfigName: "default", + }) + + render() + fireEvent.click(screen.getByTestId("chat-model-selector-trigger")) + + // Only the allow-listed model is offered; the disallowed Opus entry is absent. + expect(screen.getByTestId("chat-model-option-claude-sonnet-4-20250514")).toBeInTheDocument() + expect(screen.queryByTestId("chat-model-option-claude-opus-4-20250514")).not.toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx index 9c447fe011..8b54af07b6 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx @@ -14,14 +14,10 @@ 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") - } + // Re-enter the editor by keyboard without depending on the composer's tab-stop count. + await editor.focus() + await page.keyboard.press("Tab") + await page.keyboard.press("Shift+Tab") 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__/__screenshots__/chat-composer-focus-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-dark.png index 4de566f736bcf2ebeb288f4d8eaea9b509255c29..6c90b6f6102d1593f3dd15421757624b6cc94313 100644 GIT binary patch literal 9945 zcmb7~Wl$VJ+pbA~010lvVR0w8LvV-S7Tn$4-624Ndx8amySux)yIZgY_6#}oR=xF| zAE&-QTf4n8-90n+^YnAwJrQ5zC6M0Zy@!H=LXwgcRf2+ga{zo#g?|ftTJZ`*K|!HH zNr?)nxM!SX!hOe@eeOTM9jdCRs4*#?QWV0(cdg7Z4Cn8`i7}6??7@}`+{+**lB=bf zGpCPVAdQUF7b;cJ{Dv&EoR2_Hzw8Fd&IFGeUo8o2wAPUUG&&96`JSJT zr6(_ys55PY7#J8RC@2;?;X%RX{CI>%ge_p@RfZcLR@Ul*f`YQLSdsL!v}jb}@HsLX z8U&P42sbzP?CdNO5|S$f^11x)nlL;Xv3|}UjqL_j7M6tyKaPG8C4z5GWWlJU zj39!*R`HS;EL(gKvoJhp3nv(_(Uk1RtYmQ=sxUdsoEw!HdZgSksRlYr+lyQ zgS_xyP!JUSY0Qa|lHw{L=D_!j1Uv}+`_9e|qfXP#?k@1qVfk{3i;MH}46pY_EB#pO z+O8ObQQsWQ6f2Ks@r$$wvr|xnKy!`s5OCXqg5f{g+MaQ)dsjQ|4x#qnoUga*G`nio zSueO6+l%pX_?FV2}|ID*)L|)=L|RkB@iQ4EZ>g!99J#1eYbu#&uDZ#^+Jh z>;oK1>a<*M#|aj@J6pAD*bGGxxZjAEAQE~mZ@H2-47Y2!42`gxE7Jk@U?|{BQXWqJ z?9bx&ItiW4R-=N2XlrX*k@lBut{>JN&7#7Q*f6zRbm5w1Fnise935HSW_awSF4x&q zFZUDsc{`CdyPBAoWV)?+c+XTCNj!4w)|!suMUStwdZ9g6j|<>`=<7S0Epa^J}lf-o-$OYsM#`ow)^J=#8@?&F}yfKop z2g6SabYkSkM``O=V*QnhXm|6Tt46}Vl-K-(eFv{89Z4`Nk$+57Dehn)_Y--Qu0}MF zP`iS|$zpRjs{%f5OL}sV#vUvv94gkSk7x2G>vFmrsbrJ#9+y^!q7cV&Z~0r5w$j685GWq4cMU__a+Rt?R#y3T7_jOk^OhNGZJG5i~ z=x$PiIFr+EEzb9bjGju>eOiw5k_yAd^TcIdXFoBJQ(fhWp?;-GzcUby$lc%fB{BOc zIMe6xrt#@&m^pof@7tvB^JBbr*?_B*6vAC^-#|Q>p7&+%xB&R1U+{%Fb)TLC^)igu z*L|}qq<+;Ezwmy)$IAjzwYApBi^xQ!>$+%iBJ|6)?ZFiDV$m#nSGmvM-=#lADBnJs z&=4Bix+7C};qdRv%F?;-U9&#b?a}PS zeYhMrUgktIuyrc!S4yT1u&cC9X)>+ibL;_0@)wr+>r&N(Cp9$M~@A11uz*wD}>#pvY5Qb)#v*pkK%W? z>NZI-B_SepdR*RaDrliaCJrb5UMkGtM+_jgZ@%I1TOkMnU(AdXN~#mRUp7(vE)+UL z1kW4yiDoh!z_s(bBJikkdAGj?WAstheZe-~84gP@#iMELQE#B&p6|EgoT~MF{>~;) z$duYUpDtkq4Z1YaUcao%|FKN3C6Y%zeNK3L*ie`=)2G?H7da5)kU_I_ zgpHiqsZ$j&YHS!=;FLZ(OEST%f*&6dl(0qm>6(*&sgx+gmwFM}H4Xz7A}0=a(PJo8;h`uz*P7sb?;(!l5YO{O99mJp6Sg~)eSRUP!R zGoh=;VCAeG;v0Cl^9Ew;bSyCPxe`ZWW+gY!npNJuVpRlT;meCU@`*%rc+Ez^Bn>2E z!Cd?R?6|4VM@foxf_e3@3dE|NA2{8On_R#1-O8byzytHU4NvSF<{ROPDV5v3Qw8; z_lOr?M9$UAcs=SoB}?}zXzOdHS7lHomw5+s4t|*>K2`8mpSzK)tO&&bTjtR&r$X)GarnyLNchAeVoj^3$c1Y>wO6}#J`)HT8|wkq(){944)Rn~;I zTVJ0{9`j>>;~kAubATcVx*;^#%tyZ}dZ{nHg{23rbyY=k>rZ=}w_4E!^t1LLS?H3j zkuq#1UF)9Cx9sw*cJ>!%h3)l2(&Dm&ocxLUWnikVnB^`i^fu$SG9Se};mH>jG!a_% z(yZX6mj$9Utpn=mG2Be57nEK|1=xhy5CK#l_| z56SIM6szOQ$EfsGk)OZeVI#ij{eThQv4-9#&VPA7E(mFH`Dy!Jf&cIK^e(USRtf)< z=yltIq^~8QnzR+Ll8v@^s1mVlaEsm?>ajexl{D}z%e!ryKtm{NXDIVGn7g8jDwK* zMXvxDmndB9u@v_mOn#2jlG#9uJr507_DS=JoU3^@w-*zqMiHa@x8nzTmAviS%3gna zrZt?(){kSt)h$+aZ=AMTzjRbxb*(ZlogRB?bp#?6*3eLhu22cSQRrDfKe*5gS!-X> zGFSPYFrL3CH5)uFOs^846N3sz=d9sLd^%6h{|wV!=yUH-8zbs(TS zitLq&?mOFz-~0LhK|XNr@*Y%S3)ViTMH3E(BI!3lrhJ)Le)B&mN~DVcK(NqYZGaLG z5$XXn!$Akie#uYdD*$%s|G(JvUngi$^7F6m&o1NQ;@+wY7{;w3QwO6meEITaqTX(O zXh`}ljNSA0#KKSBbG6B3cXyYv?q7HGN5{vfp{l2|CF+Iw`IW6s`(vo>S9nOTBckDu zf&jqiRkmAiBebX!>{d}x3AQW;ntoW#)E+dJphzhI01dQmVi9fEA6lvPh~YbUu!L@d&as-NI;(ax?(}Gyv4@9;RdL*u^X0uc?*n|BK&HPK2^nLC^k?_CR3;h25TdT>L<~ZrZ;zyzM4nWIY$)%m}WV;SFncHgAYX= zV`OABZEk5<(W}+|`0v*Eb4iJb!3LbPdflaQ{nJ1|acL==LqyiAzF_`K75{4){r3s* zp8*C{5O~;mdh*8bJ2>?RB;o(^3`TL$s5Tvadbl=%bSHrRJq0Lf85usoN(&0I!$w{}X_yPy;^R@znCG zF>*a)vXdVA4#R?!X{C5}88Bb|wVv3_)73uW1I-ZFj2fL|%c^Fae@E{)E0lyk)w`3P zH(X2WF3dpQ0<*Nqs`A~w#^yI8m6J9wFtD-t6G2d{y3AtbYE9^D5Oy^lgDPC3sutH! z*_}Xxo4%J4o{*JA1cZi?`sl!7llxzT`F|!&&eCG~$AZ$A4$*)CUfXE`KMU!lHXa&& zuUY2AM0~4bWhbSh5@kHC{X$Z})#3oAet!6=BJX+tOJZ%(cUIeCxFDZxHeE7kJ9cjQ zO(Onp*5sFF<=t2t=>GYSZZ?v3c1kxR4X9f=1(nSCrs_v)44DF8k-a8ON=wnZ+OoxC zv&(V7g9|5c?EIhk7OfY@&D7M?)z$TPkkscR!rQY|Z5svP+O@Tl-1Gxs~{*2L0kn~#@xTTlVe zfqmL>KsU)`xn4UmAx}lCQGcvzi5m0JnezLPkPu_``A)9w8k6I>KLzY0`0do>)~Bu4s9Lb-8(wx$BY!BM%hUb zaX#e$)&dQ(BoT)n5OD@s!!+GtDEod@O+Mh;si~=|DwcxZH)IL4w6wy_MuO9kLm%zK z?+Cq#f`1zhAnmnp9VHFYK5Rn#v!^x!eaO8ls)VUIHVWZPc;onU(~sK%yI7E_+ESRY zn8v>WOWX#3-~Kr2&i+0MK1b=VU&g?KvIc-oML?9`S;O`#$^r=?bphRDv#;>vSeE>y z(^)yHQy`{9+m^Yc^Sk8z=WGf1K%XI0_lw%=1&pS~P^og=)>y$8_bpH+uiNbdyhMvZ zcj(pC6|j(2Gkq&*^8x!lUG{@wK^H7QV3Ngh1ztqOHAtiv!Mhzais&XS>STV`pyS@* z5GA&~Ofr%>C`G?uCbbska})ttv0rr{tbXm@93YEpxtWpyi1c)^+RR8A9-0LsPX;KG zHi~}6@|;w3tCeaPh)jv{X~`ziZ`O*G)C~jHaep*!_pIhFOC@Om$|uEepJq3%Ki~pQ z?Wj8S{4YI-%4v-sK)30vV_ZS75da=fl#sh1yTZQBZ`%yT2U1U?!?t)c*P>yXNEkAO zuwhqd5Vy%>o*1QCk7R>#!YGZ%^|hlD^59yNp;I?5KZj2P4@HTS>wc$L15heenc&}j zS7pN&&c@JG)knX%8fL0@kEkBwv0AFJSmOs)Y%c)Ti`(Thc|zI=Diz?uOo7aMx>Vb( zT&maZyI5t?sWy)AOFn~(nU3x_f-1}0VX*@6)qv$Xcvdns3Gb4VSBqMfex!{@8bTY6 z%SCT3)PdgTF^Mkld^0VV&f#$fm=cq~8eOq`W1kiofeO%(W|grN7jLWnWy)bYWrTChy_@3-j@Wxb6tkuQP<;E#-||;mPBNIoUx`(f6iQCvaIF;# z3L_By8kP4GDL7Hzz##YU_r7!8@W_fv{m!>tOadM`XNHz|F317ez#zBiK+-5v`+8yZr!7aIVUkem|@!;t4P z5DcUR{U%w;0L(MlRA}EGzO+=w`7#}!TVkgXX4&jy?%?gNF1UbKQLYNo48C4st}6Z* zU?#n~f=Q?+{uk*EPoE+A&yZt1h$DVu1ltYX(CrU(U66d2syzSSnUb=mBk(%pg{Cm0 zD?7Lj@-XhlsN2HzL{;qQ==jtP#T6vO*ccpO0Ql61VlQT@>gwD1vZdki+qP^{F^3O} z7Bs!M%baJ{D;4auMG~E7k^I$a^8~X@tkLW^gt@7S#Gr&Ktf;&g%Rpno(|jOgsT z7Z}c3uz5n{v4b{8T7*C6bh+*7;|Hv<=KX-9S|qmZ{UMfn2C&h(I=sNA_0!2XIH?+n znJG+o7$ph~s{l{M89V?yu8*0YSWWA*>N3xN(E&|~Ui!5$!7AS4_fy~p#{1`UKZW~| zbhw#}0`c^{IeTrO1|i`0WP`?0ZZB_tNqhK+Wc&AMw(oGkuJvz{+-=n`qe!Pk&Izc; z6d5N}JiDtVI5B%#o;PN?)itz;)Hf;iX347j!5!6;rB=$#^LmtXcxWhE*=mJXJYs5N z>T959F3}`!tU;CIloXZ0ViD!g#jLs>g=RJj-|d2tqQO{R9(RWqH@ex73DXX=$=FJg z3j?B7PxcYG6UFzl*S6o;+gii#NIrZ%S>AboC3-zB*xy#k(iiHm#P`qwn$8QpuGP&6b4CErPpbi@f*((3gU`)eI>Uhlx?`P1R|l8q)pUf!$OC5 zrd@441oDE<&OFGYSVA|tc!SqjG8}*zDvy+)O-W!&#u*>_8?pjgcoV%sY3=CbWML=3 z+o)ILHI}irUX@$;sr6cOr|_@|qX7q%tY1epna}0;A|&SLoNwZiiWQGpA*GwZ&MrHw zUKwY3nK@^5>F|iEl){))Ss8?QR$Srmb_Q38;lT}bW3NW)qEasSm}0cu{D#!KBIKgi2>*1NG$mdC372@#ZC zeOhP0G!%T=M2TUAkg!qed>jTRzmE}AU>UVCVqWg@ZjPJXiPd-wT~ zf=Qv$MZne3W*Zj8D_Pwvs&m-la3XxQGct<8OW42Tn075kJ3!T_+Geu|LY}T$5AW4+ z$Naa~0n$I5E@Yh|n|twsrv#w<4VuJ(-{W-l7HgL8qlINs<(pa)eU33t!qk!R@xtLR z?8*hQ2Gq6dmJXNP$yrecen2`oZ@G?L9+3NWxM9E1-!NhQM10d@Iq_u9P$TtD9wJfA z)msNyKqcF>wYJo%w7t&z0aQ&1()fv-^YiUIKaE!(Pk*=!db!!k{a&goEGT|I`?7w> z-FG_7)ZT-n_&kT%VV3P9jxZH^P{t)1eExS{k5gWdyhD1#4aoY`@qR?+J(5s=!a}29 zyO-?WFB5xTyt#$RMKEi8_}o##@&&xp=>@eGHWmb`O~dh3=KcKif%WN$gzer?i-A_3 zI8G!ywTb0GsfEqNT9O!UdnDlgdW3bHeD8iIQ2|flt`SBxnxEVU%=M93)G`BY#s>_S z?T8RfrL?OTxAwGPwa6Gu%N%gQ2<H&S{Ah)1 z8#@H!Q9qPfxDONlM=!^2pWGq`am5fdXP=m8DmR5Eo;}L0)J8Zd#dtWAPPA|54gC4Y zn-y5B(V_Q0WEyk-&R`<-9UDK7vopV88fj$}LU4tuzRk-c1*e5Qhc54*nUe#%Y9rf# zxb3HZ_nZ}@@6D%A<1?L4SD1P^wnrEA9@AP`rkgx|i>>8fI@i#DE2;v=JyATt`#x5D zP##>$t1QFo!z#}e2*HK63D3Afy2t#;-v8z{p75oA*T8s1KglO=4Z=Mk+4&gJTBbnM z(mz2LF1R6mpmGp@+F`pQ=xG;p#WZqpJ^ggL3^7l{vb}A%6gB-+H(o856U5=)+AC~0 zcaaNF2At}K08)&%@?;;vM^7e$mupxbMjPVm_llvnTCx@n@y_bY!70JHe{VRO`8o=V zh(v1Bxzc(&qp>N4-O0!~OwqUWrK4dhkIU^|V$?!$MFUP6tq=wN$|HP1yDbTo<)TLN zO4kr3XV?GHxGwWcKj^fwlQeiun-IPRzav+l`KpwnLFXOaX^hZAww2*q-zZb3?HEp_ zx4<6frNGzmKcHG~X`zsIYxCA>ESL`>A5X(*GIbJvb)<$&SmT_o7`=VGIY67{?9Qi5 z?cE)uy0Oqwd0EfgWnbM(!Xn3CH<4odPTSbOso?jR7+0C6!*b3sCL^4Z6Vg_8x`WB4 zDwCG-HZny()@6#nl&e#BTEZgo^2+8Vc&2>+5>~a5s!<92GGy9v4KRa}!+l zvIpVMz8{&t@+NKId9#Qsfv~G?BlJUX<$by4840HwPPd|CP#l8>*jfqMQ@1{c7K@IM zVC(~-{qYAm$>+d2)CKXDC!?qr7Zi8b`-MUF++cR+K9u)ETuxuT`JvxEhrG$6|G|Y?ciV=_ywS)E zm2X-wFcL9~+=l~`+!>c?eL+Rx1nhq$q+YZ$>=NH>xKgV||MU?vLou4haI zA^13V44y5r3HhDFrP`@CS;JcM4Y;wvKAiEo{uFCy8TaN-qR zh*W~~iwbFM5k?2&14v#6g7ompn&I%Qp67|eDn0j?+nT;L=TJf{DJM>!rqNWOyomOO zRi1~xZ6JaiuOL9KSuk?tTCnk^p1;Dgv?c?&d-4*+m`Y`l-IIL0JHwbShylgB4jrHj zN6^CK2FmHJrP0sf%fra zZd@84de|RZ*WYl+)acv!a3AWS(N}If^yer9t1Y&|L$|!Eol73})oIA+68fpwT}J;b zH&QY1SHB$dANmbN%&Es!;G~4uubGHwo6!cxUoH}FwF!G;sx~k#?Lab=!=EPdjUc)q z3!lwI0(B~5;X#olsHJPbihlQ_RaRjLML5{46lVL4`El)zZex(4bSZ@4R8r-pRmL$6Z2gSH- z+}1~N2RZ`|H`(lV%YG_0x7Ph}*mexf*;tV-yFNzDsPV6(ehR`yK`ub)ZbH#*Mw24Q zYygnPHq`l+T-2v{r2S?x##BIGWvup}~(i7ya^#&=g zDosF3JTEBG;yY+l_W=h;4xl`P{VXrr4(RYu=2j9G)2`5OG!9zW_+2z~$l49|2GM$7 zfmc5IDp`z>-0>;W6J<#2Y>Aame(H1zAUOnlD(?j9xgamA7 zheBTxL(F(0y3W_@OHQUMqv3Af^!xXtf;z2!F2QwM=_oS4>!5BG`B^Kepny_IOQ>Ei zt0uD3^CIy1Ua)8($smtVSlJu}vsO3W)j!y_4BTT2hru0AGn&PpuKF3fwWOrvl#HSe zff>3azI0;iPTS-nw#8muP0djdF4f*i0<^Y|4|FLn+~R=WXb>H5A0}e1Q}B((65;L* zdQ)Q^k8GocqiDma`e2Kojw!JV>mSjbko1BP4b1IAyq}Ri-Fo4Sc!I#3EG$7)k@njS z)m2qBzj5)v>4_i$PHaRI!IUMB6N}2mJ_Kz-Sw6If^$fPyZ_MA8Brc5}94`liZ$^ih z2^)H8G>+G<1WabdWrQI)hT5U<5zmgMQSDd0_BDVbq5wD|K}O+2bJ{T}dvRVOHUHzMxEqW_ZEgFrBNs5l zf3BC^pNvxw59U}H6DuLBr}*Ew=wywRmCS(g_iFukeX|m6)@=BjZwC18@W2n=**285 z|NGDJVWLHO25+QQC(337@b=L!Aos64Vn!RNhF(7fN3ADT{>UK zMDPNcZ(0%r5X#chGA%6)-uD;_47^p;ggR-;KJ{_~qr2=Q_vE+UKUy|4G&D0aQ&Ur8 z3nP@EV<=yKF1P#T_iA+Wr;8Y5268k&>hkZ64DJ)f^>&;cORsi^ETf1 z#<=g^PxpRdoUu21?Y-ulYp(hKtr_w`Q5qNPB^DAA60WR_gbET8@(%Dj8RH4?*HS<@ z3<(K@Br74R=9YGljvlWzdEaw1c%eI^+s|UH8G4rE_nJpdqB6^#npEA!iObON+lRtke^jtw_Lw)Qp_KmK#MJE!BRAecLcZXrT;w-%-Qh{8p; zo_k`vxSyjxOx5C&gbHTX^ymAQn#pC^aPPA4&G6kIUQw~>7cXaeED6a5%hRCSpSY#F zke!BEUQ#L;g#TAY{()A4wfsYZCTZkt5ii|he5w4RvN5%Sn$bc8`K_8O*^KGzZ{Ry3 zDM>mi?T zkQmvylfHoU@@TO_UP!aubp3vYp1K7Lv{uManv>V6O9VEjZiCE5&#~k-&tOn>Dl2`y z&JX;f12)h3s}{m5tMq_L1+&lKWS5P>x01L-fdJOaa%)*)(fw&`YU&}Fk%fNc*?Mwv zQdLzoHxi1<|7R`-Pld#eT4WiMz?qzMf#?`0=giHjg0 z`~0AKgh%>UOaTb@ZeTB7Rk88@Vt$Cq0dapRa=z@e_ot7H#UBm#ri<14cm^7!kj$|; z$T@;@%0yp^FLV|i@?-$&6BAbZfj+4Y>-fJNY9}uKA4PS}Z=55=o*HRTyd4x^} zIi<+Ho$ZXR|N4sJ@_SFII1Yv=EiL7afiC|&a?wV72}X&o`>5x>*?c;jD&phq?9A;k zEP}YxZE~4CoKOdADB+&K5LQ~K_Qf~{$5T_MOC#Cix=X4V%%L9 z8j1zqTnl5};%O|L>_0nmQluiU>Ul#p!NribrRO@B#QtCsa7&;0=FJ=0j>GDnEg1L> zSJ`k=u|}!Iu(Tak(fuZ?Pf>X}zE;K1q0=?owe9`)mDwVjz4+A43z7S?i7myv?jk<| z8Y#OM0V0-DC8Les-O%yK%&-wP6N<$@eykDJMX+zvy_UU6^SC?iQFW@}UrJ#!>^RvR zFvpaaZeW%BhKZ#v--YJU%OFL zQ`6%T&P~mp&3c-t5h^n^Nq$%Mnm;lkw4rTkp8`ysKJFdo5T?fygOVNWTnI~upe}cY z0HJ@V*!fhna-Jd0?nz4wZZom;_-_JxiJsQ?QB^Hp#&RUjev)v0MtwH>lt_oo^U7|J z0Da-he(3_$8?b@sS*F*8=AYA>{qpS!KffpZZx^r1eeM~b`+kU>yFF@)+K8cdUw>=L z?|Ct6O7;#5r%_4xdSk6C%;zkZ(vYvHqY3^qEK$B@x3YfGMf)ArLu1a`QR8;T_xFjy zC%rUhrEX0O)>1r4`dETE-Zx5#rIKLs2dp=>XY_wruR^X%cUKDyY zJL27q#vDg4N(#ll&Rd+DdhdPU@Iqk>FO+(0w$55R0fn?%v&ML(N4}=NDvxfeBjO|n zV)d62Wpcp~qH{(hnQLn>m|S?%HjpKaxt0flIE^BhVfb%0m|)?N1Z6@UEESL4#+vWGxgGZZn`qLD(E!jcWL{}usw0p=Zyu@-XwzxXZ z^8qlQRf-=rE2Y~C@}D&O+X&vJcNidroQu4^2m}0Ok0T+^H2XQ!qM=mYC}bpbAiR|v ztMz^E&-=eImfATT&OP_-(o@O&B2~q_=aq5C zQS+t^U5spO21_JtW?$1RN4{*QyIS#8$A!g0E;7*!(t4K?^}ToVqqtPLacF5z<}Awz z$e?@OAs1qWf@Ngo>B-5-adG}iywu0-$4ssj@KjCm(tw0+G@I@kw8}{Pc})?$SiMf` z^=iLix)_Fw`G#0~YKB$X$d_y3AdTDD2V1iHF6TL+cy4x(fa088kUcCMKFw7-KCZ&M z<*V@fW{kA&Id@S0D8sQCM8hJo^{3sc7;S4~IMsbGu234;+%4VHaeF1vqKN9yq31EA z>k!qNNoFz*|0ZLU)V@wKg>9Y~Wx)rk%}z*e@Z!5KB^y*K@uY#%a0V07)DsbGpRcy} zljYuu$8mQr#}6GKX2jgq;2%qEdLsv6B<8Lj72+Aj2_ z1p+SwM*WH_aB>#t6`nk0aOimbUU1`jJJVFy7(YwzVB=S-F;izSvt|`+R75!4auuxt zx>4~4yD9WqZT6k zgODBYJLnJTZ`YJc)P}$i8g2Wv2a84T&AjPGIp|H)eJN`w}sD;sYj! zLcy0q#HR`a*x~jADDs!}Y&Ebq6+Xt{y%OK6Nal|IZaj-&>yh|Xi`n&bB2dc-HP)9yt&SxB}AZ{m|J0(Z$L z;o!kaC|cy;aCzVSVg0gA?R>yRyU^S(mXD&B$76Zn##ts8t;E+e7%6df{ryi%Nw_WI z)ur+!+OCIepWqp={klf`)SLBm-pHKu*N5r4B|e(9~hLEJ9STe7EA zsr?RM!z~HrY|^SU3Lvn4YLWJ3d2gjH^I_>NBMH0n!kN5ZBm6y#v_3$I*0T>afhF_| zBvW-%Md&ubTn~z`iP`kc+krLY0b57rJLWi5SHyeP7A}+iXU36m39dLJ$$@CCtt((v z###lFakxI5m-{GOo*p@4ywH?u&8sx^JSA%$>Xc^}TzVozSU3DR%9?glw~std&~!Y2rOme>4*llS=gD z;emnT;^Kjk5$SfmUpF>j$H(asz^v!%N_CrFl(*QecJ%klsm(uNxhpFZSVX$y&Sm@1 zL%#g82juqRusSLR#3ouB78W+p-_N#KBAbwqP?umq%B%>eT1U_y`kDxf%e_geP?Ve>FBX&YJ7x`Y~idJ3mi;(A3$APX_t5M*BloTieno@}ITWg{vZrp=BsbbTigk zn2+8;6=Q?hxfmK!&42&?T|vFcIp$GzgM#DY6BEI`WhEtC_Wz>APgPmjaE(w%o%(+r zKmR@82w)jP9+Bh%JiNTqos>5&@2FrHVu0`rc7VrZoG?Ao6KCBE)Yr(K}tA|)kN*#Acn zUcDN^IE$td2f<76$oZX!xh?)Au|uVzC|ay$t2PG`a}V;lM;|?gYT6b0e0Fvgh{<}^ z%Y#`?K$G~~ty30%EbsPxEaigT0JkU%#;gOOwgMuaZSz4~9j{*=&J+EeD${M^;N%R# zC3QKd>={mCr|E9HVtI7X(Aw#BipRkE@eB$8SLCh!<7q$haJXgelD+Gw?@QqI!p*3iVdFA(KXz~Btb%%!cZ4|~Psa9|Oco9_-LbDkrU0U1Oe z3mf~|XU@F98n8HU^QN~62w4Gu0nlhwnT=6*(9jS6BQc;lNK%8u$YUh_UnujRQp5l6 z+MHJlxDn1pe2-}4%ig$yg1CPOR}2w+V{cJ?wk6^bd#KeNebc!w1ui3y6#H9;xRZ7N z;gCPZ&Ps2h?h4$fj~c4O^k$ci9B5!5X(75E`9Zyh*bpw+>+5S^{`3yC9}nfqXR94Y zpOeeAv58^W*HjsHrju(ZzMpR$m5BfI=TD)adTg_|%hd35(#WR8M|^_$BVDtO$^3T@ zKxvljQ~i&LGEFbhlq|MxRP(=(TJFbH$L|iXFBJLEDWvuupRJ z!(#j-XPoCb{rE~l?)jnbQsCa1x~+5BOts}o=S@c$ZV?~7>_|8^)f`tuge>=METgN_ z!(G&F>UMG=!D6}d&;m!42rwIJhu)|E)rwNjpbNXZyFmHfhewZGjz5AUA`!i()41^_7xkiqQgl(@Kw(b3V* z77Q0K)n00Eg9f3kBq+br&aVq#eNL6@N9ptBJv%u)Ejn|->$Vq6NEdKTnew%gctC*+ zwwq7gULNsUKkWyC4EJ}JJl_uTvEx9EOjW;>IOj6$>;*7v&8J@KGGaD^cR>D*C5slM zmv3@8#5&bcBoX$6Ad1d_l}1EF1msA-ZEVERYNDdZbL6*tmTJPj;u|%GjeI{ssp=b7 zKI<%UXwr$?;osP#mZZ;se@07Me(m7Mu4yH9?I;OuuT?EjeCR=K#u2r#fdfn>G&J-A zzPR6p&_?I6J1$6fnVZ)4X|Y`v25VEZE-<-}@5$3Xv^%MW4YiPdtS93yh6L1o6sTly zq6#CP09HG|Z_?dl^z`(8X+ze4eFjMS8ft1u;$Lq7Y;shP>HTtz_z5ep(gapvV2n1@ zV2_%RP`jWE0b|po`^k(>Eig?z&el-N{2Fsl?5O4@bEy;sUpBU8XDnDr@HoY?%>Ah4 za%&`0AMk{_dRJO3i+N_MUz{TUZo_({c9Tmft}OW2j9uX^Q8~F*t?mnEEw0al#AU;8 z6GNV}dp!#mJD6|z&9_)TGOtx*MBH8vIm*c#5~?1=-w`7SOz zhTJOo-=*vf-Fu^{7(bTbm==f-gSQGOz0Q;!(IqMbp;ExsSJr$YcXR!S=Zd$+q$#zoN=VAhfS7hij@V~Kcpj%lG0M3r$2tuAHlbE zBIP3>*KtKT5NH=r+f>;&3!>dt%57yj{uOHtX+WKIZ&e;Jt zP|x$YYoo0ZrGxeE>Z^uy>HW3{!oIjW*eyU$oH1S4i{GAb&l1Shyi0ED_}+j0t4D-W z6Jd1-pvEu#R7ssSY|_`?Z}AfC)rA9qK{?stzkOpbBlpkRLA3y&Chuh4STCV%-oQSS!e zMXJa^g|kjatyrn&y6BAa1wgquudhcIg0vyqX^zD3ncu{>0Fbq_19tHp@5=>8rRg%= zAbiSJi^;cGL{RTBNutV>_V?YG3-Cv{DRik5XqFcm?{T)n4xmfcwmXirTde!3nM$bX zP>STmRH=55pX1_F>=z*6(-9QxSIsq)Mhi9O6TLAsjho83Qe!KBF$UXY(vBA(gtBRo z)t|UT9D=BAbgyYiF=1D1<^;@kgRW8MIl6DupX&$kz zU)*{jEVO-hIwHNfh;_oS(yeKhLrt%k793b*OzDKLJ~HY&ss52*g7$cTr78SNo?G%V z=rT|6P)me{{~_1`DkBWQL0OTNV{NT?L}RXW)E42vw#$r5@m92)N@qBLH9`=60 zEJ1FE~U_HXtEdNoti1qm2ve8*sa)htW27j92gl_^%MPxmkGl5Byu%b80ccsDg zV8$>m^fWcbTSHFnG7 zIpX`=ncdAck-n$sNWI5S^jQla?II~|(~ms2fdDf=Zr{VUBp5WI@VT>pm|x$m#26LF z7lQc-{{4kQ>wH){J^nuP%ifzUQJ0zK)j-mn?nmzMCa#0AyxDI`1OSfo;~yrxZqBGg zklo)+RidD(1oLD`6r?%y12XgsC^Gbb8i_2ND;L5OP(92)pm4aEsK3cc&QB-hz^|DPX9Z{T+nGKYDlY}y*qBqlE^mR11#gTN}9`*u`Ar^nIQ|s-n*lqMQ7=)(?a}2me`E>LyJ54C`^23E! zkQ6JUsJa7*FRlXXPn@-jCj`49ma1-UrcH^gm#Ug_hP4}A3koczzrNIe{^Nd` z-6p}m?PibB6PG-s)!Nnj;d(p%Rf|0^<4$SvWHhU!3_TY{$NEeih5mV3{b58hm;s;(4$JK;sKxTHMs-+A<=3k=t7j@ZpxpQkcfSm5_NAtGyq94?r+Gx7^? z91Of8mXz(BY+dHAS;6W|1w!HPP!S|3*1>5fk};|IQbYE>UdBO)85gE((!naFKfGRe zTq+gbM&+c@=m_X2(Hhm2zy_v5y~*?Q{1yqjrcDkJL@zFhbmeV*2=OB<(N7N65>U% z=wuIf+al;ms$U>fR_mU!7^;h-)3su>+9lV$gf?Y!xTiJu`pLZ=`hU zE5*@LbBa5BC|qg%m&Y3Q;dT>7;cO-9G$@)q7ORrhjTp7F)_Rb(^B7fp$$gt#ThzAs z7|lERJE?Y3FXgeBM-sEAJXTe37tgRns3yTh@~^I@qgK+h?skfB%X~enAoHI+DmprY zn+H9kBqC;{-O0(T`y_0ICf#mTY8!0Ey&wdu1kRT-nO7ey@)wyNz`mbrr0gmQ4vhU1 zYbxKU=6@(%^?{d|kNO9S-gZLf%3mA$S7`h0ZpDk=hmXX7Cbk>!(X0h{hnF{Yr?0c* ziKbF*^f>Fhm8=!KGM}kx?P|uJAWjrU<7CSgKffIC4RLEcS{ud)B(xQqw%W!=o(!a5 z)@#1%BGPZ(FKa4Pe0EydL-BiX&0YC5Ap?#ac`gU73@zg?Y)T#pXd@O>_^ZK`(hL{~ z8UN%J@cCy7U#W=~V?=AEFdBH%kuc-s13K8-q(U_0gAy3?^^$J`51qfnT!^CTCq+-- zi0{rZQX;n4KTU@stKAIE*`;m8{_15feW2N*RMGwAsWj1P?3r+JbOq zCU$drxiZ2~KxQwH!oeS@%7%hFc$B$IE*D*whcGNCaqL*h&vat!8Ysm@9LzA0DZ{!P zXXspsQe1zD8-?FrQ2EV(u5HncB?&T#&q^1ZmNy7+0~$Y1Dt==V=s-e3v3%?cI4v)a zlS=Sl_~NNMKY1t=9h;(u82b80#8h%WS2~H?>LX+s3ueLg+~Ve$Xz(r8v4+~}&zf)f zBYa)j8oyaR6YbZ#*)MCV<`*)izSq9hMg4n!*H+#l8T&n5nr>@dbwt^>HFlF{8aq@ie3bU*wYR~u zWukmLwj$dfbH|t+1P>EMr7~9mM}F@3NrqgDG%$OuX`tHfj-p%!iez6&XFa11pcwpx z>_W#RpVS+8I!i!wtE)yI*vX|fzQajbs$?{|SFM0PP|r0>7Vdo8ytTqu9^z@GN&8lT z(lPwXKPt!i9a%UA>Ha*hhuM#!e5y3gsWWJQ0_K6qhfGviffb9_D@+Cwx~>>~fkGal zVKgghn_D=jt@fFa;5J&v@hGria|X;`-27ZbQ$a_Js4AF_YikxyVjN%sgnwD~NrEo< z-rN?luYdJYzQPZn*{N#vOwPHA2`Ec zEXHGR4_w(|{C5`au1__e4gy@MtgJ37o|vH%r$nl1Dq`98Iq5U^)w06E74NxE)mxEb zg8Sf)_40}--X#M8^#HW+`ZvtySD!m#X*wcQs_%9X6DAkRyc2RjYXHI1?wl-2QiTr( zymtYhrmIGiDmhi`81--zn@Z+M#RK!oXh~9Nfqw`CHG)pHn}CBU5)K^3-xsTQRGIo+U`FS4;xG`JsuE%LJa!tqW$oIj|4@p* zUjs@J8BP@%g)#+6$_+vNxtUwx0yE?6JbUaYpo%2>AArJoptEEB_+J+YzC(l=f;SG$dY5ol?_Ca~6^IgD~!sa?!0 zd_WwnfBq;bDe2(gASV|(-467bmj?A! 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 f5c759fad8546da967cdbb07f93b034d78d293be..8e9c01c7fef69c4a6aa08b4c796493338c671b8d 100644 GIT binary patch literal 10270 zcmb7~WmH_j)8<2PcbCE4ArL%RaDsaR!F6zVw_pkG0fM^|2o8h0yF+ky*-QRs-?O`C z_w0GU49vZA`*v4VS3mXZ4p&l?LPa7%dh_NDs*JRR%9}S(d%*uG2ycN;D*@riH*YZB z$ViB)xuqXwz-SjA5 zAD`Cy&`C3g7a{kvVw=AQu4IDo?y`sh)Rp|&QjU#~Zn~R@ z$({^)kzHwLi-_h6HRR>*IAt+lMCtj^$OM|ekPcD{LN+gslP-wIr2;s6@&)f0v1WEe zr`7wh=1&4SLcp5)C5WSGS3SJJNqS>Wld$!=bekb(0eB||+}G!03ha;O>K4&&K?8y`pPqpcy219n%Q-4}y;&g^qzkk) zh_Q|bBYMgy(FiMw(SqW;Et_8|{CK^z+{{^xo-s5$s0phb4p6L^$H9wSnuq+Y#;zJO0{ws?*U#VF)LI4hl{22lZ8fF|>$>vrzC>7>Ec8mz zn{9q4xXFhuNIV=!^>P)bK?tzZ=Db?Tb7zp^{nfmMPW*m#^ZDy^2VH)CI*z_BM$llI z#SJa22!Dl6@LKs5Rf4L0D@4e2WhG8%6L3YR$*9$z4XGh%^^x8?GG70??qsFcHs^m3 zVAmh)5pG+jvn@$|+U6@+&{z)Yy0nKf6U(SQwW{qf|LJq^+$vSnZ2DK8$FkxQ@=kOquS*`8m>n z-Sguei*9|aLKErPtJx3jF89Xg=H{3xTB>6OnKa9(b+7iP6FeE9Vu+#D+kBori9Ai6 zsb;$Gr1bxwbF_nyx<2lTysZ0O4W{!s#;bPtD5h{q;s$OctsyZlS^}Z|azEQPCF!sk z#XIw&k47d~$eN&DqJc`lo~>PD+OXnr5F_%ux2FAYeI%!-sKM%&N+ROZaw<5O$Z{}O zm5aqncUtG|%|>tR;c=e@N20Y&q4PaU|E)-u+S|^jhm$rsTG}tEhiJq+rJtK!NM1iS zy2A&G(uF)-y2CJw32@qAG?gCfQ+aF_@_id0j+<>8*F%z%+2-+quDTGGzd3$_W{44)`q0TBx*!%vVqU}oq`18R`*|4KUV|#l$ja=e4V&3Di zpW^S=5Pog*!yfQD?@zg3?2He%E<0;_JqBy~+s(Az?W75LUUP$&ZdW`{t}ZSX>urcZ zF{A?5K!ZHyV{7Z7Algwmwmuu3EEE)9Z;qEcIy&6$Pg)$*^M1OZm0OpwY*F=MwW>12X`YIQ zi%}8X`UJ(iIJ2Q-|67LyBwW_VqP?TVhEJ*7;ST1gdJaeCbJZSK`}#h2!H!o_iyeN-{uKq6#DN@L-JNoz%2|{Sg!P0Rs3<;86vdCBK6nA#KX&nC)=H< zU;gc!yG5&z8rxa=MPp=Qc_$>o>%*lS!8?;#%CKnBnKngj3}lw@kq=VOKzeW44x*|Bi6~h28dWEU+PQ?>1Aay-+H2v*gHZpzr&1)z{ZIUt@M) z8}}_kz!l5ckVvycJjStd z?#C*t$8jkT=IR<#{>@rP;5=!*{0^O_D4WmiIy`j(F191t4*`!$^9Yl26^<#quJ&_6 zZ7Ho#6hcGw0F$!olhxHW-xoAe{_H-Jf%x$bq#!@o?x&m87>|wN^&u7A{g)Aelgi?? z*4}S~fx7pXlb=-KgHZnNmO{upzrn5N03MPW=Ig1aO-!n{DTyNHb#T?=5D<8X;$0b= zZU(|yx8C}0Ba&@G;^q0K?dY16XS1jvQ*5mf33S9$x-20lEdete0}Oj75t%p0!keq|KCUzkwVM+-!*|X4F`fkl{J*cwve!pZ^&v6)VyU$uXRK}05?dCtINwNmj1(p2>R@&>Lm#wOj`80et+G#6>6AMi%_^cU2CNw5MBBb$zfWepIi~uTFb(&Ndvb?MXW~G+$K&e_0 z7U601xY9t!&xf@@^wSmf7#s-{d`Pi^8*wqaz9WbkSTz*ZRj(HvkP?nLi zA^3VVro%dQmOlsMKPo;dIFTfwTHKxwvW=5_S?YSN?cXZRD-(V)gWv#}Za`>8+2t*S*2zBsrA;|HPcl;XMbmI zGavOlZj#z%Zm9>-=`X51fkR5u_9<1%=q5=oWg9#~y~y20Ix$Tyig_?+0h% zw0WZ9SB*{3di7tU3<4Y+V;WYX%MmZZA4OrQ>=%Fe^0_C!XA<)}(O1Q(7ARs?^+Zl} zxHgm56|x7fk#8oe!pll4D7?FAzT8MXS9WoVFlsfa1M{HWN4plGuzgoTwEubtzu>)S z)9_hMq1_sEDJMsU_Wme@Z~SlR@n6ys9;xqPN-2?f0Yo$4oK`AS?_z~`T$fMX-zX~h z$G;WhYcB$+0?)lCz|czM@l-q??>tH-SLQFm)Xb3k)wJdTwCY!j>0fe5oYal1yo37@ z%|7eF_*&>Gnh+I3`zxu#?Qm?mu>=TUHpd~SRIRoPwrJ`>El17Sa%l6&HmFI00u{n4ge7%a zib)wWa-JL%E45At809Ew(miD-$!Bi!fnUyzk_F~$TwpO3r&_v!)JzK@ zhr=xcHbYDy1ReAYtTN-tra8+Yb!q0>YLR#>2pVfr2QeiYWrn_3s>U}GseN8v!S5mh z>w5S+E{1uvtCt)GS>(CoX=x*d5f$?$@62&sG(X}Vth4t%Oq}zi0|O1(6wo5rAVke4y1Z*++fPIi=`loe(uRgjXd|YcKScA;arhDyi{Ch99H|(*Riv3;Njb_1 znSL_%6;_aFFk1ya>KvSx9hX)CdYHmR8Cr)blyEioRxb-Oom0K(Fp-V<8OvP2On&e^ zc79j>#IqAgZ_(+lFYL+N80=|9?n-$`Mkl;F7*a;Y`|~4p@1yiyEDQOg1Qy8aRJ1dd zS=9}$83QzE3WPBx@ zl->MZyQ3NJw;|>~aa<&3B?aNyJCn?X1dH>BNE;>J-F7ovH*V>usmH*1+%^k1h&8&{ z%{C^ku@vesphA@45uptW9iK?>zBEUN377p+qrD4pec#N-sks@WGC!7XwDIEGde{tU z%?(afM^cSm30!Sm6J`dd_bP;==9~3tRa880G65aJpqiiEbeZeJ-U6gQj~jv}v%J4o z7{QJdLm>%GlsF^VCZ2@;`ReoM(`5XcP`p_=mju~{9!1n{Y$()E3)E*EOLg~G2Q{Q7 zX=QzN%}l3=Cp?@Nv+D!yw~tofXZYFT$WUpKMb|`RcRexbZL5-^_Ps$Jlc>V}| zUDOrqGUF>|{4%MC>2e2(s{;K(-*9=I4k32xFsQMfQv0*gBSn>TmTO5QP-qf!I4tJ4 zcRV~|15kzSX8Rf=oq8U!s*^s(F=!$*JZgjkmq}wc`QZjGV)fy1{+C({6AYALY~ug7 zGVH%~i$3-6(0WkhvizGq1)MroUj;N$06C5*P;flnT^Jb}@;mKis{&HI)^S(Sp0!l;|83l#q%CFGHQ7T#Q?E~TgWpuLGz_!i?rTsry03#!k zsD86c*>&^g#zy>N9KpXgVPRp}{nD)XvRr4SX;R1|`aJ}dSffnG8Bko-nzVFu`U_bl ztil`i$N}VY1%^Elz`&J5ZlWxC&BwA$hf|wfPp+@7Y=EB06xCzJ05$MWas*54g^zXf zIi%OqxIX7KRv54X4U4OC>nTgCj>^m7-&!^7*WX6rAT?<~i2B!L>Gm>T1? zfezuqYap)U4*8w;weFW8F(9crbdvY6DJezY%o`op{?nHZHJfgohh+qA&>bT;cP)@x zw+EBxOjUl#;%>WiZvs_#c6*q)1BYH{a+ESzX$2K~n)bFtr|qi5Kfnb7|eV!}W< zH9!?tAp2UeqWrfa?7#59|MRKCN%mEwRb6&Dvjs{=|!**W8pbUq-W zq9P(Fe>oq_06|r}^$%H82DY_{y#3>LW*F3*+421J;QMgY0JNl#B`h@m#zd3G-&zrv z2XnjoB^4umebO>A;~=2476hUIlj>uw`M4pFuYp&ky|+g#J*H~CsVAIOq0&T3gZKCq9ClLSZBz>${Jta`f-_Fj?^0KolHR1oW%)^@M?#_;SP)|{} zx(-Z$f66*jj6{vASw| z>B(#!=-QIZ{TYc1RRgKu)8!`yqkEc_70Q{_Q5unzqhft{6y(e1lYK^9G3b?yX1aG& z4`cmHEndJ#l_JhA&cYe7m1+!6gZ!iJd7Q&5%IuFue_z`W4g0==Ks&PYojf25{vTFj z{bv7C?RHQLn^u1)^xgik_FRmzOZ-41a(}W_v_T{KId1Ix?`^rl;x*Eit2;&^{1o~)Pvv$!ReDy4w;L|D-3?s}TRVstrE&-fXuWjnGAcuuzCy{ibP zUS!NCw}!ZsHZ)De;Bmd5)_t+UWokuh88VTdGWZ@INoR4sL&U-Jg_a{1uGCB&SMk;M z$39D3dZX!lH9(+HNk!Y~RT=gGNeoDP`f7+k*wZPd_YDke=nE$Tv&MS9`cW51RO(m~ zPN3~af}6`8-3xUe3!>qeo96w)`3tT@FcW>??h>cE&<|BDbyzd|pOY7I(?I8r3Cn?a zlyDyh0xS)@JOBlf5%jvP zG$AXA3H~s`=eW}if$)Nt8eNVSl$Di5;Ydz77#I*?0m!ndY`5)_4%#$L3USL~{5(}b zCaG4QZ7Z~&$fo8zNI8&mD zUZ;)^U98{-6fLa5Tj9~s#|w1_S-#Knh00XrIZI&|!D)Z4I$bSBGKBZBagKL)3tB=8 z!z!s@MYxol+WgqsG9LN}B#q@Fi*oPnWhAi& zcvssZFYA#NPR-p=iSh13+@O$wJ$#sTtQ2kw6$1l-Z;0BcTUo%wqZI6dz zbswi@x!!!KHrF=$a2oG!JYZW!*LB+U5rHg6yXDf&6iAL^1H4AD?%#IiHlmDbPDSuy za%NUVv3g`=nAM(UdGUGGGff$g?M4Cb0?0~OSn{!NW~=`Pz#6Sm&b!b>E{~VfY=Xo? z(W(lS(b`?G<*4Dg@cU~mCZoxOB1p^COYktO!om=m=c{XK>WLltu05I1)s7^sr3ND2dLCK`78lf zu%%_OuxZGu+h){&Or@ELKxh)1{z{Hyq}Yhg9}i~_4-Y`QAP=nFvR{*2A1&ShfQaK6 zNZ1wdcK~ z6qNx&g%1xnFrT}G|B$y(uc_Jr`Pjqp{ILIY`A13CXH25ltfV@+H8)v<-c)Jl>@Rkf zU3*IoyOWNZtyxJ`u*IO-sN;Gg?PMC<>q5QT^2pv*&tgR zIi55Rv=vZ~nS({~g@6qX$5A28JjCH@LnkB=)Sk>q5W&O1Hw#9 zgFIZBP|%27?cZJ*lJ)2Yj-uLervh0+OqTCVlV3||4U|CAZDQFzF_t6kf%Mq?57FuG zG1j{AC&WrWEiabh|B9|ptGB;!6M_-`2>U*JnI>`4>RMYL0yOfA$6uQ3Xl0~pm}3CJ z=ID4e8~1+)CLbRM2WG-nZdM1tyrQucc3%Orn{m2W3c*nG@o!`^65FH+m*b`RDkG`u zuvPDy&r_$t_+RW2^?^`@_DHQ7Yy)Q5UtQZjJ&Fw`q>bEAjliU=7J8Kgz&v;GD_N{D z`y`0KjJH{>n1RfeTt$5dtjVl+Y$2$GwGYt($L5pyQH(rOkYbK^(Nm!Wl=IBXghAhH z1`=5!K@z^&U&5St9Fr9r4HjVYXMLWoG`+s?r^-1ZdPMnjJD?D-`zccRX#w(@t?luZ z-r`7CY1o9F2zuW;jqtl15}+Eo^D*}FFARuGu;Xf@86nt@6~;I)*1lO7ZY$&&R|hwp zK9*(`(p~+0SKhQk#4`^h#)VdIPm^!%PVgL@p&W`d4Q&8>zFsoNDCsFwPf|wnVGTY# z8RlJ;Z*1o=0m$t@WizvY`ROa!o-O}OirNVKTZVTHpdd0GXA;;c?|GMq!iKT3a0B2kK#tGH;SCv(n@#=^xO z)M&rWy0m4#dl!6M#BE@<_l~b)`eI~fn?%5AsG_s!qwAfzh4XKX6Y)6GC(AGRbBCha zAVc>V0M(VK6?LAPT$-Cb0^1kuA=oPIp*=#P@$+#X+HND18_h;D@%6ppcBnFcJ=+rl zdwryMoF{EB!WYT3&}$kYqtc{b2?p_nu1wBHd{y zuE4;MYOZX1jK*duGWRWicrt=_mN+Rg^Yuq31$yJIHMQ^{uUMHu5|;=|Y>rAx`6#L@ zQO9{N5RWy%gt%T9PAN0z{a$WNbwl8i))xdLae*IqA;e&V5GJZ*>79a1kBG$%f`drI zb@o4d0mld~S;?nM%q$;;623MtFUERP=$y>x+SaMqoO_-1;0MB^*L%(miGz>oX1_FV zC+cbuGnnPpEn-f?aidkF|5Q9Ui(9v0B{#1c8j>IU`gpgS#ih~YO++HHTurjbn%ibx z4(y6x^myEDCn1u|=UdNJ`tv|gYG8h9;lW=bOomRd+y2hAV6Dw$;uJ&3U0^27TH*gpp2BhL`gVxmxDSavr-M90_!rnsA?+>1q8GjsYjkKcB~5;Gd}Ks3Ae)72~teS+%xwY?|lxA)_Hg@QAra`{=ptT4Z!jl=`j zz>^DwbiBVduUwi@4n`=$D}YkfX=?1qH; zfA_t1yFPv@He|`lWuQBa9w+hvDBrbH-6w8fZc$NQ47((Y4gB zz-1yAn4(`HPX&Jq18qcfW@Pn|PZIb-;y${~LRQGubk)$Od5|F}7m9_ZT;f}W!ULM^OQAP;sOKYi>BWqM$h@pY8{E4GOfys0Cf6Kf6h#Yu4 zr|UcYa4fobG<+7}PV}MW;mR|4=Cfjh-i-vit@=4rqMF^YtoZvl7 zOE<^mqz1`p91Z?B^~d+~B+Ri@K}fp!>|kXZFyE3Nv__$Zy`!T2y`l_7;}sv6kj2Umi7U>+Q-Y_vse{Ft>^v;AawD!ffjrbRMdjBNm8$n3*b_`0Ti|Kg{ zo@xE6&%;r$eo}`*WkP?TsDB;<|}bS}bAL2qAwMwjU(#s~d2 z;5LNd6xfhRB$SL$CuQd1T3q!!5t@QQHCzmJmmKpGq?Oy?a8vIrWL0;SFI+F`Yg z_zsq+UEYA#CFR5i5cWqY4d&&p*jS8>vhHAZ+|r=r6^*pQ9)Hh<2rH`LJ`&veG|-cY zh#m+#HG6m-F567IqB3>7B8oopt2{ri+*C61A7Do>QF7iTh4XQh!>HGQT-$PNCt0fm z=fY`89u9g~4i4rX*a%e#K{c&`itOYd_Opf+0dRy9@5fmO`30d_n{EPnGe2H~D?EYd zx4c#pT zU$uI#f2W=|MlLUo6x2%#(}8HFyDdtBkU* zDT~0UDRgL&pZRDVQTg@Qz$Z}^trc{_yu`RP0GVym*nNC#@!QAuSzKuQsQ$c}eThOT zddhAMB5)p{%FBd4A23B9ue5Ew>jlG!83Q}iqmqbBXF^M68=r%|M-0gyBBa?@RS@E1 z1II^<-S*<-A4Gm{;d{cqFtBae?G#0z?T)+sV4|E|cLg)%*; zc%z0LF1CUq(aU|SwhX^Q%@!)>5^|Yx+pe@^uf4;>^0HBTm|#^V0cvLFlFzrWQ}vkB z>I{aH$6Z&&_>C89$_-vWi>F%-@mX5pRQN`gW3WR@?n+cf-X0wwWSh z2+?ETG{+sj+3ahcpPhOC^8L>uZ*r5MWCE)x^YRQ`lc?yb#}vz`Ug8AZd`epj#oVt? z4{$ZI%RqHcCaf2pQVZY#0JOThyIYH}asvgu^=B+a2Fz)Va*X5Svi%^qhc>Rils+Ww zU4&0h`j}rkKI`B#vTgA-Jnr2128Y7ps;GBpJ6R=QV5t=YmHk=bx(+}NySpWfbzb0* z;k}$5qsTPZ@bwD1xSkdn3nt3#qxec(hQ3sFjgIm%zb9v&V{!$Bx0Wsae%Z`QgonFD zB_1n39is6ZG*u5Qw0v=qwL|`8^dG>ob#;RGd#j?w3V}`jGXO0!#lPIcYtL~}+|M+7 zKb5nPULnW=9N=ehK+WkjKmd#z@qUCI=jzeFGV6dz(MCDiYF2=<>2d!5aoPVy(r!OW z1(6E6sJxytBOCS4r861`y`$}clrF9pi`cw2`92OftHoVTda%g-^E|pX_mwQ2x~W{7 z=%r}&?8+;G^|Bh!pDt?wn1&&kh9smf@VE(pB&cVQ@J#MY7{kBc8-U&2Cw9ecPcOKh zy~J0y;Djn<8*RFc0ggrPMqTKVwFSppUk0gK1>K14}^%NdN!< literal 9606 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 G2L3Of2;d|D 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 465ffc0263d816e953f37275f9ceb514223f33bf..8d232cb2ab90a08f637f54576ea195ada3ad1802 100644 GIT binary patch literal 10477 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�T)Xjeo%zI=0Jw}Nu$swu0L3TL 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 L@}d>O`houi$=aJZ 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 1f238a4619657763f15f05e20656ec0f08a03eb9..b6976d6cff55dab62a17382b98838d18e49ba732 100644 GIT binary patch literal 9973 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

b3tsdW2#AyUx(s?ztAX#V`YOaQpN7 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 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 fad9aa30b923ada5cbd0e6c1f1a5df83319b1d39..6c90b6f6102d1593f3dd15421757624b6cc94313 100644 GIT binary patch literal 9945 zcmb7~Wl$VJ+pbA~010lvVR0w8LvV-S7Tn$4-624Ndx8amySux)yIZgY_6#}oR=xF| zAE&-QTf4n8-90n+^YnAwJrQ5zC6M0Zy@!H=LXwgcRf2+ga{zo#g?|ftTJZ`*K|!HH zNr?)nxM!SX!hOe@eeOTM9jdCRs4*#?QWV0(cdg7Z4Cn8`i7}6??7@}`+{+**lB=bf zGpCPVAdQUF7b;cJ{Dv&EoR2_Hzw8Fd&IFGeUo8o2wAPUUG&&96`JSJT zr6(_ys55PY7#J8RC@2;?;X%RX{CI>%ge_p@RfZcLR@Ul*f`YQLSdsL!v}jb}@HsLX z8U&P42sbzP?CdNO5|S$f^11x)nlL;Xv3|}UjqL_j7M6tyKaPG8C4z5GWWlJU zj39!*R`HS;EL(gKvoJhp3nv(_(Uk1RtYmQ=sxUdsoEw!HdZgSksRlYr+lyQ zgS_xyP!JUSY0Qa|lHw{L=D_!j1Uv}+`_9e|qfXP#?k@1qVfk{3i;MH}46pY_EB#pO z+O8ObQQsWQ6f2Ks@r$$wvr|xnKy!`s5OCXqg5f{g+MaQ)dsjQ|4x#qnoUga*G`nio zSueO6+l%pX_?FV2}|ID*)L|)=L|RkB@iQ4EZ>g!99J#1eYbu#&uDZ#^+Jh z>;oK1>a<*M#|aj@J6pAD*bGGxxZjAEAQE~mZ@H2-47Y2!42`gxE7Jk@U?|{BQXWqJ z?9bx&ItiW4R-=N2XlrX*k@lBut{>JN&7#7Q*f6zRbm5w1Fnise935HSW_awSF4x&q zFZUDsc{`CdyPBAoWV)?+c+XTCNj!4w)|!suMUStwdZ9g6j|<>`=<7S0Epa^J}lf-o-$OYsM#`ow)^J=#8@?&F}yfKop z2g6SabYkSkM``O=V*QnhXm|6Tt46}Vl-K-(eFv{89Z4`Nk$+57Dehn)_Y--Qu0}MF zP`iS|$zpRjs{%f5OL}sV#vUvv94gkSk7x2G>vFmrsbrJ#9+y^!q7cV&Z~0r5w$j685GWq4cMU__a+Rt?R#y3T7_jOk^OhNGZJG5i~ z=x$PiIFr+EEzb9bjGju>eOiw5k_yAd^TcIdXFoBJQ(fhWp?;-GzcUby$lc%fB{BOc zIMe6xrt#@&m^pof@7tvB^JBbr*?_B*6vAC^-#|Q>p7&+%xB&R1U+{%Fb)TLC^)igu z*L|}qq<+;Ezwmy)$IAjzwYApBi^xQ!>$+%iBJ|6)?ZFiDV$m#nSGmvM-=#lADBnJs z&=4Bix+7C};qdRv%F?;-U9&#b?a}PS zeYhMrUgktIuyrc!S4yT1u&cC9X)>+ibL;_0@)wr+>r&N(Cp9$M~@A11uz*wD}>#pvY5Qb)#v*pkK%W? z>NZI-B_SepdR*RaDrliaCJrb5UMkGtM+_jgZ@%I1TOkMnU(AdXN~#mRUp7(vE)+UL z1kW4yiDoh!z_s(bBJikkdAGj?WAstheZe-~84gP@#iMELQE#B&p6|EgoT~MF{>~;) z$duYUpDtkq4Z1YaUcao%|FKN3C6Y%zeNK3L*ie`=)2G?H7da5)kU_I_ zgpHiqsZ$j&YHS!=;FLZ(OEST%f*&6dl(0qm>6(*&sgx+gmwFM}H4Xz7A}0=a(PJo8;h`uz*P7sb?;(!l5YO{O99mJp6Sg~)eSRUP!R zGoh=;VCAeG;v0Cl^9Ew;bSyCPxe`ZWW+gY!npNJuVpRlT;meCU@`*%rc+Ez^Bn>2E z!Cd?R?6|4VM@foxf_e3@3dE|NA2{8On_R#1-O8byzytHU4NvSF<{ROPDV5v3Qw8; z_lOr?M9$UAcs=SoB}?}zXzOdHS7lHomw5+s4t|*>K2`8mpSzK)tO&&bTjtR&r$X)GarnyLNchAeVoj^3$c1Y>wO6}#J`)HT8|wkq(){944)Rn~;I zTVJ0{9`j>>;~kAubATcVx*;^#%tyZ}dZ{nHg{23rbyY=k>rZ=}w_4E!^t1LLS?H3j zkuq#1UF)9Cx9sw*cJ>!%h3)l2(&Dm&ocxLUWnikVnB^`i^fu$SG9Se};mH>jG!a_% z(yZX6mj$9Utpn=mG2Be57nEK|1=xhy5CK#l_| z56SIM6szOQ$EfsGk)OZeVI#ij{eThQv4-9#&VPA7E(mFH`Dy!Jf&cIK^e(USRtf)< z=yltIq^~8QnzR+Ll8v@^s1mVlaEsm?>ajexl{D}z%e!ryKtm{NXDIVGn7g8jDwK* zMXvxDmndB9u@v_mOn#2jlG#9uJr507_DS=JoU3^@w-*zqMiHa@x8nzTmAviS%3gna zrZt?(){kSt)h$+aZ=AMTzjRbxb*(ZlogRB?bp#?6*3eLhu22cSQRrDfKe*5gS!-X> zGFSPYFrL3CH5)uFOs^846N3sz=d9sLd^%6h{|wV!=yUH-8zbs(TS zitLq&?mOFz-~0LhK|XNr@*Y%S3)ViTMH3E(BI!3lrhJ)Le)B&mN~DVcK(NqYZGaLG z5$XXn!$Akie#uYdD*$%s|G(JvUngi$^7F6m&o1NQ;@+wY7{;w3QwO6meEITaqTX(O zXh`}ljNSA0#KKSBbG6B3cXyYv?q7HGN5{vfp{l2|CF+Iw`IW6s`(vo>S9nOTBckDu zf&jqiRkmAiBebX!>{d}x3AQW;ntoW#)E+dJphzhI01dQmVi9fEA6lvPh~YbUu!L@d&as-NI;(ax?(}Gyv4@9;RdL*u^X0uc?*n|BK&HPK2^nLC^k?_CR3;h25TdT>L<~ZrZ;zyzM4nWIY$)%m}WV;SFncHgAYX= zV`OABZEk5<(W}+|`0v*Eb4iJb!3LbPdflaQ{nJ1|acL==LqyiAzF_`K75{4){r3s* zp8*C{5O~;mdh*8bJ2>?RB;o(^3`TL$s5Tvadbl=%bSHrRJq0Lf85usoN(&0I!$w{}X_yPy;^R@znCG zF>*a)vXdVA4#R?!X{C5}88Bb|wVv3_)73uW1I-ZFj2fL|%c^Fae@E{)E0lyk)w`3P zH(X2WF3dpQ0<*Nqs`A~w#^yI8m6J9wFtD-t6G2d{y3AtbYE9^D5Oy^lgDPC3sutH! z*_}Xxo4%J4o{*JA1cZi?`sl!7llxzT`F|!&&eCG~$AZ$A4$*)CUfXE`KMU!lHXa&& zuUY2AM0~4bWhbSh5@kHC{X$Z})#3oAet!6=BJX+tOJZ%(cUIeCxFDZxHeE7kJ9cjQ zO(Onp*5sFF<=t2t=>GYSZZ?v3c1kxR4X9f=1(nSCrs_v)44DF8k-a8ON=wnZ+OoxC zv&(V7g9|5c?EIhk7OfY@&D7M?)z$TPkkscR!rQY|Z5svP+O@Tl-1Gxs~{*2L0kn~#@xTTlVe zfqmL>KsU)`xn4UmAx}lCQGcvzi5m0JnezLPkPu_``A)9w8k6I>KLzY0`0do>)~Bu4s9Lb-8(wx$BY!BM%hUb zaX#e$)&dQ(BoT)n5OD@s!!+GtDEod@O+Mh;si~=|DwcxZH)IL4w6wy_MuO9kLm%zK z?+Cq#f`1zhAnmnp9VHFYK5Rn#v!^x!eaO8ls)VUIHVWZPc;onU(~sK%yI7E_+ESRY zn8v>WOWX#3-~Kr2&i+0MK1b=VU&g?KvIc-oML?9`S;O`#$^r=?bphRDv#;>vSeE>y z(^)yHQy`{9+m^Yc^Sk8z=WGf1K%XI0_lw%=1&pS~P^og=)>y$8_bpH+uiNbdyhMvZ zcj(pC6|j(2Gkq&*^8x!lUG{@wK^H7QV3Ngh1ztqOHAtiv!Mhzais&XS>STV`pyS@* z5GA&~Ofr%>C`G?uCbbska})ttv0rr{tbXm@93YEpxtWpyi1c)^+RR8A9-0LsPX;KG zHi~}6@|;w3tCeaPh)jv{X~`ziZ`O*G)C~jHaep*!_pIhFOC@Om$|uEepJq3%Ki~pQ z?Wj8S{4YI-%4v-sK)30vV_ZS75da=fl#sh1yTZQBZ`%yT2U1U?!?t)c*P>yXNEkAO zuwhqd5Vy%>o*1QCk7R>#!YGZ%^|hlD^59yNp;I?5KZj2P4@HTS>wc$L15heenc&}j zS7pN&&c@JG)knX%8fL0@kEkBwv0AFJSmOs)Y%c)Ti`(Thc|zI=Diz?uOo7aMx>Vb( zT&maZyI5t?sWy)AOFn~(nU3x_f-1}0VX*@6)qv$Xcvdns3Gb4VSBqMfex!{@8bTY6 z%SCT3)PdgTF^Mkld^0VV&f#$fm=cq~8eOq`W1kiofeO%(W|grN7jLWnWy)bYWrTChy_@3-j@Wxb6tkuQP<;E#-||;mPBNIoUx`(f6iQCvaIF;# z3L_By8kP4GDL7Hzz##YU_r7!8@W_fv{m!>tOadM`XNHz|F317ez#zBiK+-5v`+8yZr!7aIVUkem|@!;t4P z5DcUR{U%w;0L(MlRA}EGzO+=w`7#}!TVkgXX4&jy?%?gNF1UbKQLYNo48C4st}6Z* zU?#n~f=Q?+{uk*EPoE+A&yZt1h$DVu1ltYX(CrU(U66d2syzSSnUb=mBk(%pg{Cm0 zD?7Lj@-XhlsN2HzL{;qQ==jtP#T6vO*ccpO0Ql61VlQT@>gwD1vZdki+qP^{F^3O} z7Bs!M%baJ{D;4auMG~E7k^I$a^8~X@tkLW^gt@7S#Gr&Ktf;&g%Rpno(|jOgsT z7Z}c3uz5n{v4b{8T7*C6bh+*7;|Hv<=KX-9S|qmZ{UMfn2C&h(I=sNA_0!2XIH?+n znJG+o7$ph~s{l{M89V?yu8*0YSWWA*>N3xN(E&|~Ui!5$!7AS4_fy~p#{1`UKZW~| zbhw#}0`c^{IeTrO1|i`0WP`?0ZZB_tNqhK+Wc&AMw(oGkuJvz{+-=n`qe!Pk&Izc; z6d5N}JiDtVI5B%#o;PN?)itz;)Hf;iX347j!5!6;rB=$#^LmtXcxWhE*=mJXJYs5N z>T959F3}`!tU;CIloXZ0ViD!g#jLs>g=RJj-|d2tqQO{R9(RWqH@ex73DXX=$=FJg z3j?B7PxcYG6UFzl*S6o;+gii#NIrZ%S>AboC3-zB*xy#k(iiHm#P`qwn$8QpuGP&6b4CErPpbi@f*((3gU`)eI>Uhlx?`P1R|l8q)pUf!$OC5 zrd@441oDE<&OFGYSVA|tc!SqjG8}*zDvy+)O-W!&#u*>_8?pjgcoV%sY3=CbWML=3 z+o)ILHI}irUX@$;sr6cOr|_@|qX7q%tY1epna}0;A|&SLoNwZiiWQGpA*GwZ&MrHw zUKwY3nK@^5>F|iEl){))Ss8?QR$Srmb_Q38;lT}bW3NW)qEasSm}0cu{D#!KBIKgi2>*1NG$mdC372@#ZC zeOhP0G!%T=M2TUAkg!qed>jTRzmE}AU>UVCVqWg@ZjPJXiPd-wT~ zf=Qv$MZne3W*Zj8D_Pwvs&m-la3XxQGct<8OW42Tn075kJ3!T_+Geu|LY}T$5AW4+ z$Naa~0n$I5E@Yh|n|twsrv#w<4VuJ(-{W-l7HgL8qlINs<(pa)eU33t!qk!R@xtLR z?8*hQ2Gq6dmJXNP$yrecen2`oZ@G?L9+3NWxM9E1-!NhQM10d@Iq_u9P$TtD9wJfA z)msNyKqcF>wYJo%w7t&z0aQ&1()fv-^YiUIKaE!(Pk*=!db!!k{a&goEGT|I`?7w> z-FG_7)ZT-n_&kT%VV3P9jxZH^P{t)1eExS{k5gWdyhD1#4aoY`@qR?+J(5s=!a}29 zyO-?WFB5xTyt#$RMKEi8_}o##@&&xp=>@eGHWmb`O~dh3=KcKif%WN$gzer?i-A_3 zI8G!ywTb0GsfEqNT9O!UdnDlgdW3bHeD8iIQ2|flt`SBxnxEVU%=M93)G`BY#s>_S z?T8RfrL?OTxAwGPwa6Gu%N%gQ2<H&S{Ah)1 z8#@H!Q9qPfxDONlM=!^2pWGq`am5fdXP=m8DmR5Eo;}L0)J8Zd#dtWAPPA|54gC4Y zn-y5B(V_Q0WEyk-&R`<-9UDK7vopV88fj$}LU4tuzRk-c1*e5Qhc54*nUe#%Y9rf# zxb3HZ_nZ}@@6D%A<1?L4SD1P^wnrEA9@AP`rkgx|i>>8fI@i#DE2;v=JyATt`#x5D zP##>$t1QFo!z#}e2*HK63D3Afy2t#;-v8z{p75oA*T8s1KglO=4Z=Mk+4&gJTBbnM z(mz2LF1R6mpmGp@+F`pQ=xG;p#WZqpJ^ggL3^7l{vb}A%6gB-+H(o856U5=)+AC~0 zcaaNF2At}K08)&%@?;;vM^7e$mupxbMjPVm_llvnTCx@n@y_bY!70JHe{VRO`8o=V zh(v1Bxzc(&qp>N4-O0!~OwqUWrK4dhkIU^|V$?!$MFUP6tq=wN$|HP1yDbTo<)TLN zO4kr3XV?GHxGwWcKj^fwlQeiun-IPRzav+l`KpwnLFXOaX^hZAww2*q-zZb3?HEp_ zx4<6frNGzmKcHG~X`zsIYxCA>ESL`>A5X(*GIbJvb)<$&SmT_o7`=VGIY67{?9Qi5 z?cE)uy0Oqwd0EfgWnbM(!Xn3CH<4odPTSbOso?jR7+0C6!*b3sCL^4Z6Vg_8x`WB4 zDwCG-HZny()@6#nl&e#BTEZgo^2+8Vc&2>+5>~a5s!<92GGy9v4KRa}!+l zvIpVMz8{&t@+NKId9#Qsfv~G?BlJUX<$by4840HwPPd|CP#l8>*jfqMQ@1{c7K@IM zVC(~-{qYAm$>+d2)CKXDC!?qr7Zi8b`-MUF++cR+K9u)ETuxuT`JvxEhrG$6|G|Y?ciV=_ywS)E zm2X-wFcL9~+=l~`+!>c?eL+Rx1nhq$q+YZ$>=NH>xKgV||MU?vLou4haI zA^13V44y5r3HhDFrP`@CS;JcM4Y;wvKAiEo{uFCy8TaN-qR zh*W~~iwbFM5k?2&14v#6g7ompn&I%Qp67|eDn0j?+nT;L=TJf{DJM>!rqNWOyomOO zRi1~xZ6JaiuOL9KSuk?tTCnk^p1;Dgv?c?&d-4*+m`Y`l-IIL0JHwbShylgB4jrHj zN6^CK2FmHJrP0sf%fra zZd@84de|RZ*WYl+)acv!a3AWS(N}If^yer9t1Y&|L$|!Eol73})oIA+68fpwT}J;b zH&QY1SHB$dANmbN%&Es!;G~4uubGHwo6!cxUoH}FwF!G;sx~k#?Lab=!=EPdjUc)q z3!lwI0(B~5;X#olsHJPbihlQ_RaRjLML5{46lVL4`El)zZex(4bSZ@4R8r-pRmL$6Z2gSH- z+}1~N2RZ`|H`(lV%YG_0x7Ph}*mexf*;tV-yFNzDsPV6(ehR`yK`ub)ZbH#*Mw24Q zYygnPHq`l+T-2v{r2S?x##BIGWvup}~(i7ya^#&=g zDosF3JTEBG;yY+l_W=h;4xl`P{VXrr4(RYu=2j9G)2`5OG!9zW_+2z~$l49|2GM$7 zfmc5IDp`z>-0>;W6J<#2Y>Aame(H1zAUOnlD(?j9xgamA7 zheBTxL(F(0y3W_@OHQUMqv3Af^!xXtf;z2!F2QwM=_oS4>!5BG`B^Kepny_IOQ>Ei zt0uD3^CIy1Ua)8($smtVSlJu}vsO3W)j!y_4BTT2hru0AGn&PpuKF3fwWOrvl#HSe zff>3azI0;iPTS-nw#8muP0djdF4f*i0<^Y|4|FLn+~R=WXb>H5A0}e1Q}B((65;L* zdQ)Q^k8GocqiDma`e2Kojw!JV>mSjbko1BP4b1IAyq}Ri-Fo4Sc!I#3EG$7)k@njS z)m2qBzj5)v>4_i$PHaRI!IUMB6N}2mJ_Kz-Sw6If^$fPyZ_MA8Brc5}94`liZ$^ih z2^)H8G>+G<1WabdWrQI)hT5U<5zmgMQSDd0_BDVbq5wD|K}O+2bJ{T}dvRVOHUHzMxEqW_ZEgFrBNs5l zf3BC^pNvxw59U}H6DuLBr}*Ew=wywRmCS(g_iFukeX|m6)@=BjZwC18@W2n=**285 z|NGDJVWLHO25+QQC(337@b=L!Aos64Vn!RNhF(7fN3ADT{>UK zMDPNcZ(0%r5X#chGA%6)-uD;_47^p;ggR-;KJ{_~qr2=Q_vE+UKUy|4G&D0aQ&Ur8 z3nP@EV<=yKF1P#T_iA+Wr;8Y5APMg7?%udda0wD5xVr{-4^D7z+@+_;+`HB{ zch>xwKP*;N*Qu(#-?PttpHra-4sfAtS_`l;;7OniDUqPlH(Q6+^EX|o314(wU zU!RCKmOo@mgoI#;i79|96pg@qj8-o1UtlZ_JqWej!`fkn&d$!hDf1~?_q-lgEpF#4 zUf0WKt*iIVHpX!tm-|K!5|WaVQc`Dp#FP-+($BdftpTtnLl=xzIeGc=qN1YW;thqY zF%t-`aLt=H(I_}0u#%FJ{r&x*AmnD4Payw%c1}(gDkUTYhjm=F#BRyV%*=8w31x#) z61%?Krw!Ehz>YZ6L`Y4h~YZDphDveEw4k9VUF!`MZ|ChlHNbm*dD4HoCY_f1%(d!XHh zc^)!K2#BvEGyhOM(MskiQH?C>zKDl*IiXZ$S<#S6R#|^3^7Xy43;CSM{BPhnqNMJa zJsQ|;ObzXfWr2gFUt9&9>FU0igc5}>1&+yIja$$2wY+E zOGh1ktJU#h*}Tve+duUOnc6Dm5Zv`b`qG@dHZ39oGpcsbe9QuKZp$1hWw)Zd@oj$4 zv?hUB&WUm;kEHw)8YRRolY>n%7ROTb8UX}E?}uya3bR&|p^1ryK;{pW8_%}0v$LwI zsyQEyx{qhf5M1!?QlrCIrl5#tp!t_C_$ao?p}h7>KpG*=M+;u}NA(A@r5JWH?cR?I z^)~GWeb@vV>Ya8wJ3B&`(@F&Na+t7$G@CZJr3O38C6`q%gT5#uY95~JZUT8uyuj?s z!|E~a&mFM^xL6NE`w2>NjgME0Bb4^A$7`X>Rmc750dnR5WURX$7VpzJgJ{_l_N`%# zG0byDx>6j$i*r&2Zf(>5Jp@Q;jy#w=KkzY&F zXViGzUu{J3Lq~X4IfMJ#VUNdx9#_OBKXS)>E@!^d+2{TIsb$ym%3*t$EqLhpd7@fM z^5ru2t#uqmxcZl%%grNt6RjTC{LqGtjc6h7^-y{R7@2j0AMyI4O}o{4nrs@k;Q26z zIQjRB-HDA3KLqFB`|`!{5LjtxDOaq)>dCRQ2FyJKA*SxDw%b_My9dH!fhUg%Mm>i){e&`{HUfVO3((%|zQ_g1alhIoX$kr8c<&srdne&^g=h2HO{ z$NR^rzBq#&k~c2UrGZI5yWxdA$@o#;opkHvrc&+JRB)Bc@yg@|(tLev?n4Z?oCFh1U)%dgwv+tSi9#L`YaV8HDNZQQX}jN7u8`5}=w-9W_1ZC6%n z@dr7t!s$R2rb+p2hyUVevA&_9AwNGTgWPT2C@J!dVCsgnl++g`rABlfklUGIZ(Udz zI&^D2oMoH4v2bSa;=)mm@^w|;d-5qx`n+vzm*Hf#C!@f7x~%u_-_vv*RrhT}2;Os+ zjW!pnmYR=#wnZ;`++y)5Dlf-TuNXOUyoI{7OaJ^kUu3CoiSj*xF8CtL>q>3<{O+K9r^4U=1>c9|n{uB=hF87{aSQjy?a`aDbZ#3TO!z#n z=1s^yp<_16f4tpX?+N$0$feNZE$V89Hiak2)a+H(FFR{|LVs$^SwC*v$^0pu^zp2p z`r5o?Myy3QA#2bfUw#`I_DlKK@Zm&u<`_2BZI$oFOV?wagXdjtv z-f%2YAe0n3b(Zt3*DFKsWgXUi_VSH-S{+=7wdGo_7A)oiB$k1dtFckbi4aVo|R9ixUxA{c>|HUIIAAVf}ZJi3y4J{X*|*Ja43Mh$_czcZBDIvy>&3K(!|eE$+M|79v2!ps1u^f1yuYyS7)4iM;1^1R6m`pP7esjBF| zK6Cd+c#+iTrZHH-QfsRraQ>ODy(A{Aw$>{xDKFA_9r6X)9j!~x#qx`ka)($APZ0kag4hSCVwR_wu7TmAEizXwDWa%JIkz z_IpFGgbM}A$jdWQQc~jM1LS$APCHK-T`HhyYOhNJ6MK=Zduxy@qwE&dgtX(dyR9~= z{YPnI>8}hR%I@Z{l?J2rb+-HF#;@WsD&}a4O?>MDuT^-o{NPpB zjmUme2T8qkR%;xg2i8uHX%p$LjscD(uMxwiWXOiOjLU=gYyLate0C{?8O%hC28l zaShl-fe=?xY9u9TJNLN0wH*|MVC`^qIPb_IOvuS?`H{Zryc31OQvF4Q+br$)!=Y~I zF2N^*U!T97+tjy-_D_?#@h}KB9)SGnJ1`- z=Zm)Aj@Z1w)@ALuMgG?R18Gs;jH5&0Pu&VHW@WAf$n0_&P!pQvB^R(FUc};(5ofo#4ph;Azx!6@TrUjfsQbX~R~IeVQmd4I z2eM+7gmE;hSLz4iSs|IHyRYv5ZO?jI`M^NJ=CpJn`~XPnYFBve)OwPN;-xVs9m|FoJy714AWrZ9+A3GhH8miZI`#$P>7e=`kw1_8Uit z=95fsKf<+e0&-x^ySekl)u%*onqnui-Xq8PZf^7!MrXIkA0JREI%CtjB5_yL>aAZV ziFEmI)0nCu3HWWOA_fqo=!s_6q-flzmDLT z;~;!GOKPW4YiZ&CsHjvk;2ZkV0W{04MpcK$om%F#5PW63_cLjMFXwbz`625FFBBDP zxQs)iJTlFZg45nE>oSV%4dItmhabV8Pq~gGTwxyDHbD3_cC~;>{vj@<0gI61aatLQpp8cSDIaUc81c z9uH2^z=#a{g5JQ%UzBRA9uoMWq8KlyR5{VA}zmhQc%Rl-hRN1<;va2 zKa?EQ0>ZZj{1e4tlFS-T#SHkhe6kMvE3CCym+z>QqPQ^|_$UNEyNmGu(@2SQF+p(A zf*{Yn3=^#$SD1$Qc_0Z^8o&X>>i*x(>pw0~r{m({`deH-H92`$?%Dmui~k2;89x=s zW!zmIX(%eLJynPUzT#+1(q^Tlv8HCaoACJ#5HT;W*5!&{tw2woknpnZ8oErj{#85I z@A)D8MQHLY{*<}yFE_hTk6f7~qoboM908s8(Lyc8%$2JE-S)d);E}-+Qd0YXZ%Ja- zTBx=7?3f0UG%zqYUTIZ4D%Gfm&Q<1CRE*z2i5IP?pAF-WzqM?Ci1)z($VNs+l_)8r zLqo;I#Y1CbpF4Rw?yLzMo?jOaVzpRTs?|(b-fFwnH8?1xy!eFfrl^Q#9_5_7knKYU za{m_((EZg>b#yE)E4Vg1JbY+ykaf94GBGi+F43HfNe*ze4!F~FHIe4m`!g0piA;rc zm&>Z1YE30YMH4{(A>2&_5iK!KN=Uf5w_YJ-KYd9@@!u@KP$K$`pp3=aKHv>IE5lT;HJ>9fm-%!un}K-rn^w#Ds;!}<+{1jX@#l;on)HOdnxCHsG+EDb zeK^kncoLt74T|Eg<-NYotz3vJkQUkD*bP8xe{qTD+kId+ryJKti$o{0Wm?Vb92~(| zWX^|`eWS^2)V=LD%+CoLSwFu`^%&YXokIZNij3v|z8Y}Ep$Y>RdsB~DNW+7Jq-^?# zl{S#(!orYRDb2=Ersvs-ioLI?tquLf%_nEttkdpYTU#4W#A4mkBl$0bod=iLq@1WC zBhFxOCmMbFc(sXq&dT}r$kc`}pz>&7aC#=D(zdpz{o-;cFp12}_J&h9F5$_63ZjFK zfpP1zU{+uSL>xGI*WV0CmLFgM)aq5H6I5N)bff=B3~n7Lxj}U7S&9DzW&Wcy{GX}K zdAo!a=~Tq~j7Hq|C+%fL0zx@siJ+VN%PRA&kaHTqKs1DbjUpcm?hTwh{r0tU#)_#f$<&?a=y#?N%&YtG$kyQD7wZr(U z*HZ1Q!l-sNReIeSuT|xw7u&`~6Q-x93k6i-TD+ZSM_-XeH7`Ho6SQ9$YITfezxx16 zvtpMPa7vVAa*Zr+zI~^Z?@nfM5MLd)UmQ=WzEc3GoQiw;=hmBD)(Qce%>@FVl2m?im1;ElYLl$5x*xVgExo10sBU;R20y@D!1 zk_eL_Wlpfk?s&GNE`j2BU~g}42>qgZjm4kQgUcXjv7P-8V1)rPm|dM3A3rrdK5lGI ze+5zMr}EZq5Zq2S;B(yVxB}+qY`IRf4sYJev-9(!3uo+JJAuRuewWl)UrW&^1kiA& z+3fxGF^?6}ARscV-#s$CyYQzDLp2grgW@6_t2Fahz_2yn`l-r@S#>`F^*fF{Mu1MH z+4%_lTvLwZqbCSfbOFpXA|fK7MgnPLC5}-BgW(rm-}721fAkgEtT}4r{S`)8-}u*9 zbD3R@R_Foe&N{6mV~(IHM$E#mi#@xhjo78DB&4%esX*?j53vOkY;6q%m`Ye!*cEj7 zpdF@x!fkt6km0=WN5`ktX7wY11{KQ^qx0*18Jed~N2TzQRX|BZ^NpNb)Ci{V?{Iz5r|~dx+31I) z&{u3;FC&Bx7h8YxF4vDOs#P?92&FPkp7c~gwVO+KS#e|h=yf(g|0MNgNB57t!ahK` za!tlE7wfF5!{eFqc^!8ZsA%XcLCH#qEIQWuVi6Gj0gKT$scFm1#Xj4kwMJMwmvc`8 zL3t4^!!4aiy{=MH(yBK63SYcWVvBtV)KqlwF97#g>l%u|+@QNXobu#BCzE^v*aHP0 zKpxmWyz4R8*dst$T&%Z==3lHb8rq*JPV>O_`(8E>Pj6yqC;+fEOylgSLm>Ii&fI!j zU-Ey7+v>UX$51kSEyFY^5F#enE}-zbP;@{Mtq?E}2bR9FmNTil+h;siygl)mf{Th7 z9M122c+D}of zr3uZcVKFs2Zy*sG%2w|f88=3YL@JMDN)6+-sQ51YxkWoB))K z4mR={*L$rGr-sk`NCSsaS>0(&kQd!1SJSmZ-mAGFehms{5)>3KVb`7XTv7f04+f z(JL*QuHOo6Iv~D4#v&C}Jj-gtska3%CF^8WFxwTtP}cr>Ki*9|0`M^tSPZc#K6(PZ z$}~#v!!w-Y3P_3|XjlduK!yjj5pzP~)%)w+34;z_cpdLsBhdA$yV6R7zR^^!@Izq2 zVI%D{VGuID_1w-vA^hZOCe+Z0;N|D!X2X4G9`MzES;&hntP) z9i~NQfM7-m6erfNxMLh`3yJxuvvDWK#E|l4zpsuI>Ty&UwInXcKlupIv^0&Ii*x{s zQG9n{=+E*cGszAOO9t6dkq*XrjSDIZiu_UeswE~9-_6o|$i5%DZsaYp6n|D@@ffjt zLp>Ntc{{BnPy6WGXJe-Lv=ar;Q|XGVMZLw+RFMyocpTyp#9&*u-UyOzhLh?39u#au zc~{9Be{5j}t|%pJda(b8capy290xgUtcfUS=p3)7+D4)5D&4)gthR9#PdUtjjNhxar=_j#5vUvvvmTjRyQO6?mYMwEDWUe8 zbqx2mWs%^#W5MeshlB~2w(|@kZC5`e#A22bG1eqE0RP?EL>Kf?eFDHcF8Wyq0#3|y z?H_Gv7O(xHf@G9=z4IV4>mOu74=2C#WYEi7&d}9i{nXLP5+btKdsD#=h@0?geb~4X zlncK28TNzk0g+4svU3O6o&)P-#QNOJQN9C4o?5_Px&bv3qWX=P4R0%0=z((Kb(Hz< z0s5?#e)vuhqui4TL*Qj1#V$vtwxuZer!C>-555)x7}vIF9Hj43^1qZ81X)+$)6 zX~fP6s#F2yt!ldW;EvOB=}3@nq+Z`aO!f$z0$r`F`#hTYvv=wu0gOFdKEaP31s##& zhZ*IBZPf(hh5QvV_<R*Ja-(FN@XBI49XvC^J`w5cm8Aut9 zC^XI8%ka>UyfM5Q&PVypq(whGP!n_F=OwzB*{`vXsUKS+b^4=q*olc`XZlX%%=dQ|>|qDaK?;W+ zVwKKRkVxkxDx+RLR>{_;u;Bn=?y=c zk-N%k<~@26!E^B^{w00EF8+lw5fLZAx1laF1p>;{?dLykF<{sQkaGu1wW9$B!7cS0 zE1c@UXh|?(!y1RF!wM}h`dU_}a80nG*2jZB9hvC3Y53kwf#On)Ti+d#4U4dUSN z6huI-9VV7f3?mQ1Bw2?AhJeRjp&?3*)TH(Jycmy|MP=sp&#*j+Z!y!i8f&dJ6l*-zmXVYVg`O6_lhyD#Yh#h}GU*nZ5!y6vK#Fg*@kQoT+soZMZhH zg%`V zE|bT=Vu_2wI6=5OZ4=J6JUH~0pJ(Obdv z)UYGpl6UI_0d3#YplJd**{pv`7R;V5ohJL`K^fg}a0n*P-v!toi$<4QG&i-PeEBkU z(9Rokw^L&3b%9zPbcDX6;irI=FD#oyg=(imxf1q4Gl{Zv&q3}qapnYL)d+BIqQ6TH zx$6s@%u1z*;5`A(STI(lK(S8Dh#GYtNTkiW=BGjBZ<4i*j`AXVcv1ffK0RRdtGT{} zUYU)k%W4?=Sjd|;>myH#crTa)cl0d1V7z7KrQ1%P_$fl}O&e=qfTWkwOq4us)Jc|{ zl~@;xC(*EU2WIZx8_I~OrXVabZtsU1V4D(&@z*&%0k>WkB6bCXo?e$IcluaV!`yKW zI~z@YcAR+Dt*IA&v1*nT-?z4(1KYYKi#uE>I&QL?218VeW_QU3-4vcLtq|6AMd!Y) z0;&iPc+(L^OHhl1L6}@YX(*MRXq0BF{yG@g)nn|{fq&_?)?0}`gzZiyX0(#S9v?Cn zyqW63)6LYE33S_~Hv{JD_<%=X%w`*Nej7InY?t}EsEvVI#w6!IN0Q(i=H`H-DTV$2 zh}SN5{b|b4$MlVT1MO~W7ejnzZ@F?e!1zP;QiAk7VX3~I;KClaccQ8hEC ztO~R1V5K(=*W61y4h|x2oesWt)wsy*-8rkOt_w`8l`wdSfE?#F@%;d z)4dggt`@TEcO5oZ-lAc;1^7x$YOoIjWQIP7)3|%hqxe64C1;1A=&4-?DT)JU?5BFq zm$Om0Rzb<$aGwFs9Q%)_A^tzT{(o}A6K-y9cY!z078eRG!#fJ-dL593Lj-v9&9{F- zoU0yi&>_2dpc# z7oX<#VBV$0Ua{Z5t0*aLoZq5)A6y#-;^sI}=8TxucC)ZG)zz+hfWhrbF7CFJDf zBqdM(`-_)=S0=DW7y=v|3`R)runc^WgZw<@5a1G&CC<;+9@T*(aFSwjqGiIm{{IVp CivmCZ 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 f5c759fad8546da967cdbb07f93b034d78d293be..8e9c01c7fef69c4a6aa08b4c796493338c671b8d 100644 GIT binary patch literal 10270 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 literal 9606 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 G2L3Of2;d|D 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 465ffc0263d816e953f37275f9ceb514223f33bf..8d232cb2ab90a08f637f54576ea195ada3ad1802 100644 GIT binary patch literal 10477 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�T)Xjeo%zI=0Jw}Nu$swu0L3TL zic&UZ{qWGrlCR}7H;!Mc!}nQT&u1n_3_%$iuSKD!yW5>rW%uS_Mh0#M%4xVotz3)E zYE~5#mK%as{fkyC)(`xA8%F3UNohFDYB?=Q#A1-riO)!LFk2qwxk;Qh;*^*OHxjK| zV=SetjJ9Lr=vd=%U)gqpi+-^YMkM~&{&>Qp8ciO+6&i6nKo*zfyhpH5(edJG$QFK7 z-ncq6BGDR0$Twt7OHG|K5`)L#n2ajEGnys=Ev-M;r=F#8B*$xI${ZL~Ky0k3shOvp zsQ;NpTI6c5C&%S{Q;fPYI`qse(?`A@MDEua@Ma^XS*%#?E5%~tA z`ZFZR1u#VA>}4~eQ3dOovP2VWzcYA}T?}qdR&bzMVQZq`4`r<9zO}^b5jl0ApkSf#LL2T+Hy#bq>8a|a}?UE z;1x#w7=Lg3u48T}S}Yciqe3VMdW`>(RtULHL_zCgoJEzz6h&-(bu=|O1qE6MhKGZEU^N?`g=mYe^8m)2fFJxrAN;rpoCGSBu4Gk?X2Cydx%N1{}Te(XdpMbsDU|B-PN_B*dd(CN&<2Q zkH?i3e7e+#T;5gmv+Vn)E#_imbC5TZ34WADVz06@w_z|bso#$Jirbn`6YQsCsy@pe zEjd=K69g=J;`V$ilWXBn7|T+0xe8EoeIKEjobd9*vifI_5R!nrNzvXYNmdNld58yp z3{@gvt%YGARhr*lY}2~uT~5dGKYRioBG$cWWLe2bzYDozO`udTb)PO(M@IHH@vrZ_ z;uNkA;PX(8DsJ?;wdLR0-DT0bf-VShD6(hS6cG_Y#WJb;nG$)s<|j}=KKy-9x=#iK zUfJu1o3U$9XG{-<-0ERA`<*FJ8C?zAn=GpA46TdwHO5Zc)+smp!M~Z?Vlod)&)Z>V zBsIL)VzS-pBPS;|4Ppl#a!XlG6^=s=(@@nwQP546CdL=L3?Ye93-qb9r)5}@EVeqj zh|St68lxgeNwYYw#X5^m2-2!86x`npFtnCPp^*LO`Eb=|$(`|UNiU-0Q_=n=jmw;~ zQVl50=4*)~^KY!mWuGO{*(@eSDJS1zl+i8C^@f;BmE9E;eR7<{orh5UA#bM2xJFAq zAydPQB;X_H9$9a{)L^q$>EU}48>7eVK6)`oY%n3#>!O#4gbqjl{)C|it~78)x6nST zT(Ey@9GjyW)i7!doD9?w%xjVi0^#EUmf||}NKWRda8vu}e(9^uoKJ75G&nFLWMu+B zTANYjrUgv|-#JR0Z2{!Koo4yf0)%tNH0diO5d-QO9!xxU)5ttd-ESyWu)e(m>9>d_GD8{tU}ex8Erh5qNVS(ZLQGLvnz4n9=gu4-EV57 zd-B)!&+H^w+srg=Z6X@><aHFeE*a_y8P|BwhI~PO`zlpT=l=j#DPXAM#I~%h}G@ zNcByrsT3**u>AV(4bo7CK86l|=%bkIAqePE5?f#*i5Iq(jmlAUW#@88fb&S}2nd_a zHN3kv$3NGRr4!D9#7U`}Z@%MP?h}81o)M4uac0?j2fHv0sb{epQiL=77g|06yY)OE z0w>-Y$Gb>s=N6z{J;LG23T*xm9b{o@WTkx!Fwq?_H*kY6`j#Iq6ln0-)+ULGF~t7>D(J?5 ze5w+fYKw;&k^1U;K|xpba?%8xe%*MjwFTp(T7^z=G#ScLs-*_4`W4nAQE#q2{Qg^Z z;j6Ux4`SYWYvC?|g`e7_h?_8@A>6hYw$b{hT6IQRCl{n!tB^V;(h}J94~g1X8ZoQ= z89P&z)GnPcjqsC17e=QXyfpEYXfB<|14aNfrl*>z>WtCslLpi~coJyn76(-L{O_h-zCT7*Ah|56;vg05c%t zkAKRNla74vDhOCSERk0m(bb2tfe9EzCebD+Aru`%RNMdGL%|p4H#h9yY`3$^%ggI) z5GcT#CyN+Xm@7Cq7!jBC6FGU`6;E}1eEb8YhTF-q#kI^C4aV#3f-jh`5exL#CQAtL z@U#K(86F-!Jv|NN?@utK*=WG?9HPKQL`H6|&sQ5=?vClTy3+Ln!c(-q(RRi2&j4!h z5~=Sd_TK@Nt?%zI{Peg4q&y@PRQB)v{e37XsFhZ?xY*d(s3;ylRlnz0_7AjMsOjt4 zX+V1&dzJLz?7QL$=Kp2^_ zf6Xh#&B;ou&@cIzVQ8MTYocO%9x zbMRv|UX8QKWQ~c8s@K>LzQ@~Bj>c9lmnk`pdb_pHASgozz))(y%m7;Hvfbx#b8~a# z_yo}U0q+PIO8&#$Bpjg063Grv%47Lodqf`U4X z>~AOXmzS5K8{q6ZODiZ2&Dq}esmaN^S3O@=M!RkQZ&(RwX;!vw+Uex; znOX5*U_de!uxlDx^`)49n`!h-GI>3e!PTvf%>PbxI$7r0P8`rC|G#-axc#d)#{Pey zg8$(mweaX@@10aLTn^i^{|jWTOUIqV2X;0F8kcBZ2gLnk&`0_)M`OimLQ1W-^^zN zvL=8+wfnq6xAj*X5g2et)ZkPo96NhDKCO>>Lc(JX}0H9;Y4I!iNO6f9F~+H%;g>(&{uZl9P*Z z;rtB*wRsCsF7k`yg#TH*0O*9Cn!4hY<}d(=c+2(O&l;*_M={SB>}<77Mr0m*IshXR zW4+)_)y#WszUkcWt`ZD-Wj#973qxN6ijEB%bIinz3$nCUU%PWB1V^kHvYIk5i_2m$1aMNF*Ei~)l(gTd2qBc#tE z$guR7y%ty@oaMSq(^Ycr<5Y*tHq=EwP z2ck=r&4Lv8&&r^Zbeo+RQA^*+Km$?N7|jIRg=r#1U!ie4)V>!pI;y#Na#eo zMCa_hp1&;+C8c%16D@?49JJ`zk|79h|1THV8c6@=^T7Q1vn+@%9G87^YS?Y~XpsGa zbC68pJ>K^Iwu&vti9UVp{+X6s2NlD@xXYsmRH|ldY@90|!znK(x6}DGFvJD!kB5t9kMa*Dh`i~ghxOqH3@#lulA&+pfI+#w+CPyj1M1r`}@KDurS_b zdL1t@9IJa9N^EPvmVO%)j^97D%7YkUZ8Wh~!Lc>Jxo5~IZ8X|N$@SnkiW^vB(2pD9- zp8!gUciFZ->d>iL6Z@kIV)#u?0T~*a>@r(S$i>IB>I#~QBT(y}F=&m4f^mAjo)frR z4+5%_W-x%S$;_)A+c+Dg4oG9BE`2@-de9>eQZIvh(@N%0OwKD;Yv8WbPf{ zbMKF8#{fRkIx;d6g~fp8&yI~Kn*aRtWI0#yJT8J9gUw_(_?7(e=9tV6;E~NHh9v-d zSZc6L)2KG=Yr9=-HJ`}IA$3TjX1rwd6!EuYuI0$BSp&d$xn=uF+At^ZDu(i7`R}Gm|!WWMsef z02m6{6h&oa!$(X2Lp}8q`C0$Cn3Q3VOJk1-2_T37s9p)Ypa}p{X;PYX0L_bzk3WJ< zO4lA2oO8H)HcYr%gPEFY)F94OLvs2Jj(taDeI0bu1{%M=RPqV|fO^meX zym8I9)?sFpV=dZ`+e$69H3P%Wdp8?^t#&`pp}iD|e6He8aLd@qImvrzV6`g&J~#Z0 zF1l{2P$73(V_6pE23fnodZApKqh=xvyAOkk@#9Cxv>IpgkR02VeF`K)L&I=e?dj}P zR8p2963#saN*}8tm_-ve1kP;u+DZi0U>FQBHu|#D)YWdjt}PuM^wYBBhx-N_{cV^r z#bd9NX0!FqfbG>lVs*_zOj?aBYUs`Dhr>!5o`82rDJcUK(33X+dCh88tG(Wv=oubH z=*iZZQ4^OV5rE0`d%1U+(=9xLDY0p;ZV`51$*&yz(-{*RgH@(IW!2 z>U}AvGWV%Vv&+>Wu^qs=kWwwK$I`0C0MsmNiW7L@BpS=$;qZNOXU^)wphXcWao+jP zW>xd8mXR@6&*yq(Qr#hh-%_MM z>}5pDbp~zodEKp~ZEd`C40-!N&WO}36ySe4>j8Kw*^O$WtuET`SSA>tNi`J*wW6(G z;2xy>dZ+usv|5}k6cte%K0Qm1M=`Vy_;$CRwhvb`yG||vp7r^54H=I;x-GnM)f2%3 zKzVja8S{Yn8%m;afZDqTz%A95cK(iwATFWDpLjrp0lv!V<~Psj(*-bxcbf==32|i_ zb^i2`rLukP;Fm|6rG{7DIzO#;6BOfKzEZ^AAhKUhh`?xhadBa2WMtQR%x=OerBQlC z~Co++JKu=HMktk-r4Wyl5x_7goFfF>3JQqJ4oQ3<~;lXPCET>Z*6XvvgdamJ7b`sgV6@{2`^DyRwACyab^7U&|@E?I2g5MXPxYO0$d3Pt5#X5-i zJr4{kh9qUEhPYiw3eS|=w?S^p4lsv!T@=gcHpW!BZ*z9sR$Q##17`z@AAvooF&W+?!^DdGi7YXg(FF2YCcTwrYcu>@`ucg}saE`dP^cf*E-$w z9WyY6MA!AA$Syji&XKDaohicJ*Hp?WImUvUns8@rXITz%05Zs9(U&ITf9l{^kkKoJ zOb$ei;ztoQqGgJb3lhayJ0SD!*B4=>Znp_yj zk4e4AlFUE88tTc`I~-D7jZYV-k}J270zYb|*FA|>54uQOq0#-`nJ*U)M@#8m#x-qX zpRM}6$CB3g%mP^uJ@vIBAIwBhAd+vO;mFOZM3wTiv{j(nv98sa%lxT<`;hkVL1W^w2GpG zQck88oVRk*dS$OJ(O}kU&5$85H(zwjue$;m^O=8f1N&+PYn^l%Ib9UQAIp1U_xO#1 z-swP)u9QJ=DjS9A% zmVifg*s0lkgI%FZ%*cCl`OuDP;|*C)x9$$wvC3&Uq<`i_-hawMz2)aPQ5GHDb>{Z|%A zO{@kwUN*}2VbZ?3Z$@U-sKTYp#VF=&`*E`9@gF&Rn3zyzF)sA)&^st54O{AGte}#~ z`oe2qQq&4=30UXx3(m{BRw#!oe+25QDqFx)@Ei7ir?5ZPWYW$5FRyC zlKb79F7P3E6P0Yu6}c*j7lfIWcdzVDTX7{_o8``XVyj@>t1<4de|`eIE#0IIZ*q)E zk~E;;aA@-9Jj_Zh`|#Ev&zWRXBG4%pC5yF6N~WL?G}0Wz`=fD;$dDTddd8mGXqVQ# ziFn<|d9@7jWYd8iKVaifo-0@yKv{UK$TxeKYDQ*`zPDB1M-M!EF}CFgdBLuWAyQal zKF%kbardr|+FvL2H`Ek4?GI#18=TOvuy!R+k0OiRx6+6|Sr~LA%A-OO%TG4i>3MVJ zsrZ#9!ttZUnTEY1X)lwC;gRvr-@G5xInm>t#|jITj+B`O>KEaOZ7K2#&K7?*yuybM z)NRR%S&&_#`*ZlmFPpiFeEN;4HpmTKDCVU&Ks*BUB@YnbLLh8*Q^w4NpPJQ%V$G>Q zL2m#|Ip^JQ@)5%_7tTIlqtrT_Exa5dz=;Um=(^l`JDF^Y?#BIFpvLr)*%EZ(H5Qj7 zz*ls|SA|`L#Ajt?0ZA(gfS)MYYRSligyP(I`x_SinJwmx_+m^dEM~$LQXF5RbYyiUa^!yx=o#xwu2xZp44eT(ML{@d!Q`$xr zuiZQp8IU!OrDc~uWrW849&XX!;;>EMjpyOJ`T*inyRlowU;0@f)EZu7klQepRuN2- zvn-BTf-@+d$f86gfQh&w{?oi@Bel+ikI>||$J|rWZ3~2ZuR$C19~0Bw->J&BFVx<~ zSS2Ngj?FQ&ut|_Px|-eZMUlH7SAJ`_O{~Ei+`{ShkX1CD)Jbp-Jk+sgTN`3O3-6nFz2nx zquV0~oYKFxMvJ|{XPyv=9`Q}O14F!d0|w(w*ogw)S3$of(06aBT|z;+$RD_XmN!yj L@}d>O`houi$=aJZ 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 1f238a4619657763f15f05e20656ec0f08a03eb9..b6976d6cff55dab62a17382b98838d18e49ba732 100644 GIT binary patch literal 9973 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

b3tsdW2#AyUx(s?ztAX#V`YOaQpN7 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 diff --git a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx new file mode 100644 index 0000000000..e118c59088 --- /dev/null +++ b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx @@ -0,0 +1,625 @@ +import React from "react" +import { act, renderHook, waitFor } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import { + anthropicDefaultModelId, + mainlandZAiDefaultModelId, + providerIdentifiers, + retiredProviderIdentifiers, +} from "@roo-code/types" + +import { useChatModelSelector } from "../useChatModelSelector" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { useRouterModels } from "@/components/ui/hooks/useRouterModels" +import { vscode } from "@/utils/vscode" + +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: vi.fn(), +})) + +vi.mock("@/components/ui/hooks/useRouterModels", () => ({ + useRouterModels: vi.fn(), +})) + +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +const mockUseExtensionState = useExtensionState as ReturnType +const mockUseRouterModels = useRouterModels as ReturnType +const mockPostMessage = vscode.postMessage as ReturnType + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return {children} +} + +const emitMessage = (message: unknown) => { + window.dispatchEvent(new MessageEvent("message", { data: message })) +} + +describe("useChatModelSelector", () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseRouterModels.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-opus-4-20250514" }, + routerModels: undefined, + }) + }) + + describe("static providers", () => { + it("returns static models for anthropic with apiModelId key", () => { + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.provider).toBe(providerIdentifiers.anthropic) + expect(result.current.modelIdKey).toBe("apiModelId") + expect(result.current.models).not.toBeNull() + expect(Object.keys(result.current.models!)).toContain("claude-opus-4-20250514") + expect(result.current.defaultModelId).toBe(anthropicDefaultModelId) + }) + + it("returns the mainland coding models and default for Z.AI china_coding", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.zai, zaiApiLine: "china_coding" }, + }) + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + expect(result.current.defaultModelId).toBe(mainlandZAiDefaultModelId) + expect(Object.keys(result.current.models!).sort()).toEqual([ + "glm-4.5", + "glm-4.5-air", + "glm-4.5-airx", + "glm-4.5-flash", + "glm-4.5-x", + "glm-4.5v", + "glm-4.6", + "glm-4.6v", + "glm-4.6v-flash", + "glm-4.6v-flashx", + "glm-4.7", + "glm-4.7-flash", + "glm-4.7-flashx", + "glm-5", + "glm-5-turbo", + "glm-5.1", + "glm-5.2", + "glm-5.3", + "glm-5.3-flash", + ]) + }) + + it("does not request message-based models for static providers", () => { + renderHook(() => useChatModelSelector(), { wrapper }) + + expect(mockPostMessage).not.toHaveBeenCalled() + }) + }) + + describe("zoo-gateway (dynamic router provider)", () => { + it("reads zoo-gateway models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { + [providerIdentifiers.zooGateway]: { + "anthropic/claude-sonnet-4": { maxTokens: 1, contextWindow: 1 }, + }, + }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.zooGateway, + zooGatewayModelId: "anthropic/claude-sonnet-4", + }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("zooGatewayModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["anthropic/claude-sonnet-4"]) + }) + }) + + describe("opencode-go (dynamic router provider)", () => { + it("reads opencode-go models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { [providerIdentifiers.opencodeGo]: { "glm-5.2": { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.opencodeGo, opencodeGoModelId: "glm-5.2" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("opencodeGoModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["glm-5.2"]) + }) + }) + + describe("kenari (dynamic router provider)", () => { + it("reads kenari models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { [providerIdentifiers.kenari]: { "glm-5-2": { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.kenari, kenariModelId: "glm-5-2" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("kenariModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["glm-5-2"]) + }) + }) + + describe("nanogpt (dynamic router provider)", () => { + it("reads nanogpt models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { [providerIdentifiers.nanogpt]: { "openai/gpt-5.6-sol": { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.nanogpt, nanoGptModelId: "openai/gpt-5.6-sol" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("nanoGptModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-5.6-sol"]) + }) + }) + + describe("requesty (dynamic router provider)", () => { + it("reads requesty models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { [providerIdentifiers.requesty]: { "openai/gpt-5.1": { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.requesty, requestyModelId: "openai/gpt-5.1" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("requestyModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-5.1"]) + }) + }) + + describe("unbound (dynamic router provider)", () => { + it("reads unbound models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { [providerIdentifiers.unbound]: { "openai/gpt-4o": { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.unbound, unboundModelId: "openai/gpt-4o" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("unboundModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-4o"]) + }) + }) + + describe("vercel-ai-gateway (dynamic router provider)", () => { + it("reads vercel-ai-gateway models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { + [providerIdentifiers.vercelAiGateway]: { "openai/gpt-4o-mini": { maxTokens: 1, contextWindow: 1 } }, + }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.vercelAiGateway, + vercelAiGatewayModelId: "openai/gpt-4o-mini", + }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("vercelAiGatewayModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-4o-mini"]) + }) + }) + + describe("kimi-code (dynamic router provider)", () => { + it("reads kimi-code models from the react-query routerModels using apiModelId", () => { + mockUseRouterModels.mockReturnValue({ + data: { [providerIdentifiers.kimiCode]: { "kimi-k2": { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.kimiCode, apiModelId: "kimi-k2" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("apiModelId") + expect(result.current.defaultModelId).toBeTruthy() + expect(Object.keys(result.current.models!)).toEqual(["kimi-k2"]) + }) + }) + + describe("openai (OpenAI compatible)", () => { + it("requests openAi models on mount when baseUrl and apiKey are set", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.openai, + openAiBaseUrl: "https://api.example.com/v1", + openAiApiKey: "test-key", + openAiHeaders: {}, + }, + routerModels: undefined, + }) + + renderHook(() => useChatModelSelector(), { wrapper }) + + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "requestOpenAiModels", + values: { + baseUrl: "https://api.example.com/v1", + apiKey: "test-key", + customHeaders: {}, + openAiHeaders: {}, + }, + }), + ) + }) + + it("only requests again when serialized header values change", () => { + const configure = (headers?: Record) => + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.openai, + openAiBaseUrl: "https://api.example.com/v1", + openAiApiKey: "test-key", + openAiHeaders: headers, + }, + }) + configure() + const { rerender } = renderHook(() => useChatModelSelector(), { wrapper }) + configure({}) + rerender() + expect(mockPostMessage).toHaveBeenCalledTimes(1) + configure({ "X-Test": "first" }) + rerender() + expect(mockPostMessage).toHaveBeenCalledTimes(2) + configure({ "X-Test": "first" }) + rerender() + expect(mockPostMessage).toHaveBeenCalledTimes(2) + configure({ "X-Test": "second" }) + rerender() + expect(mockPostMessage).toHaveBeenCalledTimes(3) + expect(mockPostMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + values: expect.objectContaining({ openAiHeaders: { "X-Test": "second" } }), + }), + ) + }) + + it("uses models delivered through the openAiModels message", async () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.openai, + openAiBaseUrl: "https://api.example.com/v1", + openAiApiKey: "test-key", + openAiHeaders: {}, + }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + act(() => { + emitMessage({ type: "openAiModels", openAiModels: ["gpt-4o", "gpt-4o-mini"] }) + }) + + await waitFor(() => { + expect(result.current.models).not.toBeNull() + }) + + expect(Object.keys(result.current.models!)).toEqual(expect.arrayContaining(["gpt-4o", "gpt-4o-mini"])) + expect(result.current.modelIdKey).toBe("openAiModelId") + }) + + it("stops loading when the OpenAI models response is empty", async () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.openai, + openAiBaseUrl: "https://api.example.com/v1", + openAiApiKey: "test-key", + openAiHeaders: {}, + }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.isLoading).toBe(true) + + const requestId = mockPostMessage.mock.calls[0][0].requestId as string + act(() => { + emitMessage({ type: "openAiModels", openAiModels: [], requestId }) + }) + + // An empty (but successful) response must end the spinner; otherwise the + // trigger shows "…" forever for an endpoint with no models. + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + expect(result.current.models).toBeNull() + }) + + it("ignores model responses whose requestId does not match the latest request", async () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.openai, + openAiBaseUrl: "https://api.example.com/v1", + openAiApiKey: "test-key", + openAiHeaders: {}, + }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + const requestId = mockPostMessage.mock.calls[0][0].requestId as string + expect(typeof requestId).toBe("string") + + // A response for a different request is stale and must be discarded. + act(() => { + emitMessage({ type: "openAiModels", openAiModels: ["stale-model"], requestId: "different-request" }) + }) + expect(result.current.models).toBeNull() + + // The matching response is applied. + act(() => { + emitMessage({ type: "openAiModels", openAiModels: ["fresh-model"], requestId }) + }) + await waitFor(() => { + expect(result.current.models).not.toBeNull() + }) + expect(Object.keys(result.current.models!)).toEqual(["fresh-model"]) + }) + }) + + describe("router providers", () => { + it("reads openrouter models from the react-query routerModels", () => { + mockUseRouterModels.mockReturnValue({ + data: { [providerIdentifiers.openrouter]: { "openai/gpt-4o": { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4o" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("openRouterModelId") + expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-4o"]) + }) + + it("reads poe models from the backend-broadcast state routerModels", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.poe, poeApiKey: "test" }, + routerModels: { [providerIdentifiers.poe]: { "claude-sonnet": { maxTokens: 1, contextWindow: 1 } } }, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("apiModelId") + expect(Object.keys(result.current.models!)).toEqual(["claude-sonnet"]) + }) + + it("reads litellm models from the backend-broadcast state routerModels", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.litellm, + litellmApiKey: "test", + litellmBaseUrl: "http://x", + }, + routerModels: { [providerIdentifiers.litellm]: { "gpt-4o": { maxTokens: 1, contextWindow: 1 } } }, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.modelIdKey).toBe("litellmModelId") + expect(Object.keys(result.current.models!)).toEqual(["gpt-4o"]) + }) + }) + + describe("ollama / lmstudio / vscode-lm", () => { + it("requests ollama models on mount", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.ollama, ollamaModelId: "llama3" }, + routerModels: undefined, + }) + + renderHook(() => useChatModelSelector(), { wrapper }) + + expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "requestOllamaModels" })) + }) + + it("uses models delivered through the ollamaModels message", async () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.ollama, ollamaModelId: "llama3" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + act(() => { + emitMessage({ type: "ollamaModels", ollamaModels: { llama3: { maxTokens: 1, contextWindow: 1 } } }) + }) + + await waitFor(() => { + expect(result.current.models).not.toBeNull() + }) + expect(result.current.modelIdKey).toBe("ollamaModelId") + expect(Object.keys(result.current.models!)).toEqual(["llama3"]) + }) + + it("requests lmstudio models on mount", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.lmstudio, lmStudioModelId: "local-model" }, + routerModels: undefined, + }) + + renderHook(() => useChatModelSelector(), { wrapper }) + + expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "requestLmStudioModels" })) + }) + + it("uses models delivered through the lmStudioModels message", async () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.lmstudio, lmStudioModelId: "local-model" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + act(() => { + emitMessage({ + type: "lmStudioModels", + lmStudioModels: { "local-model": { maxTokens: 1, contextWindow: 1 } }, + }) + }) + + await waitFor(() => { + expect(result.current.models).not.toBeNull() + }) + expect(result.current.modelIdKey).toBe("lmStudioModelId") + expect(Object.keys(result.current.models!)).toEqual(["local-model"]) + }) + + it("requests vsCodeLm models on mount and builds model records", async () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.vscodeLm }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "requestVsCodeLmModels" })) + + act(() => { + emitMessage({ + type: "vsCodeLmModels", + vsCodeLmModels: [{ vendor: "copilot", family: "gpt-4o" }], + }) + }) + + await waitFor(() => { + expect(result.current.models).not.toBeNull() + }) + expect(Object.keys(result.current.models!)).toEqual(["copilot/gpt-4o"]) + expect(result.current.modelIdKey).toBe("vsCodeLmModelSelector") + }) + + it("transforms vsCodeLm model ids to vendor/family and formats display values", async () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.vscodeLm, + vsCodeLmModelSelector: { vendor: "copilot", family: "gpt-4o" }, + }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + act(() => { + emitMessage({ + type: "vsCodeLmModels", + vsCodeLmModels: [{ vendor: "copilot", family: "gpt-4o" }], + }) + }) + + await waitFor(() => { + expect(result.current.models).not.toBeNull() + }) + + // valueTransform: "copilot/gpt-4o" -> { vendor: "copilot", family: "gpt-4o" } + expect(result.current.valueTransform!("copilot/gpt-4o")).toEqual({ vendor: "copilot", family: "gpt-4o" }) + // displayTransform: { vendor, family } -> "copilot/gpt-4o" + expect(result.current.displayTransform!({ vendor: "copilot", family: "gpt-4o" })).toBe("copilot/gpt-4o") + // displayTransform returns "" for missing values + expect(result.current.displayTransform!(undefined)).toBe("") + expect(result.current.displayTransform!({ vendor: "copilot" })).toBe("") + }) + }) + + it.each([ + [providerIdentifiers.openai, { type: "openAiModels", openAiModels: ["old-model"] }], + [providerIdentifiers.ollama, { type: "ollamaModels", ollamaModels: { "old-model": { contextWindow: 1 } } }], + [ + providerIdentifiers.lmstudio, + { type: "lmStudioModels", lmStudioModels: { "old-model": { contextWindow: 1 } } }, + ], + [ + providerIdentifiers.vscodeLm, + { type: "vsCodeLmModels", vsCodeLmModels: [{ vendor: "old", family: "model" }] }, + ], + ])("discards cached %s models when switching providers", (provider, message) => { + mockUseExtensionState.mockReturnValue({ apiConfiguration: { apiProvider: provider } }) + const { result, rerender } = renderHook(() => useChatModelSelector(), { wrapper }) + act(() => emitMessage(message)) + expect(Object.keys(result.current.models!)).toHaveLength(1) + mockUseExtensionState.mockReturnValue({ apiConfiguration: { apiProvider: providerIdentifiers.anthropic } }) + rerender() + mockUseExtensionState.mockReturnValue({ apiConfiguration: { apiProvider: provider } }) + rerender() + expect(Object.keys(result.current.models ?? {})).toHaveLength(0) + }) + + describe("retired providers", () => { + it("returns empty data for retired providers", () => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: retiredProviderIdentifiers.groq as unknown as "openrouter" }, + routerModels: undefined, + }) + + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.provider).toBeUndefined() + expect(result.current.models).toBeNull() + expect(result.current.modelIdKey).toBeUndefined() + }) + }) +}) diff --git a/webview-ui/src/components/chat/hooks/useChatModelSelector.ts b/webview-ui/src/components/chat/hooks/useChatModelSelector.ts new file mode 100644 index 0000000000..cd5e635d5d --- /dev/null +++ b/webview-ui/src/components/chat/hooks/useChatModelSelector.ts @@ -0,0 +1,401 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useEvent } from "react-use" + +import { + type ProviderName, + type ProviderSettings, + type ModelInfo, + type ModelRecord, + type ExtensionMessage, + type LanguageModelChatSelector, + providerIdentifiers, + openAiModelInfoSaneDefaults, + anthropicDefaultModelId, + bedrockDefaultModelId, + deepSeekDefaultModelId, + moonshotDefaultModelId, + geminiDefaultModelId, + mistralDefaultModelId, + openAiNativeDefaultModelId, + qwenCodeDefaultModelId, + vertexDefaultModelId, + xaiDefaultModelId, + sambaNovaDefaultModelId, + internationalZAiDefaultModelId, + mainlandZAiDefaultModelId, + fireworksDefaultModelId, + friendliDefaultModelId, + minimaxDefaultModelId, + mimoDefaultModelId, + basetenDefaultModelId, + openRouterDefaultModelId, + requestyDefaultModelId, + unboundDefaultModelId, + litellmDefaultModelId, + vercelAiGatewayDefaultModelId, + zooGatewayDefaultModelId, + opencodeGoDefaultModelId, + kenariDefaultModelId, + nanoGptDefaultModelId, + kimiCodeDefaultModelId, + poeDefaultModelId, + isRetiredProvider, +} from "@roo-code/types" + +import { useRouterModels } from "@/components/ui/hooks/useRouterModels" +import { getStaticModelsForProvider } from "@/components/settings/utils/providerModelConfig" +import { MODELS_BY_PROVIDER } from "@/components/settings/constants" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { vscode } from "@/utils/vscode" + +type ModelIdKey = keyof Pick< + ProviderSettings, + | "openRouterModelId" + | "requestyModelId" + | "unboundModelId" + | "openAiModelId" + | "litellmModelId" + | "vercelAiGatewayModelId" + | "zooGatewayModelId" + | "opencodeGoModelId" + | "kenariModelId" + | "nanoGptModelId" + | "apiModelId" + | "ollamaModelId" + | "lmStudioModelId" + | "vsCodeLmModelSelector" +> + +const STATIC_DEFAULT_MODEL_IDS: Partial> = { + [providerIdentifiers.anthropic]: anthropicDefaultModelId, + [providerIdentifiers.bedrock]: bedrockDefaultModelId, + [providerIdentifiers.deepseek]: deepSeekDefaultModelId, + [providerIdentifiers.moonshot]: moonshotDefaultModelId, + [providerIdentifiers.gemini]: geminiDefaultModelId, + [providerIdentifiers.mistral]: mistralDefaultModelId, + [providerIdentifiers.openaiNative]: openAiNativeDefaultModelId, + [providerIdentifiers.qwenCode]: qwenCodeDefaultModelId, + [providerIdentifiers.vertex]: vertexDefaultModelId, + [providerIdentifiers.xai]: xaiDefaultModelId, + [providerIdentifiers.sambanova]: sambaNovaDefaultModelId, + [providerIdentifiers.zai]: internationalZAiDefaultModelId, + [providerIdentifiers.fireworks]: fireworksDefaultModelId, + [providerIdentifiers.friendli]: friendliDefaultModelId, + [providerIdentifiers.minimax]: minimaxDefaultModelId, + [providerIdentifiers.mimo]: mimoDefaultModelId, + [providerIdentifiers.baseten]: basetenDefaultModelId, +} + +const ROUTER_DEFAULT_MODEL_IDS: Partial> = { + [providerIdentifiers.openrouter]: openRouterDefaultModelId, + [providerIdentifiers.requesty]: requestyDefaultModelId, + [providerIdentifiers.unbound]: unboundDefaultModelId, + [providerIdentifiers.litellm]: litellmDefaultModelId, + [providerIdentifiers.vercelAiGateway]: vercelAiGatewayDefaultModelId, + [providerIdentifiers.zooGateway]: zooGatewayDefaultModelId, + [providerIdentifiers.opencodeGo]: opencodeGoDefaultModelId, + [providerIdentifiers.kenari]: kenariDefaultModelId, + [providerIdentifiers.nanogpt]: nanoGptDefaultModelId, + [providerIdentifiers.kimiCode]: kimiCodeDefaultModelId, + [providerIdentifiers.poe]: poeDefaultModelId, +} + +// Providers whose model list is delivered through a dedicated message event +// (mirrors the settings page provider components). +const MESSAGE_BASED_PROVIDERS: ProviderName[] = [ + providerIdentifiers.openai, + providerIdentifiers.ollama, + providerIdentifiers.lmstudio, + providerIdentifiers.vscodeLm, +] + +export interface ChatModelSelectorData { + /** The provider key used to determine model source (undefined for retired providers). */ + provider: ProviderName | undefined + /** Record of available models for the current provider (null until loaded or when N/A). */ + models: Record | null + /** The configuration field key that stores the selected model for this provider. */ + modelIdKey: ModelIdKey | undefined + /** The default model id for this provider. */ + defaultModelId: string + /** Whether the model list is still loading. */ + isLoading: boolean + /** Transform a selected model id into the stored configuration value (e.g. VSCode LM selector object). */ + valueTransform?: (modelId: string) => unknown + /** Transform the stored configuration value back to a display string (e.g. VSCode LM selector). */ + displayTransform?: (value: unknown) => string +} + +/** + * Resolves the model list, storage key and defaults for the currently active + * provider so the chat input bar can render a compact model picker. + * + * The data sources mirror the settings page (`ApiOptions` and the provider + * components) so the chat selector shows the exact same models: + * - Router providers (openrouter, requesty, unbound, vercel-ai-gateway, + * zoo-gateway, opencode-go, kenari, nanogpt, kimi-code): + * react-query `useRouterModels` request. + * - litellm / poe: backend-broadcast `routerModels` from extension state. + * - openai (OpenAI compatible), ollama, lmstudio, vscode-lm: request on mount + * and listen for the corresponding `*Models` message event. + * - Static providers: `getStaticModelsForProvider`. + */ +export const useChatModelSelector = (): ChatModelSelectorData => { + const { apiConfiguration, routerModels: stateRouterModels } = useExtensionState() + + const provider = (apiConfiguration?.apiProvider || providerIdentifiers.openrouter) as ProviderName + const activeProvider = isRetiredProvider(provider) ? undefined : provider + + // Router providers are fetched through react-query (mirrors ApiOptions). + const routerModels = useRouterModels({ + provider: activeProvider, + enabled: !!activeProvider && ROUTER_DEFAULT_MODEL_IDS[activeProvider] !== undefined, + }) + + // Message-based providers: request the models on mount and keep the + // latest list delivered by the backend. Each request carries a unique + // `requestId` and a response is only applied when it matches the most + // recent request, so stale/out-of-order responses (for a previous provider + // or an earlier base URL) cannot clobber the current list. + const [openAiModels, setOpenAiModels] = useState([]) + const [ollamaModels, setOllamaModels] = useState({}) + const [lmStudioModels, setLmStudioModels] = useState({}) + const [vsCodeLmModels, setVsCodeLmModels] = useState([]) + // Track request completion explicitly. `openAiModels.length === 0` is a valid + // successful result (an endpoint with no models), so it alone must not keep + // the picker in a permanent loading state. + const [openAiModelsReceived, setOpenAiModelsReceived] = useState(false) + // Only one message-based provider is active at a time, so a single latest + // request id is sufficient to reject stale responses. + const latestRequestId = useRef(undefined) + + const onMessage = useCallback((event: MessageEvent) => { + const message: ExtensionMessage = event.data + // Responses without a requestId are broadcast/legacy payloads and are + // accepted; responses with a mismatching id are stale and ignored. + const isStale = (): boolean => + message.requestId !== undefined && + latestRequestId.current !== undefined && + message.requestId !== latestRequestId.current + switch (message.type) { + case "openAiModels": + if (isStale()) break + setOpenAiModels(message.openAiModels ?? []) + setOpenAiModelsReceived(true) + break + case "ollamaModels": + if (isStale()) break + setOllamaModels(message.ollamaModels ?? {}) + break + case "lmStudioModels": + if (isStale()) break + setLmStudioModels(message.lmStudioModels ?? {}) + break + case "vsCodeLmModels": + if (isStale()) break + setVsCodeLmModels(message.vsCodeLmModels ?? []) + break + } + }, []) + useEvent("message", onMessage) + + useEffect(() => { + setOpenAiModels([]) + setOllamaModels({}) + setLmStudioModels({}) + setVsCodeLmModels([]) + setOpenAiModelsReceived(false) + latestRequestId.current = undefined + }, [activeProvider]) + + const serializedOpenAiHeaders = JSON.stringify(apiConfiguration?.openAiHeaders ?? {}) + + // Request models on mount when a message-based provider is active + // (mirrors Ollama.tsx / LMStudio.tsx / OpenAICompatible.tsx behaviors). + useEffect(() => { + if (!activeProvider || !MESSAGE_BASED_PROVIDERS.includes(activeProvider)) { + return + } + + const requestId = `${activeProvider}-${Date.now()}-${Math.random().toString(36).slice(2)}` + latestRequestId.current = requestId + + switch (activeProvider) { + case providerIdentifiers.openai: + if (apiConfiguration?.openAiBaseUrl && apiConfiguration?.openAiApiKey) { + vscode.postMessage({ + type: "requestOpenAiModels", + requestId, + values: { + baseUrl: apiConfiguration.openAiBaseUrl, + apiKey: apiConfiguration.openAiApiKey, + customHeaders: {}, + openAiHeaders: JSON.parse(serializedOpenAiHeaders), + }, + }) + } + break + case providerIdentifiers.ollama: + vscode.postMessage({ type: "requestOllamaModels", requestId }) + break + case providerIdentifiers.lmstudio: + vscode.postMessage({ type: "requestLmStudioModels", requestId }) + break + case providerIdentifiers.vscodeLm: + vscode.postMessage({ type: "requestVsCodeLmModels", requestId }) + break + } + }, [activeProvider, apiConfiguration?.openAiBaseUrl, apiConfiguration?.openAiApiKey, serializedOpenAiHeaders]) + + // Map provider -> config field key + model list + default id. + const result = useMemo(() => { + if (!activeProvider) { + return { + provider: undefined, + models: null, + modelIdKey: undefined, + defaultModelId: "", + isLoading: false, + } + } + + const defaultModelId = + (activeProvider === providerIdentifiers.zai && apiConfiguration?.zaiApiLine === "china_coding" + ? mainlandZAiDefaultModelId + : (ROUTER_DEFAULT_MODEL_IDS[activeProvider] ?? STATIC_DEFAULT_MODEL_IDS[activeProvider] ?? "")) ?? "" + + let models: Record | null = null + let modelIdKey: ModelIdKey | undefined = undefined + let valueTransform: ((modelId: string) => unknown) | undefined + let displayTransform: ((value: unknown) => string) | undefined + let isLoading = false + + switch (activeProvider) { + case providerIdentifiers.openrouter: + models = routerModels.data?.openrouter ?? null + modelIdKey = "openRouterModelId" + break + case providerIdentifiers.requesty: + models = routerModels.data?.requesty ?? null + modelIdKey = "requestyModelId" + break + case providerIdentifiers.unbound: + models = routerModels.data?.unbound ?? null + modelIdKey = "unboundModelId" + break + case providerIdentifiers.litellm: + // The settings page reads litellm models from the backend + // broadcast cache (stateRouterModels), not from react-query. + models = stateRouterModels?.litellm ?? null + modelIdKey = "litellmModelId" + break + case providerIdentifiers.vercelAiGateway: + models = routerModels.data?.[providerIdentifiers.vercelAiGateway] ?? null + modelIdKey = "vercelAiGatewayModelId" + break + case providerIdentifiers.zooGateway: + models = routerModels.data?.[providerIdentifiers.zooGateway] ?? null + modelIdKey = "zooGatewayModelId" + break + case providerIdentifiers.opencodeGo: + models = routerModels.data?.[providerIdentifiers.opencodeGo] ?? null + modelIdKey = "opencodeGoModelId" + break + case providerIdentifiers.kenari: + models = routerModels.data?.[providerIdentifiers.kenari] ?? null + modelIdKey = "kenariModelId" + break + case providerIdentifiers.nanogpt: + models = routerModels.data?.[providerIdentifiers.nanogpt] ?? null + modelIdKey = "nanoGptModelId" + break + case providerIdentifiers.kimiCode: + // Kimi Code stores its model id in apiModelId (mirrors useSelectedModel). + models = routerModels.data?.[providerIdentifiers.kimiCode] ?? null + modelIdKey = "apiModelId" + break + case providerIdentifiers.poe: + // Same as litellm: poe uses the backend broadcast cache. + models = stateRouterModels?.poe ?? null + modelIdKey = "apiModelId" + break + case providerIdentifiers.openai: + // OpenAI Compatible: the list is fetched from the baseUrl via + // `requestOpenAiModels` and delivered through `openAiModels`. + models = + Object.keys(openAiModels).length > 0 + ? Object.fromEntries(openAiModels.map((item) => [item, openAiModelInfoSaneDefaults])) + : null + modelIdKey = "openAiModelId" + break + case providerIdentifiers.ollama: + models = Object.keys(ollamaModels).length > 0 ? ollamaModels : null + modelIdKey = "ollamaModelId" + break + case providerIdentifiers.lmstudio: + models = Object.keys(lmStudioModels).length > 0 ? lmStudioModels : null + modelIdKey = "lmStudioModelId" + break + case providerIdentifiers.vscodeLm: + models = vsCodeLmModels.reduce( + (acc, model) => { + const modelId = `${model.vendor}/${model.family}` + acc[modelId] = { + maxTokens: 0, + contextWindow: 0, + supportsPromptCache: false, + description: `${model.vendor} - ${model.family}`, + } + return acc + }, + {} as Record, + ) + modelIdKey = "vsCodeLmModelSelector" + valueTransform = (modelId) => { + const [vendor, family] = modelId.split("/") + return { vendor, family } + } + displayTransform = (value) => { + if (!value) return "" + const selector = value as { vendor?: string; family?: string } + return selector.vendor && selector.family ? `${selector.vendor}/${selector.family}` : "" + } + break + default: + // Static models providers (anthropic, bedrock, gemini, etc.). + models = MODELS_BY_PROVIDER[activeProvider] + ? getStaticModelsForProvider(activeProvider, undefined, apiConfiguration) + : null + modelIdKey = "apiModelId" + } + + isLoading = + (ROUTER_DEFAULT_MODEL_IDS[activeProvider] !== undefined && routerModels.isLoading) || + (activeProvider === providerIdentifiers.openai && + !!apiConfiguration?.openAiBaseUrl && + !!apiConfiguration?.openAiApiKey && + !openAiModelsReceived) + + return { + provider: activeProvider, + models, + modelIdKey, + defaultModelId, + isLoading, + valueTransform, + displayTransform, + } + }, [ + activeProvider, + apiConfiguration, + routerModels, + stateRouterModels, + openAiModels, + openAiModelsReceived, + ollamaModels, + lmStudioModels, + vsCodeLmModels, + ]) + + return result +} diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 7779a0a5cb..5763a76ba3 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -1,5 +1,9 @@ { "greeting": "Benvingut a Zoo Code!", + "clearSearch": "Neteja la cerca", + "searchModel": "Cerca models...", + "modelListEmpty": "Aquest proveïdor no té models disponibles", + "useCustomModel": "Utilitza el model personalitzat: {{modelId}}", "task": { "title": "Tasca", "expand": "Expandir tasca", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 45f9d34c3b..84dac08695 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -1,5 +1,9 @@ { "greeting": "Willkommen bei Zoo Code!", + "clearSearch": "Suche löschen", + "searchModel": "Modelle durchsuchen...", + "modelListEmpty": "Für diesen Anbieter sind keine Modelle verfügbar", + "useCustomModel": "Benutzerdefiniertes Modell verwenden: {{modelId}}", "task": { "title": "Aufgabe", "expand": "Aufgabe erweitern", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 460e94413e..269699d634 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -1,5 +1,9 @@ { "greeting": "Welcome to Zoo Code!", + "clearSearch": "Clear search", + "searchModel": "Search models...", + "modelListEmpty": "No models available for this provider", + "useCustomModel": "Use custom model: {{modelId}}", "task": { "title": "Task", "expand": "Expand task", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 9f2727493f..2970e3f283 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -1,5 +1,9 @@ { "greeting": "¡Bienvenido a Zoo Code!", + "clearSearch": "Limpiar búsqueda", + "searchModel": "Buscar modelos...", + "modelListEmpty": "No hay modelos disponibles para este proveedor", + "useCustomModel": "Usar modelo personalizado: {{modelId}}", "task": { "title": "Tarea", "expand": "Expandir tarea", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 2b4cf8c557..1e40b80c4c 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -1,5 +1,9 @@ { "greeting": "Bienvenue sur Zoo Code !", + "clearSearch": "Effacer la recherche", + "searchModel": "Rechercher des modèles...", + "modelListEmpty": "Aucun modèle disponible pour ce fournisseur", + "useCustomModel": "Utiliser un modèle personnalisé : {{modelId}}", "task": { "title": "Tâche", "expand": "Développer la tâche", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index ebbd679434..dd9046c762 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -1,5 +1,9 @@ { "greeting": "Zoo Code में आपका स्वागत है!", + "clearSearch": "खोज साफ़ करें", + "searchModel": "मॉडल खोजें...", + "modelListEmpty": "इस प्रदाता के लिए कोई मॉडल उपलब्ध नहीं है", + "useCustomModel": "कस्टम मॉडल का उपयोग करें: {{modelId}}", "task": { "title": "कार्य", "expand": "कार्य विस्तृत करें", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index d097894162..60307938fd 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -1,5 +1,9 @@ { "greeting": "Selamat datang di Zoo Code!", + "clearSearch": "Hapus pencarian", + "searchModel": "Cari model...", + "modelListEmpty": "Tidak ada model yang tersedia untuk penyedia ini", + "useCustomModel": "Gunakan model kustom: {{modelId}}", "task": { "title": "Tugas", "expand": "Perluas tugas", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index e99c76e702..26f84e574d 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -1,5 +1,9 @@ { "greeting": "Benvenuto in Zoo Code!", + "clearSearch": "Cancella ricerca", + "searchModel": "Cerca modelli...", + "modelListEmpty": "Nessun modello disponibile per questo provider", + "useCustomModel": "Usa modello personalizzato: {{modelId}}", "task": { "title": "Attività", "expand": "Espandi attività", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 5ffd5c873c..dfe66eca53 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -1,5 +1,9 @@ { "greeting": "Zoo Code へようこそ!", + "clearSearch": "検索をクリア", + "searchModel": "モデルを検索...", + "modelListEmpty": "このプロバイダーで利用可能なモデルはありません", + "useCustomModel": "カスタムモデルを使用: {{modelId}}", "task": { "title": "タスク", "expand": "タスクを展開", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 313d3b4722..207bcfdc78 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -1,5 +1,9 @@ { "greeting": "Zoo Code에 오신 것을 환영합니다!", + "clearSearch": "검색 지우기", + "searchModel": "모델 검색...", + "modelListEmpty": "이 공급자에 사용 가능한 모델이 없습니다", + "useCustomModel": "사용자 지정 모델 사용: {{modelId}}", "task": { "title": "작업", "expand": "작업 펼치기", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 1e091996c6..7838130444 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -1,5 +1,9 @@ { "greeting": "Welkom bij Zoo Code!", + "clearSearch": "Zoekopdracht wissen", + "searchModel": "Modellen zoeken...", + "modelListEmpty": "Geen modellen beschikbaar voor deze provider", + "useCustomModel": "Aangepast model gebruiken: {{modelId}}", "task": { "title": "Taak", "expand": "Taak uitvouwen", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 76ff83065b..47f95a87b0 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -1,5 +1,9 @@ { "greeting": "Witamy w Zoo Code!", + "clearSearch": "Wyczyść wyszukiwanie", + "searchModel": "Szukaj modeli...", + "modelListEmpty": "Brak dostępnych modeli dla tego dostawcy", + "useCustomModel": "Użyj niestandardowego modelu: {{modelId}}", "task": { "title": "Zadanie", "expand": "Rozwiń zadanie", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 5884e3424b..a7658fb4f1 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -1,5 +1,9 @@ { "greeting": "Bem-vindo ao Zoo Code!", + "clearSearch": "Limpar pesquisa", + "searchModel": "Pesquisar modelos...", + "modelListEmpty": "Nenhum modelo disponível para este provedor", + "useCustomModel": "Usar modelo personalizado: {{modelId}}", "task": { "title": "Tarefa", "expand": "Expandir tarefa", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 8de32b56fe..0a75e44a25 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -1,5 +1,9 @@ { "greeting": "Добро пожаловать в Zoo Code!", + "clearSearch": "Очистить поиск", + "searchModel": "Поиск моделей...", + "modelListEmpty": "Нет доступных моделей для этого провайдера", + "useCustomModel": "Использовать пользовательскую модель: {{modelId}}", "task": { "title": "Задача", "expand": "Развернуть задачу", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 0cb4576ee0..5c65497ebf 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -1,5 +1,9 @@ { "greeting": "Zoo Code'a hoş geldin!", + "clearSearch": "Aramayı temizle", + "searchModel": "Modelleri ara...", + "modelListEmpty": "Bu sağlayıcı için kullanılabilir model yok", + "useCustomModel": "Özel model kullan: {{modelId}}", "task": { "title": "Görev", "expand": "Görevi genişlet", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 5a3965a5c1..cd6a339bda 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -1,5 +1,9 @@ { "greeting": "Chào mừng đến với Zoo Code!", + "clearSearch": "Xóa tìm kiếm", + "searchModel": "Tìm kiếm mô hình...", + "modelListEmpty": "Không có mô hình nào khả dụng cho nhà cung cấp này", + "useCustomModel": "Sử dụng mô hình tùy chỉnh: {{modelId}}", "task": { "title": "Nhiệm vụ", "expand": "Mở rộng nhiệm vụ", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 982220c2ea..285c95f17d 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -1,5 +1,9 @@ { "greeting": "欢迎使用 Zoo Code!", + "clearSearch": "清除搜索", + "searchModel": "搜索模型...", + "modelListEmpty": "此提供商没有可用的模型", + "useCustomModel": "使用自定义模型:{{modelId}}", "task": { "title": "任务", "expand": "展开任务", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 65fbc49176..ffcea90547 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -1,5 +1,9 @@ { "greeting": "歡迎使用 Zoo Code!", + "clearSearch": "清除搜尋", + "searchModel": "搜尋模型...", + "modelListEmpty": "此提供者沒有可用的模型", + "useCustomModel": "使用自訂模型:{{modelId}}", "task": { "title": "工作", "expand": "展開工作", From f74771d00fbc8e20357b6fe0a57a235ba21f1617 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:38:15 +0000 Subject: [PATCH 02/12] feat(chat): add chat model selector in input bar Reuse getProviderDefaultModelId and a shared organization allow-list helper (packages/types) instead of re-declaring the provider matrix, keeping the changed-executable-line budget within the mutation gate. Collapse the repeated router-provider test blocks into one parameterized table. --- .../__tests__/organization-allow-list.test.ts | 81 ++++++ packages/types/src/index.ts | 1 + packages/types/src/organization-allow-list.ts | 54 ++++ packages/types/src/vscode-extension-host.ts | 1 + src/api/providers/__tests__/openai.spec.ts | 17 ++ src/api/providers/openai.ts | 10 +- src/core/config/ProviderSettingsManager.ts | 8 +- src/core/webview/ClineProvider.ts | 140 +++++------ src/core/webview/ModelRequestRegistry.ts | 29 +++ .../ClineProvider.apiHandlerRebuild.spec.ts | 106 ++++++++ .../webview/__tests__/ClineProvider.spec.ts | 14 +- .../__tests__/ModelRequestRegistry.spec.ts | 19 ++ .../__tests__/webviewMessageHandler.spec.ts | 174 ++++++++++++- src/core/webview/webviewMessageHandler.ts | 86 +++++-- src/eslint-suppressions.json | 2 +- .../src/components/chat/ChatModelSelector.tsx | 42 +--- .../__tests__/useChatModelSelector.spec.tsx | 236 ++++++------------ .../chat/hooks/useChatModelSelector.ts | 204 +++++---------- .../hooks/__tests__/useRouterModels.spec.tsx | 73 ++++++ .../components/ui/hooks/useRouterModels.ts | 24 +- 20 files changed, 877 insertions(+), 444 deletions(-) create mode 100644 packages/types/src/__tests__/organization-allow-list.test.ts create mode 100644 packages/types/src/organization-allow-list.ts create mode 100644 src/core/webview/ModelRequestRegistry.ts create mode 100644 src/core/webview/__tests__/ModelRequestRegistry.spec.ts create mode 100644 webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.tsx diff --git a/packages/types/src/__tests__/organization-allow-list.test.ts b/packages/types/src/__tests__/organization-allow-list.test.ts new file mode 100644 index 0000000000..b71a901540 --- /dev/null +++ b/packages/types/src/__tests__/organization-allow-list.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest" + +import type { OrganizationAllowList } from "../cloud.js" +import { isModelAllowedForOrganization, isProviderAllowAll } from "../organization-allow-list.js" +import { providerIdentifiers } from "../provider-identifiers.js" + +describe("isProviderAllowAll", () => { + it("allows everything when the allow list is missing", () => { + expect(isProviderAllowAll(undefined, providerIdentifiers.anthropic)).toBe(true) + }) + + it("allows everything when the organization allows all", () => { + const allowList: OrganizationAllowList = { allowAll: true, providers: {} } + expect(isProviderAllowAll(allowList, providerIdentifiers.anthropic)).toBe(true) + }) + + it("rejects when the provider is missing but the organization is restricted", () => { + const allowList: OrganizationAllowList = { allowAll: false, providers: {} } + expect(isProviderAllowAll(allowList, undefined)).toBe(false) + }) + + it("rejects a provider without an explicit entry", () => { + const allowList: OrganizationAllowList = { allowAll: false, providers: {} } + expect(isProviderAllowAll(allowList, providerIdentifiers.anthropic)).toBe(false) + }) + + it("allows a provider narrowed to a model list (the list still constrains models)", () => { + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { [providerIdentifiers.anthropic]: { allowAll: false, models: ["m"] } }, + } + // A non-allowAll provider must not expose the custom-model escape hatch. + expect(isProviderAllowAll(allowList, providerIdentifiers.anthropic)).toBe(false) + }) + + it("allows a provider explicitly marked allowAll", () => { + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { [providerIdentifiers.openrouter]: { allowAll: true } }, + } + expect(isProviderAllowAll(allowList, providerIdentifiers.openrouter)).toBe(true) + }) +}) + +describe("isModelAllowedForOrganization", () => { + it("allows any model when the allow list is missing", () => { + expect(isModelAllowedForOrganization(undefined, providerIdentifiers.anthropic, "any")).toBe(true) + }) + + it("allows any model when the organization allows all", () => { + const allowList: OrganizationAllowList = { allowAll: true, providers: {} } + expect(isModelAllowedForOrganization(allowList, providerIdentifiers.anthropic, "any")).toBe(true) + }) + + it("rejects when the provider is missing but the organization is restricted", () => { + const allowList: OrganizationAllowList = { allowAll: false, providers: {} } + expect(isModelAllowedForOrganization(allowList, undefined, "any")).toBe(false) + }) + + it("rejects a model from a provider without an explicit entry", () => { + const allowList: OrganizationAllowList = { allowAll: false, providers: {} } + expect(isModelAllowedForOrganization(allowList, providerIdentifiers.anthropic, "m")).toBe(false) + }) + + it("allows a listed model and rejects an unlisted one", () => { + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { [providerIdentifiers.anthropic]: { allowAll: false, models: ["allowed"] } }, + } + expect(isModelAllowedForOrganization(allowList, providerIdentifiers.anthropic, "allowed")).toBe(true) + expect(isModelAllowedForOrganization(allowList, providerIdentifiers.anthropic, "other")).toBe(false) + }) + + it("allows any model for a provider explicitly marked allowAll", () => { + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { [providerIdentifiers.openrouter]: { allowAll: true } }, + } + expect(isModelAllowedForOrganization(allowList, providerIdentifiers.openrouter, "anything")).toBe(true) + }) +}) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 82588ae537..18fbb08e3c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -18,6 +18,7 @@ export * from "./mcp.js" export * from "./message.js" export * from "./mode.js" export * from "./model.js" +export * from "./organization-allow-list.js" export * from "./provider-identifiers.js" export * from "./provider-settings.js" export * from "./task.js" diff --git a/packages/types/src/organization-allow-list.ts b/packages/types/src/organization-allow-list.ts new file mode 100644 index 0000000000..fa8ffe597d --- /dev/null +++ b/packages/types/src/organization-allow-list.ts @@ -0,0 +1,54 @@ +import type { OrganizationAllowList } from "./cloud.js" +import type { ProviderName } from "./provider-settings.js" + +/** + * Whether organization policy allows an arbitrary/custom model id for a provider. + * + * Only providers that are explicitly `allowAll` may use free-form ids; when a + * provider is narrowed to an allow-list, callers must not expose a custom-model + * escape hatch. This is the single source of truth shared by the settings + * model picker and the chat model selector. + */ +export const isProviderAllowAll = ( + allowList: OrganizationAllowList | undefined, + provider: ProviderName | undefined, +): boolean => { + if (!allowList || allowList.allowAll) { + return true + } + + if (!provider) { + return false + } + + return allowList.providers[provider]?.allowAll === true +} + +/** + * Whether organization policy allows selecting the given model id for a provider. + * + * `allowAll` (org-wide or per provider) permits any id; otherwise the id must be + * present in the provider's explicit `models` list. Intended as a defense-in-depth + * check that rejects a selection regardless of how it was produced (option click, + * keyboard navigation, or custom entry). + */ +export const isModelAllowedForOrganization = ( + allowList: OrganizationAllowList | undefined, + provider: ProviderName | undefined, + modelId: string, +): boolean => { + if (!allowList || allowList.allowAll) { + return true + } + + if (!provider) { + return false + } + + const providerConfig = allowList.providers[provider] + if (!providerConfig) { + return false + } + + return providerConfig.allowAll === true || (providerConfig.models?.includes(modelId) ?? false) +} diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c0e8509105..dd478106e2 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -464,6 +464,7 @@ export type EditQueuedMessagePayload = Pick { vi.mocked(axios.get).mockClear() }) + it("forwards cancellation to the HTTP request", async () => { + const controller = new AbortController() + vi.mocked(axios.get).mockImplementationOnce( + (_url, options) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener?.("abort", () => reject(new Error("cancelled"))) + }), + ) + const pending = getOpenAiModels("https://example.test/v1", "test-key", undefined, controller.signal) + expect(axios.get).toHaveBeenCalledWith( + "https://example.test/v1/models", + expect.objectContaining({ signal: controller.signal }), + ) + controller.abort() + expect(await pending).toEqual([]) + }) + it("should return empty array when baseUrl is not provided", async () => { const result = await getOpenAiModels(undefined, "test-key") expect(result).toEqual([]) diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 8ccf3335f0..e44fb1774e 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -573,8 +573,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } -export async function getOpenAiModels(baseUrl?: string, apiKey?: string, openAiHeaders?: Record) { +export async function getOpenAiModels( + baseUrl?: string, + apiKey?: string, + openAiHeaders?: Record, + signal?: AbortSignal, +) { try { + signal?.throwIfAborted() if (!baseUrl) { return [] } @@ -586,7 +592,7 @@ export async function getOpenAiModels(baseUrl?: string, apiKey?: string, openAiH return [] } - const config: Record = {} + const config: Record = signal ? { signal } : {} const headers: Record = { ...DEFAULT_HEADERS, ...(openAiHeaders || {}), diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 3fcc0e6e43..c0835047b8 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -506,7 +506,7 @@ export class ProviderSettingsManager { /** * Set the API config for a specific mode. */ - public async setModeConfig(mode: Mode, configId: string) { + public async setModeConfig(mode: Mode, configId: string | undefined) { try { return await this.lock(async () => { const providerProfiles = await this.load() @@ -515,7 +515,11 @@ export class ProviderSettingsManager { providerProfiles.modeApiConfigs = {} } // Assign the chosen config ID to this mode - providerProfiles.modeApiConfigs[mode] = configId + if (configId === undefined) { + delete providerProfiles.modeApiConfigs[mode] + } else { + providerProfiles.modeApiConfigs[mode] = configId + } await this.store(providerProfiles) }) } catch (error) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 8e54e0c5fc..175d9ee53e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -115,6 +115,7 @@ import { CustomModesManager } from "../config/CustomModesManager" import { PendingActionSettlementError, Task } from "../task/Task" import { webviewMessageHandler } from "./webviewMessageHandler" +import { ModelRequestRegistry } from "./ModelRequestRegistry" import type { WebviewFocusTracker } from "./WebviewFocusTracker" import type { ClineMessage, TodoItem } from "@roo-code/types" import { @@ -251,6 +252,7 @@ export class ClineProvider public readonly taskHistoryStore: TaskHistoryStore private taskHistoryStoreInitialized = false public static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds + public readonly modelRequests = new ModelRequestRegistry() private providerProfileMutationQueue = Promise.resolve() private historyTaskCreationQueue = Promise.resolve() @@ -840,6 +842,7 @@ export class ClineProvider } this._disposed = true + this.modelRequests.dispose() this._postStateToWebviewThrottled.cancel() this.log("Disposing ClineProvider...") @@ -1877,91 +1880,72 @@ export class ClineProvider providerSettings: ProviderSettings, activate: boolean = true, ): Promise { - // Snapshot the active profile state so that a partial failure can be - // rolled back instead of leaving the persisted store and the in-memory - // context (global state + provider settings) out of sync. - const previousProviderSettings = this.contextProxy.getProviderSettings() - const previousCurrentApiConfigName = this.contextProxy.getValue("currentApiConfigName") - // Snapshot the active mode's profile binding so it too can be restored if a - // later write in the activation sequence fails. - let previousModeConfigId: string | undefined - let mutatedMode: Mode | undefined - let didMutateState = false - 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() - previousModeConfigId = await this.providerSettingsManager.getModeConfigId(mode) - mutatedMode = mode - - // These promises do the following: - // 1. Adds or updates the list of provider profiles. - // 2. Sets the current provider profile. - // 3. Sets the current mode's provider profile. - // 4. Copies the provider settings to the context. - // - // Note: 1, 2, and 4 can be done in one `ContextProxy` call: - // this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... }) - // We should probably switch to that and verify that it works. - // I left the original implementation in just to be safe. - const listApiConfig = await this.providerSettingsManager.listConfig() - didMutateState = true - await Promise.all([ - this.updateGlobalState("listApiConfigMeta", listApiConfig), - 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 { - const listApiConfig = await this.providerSettingsManager.listConfig() - didMutateState = true - await this.updateGlobalState("listApiConfigMeta", listApiConfig) - } + signal.throwIfAborted() + // Snapshot and compensate inside the queue so a failed save cannot + // overwrite a later successful mutation. + const previousProfile = (await this.providerSettingsManager.hasConfig(name)) + ? await this.providerSettingsManager.getProfile({ name }) + : undefined + const previousSettings = this.contextProxy.getProviderSettings() + const previousName = this.contextProxy.getValue("currentApiConfigName") + const previousMeta = this.contextProxy.getValue("listApiConfigMeta") + const task = activate ? this.getCurrentTask() : undefined + const previousTaskSettings = task?.apiConfiguration + const mode = activate ? (await this.getState()).mode : undefined + const previousModeConfigId = mode ? await this.providerSettingsManager.getModeConfigId(mode) : undefined + let saved = false - await this.postStateToWebview() - return id - }) - } catch (error) { - // Restore the previously-persisted profile state so a failure does not - // leave the context pointing at a half-written profile. The provider - // settings store (`saveConfig`) is itself atomic, so only the context - // copy needs restoring. - if (didMutateState) { try { - this.updateTaskApiHandlerIfNeeded(previousProviderSettings, { forceRebuild: true }) - await this.contextProxy.setProviderSettings(previousProviderSettings) - await this.updateGlobalState("currentApiConfigName", previousCurrentApiConfigName) - if (mutatedMode && previousModeConfigId) { - await this.providerSettingsManager.setModeConfig(mutatedMode, previousModeConfigId) + signal.throwIfAborted() + const id = await this.providerSettingsManager.saveConfig(name, providerSettings) + saved = true + signal.throwIfAborted() + const listApiConfig = await this.providerSettingsManager.listConfig() + await this.updateGlobalState("listApiConfigMeta", listApiConfig) + if (activate && mode) { + // Sequential writes must settle before compensation starts. + await this.updateGlobalState("currentApiConfigName", name) + await this.providerSettingsManager.setModeConfig(mode, id) + await this.contextProxy.setProviderSettings(providerSettings) + this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) } - await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) + signal.throwIfAborted() await this.postStateToWebview() - } catch (rollbackError) { - this.log( - `Failed to roll back provider profile state: ${ - rollbackError instanceof Error ? rollbackError.message : String(rollbackError) - }`, - ) + signal.throwIfAborted() + if (activate) await this.persistStickyProviderProfileToCurrentTask(name) + return id + } catch (error) { + if (saved) { + // Attempt every compensation even if an individual store is unavailable. + const restore = async (write: () => unknown | Promise) => { + try { + await write() + } catch (rollbackError) { + this.log(`Failed to roll back provider profile state: ${String(rollbackError)}`) + } + } + await restore(() => + previousProfile + ? this.providerSettingsManager.saveConfig(name, previousProfile) + : this.providerSettingsManager.deleteConfig(name), + ) + if (activate && mode) { + await restore(() => this.providerSettingsManager.setModeConfig(mode, previousModeConfigId)) + await restore(() => this.contextProxy.setProviderSettings(previousSettings)) + await restore(() => this.updateGlobalState("currentApiConfigName", previousName)) + if (task && previousTaskSettings) { + await restore(() => task.updateApiConfiguration(previousTaskSettings)) + } + } + await restore(() => this.updateGlobalState("listApiConfigMeta", previousMeta)) + await restore(() => this.postStateToWebview()) + } + throw error } - } - + }) + } catch (error) { this.log( `Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) diff --git a/src/core/webview/ModelRequestRegistry.ts b/src/core/webview/ModelRequestRegistry.ts new file mode 100644 index 0000000000..ba4b8cbd3a --- /dev/null +++ b/src/core/webview/ModelRequestRegistry.ts @@ -0,0 +1,29 @@ +/** Model discovery work owned by one webview. */ +export class ModelRequestRegistry { + private readonly requests = new Map() + private disposed = false + + async run(id: string, fetch: (signal: AbortSignal) => Promise): Promise { + if (this.disposed) return + this.cancel(id) + const controller = new AbortController() + this.requests.set(id, controller) + try { + await fetch(controller.signal) + } catch (error) { + if (!controller.signal.aborted) throw error + } finally { + if (this.requests.get(id) === controller) this.requests.delete(id) + } + } + + cancel(id: string): void { + this.requests.get(id)?.abort() + this.requests.delete(id) + } + + dispose(): void { + this.disposed = true + for (const id of this.requests.keys()) this.cancel(id) + } +} diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 9e5fa3f67b..9a202c7bed 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -1,3 +1,4 @@ +import { ProviderSettingsManager } from "../../config/ProviderSettingsManager" import { WebviewFocusTracker } from "../WebviewFocusTracker" // npx vitest core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -244,6 +245,8 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { // Mock providerSettingsManager ;(provider as any).providerSettingsManager = { saveConfig: vi.fn().mockResolvedValue("test-id"), + hasConfig: vi.fn().mockResolvedValue(true), + deleteConfig: vi.fn(), listConfig: vi.fn().mockResolvedValue([ { name: "test-config", @@ -464,6 +467,109 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { }) }) + describe("persisted profile compensation", () => { + const oldSettings = { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4" } + const newSettings = { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "new-model" } + beforeEach(async () => { + Object.defineProperty(provider, "providerSettingsManager", { + value: new ProviderSettingsManager(mockContext), + configurable: true, + }) + await provider.contextProxy.setValue("mode", "code") + await provider.providerSettingsManager.saveConfig("existing", oldSettings) + await provider.providerSettingsManager.setModeConfig("code", undefined) + await provider.contextProxy.setProviderSettings(oldSettings) + await provider.contextProxy.setValue("currentApiConfigName", "existing") + await provider.contextProxy.setValue( + "listApiConfigMeta", + await provider.providerSettingsManager.listConfig(), + ) + }) + + test.each(["list", "activation", "broadcast"] as const)( + "restores existing and removes new profiles after %s failure", + async (phase) => { + const manager = provider.providerSettingsManager + const previous = await manager.getProfile({ name: "existing" }) + const previousMeta = provider.contextProxy.getValue("listApiConfigMeta") + const task = new Task({ ...defaultTaskOptions, apiConfiguration: oldSettings }) + await provider.addClineToStack(task) + for (const name of ["existing", "new-profile"]) { + if (phase === "list") + vi.spyOn(manager, "listConfig").mockRejectedValueOnce(new Error("list failed")) + if (phase === "activation") + vi.spyOn(provider.contextProxy, "setProviderSettings").mockRejectedValueOnce( + new Error("activation failed"), + ) + if (phase === "broadcast") + vi.spyOn(provider, "postStateToWebview").mockRejectedValueOnce(new Error("broadcast failed")) + expect(await provider.upsertProviderProfile(name, newSettings)).toBeUndefined() + expect(await manager.getProfile({ name: "existing" })).toEqual(previous) + expect(await manager.hasConfig("new-profile")).toBe(false) + expect(await manager.getModeConfigId("code")).toBeUndefined() + expect(provider.contextProxy.getProviderSettings()).toMatchObject(oldSettings) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("existing") + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual(previousMeta) + expect(task.apiConfiguration).toEqual(oldSettings) + expect(task.setTaskApiConfigName).not.toHaveBeenCalled() + } + }, + ) + + test("keeps a later update queued until persisted rollback completes", async () => { + const manager = provider.providerSettingsManager + const save = manager.saveConfig.bind(manager) + let releaseRollback!: () => void + vi.spyOn(manager, "saveConfig") + .mockImplementationOnce(save) + .mockImplementationOnce(async (name, settings) => { + await new Promise((resolve) => { + releaseRollback = resolve + }) + return save(name, settings) + }) + vi.spyOn(provider, "postStateToWebview").mockRejectedValueOnce(new Error("broadcast failed")) + const first = provider.upsertProviderProfile("existing", newSettings) + await vi.waitFor(() => expect(manager.saveConfig).toHaveBeenCalledTimes(2)) + const finalSettings = { ...oldSettings, openRouterModelId: "final-model" } + const second = provider.upsertProviderProfile("existing", finalSettings) + await Promise.resolve() + expect(manager.saveConfig).toHaveBeenCalledTimes(2) + releaseRollback() + expect(await first).toBeUndefined() + expect(await second).toBeTruthy() + expect(await manager.getProfile({ name: "existing" })).toMatchObject(finalSettings) + expect(provider.contextProxy.getProviderSettings()).toMatchObject(finalSettings) + }) + + test("snapshots queued updates after the prior mutation and finishes rollback before the next", async () => { + let release!: () => void + const broadcast = vi + .spyOn(provider, "postStateToWebview") + .mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }), + ) + .mockRejectedValueOnce(new Error("second broadcast failed")) + const first = provider.upsertProviderProfile("existing", newSettings) + await vi.waitFor(() => expect(broadcast).toHaveBeenCalledTimes(1)) + const second = provider.upsertProviderProfile("existing", { + ...newSettings, + openRouterModelId: "failed-model", + }) + release() + expect(await first).toBeTruthy() + expect(await second).toBeUndefined() + expect(await provider.providerSettingsManager.getProfile({ name: "existing" })).toMatchObject(newSettings) + expect(provider.contextProxy.getProviderSettings()).toMatchObject(newSettings) + expect(await provider.providerSettingsManager.getModeConfigId("code")).toBe( + (await provider.providerSettingsManager.getProfile({ name: "existing" })).id, + ) + }) + }) + describe("activateProviderProfile", () => { test("serializes provider profile mutations without interleaving", async () => { const events: string[] = [] diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 3eb8057df0..a72ee858b3 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2630,6 +2630,7 @@ describe("ClineProvider", () => { .mockResolvedValue([ { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]), + hasConfig: vi.fn().mockResolvedValue(false), saveConfig: vi.fn().mockResolvedValue("test-id"), setModeConfig: vi.fn(), getModeConfigId: vi.fn().mockResolvedValue(undefined), @@ -3367,7 +3368,11 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + hasConfig: vi.fn().mockResolvedValue(false), + deleteConfig: vi.fn(), setModeConfig: vi.fn().mockRejectedValue(new Error("Failed to update mode config")), + saveConfig: vi.fn().mockResolvedValue("test-id"), + getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() .mockResolvedValue([ @@ -3400,6 +3405,8 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + hasConfig: vi.fn().mockResolvedValue(false), + deleteConfig: vi.fn(), setModeConfig: vi.fn(), saveConfig: vi.fn().mockResolvedValue(undefined), getModeConfigId: vi.fn().mockResolvedValue(undefined), @@ -3446,6 +3453,8 @@ describe("ClineProvider", () => { throw new Error("API handler error") }) ;(provider as any).providerSettingsManager = { + hasConfig: vi.fn().mockResolvedValue(false), + deleteConfig: vi.fn(), setModeConfig: vi.fn(), saveConfig: vi.fn().mockResolvedValue(undefined), getModeConfigId: vi.fn().mockResolvedValue(undefined), @@ -3478,9 +3487,8 @@ describe("ClineProvider", () => { ) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.create_api_config") - // The partial failure is rolled back: the persisted profile list is - // re-read and the in-memory state is restored rather than left pointing - // at a half-applied profile. + // The initial metadata write is followed by restoration of the snapshot. + expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith("test-config") expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [ { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]) diff --git a/src/core/webview/__tests__/ModelRequestRegistry.spec.ts b/src/core/webview/__tests__/ModelRequestRegistry.spec.ts new file mode 100644 index 0000000000..00d1163e3f --- /dev/null +++ b/src/core/webview/__tests__/ModelRequestRegistry.spec.ts @@ -0,0 +1,19 @@ +import { ModelRequestRegistry } from "../ModelRequestRegistry" + +it("aborts all pending requests on disposal and does not start new work", async () => { + const registry = new ModelRequestRegistry() + const signals: AbortSignal[] = [] + const fetch = vi.fn( + (signal: AbortSignal) => + new Promise((resolve) => { + signals.push(signal) + signal.addEventListener("abort", () => resolve(), { once: true }) + }), + ) + const first = registry.run("first", fetch) + const second = registry.run("second", fetch) + registry.dispose() + await Promise.all([first, second, registry.run("third", fetch)]) + expect(signals.every((signal) => signal.aborted)).toBe(true) + expect(fetch).toHaveBeenCalledTimes(2) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 0d40968407..9f040859e1 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1,3 +1,14 @@ +import { ModelRequestRegistry } from "../ModelRequestRegistry" +import { getOpenAiModels } from "../../../api/providers/openai" +import { getVsCodeLmModels } from "../../../api/providers/vscode-lm" +vi.mock("../../../api/providers/openai", async (importOriginal) => ({ + ...(await importOriginal()), + getOpenAiModels: vi.fn(), +})) +vi.mock("../../../api/providers/vscode-lm", async (importOriginal) => ({ + ...(await importOriginal()), + getVsCodeLmModels: vi.fn(), +})) // npx vitest core/webview/__tests__/webviewMessageHandler.spec.ts import type { Mock } from "vitest" @@ -97,6 +108,7 @@ const mockFetchOpenAiCodexRateLimitInfo = vi.mocked(fetchOpenAiCodexRateLimitInf // Mock ClineProvider const mockClineProvider = { + modelRequests: new ModelRequestRegistry(), getState: vi.fn(), postMessageToWebview: vi.fn(), customModesManager: { @@ -202,6 +214,22 @@ describe("webviewMessageHandler - organization allowlist enforcement", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalled() }) + it("rejects a disallowed save without persisting or updating state", async () => { + const saveConfig = vi.fn() + const listConfig = vi.fn() + Object.assign(mockClineProvider, { providerSettingsManager: { saveConfig, listConfig } }) + await webviewMessageHandler(mockClineProvider, { + type: "saveApiConfiguration", + text: "profile", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-opus-4-20250514" }, + }) + expect(saveConfig).not.toHaveBeenCalled() + expect(listConfig).not.toHaveBeenCalled() + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(t("common:errors.violated_organization_allowlist")) + }) + it("allows an allow-listed model on upsertApiConfiguration", async () => { const upsertProviderProfile = vi.fn().mockResolvedValue("id") setUpsertSpy(upsertProviderProfile) @@ -344,15 +372,18 @@ describe("webviewMessageHandler - requestLmStudioModels", () => { await webviewMessageHandler(mockClineProvider, { type: "requestLmStudioModels", + requestId: "models-request", }) expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.lmstudio, + signal: expect.any(AbortSignal), baseUrl: "http://localhost:1234", }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "lmStudioModels", + requestId: "models-request", lmStudioModels: mockModels, }) }) @@ -362,10 +393,11 @@ describe("webviewMessageHandler - requestLmStudioModels", () => { await webviewMessageHandler(mockClineProvider, { type: "requestLmStudioModels", + requestId: "models-request", values: { baseUrl: "http://127.0.0.1:4321" }, }) - expect(mockGetLMStudioModels).toHaveBeenCalledWith("http://127.0.0.1:4321") + expect(mockGetLMStudioModels).toHaveBeenCalledWith("http://127.0.0.1:4321", { signal: expect.any(AbortSignal) }) expect(mockGetModels).not.toHaveBeenCalled() }) @@ -374,10 +406,11 @@ describe("webviewMessageHandler - requestLmStudioModels", () => { await webviewMessageHandler(mockClineProvider, { type: "requestLmStudioModels", + requestId: "models-request", values: { baseUrl: "" }, }) - expect(mockGetLMStudioModels).toHaveBeenCalledWith("") + expect(mockGetLMStudioModels).toHaveBeenCalledWith("", { signal: expect.any(AbortSignal) }) expect(mockGetModels).not.toHaveBeenCalled() }) }) @@ -447,15 +480,18 @@ describe("webviewMessageHandler - requestOllamaModels", () => { await webviewMessageHandler(mockClineProvider, { type: "requestOllamaModels", + requestId: "models-request", }) expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.ollama, + signal: expect.any(AbortSignal), baseUrl: "http://localhost:1234", }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "ollamaModels", + requestId: "models-request", ollamaModels: mockModels, }) }) @@ -465,10 +501,12 @@ describe("webviewMessageHandler - requestOllamaModels", () => { await webviewMessageHandler(mockClineProvider, { type: "requestOllamaModels", + requestId: "models-request", }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "ollamaModels", + requestId: "models-request", ollamaModels: {}, }) }) @@ -478,10 +516,12 @@ describe("webviewMessageHandler - requestOllamaModels", () => { await webviewMessageHandler(mockClineProvider, { type: "requestOllamaModels", + requestId: "models-request", }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "ollamaModels", + requestId: "models-request", ollamaModels: {}, error: "Connection refused", }) @@ -496,6 +536,7 @@ describe("webviewMessageHandler - requestOllamaModels", () => { await webviewMessageHandler(mockClineProvider, { type: "requestOllamaModels", + requestId: "models-request", values: { baseUrl: "https://ollama.example.com" }, }) @@ -505,6 +546,7 @@ describe("webviewMessageHandler - requestOllamaModels", () => { ) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "ollamaModels", + requestId: "models-request", ollamaModels: {}, error: "Cache write failed", }) @@ -524,6 +566,7 @@ describe("webviewMessageHandler - requestOllamaModels", () => { await webviewMessageHandler(mockClineProvider, { type: "requestOllamaModels", + requestId: "models-request", values: { baseUrl: "https://ollama.example.com", apiKey: "secret-key", @@ -534,6 +577,7 @@ describe("webviewMessageHandler - requestOllamaModels", () => { expect(mockFlushModels).toHaveBeenCalledWith( { provider: providerIdentifiers.ollama, + signal: expect.any(AbortSignal), baseUrl: "https://ollama.example.com", apiKey: "secret-key", }, @@ -541,12 +585,14 @@ describe("webviewMessageHandler - requestOllamaModels", () => { ) expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.ollama, + signal: expect.any(AbortSignal), baseUrl: "https://ollama.example.com", apiKey: "secret-key", }) expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "ollamaModels", + requestId: "models-request", ollamaModels: mockModels, }) }) @@ -2358,3 +2404,127 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() }) }) + +describe("model request correlation and cancellation", () => { + beforeEach(() => { + vi.clearAllMocks() + mockClineProvider.getState = vi.fn().mockResolvedValue({ apiConfiguration: {} }) + mockFlushModels.mockReset().mockResolvedValue(undefined) + mockGetModels.mockReset().mockResolvedValue({}) + }) + + it("echoes the OpenAI request ID", async () => { + vi.mocked(getOpenAiModels).mockResolvedValue(["model"]) + await webviewMessageHandler(mockClineProvider, { + type: "requestOpenAiModels", + requestId: "openai-request", + values: { baseUrl: "https://example.test/v1", apiKey: "test-key" }, + }) + expect(getOpenAiModels).toHaveBeenCalledWith( + "https://example.test/v1", + "test-key", + undefined, + expect.any(AbortSignal), + ) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "openAiModels", + requestId: "openai-request", + openAiModels: ["model"], + }) + }) + + it("echoes the VS Code LM request ID", async () => { + vi.mocked(getVsCodeLmModels).mockResolvedValue([]) + await webviewMessageHandler(mockClineProvider, { type: "requestVsCodeLmModels", requestId: "vscode-request" }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "vsCodeLmModels", + requestId: "vscode-request", + vsCodeLmModels: [], + }) + }) + + it.each(["requestOllamaModels", "requestLmStudioModels"] as const)( + "cancels %s during refresh before reading models", + async (type) => { + let finish!: () => void + mockFlushModels.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const pending = webviewMessageHandler(mockClineProvider, { type, requestId: "cancel-refresh" }) + await vi.waitFor(() => expect(mockFlushModels).toHaveBeenCalled()) + const signal = mockFlushModels.mock.calls[0][0].signal + await webviewMessageHandler(mockClineProvider, { type: "cancelModelRequest", requestId: "cancel-refresh" }) + expect(signal?.aborted).toBe(true) + finish() + await pending + expect(mockGetModels).not.toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + }, + ) + + it.each(["requestOllamaModels", "requestLmStudioModels", "requestRouterModels"] as const)( + "cancels %s during model reads without publishing results", + async (type) => { + let finish!: (models: ModelRecord) => void + mockGetModels.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const pending = webviewMessageHandler(mockClineProvider, { + type, + requestId: "cancel-read", + values: { provider: providerIdentifiers.openrouter }, + }) + await vi.waitFor(() => expect(mockGetModels).toHaveBeenCalled()) + const signal = mockGetModels.mock.calls[0][0].signal + await webviewMessageHandler(mockClineProvider, { type: "cancelModelRequest", requestId: "cancel-read" }) + expect(signal?.aborted).toBe(true) + finish({}) + await pending + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + }, + ) + + it("settles cancelled VS Code discovery even when the native API is still pending", async () => { + let finish!: (models: Awaited>) => void + vi.mocked(getVsCodeLmModels).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const pending = webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + requestId: "cancel-vscode", + }) + await webviewMessageHandler(mockClineProvider, { type: "cancelModelRequest", requestId: "cancel-vscode" }) + await pending + finish([]) + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("aborts OpenAI HTTP work and suppresses its late response", async () => { + let finish!: (models: string[]) => void + vi.mocked(getOpenAiModels).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const pending = webviewMessageHandler(mockClineProvider, { + type: "requestOpenAiModels", + requestId: "cancel-openai", + values: { baseUrl: "https://example.test/v1", apiKey: "test-key" }, + }) + await webviewMessageHandler(mockClineProvider, { type: "cancelModelRequest", requestId: "cancel-openai" }) + expect(vi.mocked(getOpenAiModels).mock.calls[0][3]?.aborted).toBe(true) + finish(["stale"]) + await pending + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index bf45690a9e..3095b7f482 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1,3 +1,4 @@ +import { resolveModelWithAbort } from "../../api/providers/utils/abort-signal" import { safeWriteJson } from "../../utils/safeWriteJson" import * as path from "path" import * as os from "os" @@ -127,6 +128,41 @@ export const webviewMessageHandler = async ( message: WebviewMessage, marketplaceManager?: MarketplaceManager, ) => { + if (message.type === "cancelModelRequest") { + if (message.requestId) provider.modelRequests.cancel(message.requestId) + return + } + if ( + message.requestId && + [ + "requestOllamaModels", + "requestLmStudioModels", + "requestOpenAiModels", + "requestVsCodeLmModels", + "requestRouterModels", + ].includes(message.type) + ) { + return provider.modelRequests.run(message.requestId, (signal) => + handleWebviewMessage(provider, message, marketplaceManager, signal), + ) + } + return handleWebviewMessage(provider, message, marketplaceManager) +} + +const handleWebviewMessage = async ( + provider: ClineProvider, + message: WebviewMessage, + marketplaceManager?: MarketplaceManager, + modelRequestSignal?: AbortSignal, +) => { + const requestOptions = (options: GetModelsOptions): GetModelsOptions => { + modelRequestSignal?.throwIfAborted() + return modelRequestSignal ? { ...options, signal: modelRequestSignal } : options + } + const getRequestModels = (options: GetModelsOptions) => getModels(requestOptions(options)) + const flushRequestModels = (options: GetModelsOptions, refresh: boolean) => + flushModels(requestOptions(options), refresh) + // Utility functions provided for concise get/update of global state via contextProxy API. const getGlobalState = (key: K) => provider.contextProxy.getValue(key) const updateGlobalState = async (key: K, value: GlobalState[K]) => @@ -1142,8 +1178,9 @@ export const webviewMessageHandler = async ( const safeGetModels = async (options: GetModelsOptions): Promise => { try { - return await getModels(options) + return await getRequestModels(options) } catch (error) { + modelRequestSignal?.throwIfAborted() console.error( `Failed to fetch models in webviewMessageHandler requestRouterModels for ${options.provider}:`, error, @@ -1198,7 +1235,7 @@ export const webviewMessageHandler = async ( // If explicit credentials are provided in message.values (from Refresh Models button), // flush the cache first to ensure we fetch fresh data with the new credentials if (message?.values?.litellmApiKey || message?.values?.litellmBaseUrl) { - await flushModels( + await flushRequestModels( { provider: providerIdentifiers.litellm, apiKey: litellmApiKey, baseUrl: litellmBaseUrl }, true, ) @@ -1216,7 +1253,7 @@ export const webviewMessageHandler = async ( if (poeApiKey) { if (message?.values?.poeApiKey || message?.values?.poeBaseUrl) { - await flushModels( + await flushRequestModels( { provider: providerIdentifiers.poe, apiKey: poeApiKey, baseUrl: poeBaseUrl }, true, ) @@ -1234,7 +1271,7 @@ export const webviewMessageHandler = async ( if (deepSeekApiKey) { if (message?.values?.deepSeekApiKey || message?.values?.deepSeekBaseUrl) { - await flushModels( + await flushRequestModels( { provider: providerIdentifiers.deepseek, apiKey: deepSeekApiKey, baseUrl: deepSeekBaseUrl }, true, ) @@ -1256,7 +1293,7 @@ export const webviewMessageHandler = async ( if (moonshotApiKey) { if (message?.values?.moonshotApiKey || message?.values?.moonshotBaseUrl) { - await flushModels( + await flushRequestModels( { provider: providerIdentifiers.moonshot, apiKey: moonshotApiKey, baseUrl: moonshotBaseUrl }, true, ) @@ -1281,7 +1318,7 @@ export const webviewMessageHandler = async ( // Refresh the cache when a new key is explicitly provided (e.g. the Refresh Models button). if (message?.values?.opencodeGoApiKey) { - await flushModels({ provider: providerIdentifiers.opencodeGo, apiKey: opencodeGoApiKey }, true) + await flushRequestModels({ provider: providerIdentifiers.opencodeGo, apiKey: opencodeGoApiKey }, true) } candidates.push({ @@ -1298,7 +1335,7 @@ export const webviewMessageHandler = async ( // Refresh the cache when a new key is explicitly provided (e.g. the Refresh Models button). if (message?.values?.kenariApiKey) { - await flushModels({ provider: providerIdentifiers.kenari, apiKey: kenariApiKey }, true) + await flushRequestModels({ provider: providerIdentifiers.kenari, apiKey: kenariApiKey }, true) } candidates.push({ @@ -1311,7 +1348,7 @@ export const webviewMessageHandler = async ( // same key-scoped options for refresh and retrieval. const nanoGptApiKey = message?.values?.nanoGptApiKey ?? apiConfiguration.nanoGptApiKey if (message?.values?.nanoGptApiKey !== undefined) { - await flushModels({ provider: providerIdentifiers.nanogpt, apiKey: nanoGptApiKey }, true) + await flushRequestModels({ provider: providerIdentifiers.nanogpt, apiKey: nanoGptApiKey }, true) } candidates.push({ @@ -1343,7 +1380,7 @@ export const webviewMessageHandler = async ( // If refresh flag is set and we have a specific provider, flush its cache first if (shouldRefresh && providerFilter && modelFetchPromises.length > 0) { const targetCandidate = modelFetchPromises[0] - await flushModels(targetCandidate.options, true) + await flushRequestModels(targetCandidate.options, true) } const results = await Promise.allSettled( @@ -1353,6 +1390,7 @@ export const webviewMessageHandler = async ( }), ) + modelRequestSignal?.throwIfAborted() results.forEach((result, index) => { const routerName = modelFetchPromises[index].key @@ -1376,8 +1414,10 @@ export const webviewMessageHandler = async ( } }) + modelRequestSignal?.throwIfAborted() await provider.postMessageToWebview({ type: RouterModelsMessageType.routerModels, + requestId: message.requestId, routerModels, values: providerFilter ? { provider: requestedProvider } : undefined, }) @@ -1402,10 +1442,12 @@ export const webviewMessageHandler = async ( // Refresh the cache before reading the models. Keep this error // separate from the read below so diagnostics identify which // cache operation failed. - await flushModels(ollamaOptions, true) + await flushRequestModels(ollamaOptions, true) } catch (error) { + modelRequestSignal?.throwIfAborted() const errorMsg = error instanceof Error ? error.message : String(error) provider.log(`[requestOllamaModels] Failed to refresh model cache for ${logBaseUrl}: ${errorMsg}`) + modelRequestSignal?.throwIfAborted() await provider.postMessageToWebview({ type: OllamaModelsMessageType.ollamaModels, ollamaModels: {}, @@ -1416,18 +1458,21 @@ export const webviewMessageHandler = async ( } try { - const ollamaModels = await getModels(ollamaOptions) + const ollamaModels = await getRequestModels(ollamaOptions) // Always post a response so the webview refresh status can // transition out of "loading" — even when no models are found. + modelRequestSignal?.throwIfAborted() await provider.postMessageToWebview({ type: OllamaModelsMessageType.ollamaModels, ollamaModels, requestId: message.requestId, }) } catch (error) { + modelRequestSignal?.throwIfAborted() const errorMsg = error instanceof Error ? error.message : String(error) provider.log(`[requestOllamaModels] Failed to read models for ${logBaseUrl}: ${errorMsg}`) + modelRequestSignal?.throwIfAborted() await provider.postMessageToWebview({ type: OllamaModelsMessageType.ollamaModels, ollamaModels: {}, @@ -1445,18 +1490,23 @@ export const webviewMessageHandler = async ( const hasPreviewBaseUrl = typeof requestedBaseUrl === "string" let lmStudioModels: ModelRecord if (hasPreviewBaseUrl) { - lmStudioModels = await getLMStudioModels(requestedBaseUrl) + modelRequestSignal?.throwIfAborted() + lmStudioModels = await getLMStudioModels( + requestedBaseUrl, + ...(modelRequestSignal ? [{ signal: modelRequestSignal }] : []), + ) } else { const lmStudioOptions = { provider: providerIdentifiers.lmstudio, baseUrl: lmStudioApiConfig.lmStudioBaseUrl, } // Flush cache and refresh to ensure fresh models. - await flushModels(lmStudioOptions, true) - lmStudioModels = await getModels(lmStudioOptions) + await flushRequestModels(lmStudioOptions, true) + lmStudioModels = await getRequestModels(lmStudioOptions) } if (Object.keys(lmStudioModels).length > 0) { + modelRequestSignal?.throwIfAborted() await provider.postMessageToWebview({ type: LmStudioModelsMessageType.lmStudioModels, lmStudioModels: lmStudioModels, @@ -1464,12 +1514,14 @@ export const webviewMessageHandler = async ( }) } } catch (error) { + modelRequestSignal?.throwIfAborted() // Silently fail - user hasn't configured LM Studio yet. console.debug("LM Studio models fetch failed:", error) } break } case "requestRooModels": { + modelRequestSignal?.throwIfAborted() await provider.postMessageToWebview({ type: RouterModelsMessageType.singleRouterModelFetchResponse, success: false, @@ -1484,8 +1536,10 @@ export const webviewMessageHandler = async ( message?.values?.baseUrl, message?.values?.apiKey, message?.values?.openAiHeaders, + modelRequestSignal, ) + modelRequestSignal?.throwIfAborted() await provider.postMessageToWebview({ type: OpenAiModelsMessageType.openAiModels, openAiModels, @@ -1495,8 +1549,10 @@ export const webviewMessageHandler = async ( break case VsCodeLmModelsMessageType.requestVsCodeLmModels: - const vsCodeLmModels = await getVsCodeLmModels() + modelRequestSignal?.throwIfAborted() + const vsCodeLmModels = await resolveModelWithAbort(getVsCodeLmModels, modelRequestSignal, "VS Code LM") // TODO: Cache like we do for OpenRouter, etc? + modelRequestSignal?.throwIfAborted() await provider.postMessageToWebview({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels, diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index e4b15aa27e..c8d7ffe57a 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -401,7 +401,7 @@ }, "api/providers/openai.ts": { "@typescript-eslint/no-explicit-any": { - "count": 3 + "count": 2 } }, "api/providers/openrouter.ts": { diff --git a/webview-ui/src/components/chat/ChatModelSelector.tsx b/webview-ui/src/components/chat/ChatModelSelector.tsx index bff20e2057..5989d0e287 100644 --- a/webview-ui/src/components/chat/ChatModelSelector.tsx +++ b/webview-ui/src/components/chat/ChatModelSelector.tsx @@ -7,6 +7,8 @@ import { useAppTranslation } from "@/i18n/TranslationContext" import { vscode } from "@/utils/vscode" import { useExtensionState } from "@/context/ExtensionStateContext" import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" +import { isModelAllowedForOrganization, isProviderAllowAll } from "@roo-code/types" + import { filterModels } from "@/components/settings/utils/organizationFilters" import { useChatModelSelector } from "./hooks/useChatModelSelector" @@ -58,39 +60,17 @@ export const ChatModelSelector = ({ disabled = false, title, triggerClassName = return modelIds.filter((id) => id.toLowerCase().includes(q)) }, [modelIds, searchValue]) - // Whether org policy permits an arbitrary/custom model id for this provider. - // Only providers that are explicitly `allowAll` may use free-form ids; when a - // provider is narrowed to an allow-list we must not expose the custom-model - // escape hatch (the options list is already filtered by `filterModels`). - const canUseCustomModel = useMemo(() => { - if (!organizationAllowList || organizationAllowList.allowAll) { - return true - } - if (!provider) { - return false - } - return organizationAllowList.providers[provider]?.allowAll === true - }, [organizationAllowList, provider]) + // Org policy permits an arbitrary/custom model id only when the provider is + // explicitly `allowAll`; the options list is already filtered by `filterModels`. + const canUseCustomModel = useMemo( + () => isProviderAllowAll(organizationAllowList, provider), + [organizationAllowList, provider], + ) - // Defense-in-depth: reject a selection that the organization policy disallows, - // regardless of how it was produced (option click, keyboard, or custom entry). + // Defense-in-depth: reject a selection the org policy disallows, regardless + // of how it was produced (option click, keyboard, or custom entry). const isModelAllowed = useCallback( - (modelId: string): boolean => { - if (!organizationAllowList || organizationAllowList.allowAll) { - return true - } - if (!provider) { - return false - } - const providerConfig = organizationAllowList.providers[provider] - if (!providerConfig) { - return false - } - if (providerConfig.allowAll) { - return true - } - return providerConfig.models?.includes(modelId) ?? false - }, + (modelId: string): boolean => isModelAllowedForOrganization(organizationAllowList, provider, modelId), [organizationAllowList, provider], ) diff --git a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx index e118c59088..aff8acfc7c 100644 --- a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx +++ b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx @@ -55,6 +55,63 @@ describe("useChatModelSelector", () => { }) }) + describe("request cleanup", () => { + it.each([ + providerIdentifiers.ollama, + providerIdentifiers.lmstudio, + providerIdentifiers.openai, + providerIdentifiers.vscodeLm, + ])("cancels %s on provider switch and rapid remount", (apiProvider) => { + const config = { apiProvider, openAiBaseUrl: "https://example.test/v1", openAiApiKey: "test-key" } + mockUseExtensionState.mockReturnValue({ apiConfiguration: config }) + const first = renderHook(() => useChatModelSelector(), { wrapper }) + const request = mockPostMessage.mock.calls.at(-1)![0] + mockUseExtensionState.mockReturnValue({ apiConfiguration: { apiProvider: providerIdentifiers.anthropic } }) + first.rerender() + expect(mockPostMessage).toHaveBeenLastCalledWith({ + type: "cancelModelRequest", + requestId: request.requestId, + }) + mockUseExtensionState.mockReturnValue({ apiConfiguration: config }) + first.rerender() + const restarted = mockPostMessage.mock.calls.at(-1)![0] + expect(restarted.requestId).not.toBe(request.requestId) + first.unmount() + expect(mockPostMessage).toHaveBeenLastCalledWith({ + type: "cancelModelRequest", + requestId: restarted.requestId, + }) + const second = renderHook(() => useChatModelSelector(), { wrapper }) + const remounted = mockPostMessage.mock.calls.at(-1)![0] + expect(remounted.requestId).not.toBe(restarted.requestId) + second.unmount() + expect(mockPostMessage).toHaveBeenLastCalledWith({ + type: "cancelModelRequest", + requestId: remounted.requestId, + }) + }) + + it("cancels the replayed StrictMode effect and the final request on unmount", () => { + mockUseExtensionState.mockReturnValue({ apiConfiguration: { apiProvider: providerIdentifiers.ollama } }) + const { unmount } = renderHook(() => useChatModelSelector(), { + wrapper: ({ children }) => {children}, + }) + const messages = mockPostMessage.mock.calls.map(([message]) => message) + expect(messages.map((message) => message.type)).toEqual([ + "requestOllamaModels", + "cancelModelRequest", + "requestOllamaModels", + ]) + expect(messages[1].requestId).toBe(messages[0].requestId) + expect(messages[2].requestId).not.toBe(messages[0].requestId) + unmount() + expect(mockPostMessage).toHaveBeenLastCalledWith({ + type: "cancelModelRequest", + requestId: messages[2].requestId, + }) + }) + }) + describe("static providers", () => { it("returns static models for anthropic with apiModelId key", () => { const { result } = renderHook(() => useChatModelSelector(), { wrapper }) @@ -102,175 +159,32 @@ describe("useChatModelSelector", () => { }) }) - describe("zoo-gateway (dynamic router provider)", () => { - it("reads zoo-gateway models from the react-query routerModels", () => { + describe("dynamic router providers", () => { + it.each([ + [providerIdentifiers.zooGateway, "zooGatewayModelId", "anthropic/claude-sonnet-4"], + [providerIdentifiers.opencodeGo, "opencodeGoModelId", "glm-5.2"], + [providerIdentifiers.kenari, "kenariModelId", "glm-5-2"], + [providerIdentifiers.nanogpt, "nanoGptModelId", "openai/gpt-5.6-sol"], + [providerIdentifiers.requesty, "requestyModelId", "openai/gpt-5.1"], + [providerIdentifiers.unbound, "unboundModelId", "openai/gpt-4o"], + [providerIdentifiers.vercelAiGateway, "vercelAiGatewayModelId", "openai/gpt-4o-mini"], + [providerIdentifiers.kimiCode, "apiModelId", "kimi-k2"], + ] as const)("reads %s models from the react-query routerModels", (provider, modelIdKey, modelId) => { mockUseRouterModels.mockReturnValue({ - data: { - [providerIdentifiers.zooGateway]: { - "anthropic/claude-sonnet-4": { maxTokens: 1, contextWindow: 1 }, - }, - }, + data: { [provider]: { [modelId]: { maxTokens: 1, contextWindow: 1 } } }, isLoading: false, isError: false, }) mockUseExtensionState.mockReturnValue({ - apiConfiguration: { - apiProvider: providerIdentifiers.zooGateway, - zooGatewayModelId: "anthropic/claude-sonnet-4", - }, + apiConfiguration: { apiProvider: provider, [modelIdKey]: modelId }, routerModels: undefined, }) const { result } = renderHook(() => useChatModelSelector(), { wrapper }) - expect(result.current.modelIdKey).toBe("zooGatewayModelId") + expect(result.current.modelIdKey).toBe(modelIdKey) expect(result.current.defaultModelId).toBeTruthy() - expect(Object.keys(result.current.models!)).toEqual(["anthropic/claude-sonnet-4"]) - }) - }) - - describe("opencode-go (dynamic router provider)", () => { - it("reads opencode-go models from the react-query routerModels", () => { - mockUseRouterModels.mockReturnValue({ - data: { [providerIdentifiers.opencodeGo]: { "glm-5.2": { maxTokens: 1, contextWindow: 1 } } }, - isLoading: false, - isError: false, - }) - mockUseExtensionState.mockReturnValue({ - apiConfiguration: { apiProvider: providerIdentifiers.opencodeGo, opencodeGoModelId: "glm-5.2" }, - routerModels: undefined, - }) - - const { result } = renderHook(() => useChatModelSelector(), { wrapper }) - - expect(result.current.modelIdKey).toBe("opencodeGoModelId") - expect(result.current.defaultModelId).toBeTruthy() - expect(Object.keys(result.current.models!)).toEqual(["glm-5.2"]) - }) - }) - - describe("kenari (dynamic router provider)", () => { - it("reads kenari models from the react-query routerModels", () => { - mockUseRouterModels.mockReturnValue({ - data: { [providerIdentifiers.kenari]: { "glm-5-2": { maxTokens: 1, contextWindow: 1 } } }, - isLoading: false, - isError: false, - }) - mockUseExtensionState.mockReturnValue({ - apiConfiguration: { apiProvider: providerIdentifiers.kenari, kenariModelId: "glm-5-2" }, - routerModels: undefined, - }) - - const { result } = renderHook(() => useChatModelSelector(), { wrapper }) - - expect(result.current.modelIdKey).toBe("kenariModelId") - expect(result.current.defaultModelId).toBeTruthy() - expect(Object.keys(result.current.models!)).toEqual(["glm-5-2"]) - }) - }) - - describe("nanogpt (dynamic router provider)", () => { - it("reads nanogpt models from the react-query routerModels", () => { - mockUseRouterModels.mockReturnValue({ - data: { [providerIdentifiers.nanogpt]: { "openai/gpt-5.6-sol": { maxTokens: 1, contextWindow: 1 } } }, - isLoading: false, - isError: false, - }) - mockUseExtensionState.mockReturnValue({ - apiConfiguration: { apiProvider: providerIdentifiers.nanogpt, nanoGptModelId: "openai/gpt-5.6-sol" }, - routerModels: undefined, - }) - - const { result } = renderHook(() => useChatModelSelector(), { wrapper }) - - expect(result.current.modelIdKey).toBe("nanoGptModelId") - expect(result.current.defaultModelId).toBeTruthy() - expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-5.6-sol"]) - }) - }) - - describe("requesty (dynamic router provider)", () => { - it("reads requesty models from the react-query routerModels", () => { - mockUseRouterModels.mockReturnValue({ - data: { [providerIdentifiers.requesty]: { "openai/gpt-5.1": { maxTokens: 1, contextWindow: 1 } } }, - isLoading: false, - isError: false, - }) - mockUseExtensionState.mockReturnValue({ - apiConfiguration: { apiProvider: providerIdentifiers.requesty, requestyModelId: "openai/gpt-5.1" }, - routerModels: undefined, - }) - - const { result } = renderHook(() => useChatModelSelector(), { wrapper }) - - expect(result.current.modelIdKey).toBe("requestyModelId") - expect(result.current.defaultModelId).toBeTruthy() - expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-5.1"]) - }) - }) - - describe("unbound (dynamic router provider)", () => { - it("reads unbound models from the react-query routerModels", () => { - mockUseRouterModels.mockReturnValue({ - data: { [providerIdentifiers.unbound]: { "openai/gpt-4o": { maxTokens: 1, contextWindow: 1 } } }, - isLoading: false, - isError: false, - }) - mockUseExtensionState.mockReturnValue({ - apiConfiguration: { apiProvider: providerIdentifiers.unbound, unboundModelId: "openai/gpt-4o" }, - routerModels: undefined, - }) - - const { result } = renderHook(() => useChatModelSelector(), { wrapper }) - - expect(result.current.modelIdKey).toBe("unboundModelId") - expect(result.current.defaultModelId).toBeTruthy() - expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-4o"]) - }) - }) - - describe("vercel-ai-gateway (dynamic router provider)", () => { - it("reads vercel-ai-gateway models from the react-query routerModels", () => { - mockUseRouterModels.mockReturnValue({ - data: { - [providerIdentifiers.vercelAiGateway]: { "openai/gpt-4o-mini": { maxTokens: 1, contextWindow: 1 } }, - }, - isLoading: false, - isError: false, - }) - mockUseExtensionState.mockReturnValue({ - apiConfiguration: { - apiProvider: providerIdentifiers.vercelAiGateway, - vercelAiGatewayModelId: "openai/gpt-4o-mini", - }, - routerModels: undefined, - }) - - const { result } = renderHook(() => useChatModelSelector(), { wrapper }) - - expect(result.current.modelIdKey).toBe("vercelAiGatewayModelId") - expect(result.current.defaultModelId).toBeTruthy() - expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-4o-mini"]) - }) - }) - - describe("kimi-code (dynamic router provider)", () => { - it("reads kimi-code models from the react-query routerModels using apiModelId", () => { - mockUseRouterModels.mockReturnValue({ - data: { [providerIdentifiers.kimiCode]: { "kimi-k2": { maxTokens: 1, contextWindow: 1 } } }, - isLoading: false, - isError: false, - }) - mockUseExtensionState.mockReturnValue({ - apiConfiguration: { apiProvider: providerIdentifiers.kimiCode, apiModelId: "kimi-k2" }, - routerModels: undefined, - }) - - const { result } = renderHook(() => useChatModelSelector(), { wrapper }) - - expect(result.current.modelIdKey).toBe("apiModelId") - expect(result.current.defaultModelId).toBeTruthy() - expect(Object.keys(result.current.models!)).toEqual(["kimi-k2"]) + expect(Object.keys(result.current.models!)).toEqual([modelId]) }) }) @@ -318,13 +232,13 @@ describe("useChatModelSelector", () => { expect(mockPostMessage).toHaveBeenCalledTimes(1) configure({ "X-Test": "first" }) rerender() - expect(mockPostMessage).toHaveBeenCalledTimes(2) + expect(mockPostMessage).toHaveBeenCalledTimes(3) configure({ "X-Test": "first" }) rerender() - expect(mockPostMessage).toHaveBeenCalledTimes(2) + expect(mockPostMessage).toHaveBeenCalledTimes(3) configure({ "X-Test": "second" }) rerender() - expect(mockPostMessage).toHaveBeenCalledTimes(3) + expect(mockPostMessage).toHaveBeenCalledTimes(5) expect(mockPostMessage).toHaveBeenLastCalledWith( expect.objectContaining({ values: expect.objectContaining({ openAiHeaders: { "X-Test": "second" } }), diff --git a/webview-ui/src/components/chat/hooks/useChatModelSelector.ts b/webview-ui/src/components/chat/hooks/useChatModelSelector.ts index cd5e635d5d..cc48e4498d 100644 --- a/webview-ui/src/components/chat/hooks/useChatModelSelector.ts +++ b/webview-ui/src/components/chat/hooks/useChatModelSelector.ts @@ -2,44 +2,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useEvent } from "react-use" import { - type ProviderName, - type ProviderSettings, type ModelInfo, type ModelRecord, type ExtensionMessage, type LanguageModelChatSelector, - providerIdentifiers, - openAiModelInfoSaneDefaults, - anthropicDefaultModelId, - bedrockDefaultModelId, - deepSeekDefaultModelId, - moonshotDefaultModelId, - geminiDefaultModelId, - mistralDefaultModelId, - openAiNativeDefaultModelId, - qwenCodeDefaultModelId, - vertexDefaultModelId, - xaiDefaultModelId, - sambaNovaDefaultModelId, - internationalZAiDefaultModelId, - mainlandZAiDefaultModelId, - fireworksDefaultModelId, - friendliDefaultModelId, - minimaxDefaultModelId, - mimoDefaultModelId, - basetenDefaultModelId, - openRouterDefaultModelId, - requestyDefaultModelId, - unboundDefaultModelId, - litellmDefaultModelId, - vercelAiGatewayDefaultModelId, - zooGatewayDefaultModelId, - opencodeGoDefaultModelId, - kenariDefaultModelId, - nanoGptDefaultModelId, - kimiCodeDefaultModelId, - poeDefaultModelId, + type ProviderName, + type ProviderSettings, + getProviderDefaultModelId, isRetiredProvider, + openAiModelInfoSaneDefaults, + providerIdentifiers, } from "@roo-code/types" import { useRouterModels } from "@/components/ui/hooks/useRouterModels" @@ -66,48 +38,32 @@ type ModelIdKey = keyof Pick< | "vsCodeLmModelSelector" > -const STATIC_DEFAULT_MODEL_IDS: Partial> = { - [providerIdentifiers.anthropic]: anthropicDefaultModelId, - [providerIdentifiers.bedrock]: bedrockDefaultModelId, - [providerIdentifiers.deepseek]: deepSeekDefaultModelId, - [providerIdentifiers.moonshot]: moonshotDefaultModelId, - [providerIdentifiers.gemini]: geminiDefaultModelId, - [providerIdentifiers.mistral]: mistralDefaultModelId, - [providerIdentifiers.openaiNative]: openAiNativeDefaultModelId, - [providerIdentifiers.qwenCode]: qwenCodeDefaultModelId, - [providerIdentifiers.vertex]: vertexDefaultModelId, - [providerIdentifiers.xai]: xaiDefaultModelId, - [providerIdentifiers.sambanova]: sambaNovaDefaultModelId, - [providerIdentifiers.zai]: internationalZAiDefaultModelId, - [providerIdentifiers.fireworks]: fireworksDefaultModelId, - [providerIdentifiers.friendli]: friendliDefaultModelId, - [providerIdentifiers.minimax]: minimaxDefaultModelId, - [providerIdentifiers.mimo]: mimoDefaultModelId, - [providerIdentifiers.baseten]: basetenDefaultModelId, -} - -const ROUTER_DEFAULT_MODEL_IDS: Partial> = { - [providerIdentifiers.openrouter]: openRouterDefaultModelId, - [providerIdentifiers.requesty]: requestyDefaultModelId, - [providerIdentifiers.unbound]: unboundDefaultModelId, - [providerIdentifiers.litellm]: litellmDefaultModelId, - [providerIdentifiers.vercelAiGateway]: vercelAiGatewayDefaultModelId, - [providerIdentifiers.zooGateway]: zooGatewayDefaultModelId, - [providerIdentifiers.opencodeGo]: opencodeGoDefaultModelId, - [providerIdentifiers.kenari]: kenariDefaultModelId, - [providerIdentifiers.nanogpt]: nanoGptDefaultModelId, - [providerIdentifiers.kimiCode]: kimiCodeDefaultModelId, - [providerIdentifiers.poe]: poeDefaultModelId, +// Router providers: model list comes from `useRouterModels` (or the backend +// broadcast cache for litellm/poe). The map holds the config field each one +// stores its selection under, so the switch below never re-declares them. +// Mirrors the router set used by `getProviderDefaultModelId`. +const ROUTER_PROVIDERS: Partial> = { + [providerIdentifiers.openrouter]: { modelIdKey: "openRouterModelId" }, + [providerIdentifiers.requesty]: { modelIdKey: "requestyModelId" }, + [providerIdentifiers.unbound]: { modelIdKey: "unboundModelId" }, + [providerIdentifiers.litellm]: { modelIdKey: "litellmModelId", fromState: true }, + [providerIdentifiers.vercelAiGateway]: { modelIdKey: "vercelAiGatewayModelId" }, + [providerIdentifiers.zooGateway]: { modelIdKey: "zooGatewayModelId" }, + [providerIdentifiers.opencodeGo]: { modelIdKey: "opencodeGoModelId" }, + [providerIdentifiers.kenari]: { modelIdKey: "kenariModelId" }, + [providerIdentifiers.nanogpt]: { modelIdKey: "nanoGptModelId" }, + [providerIdentifiers.kimiCode]: { modelIdKey: "apiModelId" }, + [providerIdentifiers.poe]: { modelIdKey: "apiModelId", fromState: true }, } // Providers whose model list is delivered through a dedicated message event // (mirrors the settings page provider components). -const MESSAGE_BASED_PROVIDERS: ProviderName[] = [ +const MESSAGE_BASED_PROVIDERS = new Set([ providerIdentifiers.openai, providerIdentifiers.ollama, providerIdentifiers.lmstudio, providerIdentifiers.vscodeLm, -] +]) export interface ChatModelSelectorData { /** The provider key used to determine model source (undefined for retired providers). */ @@ -132,25 +88,23 @@ export interface ChatModelSelectorData { * * The data sources mirror the settings page (`ApiOptions` and the provider * components) so the chat selector shows the exact same models: - * - Router providers (openrouter, requesty, unbound, vercel-ai-gateway, - * zoo-gateway, opencode-go, kenari, nanogpt, kimi-code): - * react-query `useRouterModels` request. - * - litellm / poe: backend-broadcast `routerModels` from extension state. + * - Router providers (see `ROUTER_PROVIDERS`): react-query `useRouterModels` + * request, except litellm/poe which read the backend-broadcast cache. * - openai (OpenAI compatible), ollama, lmstudio, vscode-lm: request on mount * and listen for the corresponding `*Models` message event. * - Static providers: `getStaticModelsForProvider`. + * + * Default model ids come from the shared `getProviderDefaultModelId` helper so + * this hook never re-declares the provider matrix. */ export const useChatModelSelector = (): ChatModelSelectorData => { const { apiConfiguration, routerModels: stateRouterModels } = useExtensionState() const provider = (apiConfiguration?.apiProvider || providerIdentifiers.openrouter) as ProviderName const activeProvider = isRetiredProvider(provider) ? undefined : provider + const routerConfig = activeProvider ? ROUTER_PROVIDERS[activeProvider] : undefined - // Router providers are fetched through react-query (mirrors ApiOptions). - const routerModels = useRouterModels({ - provider: activeProvider, - enabled: !!activeProvider && ROUTER_DEFAULT_MODEL_IDS[activeProvider] !== undefined, - }) + const routerModels = useRouterModels({ provider: activeProvider, enabled: !!routerConfig }) // Message-based providers: request the models on mount and keep the // latest list delivered by the backend. Each request carries a unique @@ -173,10 +127,7 @@ export const useChatModelSelector = (): ChatModelSelectorData => { const message: ExtensionMessage = event.data // Responses without a requestId are broadcast/legacy payloads and are // accepted; responses with a mismatching id are stale and ignored. - const isStale = (): boolean => - message.requestId !== undefined && - latestRequestId.current !== undefined && - message.requestId !== latestRequestId.current + const isStale = (): boolean => message.requestId !== undefined && message.requestId !== latestRequestId.current switch (message.type) { case "openAiModels": if (isStale()) break @@ -213,7 +164,7 @@ export const useChatModelSelector = (): ChatModelSelectorData => { // Request models on mount when a message-based provider is active // (mirrors Ollama.tsx / LMStudio.tsx / OpenAICompatible.tsx behaviors). useEffect(() => { - if (!activeProvider || !MESSAGE_BASED_PROVIDERS.includes(activeProvider)) { + if (!activeProvider || !MESSAGE_BASED_PROVIDERS.has(activeProvider)) { return } @@ -245,10 +196,15 @@ export const useChatModelSelector = (): ChatModelSelectorData => { vscode.postMessage({ type: "requestVsCodeLmModels", requestId }) break } + return () => { + latestRequestId.current = undefined + vscode.postMessage({ type: "cancelModelRequest", requestId }) + } }, [activeProvider, apiConfiguration?.openAiBaseUrl, apiConfiguration?.openAiApiKey, serializedOpenAiHeaders]) - // Map provider -> config field key + model list + default id. - const result = useMemo(() => { + // Resolve the model list + storage key + transforms for the active provider. + // The default id comes from the shared provider registry. + return useMemo(() => { if (!activeProvider) { return { provider: undefined, @@ -259,10 +215,24 @@ export const useChatModelSelector = (): ChatModelSelectorData => { } } - const defaultModelId = - (activeProvider === providerIdentifiers.zai && apiConfiguration?.zaiApiLine === "china_coding" - ? mainlandZAiDefaultModelId - : (ROUTER_DEFAULT_MODEL_IDS[activeProvider] ?? STATIC_DEFAULT_MODEL_IDS[activeProvider] ?? "")) ?? "" + const defaultModelId = getProviderDefaultModelId(activeProvider, { + isChina: activeProvider === providerIdentifiers.zai && apiConfiguration?.zaiApiLine === "china_coding", + }) + + if (routerConfig) { + // RouterModels is keyed by dynamic/local providers only, so a narrow + // structural cast is needed to index it by the active provider name. + const source = (routerConfig.fromState ? stateRouterModels : routerModels.data) as + | Partial> + | undefined + return { + provider: activeProvider, + models: source?.[activeProvider] ?? null, + modelIdKey: routerConfig.modelIdKey, + defaultModelId, + isLoading: routerModels.isLoading, + } + } let models: Record | null = null let modelIdKey: ModelIdKey | undefined = undefined @@ -271,54 +241,6 @@ export const useChatModelSelector = (): ChatModelSelectorData => { let isLoading = false switch (activeProvider) { - case providerIdentifiers.openrouter: - models = routerModels.data?.openrouter ?? null - modelIdKey = "openRouterModelId" - break - case providerIdentifiers.requesty: - models = routerModels.data?.requesty ?? null - modelIdKey = "requestyModelId" - break - case providerIdentifiers.unbound: - models = routerModels.data?.unbound ?? null - modelIdKey = "unboundModelId" - break - case providerIdentifiers.litellm: - // The settings page reads litellm models from the backend - // broadcast cache (stateRouterModels), not from react-query. - models = stateRouterModels?.litellm ?? null - modelIdKey = "litellmModelId" - break - case providerIdentifiers.vercelAiGateway: - models = routerModels.data?.[providerIdentifiers.vercelAiGateway] ?? null - modelIdKey = "vercelAiGatewayModelId" - break - case providerIdentifiers.zooGateway: - models = routerModels.data?.[providerIdentifiers.zooGateway] ?? null - modelIdKey = "zooGatewayModelId" - break - case providerIdentifiers.opencodeGo: - models = routerModels.data?.[providerIdentifiers.opencodeGo] ?? null - modelIdKey = "opencodeGoModelId" - break - case providerIdentifiers.kenari: - models = routerModels.data?.[providerIdentifiers.kenari] ?? null - modelIdKey = "kenariModelId" - break - case providerIdentifiers.nanogpt: - models = routerModels.data?.[providerIdentifiers.nanogpt] ?? null - modelIdKey = "nanoGptModelId" - break - case providerIdentifiers.kimiCode: - // Kimi Code stores its model id in apiModelId (mirrors useSelectedModel). - models = routerModels.data?.[providerIdentifiers.kimiCode] ?? null - modelIdKey = "apiModelId" - break - case providerIdentifiers.poe: - // Same as litellm: poe uses the backend broadcast cache. - models = stateRouterModels?.poe ?? null - modelIdKey = "apiModelId" - break case providerIdentifiers.openai: // OpenAI Compatible: the list is fetched from the baseUrl via // `requestOpenAiModels` and delivered through `openAiModels`. @@ -327,6 +249,8 @@ export const useChatModelSelector = (): ChatModelSelectorData => { ? Object.fromEntries(openAiModels.map((item) => [item, openAiModelInfoSaneDefaults])) : null modelIdKey = "openAiModelId" + isLoading = + !!apiConfiguration?.openAiBaseUrl && !!apiConfiguration?.openAiApiKey && !openAiModelsReceived break case providerIdentifiers.ollama: models = Object.keys(ollamaModels).length > 0 ? ollamaModels : null @@ -369,13 +293,6 @@ export const useChatModelSelector = (): ChatModelSelectorData => { modelIdKey = "apiModelId" } - isLoading = - (ROUTER_DEFAULT_MODEL_IDS[activeProvider] !== undefined && routerModels.isLoading) || - (activeProvider === providerIdentifiers.openai && - !!apiConfiguration?.openAiBaseUrl && - !!apiConfiguration?.openAiApiKey && - !openAiModelsReceived) - return { provider: activeProvider, models, @@ -387,6 +304,7 @@ export const useChatModelSelector = (): ChatModelSelectorData => { } }, [ activeProvider, + routerConfig, apiConfiguration, routerModels, stateRouterModels, @@ -396,6 +314,4 @@ export const useChatModelSelector = (): ChatModelSelectorData => { lmStudioModels, vsCodeLmModels, ]) - - return result } diff --git a/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.tsx new file mode 100644 index 0000000000..fca0b3861d --- /dev/null +++ b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.tsx @@ -0,0 +1,73 @@ +import { providerIdentifiers } from "@roo-code/types" +import React from "react" +import { renderHook } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { fetchRouterModels, useRouterModels } from "../useRouterModels" +import { vscode } from "@/utils/vscode" + +vi.mock("@/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) + +describe("router request cleanup", () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it("removes its listener and timer and cancels backend work on abort", async () => { + const add = vi.spyOn(window, "addEventListener") + const remove = vi.spyOn(window, "removeEventListener") + const controller = new AbortController() + const pending = fetchRouterModels(providerIdentifiers.openrouter, controller.signal) + const rejected = expect(pending).rejects.toBeDefined() + const request = vi.mocked(vscode.postMessage).mock.calls[0][0] + const listener = add.mock.calls.find(([type]) => type === "message")![1] + expect(vi.getTimerCount()).toBe(1) + controller.abort() + await rejected + expect(remove).toHaveBeenCalledWith("message", listener) + expect(vi.getTimerCount()).toBe(0) + expect(vscode.postMessage).toHaveBeenLastCalledWith({ + type: "cancelModelRequest", + requestId: request.requestId, + }) + }) + + it("aborts a pending query when its last observer unmounts", () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }) + const remove = vi.spyOn(window, "removeEventListener") + const { unmount } = renderHook(() => useRouterModels({ provider: providerIdentifiers.openrouter }), { + wrapper: ({ children }) => {children}, + }) + const request = vi.mocked(vscode.postMessage).mock.calls[0][0] + unmount() + expect(vscode.postMessage).toHaveBeenLastCalledWith({ + type: "cancelModelRequest", + requestId: request.requestId, + }) + expect(remove).toHaveBeenCalledWith("message", expect.any(Function)) + expect(vi.getTimerCount()).toBe(0) + client.clear() + }) + + it("cleans up after a matching response and ignores another request's response", async () => { + const controller = new AbortController() + const pending = fetchRouterModels(providerIdentifiers.openrouter, controller.signal) + const request = vi.mocked(vscode.postMessage).mock.calls[0][0] + const response = { + type: "routerModels", + values: { provider: providerIdentifiers.openrouter }, + routerModels: { openrouter: {} }, + } + window.dispatchEvent(new MessageEvent("message", { data: { ...response, requestId: "stale" } })) + expect(vi.getTimerCount()).toBe(1) + window.dispatchEvent(new MessageEvent("message", { data: { ...response, requestId: request.requestId } })) + expect(await pending).toEqual(response.routerModels) + expect(vi.getTimerCount()).toBe(0) + controller.abort() + expect(vscode.postMessage).toHaveBeenCalledTimes(1) + }) +}) diff --git a/webview-ui/src/components/ui/hooks/useRouterModels.ts b/webview-ui/src/components/ui/hooks/useRouterModels.ts index a7d6b36725..9205f952ca 100644 --- a/webview-ui/src/components/ui/hooks/useRouterModels.ts +++ b/webview-ui/src/components/ui/hooks/useRouterModels.ts @@ -14,9 +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 requestId = crypto.randomUUID() + if (signal?.aborted) { + reject(signal.reason) + return + } + const abort = () => { + cleanup() + vscode.postMessage({ type: "cancelModelRequest", requestId }) + reject(signal?.reason ?? new Error("Router models request aborted")) + } const cleanup = () => { + clearTimeout(timeout) + signal?.removeEventListener("abort", abort) if (typeof window !== "undefined") { window.removeEventListener("message", handler) } @@ -24,6 +36,7 @@ export const fetchRouterModels = async (provider?: string) => const timeout = setTimeout(() => { cleanup() + vscode.postMessage({ type: "cancelModelRequest", requestId }) reject(new Error("Router models request timed out")) }, 10000) @@ -34,7 +47,7 @@ export const fetchRouterModels = async (provider?: string) => const msgProvider = message?.values?.provider as string | undefined // Verify response matches request - if (provider !== msgProvider) { + if (provider !== msgProvider || (message.requestId !== undefined && message.requestId !== requestId)) { // Not our response; ignore and wait for the matching one return } @@ -50,11 +63,12 @@ export const fetchRouterModels = async (provider?: string) => } } + signal?.addEventListener("abort", abort, { once: true }) window.addEventListener("message", handler) if (provider) { - vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels, values: { provider } }) + vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels, requestId, values: { provider } }) } else { - vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels }) + vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels, requestId }) } }) @@ -62,7 +76,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, }) } From 8d48d037febd033adfc446d40db0a75ba3eca7a1 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:00:49 +0000 Subject: [PATCH 03/12] fix(providers): serialize profile mutations through rollback and propagate model discovery cancellation --- .../fetchers/__tests__/kimi-code.spec.ts | 23 +++- .../modelCache.authCancellation.spec.ts | 91 ++++++++++++++++ .../fetchers/__tests__/modelCache.spec.ts | 10 +- src/api/providers/fetchers/kimi-code.ts | 8 +- src/api/providers/fetchers/modelCache.ts | 25 +++-- src/api/providers/fetchers/zoo-gateway.ts | 10 +- .../__tests__/ProviderSettingsManager.spec.ts | 26 +++++ src/core/webview/ClineProvider.ts | 43 +++++--- .../ClineProvider.apiHandlerRebuild.spec.ts | 101 +++++++++++++++++- .../__tests__/ModelRequestRegistry.spec.ts | 36 +++++++ 10 files changed, 336 insertions(+), 37 deletions(-) create mode 100644 src/api/providers/fetchers/__tests__/modelCache.authCancellation.spec.ts diff --git a/src/api/providers/fetchers/__tests__/kimi-code.spec.ts b/src/api/providers/fetchers/__tests__/kimi-code.spec.ts index a96e760253..b8e25c4e43 100644 --- a/src/api/providers/fetchers/__tests__/kimi-code.spec.ts +++ b/src/api/providers/fetchers/__tests__/kimi-code.spec.ts @@ -1,3 +1,5 @@ +import { getEventListeners } from "events" + import { getKimiCodeModels, kimiCodeModelSchema, mapKimiCodeModel } from "../kimi-code" describe("Kimi Code model discovery", () => { @@ -83,6 +85,23 @@ describe("Kimi Code model discovery", () => { expect(models["model-b"].supportsReasoningEffort).toEqual(["low", "high", "max"]) }) + it.each(["success", "failure"])("cleans up caller listeners and timeout on %s", async (outcome) => { + vi.useFakeTimers() + const controller = new AbortController() + const transport = vi.spyOn(globalThis, "fetch") + const failure = new Error("network failed") + if (outcome === "success") transport.mockResolvedValue(new Response(JSON.stringify({ data: [] }))) + else transport.mockRejectedValue(failure) + + const result = getKimiCodeModels("token", { signal: controller.signal }) + if (outcome === "success") await expect(result).resolves.toEqual({}) + else await expect(result).rejects.toBe(failure) + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0) + expect(vi.getTimerCount()).toBe(0) + controller.abort() + expect(transport.mock.calls[0][1]?.signal?.aborted).toBe(false) + }) + it("aborts model discovery after its deadline", async () => { vi.useFakeTimers() vi.spyOn(globalThis, "fetch").mockImplementation((_input, init) => { @@ -90,11 +109,13 @@ describe("Kimi Code model discovery", () => { init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }) }) }) - const result = expect(getKimiCodeModels("token")).rejects.toThrow("timed out") + const controller = new AbortController() + const result = expect(getKimiCodeModels("token", { signal: controller.signal })).rejects.toThrow("timed out") await vi.advanceTimersByTimeAsync(10_000) await result expect(vi.mocked(fetch).mock.calls[0][1]?.signal?.aborted).toBe(true) + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0) expect(vi.getTimerCount()).toBe(0) }) diff --git a/src/api/providers/fetchers/__tests__/modelCache.authCancellation.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.authCancellation.spec.ts new file mode 100644 index 0000000000..15c2df1b38 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/modelCache.authCancellation.spec.ts @@ -0,0 +1,91 @@ +import { getEventListeners } from "events" +import axios from "axios" +import { providerIdentifiers } from "@roo-code/types" + +import { ModelRequestRegistry } from "../../../../core/webview/ModelRequestRegistry" +import { getModels, refreshModels } from "../modelCache" + +vi.mock("../../../../services/zoo-code-auth", () => ({ + getZooCodeBaseUrl: () => "https://example.test", + resolveZooGatewaySessionToken: (token?: string) => token, +})) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { instance: { captureEvent: vi.fn(), isTelemetryEnabled: () => false } }, +})) + +// Keep the registry, cache dispatch, and auth-scoped fetchers real; only HTTP is replaced. +describe.each([getModels, refreshModels])("auth-scoped cancellation via %s", (loadModels) => { + describe.each([providerIdentifiers.zooGateway, providerIdentifiers.kimiCode])("%s", (provider) => { + let registry: ModelRequestRegistry + let signals: AbortSignal[] + let events: string[] + + beforeEach(() => { + vi.useFakeTimers() + registry = new ModelRequestRegistry() + signals = [] + events = [] + vi.spyOn(console, "error").mockImplementation(() => {}) + const pendingRequest = (signal?: AbortSignal | null) => { + expect(signal).toBeDefined() + if (!signal) throw new Error("HTTP request has no cancellation signal") + signals.push(signal) + const index = signals.length + events.push(`start:${index}`) + return new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + events.push(`abort:${index}`) + reject(signal.reason) + }, + { once: true }, + ) + }) + } + vi.spyOn(axios, "get").mockImplementation((_url, config) => { + expect(config?.timeout).toBe(15_000) + return pendingRequest(config?.signal instanceof AbortSignal ? config.signal : undefined) + }) + vi.spyOn(globalThis, "fetch").mockImplementation((_url, init) => pendingRequest(init?.signal)) + }) + + afterEach(() => { + registry.dispose() + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it.each(["cancel", "replace"])("aborts HTTP before restarting after %s", async (action) => { + const ownerSignals: AbortSignal[] = [] + const discover = async (signal: AbortSignal) => { + ownerSignals.push(signal) + await loadModels({ provider, apiKey: "synthetic-token", signal }) + } + const first = registry.run("router", discover) + expect(events).toEqual(["start:1"]) + if (action === "cancel") registry.cancel("router") + const second = registry.run("router", discover) + expect(events).toEqual(["start:1", "abort:1", "start:2"]) + expect(signals[0].aborted).toBe(true) + expect(signals[1].aborted).toBe(false) + await first + registry.cancel("router") + await second + expect(events).toEqual(["start:1", "abort:1", "start:2", "abort:2"]) + for (const signal of ownerSignals) expect(getEventListeners(signal, "abort")).toHaveLength(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it("does not start HTTP for a pre-aborted caller", async () => { + const controller = new AbortController() + controller.abort() + const result = loadModels({ provider, apiKey: "synthetic-token", signal: controller.signal }) + if (loadModels === getModels) await expect(result).rejects.toMatchObject({ name: "AbortError" }) + else await expect(result).resolves.toEqual({}) + expect(axios.get).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 3b95f32234..cdee3f0bad 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -1645,7 +1645,7 @@ it("releases the entry for a fetcher double that honors no cancellation at all", expect(mockGetOpenRouterModels).toHaveBeenCalledTimes(2) }) -it("ignores the caller signal on the auth-scoped bypass without entering the flight map", async () => { +it("forwards the caller signal on the auth-scoped bypass without entering the flight map", async () => { setupCancellationMocks() // The single-flight arms its per-flight fetch bound whenever it creates a flight; the // auth-scoped bypass must never touch that machinery. @@ -1663,14 +1663,10 @@ it("ignores the caller signal on the auth-scoped bypass without entering the fli // its own fetch, so the bypass never shares (or poisons) a flight with anything. const second = getModels({ provider: providerIdentifiers.zooGateway, apiKey: "token-a" }) expect(mockGetZooGatewayModels).toHaveBeenCalledTimes(2) - // The bypass carries no cancellation: the fetcher receives exactly its own options - // argument, so the caller's bound is never threaded to this path. - expect(mockGetZooGatewayModels.mock.calls[0]).toHaveLength(1) + // Each auth-scoped fetch receives only its own caller's cancellation signal. + expect(mockGetZooGatewayModels.mock.calls[0][1]).toEqual({ signal: controller.signal }) expect(mockGetZooGatewayModels.mock.calls[1]).toHaveLength(1) - // The caller's signal is ignored on this path: aborting changes nothing for a fetch the - // single-flight never owns, and the fetcher's own request bound remains the stop mechanism. - controller.abort() await expect(first).resolves.toEqual(cancelledModelsB) await expect(second).resolves.toEqual(cancelledModelsB) expect(boundSpy).not.toHaveBeenCalled() diff --git a/src/api/providers/fetchers/kimi-code.ts b/src/api/providers/fetchers/kimi-code.ts index 02bf9180f7..6e757874e8 100644 --- a/src/api/providers/fetchers/kimi-code.ts +++ b/src/api/providers/fetchers/kimi-code.ts @@ -1,5 +1,7 @@ import { z } from "zod" +import { throwIfAborted } from "../utils/abort-signal" + import { KIMI_CODE_BASE_URL, kimiCodeDefaultModelInfo, @@ -37,9 +39,12 @@ export function mapKimiCodeModel(model: z.infer): Mo } } -export async function getKimiCodeModels(apiKey?: string): Promise { +export async function getKimiCodeModels(apiKey?: string, opts?: { signal?: AbortSignal }): Promise { + throwIfAborted(opts?.signal) if (!apiKey) throw new Error("Kimi Code authentication is required to fetch models") const controller = new AbortController() + const onAbort = () => controller.abort(opts?.signal?.reason) + opts?.signal?.addEventListener("abort", onAbort, { once: true }) const timeout = setTimeout( () => controller.abort(new Error("Kimi Code models request timed out")), KIMI_CODE_MODELS_TIMEOUT_MS, @@ -58,5 +63,6 @@ export async function getKimiCodeModels(apiKey?: string): Promise { return Object.fromEntries(parsed.data.map((model) => [model.id, mapKimiCodeModel(model)])) } finally { clearTimeout(timeout) + opts?.signal?.removeEventListener("abort", onAbort) } } diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 6f0898c71b..23d893e094 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -250,7 +250,7 @@ async function readModels(cacheKey: string): Promise { * @param options - Provider options for fetching models * @param signal - Cancellation signal forwarded to the dispatched fetcher. The single-flight * (dedupedFetch) passes its internal controller's signal; the auth-scoped direct path passes - * none, so those fetchers keep their own bounds. + * the caller's signal while fetchers retain their own timeout bounds. * @returns Fresh models from the provider API */ async function fetchModelsFromProvider(options: GetModelsOptions, signal?: AbortSignal): Promise { @@ -305,10 +305,13 @@ async function fetchModelsFromProvider(options: GetModelsOptions, signal?: Abort models = await getMoonshotModels(options.baseUrl, options.apiKey, ...fetchOpts) break case providerIdentifiers.zooGateway: - models = await getZooGatewayModels({ zooSessionToken: options.apiKey, zooGatewayBaseUrl: options.baseUrl }) + models = await getZooGatewayModels( + { zooSessionToken: options.apiKey, zooGatewayBaseUrl: options.baseUrl }, + ...fetchOpts, + ) break case providerIdentifiers.kimiCode: - models = await getKimiCodeModels(options.apiKey) + models = await getKimiCodeModels(options.apiKey, ...fetchOpts) break default: { // Ensures router is exhaustively checked if RouterName is a strict union. @@ -356,10 +359,10 @@ export const getModels = async (options: GetModelsOptions): Promise // getModels(), and a fetch failure joined from refreshModels() still re-throws for // getModels() callers. try { - // The auth-scoped fetch bypasses the single-flight entirely, so options.signal is - // deliberately not forwarded there: there is no shared entry to release on abort, and - // these fetchers bound their own requests. - const sharedFetch = shouldSkipCache ? fetchModelsFromProvider(options) : dedupedFetch(cacheKey, options) + // Auth-scoped fetches belong to this caller, so cancellation goes directly to the fetcher. + const sharedFetch = shouldSkipCache + ? fetchModelsFromProvider(options, options.signal) + : dedupedFetch(cacheKey, options) const fetched = await sharedFetch const modelCount = Object.keys(fetched).length @@ -557,10 +560,10 @@ export const refreshModels = async (options: GetModelsOptions): Promise> { +export async function getZooGatewayModels( + options?: ApiHandlerOptions, + opts?: { signal?: AbortSignal }, +): Promise> { + throwIfAborted(opts?.signal) const models: Record = {} const baseURL = options?.zooGatewayBaseUrl ?? `${getZooCodeBaseUrl()}/api/gateway/v1` @@ -37,6 +43,7 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise< const response = await axios.get(`${baseURL}/models`, { headers, timeout: MODEL_DISCOVERY_TIMEOUT_MS, + signal: opts?.signal, }) const result = vercelAiGatewayModelsResponseSchema.safeParse(response.data) @@ -57,6 +64,7 @@ export async function getZooGatewayModels(options?: ApiHandlerOptions): Promise< models[id] = parseZooGatewayModel({ id, model }) } } catch (error) { + throwIfAborted(opts?.signal) // Log only safe fields; never serialize the full error object because it // includes request config/headers which carry the bearer session token. const err = error as { diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 13e1aeeb2d..2c7c571458 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -73,6 +73,32 @@ describe("ProviderSettingsManager", () => { providerSettingsManager = new ProviderSettingsManager(mockContext) }) + describe("setModeConfig", () => { + it("persists a string mapping and removes only the unset mode after reload", async () => { + const storage = new Map() + mockSecrets.get.mockImplementation(async (key: string) => storage.get(key)) + mockSecrets.store.mockImplementation(async (key: string, value: string) => { + storage.set(key, value) + }) + + await providerSettingsManager.setModeConfig("code", "code-profile") + await providerSettingsManager.setModeConfig("ask", "ask-profile") + const reloaded = new ProviderSettingsManager(mockContext) + expect(await reloaded.getModeConfigId("code")).toBe("code-profile") + const storedModes = (await reloaded.export()).modeApiConfigs + expect(storedModes).toMatchObject({ code: "code-profile", ask: "ask-profile" }) + + await reloaded.setModeConfig("code", undefined) + const afterUnset = new ProviderSettingsManager(mockContext) + expect(await afterUnset.getModeConfigId("code")).toBeUndefined() + const remainingModes = (await afterUnset.export()).modeApiConfigs + expect(remainingModes).not.toHaveProperty("code") + expect(remainingModes).toEqual( + Object.fromEntries(Object.entries(storedModes ?? {}).filter(([mode]) => mode !== "code")), + ) + }) + }) + describe("initialize", () => { it("should not write to storage when secrets.get returns null", async () => { // Mock readConfig to return null diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 175d9ee53e..5c4bee0f48 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -262,15 +262,28 @@ export class ClineProvider private enqueueProviderProfileMutation(fn: (signal: AbortSignal) => Promise): Promise { const controller = new AbortController() - // 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 callerResult = this.withProviderProfileMutationTimeout(run, () => { - controller.abort() - this.log("Provider profile mutation timed out; aborting in-flight mutation") + // The queue tail always resolves. Skip callers whose queue wait expired before + // allowing their mutation to perform any work. + const run = this.providerProfileMutationQueue.then(() => { + controller.signal.throwIfAborted() + return fn(controller.signal) }) + // Allow a separate wait window for an earlier operation and compensation; + // waiting must not consume this mutation's execution timeout. A stalled queue + // still returns an error to the caller without permitting concurrent writes. + const callerResult = this.withProviderProfileMutationTimeout( + this.providerProfileMutationQueue, + () => { + controller.abort() + this.log("Provider profile mutation queue wait timed out; skipping queued mutation") + }, + ClineProvider.PENDING_OPERATION_TIMEOUT_MS * 2, + ).then(() => + this.withProviderProfileMutationTimeout(run, () => { + controller.abort() + this.log("Provider profile mutation timed out; aborting in-flight mutation") + }), + ) void run.then( () => { @@ -289,22 +302,26 @@ 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( + // Keep ownership until all writes and compensation settle, even if the caller + // has timed out. Otherwise an older rollback can overwrite a later mutation. + this.providerProfileMutationQueue = run.then( () => undefined, () => undefined, ) return callerResult } - private withProviderProfileMutationTimeout(operation: Promise, onTimeout: () => void): Promise { + private withProviderProfileMutationTimeout( + operation: Promise, + onTimeout: () => void, + timeoutMs = ClineProvider.PENDING_OPERATION_TIMEOUT_MS, + ): Promise { let timeoutId: ReturnType | undefined const timeout = new Promise((_, reject) => { timeoutId = setTimeout(() => { onTimeout() reject(new Error("Provider profile mutation timed out")) - }, ClineProvider.PENDING_OPERATION_TIMEOUT_MS) + }, timeoutMs) }) return Promise.race([operation, timeout]).finally(() => { diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 9a202c7bed..a15e162ee5 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -516,6 +516,63 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { }, ) + test("holds later model updates until a timed-out save finishes compensation", async () => { + vi.useFakeTimers() + try { + const manager = provider.providerSettingsManager + const save = manager.saveConfig.bind(manager) + let releaseBroadcast!: () => void + let releaseRollback!: () => void + const broadcast = vi.spyOn(provider, "postStateToWebview").mockImplementationOnce( + () => + new Promise((resolve) => { + releaseBroadcast = resolve + }), + ) + const saves = vi + .spyOn(manager, "saveConfig") + .mockImplementationOnce(save) + .mockImplementationOnce(async (name, settings) => { + await new Promise((resolve) => { + releaseRollback = resolve + }) + return save(name, settings) + }) + const first = provider.upsertProviderProfile("existing", newSettings) + await vi.advanceTimersByTimeAsync(0) + expect(broadcast).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS) + expect(await first).toBeUndefined() + + const finalSettings = { ...oldSettings, openRouterModelId: "final-model" } + let secondSettled = false + const second = provider.upsertProviderProfile("existing", finalSettings).then((result) => { + secondSettled = true + return result + }) + await vi.advanceTimersByTimeAsync(0) + expect(saves).toHaveBeenCalledTimes(1) + releaseBroadcast() + await vi.advanceTimersByTimeAsync(0) + expect(saves).toHaveBeenCalledTimes(2) + expect(saves.mock.calls[1][1]).toMatchObject(oldSettings) + // Waiting for compensation must not consume the next save's execution window. + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS + 1) + expect(secondSettled).toBe(false) + expect(saves).toHaveBeenCalledTimes(2) + releaseRollback() + expect(await second).toBeTruthy() + expect(saves).toHaveBeenCalledTimes(3) + const reloaded = new ProviderSettingsManager(mockContext) + const profile = await reloaded.getProfile({ name: "existing" }) + expect(profile).toMatchObject(finalSettings) + expect(await reloaded.getModeConfigId("code")).toBe(profile.id) + expect(provider.contextProxy.getProviderSettings()).toMatchObject(finalSettings) + } finally { + vi.useRealTimers() + } + }) + test("keeps a later update queued until persisted rollback completes", async () => { const manager = provider.providerSettingsManager const save = manager.saveConfig.bind(manager) @@ -632,7 +689,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "second-profile") }) - test("timed-out mutations abort before writing state and advance the queue", async () => { + test("timed-out mutations abort before writing state and release the queue after settling", async () => { vi.useFakeTimers() const logSpy = vi.spyOn(provider, "log") const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") @@ -664,9 +721,9 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS) await firstResult - // Queue advanced immediately on timeout — second enqueues now. + // The caller timed out, but the underlying activation still owns the queue. const second = provider.activateProviderProfile({ name: "second-profile" }) - // activateProfile not yet called for second (it runs in the next microtask). + await vi.advanceTimersByTimeAsync(0) expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledTimes(1) // Resolve the first activation's inner promise so its in-flight mock can return. @@ -683,6 +740,44 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { } }) + test("bounds queue waits and skips expired mutations when the blocked write eventually settles", async () => { + vi.useFakeTimers() + try { + let release!: () => void + const first = provider["enqueueProviderProfileMutation"]( + () => + new Promise((resolve) => { + release = resolve + }), + ) + const firstResult = expect(first).rejects.toThrow("Provider profile mutation timed out") + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS) + await firstResult + + const expiredWrite = vi.fn(async () => {}) + const second = provider["enqueueProviderProfileMutation"](expiredWrite) + const onRejected = vi.fn() + void second.catch(onRejected) + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS * 2) + expect(onRejected).toHaveBeenCalledWith( + expect.objectContaining({ message: "Provider profile mutation timed out" }), + ) + expect(expiredWrite).not.toHaveBeenCalled() + + const nextWrite = vi.fn(async () => {}) + const third = provider["enqueueProviderProfileMutation"](nextWrite) + await vi.advanceTimersByTimeAsync(0) + expect(nextWrite).not.toHaveBeenCalled() + release() + await third + expect(expiredWrite).not.toHaveBeenCalled() + expect(nextWrite).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + test("mode switch preserves its default task when queued behind a profile mutation", async () => { let releaseProfileActivation!: () => void const profileActivation = new Promise((resolve) => { diff --git a/src/core/webview/__tests__/ModelRequestRegistry.spec.ts b/src/core/webview/__tests__/ModelRequestRegistry.spec.ts index 00d1163e3f..05998f2374 100644 --- a/src/core/webview/__tests__/ModelRequestRegistry.spec.ts +++ b/src/core/webview/__tests__/ModelRequestRegistry.spec.ts @@ -17,3 +17,39 @@ it("aborts all pending requests on disposal and does not start new work", async expect(signals.every((signal) => signal.aborted)).toBe(true) expect(fetch).toHaveBeenCalledTimes(2) }) + +it("aborts a replaced request and keeps its replacement registered after late completion", async () => { + const registry = new ModelRequestRegistry() + let finishFirst!: () => void + let firstSignal!: AbortSignal + let secondSignal!: AbortSignal + const first = registry.run("same-id", (signal) => { + firstSignal = signal + return new Promise((resolve) => { + finishFirst = resolve + }) + }) + const second = registry.run("same-id", (signal) => { + expect(firstSignal.aborted).toBe(true) + secondSignal = signal + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }) + }) + }) + finishFirst() + await first + expect(secondSignal.aborted).toBe(false) + registry.cancel("same-id") + expect(secondSignal.aborted).toBe(true) + await second +}) + +it("propagates errors from requests that were not aborted", async () => { + const registry = new ModelRequestRegistry() + const failure = new Error("model discovery failed") + await expect( + registry.run("failed", async () => { + throw failure + }), + ).rejects.toBe(failure) +}) From 0c93897190f923ffc1fd7c1ff76ddda676d06be5 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:16:01 +0000 Subject: [PATCH 04/12] test(providers): cover model request cancellation and already-aborted signals --- .../__tests__/ModelRequestRegistry.spec.ts | 21 +++++++++++++++++++ .../hooks/__tests__/useRouterModels.spec.tsx | 15 +++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/core/webview/__tests__/ModelRequestRegistry.spec.ts b/src/core/webview/__tests__/ModelRequestRegistry.spec.ts index 05998f2374..c820831210 100644 --- a/src/core/webview/__tests__/ModelRequestRegistry.spec.ts +++ b/src/core/webview/__tests__/ModelRequestRegistry.spec.ts @@ -44,6 +44,27 @@ it("aborts a replaced request and keeps its replacement registered after late co await second }) +it("swallows abort-triggered rejections on replacement and explicit cancellation", async () => { + const registry = new ModelRequestRegistry() + const signals: AbortSignal[] = [] + const fetch = (signal: AbortSignal) => + new Promise((_resolve, reject) => { + signals.push(signal) + signal.addEventListener("abort", () => reject(signal.reason), { once: true }) + }) + const first = registry.run("same-id", fetch) + const firstResolved = expect(first).resolves.toBeUndefined() + const second = registry.run("same-id", fetch) + const secondResolved = expect(second).resolves.toBeUndefined() + + await firstResolved + expect(signals[0].aborted).toBe(true) + expect(signals[1].aborted).toBe(false) + registry.cancel("same-id") + expect(signals[1].aborted).toBe(true) + await secondResolved +}) + it("propagates errors from requests that were not aborted", async () => { const registry = new ModelRequestRegistry() const failure = new Error("model discovery failed") diff --git a/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.tsx index fca0b3861d..9e2b45228a 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.tsx +++ b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.tsx @@ -17,6 +17,21 @@ describe("router request cleanup", () => { vi.restoreAllMocks() }) + it("rejects an already-aborted signal without starting any work", async () => { + const add = vi.spyOn(window, "addEventListener") + const timeout = vi.spyOn(globalThis, "setTimeout") + const controller = new AbortController() + const reason = new Error("Model request already cancelled") + controller.abort(reason) + + await expect(fetchRouterModels(providerIdentifiers.openrouter, controller.signal)).rejects.toBe(reason) + + expect(add).not.toHaveBeenCalled() + expect(timeout).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + expect(vscode.postMessage).not.toHaveBeenCalled() + }) + it("removes its listener and timer and cancels backend work on abort", async () => { const add = vi.spyOn(window, "addEventListener") const remove = vi.spyOn(window, "removeEventListener") From ef3a32d9b48084619ab4912d92b208a44fd358c4 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:38:59 +0000 Subject: [PATCH 05/12] fix(providers): prevent duplicate model discovery across selector remounts and reuse shared catalogs --- .../fetchers/__tests__/modelCache.spec.ts | 83 +++++++++++++++++++ src/api/providers/fetchers/lmstudio.ts | 4 +- src/api/providers/fetchers/modelCache.ts | 21 +++-- src/api/providers/fetchers/poe.ts | 4 +- .../chat/__tests__/ChatModelSelector.spec.tsx | 45 ++++++++++ .../__tests__/useChatModelSelector.spec.tsx | 22 +++++ .../chat/hooks/useChatModelSelector.ts | 7 +- .../hooks/__tests__/useRouterModels.spec.tsx | 23 +++++ 8 files changed, 194 insertions(+), 15 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index cdee3f0bad..34599407ad 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -41,6 +41,8 @@ vi.mock("fs", () => ({ })) // Mock all the model fetchers +vi.mock("../poe") +vi.mock("../lmstudio") vi.mock("../litellm") vi.mock("../openrouter") vi.mock("../requesty") @@ -69,6 +71,8 @@ import * as fsSync from "fs" import NodeCache from "node-cache" import { TelemetryService } from "@roo-code/telemetry" import { getModels, getModelsFromCache } from "../modelCache" +import { getPoeModels } from "../poe" +import { getLMStudioModels } from "../lmstudio" import { getLiteLLMModels } from "../litellm" import { getOpenRouterModels } from "../openrouter" import { getRequestyModels } from "../requesty" @@ -1674,3 +1678,82 @@ it("forwards the caller signal on the auth-scoped bypass without entering the fl boundSpy.mockRestore() } }) + +// These SDKs cannot stop discovery when their caller aborts. Keep the pending +// operation registered across selector unmount/remount and even after its bound. +it.each([providerIdentifiers.poe, providerIdentifiers.lmstudio])( + "reuses pending %s discovery after cancellation and timeout until it settles", + async (provider) => { + setupCancellationMocks() + const timeoutController = new AbortController() + const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal) + const fetcher = provider === providerIdentifiers.poe ? vi.mocked(getPoeModels) : vi.mocked(getLMStudioModels) + let resolveDiscovery!: (models: ModelRecord) => void + fetcher.mockReturnValueOnce( + new Promise((resolve) => { + resolveDiscovery = resolve + }), + ) + try { + const controller = new AbortController() + const first = getModels({ provider, signal: controller.signal }) + controller.abort() + await expect(first).rejects.toMatchObject({ name: "AbortError" }) + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0) + + const remounted = getModels({ provider }) + expect(fetcher).toHaveBeenCalledTimes(1) + timeoutController.abort() + await expect(remounted).rejects.toMatchObject({ name: "AbortError" }) + await expect(getModels({ provider })).rejects.toMatchObject({ name: "AbortError" }) + expect(fetcher).toHaveBeenCalledTimes(1) + expect(getEventListeners(timeoutController.signal, "abort")).toHaveLength(0) + + resolveDiscovery(cancelledModels) + await drainMicrotasks() + expect(getEventListeners(timeoutController.signal, "abort")).toHaveLength(0) + // Once the old SDK operation has actually finished, another request may start. + timeoutSpy.mockRestore() + fetcher.mockResolvedValueOnce(cancelledModelsB) + await expect(getModels({ provider })).resolves.toEqual(cancelledModelsB) + expect(fetcher).toHaveBeenCalledTimes(2) + } finally { + resolveDiscovery(cancelledModels) + timeoutSpy.mockRestore() + } + }, +) + +it.each([providerIdentifiers.poe, providerIdentifiers.lmstudio])( + "delivers the same %s discovery to a remounted caller and releases listeners on settlement", + async (provider) => { + setupCancellationMocks() + const timeoutController = new AbortController() + const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal) + const fetcher = provider === providerIdentifiers.poe ? vi.mocked(getPoeModels) : vi.mocked(getLMStudioModels) + let resolveDiscovery!: (models: ModelRecord) => void + fetcher.mockReturnValueOnce( + new Promise((resolve) => { + resolveDiscovery = resolve + }), + ) + try { + const controller = new AbortController() + const first = getModels({ provider, signal: controller.signal }) + controller.abort() + await expect(first).rejects.toMatchObject({ name: "AbortError" }) + const remounted = getModels({ provider }) + expect(fetcher).toHaveBeenCalledTimes(1) + const args = fetcher.mock.calls[0] + const options = args[args.length - 1] + expect(options).toEqual({ signal: expect.objectContaining({ aborted: false }) }) + resolveDiscovery(cancelledModels) + await expect(remounted).resolves.toEqual(cancelledModels) + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0) + expect(getEventListeners(timeoutController.signal, "abort")).toHaveLength(0) + } finally { + resolveDiscovery(cancelledModels) + timeoutSpy.mockRestore() + } + }, +) diff --git a/src/api/providers/fetchers/lmstudio.ts b/src/api/providers/fetchers/lmstudio.ts index 32387e2f08..469ba71847 100644 --- a/src/api/providers/fetchers/lmstudio.ts +++ b/src/api/providers/fetchers/lmstudio.ts @@ -74,8 +74,8 @@ export async function getLMStudioModels( await axios.get(`${baseUrl}/v1/models`, { signal: opts?.signal }) // The SDK's model-list calls expose no cancellation option, so an abort - // during them cannot reach the network; releasing the shared cache entry - // and stopping the waiters happens at the model-cache layer. + // during them cannot reach the network. The model cache stops cancelled waiters + // but retains this operation until settlement to prevent duplicate discovery. const client = new LMStudioClient({ baseUrl: lmsUrl }) // First, try to get all downloaded models diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 23d893e094..8ca238a02c 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -47,9 +47,9 @@ const modelRecordSchema = z.record(z.string(), modelInfoSchema) const inFlightRefresh = new Map() // Upper bound for any fetch started through the single-flight, so a hung endpoint can never keep -// an in-flight entry pending indefinitely. The value is the maximum of the 5–15 s bounds the -// individual fetchers it subsumes used to apply, relaxing rather than tightening endpoints that -// already had a bound. +// a waiter pending indefinitely. Non-cancellable SDK operations retain their entry until +// settlement. The value is the maximum of the 5–15 s bounds the individual fetchers used to +// apply, relaxing rather than tightening endpoints that already had a bound. const MODEL_CATALOG_FETCH_TIMEOUT_MS = 15_000 /** @@ -59,9 +59,9 @@ const MODEL_CATALOG_FETCH_TIMEOUT_MS = 15_000 * - The internal AbortController is the only object that may cancel the flight's network * request; caller signals are only ever merged into a per-waiter abort view, so one waiter * aborting can never cancel the fetch out from under the others. - * - When the last waiter detaches while the fetch is still pending, the flight is aborted and - * its map entry removed synchronously, so a caller arriving immediately afterwards starts a - * fresh fetch instead of joining a doomed one. + * - When the last waiter detaches, cancellable flights are aborted and removed immediately. + * SDK calls without cancellation support remain registered until settlement so new waiters + * cannot start overlapping discovery. * - Settlement never stores data in the map: it only removes the entry it created, guarded by * flight identity so a late-settling stale flight can never evict a newer one. */ @@ -71,6 +71,7 @@ type FlightRecord = { timeoutSignal: AbortSignal waiters: number pending: boolean + cancelWhenUnused: boolean } // Cache keys (see getCacheKey) for which we've already reported an empty model response this @@ -459,6 +460,8 @@ function dedupedFetch(cacheKey: string, options: GetModelsOptions): Promise { throwIfAborted(callerSignal) @@ -494,7 +497,7 @@ function joinFlight(cacheKey: string, record: FlightRecord, callerSignal?: Abort detached = true removeViewListener?.() record.waiters-- - if (record.waiters === 0 && record.pending) { + if (record.waiters === 0 && record.pending && record.cancelWhenUnused) { // Synchronous release: abort the shared fetch and drop the entry before any further // await point runs, so a late joiner never sees a doomed flight. record.controller.abort() diff --git a/src/api/providers/fetchers/poe.ts b/src/api/providers/fetchers/poe.ts index a78ddd352c..fcc98397e3 100644 --- a/src/api/providers/fetchers/poe.ts +++ b/src/api/providers/fetchers/poe.ts @@ -12,8 +12,8 @@ export async function getPoeModels( // fetchPoeModels populates the internal model store, then getModels() // returns only code-capable models with camelCase fields. // The Poe SDK exposes no cancellation option, so the caller's signal - // cannot reach the network here; an abort still releases the shared - // cache entry and stops the waiters at the model-cache layer. + // cannot reach the network here. The model cache stops cancelled waiters + // but retains this operation until settlement to prevent duplicate discovery. throwIfAborted(opts?.signal) await fetchPoeModels({ apiKey, baseURL }) throwIfAborted(opts?.signal) diff --git a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx index a98f681915..d855e9f12a 100644 --- a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx @@ -137,6 +137,51 @@ describe("ChatModelSelector", () => { expect(screen.getByTestId("chat-model-option-claude-sonnet-4-20250514")).toBeInTheDocument() }) + test("shows the empty catalog message once loading finishes", async () => { + const user = userEvent.setup() + mockUseChatModelSelector.mockReturnValue({ + provider: providerIdentifiers.anthropic, + models: {}, + modelIdKey: "apiModelId", + defaultModelId: "", + isLoading: false, + }) + render() + await user.click(screen.getByTestId("chat-model-selector-trigger")) + expect(screen.getByText("chat:modelListEmpty")).toBeInTheDocument() + }) + + test("posts the transformed selection for compound configurations", async () => { + const user = userEvent.setup() + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.vscodeLm }, + organizationAllowList: { allowAll: true, providers: {} }, + currentApiConfigName: "default", + }) + mockUseChatModelSelector.mockReturnValue({ + provider: providerIdentifiers.vscodeLm, + models: { "copilot/gpt-4o": { maxTokens: 1, contextWindow: 1 } }, + modelIdKey: "vsCodeLmModelSelector", + defaultModelId: "", + isLoading: false, + valueTransform: (modelId: string) => { + const [vendor, family] = modelId.split("/") + return { vendor, family } + }, + }) + render() + await user.click(screen.getByTestId("chat-model-selector-trigger")) + await user.click(screen.getByTestId("chat-model-option-copilot/gpt-4o")) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: { + apiProvider: providerIdentifiers.vscodeLm, + vsCodeLmModelSelector: { vendor: "copilot", family: "gpt-4o" }, + }, + }) + }) + test("selecting a model posts upsertApiConfiguration message", () => { render() diff --git a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx index aff8acfc7c..5a76f51516 100644 --- a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx +++ b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx @@ -350,6 +350,28 @@ describe("useChatModelSelector", () => { expect(Object.keys(result.current.models!)).toEqual(["openai/gpt-4o"]) }) + it.each([providerIdentifiers.poe, providerIdentifiers.litellm])( + "does not query %s on mount or remount when models come from shared state", + (provider) => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: provider }, + routerModels: { [provider]: {} }, + }) + // A disabled query's status must not hide the shared state's empty result. + mockUseRouterModels.mockReturnValue({ isLoading: true }) + const first = renderHook(() => useChatModelSelector(), { wrapper }) + expect(first.result.current.isLoading).toBe(false) + first.unmount() + const second = renderHook(() => useChatModelSelector(), { wrapper }) + expect(second.result.current.models).toEqual({}) + second.unmount() + for (const [options] of mockUseRouterModels.mock.calls) { + expect(options).toEqual({ provider, enabled: false }) + } + expect(mockPostMessage).not.toHaveBeenCalled() + }, + ) + it("reads poe models from the backend-broadcast state routerModels", () => { mockUseExtensionState.mockReturnValue({ apiConfiguration: { apiProvider: providerIdentifiers.poe, poeApiKey: "test" }, diff --git a/webview-ui/src/components/chat/hooks/useChatModelSelector.ts b/webview-ui/src/components/chat/hooks/useChatModelSelector.ts index cc48e4498d..1c7753a3bc 100644 --- a/webview-ui/src/components/chat/hooks/useChatModelSelector.ts +++ b/webview-ui/src/components/chat/hooks/useChatModelSelector.ts @@ -104,7 +104,10 @@ export const useChatModelSelector = (): ChatModelSelectorData => { const activeProvider = isRetiredProvider(provider) ? undefined : provider const routerConfig = activeProvider ? ROUTER_PROVIDERS[activeProvider] : undefined - const routerModels = useRouterModels({ provider: activeProvider, enabled: !!routerConfig }) + const routerModels = useRouterModels({ + provider: activeProvider, + enabled: !!routerConfig && !routerConfig.fromState, + }) // Message-based providers: request the models on mount and keep the // latest list delivered by the backend. Each request carries a unique @@ -230,7 +233,7 @@ export const useChatModelSelector = (): ChatModelSelectorData => { models: source?.[activeProvider] ?? null, modelIdKey: routerConfig.modelIdKey, defaultModelId, - isLoading: routerModels.isLoading, + isLoading: routerConfig.fromState ? false : routerModels.isLoading, } } diff --git a/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.tsx index 9e2b45228a..574ecefbc8 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.tsx +++ b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.tsx @@ -51,6 +51,29 @@ describe("router request cleanup", () => { }) }) + it("times out, cancels the matching request, and releases listeners and timers", async () => { + const add = vi.spyOn(window, "addEventListener") + const remove = vi.spyOn(window, "removeEventListener") + const controller = new AbortController() + const addAbort = vi.spyOn(controller.signal, "addEventListener") + const removeAbort = vi.spyOn(controller.signal, "removeEventListener") + const pending = fetchRouterModels(providerIdentifiers.openrouter, controller.signal) + const rejected = expect(pending).rejects.toThrow("Router models request timed out") + const request = vi.mocked(vscode.postMessage).mock.calls[0][0] + const listener = add.mock.calls.find(([type]) => type === "message")![1] + await vi.advanceTimersByTimeAsync(10_000) + await rejected + expect(remove).toHaveBeenCalledWith("message", listener) + expect(removeAbort).toHaveBeenCalledWith("abort", addAbort.mock.calls[0][1]) + expect(vi.getTimerCount()).toBe(0) + expect(vscode.postMessage).toHaveBeenLastCalledWith({ + type: "cancelModelRequest", + requestId: request.requestId, + }) + controller.abort() + expect(vscode.postMessage).toHaveBeenCalledTimes(2) + }) + it("aborts a pending query when its last observer unmounts", () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }) const remove = vi.spyOn(window, "removeEventListener") From ac34a0b475e8ebf11d739c763371bc26ef043a30 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:06:47 +0000 Subject: [PATCH 06/12] test(providers): strengthen rollback queue guard assertions after an event-loop tick --- .../webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index a15e162ee5..72ebb6ddb7 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -589,8 +589,10 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const first = provider.upsertProviderProfile("existing", newSettings) await vi.waitFor(() => expect(manager.saveConfig).toHaveBeenCalledTimes(2)) const finalSettings = { ...oldSettings, openRouterModelId: "final-model" } + const hasConfig = vi.spyOn(manager, "hasConfig") const second = provider.upsertProviderProfile("existing", finalSettings) - await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(hasConfig).not.toHaveBeenCalled() expect(manager.saveConfig).toHaveBeenCalledTimes(2) releaseRollback() expect(await first).toBeUndefined() From d3add49321875455a0000bee12fc7cba8a12b563 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:07:28 +0000 Subject: [PATCH 07/12] fix(chat): show empty model list message when search has no matches --- .../src/components/chat/ChatModelSelector.tsx | 2 +- .../chat/__tests__/ChatModelSelector.spec.tsx | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/ChatModelSelector.tsx b/webview-ui/src/components/chat/ChatModelSelector.tsx index 5989d0e287..01656b4867 100644 --- a/webview-ui/src/components/chat/ChatModelSelector.tsx +++ b/webview-ui/src/components/chat/ChatModelSelector.tsx @@ -196,7 +196,7 @@ export const ChatModelSelector = ({ disabled = false, title, triggerClassName = )}

- {modelIds.length === 0 && !isLoading && ( + {filteredModelIds.length === 0 && !isLoading && (
{t("chat:modelListEmpty")}
)} diff --git a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx index d855e9f12a..3cac4e9522 100644 --- a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx @@ -151,6 +151,24 @@ describe("ChatModelSelector", () => { expect(screen.getByText("chat:modelListEmpty")).toBeInTheDocument() }) + test.each([false, true])("handles an unmatched search while isLoading is %s", async (isLoading) => { + const user = userEvent.setup() + mockUseChatModelSelector.mockReturnValue({ ...mockUseChatModelSelector(), isLoading }) + render() + await user.click(screen.getByTestId("chat-model-selector-trigger")) + await user.type(screen.getByRole("textbox"), "custom-model") + + expect(screen.queryByTestId("chat-model-option-claude-opus-4-20250514")).not.toBeInTheDocument() + expect(screen.queryByTestId("chat-model-option-claude-sonnet-4-20250514")).not.toBeInTheDocument() + expect(screen.queryByText("chat:modelListEmpty") !== null).toBe(!isLoading) + expect(screen.getByTestId("chat-model-use-custom")).toBeInTheDocument() + + await user.click(screen.getByRole("button", { name: "chat:clearSearch" })) + expect(screen.queryByText("chat:modelListEmpty")).not.toBeInTheDocument() + expect(screen.getByTestId("chat-model-option-claude-opus-4-20250514")).toBeInTheDocument() + expect(screen.getByTestId("chat-model-option-claude-sonnet-4-20250514")).toBeInTheDocument() + }) + test("posts the transformed selection for compound configurations", async () => { const user = userEvent.setup() mockUseExtensionState.mockReturnValue({ From c9bf7fd2efaa631ca86e48d898a3b77f0f182e90 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:08:03 +0000 Subject: [PATCH 08/12] fix(providers): enforce allowlists on profile renames and complete empty or failed LM Studio model requests --- .../__tests__/webviewMessageHandler.spec.ts | 95 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 37 +++++--- .../__tests__/useChatModelSelector.spec.tsx | 67 ++++++++----- 3 files changed, 164 insertions(+), 35 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 9f040859e1..c5dd7db3f7 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -230,6 +230,64 @@ describe("webviewMessageHandler - organization allowlist enforcement", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(t("common:errors.violated_organization_allowlist")) }) + it("rejects a disallowed rename before saving, deleting, or activating a profile", async () => { + const saveConfig = vi.fn() + const deleteConfig = vi.fn() + const activateProviderProfile = vi.fn() + Object.assign(mockClineProvider, { + providerSettingsManager: { + getProfile: vi.fn().mockResolvedValue({ id: "profile-id" }), + saveConfig, + deleteConfig, + }, + activateProviderProfile, + }) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old", newName: "new" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-opus-4-20250514" }, + }) + + expect(saveConfig).not.toHaveBeenCalled() + expect(deleteConfig).not.toHaveBeenCalled() + expect(activateProviderProfile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(t("common:errors.violated_organization_allowlist")) + }) + + it.each([restrictiveAllowList, undefined])( + "renames an allowed profile and preserves its ID (%j)", + async (organizationAllowList) => { + mockClineProvider.getState = vi.fn().mockResolvedValue({ organizationAllowList }) + const saveConfig = vi.fn() + const deleteConfig = vi.fn() + const activateProviderProfile = vi.fn() + Object.assign(mockClineProvider, { + providerSettingsManager: { + getProfile: vi.fn().mockResolvedValue({ id: "profile-id" }), + saveConfig, + deleteConfig, + }, + activateProviderProfile, + }) + const apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-sonnet-4-20250514", + } + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old", newName: "new" }, + apiConfiguration, + }) + + expect(saveConfig).toHaveBeenCalledWith("new", { ...apiConfiguration, id: "profile-id" }) + expect(deleteConfig).toHaveBeenCalledWith("old") + expect(activateProviderProfile).toHaveBeenCalledWith({ name: "new" }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }, + ) + it("allows an allow-listed model on upsertApiConfiguration", async () => { const upsertProviderProfile = vi.fn().mockResolvedValue("id") setUpsertSpy(upsertProviderProfile) @@ -388,6 +446,43 @@ describe("webviewMessageHandler - requestLmStudioModels", () => { }) }) + it.each([ + { source: "persisted settings", values: undefined }, + { source: "request payload", values: { baseUrl: "http://127.0.0.1:4321" } }, + ])("completes empty and failed fetches using $source with the request ID", async ({ values }) => { + const fetchModels = values ? mockGetLMStudioModels : mockGetModels + fetchModels.mockResolvedValueOnce({}).mockRejectedValueOnce(new Error("LM Studio unavailable")) + + for (const requestId of ["empty-models", "failed-models"]) { + vi.mocked(mockClineProvider.postMessageToWebview).mockClear() + await webviewMessageHandler(mockClineProvider, { type: "requestLmStudioModels", requestId, values }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "lmStudioModels", + lmStudioModels: {}, + requestId, + }) + } + }) + + it("does not send a completion after a model request is cancelled", async () => { + mockGetLMStudioModels.mockImplementation(async () => { + await webviewMessageHandler(mockClineProvider, { + type: "cancelModelRequest", + requestId: "cancelled-models", + }) + throw new Error("Request cancelled") + }) + + await webviewMessageHandler(mockClineProvider, { + type: "requestLmStudioModels", + requestId: "cancelled-models", + values: { baseUrl: "http://127.0.0.1:4321" }, + }) + + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + it("prefers the request payload base URL over persisted settings", async () => { mockGetLMStudioModels.mockResolvedValue({}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 3095b7f482..f6bfead1c6 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1484,11 +1484,11 @@ const handleWebviewMessage = async ( } case LmStudioModelsMessageType.requestLmStudioModels: { // Specific handler for LM Studio models only. - const { apiConfiguration: lmStudioApiConfig } = await provider.getState() + let lmStudioModels: ModelRecord = {} try { + const { apiConfiguration: lmStudioApiConfig } = await provider.getState() const requestedBaseUrl = message.values?.baseUrl const hasPreviewBaseUrl = typeof requestedBaseUrl === "string" - let lmStudioModels: ModelRecord if (hasPreviewBaseUrl) { modelRequestSignal?.throwIfAborted() lmStudioModels = await getLMStudioModels( @@ -1504,20 +1504,17 @@ const handleWebviewMessage = async ( await flushRequestModels(lmStudioOptions, true) lmStudioModels = await getRequestModels(lmStudioOptions) } - - if (Object.keys(lmStudioModels).length > 0) { - modelRequestSignal?.throwIfAborted() - await provider.postMessageToWebview({ - type: LmStudioModelsMessageType.lmStudioModels, - lmStudioModels: lmStudioModels, - requestId: message.requestId, - }) - } } catch (error) { modelRequestSignal?.throwIfAborted() // Silently fail - user hasn't configured LM Studio yet. console.debug("LM Studio models fetch failed:", error) } + modelRequestSignal?.throwIfAborted() + await provider.postMessageToWebview({ + type: LmStudioModelsMessageType.lmStudioModels, + lmStudioModels, + requestId: message.requestId, + }) break } case "requestRooModels": { @@ -2433,6 +2430,18 @@ const handleWebviewMessage = async ( break } + const { organizationAllowList } = await provider.getState() + if ( + !ProfileValidator.isProfileAllowed( + message.apiConfiguration, + organizationAllowList ?? ORGANIZATION_ALLOW_ALL, + ) + ) { + throw new OrganizationAllowListViolationError( + t("common:errors.violated_organization_allowlist"), + ) + } + // Load the old configuration to get its ID. const { id } = await provider.providerSettingsManager.getProfile({ name: oldName }) @@ -2450,7 +2459,11 @@ const handleWebviewMessage = async ( `Error rename api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) - vscode.window.showErrorMessage(t("common:errors.rename_api_config")) + vscode.window.showErrorMessage( + error instanceof OrganizationAllowListViolationError + ? error.message + : t("common:errors.rename_api_config"), + ) } } break diff --git a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx index 5a76f51516..424b3a450a 100644 --- a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx +++ b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx @@ -4,6 +4,14 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { anthropicDefaultModelId, + zooGatewayDefaultModelId, + opencodeGoDefaultModelId, + kenariDefaultModelId, + nanoGptDefaultModelId, + requestyDefaultModelId, + unboundDefaultModelId, + vercelAiGatewayDefaultModelId, + kimiCodeDefaultModelId, mainlandZAiDefaultModelId, providerIdentifiers, retiredProviderIdentifiers, @@ -161,31 +169,44 @@ describe("useChatModelSelector", () => { describe("dynamic router providers", () => { it.each([ - [providerIdentifiers.zooGateway, "zooGatewayModelId", "anthropic/claude-sonnet-4"], - [providerIdentifiers.opencodeGo, "opencodeGoModelId", "glm-5.2"], - [providerIdentifiers.kenari, "kenariModelId", "glm-5-2"], - [providerIdentifiers.nanogpt, "nanoGptModelId", "openai/gpt-5.6-sol"], - [providerIdentifiers.requesty, "requestyModelId", "openai/gpt-5.1"], - [providerIdentifiers.unbound, "unboundModelId", "openai/gpt-4o"], - [providerIdentifiers.vercelAiGateway, "vercelAiGatewayModelId", "openai/gpt-4o-mini"], - [providerIdentifiers.kimiCode, "apiModelId", "kimi-k2"], - ] as const)("reads %s models from the react-query routerModels", (provider, modelIdKey, modelId) => { - mockUseRouterModels.mockReturnValue({ - data: { [provider]: { [modelId]: { maxTokens: 1, contextWindow: 1 } } }, - isLoading: false, - isError: false, - }) - mockUseExtensionState.mockReturnValue({ - apiConfiguration: { apiProvider: provider, [modelIdKey]: modelId }, - routerModels: undefined, - }) + [ + providerIdentifiers.zooGateway, + "zooGatewayModelId", + "anthropic/claude-sonnet-4", + zooGatewayDefaultModelId, + ], + [providerIdentifiers.opencodeGo, "opencodeGoModelId", "glm-5.2", opencodeGoDefaultModelId], + [providerIdentifiers.kenari, "kenariModelId", "glm-5-2", kenariDefaultModelId], + [providerIdentifiers.nanogpt, "nanoGptModelId", "openai/gpt-5.6-sol", nanoGptDefaultModelId], + [providerIdentifiers.requesty, "requestyModelId", "openai/gpt-5.1", requestyDefaultModelId], + [providerIdentifiers.unbound, "unboundModelId", "openai/gpt-4o", unboundDefaultModelId], + [ + providerIdentifiers.vercelAiGateway, + "vercelAiGatewayModelId", + "openai/gpt-4o-mini", + vercelAiGatewayDefaultModelId, + ], + [providerIdentifiers.kimiCode, "apiModelId", "kimi-k2", kimiCodeDefaultModelId], + ] as const)( + "reads %s models from the react-query routerModels", + (provider, modelIdKey, modelId, defaultModelId) => { + mockUseRouterModels.mockReturnValue({ + data: { [provider]: { [modelId]: { maxTokens: 1, contextWindow: 1 } } }, + isLoading: false, + isError: false, + }) + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: provider, [modelIdKey]: modelId }, + routerModels: undefined, + }) - const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) - expect(result.current.modelIdKey).toBe(modelIdKey) - expect(result.current.defaultModelId).toBeTruthy() - expect(Object.keys(result.current.models!)).toEqual([modelId]) - }) + expect(result.current.modelIdKey).toBe(modelIdKey) + expect(result.current.defaultModelId).toBe(defaultModelId) + expect(Object.keys(result.current.models!)).toEqual([modelId]) + }, + ) }) describe("openai (OpenAI compatible)", () => { From 697a895a9aaa26181befecfa864dda62519edb07 Mon Sep 17 00:00:00 2001 From: seeones Date: Sun, 11 Oct 2026 10:21:35 +0800 Subject: [PATCH 09/12] test(chat): fix TS2348 by using a typed selector state factory The previous mock-return helper invoked the vi.fn() mock itself, which is not callable under TypeScript and broke the webview tsc build. --- .../chat/__tests__/ChatModelSelector.spec.tsx | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx index 3cac4e9522..5410173000 100644 --- a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx @@ -66,6 +66,18 @@ const anthropicModels = { }, } +// Build the hook's return value for a mocked render. A factory keeps the +// default shape in one place so tests can override a single field without +// invoking the mock itself (which is not callable under TypeScript). +const createSelectorState = (overrides: Partial> = {}) => ({ + provider: providerIdentifiers.anthropic, + models: anthropicModels, + modelIdKey: "apiModelId", + defaultModelId: "claude-sonnet-4-20250514", + isLoading: false, + ...overrides, +}) + describe("ChatModelSelector", () => { const defaultProps = { title: "Select model", @@ -101,13 +113,7 @@ describe("ChatModelSelector", () => { organizationAllowList: { allowAll: true, providers: {} }, currentApiConfigName: "default", }) - mockUseChatModelSelector.mockReturnValue({ - provider: providerIdentifiers.anthropic, - models: anthropicModels, - modelIdKey: "apiModelId", - defaultModelId: "claude-sonnet-4-20250514", - isLoading: false, - }) + mockUseChatModelSelector.mockReturnValue(createSelectorState()) }) test("renders the trigger with the current model id", () => { @@ -153,7 +159,7 @@ describe("ChatModelSelector", () => { test.each([false, true])("handles an unmatched search while isLoading is %s", async (isLoading) => { const user = userEvent.setup() - mockUseChatModelSelector.mockReturnValue({ ...mockUseChatModelSelector(), isLoading }) + mockUseChatModelSelector.mockReturnValue(createSelectorState({ isLoading })) render() await user.click(screen.getByTestId("chat-model-selector-trigger")) await user.type(screen.getByRole("textbox"), "custom-model") From b2883284c7846b3fa8ee67a22074553feffb2993 Mon Sep 17 00:00:00 2001 From: seeones Date: Sun, 11 Oct 2026 17:22:47 +0800 Subject: [PATCH 10/12] fix(d35): address 3 CodeRabbit findings for chat model selector - ProfileValidator: fall back to vendor/family compound id for vscode-lm when the selector has no explicit id, matching what the webview stores - useChatModelSelector: clear stale OpenAI model list when request inputs (baseUrl / apiKey / headers) change without switching provider - modelCache: release the in-flight entry on deadline timeout for non-cancellable providers (poe, lmstudio) when waiters reach zero Tests: ProfileValidator 51 passed, useChatModelSelector 37 passed, modelCache 67 passed, shared 397 passed, fetchers 314 passed, webview chat 58 passed, tsc src/webview exit 0, eslint exit 0. --- .../fetchers/__tests__/modelCache.spec.ts | 16 +++++++------- src/api/providers/fetchers/modelCache.ts | 21 +++++++++++++++---- src/shared/ProfileValidator.ts | 14 ++++++++++--- src/shared/__tests__/ProfileValidator.spec.ts | 15 +++++++++++++ .../chat/hooks/useChatModelSelector.ts | 9 ++++++++ 5 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 34599407ad..9631f553a0 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -1682,7 +1682,7 @@ it("forwards the caller signal on the auth-scoped bypass without entering the fl // These SDKs cannot stop discovery when their caller aborts. Keep the pending // operation registered across selector unmount/remount and even after its bound. it.each([providerIdentifiers.poe, providerIdentifiers.lmstudio])( - "reuses pending %s discovery after cancellation and timeout until it settles", + "reuses pending %s discovery across cancellation and releases it at the bound", async (provider) => { setupCancellationMocks() const timeoutController = new AbortController() @@ -1705,18 +1705,18 @@ it.each([providerIdentifiers.poe, providerIdentifiers.lmstudio])( expect(fetcher).toHaveBeenCalledTimes(1) timeoutController.abort() await expect(remounted).rejects.toMatchObject({ name: "AbortError" }) - await expect(getModels({ provider })).rejects.toMatchObject({ name: "AbortError" }) - expect(fetcher).toHaveBeenCalledTimes(1) + // The deadline released the entry even though the non-cancellable SDK call is still + // pending, so a retry starts a fresh flight instead of joining the expired one. Give + // the fresh flight its own (unaborted) bound; the original call is left to settle. + timeoutSpy.mockReturnValue(new AbortController().signal) + fetcher.mockResolvedValueOnce(cancelledModelsB) + await expect(getModels({ provider })).resolves.toEqual(cancelledModelsB) + expect(fetcher).toHaveBeenCalledTimes(2) expect(getEventListeners(timeoutController.signal, "abort")).toHaveLength(0) resolveDiscovery(cancelledModels) await drainMicrotasks() expect(getEventListeners(timeoutController.signal, "abort")).toHaveLength(0) - // Once the old SDK operation has actually finished, another request may start. - timeoutSpy.mockRestore() - fetcher.mockResolvedValueOnce(cancelledModelsB) - await expect(getModels({ provider })).resolves.toEqual(cancelledModelsB) - expect(fetcher).toHaveBeenCalledTimes(2) } finally { resolveDiscovery(cancelledModels) timeoutSpy.mockRestore() diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 8ca238a02c..b09ca858ec 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -416,9 +416,7 @@ function dedupedFetch(cacheKey: string, options: GetModelsOptions): Promise controller.abort(timeoutSignal.reason) - timeoutSignal.addEventListener("abort", onTimeout, { once: true }) - const removeTimeoutListener = () => timeoutSignal.removeEventListener("abort", onTimeout) + let removeTimeoutListener = () => {} // Settlement and a last-waiter abort may happen in either order; both paths are idempotent, // and the identity guard makes the two interleavings equivalent. @@ -467,6 +465,21 @@ function dedupedFetch(cacheKey: string, options: GetModelsOptions): Promise { + controller.abort(timeoutSignal.reason) + if (record.waiters === 0 && record.pending) { + guardedDelete() + } + } + timeoutSignal.addEventListener("abort", onTimeout, { once: true }) + removeTimeoutListener = () => timeoutSignal.removeEventListener("abort", onTimeout) + // The settle reactions above can only run after this function's current synchronous run -- // including the set() below -- completes, since that's the earliest a promise reaction can // fire. So the entry is always registered before any settle handler can delete it, even if @@ -497,7 +510,7 @@ function joinFlight(cacheKey: string, record: FlightRecord, callerSignal?: Abort detached = true removeViewListener?.() record.waiters-- - if (record.waiters === 0 && record.pending && record.cancelWhenUnused) { + if (record.waiters === 0 && record.pending && (record.cancelWhenUnused || record.timeoutSignal.aborted)) { // Synchronous release: abort the shared fetch and drop the entry before any further // await point runs, so a late joiner never sees a doomed flight. record.controller.abort() diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index 923582bd06..b002c0e5ee 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -69,9 +69,17 @@ export class ProfileValidator { return profile.litellmModelId case providerIdentifiers.lmstudio: return profile.lmStudioModelId - case providerIdentifiers.vscodeLm: - // We probably need something more flexible for this one, if we need to really support it here. - return profile.vsCodeLmModelSelector?.id + case providerIdentifiers.vscodeLm: { + // The webview's model selector validates and stores the compound `vendor/family` + // identity when no explicit `id` is set, so the validator must extract the same + // value; otherwise a restrictive allowlist entry keyed on `vendor/family` would + // never match the stored selection. + const selector = profile.vsCodeLmModelSelector + return ( + selector?.id ?? + (selector?.vendor && selector?.family ? `${selector.vendor}/${selector.family}` : undefined) + ) + } case providerIdentifiers.openrouter: return profile.openRouterModelId case providerIdentifiers.ollama: diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index 865fa5cf51..8c2a769af5 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -296,6 +296,21 @@ describe("ProfileValidator", () => { expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) }) + it("should fall back to the vendor/family compound id for vscode-lm when id is absent", () => { + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { + [providerIdentifiers.vscodeLm]: { allowAll: false, models: ["copilot/gpt-4"] }, + }, + } + const profile: ProviderSettings = { + apiProvider: providerIdentifiers.vscodeLm, + vsCodeLmModelSelector: { vendor: "copilot", family: "gpt-4" }, + } + + expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) + }) + it("should extract lmStudioModelId for lmstudio provider", () => { const allowList: OrganizationAllowList = { allowAll: false, diff --git a/webview-ui/src/components/chat/hooks/useChatModelSelector.ts b/webview-ui/src/components/chat/hooks/useChatModelSelector.ts index 1c7753a3bc..327518c6a7 100644 --- a/webview-ui/src/components/chat/hooks/useChatModelSelector.ts +++ b/webview-ui/src/components/chat/hooks/useChatModelSelector.ts @@ -171,6 +171,15 @@ export const useChatModelSelector = (): ChatModelSelectorData => { return } + // The effect reruns when the OpenAI request inputs change (base URL, API key, headers) + // without changing the provider. Drop the previous list first so credentials that were + // removed or replaced don't leave stale models selectable when no replacement request is + // sent. Request-ID creation and cleanup are unchanged, preserving cancellation behavior. + if (activeProvider === providerIdentifiers.openai) { + setOpenAiModels([]) + setOpenAiModelsReceived(false) + } + const requestId = `${activeProvider}-${Date.now()}-${Math.random().toString(36).slice(2)}` latestRequestId.current = requestId From 3dbabe39d868a7167fdb556a19888eeaa6cca150 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:15:13 +0000 Subject: [PATCH 11/12] fix(providers): preserve concurrent profile saves during rollback and share pending VS Code model discovery --- src/core/config/ProviderSettingsManager.ts | 53 +++++++++---- src/core/webview/ClineProvider.ts | 22 ++++-- .../ClineProvider.apiHandlerRebuild.spec.ts | 46 ++++++++++- .../__tests__/webviewMessageHandler.spec.ts | 77 +++++++++++++++---- src/core/webview/webviewMessageHandler.ts | 15 +++- .../chat/__tests__/ChatModelSelector.spec.tsx | 34 ++++++++ .../__tests__/useChatModelSelector.spec.tsx | 29 +++++++ 7 files changed, 237 insertions(+), 39 deletions(-) diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index c0835047b8..010aaa9324 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -52,6 +52,12 @@ export const providerProfilesSchema = z.object({ export type ProviderProfiles = z.infer +export class ProviderProfileConflictError extends Error { + constructor(name: string) { + super(`Provider profile '${name}' changed during update; rollback was skipped`) + } +} + export class ProviderSettingsManager { private static readonly SCOPE_PREFIX = "roo_cline_config_" private readonly defaultConfigId = this.generateId() @@ -374,34 +380,53 @@ export class ProviderSettingsManager { } } + private normalizeConfig(config: ProviderSettingsWithId): ProviderSettingsWithId { + const normalizedConfig = downgradeLegacyRooConfig(config as Record) + .config as ProviderSettingsWithId + + // For active providers, filter out settings from other providers. + // For retired providers, preserve full profile fields (including legacy + // provider-specific keys) to avoid data loss — passthrough() keeps + // unknown keys that strict parse() would strip. + return typeof normalizedConfig.apiProvider === "string" && isRetiredProvider(normalizedConfig.apiProvider) + ? providerSettingsWithIdSchema.passthrough().parse(normalizedConfig) + : discriminatedProviderSettingsWithIdSchema.parse(normalizedConfig) + } + + private assertConfigUnchanged(profiles: ProviderProfiles, name: string, expected?: ProviderSettingsWithId) { + // Match the JSON representation written to storage (undefined fields are omitted). + if ( + expected && + !deepEqual(profiles.apiConfigs[name], JSON.parse(JSON.stringify(this.normalizeConfig(expected)))) + ) { + throw new ProviderProfileConflictError(name) + } + } + /** * Save a config with the given name. * Preserves the ID from the input 'config' object if it exists, * otherwise generates a new one (for creation scenarios). */ - public async saveConfig(name: string, config: ProviderSettingsWithId): Promise { + public async saveConfig( + name: string, + config: ProviderSettingsWithId, + expected?: ProviderSettingsWithId, + ): Promise { try { return await this.lock(async () => { const providerProfiles = await this.load() + this.assertConfigUnchanged(providerProfiles, name, expected) // Preserve the existing ID if this is an update to an existing config. const existingId = providerProfiles.apiConfigs[name]?.id const id = config.id || existingId || this.generateId() - const normalizedConfig = downgradeLegacyRooConfig(config as Record) - .config as ProviderSettingsWithId - - // For active providers, filter out settings from other providers. - // For retired providers, preserve full profile fields (including legacy - // provider-specific keys) to avoid data loss — passthrough() keeps - // unknown keys that strict parse() would strip. - const filteredConfig = - typeof normalizedConfig.apiProvider === "string" && isRetiredProvider(normalizedConfig.apiProvider) - ? providerSettingsWithIdSchema.passthrough().parse(normalizedConfig) - : discriminatedProviderSettingsWithIdSchema.parse(normalizedConfig) + const filteredConfig = this.normalizeConfig(config) providerProfiles.apiConfigs[name] = { ...filteredConfig, id } await this.store(providerProfiles) return id }) } catch (error) { + if (error instanceof ProviderProfileConflictError) throw error throw new Error(`Failed to save config: ${error}`) } } @@ -468,10 +493,11 @@ export class ProviderSettingsManager { /** * Delete a config by name. */ - public async deleteConfig(name: string) { + public async deleteConfig(name: string, expected?: ProviderSettingsWithId) { try { return await this.lock(async () => { const providerProfiles = await this.load() + this.assertConfigUnchanged(providerProfiles, name, expected) if (!providerProfiles.apiConfigs[name]) { throw new Error(`Config '${name}' not found`) @@ -485,6 +511,7 @@ export class ProviderSettingsManager { await this.store(providerProfiles) }) } catch (error) { + if (error instanceof ProviderProfileConflictError) throw error throw new Error(`Failed to delete config: ${error}`) } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5c4bee0f48..c064b2b24a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -110,7 +110,7 @@ import { buildApiHandler } from "../../api" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/providers/fetchers/lmstudio" import { ContextProxy } from "../config/ContextProxy" -import { ProviderSettingsManager } from "../config/ProviderSettingsManager" +import { ProviderProfileConflictError, ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { PendingActionSettlementError, Task } from "../task/Task" @@ -1912,12 +1912,12 @@ export class ClineProvider const previousTaskSettings = task?.apiConfiguration const mode = activate ? (await this.getState()).mode : undefined const previousModeConfigId = mode ? await this.providerSettingsManager.getModeConfigId(mode) : undefined - let saved = false + let savedId: string | undefined try { signal.throwIfAborted() const id = await this.providerSettingsManager.saveConfig(name, providerSettings) - saved = true + savedId = id signal.throwIfAborted() const listApiConfig = await this.providerSettingsManager.listConfig() await this.updateGlobalState("listApiConfigMeta", listApiConfig) @@ -1934,19 +1934,24 @@ export class ClineProvider if (activate) await this.persistStickyProviderProfileToCurrentTask(name) return id } catch (error) { - if (saved) { + if (savedId) { // Attempt every compensation even if an individual store is unavailable. + let profileConflict = false const restore = async (write: () => unknown | Promise) => { try { await write() } catch (rollbackError) { this.log(`Failed to roll back provider profile state: ${String(rollbackError)}`) + if (rollbackError instanceof ProviderProfileConflictError) profileConflict = true } } await restore(() => previousProfile - ? this.providerSettingsManager.saveConfig(name, previousProfile) - : this.providerSettingsManager.deleteConfig(name), + ? this.providerSettingsManager.saveConfig(name, previousProfile, { + ...providerSettings, + id: savedId, + }) + : this.providerSettingsManager.deleteConfig(name, { ...providerSettings, id: savedId }), ) if (activate && mode) { await restore(() => this.providerSettingsManager.setModeConfig(mode, previousModeConfigId)) @@ -1956,7 +1961,10 @@ export class ClineProvider await restore(() => task.updateApiConfiguration(previousTaskSettings)) } } - await restore(() => this.updateGlobalState("listApiConfigMeta", previousMeta)) + // A direct settings save may have published newer profile metadata. + if (!profileConflict) { + await restore(() => this.updateGlobalState("listApiConfigMeta", previousMeta)) + } await restore(() => this.postStateToWebview()) } throw error diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 72ebb6ddb7..7b67f16a34 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -516,6 +516,44 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { }, ) + test.each(["existing", "new-profile"])( + "preserves a concurrent settings save for %s when upsert fails", + async (name) => { + const manager = provider.providerSettingsManager + const finalSettings = { ...oldSettings, openRouterModelId: "concurrent-model" } + const task = new Task({ ...defaultTaskOptions, apiConfiguration: oldSettings }) + await provider.addClineToStack(task) + let failBroadcast!: (reason: Error) => void + const broadcast = vi.spyOn(provider, "postStateToWebview").mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failBroadcast = reject + }), + ) + const log = vi.spyOn(provider, "log") + const pending = provider.upsertProviderProfile(name, newSettings) + await vi.waitFor(() => expect(broadcast).toHaveBeenCalledTimes(1)) + const upserted = await manager.getProfile({ name }) + + // saveApiConfiguration and other direct writers bypass the provider queue. + await manager.saveConfig(name, finalSettings) + const metadata = await manager.listConfig() + await provider.contextProxy.setValue("listApiConfigMeta", metadata) + failBroadcast(new Error("broadcast failed")) + expect(await pending).toBeUndefined() + + const reloaded = new ProviderSettingsManager(mockContext) + expect(await reloaded.getProfile({ name })).toMatchObject({ ...finalSettings, id: upserted.id }) + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual(metadata) + expect(await reloaded.getModeConfigId("code")).toBeUndefined() + expect(provider.contextProxy.getProviderSettings()).toMatchObject(oldSettings) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("existing") + expect(task.apiConfiguration).toEqual(oldSettings) + expect(broadcast).toHaveBeenCalledTimes(2) + expect(log).toHaveBeenCalledWith(expect.stringContaining("rollback was skipped")) + }, + ) + test("holds later model updates until a timed-out save finishes compensation", async () => { vi.useFakeTimers() try { @@ -532,11 +570,11 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const saves = vi .spyOn(manager, "saveConfig") .mockImplementationOnce(save) - .mockImplementationOnce(async (name, settings) => { + .mockImplementationOnce(async (name, settings, expected) => { await new Promise((resolve) => { releaseRollback = resolve }) - return save(name, settings) + return save(name, settings, expected) }) const first = provider.upsertProviderProfile("existing", newSettings) await vi.advanceTimersByTimeAsync(0) @@ -579,11 +617,11 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { let releaseRollback!: () => void vi.spyOn(manager, "saveConfig") .mockImplementationOnce(save) - .mockImplementationOnce(async (name, settings) => { + .mockImplementationOnce(async (name, settings, expected) => { await new Promise((resolve) => { releaseRollback = resolve }) - return save(name, settings) + return save(name, settings, expected) }) vi.spyOn(provider, "postStateToWebview").mockRejectedValueOnce(new Error("broadcast failed")) const first = provider.upsertProviderProfile("existing", newSettings) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index c5dd7db3f7..feb14b51bd 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -2585,22 +2585,73 @@ describe("model request correlation and cancellation", () => { }, ) - it("settles cancelled VS Code discovery even when the native API is still pending", async () => { - let finish!: (models: Awaited>) => void - vi.mocked(getVsCodeLmModels).mockImplementationOnce( - () => - new Promise((resolve) => { - finish = resolve - }), - ) - const pending = webviewMessageHandler(mockClineProvider, { + it.each(["cancel", "dispose"])( + "shares pending VS Code discovery after %s and refreshes after settlement", + async (action) => { + let finish!: (models: Awaited>) => void + vi.mocked(getVsCodeLmModels).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const pending = webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + requestId: "cancel-vscode", + }) + if (action === "cancel") { + await webviewMessageHandler(mockClineProvider, { + type: "cancelModelRequest", + requestId: "cancel-vscode", + }) + } else { + mockClineProvider.modelRequests.dispose() + Object.defineProperty(mockClineProvider, "modelRequests", { + value: new ModelRequestRegistry(), + configurable: true, + }) + } + await pending + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + const restarted = webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + requestId: "restarted-vscode", + }) + const sibling = webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + requestId: "sibling-vscode", + }) + await webviewMessageHandler(mockClineProvider, { type: "cancelModelRequest", requestId: "sibling-vscode" }) + await sibling + expect(getVsCodeLmModels).toHaveBeenCalledTimes(1) + finish([]) + await restarted + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "vsCodeLmModels", + requestId: "restarted-vscode", + vsCodeLmModels: [], + }) + await webviewMessageHandler(mockClineProvider, { type: "requestVsCodeLmModels", requestId: "fresh-vscode" }) + expect(getVsCodeLmModels).toHaveBeenCalledTimes(2) + }, + ) + + it("allows VS Code discovery to retry after a shared failure", async () => { + vi.mocked(getVsCodeLmModels).mockRejectedValueOnce(new Error("discovery failed")) + const first = webviewMessageHandler(mockClineProvider, { type: "requestVsCodeLmModels", - requestId: "cancel-vscode", + requestId: "failed-first", }) - await webviewMessageHandler(mockClineProvider, { type: "cancelModelRequest", requestId: "cancel-vscode" }) - await pending - finish([]) + const second = webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + requestId: "failed-second", + }) + await expect(first).rejects.toThrow("discovery failed") + await expect(second).rejects.toThrow("discovery failed") + expect(getVsCodeLmModels).toHaveBeenCalledTimes(1) expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + await webviewMessageHandler(mockClineProvider, { type: "requestVsCodeLmModels", requestId: "retry" }) + expect(getVsCodeLmModels).toHaveBeenCalledTimes(2) }) it("aborts OpenAI HTTP work and suppresses its late response", async () => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index f6bfead1c6..bdc58c34a8 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -123,6 +123,16 @@ import { handleCheckoutBranch, } from "./worktree" +// Native discovery has no cancellation token. Keep it shared across request IDs +// and provider instances until it settles; cancellation only detaches a waiter. +let pendingVsCodeLmModels: ReturnType | undefined +const discoverVsCodeLmModels = () => { + pendingVsCodeLmModels ??= getVsCodeLmModels().finally(() => { + pendingVsCodeLmModels = undefined + }) + return pendingVsCodeLmModels +} + export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, @@ -1545,9 +1555,9 @@ const handleWebviewMessage = async ( } break - case VsCodeLmModelsMessageType.requestVsCodeLmModels: + case VsCodeLmModelsMessageType.requestVsCodeLmModels: { modelRequestSignal?.throwIfAborted() - const vsCodeLmModels = await resolveModelWithAbort(getVsCodeLmModels, modelRequestSignal, "VS Code LM") + const vsCodeLmModels = await resolveModelWithAbort(discoverVsCodeLmModels, modelRequestSignal, "VS Code LM") // TODO: Cache like we do for OpenRouter, etc? modelRequestSignal?.throwIfAborted() await provider.postMessageToWebview({ @@ -1556,6 +1566,7 @@ const handleWebviewMessage = async ( requestId: message.requestId, }) break + } case "openImage": await openImage(message.text!, { values: message.values }) break diff --git a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx index 5410173000..aee33eb834 100644 --- a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx @@ -143,6 +143,40 @@ describe("ChatModelSelector", () => { expect(screen.getByTestId("chat-model-option-claude-sonnet-4-20250514")).toBeInTheDocument() }) + test("hides deprecated models while retaining the persisted deprecated selection", () => { + mockUseChatModelSelector.mockReturnValue( + createSelectorState({ + models: { + ...anthropicModels, + "claude-opus-4-20250514": { ...anthropicModels["claude-opus-4-20250514"], deprecated: true }, + "retired-model": { ...anthropicModels["claude-sonnet-4-20250514"], deprecated: true }, + }, + }), + ) + render() + fireEvent.click(screen.getByTestId("chat-model-selector-trigger")) + + expect(screen.queryByTestId("chat-model-option-retired-model")).not.toBeInTheDocument() + expect(screen.getByTestId("chat-model-option-claude-opus-4-20250514")).toBeInTheDocument() + expect(screen.getByTestId("chat-model-option-claude-sonnet-4-20250514")).toBeInTheDocument() + }) + + test.each(["ArrowDown", "ArrowUp", "Enter"])("ignores %s while composing text", async (key) => { + const user = userEvent.setup() + render() + await user.click(screen.getByTestId("chat-model-selector-trigger")) + const search = screen.getByRole("textbox") + expect(search).toHaveFocus() + + fireEvent.keyDown(search, { key, isComposing: true }) + + expect(search).toHaveFocus() + expect(vscode.postMessage).not.toHaveBeenCalled() + // Normal navigation still works after composition ends. + await user.keyboard("{ArrowDown}{Enter}") + expect(vscode.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "upsertApiConfiguration" })) + }) + test("shows the empty catalog message once loading finishes", async () => { const user = userEvent.setup() mockUseChatModelSelector.mockReturnValue({ diff --git a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx index 424b3a450a..2eb93bd3d1 100644 --- a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx +++ b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx @@ -236,6 +236,35 @@ describe("useChatModelSelector", () => { ) }) + it.each(["openAiBaseUrl", "openAiApiKey"] as const)( + "clears loaded models and suppresses requests when %s is removed", + (missingField) => { + const config = { + apiProvider: providerIdentifiers.openai, + openAiBaseUrl: "https://api.example.com/v1", + openAiApiKey: "test-key", + } + mockUseExtensionState.mockReturnValue({ apiConfiguration: config }) + const { result, rerender } = renderHook(() => useChatModelSelector(), { wrapper }) + const requestId = mockPostMessage.mock.calls[0][0].requestId + act(() => emitMessage({ type: "openAiModels", openAiModels: ["gpt-4o"], requestId })) + expect(Object.keys(result.current.models!)).toEqual(["gpt-4o"]) + + mockPostMessage.mockClear() + mockUseExtensionState.mockReturnValue({ apiConfiguration: { ...config, [missingField]: undefined } }) + rerender() + + expect(mockPostMessage.mock.calls.map(([message]) => message)).toEqual([ + { type: "cancelModelRequest", requestId }, + ]) + expect(result.current.models).toBeNull() + expect(result.current.isLoading).toBe(false) + act(() => emitMessage({ type: "openAiModels", openAiModels: ["stale-model"], requestId })) + expect(result.current.models).toBeNull() + expect(result.current.isLoading).toBe(false) + }, + ) + it("only requests again when serialized header values change", () => { const configure = (headers?: Record) => mockUseExtensionState.mockReturnValue({ From 0165217d902571f798c396d43fa8f91d7eb4b718 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:26:58 +0000 Subject: [PATCH 12/12] fix(providers): merge concurrent profile edits safely, acknowledge saves, make renames atomic, and bound shared LM Studio discovery --- packages/types/src/vscode-extension-host.ts | 3 + src/core/config/ProviderSettingsManager.ts | 44 ++++- .../__tests__/ProviderSettingsManager.spec.ts | 122 +++++++++++++- src/core/webview/ClineProvider.ts | 48 +++++- .../ClineProvider.apiHandlerRebuild.spec.ts | 68 +++++++- .../webview/__tests__/ClineProvider.spec.ts | 51 +++++- .../__tests__/webviewMessageHandler.spec.ts | 155 +++++++++++++++++- src/core/webview/webviewMessageHandler.ts | 92 ++++++++--- src/shared/__tests__/ProfileValidator.spec.ts | 17 ++ .../src/components/chat/ChatModelSelector.tsx | 1 + .../chat/__tests__/ChatModelSelector.spec.tsx | 18 +- .../__tests__/useChatModelSelector.spec.tsx | 10 ++ .../components/settings/ApiConfigManager.tsx | 27 ++- .../src/components/settings/SettingsView.tsx | 80 +++++++-- .../__tests__/ApiConfigManager.spec.tsx | 38 +++++ .../SettingsView.change-detection.spec.tsx | 15 ++ .../settings/__tests__/SettingsView.spec.tsx | 11 ++ .../SettingsView.unsaved-changes.spec.tsx | 130 ++++++++++++++- 18 files changed, 857 insertions(+), 73 deletions(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index dd478106e2..eef84f326f 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -42,6 +42,7 @@ export interface ExtensionMessage { | "enhancedPrompt" | "commitSearchResults" | "listApiConfig" + | "apiConfigurationSaved" | typeof RouterModelsMessageType.routerModels | "zooGatewayCredentialsReady" | typeof OpenAiModelsMessageType.openAiModels @@ -662,6 +663,8 @@ export interface WebviewMessage { dataUri?: string askResponse?: ClineAskResponse apiConfiguration?: ProviderSettings + /** Profile when editing began; used to merge edits without overwriting concurrent changes. */ + apiConfigurationBaseline?: ProviderSettings images?: string[] bool?: boolean value?: number diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 010aaa9324..0aa93052a0 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -4,6 +4,7 @@ import deepEqual from "fast-deep-equal" import { type ProviderSettingsWithId, + type ProviderSettings, providerSettingsWithIdSchema, discriminatedProviderSettingsWithIdSchema, isSecretStateKey, @@ -407,21 +408,58 @@ export class ProviderSettingsManager { * Save a config with the given name. * Preserves the ID from the input 'config' object if it exists, * otherwise generates a new one (for creation scenarios). + * A baseline applies only fields edited by the caller, rejecting overlapping edits. + * Renames update both names in one stored write under the same lock. */ public async saveConfig( name: string, config: ProviderSettingsWithId, expected?: ProviderSettingsWithId, + baseline?: ProviderSettings, + renameFrom?: string, ): Promise { try { return await this.lock(async () => { const providerProfiles = await this.load() - this.assertConfigUnchanged(providerProfiles, name, expected) + const sourceName = renameFrom ?? name + this.assertConfigUnchanged(providerProfiles, sourceName, expected) + if (renameFrom && (!providerProfiles.apiConfigs[renameFrom] || providerProfiles.apiConfigs[name])) { + throw new ProviderProfileConflictError(name) + } // Preserve the existing ID if this is an update to an existing config. - const existingId = providerProfiles.apiConfigs[name]?.id + const existingId = providerProfiles.apiConfigs[sourceName]?.id const id = config.id || existingId || this.generateId() - const filteredConfig = this.normalizeConfig(config) + let filteredConfig = this.normalizeConfig(config) + if (baseline && providerProfiles.apiConfigs[sourceName]) { + const current = providerProfiles.apiConfigs[sourceName] + const original = this.normalizeConfig(baseline) + if ( + current.apiProvider !== original.apiProvider && + current.apiProvider !== filteredConfig.apiProvider + ) { + throw new ProviderProfileConflictError(sourceName) + } + const merged = { ...current } + for (const key of new Set([...Object.keys(original), ...Object.keys(filteredConfig)])) { + if (key === "id") continue + const field = key as keyof ProviderSettings + if (deepEqual(original[field], filteredConfig[field])) continue + if ( + !deepEqual(current[field], original[field]) && + !deepEqual(current[field], filteredConfig[field]) + ) { + throw new ProviderProfileConflictError(name) + } + Object.assign(merged, { [field]: filteredConfig[field] }) + } + filteredConfig = this.normalizeConfig(merged) + } providerProfiles.apiConfigs[name] = { ...filteredConfig, id } + if (renameFrom) { + delete providerProfiles.apiConfigs[renameFrom] + if (providerProfiles.currentApiConfigName === renameFrom) + providerProfiles.currentApiConfigName = name + } await this.store(providerProfiles) return id }) diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 2c7c571458..57eb13de2a 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -12,7 +12,12 @@ import { import { clearAllMocks } from "../../../test-utils/reset" import { makeExtensionContext } from "../../../test-utils/vscode" -import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager" +import { + ProviderProfileConflictError, + ProviderSettingsManager, + ProviderProfiles, + SyncCloudProfilesResult, +} from "../ProviderSettingsManager" // `export()` builds an API handler per profile to read model capabilities. Mock // buildApiHandler with the real @roo-code/types model definitions so the token-field @@ -73,6 +78,121 @@ describe("ProviderSettingsManager", () => { providerSettingsManager = new ProviderSettingsManager(mockContext) }) + describe("concurrent profile edits", () => { + const original = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "old-key", + openRouterModelId: "old-model", + } + beforeEach(async () => { + const storage = new Map() + mockSecrets.get.mockImplementation(async (key: string) => storage.get(key)) + mockSecrets.store.mockImplementation(async (key: string, value: string) => { + storage.set(key, value) + }) + await providerSettingsManager.saveConfig("profile", original) + }) + + it.each(["save", "delete"] as const)("allows %s with a matching expected profile", async (operation) => { + const expected = await providerSettingsManager.getProfile({ name: "profile" }) + if (operation === "save") { + await providerSettingsManager.saveConfig("profile", { ...original, openRouterModelId: "new" }, expected) + expect(await providerSettingsManager.getProfile({ name: "profile" })).toMatchObject({ + openRouterModelId: "new", + }) + } else { + await providerSettingsManager.deleteConfig("profile", expected) + expect(await providerSettingsManager.hasConfig("profile")).toBe(false) + } + }) + + it.each(["save", "delete"] as const)( + "rejects %s without writing when the expected profile is stale", + async (operation) => { + const expected = await providerSettingsManager.getProfile({ name: "profile" }) + await providerSettingsManager.saveConfig("profile", { ...original, openRouterApiKey: "new-key" }) + const stored = await providerSettingsManager.export() + mockSecrets.store.mockClear() + const mutation = + operation === "save" + ? providerSettingsManager.saveConfig("profile", original, expected) + : providerSettingsManager.deleteConfig("profile", expected) + await expect(mutation).rejects.toBeInstanceOf(ProviderProfileConflictError) + expect(mockSecrets.store).not.toHaveBeenCalled() + expect(await providerSettingsManager.export()).toEqual(stored) + }, + ) + + it.each([true, false])( + "preserves concurrent credential and model edits (model first: %s)", + async (modelFirst) => { + const model = { ...original, openRouterModelId: "new-model" } + const settings = { ...original, openRouterApiKey: "new-key" } + for (const edit of modelFirst ? [model, settings] : [settings, model]) { + await providerSettingsManager.saveConfig("profile", edit, undefined, original) + } + expect(await providerSettingsManager.getProfile({ name: "profile" })).toMatchObject({ + openRouterApiKey: "new-key", + openRouterModelId: "new-model", + }) + }, + ) + + it("rejects conflicting edits and provider switches without writes", async () => { + await providerSettingsManager.saveConfig("profile", { ...original, openRouterModelId: "new-model" }) + mockSecrets.store.mockClear() + await expect( + providerSettingsManager.saveConfig( + "profile", + { ...original, openRouterModelId: "other-model" }, + undefined, + original, + ), + ).rejects.toBeInstanceOf(ProviderProfileConflictError) + expect(mockSecrets.store).not.toHaveBeenCalled() + await providerSettingsManager.saveConfig("profile", { apiProvider: providerIdentifiers.anthropic }) + mockSecrets.store.mockClear() + await expect( + providerSettingsManager.saveConfig( + "profile", + { ...original, openRouterModelId: "other-model" }, + undefined, + original, + ), + ).rejects.toBeInstanceOf(ProviderProfileConflictError) + expect(mockSecrets.store).not.toHaveBeenCalled() + }) + + it("accepts identical concurrent provider changes", async () => { + const updated = { apiProvider: providerIdentifiers.anthropic, apiKey: "new-provider-key" } + await providerSettingsManager.saveConfig("profile", updated, undefined, original) + await providerSettingsManager.saveConfig("profile", updated, undefined, original) + expect(await providerSettingsManager.getProfile({ name: "profile" })).toMatchObject(updated) + }) + + it("renames atomically while preserving a concurrent model edit and profile ID", async () => { + const { id } = await providerSettingsManager.getProfile({ name: "profile" }) + await providerSettingsManager.saveConfig("profile", { ...original, openRouterModelId: "new-model" }) + await providerSettingsManager.saveConfig( + "renamed", + { ...original, openRouterApiKey: "new-key" }, + undefined, + original, + "profile", + ) + expect(await providerSettingsManager.hasConfig("profile")).toBe(false) + expect(await providerSettingsManager.getProfile({ name: "renamed" })).toMatchObject({ + id, + openRouterModelId: "new-model", + openRouterApiKey: "new-key", + }) + await expect( + providerSettingsManager.saveConfig("default", original, undefined, original, "renamed"), + ).rejects.toBeInstanceOf(ProviderProfileConflictError) + expect(await providerSettingsManager.hasConfig("renamed")).toBe(true) + }) + }) + describe("setModeConfig", () => { it("persists a string mapping and removes only the unset mode after reload", async () => { const storage = new Map() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c064b2b24a..d9c902d74c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1896,14 +1896,17 @@ export class ClineProvider name: string, providerSettings: ProviderSettings, activate: boolean = true, + baseline?: ProviderSettings, + renameFrom?: string, ): Promise { try { return await this.enqueueProviderProfileMutation(async (signal) => { signal.throwIfAborted() // Snapshot and compensate inside the queue so a failed save cannot // overwrite a later successful mutation. - const previousProfile = (await this.providerSettingsManager.hasConfig(name)) - ? await this.providerSettingsManager.getProfile({ name }) + const sourceName = renameFrom ?? name + const previousProfile = (await this.providerSettingsManager.hasConfig(sourceName)) + ? await this.providerSettingsManager.getProfile({ name: sourceName }) : undefined const previousSettings = this.contextProxy.getProviderSettings() const previousName = this.contextProxy.getValue("currentApiConfigName") @@ -1916,8 +1919,27 @@ export class ClineProvider try { signal.throwIfAborted() - const id = await this.providerSettingsManager.saveConfig(name, providerSettings) + const id = await this.providerSettingsManager.saveConfig( + name, + providerSettings, + previousProfile, + baseline, + renameFrom, + ) savedId = id + // Activate and compensate the merged stored profile, including concurrent edits. + providerSettings = await this.providerSettingsManager.getProfile({ name }) + const { organizationAllowList } = await this.getState() + if ( + !ProfileValidator.isProfileAllowed( + providerSettings, + organizationAllowList ?? ORGANIZATION_ALLOW_ALL, + ) + ) { + throw new OrganizationAllowListViolationError( + t("common:errors.violated_organization_allowlist"), + ) + } signal.throwIfAborted() const listApiConfig = await this.providerSettingsManager.listConfig() await this.updateGlobalState("listApiConfigMeta", listApiConfig) @@ -1947,10 +1969,16 @@ export class ClineProvider } await restore(() => previousProfile - ? this.providerSettingsManager.saveConfig(name, previousProfile, { - ...providerSettings, - id: savedId, - }) + ? this.providerSettingsManager.saveConfig( + sourceName, + previousProfile, + { + ...providerSettings, + id: savedId, + }, + undefined, + renameFrom ? name : undefined, + ) : this.providerSettingsManager.deleteConfig(name, { ...providerSettings, id: savedId }), ) if (activate && mode) { @@ -1975,7 +2003,11 @@ export class ClineProvider `Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) - vscode.window.showErrorMessage(t("common:errors.create_api_config")) + vscode.window.showErrorMessage( + error instanceof OrganizationAllowListViolationError + ? error.message + : t("common:errors.create_api_config"), + ) return undefined } } diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 7b67f16a34..bfdd6f019e 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -1,3 +1,4 @@ +import { t } from "../../../i18n" import { ProviderSettingsManager } from "../../config/ProviderSettingsManager" import { WebviewFocusTracker } from "../WebviewFocusTracker" // npx vitest core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -6,7 +7,7 @@ import * as vscode from "vscode" import { makeCompositeDisposable } from "../../../test-utils/vscode" import { TelemetryService } from "@roo-code/telemetry" -import { getModelId, RooCodeEventName } from "@roo-code/types" +import { getModelId, RooCodeEventName, type ProviderSettings } from "@roo-code/types" import { ContextProxy } from "../../config/ContextProxy" import type { Mode } from "../../../shared/modes" @@ -242,9 +243,16 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { new WebviewFocusTracker(), ) + let storedSettings: ProviderSettings = { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + } // Mock providerSettingsManager ;(provider as any).providerSettingsManager = { - saveConfig: vi.fn().mockResolvedValue("test-id"), + saveConfig: vi.fn().mockImplementation(async (_name: string, settings: ProviderSettings) => { + storedSettings = settings + return "test-id" + }), hasConfig: vi.fn().mockResolvedValue(true), deleteConfig: vi.fn(), listConfig: vi.fn().mockResolvedValue([ @@ -263,12 +271,9 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", }), - getProfile: vi.fn().mockResolvedValue({ - name: "test-config", - id: "test-id", - apiProvider: providerIdentifiers.openrouter, - openRouterModelId: "openai/gpt-4", - }), + getProfile: vi + .fn() + .mockImplementation(async () => ({ name: "test-config", id: "test-id", ...storedSettings })), } // Get the buildApiHandler mock @@ -486,6 +491,51 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { ) }) + test.each([true, false])("publishes merged settings and model edits (model first: %s)", async (modelFirst) => { + const settings = { ...oldSettings, openRouterApiKey: "updated-key" } + const edits = modelFirst ? [newSettings, settings] : [settings, newSettings] + const task = new Task({ ...defaultTaskOptions, apiConfiguration: oldSettings }) + await provider.addClineToStack(task) + const results = await Promise.all( + edits.map((edit) => provider.upsertProviderProfile("existing", edit, true, oldSettings)), + ) + expect(results.every(Boolean)).toBe(true) + const expected = { ...newSettings, openRouterApiKey: "updated-key" } + expect(await provider.providerSettingsManager.getProfile({ name: "existing" })).toMatchObject(expected) + expect(provider.contextProxy.getProviderSettings()).toMatchObject(expected) + expect(task.apiConfiguration).toMatchObject(expected) + }) + + test("rolls back a policy rejection and reports its specific error for an inactive save", async () => { + vi.spyOn(provider, "getState").mockResolvedValue({ + ...(await provider.getState()), + organizationAllowList: { + allowAll: false, + providers: { + [providerIdentifiers.openrouter]: { allowAll: false, models: [oldSettings.openRouterModelId] }, + }, + }, + }) + expect(await provider.upsertProviderProfile("existing", newSettings, false, oldSettings)).toBeUndefined() + expect(await provider.providerSettingsManager.getProfile({ name: "existing" })).toMatchObject(oldSettings) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + t("common:errors.violated_organization_allowlist"), + ) + }) + + test("compensates a rename and its activation before processing another save", async () => { + const before = await provider.providerSettingsManager.getProfile({ name: "existing" }) + vi.spyOn(provider, "postStateToWebview").mockRejectedValueOnce(new Error("broadcast failed")) + expect( + await provider.upsertProviderProfile("renamed", newSettings, true, oldSettings, "existing"), + ).toBeUndefined() + expect(await provider.providerSettingsManager.hasConfig("renamed")).toBe(false) + expect(await provider.providerSettingsManager.getProfile({ name: "existing" })).toEqual(before) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("existing") + expect(provider.contextProxy.getProviderSettings()).toMatchObject(oldSettings) + expect(await provider.upsertProviderProfile("existing", newSettings, true, oldSettings)).toBeTruthy() + }) + test.each(["list", "activation", "broadcast"] as const)( "restores existing and removes new profiles after %s failure", async (phase) => { @@ -535,7 +585,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { await vi.waitFor(() => expect(broadcast).toHaveBeenCalledTimes(1)) const upserted = await manager.getProfile({ name }) - // saveApiConfiguration and other direct writers bypass the provider queue. + // Non-webview direct manager writers can bypass the provider queue. await manager.saveConfig(name, finalSettings) const metadata = await manager.listConfig() await provider.contextProxy.setValue("listApiConfigMeta", metadata) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index a72ee858b3..7b6082a6a4 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2631,6 +2631,9 @@ describe("ClineProvider", () => { { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]), hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi + .fn() + .mockResolvedValue({ id: "test-id", apiProvider: providerIdentifiers.anthropic, apiKey: "test-key" }), saveConfig: vi.fn().mockResolvedValue("test-id"), setModeConfig: vi.fn(), getModeConfigId: vi.fn().mockResolvedValue(undefined), @@ -3369,6 +3372,11 @@ describe("ClineProvider", () => { ;(provider as any).providerSettingsManager = { hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi.fn().mockResolvedValue({ + id: "test-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-key", + }), deleteConfig: vi.fn(), setModeConfig: vi.fn().mockRejectedValue(new Error("Failed to update mode config")), saveConfig: vi.fn().mockResolvedValue("test-id"), @@ -3406,9 +3414,14 @@ describe("ClineProvider", () => { ;(provider as any).providerSettingsManager = { hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi.fn().mockResolvedValue({ + id: "test-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-key", + }), deleteConfig: vi.fn(), setModeConfig: vi.fn(), - saveConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("test-id"), getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() @@ -3430,7 +3443,13 @@ describe("ClineProvider", () => { }) // Verify config was saved - expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith("test-config", testApiConfig) + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith( + "test-config", + testApiConfig, + undefined, + undefined, + undefined, + ) // Verify state updates expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [ @@ -3454,9 +3473,14 @@ describe("ClineProvider", () => { }) ;(provider as any).providerSettingsManager = { hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi.fn().mockResolvedValue({ + id: "test-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-key", + }), deleteConfig: vi.fn(), setModeConfig: vi.fn(), - saveConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("test-id"), getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() @@ -3488,7 +3512,10 @@ describe("ClineProvider", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.create_api_config") // The initial metadata write is followed by restoration of the snapshot. - expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith("test-config") + expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith( + "test-config", + expect.objectContaining({ ...testApiConfig, id: "test-id" }), + ) expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [ { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]) @@ -3499,8 +3526,14 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi.fn().mockResolvedValue({ + id: "test-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-key", + }), setModeConfig: vi.fn(), - saveConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("test-id"), listConfig: vi .fn() .mockResolvedValue([ @@ -3521,7 +3554,13 @@ describe("ClineProvider", () => { }) // Verify config was saved - expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith("test-config", testApiConfig) + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith( + "test-config", + testApiConfig, + undefined, + undefined, + undefined, + ) // Verify state updates expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [ diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index feb14b51bd..ed8b43ac4e 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -281,13 +281,72 @@ describe("webviewMessageHandler - organization allowlist enforcement", () => { apiConfiguration, }) - expect(saveConfig).toHaveBeenCalledWith("new", { ...apiConfiguration, id: "profile-id" }) - expect(deleteConfig).toHaveBeenCalledWith("old") - expect(activateProviderProfile).toHaveBeenCalledWith({ name: "new" }) + expect(mockClineProvider.upsertProviderProfile).toHaveBeenCalledWith( + "new", + apiConfiguration, + true, + undefined, + "old", + ) + expect(saveConfig).not.toHaveBeenCalled() + expect(deleteConfig).not.toHaveBeenCalled() expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() }, ) + it.each([{ text: "profile" }, { apiConfiguration: {} }])( + "rejects incomplete save requests with an acknowledgement", + async (fields) => { + await webviewMessageHandler(mockClineProvider, { + type: "upsertApiConfiguration", + requestId: "invalid-save", + ...fields, + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "apiConfigurationSaved", + requestId: "invalid-save", + success: false, + }) + }, + ) + + it.each(["success", "conflict", "error"])("acknowledges an API configuration save after %s", async (outcome) => { + const save = + outcome === "error" + ? vi.fn().mockRejectedValue(new Error("save failed")) + : vi.fn().mockResolvedValue(outcome === "success" ? "id" : undefined) + setUpsertSpy(save) + await webviewMessageHandler(mockClineProvider, { + type: "upsertApiConfiguration", + text: "profile", + requestId: "save-request", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-sonnet-4-20250514" }, + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "apiConfigurationSaved", + requestId: "save-request", + success: outcome === "success", + }) + }) + + it.each(["success", "conflict", "invalid", "unchanged"])("acknowledges profile rename %s", async (outcome) => { + setUpsertSpy(vi.fn().mockResolvedValue(outcome === "success" ? "id" : undefined)) + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + requestId: "rename-request", + values: { oldName: "old", newName: outcome === "unchanged" ? "old" : "new" }, + apiConfiguration: + outcome === "invalid" + ? undefined + : { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-sonnet-4-20250514" }, + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "apiConfigurationSaved", + requestId: "rename-request", + success: outcome === "success" || outcome === "unchanged", + }) + }) + it("allows an allow-listed model on upsertApiConfiguration", async () => { const upsertProviderProfile = vi.fn().mockResolvedValue("id") setUpsertSpy(upsertProviderProfile) @@ -301,6 +360,8 @@ describe("webviewMessageHandler - organization allowlist enforcement", () => { expect(upsertProviderProfile).toHaveBeenCalledWith( "profile", expect.objectContaining({ apiModelId: "claude-sonnet-4-20250514" }), + true, + undefined, ) expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() }) @@ -410,6 +471,92 @@ describe("webviewMessageHandler - requestLmStudioModels", () => { }) }) + it.each(["cancel", "dispose"])( + "detaches a preview on %s while retaining discovery for the next waiter", + async (action) => { + let finish!: (models: ModelRecord) => void + mockGetLMStudioModels.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const values = { baseUrl: "http://localhost:4567" } + const first = webviewMessageHandler(mockClineProvider, { + type: "requestLmStudioModels", + requestId: "first-preview", + values, + }) + await vi.waitFor(() => expect(mockGetLMStudioModels).toHaveBeenCalledTimes(1)) + if (action === "cancel") { + await webviewMessageHandler(mockClineProvider, { + type: "cancelModelRequest", + requestId: "first-preview", + }) + } else { + mockClineProvider.modelRequests.dispose() + Object.defineProperty(mockClineProvider, "modelRequests", { + value: new ModelRequestRegistry(), + configurable: true, + }) + } + await first + const second = webviewMessageHandler(mockClineProvider, { + type: "requestLmStudioModels", + requestId: "second-preview", + values, + }) + await Promise.resolve() + expect(mockGetLMStudioModels).toHaveBeenCalledTimes(1) + finish({}) + await second + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalledWith( + expect.objectContaining({ requestId: "first-preview" }), + ) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ requestId: "second-preview" }), + ) + }, + ) + + it("bounds preview waiters without starting overlapping SDK discovery after timeout", async () => { + const timeout = new AbortController() + const timeoutSignal = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeout.signal) + let finish!: (models: ModelRecord) => void + mockGetLMStudioModels.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + try { + const values = { baseUrl: "http://localhost:5432" } + const first = webviewMessageHandler(mockClineProvider, { + type: "requestLmStudioModels", + requestId: "timeout-preview", + values, + }) + await vi.waitFor(() => expect(mockGetLMStudioModels).toHaveBeenCalledTimes(1)) + timeout.abort() + await first + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ requestId: "timeout-preview", lmStudioModels: {} }), + ) + timeoutSignal.mockReturnValue(new AbortController().signal) + const second = webviewMessageHandler(mockClineProvider, { + type: "requestLmStudioModels", + requestId: "retry-preview", + values, + }) + await Promise.resolve() + expect(mockGetLMStudioModels).toHaveBeenCalledTimes(1) + finish({}) + await second + } finally { + timeoutSignal.mockRestore() + } + }) + it("successfully fetches models from LMStudio", async () => { const mockModels: ModelRecord = { "model-1": { @@ -505,7 +652,7 @@ describe("webviewMessageHandler - requestLmStudioModels", () => { values: { baseUrl: "" }, }) - expect(mockGetLMStudioModels).toHaveBeenCalledWith("", { signal: expect.any(AbortSignal) }) + expect(mockGetLMStudioModels).toHaveBeenCalledWith("http://localhost:1234", { signal: expect.any(AbortSignal) }) expect(mockGetModels).not.toHaveBeenCalled() }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index bdc58c34a8..20a49ecaa5 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1,4 +1,4 @@ -import { resolveModelWithAbort } from "../../api/providers/utils/abort-signal" +import { mergeAbortSignalAndTimeout, resolveModelWithAbort } from "../../api/providers/utils/abort-signal" import { safeWriteJson } from "../../utils/safeWriteJson" import * as path from "path" import * as os from "os" @@ -133,6 +133,21 @@ const discoverVsCodeLmModels = () => { return pendingVsCodeLmModels } +const pendingLmStudioPreviews = new Map>() +const discoverLmStudioPreview = (baseUrl: string) => { + const key = baseUrl || "http://localhost:1234" + let pending = pendingLmStudioPreviews.get(key) + if (!pending) { + // Bound the cancellable HTTP probe. SDK discovery must remain registered until + // it actually settles, even when every timed-out waiter has detached. + pending = getLMStudioModels(key, { signal: AbortSignal.timeout(15_000) }).finally(() => + pendingLmStudioPreviews.delete(key), + ) + pendingLmStudioPreviews.set(key, pending) + } + return pending +} + export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, @@ -1501,9 +1516,10 @@ const handleWebviewMessage = async ( const hasPreviewBaseUrl = typeof requestedBaseUrl === "string" if (hasPreviewBaseUrl) { modelRequestSignal?.throwIfAborted() - lmStudioModels = await getLMStudioModels( - requestedBaseUrl, - ...(modelRequestSignal ? [{ signal: modelRequestSignal }] : []), + lmStudioModels = await resolveModelWithAbort( + () => discoverLmStudioPreview(requestedBaseUrl), + mergeAbortSignalAndTimeout(modelRequestSignal, 15_000), + "LM Studio", ) } else { const lmStudioOptions = { @@ -2387,9 +2403,12 @@ const handleWebviewMessage = async ( t("common:errors.violated_organization_allowlist"), ) } - await provider.providerSettingsManager.saveConfig(message.text, message.apiConfiguration) - const listApiConfig = await provider.providerSettingsManager.listConfig() - await updateGlobalState("listApiConfigMeta", listApiConfig) + await provider.upsertProviderProfile( + message.text, + message.apiConfiguration, + false, + message.apiConfigurationBaseline, + ) } catch (error) { provider.log( `Error save api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, @@ -2404,6 +2423,7 @@ const handleWebviewMessage = async ( break case "upsertApiConfiguration": if (message.text && message.apiConfiguration) { + let success = false try { // Enforce the organization allowlist at the persistence // boundary, not only in the webview, so a crafted or stale @@ -2419,7 +2439,13 @@ const handleWebviewMessage = async ( t("common:errors.violated_organization_allowlist"), ) } - await provider.upsertProviderProfile(message.text, message.apiConfiguration) + const savedId = await provider.upsertProviderProfile( + message.text, + message.apiConfiguration, + true, + message.apiConfigurationBaseline, + ) + success = savedId !== undefined } catch (error) { provider.log( `Error upsert api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, @@ -2429,15 +2455,31 @@ const handleWebviewMessage = async ( ? error.message : t("common:errors.save_api_config"), ) + } finally { + if (message.requestId) { + await provider.postMessageToWebview({ + type: "apiConfigurationSaved", + requestId: message.requestId, + success, + }) + } } + } else if (message.requestId) { + await provider.postMessageToWebview({ + type: "apiConfigurationSaved", + requestId: message.requestId, + success: false, + }) } break case "renameApiConfiguration": if (message.values && message.apiConfiguration) { + let success = false try { const { oldName, newName } = message.values if (oldName === newName) { + success = true break } @@ -2453,18 +2495,14 @@ const handleWebviewMessage = async ( ) } - // Load the old configuration to get its ID. - const { id } = await provider.providerSettingsManager.getProfile({ name: oldName }) - - // Create a new configuration with the new name and old ID. - await provider.providerSettingsManager.saveConfig(newName, { ...message.apiConfiguration, id }) - - // Delete the old configuration. - await provider.providerSettingsManager.deleteConfig(oldName) - - // Re-activate to update the global settings related to the - // currently activated provider profile. - await provider.activateProviderProfile({ name: newName }) + const savedId = await provider.upsertProviderProfile( + newName, + message.apiConfiguration, + true, + message.apiConfigurationBaseline, + oldName, + ) + success = savedId !== undefined } catch (error) { provider.log( `Error rename api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, @@ -2475,7 +2513,21 @@ const handleWebviewMessage = async ( ? error.message : t("common:errors.rename_api_config"), ) + } finally { + if (message.requestId) { + await provider.postMessageToWebview({ + type: "apiConfigurationSaved", + requestId: message.requestId, + success, + }) + } } + } else if (message.requestId) { + await provider.postMessageToWebview({ + type: "apiConfigurationSaved", + requestId: message.requestId, + success: false, + }) } break case "loadApiConfiguration": diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index 8c2a769af5..996ee2ed3f 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -311,6 +311,23 @@ describe("ProfileValidator", () => { expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) }) + it.each([undefined, {}, { vendor: "copilot" }, { family: "gpt-4" }])( + "rejects incomplete VS Code LM selector %j", + (vsCodeLmModelSelector) => { + expect( + ProfileValidator.isProfileAllowed( + { apiProvider: providerIdentifiers.vscodeLm, vsCodeLmModelSelector }, + { + allowAll: false, + providers: { + [providerIdentifiers.vscodeLm]: { allowAll: false, models: ["copilot/gpt-4"] }, + }, + }, + ), + ).toBe(false) + }, + ) + it("should extract lmStudioModelId for lmstudio provider", () => { const allowList: OrganizationAllowList = { allowAll: false, diff --git a/webview-ui/src/components/chat/ChatModelSelector.tsx b/webview-ui/src/components/chat/ChatModelSelector.tsx index 01656b4867..6296143971 100644 --- a/webview-ui/src/components/chat/ChatModelSelector.tsx +++ b/webview-ui/src/components/chat/ChatModelSelector.tsx @@ -96,6 +96,7 @@ export const ChatModelSelector = ({ disabled = false, title, triggerClassName = vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, + apiConfigurationBaseline: apiConfiguration, apiConfiguration: { ...apiConfiguration, [modelIdKey]: valueToStore, diff --git a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx index aee33eb834..82e2648137 100644 --- a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx @@ -1,12 +1,17 @@ import type { ReactNode } from "react" import userEvent from "@testing-library/user-event" import { render, screen, fireEvent } from "@/utils/test-utils" -import { providerIdentifiers } from "@roo-code/types" +import { isModelAllowedForOrganization, providerIdentifiers } from "@roo-code/types" import { vscode } from "@/utils/vscode" import { ChatModelSelector } from "../ChatModelSelector" import { useChatModelSelector } from "../hooks/useChatModelSelector" +vi.mock("@roo-code/types", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isModelAllowedForOrganization: vi.fn(actual.isModelAllowedForOrganization) } +}) + // Mock the dependencies vi.mock("@/utils/vscode", () => ({ vscode: { @@ -232,6 +237,7 @@ describe("ChatModelSelector", () => { await user.click(screen.getByTestId("chat-model-option-copilot/gpt-4o")) expect(vscode.postMessage).toHaveBeenCalledWith({ type: "upsertApiConfiguration", + apiConfigurationBaseline: mockUseExtensionState().apiConfiguration, text: "default", apiConfiguration: { apiProvider: providerIdentifiers.vscodeLm, @@ -251,6 +257,7 @@ describe("ChatModelSelector", () => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: "upsertApiConfiguration", + apiConfigurationBaseline: mockUseExtensionState().apiConfiguration, text: "default", apiConfiguration: { apiProvider: providerIdentifiers.anthropic, @@ -274,6 +281,7 @@ describe("ChatModelSelector", () => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: "upsertApiConfiguration", + apiConfigurationBaseline: mockUseExtensionState().apiConfiguration, text: "default", apiConfiguration: { apiProvider: providerIdentifiers.anthropic, @@ -459,6 +467,14 @@ describe("ChatModelSelector", () => { expect(screen.queryByTestId("chat-model-use-custom")).not.toBeInTheDocument() }) + test("rejects a rendered selection when the policy check denies it", () => { + render() + fireEvent.click(screen.getByTestId("chat-model-selector-trigger")) + vi.mocked(isModelAllowedForOrganization).mockReturnValueOnce(false) + fireEvent.click(screen.getByTestId("chat-model-option-claude-sonnet-4-20250514")) + expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "upsertApiConfiguration" })) + }) + test("filters out models that the organization policy disallows", () => { mockUseExtensionState.mockReturnValue({ apiConfiguration: { diff --git a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx index 2eb93bd3d1..45c798a4c1 100644 --- a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx +++ b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { anthropicDefaultModelId, + openRouterDefaultModelId, zooGatewayDefaultModelId, opencodeGoDefaultModelId, kenariDefaultModelId, @@ -120,6 +121,15 @@ describe("useChatModelSelector", () => { }) }) + it("defaults an unset provider to OpenRouter and configures its catalog query", () => { + mockUseExtensionState.mockReturnValue({ apiConfiguration: {} }) + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + expect(result.current.provider).toBe(providerIdentifiers.openrouter) + expect(result.current.modelIdKey).toBe("openRouterModelId") + expect(result.current.defaultModelId).toBe(openRouterDefaultModelId) + expect(mockUseRouterModels).toHaveBeenCalledWith({ provider: providerIdentifiers.openrouter, enabled: true }) + }) + describe("static providers", () => { it("returns static models for anthropic with apiModelId key", () => { const { result } = renderHook(() => useChatModelSelector(), { wrapper }) diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index d57cd004a9..ac9d038cc0 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -17,6 +17,7 @@ import { } from "@/components/ui" interface ApiConfigManagerProps { + disabled?: boolean currentApiConfigName?: string listApiConfigMeta?: ProviderSettingsEntry[] organizationAllowList?: OrganizationAllowList @@ -27,6 +28,7 @@ interface ApiConfigManagerProps { } const ApiConfigManager = ({ + disabled = false, currentApiConfigName = "", listApiConfigMeta = [], organizationAllowList, @@ -119,16 +121,18 @@ const ApiConfigManager = ({ }, [currentApiConfigName]) const handleSelectConfig = (configName: string) => { - if (!configName) return + if (disabled || !configName) return onSelectConfig(configName) } const handleAdd = () => { + if (disabled) return resetCreateState() setIsCreating(true) } const handleStartRename = () => { + if (disabled) return setIsRenaming(true) setInputValue(currentApiConfigName || "") setError(null) @@ -139,6 +143,7 @@ const ApiConfigManager = ({ } const handleSave = () => { + if (disabled) return const trimmedValue = inputValue.trim() const error = validateName(trimmedValue, false) @@ -159,6 +164,7 @@ const ApiConfigManager = ({ } const handleNewProfileSave = () => { + if (disabled) return const trimmedValue = newProfileName.trim() const error = validateName(trimmedValue, true) @@ -172,7 +178,7 @@ const ApiConfigManager = ({ } const handleDelete = () => { - if (!currentApiConfigName || !listApiConfigMeta || listApiConfigMeta.length <= 1) return + if (disabled || !currentApiConfigName || !listApiConfigMeta || listApiConfigMeta.length <= 1) return // Let the extension handle both deletion and selection. onDeleteConfig(currentApiConfigName) @@ -188,6 +194,7 @@ const ApiConfigManager = ({
{ @@ -209,7 +216,7 @@ const ApiConfigManager = ({ @@ -269,6 +282,7 @@ const ApiConfigManager = ({ @@ -313,6 +327,7 @@ const ApiConfigManager = ({ {t("settings:providers.newProfile")} { @@ -343,7 +358,7 @@ const ApiConfigManager = ({ @@ -773,6 +832,7 @@ const SettingsView = forwardRef(({ onDone, t
@@ -783,20 +843,14 @@ const SettingsView = forwardRef(({ onDone, t onDeleteConfig={(configName: string) => vscode.postMessage({ type: "deleteApiConfiguration", text: configName }) } - onRenameConfig={(oldName: string, newName: string) => { - vscode.postMessage({ + onRenameConfig={(oldName: string, newName: string) => + postApiConfiguration({ type: "renameApiConfiguration", values: { oldName, newName }, - apiConfiguration, }) - prevApiConfigName.current = newName - }} + } onUpsertConfig={(configName: string) => - vscode.postMessage({ - type: "upsertApiConfiguration", - text: configName, - apiConfiguration, - }) + postApiConfiguration({ type: "upsertApiConfiguration", text: configName }) } /> { const getRenameForm = () => screen.getByTestId("rename-form") const getDialogContent = () => screen.getByTestId("dialog-content") + it("disables profile actions while a save is pending", () => { + render() + for (const id of ["add-profile-button", "rename-profile-button", "delete-profile-button"]) { + expect(screen.getByTestId(id)).toBeDisabled() + fireEvent.click(screen.getByTestId(id)) + } + fireEvent.change(screen.getByTestId("select-component"), { target: { value: "Another Config" } }) + expect(mockOnSelectConfig).not.toHaveBeenCalled() + expect(mockOnDeleteConfig).not.toHaveBeenCalled() + expect(mockOnRenameConfig).not.toHaveBeenCalled() + expect(mockOnUpsertConfig).not.toHaveBeenCalled() + }) + + it.each(["rename", "create"])( + "retains a %s draft while disabled and submits after the pending save finishes", + (action) => { + const view = render() + fireEvent.click(screen.getByTestId(action === "rename" ? "rename-profile-button" : "add-profile-button")) + const input = + action === "rename" + ? screen.getByPlaceholderText("settings:providers.enterNewName") + : screen.getByTestId("new-profile-input") + fireEvent.input(input, { target: { value: "New Profile" } }) + view.rerender() + const submit = screen.getByTestId(action === "rename" ? "save-rename-button" : "create-profile-button") + expect(submit).toBeDisabled() + fireEvent.keyDown(input, { key: "Enter" }) + expect(mockOnRenameConfig).not.toHaveBeenCalled() + expect(mockOnUpsertConfig).not.toHaveBeenCalled() + expect(input).toHaveValue("New Profile") + view.rerender() + fireEvent.click(submit) + expect(action === "rename" ? mockOnRenameConfig : mockOnUpsertConfig).toHaveBeenCalledTimes(1) + if (action === "rename") expect(mockOnRenameConfig).toHaveBeenCalledWith("Default Config", "New Profile") + else expect(mockOnUpsertConfig).toHaveBeenCalledWith("New Profile") + }, + ) + it("opens new profile dialog when clicking add button", () => { render() diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx index 6704aa1cf0..83c09575a5 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx @@ -508,6 +508,8 @@ describe("SettingsView - Change Detection Fix", () => { fireEvent.click(screen.getByTestId("save-button")) expect(mockPostMessage).toHaveBeenCalledWith({ type: "upsertApiConfiguration", + requestId: expect.any(String), + apiConfigurationBaseline: expect.any(Object), text: "default", apiConfiguration: expect.objectContaining({ reasoningEffort: "high" }), }) @@ -560,6 +562,8 @@ describe("SettingsView - Change Detection Fix", () => { fireEvent.click(screen.getByTestId("save-button")) expect(mockPostMessage).toHaveBeenCalledWith({ type: "upsertApiConfiguration", + requestId: expect.any(String), + apiConfigurationBaseline: expect.any(Object), text: "default", apiConfiguration: expect.objectContaining({ apiProvider: providerIdentifiers.baseten, @@ -592,12 +596,23 @@ describe("SettingsView - Change Detection Fix", () => { }) expect(screen.getByTestId("provider-value")).toHaveTextContent("deepseek") + const saved = mockPostMessage.mock.calls + .map(([message]) => message) + .find((message) => message.type === "upsertApiConfiguration")! + fireEvent( + window, + new MessageEvent("message", { + data: { type: "apiConfigurationSaved", requestId: saved.requestId, success: true }, + }), + ) mockPostMessage.mockClear() fireEvent.click(screen.getByTestId("save-button")) expect(mockPostMessage).toHaveBeenCalledWith({ type: "upsertApiConfiguration", + requestId: expect.any(String), + apiConfigurationBaseline: expect.any(Object), text: "default", apiConfiguration: expect.objectContaining({ apiProvider: providerIdentifiers.deepseek, diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index 97b390f6f4..70e9a23ddd 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -447,6 +447,17 @@ describe("SettingsView - Sound Settings", () => { }), ) + const saved = vi + .mocked(vscode.postMessage) + .mock.calls.map(([message]) => message) + .find((message) => message.type === "upsertApiConfiguration")! + fireEvent( + window, + new MessageEvent("message", { + data: { type: "apiConfigurationSaved", requestId: saved.requestId, success: true }, + }), + ) + // Reset clears the override; it is persisted as null (not undefined). fireEvent.click(within(getSettingsContent()).getByTestId("chat-font-size-reset")) fireEvent.click(screen.getByTestId("save-button")) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index 575da5a833..ddb51da776 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -1,5 +1,5 @@ import { providerIdentifiers, openAiModelInfoSaneDefaults, type ProviderSettings } from "@roo-code/types" -import { screen, fireEvent, waitFor } from "@testing-library/react" +import { act, screen, fireEvent, waitFor } from "@testing-library/react" import { renderWithExtensionState } from "@/utils/test-utils" import { vi, describe, it, expect, beforeEach } from "vitest" @@ -249,6 +249,7 @@ vi.mock("../SettingsSearch", () => ({ import { useExtensionState } from "@src/context/ExtensionStateContext" import ApiOptions from "../ApiOptions" +import ApiConfigManager from "../ApiConfigManager" describe("SettingsView - Unsaved Changes Detection", () => { let queryClient: QueryClient @@ -320,6 +321,9 @@ describe("SettingsView - Unsaved Changes Detection", () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(ApiConfigManager).mockImplementation(() => ( +
ApiConfigManager
+ )) // Reset the ApiOptions mock to its default implementation vi.mocked(ApiOptions).mockImplementation(() => { // Don't do anything with props, just render a div @@ -674,7 +678,9 @@ describe("SettingsView - Unsaved Changes Detection", () => { expect(postMessage).toHaveBeenCalledWith({ type: "upsertApiConfiguration", + requestId: expect.any(String), text: "default", + apiConfigurationBaseline: liveApiConfiguration, apiConfiguration: { apiProvider: providerIdentifiers.nanogpt, nanoGptApiKey: "unsaved-key", @@ -682,6 +688,124 @@ describe("SettingsView - Unsaved Changes Detection", () => { nanoGptRoutingPreference: "tools", }, }) + const firstSave = postMessage.mock.calls + .map(([message]) => message) + .find((message) => message.type === "upsertApiConfiguration")! + fireEvent( + window, + new MessageEvent("message", { + data: { type: "apiConfigurationSaved", requestId: "unrelated", success: true }, + }), + ) + expect(screen.getByTestId("save-button")).toBeDisabled() + fireEvent( + window, + new MessageEvent("message", { + data: { type: "apiConfigurationSaved", requestId: firstSave.requestId, success: false }, + }), + ) + expect(screen.getByTestId("save-button")).toBeEnabled() + postMessage.mockClear() + fireEvent.click(screen.getByTestId("save-button")) + const retry = postMessage.mock.calls + .map(([message]) => message) + .find((message) => message.type === "upsertApiConfiguration")! + expect(retry.apiConfigurationBaseline).toEqual(liveApiConfiguration) + expect(retry.apiConfiguration).toEqual(firstSave.apiConfiguration) + fireEvent( + window, + new MessageEvent("message", { + data: { type: "apiConfigurationSaved", requestId: retry.requestId, success: true }, + }), + ) + postMessage.mockClear() + fireEvent.change(screen.getByTestId("cached-nanogpt-model"), { target: { value: "openai/original" } }) + fireEvent.click(screen.getByTestId("save-button")) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "upsertApiConfiguration", + apiConfigurationBaseline: expect.objectContaining({ nanoGptModelId: "openai/next" }), + apiConfiguration: expect.objectContaining({ nanoGptModelId: "openai/original" }), + }), + ) + }) + + it.each(["upsert", "rename"])("advances the baseline after a confirmed profile-manager %s", (operation) => { + vi.mocked(ApiOptions).mockImplementation(({ apiConfiguration, setApiConfigurationField }) => ( + setApiConfigurationField("modelTemperature", Number(event.target.value))} + /> + )) + vi.mocked(ApiConfigManager).mockImplementation(({ onRenameConfig, onUpsertConfig }) => ( + + )) + renderWithExtensionState(, { queryClient }) + fireEvent.change(screen.getByTestId("profile-temperature"), { target: { value: "0.5" } }) + fireEvent.click(screen.getByTestId("persist-profile")) + const first = postMessage.mock.calls + .map(([message]) => message) + .find( + (message) => + message.type === (operation === "rename" ? "renameApiConfiguration" : "upsertApiConfiguration"), + )! + expect(first.requestId).toEqual(expect.any(String)) + fireEvent( + window, + new MessageEvent("message", { + data: { type: "apiConfigurationSaved", requestId: first.requestId, success: true }, + }), + ) + postMessage.mockClear() + fireEvent.change(screen.getByTestId("profile-temperature"), { target: { value: "0.7" } }) + fireEvent.click(screen.getByTestId("save-button")) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "upsertApiConfiguration", + apiConfigurationBaseline: expect.objectContaining({ modelTemperature: 0.5 }), + apiConfiguration: expect.objectContaining({ modelTemperature: 0.7 }), + }), + ) + }) + + it("re-enables Save with the original baseline when its acknowledgement is lost", () => { + vi.mocked(ApiOptions).mockImplementation(({ setApiConfigurationField }) => ( + + )) + const view = renderWithExtensionState(, { queryClient }) + fireEvent.click(screen.getByTestId("edit-before-timeout")) + vi.useFakeTimers() + try { + fireEvent.click(screen.getByTestId("save-button")) + const first = postMessage.mock.calls + .map(([message]) => message) + .find((message) => message.type === "upsertApiConfiguration")! + expect(screen.getByTestId("save-button")).toBeDisabled() + expect(vi.mocked(ApiConfigManager).mock.lastCall?.[0].disabled).toBe(true) + act(() => vi.advanceTimersByTime(120_000)) + expect(vi.mocked(ApiConfigManager).mock.lastCall?.[0].disabled).toBe(false) + expect(screen.getByTestId("save-button")).toBeEnabled() + postMessage.mockClear() + fireEvent.click(screen.getByTestId("save-button")) + const retry = postMessage.mock.calls + .map(([message]) => message) + .find((message) => message.type === "upsertApiConfiguration")! + expect(retry.requestId).not.toBe(first.requestId) + expect(retry.apiConfigurationBaseline).toEqual(first.apiConfigurationBaseline) + expect(retry.apiConfiguration).toEqual(first.apiConfiguration) + } finally { + view.unmount() + vi.useRealTimers() + } }) it("keeps OpenAI-compatible reasoning edits cached until Save despite a live state refresh", async () => { @@ -714,7 +838,7 @@ describe("SettingsView - Unsaved Changes Detection", () => { vi.mocked(useExtensionState, { partial: true }).mockReturnValue({ ...defaultExtensionState, - apiConfiguration: { ...configuration }, + apiConfiguration: { ...configuration, openAiModelId: "concurrent-model" }, }) view.rerender() @@ -726,7 +850,9 @@ describe("SettingsView - Unsaved Changes Detection", () => { fireEvent.click(screen.getByTestId("save-button")) expect(postMessage).toHaveBeenCalledWith({ type: "upsertApiConfiguration", + requestId: expect.any(String), text: "default", + apiConfigurationBaseline: configuration, apiConfiguration: { ...configuration, enableReasoningEffort: true,