From 1230e1f7448483100104afa35202ce3a17d4c227 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 25 Aug 2026 21:42:51 +0900 Subject: [PATCH 1/4] fix(openrouter): support extended reasoning efforts and add refresh models button --- src/api/providers/fetchers/openrouter.ts | 4 +- .../settings/providers/OpenRouter.tsx | 92 ++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/api/providers/fetchers/openrouter.ts b/src/api/providers/fetchers/openrouter.ts index a25c1b96fa..16d9bdd2af 100644 --- a/src/api/providers/fetchers/openrouter.ts +++ b/src/api/providers/fetchers/openrouter.ts @@ -217,7 +217,9 @@ export const parseOpenRouterModel = ({ cacheWritesPrice, cacheReadsPrice, description: model.description, - supportsReasoningEffort: supportedParameters ? supportedParameters.includes("reasoning") : undefined, + supportsReasoningEffort: supportedParameters?.includes("reasoning") + ? ["low", "medium", "high", "xhigh", "max"] + : undefined, supportedParameters: supportedParameters ? supportedParameters.filter(isModelParameter) : undefined, } diff --git a/webview-ui/src/components/settings/providers/OpenRouter.tsx b/webview-ui/src/components/settings/providers/OpenRouter.tsx index 2dba8c8459..d2973f5b5c 100644 --- a/webview-ui/src/components/settings/providers/OpenRouter.tsx +++ b/webview-ui/src/components/settings/providers/OpenRouter.tsx @@ -1,4 +1,5 @@ -import { useCallback, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" +import { useQueryClient } from "@tanstack/react-query" import { Checkbox } from "vscrui" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" @@ -7,17 +8,31 @@ import { type OrganizationAllowList, type RouterModels, openRouterDefaultModelId, + allRouterModelsProvider, + providerIdentifiers, + RouterModelsMessageType, + type ExtensionMessage, } from "@roo-code/types" +import type { RouterName } from "@roo/api" import { useAppTranslation } from "@src/i18n/TranslationContext" import { getOpenRouterAuthUrl } from "@src/oauth/urls" import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" +import { vscode } from "@src/utils/vscode" +import { Button } from "@src/components/ui" import { inputEventTransform } from "../transforms" import { ModelPicker } from "../ModelPicker" import { OpenRouterBalanceDisplay } from "./OpenRouterBalanceDisplay" +enum RefreshStatus { + Idle = "idle", + Loading = "loading", + Success = "success", + Error = "error", +} + type OpenRouterProps = { apiConfiguration: ProviderSettings setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void @@ -42,6 +57,54 @@ export const OpenRouter = ({ const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl) + const queryClient = useQueryClient() + 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 === RouterModelsMessageType.singleRouterModelFetchResponse && !message.success) { + const providerName = message.values?.provider as RouterName + if (providerName === providerIdentifiers.openrouter && refreshStatus === RefreshStatus.Loading) { + errorJustReceived.current = true + setRefreshStatus(RefreshStatus.Error) + setRefreshError(message.error) + } + } else if (message.type === RouterModelsMessageType.routerModels) { + if (refreshStatus === RefreshStatus.Loading) { + if (!errorJustReceived.current) { + setRefreshStatus(RefreshStatus.Success) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, providerIdentifiers.openrouter], + }) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, allRouterModelsProvider], + }) + } + } + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [refreshStatus, queryClient]) + + const handleRefreshModels = useCallback(() => { + errorJustReceived.current = false + setRefreshStatus(RefreshStatus.Loading) + setRefreshError(undefined) + + vscode.postMessage({ + type: RouterModelsMessageType.requestRouterModels, + values: { + provider: providerIdentifiers.openrouter, + refresh: true, + }, + }) + }, []) + const handleInputChange = useCallback( ( field: K, @@ -103,6 +166,33 @@ export const OpenRouter = ({ )} )} + + {refreshStatus === RefreshStatus.Loading && ( +
+ {t("settings:providers.refreshModels.loading")} +
+ )} + {refreshStatus === RefreshStatus.Success && ( +
{t("settings:providers.refreshModels.success")}
+ )} + {refreshStatus === RefreshStatus.Error && ( +
+ {refreshError || t("settings:providers.refreshModels.error")} +
+ )} Date: Tue, 25 Aug 2026 22:12:01 +0900 Subject: [PATCH 2/4] test(openrouter): update supportsReasoningEffort expectation to effort array --- src/api/providers/fetchers/__tests__/openrouter.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/providers/fetchers/__tests__/openrouter.spec.ts b/src/api/providers/fetchers/__tests__/openrouter.spec.ts index 89169c9c56..d212c5903a 100644 --- a/src/api/providers/fetchers/__tests__/openrouter.spec.ts +++ b/src/api/providers/fetchers/__tests__/openrouter.spec.ts @@ -43,7 +43,7 @@ describe("OpenRouter API", () => { description: expect.any(String), supportsReasoningBudget: true, requiredReasoningBudget: true, - supportsReasoningEffort: true, + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], supportedParameters: ["max_tokens", "temperature", "reasoning", "include_reasoning"], }) From 352c92acc29013236cf2dd97ff69ac2ecce4bead Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 25 Aug 2026 23:10:36 +0900 Subject: [PATCH 3/4] fix(openrouter): correlate routerModels refresh response to provider --- .../settings/providers/OpenRouter.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/webview-ui/src/components/settings/providers/OpenRouter.tsx b/webview-ui/src/components/settings/providers/OpenRouter.tsx index d2973f5b5c..b0e111292f 100644 --- a/webview-ui/src/components/settings/providers/OpenRouter.tsx +++ b/webview-ui/src/components/settings/providers/OpenRouter.tsx @@ -73,16 +73,21 @@ export const OpenRouter = ({ setRefreshError(message.error) } } else if (message.type === RouterModelsMessageType.routerModels) { - if (refreshStatus === RefreshStatus.Loading) { - if (!errorJustReceived.current) { - setRefreshStatus(RefreshStatus.Success) - void queryClient.invalidateQueries({ - queryKey: [RouterModelsMessageType.routerModels, providerIdentifiers.openrouter], - }) - void queryClient.invalidateQueries({ - queryKey: [RouterModelsMessageType.routerModels, allRouterModelsProvider], - }) - } + const providerName = message.values?.provider as RouterName | undefined + // Scoped responses must match our provider; unscoped (legacy/global) + // broadcasts are still accepted so Loading cannot hang. + if ( + (providerName === undefined || providerName === providerIdentifiers.openrouter) && + refreshStatus === RefreshStatus.Loading && + !errorJustReceived.current + ) { + setRefreshStatus(RefreshStatus.Success) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, providerIdentifiers.openrouter], + }) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, allRouterModelsProvider], + }) } } } From 5aa7c2f3409b227419974a7dbe361b2b646941a5 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 25 Aug 2026 23:10:50 +0900 Subject: [PATCH 4/4] test(openrouter): cover refresh models flow for provider correlation --- .../providers/__tests__/OpenRouter.spec.tsx | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 webview-ui/src/components/settings/providers/__tests__/OpenRouter.spec.tsx diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenRouter.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenRouter.spec.tsx new file mode 100644 index 0000000000..c7b5dbb5a5 --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/OpenRouter.spec.tsx @@ -0,0 +1,344 @@ +import { render, screen, fireEvent, act } from "@testing-library/react" + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types" +import { + openRouterDefaultModelId, + allRouterModelsProvider, + providerIdentifiers, + RouterModelsMessageType, +} from "@roo-code/types" + +import { OpenRouter } from "../OpenRouter" + +// Explicit toolkit stub (vitest validates factory keys, so no Proxy tricks). +// Uses React.createElement because this factory runs while the spec's own +// imports are still initializing (hoisted vi.mock). +vi.mock("@vscode/webview-ui-toolkit/react", async () => { + const React = await import("react") + const VSCodeTextField = ({ children, value, onInput, type }: any) => + React.createElement( + "div", + null, + children, + React.createElement("input", { + type, + value, + onChange: (e: any) => onInput(e), + "data-testid": type === "url" ? "base-url-input" : "api-key-input", + }), + ) + const VSCodeLink = ({ children, href }: any) => + React.createElement("a", { href, "data-vscode-stub": "VSCodeLink" }, children) + return { VSCodeTextField, VSCodeLink } +}) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@src/oauth/urls", () => ({ + getOpenRouterAuthUrl: () => "https://openrouter.ai/auth", +})) + +// Alias-path mock (reliable across environments): prevents the real balance +// display from issuing network requests during tests. +vi.mock("@src/components/settings/providers/OpenRouterBalanceDisplay", () => ({ + OpenRouterBalanceDisplay: () => null, +})) + +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ + VSCodeButtonLink: ({ children, href }: any) => ( + + {children} + + ), +})) + +const { postMessageMock } = vi.hoisted(() => ({ + postMessageMock: vi.fn(), +})) + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: postMessageMock, + }, +})) + +// Keep every real export (Popover, Select, ...) so leaked child renders work, +// and only stub Button to assert onClick/disabled without styling deps. +vi.mock("@src/components/ui", async (importOriginal) => ({ + ...(await importOriginal()), + Button: ({ children, onClick, disabled, className }: any) => ( + + ), +})) + +vi.mock("vscrui", async (importOriginal) => ({ + ...(await importOriginal()), + Checkbox: ({ checked, onChange, children }: any) => ( + + ), +})) + +// The shared Button stub is also used by the real ModelPicker rendered +// underneath, so identify OUR refresh button via its unique i18n label. +const getRefreshButton = () => + screen.getByText("settings:providers.refreshModels.label").closest("button") as HTMLElement + +describe("OpenRouter", () => { + const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } + const mockSetApiConfigurationField = vi.fn() + + let queryClient: QueryClient + let invalidateQueriesSpy: ReturnType + + const renderComponent = ({ + apiConfiguration = { openRouterApiKey: "key" } as ProviderSettings, + simplifySettings = true, + }: { + apiConfiguration?: ProviderSettings + simplifySettings?: boolean + } = {}) => + render( + + + , + ) + + beforeEach(() => { + vi.clearAllMocks() + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + invalidateQueriesSpy = vi.spyOn(queryClient, "invalidateQueries") + }) + + it("updates the API key via setApiConfigurationField on input", () => { + renderComponent() + + fireEvent.change(screen.getByTestId("api-key-input"), { target: { value: "secret-key" } }) + + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("openRouterApiKey", "secret-key") + }) + + it("shows the get-API-key CTA only when no API key is set", () => { + const { rerender } = renderComponent({ apiConfiguration: { openRouterApiKey: "" } }) + + expect(screen.getByTestId("get-api-key-link")).toBeInTheDocument() + expect(screen.getByTestId("get-api-key-link")).toHaveAttribute("href", "https://openrouter.ai/auth") + + rerender( + + + , + ) + expect(screen.queryByTestId("get-api-key-link")).not.toBeInTheDocument() + }) + + it("clears the base URL when the custom base URL checkbox is unchecked", () => { + renderComponent({ + apiConfiguration: { openRouterApiKey: "key", openRouterBaseUrl: "https://proxy.example.com" }, + simplifySettings: false, + }) + + fireEvent.click(screen.getByTestId("base-url-checkbox")) + + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("openRouterBaseUrl", "") + }) + + describe("refresh models", () => { + const dispatchMessage = (data: any) => + act(() => { + window.dispatchEvent(new MessageEvent("message", { data })) + }) + + it("renders the refresh button in idle state", () => { + renderComponent() + + const button = getRefreshButton() + expect(button).not.toBeDisabled() + expect(button.querySelector(".codicon-refresh")).not.toBeNull() + expect(screen.getByText("settings:providers.refreshModels.label")).toBeInTheDocument() + }) + + it("sends requestRouterModels for the openrouter provider when clicked", () => { + renderComponent() + + fireEvent.click(getRefreshButton()) + + expect(postMessageMock).toHaveBeenCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.openrouter, refresh: true }, + }) + }) + + it("enters loading state and disables the button while refreshing", () => { + renderComponent() + + fireEvent.click(getRefreshButton()) + + const button = getRefreshButton() + expect(button).toBeDisabled() + expect(button.querySelector(".codicon-loading")).not.toBeNull() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + it("shows success and invalidates caches when a scoped routerModels response arrives", () => { + renderComponent() + + fireEvent.click(getRefreshButton()) + dispatchMessage({ + type: RouterModelsMessageType.routerModels, + values: { provider: providerIdentifiers.openrouter }, + }) + + expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument() + expect(invalidateQueriesSpy).toHaveBeenCalledTimes(2) + expect(invalidateQueriesSpy).toHaveBeenNthCalledWith(1, { + queryKey: [RouterModelsMessageType.routerModels, providerIdentifiers.openrouter], + }) + expect(invalidateQueriesSpy).toHaveBeenNthCalledWith(2, { + queryKey: [RouterModelsMessageType.routerModels, allRouterModelsProvider], + }) + }) + + it("still resolves on unscoped (legacy/global) routerModels broadcasts", () => { + renderComponent() + + fireEvent.click(getRefreshButton()) + dispatchMessage({ type: RouterModelsMessageType.routerModels }) + + expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument() + }) + + it("ignores scoped routerModels responses belonging to other providers", () => { + renderComponent() + + fireEvent.click(getRefreshButton()) + dispatchMessage({ + type: RouterModelsMessageType.routerModels, + values: { provider: providerIdentifiers.moonshot }, + }) + + expect(screen.queryByText("settings:providers.refreshModels.success")).not.toBeInTheDocument() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + expect(invalidateQueriesSpy).not.toHaveBeenCalled() + }) + + it("shows error state with the received error message on fetch failure", () => { + renderComponent() + + fireEvent.click(getRefreshButton()) + dispatchMessage({ + type: RouterModelsMessageType.singleRouterModelFetchResponse, + success: false, + values: { provider: providerIdentifiers.openrouter }, + error: "Invalid API key", + }) + + expect(screen.getByText("Invalid API key")).toBeInTheDocument() + }) + + it("falls back to the default error translation when no error is provided", () => { + renderComponent() + + fireEvent.click(getRefreshButton()) + dispatchMessage({ + type: RouterModelsMessageType.singleRouterModelFetchResponse, + success: false, + values: { provider: providerIdentifiers.openrouter }, + }) + + expect(screen.getByText("settings:providers.refreshModels.error")).toBeInTheDocument() + }) + + it("ignores fetch failures for other providers", () => { + renderComponent() + + fireEvent.click(getRefreshButton()) + dispatchMessage({ + type: RouterModelsMessageType.singleRouterModelFetchResponse, + success: false, + values: { provider: providerIdentifiers.moonshot }, + error: "should not show", + }) + + expect(screen.queryByText("should not show")).not.toBeInTheDocument() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + it("does not override an error with success when routerModels arrives after a failure", () => { + renderComponent() + + fireEvent.click(getRefreshButton()) + + // Dispatch both within the same act batch so the handler still sees + // refreshStatus === "loading" and the errorJustReceived guard is exercised. + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.singleRouterModelFetchResponse, + success: false, + values: { provider: providerIdentifiers.openrouter }, + error: "boom", + }, + }), + ) + window.dispatchEvent( + new MessageEvent("message", { data: { type: RouterModelsMessageType.routerModels } }), + ) + }) + + expect(screen.getByText("boom")).toBeInTheDocument() + expect(screen.queryByText("settings:providers.refreshModels.success")).not.toBeInTheDocument() + }) + + it("ignores routerModels messages when not in loading state", () => { + renderComponent() + + // No refresh initiated; an unsolicited routerModels message should be a no-op. + dispatchMessage({ type: RouterModelsMessageType.routerModels }) + + expect(screen.queryByText("settings:providers.refreshModels.success")).not.toBeInTheDocument() + expect(screen.queryByText("settings:providers.refreshModels.loading")).not.toBeInTheDocument() + }) + + it("stops listening for messages after unmount", () => { + const { unmount } = renderComponent() + + unmount() + + expect(() => + act(() => { + window.dispatchEvent( + new MessageEvent("message", { data: { type: RouterModelsMessageType.routerModels } }), + ) + }), + ).not.toThrow() + expect(screen.queryByText("settings:providers.refreshModels.label")).not.toBeInTheDocument() + }) + }) +})