diff --git a/src/api/providers/fetchers/__tests__/openrouter.spec.ts b/src/api/providers/fetchers/__tests__/openrouter.spec.ts index 89169c9c56..641532722d 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"], }) @@ -539,5 +539,62 @@ describe("OpenRouter API", () => { expect(resultWithoutTools.supportedParameters).toContain("max_tokens") }) + + it("leaves supportsReasoningEffort undefined when reasoning is absent or supportedParameters is unset", () => { + const mockModel = { + name: "Reasoning Effort Model", + description: "Model without reasoning parameter support", + context_length: 128000, + max_completion_tokens: 8192, + pricing: { + prompt: "0.000003", + completion: "0.000015", + }, + } + + const resultWithoutReasoningParam = parseOpenRouterModel({ + id: "test/no-reasoning-model", + model: mockModel, + inputModality: ["text"], + outputModality: ["text"], + maxTokens: 8192, + supportedParameters: ["max_tokens", "temperature"], + }) + + const resultWithoutSupportedParameters = parseOpenRouterModel({ + id: "test/unset-parameters-model", + model: mockModel, + inputModality: ["text"], + outputModality: ["text"], + maxTokens: 8192, + }) + + expect(resultWithoutReasoningParam.supportsReasoningEffort).toBeUndefined() + expect(resultWithoutSupportedParameters.supportsReasoningEffort).toBeUndefined() + }) + + it("sets supportsReasoningEffort when supportedParameters includes reasoning", () => { + const mockModel = { + name: "Reasoning Effort Model", + description: "Model with reasoning parameter support", + context_length: 128000, + max_completion_tokens: 8192, + pricing: { + prompt: "0.000003", + completion: "0.000015", + }, + } + + const resultWithReasoningParam = parseOpenRouterModel({ + id: "test/reasoning-effort-model", + model: mockModel, + inputModality: ["text"], + outputModality: ["text"], + maxTokens: 8192, + supportedParameters: ["reasoning", "max_tokens", "temperature"], + }) + + expect(resultWithReasoningParam.supportsReasoningEffort).toEqual(["low", "medium", "high", "xhigh", "max"]) + }) }) }) 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..2d8f23a9e4 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,65 @@ export const OpenRouter = ({ const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl) + const queryClient = useQueryClient() + const [refreshStatus, setRefreshStatus] = useState(RefreshStatus.Idle) + const [refreshError, setRefreshError] = useState() + // Stryker disable next-line BooleanLiteral : initial value never read before reset in handleRefreshModels + 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) { + 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], + }) + } + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [refreshStatus, queryClient]) + + const handleRefreshModels = useCallback( + () => { + errorJustReceived.current = false + setRefreshStatus(RefreshStatus.Loading) + // Stryker disable next-line CallExpression : refreshError is unobservable outside Error status; every Error transition re-sets it in the same batch + setRefreshError(undefined) + + vscode.postMessage({ + type: RouterModelsMessageType.requestRouterModels, + values: { + provider: providerIdentifiers.openrouter, + refresh: true, + }, + }) + }, + // Stryker disable next-line ArrayDeclaration : deps array is constant; replacing it with any constant array is memoization-equivalent + [], + ) + const handleInputChange = useCallback( ( field: K, @@ -103,6 +177,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")} +
+ )} { + const React = await import("react") + type VSCodeTextFieldProps = { + children?: ReactNode + value?: string + onInput?: (event: ChangeEvent) => void + type?: string + } + const VSCodeTextField = ({ children, value, onInput, type }: VSCodeTextFieldProps) => + React.createElement( + "div", + null, + children, + React.createElement("input", { + type, + value, + onChange: (e: ChangeEvent) => onInput?.(e), + "data-testid": type === "url" ? "base-url-input" : "api-key-input", + }), + ) + type VSCodeLinkProps = { children?: ReactNode; href?: string } + const VSCodeLink = ({ children, href }: VSCodeLinkProps) => + 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 }: { children?: ReactNode; href?: string }) => ( + + {children} + + ), +})) + +// Stub the whole hook module: the real fetchRouterModels registers a +// transient window "message" listener that only cleans up when a matching +// response arrives or the 10s timeout fires. Stubbing removes that noise so +// the unmount test below can assert listener balance for OpenRouter's own +// effect pair in isolation. +vi.mock("@src/components/ui/hooks/useRouterModels", () => ({ + useRouterModels: ({ provider }: { provider?: string }) => ({ + data: provider ? { [provider]: {} } : {}, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), +})) + +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, + }: { + children?: ReactNode + onClick?: React.MouseEventHandler + disabled?: boolean + className?: string + }) => ( + + ), +})) + +vi.mock("vscrui", async (importOriginal) => ({ + ...(await importOriginal()), + Checkbox: ({ + checked, + onChange, + children, + }: { + checked?: boolean + onChange?: (checked: boolean) => void + children?: ReactNode + }) => ( + + ), +})) + +// 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 = (): HTMLButtonElement => { + const button = screen.getByText("settings:providers.refreshModels.label").closest("button") + if (!(button instanceof HTMLButtonElement)) { + throw new Error("Refresh button element not found or not a