From d488b6dd6c30bacfa19c7b1d6eb462ccc946572b Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Wed, 5 Aug 2026 15:10:21 +0300 Subject: [PATCH 1/2] refactor(webview): canonicalize provider settings identifiers --- .../components/settings/providers/Kenari.tsx | 3 +- .../settings/providers/KimiCode.tsx | 7 +- .../components/settings/providers/LiteLLM.tsx | 14 +- .../settings/providers/Moonshot.tsx | 13 +- .../settings/providers/OpenCodeGo.tsx | 11 +- .../src/components/settings/providers/Poe.tsx | 6 +- .../settings/providers/Requesty.tsx | 8 +- .../components/settings/providers/Unbound.tsx | 6 +- .../settings/providers/VercelAiGateway.tsx | 3 +- .../settings/providers/ZooGateway.tsx | 3 +- .../providers/__tests__/KimiCode.spec.tsx | 75 ++++++++- .../providers/__tests__/LiteLLM.spec.tsx | 145 ++++++++++++++++++ .../providers/__tests__/Moonshot.spec.tsx | 93 +++++++++-- .../settings/providers/__tests__/Poe.spec.tsx | 115 ++++++++++++++ .../__tests__/ProviderRouting.spec.tsx | 82 ++++++++++ .../providers/__tests__/Requesty.spec.tsx | 57 +++++++ 16 files changed, 599 insertions(+), 42 deletions(-) create mode 100644 webview-ui/src/components/settings/providers/__tests__/LiteLLM.spec.tsx create mode 100644 webview-ui/src/components/settings/providers/__tests__/Poe.spec.tsx create mode 100644 webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx create mode 100644 webview-ui/src/components/settings/providers/__tests__/Requesty.spec.tsx diff --git a/webview-ui/src/components/settings/providers/Kenari.tsx b/webview-ui/src/components/settings/providers/Kenari.tsx index 577a44ac71..e8d2f5cdb9 100644 --- a/webview-ui/src/components/settings/providers/Kenari.tsx +++ b/webview-ui/src/components/settings/providers/Kenari.tsx @@ -6,6 +6,7 @@ import { type OrganizationAllowList, type RouterModels, kenariDefaultModelId, + providerIdentifiers, } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" @@ -69,7 +70,7 @@ export const Kenari = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} defaultModelId={kenariDefaultModelId} - models={routerModels?.["kenari"] ?? {}} + models={routerModels?.[providerIdentifiers.kenari] ?? {}} modelIdKey="kenariModelId" serviceName="Kenari" serviceUrl="https://kenari.id/docs" diff --git a/webview-ui/src/components/settings/providers/KimiCode.tsx b/webview-ui/src/components/settings/providers/KimiCode.tsx index 4e9d3ff561..de060ad5c5 100644 --- a/webview-ui/src/components/settings/providers/KimiCode.tsx +++ b/webview-ui/src/components/settings/providers/KimiCode.tsx @@ -7,6 +7,7 @@ import { type KimiCodeAuthMethod, type ModelRecord, type ProviderSettings, + providerIdentifiers, } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" @@ -37,10 +38,10 @@ export const KimiCode = ({ const { t } = useAppTranslation() const authMethod = apiConfiguration.kimiCodeAuthMethod ?? "oauth" const { data, refetch, isFetching } = useRouterModels({ - provider: "kimi-code", + provider: providerIdentifiers.kimiCode, enabled: authMethod === "oauth" ? kimiCodeIsAuthenticated : !!apiConfiguration.kimiCodeApiKey, }) - const discoveredModels = data?.["kimi-code"] + const discoveredModels = data?.[providerIdentifiers.kimiCode] const models: ModelRecord = discoveredModels && Object.keys(discoveredModels).length > 0 ? discoveredModels : kimiCodeModels @@ -52,7 +53,7 @@ export const KimiCode = ({ vscode.postMessage({ type: "requestRouterModels", values: { - provider: "kimi-code", + provider: providerIdentifiers.kimiCode, refresh: true, kimiCodeAuthMethod: authMethod, kimiCodeApiKey: apiConfiguration.kimiCodeApiKey, diff --git a/webview-ui/src/components/settings/providers/LiteLLM.tsx b/webview-ui/src/components/settings/providers/LiteLLM.tsx index 2a8dcb8d67..9688a48184 100644 --- a/webview-ui/src/components/settings/providers/LiteLLM.tsx +++ b/webview-ui/src/components/settings/providers/LiteLLM.tsx @@ -7,6 +7,7 @@ import { type OrganizationAllowList, type ExtensionMessage, litellmDefaultModelId, + providerIdentifiers, } from "@roo-code/types" import { RouterName } from "@roo/api" @@ -46,7 +47,7 @@ export const LiteLLM = ({ const message = event.data if (message.type === "singleRouterModelFetchResponse" && !message.success) { const providerName = message.values?.provider as RouterName - if (providerName === "litellm") { + if (providerName === providerIdentifiers.litellm) { litellmErrorJustReceived.current = true setRefreshStatus("error") setRefreshError(message.error) @@ -57,12 +58,11 @@ export const LiteLLM = ({ if (refreshStatus === "loading") { if (!litellmErrorJustReceived.current) { setRefreshStatus("success") - // Invalidate only the LiteLLM router-models query so useSelectedModel - // picks up the refreshed list. useSelectedModel reads LiteLLM under the - // compound key ["routerModels", "litellm"] (see useRouterModels), so we - // target that exact key rather than the bare ["routerModels"] prefix, - // which would needlessly invalidate every other provider's query too. - queryClient.invalidateQueries({ queryKey: ["routerModels", "litellm"] }) + // Refresh the provider-scoped cache used by useSelectedModel and the shared cache used by + // ApiOptions. Target both exact keys rather than the bare ["routerModels"] prefix, which + // would needlessly invalidate every other provider's query too. + void queryClient.invalidateQueries({ queryKey: ["routerModels", providerIdentifiers.litellm] }) + void queryClient.invalidateQueries({ queryKey: ["routerModels", "all"] }) } // If litellmErrorJustReceived.current is true, status is already (or will be) "error". } diff --git a/webview-ui/src/components/settings/providers/Moonshot.tsx b/webview-ui/src/components/settings/providers/Moonshot.tsx index 2d6c7d849a..ee974c2502 100644 --- a/webview-ui/src/components/settings/providers/Moonshot.tsx +++ b/webview-ui/src/components/settings/providers/Moonshot.tsx @@ -2,8 +2,12 @@ import { useCallback, useState, useEffect, useRef } from "react" import { VSCodeTextField, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react" import { useQueryClient } from "@tanstack/react-query" -import type { ProviderSettings, ExtensionMessage } from "@roo-code/types" -import { moonshotDefaultModelId } from "@roo-code/types" +import { + type ProviderSettings, + type ExtensionMessage, + moonshotDefaultModelId, + providerIdentifiers, +} from "@roo-code/types" import { RouterName } from "@roo/api" @@ -14,7 +18,6 @@ import { vscode } from "@src/utils/vscode" import { Button } from "@src/components/ui" import { ModelPicker } from "../ModelPicker" import { handleModelChangeSideEffects } from "../utils/providerModelConfig" -import type { ProviderName } from "@roo-code/types" import { inputEventTransform } from "../transforms" @@ -37,7 +40,7 @@ export const Moonshot = ({ apiConfiguration, setApiConfigurationField, simplifyS const message = event.data if (message.type === "singleRouterModelFetchResponse" && !message.success) { const providerName = message.values?.provider as RouterName - if (providerName === "moonshot" && refreshStatus === "loading") { + if (providerName === providerIdentifiers.moonshot && refreshStatus === "loading") { moonshotErrorJustReceived.current = true setRefreshStatus("error") setRefreshError(message.error) @@ -138,7 +141,7 @@ export const Moonshot = ({ apiConfiguration, setApiConfigurationField, simplifyS serviceUrl="https://platform.moonshot.ai" simplifySettings={simplifySettings} onModelChange={(modelId) => - handleModelChangeSideEffects("moonshot" as ProviderName, modelId, setApiConfigurationField) + handleModelChangeSideEffects(providerIdentifiers.moonshot, modelId, setApiConfigurationField) } /> + ), +})) + +vi.mock("../../ModelPicker", () => ({ + ModelPicker: () =>
, +})) + +describe("LiteLLM", () => { + const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } + + beforeEach(() => { + vi.clearAllMocks() + mockUseExtensionState.mockReturnValue({ routerModels: { [providerIdentifiers.litellm]: {} } }) + }) + + it("invalidates both LiteLLM caches after a successful model refresh", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.litellm, + litellmApiKey: "test-key", + litellmBaseUrl: "http://localhost:4000", + } + + render( + + + , + ) + + fireEvent.click(screen.getByTestId("refresh-button")) + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "routerModels" } })) + }) + + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["routerModels", providerIdentifiers.litellm], + }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["routerModels", "all"] }) + }) + }) + + it("recognizes failed refresh responses for the canonical LiteLLM provider", () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + , + ) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + values: { provider: providerIdentifiers.litellm }, + error: "LiteLLM unavailable", + }, + }), + ) + }) + + expect(screen.getByText("LiteLLM unavailable")).toBeInTheDocument() + }) + + it("ignores failed refresh responses for another provider", () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + , + ) + + fireEvent.click(screen.getByTestId("refresh-button")) + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + values: { provider: providerIdentifiers.openrouter }, + error: "OpenRouter unavailable", + }, + }), + ) + }) + + expect(screen.queryByText("OpenRouter unavailable")).not.toBeInTheDocument() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx index 1ffcf9b683..3265aef30d 100644 --- a/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx @@ -2,7 +2,7 @@ import React from "react" import { render, screen, fireEvent, waitFor, act } from "@/utils/test-utils" -import type { ProviderSettings } from "@roo-code/types" +import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" import { Moonshot } from "../Moonshot" @@ -47,13 +47,18 @@ vi.mock("@vscode/webview-ui-toolkit/react", async (importOriginal) => { }) // Mock the ModelPicker - must be a simple component that doesn't import anything -vi.mock("../ModelPicker", () => ({ - ModelPicker: function MockModelPicker() { +vi.mock("../../ModelPicker", () => ({ + ModelPicker: function MockModelPicker({ onModelChange }: { onModelChange?: (modelId: string) => void }) { return React.createElement( "div", { "data-testid": "model-picker" }, React.createElement("span", { "data-testid": "model-picker-default" }, "mock-default"), React.createElement("span", { "data-testid": "model-picker-count" }, "0"), + React.createElement( + "button", + { "data-testid": "change-model", onClick: () => onModelChange?.("moonshot-v1-128k") }, + "Change model", + ), ) }, })) @@ -103,11 +108,6 @@ vi.mock("@src/components/common/VSCodeButtonLink", () => ({ ), })) -// Mock handleModelChangeSideEffects -vi.mock("../utils/providerModelConfig", () => ({ - handleModelChangeSideEffects: vi.fn(), -})) - import { useExtensionState } from "@src/context/ExtensionStateContext" import { vscode } from "@src/utils/vscode" @@ -117,7 +117,7 @@ describe("Moonshot Component", () => { const mockSetApiConfigurationField = vi.fn() const createDefaultApiConfiguration = (overrides?: Partial): ProviderSettings => ({ - apiProvider: "moonshot", + apiProvider: providerIdentifiers.moonshot, moonshotBaseUrl: "https://api.moonshot.ai/v1", ...overrides, }) @@ -258,7 +258,7 @@ describe("Moonshot Component", () => { type: "singleRouterModelFetchResponse", success: false, error: "API connection failed", - values: { provider: "moonshot" }, + values: { provider: providerIdentifiers.moonshot }, }, "*", ) @@ -274,6 +274,77 @@ describe("Moonshot Component", () => { }) }) + it("ignores another provider's failed refresh response while loading", async () => { + render( + , + ) + + const refreshButton = screen + .getAllByTestId("button") + .find((button) => button.getAttribute("data-variant") === "outline")! + fireEvent.click(refreshButton) + await waitFor(() => expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument()) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + error: "OpenRouter unavailable", + values: { provider: providerIdentifiers.openrouter }, + }, + }), + ) + }) + + expect(screen.queryByText("OpenRouter unavailable")).not.toBeInTheDocument() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + it("ignores a Moonshot failure response before refresh starts", () => { + render( + , + ) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + error: "Moonshot unavailable", + values: { provider: providerIdentifiers.moonshot }, + }, + }), + ) + }) + + expect(screen.queryByText("Moonshot unavailable")).not.toBeInTheDocument() + expect(screen.queryByText("settings:providers.refreshModels.loading")).not.toBeInTheDocument() + }) + + it("resets model-specific settings when the selected model changes", () => { + render( + , + ) + + fireEvent.click(screen.getByTestId("change-model")) + + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", undefined) + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("modelMaxTokens", undefined) + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", undefined) + }) + it("race condition: error arrives before routerModels success — stays in error state", async () => { mockUseExtensionState.mockReturnValue({ routerModels: {}, @@ -308,7 +379,7 @@ describe("Moonshot Component", () => { type: "singleRouterModelFetchResponse", success: false, error: "API connection failed", - values: { provider: "moonshot" }, + values: { provider: providerIdentifiers.moonshot }, }, "*", ) diff --git a/webview-ui/src/components/settings/providers/__tests__/Poe.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Poe.spec.tsx new file mode 100644 index 0000000000..6595750cf9 --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/Poe.spec.tsx @@ -0,0 +1,115 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { act, fireEvent, render, screen } from "@testing-library/react" + +import { providerIdentifiers, type OrganizationAllowList, type ProviderSettings } from "@roo-code/types" + +import { Poe } from "../Poe" + +const { mockUseExtensionState } = vi.hoisted(() => ({ + mockUseExtensionState: vi.fn(), +})) + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: mockUseExtensionState, +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ + VSCodeButtonLink: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock("@src/components/ui", () => ({ + Button: ({ children, onClick, disabled }: React.ComponentProps<"button">) => ( + + ), +})) + +vi.mock("../../ModelPicker", () => ({ + ModelPicker: ({ onModelChange }: { onModelChange?: (modelId: string) => void }) => ( + + ), +})) + +describe("Poe", () => { + const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } + const setApiConfigurationField = vi.fn() + + const renderComponent = (apiConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.poe }) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + return render( + + + , + ) + } + + beforeEach(() => { + vi.clearAllMocks() + mockUseExtensionState.mockReturnValue({ routerModels: { [providerIdentifiers.poe]: {} } }) + }) + + it("shows the Poe refresh error returned by the extension", () => { + renderComponent({ apiProvider: providerIdentifiers.poe, poeApiKey: "test-key" }) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + values: { provider: providerIdentifiers.poe }, + error: "Poe authentication failed", + }, + }), + ) + }) + + expect(screen.getByText("Poe authentication failed")).toBeInTheDocument() + }) + + it("ignores failed refresh responses for another provider", () => { + renderComponent({ apiProvider: providerIdentifiers.poe, poeApiKey: "test-key" }) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + values: { provider: providerIdentifiers.openrouter }, + error: "OpenRouter authentication failed", + }, + }), + ) + }) + + expect(screen.queryByText("OpenRouter authentication failed")).not.toBeInTheDocument() + expect(screen.queryByText("settings:providers.refreshModels.error")).not.toBeInTheDocument() + }) + + it("clears model-specific reasoning settings when the Poe model changes", () => { + renderComponent() + + fireEvent.click(screen.getByTestId("model-picker")) + + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", undefined) + expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxTokens", undefined) + expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", undefined) + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx new file mode 100644 index 0000000000..e355baa08c --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen } from "@testing-library/react" + +import { providerIdentifiers, type OrganizationAllowList, type RouterModels } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +import { Unbound } from "../Unbound" +import { VercelAiGateway } from "../VercelAiGateway" + +const { modelPickerMock } = vi.hoisted(() => ({ modelPickerMock: vi.fn(() => null) })) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock("@src/components/ui", () => ({ + Button: ({ children, onClick }: React.ComponentProps<"button">) => , +})) + +vi.mock("../../ModelPicker", () => ({ ModelPicker: modelPickerMock })) +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ VSCodeButtonLink: () => null })) + +describe("provider model routing", () => { + const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } + + beforeEach(() => vi.clearAllMocks()) + + it("requests fresh Unbound models when the refresh button is clicked", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + + render( + , + ) + + fireEvent.click(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })) + + expect(postMessage).toHaveBeenCalledWith({ + type: "requestRouterModels", + values: { provider: providerIdentifiers.unbound, refresh: true }, + }) + }) + + it("passes Vercel AI Gateway models selected by its provider identifier to the model picker", () => { + const models = { "anthropic/claude": { contextWindow: 1, supportsPromptCache: false } } + const routerModels = Object.fromEntries( + Object.values(providerIdentifiers).map((provider) => [provider, {}]), + ) as RouterModels + routerModels[providerIdentifiers.vercelAiGateway] = models + + render( + , + ) + + expect(modelPickerMock).toHaveBeenCalledWith(expect.objectContaining({ models }), expect.anything()) + }) + + it("passes an empty model set when Vercel AI Gateway models are unavailable", () => { + render( + , + ) + + expect(modelPickerMock).toHaveBeenCalledWith(expect.objectContaining({ models: {} }), expect.anything()) + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/Requesty.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Requesty.spec.tsx new file mode 100644 index 0000000000..bb7d4e234f --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/Requesty.spec.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from "@testing-library/react" + +import { providerIdentifiers, type OrganizationAllowList } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +import { Requesty } from "../Requesty" + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children }: { children: React.ReactNode }) =>
{children}
, + VSCodeCheckbox: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock("@src/components/ui", () => ({ + Button: ({ children, onClick }: React.ComponentProps<"button">) => ( + + ), +})) + +vi.mock("../../ModelPicker", () => ({ ModelPicker: () => null })) +vi.mock("../RequestyBalanceDisplay", () => ({ RequestyBalanceDisplay: () => null })) + +describe("Requesty", () => { + const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } + + it("uses the canonical Requesty identifier for OAuth and model refresh", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + + render( + , + ) + + const href = screen.getByRole("link").getAttribute("href") + expect(href).not.toBeNull() + const callbackUrl = new URL(href!).searchParams.get("callback_url") + expect(callbackUrl).not.toBeNull() + expect(new URL(callbackUrl!).pathname).toMatch(new RegExp(`/${providerIdentifiers.requesty}$`)) + + fireEvent.click(screen.getByTestId("refresh-button")) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestRouterModels", + values: { provider: providerIdentifiers.requesty, refresh: true }, + }) + }) +}) From a6b23c3ac24800d3f6cec811ec774cdeffd79f8e Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Sun, 16 Aug 2026 23:41:13 +0300 Subject: [PATCH 2/2] refactor(webview): centralize model message identifiers --- .../src/__tests__/model-message-types.test.ts | 47 +++++++++++++++++ packages/types/src/__tests__/nanogpt.test.ts | 9 ++-- packages/types/src/model.ts | 15 ++++++ packages/types/src/providers/lm-studio.ts | 10 ++++ packages/types/src/providers/ollama.ts | 10 ++++ packages/types/src/providers/openai.ts | 10 ++++ packages/types/src/providers/vscode-llm.ts | 10 ++++ packages/types/src/vscode-extension-host.ts | 30 ++++++----- src/api/providers/__tests__/nanogpt.spec.ts | 8 +-- src/api/providers/nanogpt.ts | 3 +- src/core/webview/webviewMessageHandler.ts | 41 ++++++++------- .../src/components/settings/ApiOptions.tsx | 14 ++++-- .../settings/providers/KimiCode.tsx | 3 +- .../settings/providers/LMStudio.tsx | 9 +++- .../components/settings/providers/LiteLLM.tsx | 50 +++++++++++++------ .../settings/providers/Moonshot.tsx | 46 +++++++++++------ .../components/settings/providers/NanoGPT.tsx | 8 +-- .../components/settings/providers/Ollama.tsx | 41 +++++++++------ .../settings/providers/OpenAICompatible.tsx | 3 +- .../settings/providers/OpenCodeGo.tsx | 34 ++++++++----- .../src/components/settings/providers/Poe.tsx | 48 +++++++++++------- .../settings/providers/Requesty.tsx | 3 +- .../components/settings/providers/Unbound.tsx | 3 +- .../settings/providers/VSCodeLM.tsx | 9 +++- .../providers/__tests__/LiteLLM.spec.tsx | 46 ++++++++++++++++- .../providers/__tests__/Moonshot.spec.tsx | 35 ++++++++++++- .../providers/__tests__/NanoGPT.spec.tsx | 10 ++-- .../settings/providers/__tests__/Poe.spec.tsx | 39 ++++++++++++++- .../components/ui/hooks/useLmStudioModels.ts | 6 +-- .../components/ui/hooks/useOllamaModels.ts | 6 +-- .../components/ui/hooks/useRouterModels.ts | 15 ++++-- .../ui/hooks/useZooGatewayRouterModelsSync.ts | 13 +++-- .../src/context/ExtensionStateContext.tsx | 3 +- 33 files changed, 483 insertions(+), 154 deletions(-) create mode 100644 packages/types/src/__tests__/model-message-types.test.ts diff --git a/packages/types/src/__tests__/model-message-types.test.ts b/packages/types/src/__tests__/model-message-types.test.ts new file mode 100644 index 0000000000..453de78be1 --- /dev/null +++ b/packages/types/src/__tests__/model-message-types.test.ts @@ -0,0 +1,47 @@ +import { + OllamaModelsMessageType, + ollamaModelsMessageTypeSchema, + ollamaModelsMessageTypes, +} from "../providers/ollama.js" +import { OpenAiModelsMessageType, openAiModelsMessageTypeSchema } from "../providers/openai.js" +import { LmStudioModelsMessageType, lmStudioModelsMessageTypeSchema } from "../providers/lm-studio.js" +import { VsCodeLmModelsMessageType, vsCodeLmModelsMessageTypeSchema } from "../providers/vscode-llm.js" + +describe("OllamaModelsMessageType", () => { + it("exposes the request and response message types", () => { + expect(ollamaModelsMessageTypes).toEqual(["requestOllamaModels", "ollamaModels"]) + expect(OllamaModelsMessageType.requestOllamaModels).toBe("requestOllamaModels") + expect(OllamaModelsMessageType.ollamaModels).toBe("ollamaModels") + }) + + it("validates supported message types", () => { + expect(ollamaModelsMessageTypeSchema.safeParse("requestOllamaModels").success).toBe(true) + expect(ollamaModelsMessageTypeSchema.safeParse("ollamaModels").success).toBe(true) + expect(ollamaModelsMessageTypeSchema.safeParse("requestUnknownModels").success).toBe(false) + }) +}) + +describe.each([ + ["OpenAI", OpenAiModelsMessageType, openAiModelsMessageTypeSchema, "requestOpenAiModels", "openAiModels"], + [ + "LM Studio", + LmStudioModelsMessageType, + lmStudioModelsMessageTypeSchema, + "requestLmStudioModels", + "lmStudioModels", + ], + [ + "VS Code LM", + VsCodeLmModelsMessageType, + vsCodeLmModelsMessageTypeSchema, + "requestVsCodeLmModels", + "vsCodeLmModels", + ], +])("%s model message types", (_provider, messageType, schema, requestType, responseType) => { + it("exposes and validates its request and response types", () => { + expect(messageType).toMatchObject({ [requestType]: requestType, [responseType]: responseType }) + expect(schema.safeParse(requestType).success).toBe(true) + expect(schema.safeParse(responseType).success).toBe(true) + expect(schema.safeParse("unknownModelsMessage").success).toBe(false) + }) +}) diff --git a/packages/types/src/__tests__/nanogpt.test.ts b/packages/types/src/__tests__/nanogpt.test.ts index 50eacdd276..717d05ec59 100644 --- a/packages/types/src/__tests__/nanogpt.test.ts +++ b/packages/types/src/__tests__/nanogpt.test.ts @@ -13,13 +13,16 @@ import { describe("NanoGPT shared contract", () => { it("registers the stable dynamic-provider identity and default model", () => { expect(providerIdentifiers.nanogpt).toBe("nanogpt") - expect(dynamicProviders).toContain("nanogpt") - expect(getProviderDefaultModelId("nanogpt")).toBe(nanoGptDefaultModelId) + expect(dynamicProviders).toContain(providerIdentifiers.nanogpt) + expect(getProviderDefaultModelId(providerIdentifiers.nanogpt)).toBe(nanoGptDefaultModelId) }) it("classifies the API key as secret and resolves missing routing to auto", () => { expect(isSecretStateKey("nanoGptApiKey")).toBe(true) - const settings = providerSettingsSchema.parse({ apiProvider: "nanogpt", nanoGptModelId: "model" }) + const settings = providerSettingsSchema.parse({ + apiProvider: providerIdentifiers.nanogpt, + nanoGptModelId: "model", + }) expect(settings.nanoGptRoutingPreference ?? nanoGptDefaultRoutingPreference).toBe("auto") expect(getModelId(settings)).toBe("model") }) diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 9fbf9e358b..cedf2280b3 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -186,3 +186,18 @@ export type ModelInfo = z.infer export type ModelRecord = Record export type RouterModels = Record + +export const routerModelsMessageTypes = [ + "flushRouterModels", + "requestRouterModels", + "routerModels", + "singleRouterModelFetchResponse", +] as const + +export const routerModelsMessageTypeSchema = z.enum(routerModelsMessageTypes) + +export const RouterModelsMessageType = routerModelsMessageTypeSchema.enum + +export type RouterModelsMessageType = z.infer + +export const allRouterModelsProvider = "all" as const diff --git a/packages/types/src/providers/lm-studio.ts b/packages/types/src/providers/lm-studio.ts index d0df134470..5360178840 100644 --- a/packages/types/src/providers/lm-studio.ts +++ b/packages/types/src/providers/lm-studio.ts @@ -1,5 +1,15 @@ +import { z } from "zod" + import type { ModelInfo } from "../model.js" +export const lmStudioModelsMessageTypes = ["requestLmStudioModels", "lmStudioModels"] as const + +export const lmStudioModelsMessageTypeSchema = z.enum(lmStudioModelsMessageTypes) + +export const LmStudioModelsMessageType = lmStudioModelsMessageTypeSchema.enum + +export type LmStudioModelsMessageType = z.infer + export const LMSTUDIO_DEFAULT_TEMPERATURE = 0 // LM Studio diff --git a/packages/types/src/providers/ollama.ts b/packages/types/src/providers/ollama.ts index 160083511f..e97a480b88 100644 --- a/packages/types/src/providers/ollama.ts +++ b/packages/types/src/providers/ollama.ts @@ -1,7 +1,17 @@ +import { z } from "zod" + import type { ModelInfo } from "../model.js" // Ollama // https://ollama.com/models +export const ollamaModelsMessageTypes = ["requestOllamaModels", "ollamaModels"] as const + +export const ollamaModelsMessageTypeSchema = z.enum(ollamaModelsMessageTypes) + +export const OllamaModelsMessageType = ollamaModelsMessageTypeSchema.enum + +export type OllamaModelsMessageType = z.infer + export const ollamaDefaultModelId = "devstral:24b" export const ollamaDefaultModelInfo: ModelInfo = { maxTokens: 4096, diff --git a/packages/types/src/providers/openai.ts b/packages/types/src/providers/openai.ts index b090509bc2..acf5649624 100644 --- a/packages/types/src/providers/openai.ts +++ b/packages/types/src/providers/openai.ts @@ -1,6 +1,16 @@ +import { z } from "zod" + import type { ModelInfo } from "../model.js" // https://openai.com/api/pricing/ +export const openAiModelsMessageTypes = ["requestOpenAiModels", "openAiModels"] as const + +export const openAiModelsMessageTypeSchema = z.enum(openAiModelsMessageTypes) + +export const OpenAiModelsMessageType = openAiModelsMessageTypeSchema.enum + +export type OpenAiModelsMessageType = z.infer + export type OpenAiNativeModelId = keyof typeof openAiNativeModels export const OPENAI_API_PROTOCOL = "openai" diff --git a/packages/types/src/providers/vscode-llm.ts b/packages/types/src/providers/vscode-llm.ts index 5286b0ed28..7069f49f54 100644 --- a/packages/types/src/providers/vscode-llm.ts +++ b/packages/types/src/providers/vscode-llm.ts @@ -1,5 +1,15 @@ +import { z } from "zod" + import type { ModelInfo } from "../model.js" +export const vsCodeLmModelsMessageTypes = ["requestVsCodeLmModels", "vsCodeLmModels"] as const + +export const vsCodeLmModelsMessageTypeSchema = z.enum(vsCodeLmModelsMessageTypes) + +export const VsCodeLmModelsMessageType = vsCodeLmModelsMessageTypeSchema.enum + +export type VsCodeLmModelsMessageType = z.infer + export type VscodeLlmModelId = keyof typeof vscodeLlmModels export const vscodeLlmDefaultModelId: VscodeLlmModelId = "claude-sonnet-4.5" diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 63d5be87a8..ea52c09599 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -12,7 +12,11 @@ import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, import type { SerializedCustomToolDefinition } from "./custom-tool.js" import type { GitCommit } from "./git.js" import type { McpServer } from "./mcp.js" -import type { ModelRecord, RouterModels } from "./model.js" +import { RouterModelsMessageType, type ModelRecord, type RouterModels } from "./model.js" +import { LmStudioModelsMessageType } from "./providers/lm-studio.js" +import { OllamaModelsMessageType } from "./providers/ollama.js" +import { OpenAiModelsMessageType } from "./providers/openai.js" +import { VsCodeLmModelsMessageType } from "./providers/vscode-llm.js" import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js" import type { SkillMetadata } from "./skills.js" import type { RuleMetadata } from "./rules.js" @@ -38,12 +42,12 @@ export interface ExtensionMessage { | "enhancedPrompt" | "commitSearchResults" | "listApiConfig" - | "routerModels" + | typeof RouterModelsMessageType.routerModels | "zooGatewayCredentialsReady" - | "openAiModels" - | "ollamaModels" - | "lmStudioModels" - | "vsCodeLmModels" + | typeof OpenAiModelsMessageType.openAiModels + | typeof OllamaModelsMessageType.ollamaModels + | typeof LmStudioModelsMessageType.lmStudioModels + | typeof VsCodeLmModelsMessageType.vsCodeLmModels | "vsCodeLmApiAvailable" | "updatePrompt" | "systemPrompt" @@ -69,7 +73,7 @@ export interface ExtensionMessage { | "authenticatedUser" | "condenseTaskContextStarted" | "condenseTaskContextResponse" - | "singleRouterModelFetchResponse" + | typeof RouterModelsMessageType.singleRouterModelFetchResponse | "indexingStatusUpdate" | "indexCleared" | "codebaseIndexConfig" @@ -474,13 +478,13 @@ export interface WebviewMessage { | "importSettings" | "exportSettings" | "resetState" - | "flushRouterModels" - | "requestRouterModels" - | "requestOpenAiModels" - | "requestOllamaModels" - | "requestLmStudioModels" + | typeof RouterModelsMessageType.flushRouterModels + | typeof RouterModelsMessageType.requestRouterModels + | typeof OpenAiModelsMessageType.requestOpenAiModels + | typeof OllamaModelsMessageType.requestOllamaModels + | typeof LmStudioModelsMessageType.requestLmStudioModels | "requestRooModels" - | "requestVsCodeLmModels" + | typeof VsCodeLmModelsMessageType.requestVsCodeLmModels | "openImage" | "saveImage" | "openFile" diff --git a/src/api/providers/__tests__/nanogpt.spec.ts b/src/api/providers/__tests__/nanogpt.spec.ts index 878663f1e2..6ac9a17d61 100644 --- a/src/api/providers/__tests__/nanogpt.spec.ts +++ b/src/api/providers/__tests__/nanogpt.spec.ts @@ -5,7 +5,7 @@ vi.mock("vscode", () => ({ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { nanoGptDefaultModelId } from "@roo-code/types" +import { nanoGptDefaultModelId, providerIdentifiers } from "@roo-code/types" import { buildApiHandler } from "../../index" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" @@ -49,7 +49,7 @@ describe("NanoGptHandler", () => { }) it("is constructed by the backend provider registry", () => { - expect(buildApiHandler({ apiProvider: "nanogpt" })).toBeInstanceOf(NanoGptHandler) + expect(buildApiHandler({ apiProvider: providerIdentifiers.nanogpt })).toBeInstanceOf(NanoGptHandler) }) it("keeps the canonical model ID while applying request-only routing", async () => { @@ -187,7 +187,9 @@ describe("NanoGptHandler", () => { vi.mocked(getModels).mockResolvedValue({}) mockCreate.mockResolvedValue(asyncStreamFrom([])) await collectStream(new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages)) - expect(getModels).toHaveBeenLastCalledWith(expect.objectContaining({ provider: "nanogpt", apiKey: undefined })) + expect(getModels).toHaveBeenLastCalledWith( + expect.objectContaining({ provider: providerIdentifiers.nanogpt, apiKey: undefined }), + ) }) it("maps usage with root-field precedence and no reasoning double count", async () => { diff --git a/src/api/providers/nanogpt.ts b/src/api/providers/nanogpt.ts index 73ce199d23..38fb54c384 100644 --- a/src/api/providers/nanogpt.ts +++ b/src/api/providers/nanogpt.ts @@ -6,6 +6,7 @@ import { NANOGPT_BASE_URL, nanoGptDefaultModelId, nanoGptDefaultModelInfo, + providerIdentifiers, type NanoGptRoutingPreference, } from "@roo-code/types" @@ -58,7 +59,7 @@ export class NanoGptHandler extends RouterProvider implements SingleCompletionHa constructor(options: ApiHandlerOptions) { super({ options, - name: "nanogpt", + name: providerIdentifiers.nanogpt, baseURL: NANOGPT_BASE_URL, apiKey: options.nanoGptApiKey, modelId: options.nanoGptModelId, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index f0fc33501f..e88fd864cd 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -23,6 +23,11 @@ import { checkoutRestorePayloadSchema, getCompletionCheckpoint, providerIdentifiers, + LmStudioModelsMessageType, + OllamaModelsMessageType, + OpenAiModelsMessageType, + RouterModelsMessageType, + VsCodeLmModelsMessageType, } from "@roo-code/types" import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" @@ -1044,13 +1049,13 @@ export const webviewMessageHandler = async ( case "resetState": await provider.resetState() break - case "flushRouterModels": + case RouterModelsMessageType.flushRouterModels: const routerNameFlush: RouterName = toRouterName(message.text) // Note: flushRouterModels is a generic flush without credentials // For providers that need credentials, use their specific handlers await flushModels({ provider: routerNameFlush } as GetModelsOptions, true) break - case "requestRouterModels": { + case RouterModelsMessageType.requestRouterModels: { const { apiConfiguration } = await provider.getState() // Optional single provider filter from webview @@ -1225,12 +1230,12 @@ 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: "nanogpt", apiKey: nanoGptApiKey }, true) + await flushModels({ provider: providerIdentifiers.nanogpt, apiKey: nanoGptApiKey }, true) } candidates.push({ - key: "nanogpt", - options: { provider: "nanogpt", apiKey: nanoGptApiKey }, + key: providerIdentifiers.nanogpt, + options: { provider: providerIdentifiers.nanogpt, apiKey: nanoGptApiKey }, }) if (!providerFilter || providerFilter === "kimi-code") { @@ -1282,7 +1287,7 @@ export const webviewMessageHandler = async ( routerModels[routerName] = {} // Ensure it's an empty object in the main routerModels message. void provider.postMessageToWebview({ - type: "singleRouterModelFetchResponse", + type: RouterModelsMessageType.singleRouterModelFetchResponse, success: false, error: errorMessage, values: { provider: routerName }, @@ -1291,13 +1296,13 @@ export const webviewMessageHandler = async ( }) await provider.postMessageToWebview({ - type: "routerModels", + type: RouterModelsMessageType.routerModels, routerModels, values: providerFilter ? { provider: requestedProvider } : undefined, }) break } - case "requestOllamaModels": { + case OllamaModelsMessageType.requestOllamaModels: { // Specific handler for Ollama models only. const { apiConfiguration: ollamaApiConfig } = await provider.getState() // Prefer the baseUrl/apiKey from the message values (which reflect @@ -1321,7 +1326,7 @@ export const webviewMessageHandler = async ( const errorMsg = error instanceof Error ? error.message : String(error) provider.log(`[requestOllamaModels] Failed to refresh model cache for ${logBaseUrl}: ${errorMsg}`) await provider.postMessageToWebview({ - type: "ollamaModels", + type: OllamaModelsMessageType.ollamaModels, ollamaModels: {}, error: errorMsg, }) @@ -1333,19 +1338,19 @@ 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: "ollamaModels", ollamaModels }) + await provider.postMessageToWebview({ type: OllamaModelsMessageType.ollamaModels, ollamaModels }) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) provider.log(`[requestOllamaModels] Failed to read models for ${logBaseUrl}: ${errorMsg}`) await provider.postMessageToWebview({ - type: "ollamaModels", + type: OllamaModelsMessageType.ollamaModels, ollamaModels: {}, error: errorMsg, }) } break } - case "requestLmStudioModels": { + case LmStudioModelsMessageType.requestLmStudioModels: { // Specific handler for LM Studio models only. const { apiConfiguration: lmStudioApiConfig } = await provider.getState() try { @@ -1366,7 +1371,7 @@ export const webviewMessageHandler = async ( if (Object.keys(lmStudioModels).length > 0) { await provider.postMessageToWebview({ - type: "lmStudioModels", + type: LmStudioModelsMessageType.lmStudioModels, lmStudioModels: lmStudioModels, }) } @@ -1378,14 +1383,14 @@ export const webviewMessageHandler = async ( } case "requestRooModels": { await provider.postMessageToWebview({ - type: "singleRouterModelFetchResponse", + type: RouterModelsMessageType.singleRouterModelFetchResponse, success: false, error: getRouterRemovalMessage(), values: { provider: "roo" }, }) break } - case "requestOpenAiModels": + case OpenAiModelsMessageType.requestOpenAiModels: if (message?.values?.baseUrl && message?.values?.apiKey) { const openAiModels = await getOpenAiModels( message?.values?.baseUrl, @@ -1393,14 +1398,14 @@ export const webviewMessageHandler = async ( message?.values?.openAiHeaders, ) - await provider.postMessageToWebview({ type: "openAiModels", openAiModels }) + await provider.postMessageToWebview({ type: OpenAiModelsMessageType.openAiModels, openAiModels }) } break - case "requestVsCodeLmModels": + case VsCodeLmModelsMessageType.requestVsCodeLmModels: const vsCodeLmModels = await getVsCodeLmModels() // TODO: Cache like we do for OpenRouter, etc? - await provider.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels }) + await provider.postMessageToWebview({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels }) break case "openImage": await openImage(message.text!, { values: message.values }) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index f3375abfaf..3e1495baff 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -10,6 +10,10 @@ import { isRetiredProvider, providerIdentifiers, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, + OllamaModelsMessageType, + OpenAiModelsMessageType, + RouterModelsMessageType, + VsCodeLmModelsMessageType, } from "@roo-code/types" import { @@ -214,7 +218,7 @@ const ApiOptions = ({ const headerObject = convertHeadersToObject(customHeaders) vscode.postMessage({ - type: "requestOpenAiModels", + type: OpenAiModelsMessageType.requestOpenAiModels, values: { baseUrl: apiConfiguration?.openAiBaseUrl, apiKey: apiConfiguration?.openAiApiKey, @@ -224,7 +228,7 @@ const ApiOptions = ({ }) } else if (selectedProvider === providerIdentifiers.ollama) { vscode.postMessage({ - type: "requestOllamaModels", + type: OllamaModelsMessageType.requestOllamaModels, values: { baseUrl: apiConfiguration?.ollamaBaseUrl, apiKey: apiConfiguration?.ollamaApiKey, @@ -233,17 +237,17 @@ const ApiOptions = ({ } else if (selectedProvider === providerIdentifiers.lmstudio) { requestLmStudioModels(apiConfiguration?.lmStudioBaseUrl) } else if (selectedProvider === providerIdentifiers.vscodeLm) { - vscode.postMessage({ type: "requestVsCodeLmModels" }) + vscode.postMessage({ type: VsCodeLmModelsMessageType.requestVsCodeLmModels }) } else if (selectedProvider === providerIdentifiers.litellm) { vscode.postMessage({ - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, values: { litellmApiKey: apiConfiguration?.litellmApiKey, litellmBaseUrl: apiConfiguration?.litellmBaseUrl, }, }) } else if (selectedProvider === providerIdentifiers.poe) { - vscode.postMessage({ type: "requestRouterModels" }) + vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels }) } }, 250, diff --git a/webview-ui/src/components/settings/providers/KimiCode.tsx b/webview-ui/src/components/settings/providers/KimiCode.tsx index de060ad5c5..a0350c9471 100644 --- a/webview-ui/src/components/settings/providers/KimiCode.tsx +++ b/webview-ui/src/components/settings/providers/KimiCode.tsx @@ -8,6 +8,7 @@ import { type ModelRecord, type ProviderSettings, providerIdentifiers, + RouterModelsMessageType, } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" @@ -51,7 +52,7 @@ export const KimiCode = ({ const refreshModels = () => { vscode.postMessage({ - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, values: { provider: providerIdentifiers.kimiCode, refresh: true, diff --git a/webview-ui/src/components/settings/providers/LMStudio.tsx b/webview-ui/src/components/settings/providers/LMStudio.tsx index 786c3f4474..64c12606c1 100644 --- a/webview-ui/src/components/settings/providers/LMStudio.tsx +++ b/webview-ui/src/components/settings/providers/LMStudio.tsx @@ -4,7 +4,12 @@ import { Trans } from "react-i18next" import { Checkbox } from "vscrui" import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import type { ProviderSettings, ExtensionMessage, ModelRecord } from "@roo-code/types" +import { + type ProviderSettings, + type ExtensionMessage, + type ModelRecord, + LmStudioModelsMessageType, +} from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" import { requestLmStudioModels } from "@src/components/ui/hooks/useLmStudioModels" @@ -40,7 +45,7 @@ export const LMStudio = ({ apiConfiguration, setApiConfigurationField }: LMStudi const message: ExtensionMessage = event.data switch (message.type) { - case "lmStudioModels": + case LmStudioModelsMessageType.lmStudioModels: { const newModels = message.lmStudioModels ?? {} setLmStudioModels(newModels) diff --git a/webview-ui/src/components/settings/providers/LiteLLM.tsx b/webview-ui/src/components/settings/providers/LiteLLM.tsx index 9688a48184..5f3b7dc27b 100644 --- a/webview-ui/src/components/settings/providers/LiteLLM.tsx +++ b/webview-ui/src/components/settings/providers/LiteLLM.tsx @@ -8,6 +8,8 @@ import { type ExtensionMessage, litellmDefaultModelId, providerIdentifiers, + allRouterModelsProvider, + RouterModelsMessageType, } from "@roo-code/types" import { RouterName } from "@roo/api" @@ -28,6 +30,13 @@ type LiteLLMProps = { simplifySettings?: boolean } +enum RefreshStatus { + Idle = "idle", + Loading = "loading", + Success = "success", + Error = "error", +} + export const LiteLLM = ({ apiConfiguration, setApiConfigurationField, @@ -38,31 +47,35 @@ export const LiteLLM = ({ const { t } = useAppTranslation() const queryClient = useQueryClient() const { routerModels } = useExtensionState() - const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshStatus, setRefreshStatus] = useState(RefreshStatus.Idle) const [refreshError, setRefreshError] = useState() const litellmErrorJustReceived = useRef(false) useEffect(() => { const handleMessage = (event: MessageEvent) => { const message = event.data - if (message.type === "singleRouterModelFetchResponse" && !message.success) { + if (message.type === RouterModelsMessageType.singleRouterModelFetchResponse && !message.success) { const providerName = message.values?.provider as RouterName if (providerName === providerIdentifiers.litellm) { litellmErrorJustReceived.current = true - setRefreshStatus("error") + setRefreshStatus(RefreshStatus.Error) setRefreshError(message.error) } - } else if (message.type === "routerModels") { + } else if (message.type === RouterModelsMessageType.routerModels) { // If we were loading and no specific error for litellm was just received, mark as success. // The ModelPicker will show available models or "no models found". - if (refreshStatus === "loading") { + if (refreshStatus === RefreshStatus.Loading) { if (!litellmErrorJustReceived.current) { - setRefreshStatus("success") + setRefreshStatus(RefreshStatus.Success) // Refresh the provider-scoped cache used by useSelectedModel and the shared cache used by // ApiOptions. Target both exact keys rather than the bare ["routerModels"] prefix, which // would needlessly invalidate every other provider's query too. - void queryClient.invalidateQueries({ queryKey: ["routerModels", providerIdentifiers.litellm] }) - void queryClient.invalidateQueries({ queryKey: ["routerModels", "all"] }) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, providerIdentifiers.litellm], + }) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, allRouterModelsProvider], + }) } // If litellmErrorJustReceived.current is true, status is already (or will be) "error". } @@ -88,19 +101,22 @@ export const LiteLLM = ({ const handleRefreshModels = useCallback(() => { litellmErrorJustReceived.current = false // Reset flag on new refresh action - setRefreshStatus("loading") + setRefreshStatus(RefreshStatus.Loading) setRefreshError(undefined) const key = apiConfiguration.litellmApiKey const url = apiConfiguration.litellmBaseUrl if (!key || !url) { - setRefreshStatus("error") + setRefreshStatus(RefreshStatus.Error) setRefreshError(t("settings:providers.refreshModels.missingConfig")) return } - vscode.postMessage({ type: "requestRouterModels", values: { litellmApiKey: key, litellmBaseUrl: url } }) + vscode.postMessage({ + type: RouterModelsMessageType.requestRouterModels, + values: { litellmApiKey: key, litellmBaseUrl: url }, + }) }, [apiConfiguration, setRefreshStatus, setRefreshError, t]) return ( @@ -130,11 +146,13 @@ export const LiteLLM = ({ variant="outline" onClick={handleRefreshModels} disabled={ - refreshStatus === "loading" || !apiConfiguration.litellmApiKey || !apiConfiguration.litellmBaseUrl + refreshStatus === RefreshStatus.Loading || + !apiConfiguration.litellmApiKey || + !apiConfiguration.litellmBaseUrl } className="w-full">
- {refreshStatus === "loading" ? ( + {refreshStatus === RefreshStatus.Loading ? ( ) : ( @@ -142,15 +160,15 @@ export const LiteLLM = ({ {t("settings:providers.refreshModels.label")}
- {refreshStatus === "loading" && ( + {refreshStatus === RefreshStatus.Loading && (
{t("settings:providers.refreshModels.loading")}
)} - {refreshStatus === "success" && ( + {refreshStatus === RefreshStatus.Success && (
{t("settings:providers.refreshModels.success")}
)} - {refreshStatus === "error" && ( + {refreshStatus === RefreshStatus.Error && (
{refreshError || t("settings:providers.refreshModels.error")}
diff --git a/webview-ui/src/components/settings/providers/Moonshot.tsx b/webview-ui/src/components/settings/providers/Moonshot.tsx index ee974c2502..ed561f2b9f 100644 --- a/webview-ui/src/components/settings/providers/Moonshot.tsx +++ b/webview-ui/src/components/settings/providers/Moonshot.tsx @@ -7,6 +7,8 @@ import { type ExtensionMessage, moonshotDefaultModelId, providerIdentifiers, + allRouterModelsProvider, + RouterModelsMessageType, } from "@roo-code/types" import { RouterName } from "@roo/api" @@ -27,29 +29,41 @@ type MoonshotProps = { simplifySettings?: boolean } +enum RefreshStatus { + Idle = "idle", + Loading = "loading", + Success = "success", + Error = "error", +} + export const Moonshot = ({ apiConfiguration, setApiConfigurationField, simplifySettings }: MoonshotProps) => { const { t } = useAppTranslation() const { routerModels } = useExtensionState() const queryClient = useQueryClient() - const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshStatus, setRefreshStatus] = useState(RefreshStatus.Idle) const [refreshError, setRefreshError] = useState() const moonshotErrorJustReceived = useRef(false) useEffect(() => { const handleMessage = (event: MessageEvent) => { const message = event.data - if (message.type === "singleRouterModelFetchResponse" && !message.success) { + if (message.type === RouterModelsMessageType.singleRouterModelFetchResponse && !message.success) { const providerName = message.values?.provider as RouterName - if (providerName === providerIdentifiers.moonshot && refreshStatus === "loading") { + if (providerName === providerIdentifiers.moonshot && refreshStatus === RefreshStatus.Loading) { moonshotErrorJustReceived.current = true - setRefreshStatus("error") + setRefreshStatus(RefreshStatus.Error) setRefreshError(message.error) } - } else if (message.type === "routerModels") { - if (refreshStatus === "loading") { + } else if (message.type === RouterModelsMessageType.routerModels) { + if (refreshStatus === RefreshStatus.Loading) { if (!moonshotErrorJustReceived.current) { - setRefreshStatus("success") - queryClient.invalidateQueries({ queryKey: ["routerModels"] }) + setRefreshStatus(RefreshStatus.Success) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, providerIdentifiers.moonshot], + }) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, allRouterModelsProvider], + }) } } } @@ -74,19 +88,19 @@ export const Moonshot = ({ apiConfiguration, setApiConfigurationField, simplifyS const handleRefreshModels = useCallback(() => { moonshotErrorJustReceived.current = false - setRefreshStatus("loading") + setRefreshStatus(RefreshStatus.Loading) setRefreshError(undefined) const key = apiConfiguration.moonshotApiKey if (!key) { - setRefreshStatus("error") + setRefreshStatus(RefreshStatus.Error) setRefreshError(t("settings:providers.refreshModels.missingConfig")) return } vscode.postMessage({ - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, values: { moonshotApiKey: key, moonshotBaseUrl: apiConfiguration.moonshotBaseUrl }, }) }, [apiConfiguration, t]) @@ -147,9 +161,9 @@ export const Moonshot = ({ apiConfiguration, setApiConfigurationField, simplifyS - {refreshStatus === "loading" && ( + {refreshStatus === RefreshStatus.Loading && (
{t("settings:providers.refreshModels.loading")}
)} - {refreshStatus === "success" && ( + {refreshStatus === RefreshStatus.Success && (
{t("settings:providers.refreshModels.success")}
)} - {refreshStatus === "error" && ( + {refreshStatus === RefreshStatus.Error && (
{refreshError || t("settings:providers.refreshModels.error")}
diff --git a/webview-ui/src/components/settings/providers/NanoGPT.tsx b/webview-ui/src/components/settings/providers/NanoGPT.tsx index cc7b7a431f..11e0005604 100644 --- a/webview-ui/src/components/settings/providers/NanoGPT.tsx +++ b/webview-ui/src/components/settings/providers/NanoGPT.tsx @@ -9,6 +9,8 @@ import { nanoGptDefaultModelId, nanoGptDefaultRoutingPreference, nanoGptRoutingPreferences, + providerIdentifiers, + RouterModelsMessageType, } from "@roo-code/types" import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" @@ -62,9 +64,9 @@ export const NanoGPT = ({ useEffect(() => { vscode.postMessage({ - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, values: { - provider: "nanogpt", + provider: providerIdentifiers.nanogpt, nanoGptApiKey: apiConfiguration.nanoGptApiKey, }, }) @@ -93,7 +95,7 @@ export const NanoGPT = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} defaultModelId={nanoGptDefaultModelId} - models={routerModels?.nanogpt ?? {}} + models={routerModels?.[providerIdentifiers.nanogpt] ?? {}} modelIdKey="nanoGptModelId" serviceName={t("settings:providers.nanoGpt.provider")} serviceUrl="https://nano-gpt.com/api" diff --git a/webview-ui/src/components/settings/providers/Ollama.tsx b/webview-ui/src/components/settings/providers/Ollama.tsx index 9b11c85369..8d1e7348f4 100644 --- a/webview-ui/src/components/settings/providers/Ollama.tsx +++ b/webview-ui/src/components/settings/providers/Ollama.tsx @@ -2,7 +2,13 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { Checkbox } from "vscrui" -import { type ProviderSettings, type ExtensionMessage, type ModelRecord, ollamaDefaultModelInfo } from "@roo-code/types" +import { + type ProviderSettings, + type ExtensionMessage, + type ModelRecord, + ollamaDefaultModelInfo, + OllamaModelsMessageType, +} from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" import { useRouterModels } from "@src/components/ui/hooks/useRouterModels" @@ -18,11 +24,18 @@ type OllamaProps = { setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void } +enum RefreshStatus { + Idle = "idle", + Loading = "loading", + Success = "success", + Error = "error", +} + export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaProps) => { const { t } = useAppTranslation() const [ollamaModels, setOllamaModels] = useState({}) - const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshStatus, setRefreshStatus] = useState(RefreshStatus.Idle) const [refreshError, setRefreshError] = useState() const refreshStatusRef = useRef(refreshStatus) const routerModels = useRouterModels() @@ -42,13 +55,13 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro const handleMessage = (event: MessageEvent) => { const message: ExtensionMessage = event.data - if (message.type === "ollamaModels") { + if (message.type === OllamaModelsMessageType.ollamaModels) { if (!message.error) { setOllamaModels(message.ollamaModels ?? {}) } - if (refreshStatusRef.current === "loading") { - const nextStatus = message.error ? "error" : "success" + if (refreshStatusRef.current === RefreshStatus.Loading) { + const nextStatus = message.error ? RefreshStatus.Error : RefreshStatus.Success refreshStatusRef.current = nextStatus setRefreshStatus(nextStatus) setRefreshError(message.error) @@ -63,11 +76,11 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro }, []) const handleRefreshModels = useCallback(() => { - refreshStatusRef.current = "loading" - setRefreshStatus("loading") + refreshStatusRef.current = RefreshStatus.Loading + setRefreshStatus(RefreshStatus.Loading) setRefreshError(undefined) vscode.postMessage({ - type: "requestOllamaModels", + type: OllamaModelsMessageType.requestOllamaModels, values: { baseUrl: apiConfiguration?.ollamaBaseUrl, apiKey: apiConfiguration?.ollamaApiKey, @@ -78,7 +91,7 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro // Refresh models on mount useEffect(() => { // Request fresh models - the handler now flushes cache automatically - vscode.postMessage({ type: "requestOllamaModels" }) + vscode.postMessage({ type: OllamaModelsMessageType.requestOllamaModels }) }, []) // Check if the selected model exists in the fetched models @@ -130,10 +143,10 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro - {refreshStatus === "loading" && ( + {refreshStatus === RefreshStatus.Loading && (
{t("settings:providers.refreshModels.loading")}
)} - {refreshStatus === "success" && ( + {refreshStatus === RefreshStatus.Success && (
{t("settings:providers.refreshModels.success")}
)} - {refreshStatus === "error" && ( + {refreshStatus === RefreshStatus.Error && (
{refreshError || t("settings:providers.refreshModels.error")}
diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index f9a021812b..7870b21f32 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -12,6 +12,7 @@ import { azureOpenAiDefaultApiVersion, isAzureOpenAiBaseUrl, openAiModelInfoSaneDefaults, + OpenAiModelsMessageType, } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" @@ -115,7 +116,7 @@ export const OpenAICompatible = ({ const message: ExtensionMessage = event.data switch (message.type) { - case "openAiModels": { + case OpenAiModelsMessageType.openAiModels: { const updatedModels = message.openAiModels ?? [] setOpenAiModels(Object.fromEntries(updatedModels.map((item) => [item, openAiModelInfoSaneDefaults]))) break diff --git a/webview-ui/src/components/settings/providers/OpenCodeGo.tsx b/webview-ui/src/components/settings/providers/OpenCodeGo.tsx index b8d9bb0105..249e2de534 100644 --- a/webview-ui/src/components/settings/providers/OpenCodeGo.tsx +++ b/webview-ui/src/components/settings/providers/OpenCodeGo.tsx @@ -8,6 +8,7 @@ import { type ExtensionMessage, opencodeGoDefaultModelId, providerIdentifiers, + RouterModelsMessageType, } from "@roo-code/types" import type { RouterName } from "@roo/api" @@ -29,6 +30,13 @@ type OpenCodeGoProps = { simplifySettings?: boolean } +enum RefreshStatus { + Idle = "idle", + Loading = "loading", + Success = "success", + Error = "error", +} + export const OpenCodeGo = ({ apiConfiguration, setApiConfigurationField, @@ -38,24 +46,24 @@ export const OpenCodeGo = ({ simplifySettings, }: OpenCodeGoProps) => { const { t } = useAppTranslation() - const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshStatus, setRefreshStatus] = useState(RefreshStatus.Idle) const [refreshError, setRefreshError] = useState() const errorJustReceived = useRef(false) useEffect(() => { const handleMessage = (event: MessageEvent) => { const message = event.data - if (message.type === "singleRouterModelFetchResponse" && !message.success) { + if (message.type === RouterModelsMessageType.singleRouterModelFetchResponse && !message.success) { const providerName = message.values?.provider as RouterName if (providerName === providerIdentifiers.opencodeGo) { errorJustReceived.current = true - setRefreshStatus("error") + setRefreshStatus(RefreshStatus.Error) setRefreshError(message.error) } - } else if (message.type === "routerModels") { - if (refreshStatus === "loading") { + } else if (message.type === RouterModelsMessageType.routerModels) { + if (refreshStatus === RefreshStatus.Loading) { if (!errorJustReceived.current) { - setRefreshStatus("success") + setRefreshStatus(RefreshStatus.Success) } } } @@ -80,10 +88,10 @@ export const OpenCodeGo = ({ const handleRefreshModels = useCallback(() => { errorJustReceived.current = false - setRefreshStatus("loading") + setRefreshStatus(RefreshStatus.Loading) setRefreshError(undefined) vscode.postMessage({ - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, values: { provider: providerIdentifiers.opencodeGo, refresh: true, @@ -113,10 +121,10 @@ export const OpenCodeGo = ({ - {refreshStatus === "loading" && ( + {refreshStatus === RefreshStatus.Loading && (
{t("settings:providers.refreshModels.loading")}
)} - {refreshStatus === "success" && ( + {refreshStatus === RefreshStatus.Success && (
{t("settings:providers.refreshModels.success")}
)} - {refreshStatus === "error" && ( + {refreshStatus === RefreshStatus.Error && (
{refreshError || t("settings:providers.refreshModels.error")}
diff --git a/webview-ui/src/components/settings/providers/Poe.tsx b/webview-ui/src/components/settings/providers/Poe.tsx index e5f22bfcf0..b549b8aae1 100644 --- a/webview-ui/src/components/settings/providers/Poe.tsx +++ b/webview-ui/src/components/settings/providers/Poe.tsx @@ -8,6 +8,8 @@ import { type ExtensionMessage, poeDefaultModelId, providerIdentifiers, + allRouterModelsProvider, + RouterModelsMessageType, } from "@roo-code/types" import { RouterName } from "@roo/api" @@ -30,6 +32,13 @@ type PoeProps = { simplifySettings?: boolean } +enum RefreshStatus { + Idle = "idle", + Loading = "loading", + Success = "success", + Error = "error", +} + export const Poe = ({ apiConfiguration, setApiConfigurationField, @@ -40,27 +49,32 @@ export const Poe = ({ const { t } = useAppTranslation() const queryClient = useQueryClient() const { routerModels } = useExtensionState() - const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshStatus, setRefreshStatus] = useState(RefreshStatus.Idle) const [refreshError, setRefreshError] = useState() const poeErrorJustReceived = useRef(false) useEffect(() => { const handleMessage = (event: MessageEvent) => { const message = event.data - if (message.type === "singleRouterModelFetchResponse" && !message.success) { + if (message.type === RouterModelsMessageType.singleRouterModelFetchResponse && !message.success) { const providerName = message.values?.provider as RouterName if (providerName === providerIdentifiers.poe) { poeErrorJustReceived.current = true - setRefreshStatus("error") + setRefreshStatus(RefreshStatus.Error) setRefreshError(message.error) } - } else if (message.type === "routerModels") { - if (refreshStatus === "loading") { + } else if (message.type === RouterModelsMessageType.routerModels) { + if (refreshStatus === RefreshStatus.Loading) { if (!poeErrorJustReceived.current) { - setRefreshStatus("success") - // Invalidate the react-query router models cache so - // validation in ApiOptions picks up the refreshed list. - queryClient.invalidateQueries({ queryKey: ["routerModels"] }) + setRefreshStatus(RefreshStatus.Success) + // Refresh the provider-scoped cache used by useSelectedModel and the shared cache used by + // ApiOptions without invalidating every other provider's query. + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, providerIdentifiers.poe], + }) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, allRouterModelsProvider], + }) } } } @@ -85,19 +99,19 @@ export const Poe = ({ const handleRefreshModels = useCallback(() => { poeErrorJustReceived.current = false - setRefreshStatus("loading") + setRefreshStatus(RefreshStatus.Loading) setRefreshError(undefined) const key = apiConfiguration.poeApiKey if (!key) { - setRefreshStatus("error") + setRefreshStatus(RefreshStatus.Error) setRefreshError(t("settings:providers.refreshModels.missingConfig")) return } vscode.postMessage({ - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, values: { poeApiKey: key, poeBaseUrl: apiConfiguration.poeBaseUrl }, }) }, [apiConfiguration, t]) @@ -123,9 +137,9 @@ export const Poe = ({ - {refreshStatus === "loading" && ( + {refreshStatus === RefreshStatus.Loading && (
{t("settings:providers.refreshModels.loading")}
)} - {refreshStatus === "success" && ( + {refreshStatus === RefreshStatus.Success && (
{t("settings:providers.refreshModels.success")}
)} - {refreshStatus === "error" && ( + {refreshStatus === RefreshStatus.Error && (
{refreshError || t("settings:providers.refreshModels.error")}
diff --git a/webview-ui/src/components/settings/providers/Requesty.tsx b/webview-ui/src/components/settings/providers/Requesty.tsx index fbed2b8210..4149dbc2c8 100644 --- a/webview-ui/src/components/settings/providers/Requesty.tsx +++ b/webview-ui/src/components/settings/providers/Requesty.tsx @@ -7,6 +7,7 @@ import { type RouterModels, requestyDefaultModelId, providerIdentifiers, + RouterModelsMessageType, } from "@roo-code/types" import { vscode } from "@src/utils/vscode" @@ -131,7 +132,7 @@ export const Requesty = ({ variant="outline" onClick={() => { vscode.postMessage({ - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, values: { provider: providerIdentifiers.requesty, refresh: true }, }) }}> diff --git a/webview-ui/src/components/settings/providers/Unbound.tsx b/webview-ui/src/components/settings/providers/Unbound.tsx index 44f5c4616a..b3211f0f8f 100644 --- a/webview-ui/src/components/settings/providers/Unbound.tsx +++ b/webview-ui/src/components/settings/providers/Unbound.tsx @@ -7,6 +7,7 @@ import { type RouterModels, unboundDefaultModelId, providerIdentifiers, + RouterModelsMessageType, } from "@roo-code/types" import { vscode } from "@src/utils/vscode" @@ -79,7 +80,7 @@ export const Unbound = ({ variant="outline" onClick={() => { vscode.postMessage({ - type: "requestRouterModels", + type: RouterModelsMessageType.requestRouterModels, values: { provider: providerIdentifiers.unbound, refresh: true }, }) }}> diff --git a/webview-ui/src/components/settings/providers/VSCodeLM.tsx b/webview-ui/src/components/settings/providers/VSCodeLM.tsx index 997f08c100..4b8d66d165 100644 --- a/webview-ui/src/components/settings/providers/VSCodeLM.tsx +++ b/webview-ui/src/components/settings/providers/VSCodeLM.tsx @@ -2,7 +2,12 @@ import { useState, useCallback, useMemo } from "react" import { useEvent } from "react-use" import { LanguageModelChatSelector } from "vscode" -import type { ProviderSettings, ExtensionMessage, ModelInfo } from "@roo-code/types" +import { + type ProviderSettings, + type ExtensionMessage, + type ModelInfo, + VsCodeLmModelsMessageType, +} from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" @@ -22,7 +27,7 @@ export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeL const message: ExtensionMessage = event.data switch (message.type) { - case "vsCodeLmModels": + case VsCodeLmModelsMessageType.vsCodeLmModels: { const newModels = message.vsCodeLmModels ?? [] setVsCodeLmModels(newModels) diff --git a/webview-ui/src/components/settings/providers/__tests__/LiteLLM.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/LiteLLM.spec.tsx index d86218af10..e4ff4521b1 100644 --- a/webview-ui/src/components/settings/providers/__tests__/LiteLLM.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/LiteLLM.spec.tsx @@ -1,7 +1,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" -import { providerIdentifiers, type OrganizationAllowList, type ProviderSettings } from "@roo-code/types" +import { + allRouterModelsProvider, + providerIdentifiers, + type OrganizationAllowList, + type ProviderSettings, +} from "@roo-code/types" import { LiteLLM } from "../LiteLLM" @@ -75,7 +80,7 @@ describe("LiteLLM", () => { expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["routerModels", providerIdentifiers.litellm], }) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["routerModels", "all"] }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["routerModels", allRouterModelsProvider] }) }) }) @@ -107,6 +112,43 @@ describe("LiteLLM", () => { expect(screen.getByText("LiteLLM unavailable")).toBeInTheDocument() }) + it("does not invalidate caches when a LiteLLM error and router models arrive in the same tick", () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + render( + + + , + ) + + fireEvent.click(screen.getByTestId("refresh-button")) + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + values: { provider: providerIdentifiers.litellm }, + error: "LiteLLM unavailable", + }, + }), + ) + window.dispatchEvent(new MessageEvent("message", { data: { type: "routerModels" } })) + }) + + expect(screen.getByText("LiteLLM unavailable")).toBeInTheDocument() + expect(invalidateQueries).not.toHaveBeenCalled() + }) + it("ignores failed refresh responses for another provider", () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render( diff --git a/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx index 3265aef30d..1cffc1d5ba 100644 --- a/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/Moonshot.spec.tsx @@ -2,7 +2,8 @@ import React from "react" import { render, screen, fireEvent, waitFor, act } from "@/utils/test-utils" -import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { allRouterModelsProvider, providerIdentifiers, type ProviderSettings } from "@roo-code/types" import { Moonshot } from "../Moonshot" @@ -223,6 +224,38 @@ describe("Moonshot Component", () => { }) }) + it("invalidates only the Moonshot and shared router-model caches after a successful refresh", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + render( + + + , + ) + + const refreshButton = screen + .getAllByTestId("button") + .find((button) => button.getAttribute("data-variant") === "outline")! + fireEvent.click(refreshButton) + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "routerModels" } })) + }) + + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["routerModels", providerIdentifiers.moonshot], + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["routerModels", allRouterModelsProvider], + }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ["routerModels"] }) + }) + }) + it("shows error state after singleRouterModelFetchResponse error message", async () => { mockUseExtensionState.mockReturnValue({ routerModels: {}, diff --git a/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx index d2bf7b3c04..bb810caa75 100644 --- a/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx @@ -6,6 +6,8 @@ import { type RouterModels, nanoGptDefaultModelId, nanoGptRoutingPreferences, + providerIdentifiers, + RouterModelsMessageType, } from "@roo-code/types" import { NanoGPT } from "../NanoGPT" @@ -160,8 +162,8 @@ describe("NanoGPT", () => { it("refreshes models with the unsaved cached key whenever it changes", () => { const { rerender } = renderComponent({ nanoGptApiKey: "first-key" }) expect(postMessageMock).toHaveBeenLastCalledWith({ - type: "requestRouterModels", - values: { provider: "nanogpt", nanoGptApiKey: "first-key" }, + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.nanogpt, nanoGptApiKey: "first-key" }, }) rerender( @@ -174,8 +176,8 @@ describe("NanoGPT", () => { ) expect(postMessageMock).toHaveBeenLastCalledWith({ - type: "requestRouterModels", - values: { provider: "nanogpt", nanoGptApiKey: "unsaved-key" }, + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.nanogpt, nanoGptApiKey: "unsaved-key" }, }) }) }) diff --git a/webview-ui/src/components/settings/providers/__tests__/Poe.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Poe.spec.tsx index 6595750cf9..b8c9f6e254 100644 --- a/webview-ui/src/components/settings/providers/__tests__/Poe.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/Poe.spec.tsx @@ -1,7 +1,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { act, fireEvent, render, screen } from "@testing-library/react" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" -import { providerIdentifiers, type OrganizationAllowList, type ProviderSettings } from "@roo-code/types" +import { + allRouterModelsProvider, + providerIdentifiers, + type OrganizationAllowList, + type ProviderSettings, +} from "@roo-code/types" import { Poe } from "../Poe" @@ -103,6 +108,36 @@ describe("Poe", () => { expect(screen.queryByText("settings:providers.refreshModels.error")).not.toBeInTheDocument() }) + it("invalidates only the Poe and shared router-model caches after a successful refresh", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + render( + + + , + ) + + fireEvent.click(screen.getByTestId("refresh-button")) + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "routerModels" } })) + }) + + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["routerModels", providerIdentifiers.poe], + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["routerModels", allRouterModelsProvider], + }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ["routerModels"] }) + }) + }) + it("clears model-specific reasoning settings when the Poe model changes", () => { renderComponent() diff --git a/webview-ui/src/components/ui/hooks/useLmStudioModels.ts b/webview-ui/src/components/ui/hooks/useLmStudioModels.ts index 79e46fe6cf..c045865181 100644 --- a/webview-ui/src/components/ui/hooks/useLmStudioModels.ts +++ b/webview-ui/src/components/ui/hooks/useLmStudioModels.ts @@ -1,12 +1,12 @@ import { useQuery } from "@tanstack/react-query" -import { type ModelRecord, type ExtensionMessage } from "@roo-code/types" +import { type ModelRecord, type ExtensionMessage, LmStudioModelsMessageType } from "@roo-code/types" import { vscode } from "@src/utils/vscode" export const requestLmStudioModels = (baseUrl?: string) => vscode.postMessage({ - type: "requestLmStudioModels", + type: LmStudioModelsMessageType.requestLmStudioModels, values: typeof baseUrl === "string" ? { baseUrl } : undefined, }) @@ -24,7 +24,7 @@ const getLmStudioModels = async (baseUrl?: string) => const handler = (event: MessageEvent) => { const message: ExtensionMessage = event.data - if (message.type === "lmStudioModels") { + if (message.type === LmStudioModelsMessageType.lmStudioModels) { clearTimeout(timeout) cleanup() diff --git a/webview-ui/src/components/ui/hooks/useOllamaModels.ts b/webview-ui/src/components/ui/hooks/useOllamaModels.ts index 80fc727f71..5f81ac6b89 100644 --- a/webview-ui/src/components/ui/hooks/useOllamaModels.ts +++ b/webview-ui/src/components/ui/hooks/useOllamaModels.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query" -import { type ModelRecord, type ExtensionMessage } from "@roo-code/types" +import { type ModelRecord, type ExtensionMessage, OllamaModelsMessageType } from "@roo-code/types" import { vscode } from "@src/utils/vscode" @@ -18,7 +18,7 @@ const getOllamaModels = async () => const handler = (event: MessageEvent) => { const message: ExtensionMessage = event.data - if (message.type === "ollamaModels") { + if (message.type === OllamaModelsMessageType.ollamaModels) { clearTimeout(timeout) cleanup() @@ -31,7 +31,7 @@ const getOllamaModels = async () => } window.addEventListener("message", handler) - vscode.postMessage({ type: "requestOllamaModels" }) + vscode.postMessage({ type: OllamaModelsMessageType.requestOllamaModels }) }) export const useOllamaModels = (modelId?: string) => diff --git a/webview-ui/src/components/ui/hooks/useRouterModels.ts b/webview-ui/src/components/ui/hooks/useRouterModels.ts index 5a562c6a48..a7d6b36725 100644 --- a/webview-ui/src/components/ui/hooks/useRouterModels.ts +++ b/webview-ui/src/components/ui/hooks/useRouterModels.ts @@ -1,6 +1,11 @@ import { useQuery } from "@tanstack/react-query" -import { type RouterModels, type ExtensionMessage } from "@roo-code/types" +import { + allRouterModelsProvider, + RouterModelsMessageType, + type RouterModels, + type ExtensionMessage, +} from "@roo-code/types" import { vscode } from "@src/utils/vscode" @@ -25,7 +30,7 @@ export const fetchRouterModels = async (provider?: string) => const handler = (event: MessageEvent) => { const message: ExtensionMessage = event.data - if (message.type === "routerModels") { + if (message.type === RouterModelsMessageType.routerModels) { const msgProvider = message?.values?.provider as string | undefined // Verify response matches request @@ -47,16 +52,16 @@ export const fetchRouterModels = async (provider?: string) => window.addEventListener("message", handler) if (provider) { - vscode.postMessage({ type: "requestRouterModels", values: { provider } }) + vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels, values: { provider } }) } else { - vscode.postMessage({ type: "requestRouterModels" }) + vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels }) } }) export const useRouterModels = (opts: UseRouterModelsOptions = {}) => { const provider = opts.provider || undefined return useQuery({ - queryKey: ["routerModels", provider || "all"], + queryKey: [RouterModelsMessageType.routerModels, provider || allRouterModelsProvider], queryFn: () => fetchRouterModels(provider), enabled: opts.enabled !== false, }) diff --git a/webview-ui/src/components/ui/hooks/useZooGatewayRouterModelsSync.ts b/webview-ui/src/components/ui/hooks/useZooGatewayRouterModelsSync.ts index 3fa81aa101..b56960bcb2 100644 --- a/webview-ui/src/components/ui/hooks/useZooGatewayRouterModelsSync.ts +++ b/webview-ui/src/components/ui/hooks/useZooGatewayRouterModelsSync.ts @@ -1,7 +1,13 @@ import { useCallback, useEffect, useRef } from "react" import { useQueryClient } from "@tanstack/react-query" -import { type ExtensionMessage, type RouterModels, providerIdentifiers } from "@roo-code/types" +import { + allRouterModelsProvider, + RouterModelsMessageType, + type ExtensionMessage, + type RouterModels, + providerIdentifiers, +} from "@roo-code/types" import { useExtensionState } from "@src/context/ExtensionStateContext" @@ -28,8 +34,9 @@ export function useZooGatewayRouterModelsSync() { return } - queryClient.setQueryData(["routerModels", "all"], (current) => - current ? { ...current, [providerIdentifiers.zooGateway]: zooModels } : partial, + queryClient.setQueryData( + [RouterModelsMessageType.routerModels, allRouterModelsProvider], + (current) => (current ? { ...current, [providerIdentifiers.zooGateway]: zooModels } : partial), ) } catch { // Ignore: bulk router fetch may still be in flight. diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index a672abbdc1..b7fe5a9004 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -18,6 +18,7 @@ import { type Command, type McpServer, RouterModels, + RouterModelsMessageType, ORGANIZATION_ALLOW_ALL, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, DEFAULT_DIFF_FUZZY_THRESHOLD, @@ -444,7 +445,7 @@ export const ExtensionStateContextProvider: React.FC<{ setListApiConfigMeta(message.listApiConfig ?? []) break } - case "routerModels": { + case RouterModelsMessageType.routerModels: { const provider = message.values?.provider as string | undefined const incoming = message.routerModels if (provider && incoming) {