From 9dcd2c0e2f980da64570eaa9a64125c230387285 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Wed, 5 Aug 2026 15:25:07 +0300 Subject: [PATCH 1/3] refactor(webview): canonicalize ApiOptions provider identifiers --- .../src/components/settings/ApiOptions.tsx | 87 ++-- .../ApiOptions.interactions.spec.tsx | 388 ++++++++++++++++++ 2 files changed, 432 insertions(+), 43 deletions(-) create mode 100644 webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index c5e69978ff..78161455c7 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -8,6 +8,7 @@ import { type ProviderName, type ProviderSettings, isRetiredProvider, + providerIdentifiers, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, } from "@roo-code/types" @@ -207,7 +208,7 @@ const ApiOptions = ({ // stops typing. useDebounce( () => { - if (selectedProvider === "openai") { + if (selectedProvider === providerIdentifiers.openai) { // Use our custom headers state to build the headers object. const headerObject = convertHeadersToObject(customHeaders) @@ -220,7 +221,7 @@ const ApiOptions = ({ openAiHeaders: headerObject, }, }) - } else if (selectedProvider === "ollama") { + } else if (selectedProvider === providerIdentifiers.ollama) { vscode.postMessage({ type: "requestOllamaModels", values: { @@ -228,11 +229,11 @@ const ApiOptions = ({ apiKey: apiConfiguration?.ollamaApiKey, }, }) - } else if (selectedProvider === "lmstudio") { + } else if (selectedProvider === providerIdentifiers.lmstudio) { requestLmStudioModels(apiConfiguration?.lmStudioBaseUrl) - } else if (selectedProvider === "vscode-lm") { + } else if (selectedProvider === providerIdentifiers.vscodeLm) { vscode.postMessage({ type: "requestVsCodeLmModels" }) - } else if (selectedProvider === "litellm") { + } else if (selectedProvider === providerIdentifiers.litellm) { vscode.postMessage({ type: "requestRouterModels", values: { @@ -240,7 +241,7 @@ const ApiOptions = ({ litellmBaseUrl: apiConfiguration?.litellmBaseUrl, }, }) - } else if (selectedProvider === "poe") { + } else if (selectedProvider === providerIdentifiers.poe) { vscode.postMessage({ type: "requestRouterModels" }) } }, @@ -270,7 +271,7 @@ const ApiOptions = ({ // Zoo Gateway renders its own auth-state error inline (sign-in card in // ZooGateway.tsx) so it can react to zooCodeIsAuthenticated changes // without re-running this effect or threading auth state through validation. - if (apiConfiguration.apiProvider === "zoo-gateway") { + if (apiConfiguration.apiProvider === providerIdentifiers.zooGateway) { setErrorMessage(undefined) return } @@ -322,7 +323,7 @@ const ApiOptions = ({ } // Bedrock has a special “custom-arn” pseudo-model that isn't part of MODELS_BY_PROVIDER. - if (provider === "bedrock" && modelId === "custom-arn") { + if (provider === providerIdentifiers.bedrock && modelId === "custom-arn") { return } @@ -441,7 +442,7 @@ const ApiOptions = ({ ) : ( <> - {selectedProvider === "openrouter" && ( + {selectedProvider === providerIdentifiers.openrouter && ( )} - {selectedProvider === "requesty" && ( + {selectedProvider === providerIdentifiers.requesty && ( )} - {selectedProvider === "unbound" && ( + {selectedProvider === providerIdentifiers.unbound && ( )} - {selectedProvider === "anthropic" && ( + {selectedProvider === providerIdentifiers.anthropic && ( )} - {selectedProvider === "openai-codex" && ( + {selectedProvider === providerIdentifiers.openaiCodex && ( )} - {selectedProvider === "openai-native" && ( + {selectedProvider === providerIdentifiers.openaiNative && ( )} - {selectedProvider === "mistral" && ( + {selectedProvider === providerIdentifiers.mistral && ( )} - {selectedProvider === "baseten" && ( + {selectedProvider === providerIdentifiers.baseten && ( )} - {selectedProvider === "bedrock" && ( + {selectedProvider === providerIdentifiers.bedrock && ( )} - {selectedProvider === "vertex" && ( + {selectedProvider === providerIdentifiers.vertex && ( )} - {selectedProvider === "gemini" && ( + {selectedProvider === providerIdentifiers.gemini && ( )} - {selectedProvider === "openai" && ( + {selectedProvider === providerIdentifiers.openai && ( )} - {selectedProvider === "lmstudio" && ( + {selectedProvider === providerIdentifiers.lmstudio && ( )} - {selectedProvider === "deepseek" && ( + {selectedProvider === providerIdentifiers.deepseek && ( )} - {selectedProvider === "qwen-code" && ( + {selectedProvider === providerIdentifiers.qwenCode && ( )} - {selectedProvider === "moonshot" && ( + {selectedProvider === providerIdentifiers.moonshot && ( )} - {selectedProvider === "kimi-code" && ( + {selectedProvider === providerIdentifiers.kimiCode && ( )} - {selectedProvider === "minimax" && ( + {selectedProvider === providerIdentifiers.minimax && ( )} - {selectedProvider === "mimo" && ( + {selectedProvider === providerIdentifiers.mimo && ( )} - {selectedProvider === "vscode-lm" && ( + {selectedProvider === providerIdentifiers.vscodeLm && ( )} - {selectedProvider === "ollama" && ( + {selectedProvider === providerIdentifiers.ollama && ( )} - {selectedProvider === "xai" && ( + {selectedProvider === providerIdentifiers.xai && ( )} - {selectedProvider === "litellm" && ( + {selectedProvider === providerIdentifiers.litellm && ( )} - {selectedProvider === "sambanova" && ( + {selectedProvider === providerIdentifiers.sambanova && ( )} - {selectedProvider === "zai" && ( + {selectedProvider === providerIdentifiers.zai && ( )} - {selectedProvider === "vercel-ai-gateway" && ( + {selectedProvider === providerIdentifiers.vercelAiGateway && ( )} - {selectedProvider === "opencode-go" && ( + {selectedProvider === providerIdentifiers.opencodeGo && ( )} - {selectedProvider === "kenari" && ( + {selectedProvider === providerIdentifiers.kenari && ( )} - {selectedProvider === "zoo-gateway" && ( + {selectedProvider === providerIdentifiers.zooGateway && ( )} - {selectedProvider === "fireworks" && ( + {selectedProvider === providerIdentifiers.fireworks && ( )} - {selectedProvider === "friendli" && ( + {selectedProvider === providerIdentifiers.friendli && ( )} - {selectedProvider === "poe" && ( + {selectedProvider === providerIdentifiers.poe && ( - {selectedProvider === "bedrock" && selectedModelId === "custom-arn" && ( + {selectedProvider === providerIdentifiers.bedrock && selectedModelId === "custom-arn" && ( setApiConfigurationField("consecutiveMistakeLimit", value)} /> - {selectedProvider === "poe" && ( + {selectedProvider === providerIdentifiers.poe && ( )} - {selectedProvider === "openrouter" && + {selectedProvider === providerIdentifiers.openrouter && openRouterModelProviders && Object.keys(openRouterModelProviders).length > 0 && (
diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx new file mode 100644 index 0000000000..02c6c1af02 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx @@ -0,0 +1,388 @@ +import { act, fireEvent, render, screen, within } from "@/utils/test-utils" +import { bedrockDefaultModelId, providerIdentifiers, type ProviderSettings } from "@roo-code/types" +import type { ChangeEventHandler, InputHTMLAttributes, ReactNode } from "react" + +import { requestLmStudioModels } from "@src/components/ui/hooks/useLmStudioModels" +import type { useOpenRouterModelProviders } from "@src/components/ui/hooks/useOpenRouterModelProviders" +import { vscode } from "@src/utils/vscode" + +import ApiOptions, { type ApiOptionsProps } from "../ApiOptions" + +type OpenRouterModelProvidersQueryResult = Pick, "data"> + +const { useOpenRouterModelProvidersMock } = vi.hoisted(() => ({ + useOpenRouterModelProvidersMock: vi.fn<() => OpenRouterModelProvidersQueryResult>(() => ({ data: undefined })), +})) + +type ChildrenProps = { children?: ReactNode } + +type VSCodeTextFieldMockProps = ChildrenProps & + Pick, "value" | "placeholder"> & { + onInput?: ChangeEventHandler + } + +type SearchableSelectMockProps = { + value?: string + onValueChange: (value: string) => void + options: Array<{ value: string; label: string }> + "data-testid"?: string +} + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + organizationAllowList: { allowAll: true, providers: {} }, + openAiCodexIsAuthenticated: false, + kimiCodeIsAuthenticated: false, + kimiCodeOAuthState: undefined, + }), +})) + +vi.mock("@src/components/ui/hooks/useRouterModels", () => ({ + useRouterModels: () => ({ data: {}, refetch: vi.fn() }), +})) + +vi.mock("@src/components/ui/hooks/useZooGatewayRouterModelsSync", () => ({ + useZooGatewayRouterModelsSync: vi.fn(), +})) + +vi.mock("@src/components/ui/hooks/useOpenRouterModelProviders", () => ({ + useOpenRouterModelProviders: useOpenRouterModelProvidersMock, + OPENROUTER_DEFAULT_PROVIDER_NAME: "Auto", +})) + +vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: (configuration: ProviderSettings) => ({ + provider: configuration.apiProvider, + id: configuration.apiModelId, + info: {}, + }), +})) + +vi.mock("@src/components/ui/hooks/useLmStudioModels", () => ({ + requestLmStudioModels: vi.fn(), +})) + +vi.mock("../providers", () => { + const Provider = () => null + return { + Anthropic: Provider, + Baseten: Provider, + Bedrock: Provider, + DeepSeek: Provider, + Gemini: Provider, + LMStudio: Provider, + LiteLLM: Provider, + Mistral: Provider, + Moonshot: Provider, + KimiCode: Provider, + Ollama: Provider, + OpenAI: Provider, + OpenAICompatible: Provider, + OpenAICodex: Provider, + OpenRouter: Provider, + Poe: Provider, + QwenCode: Provider, + Requesty: Provider, + SambaNova: Provider, + Unbound: Provider, + Vertex: Provider, + VSCodeLM: Provider, + XAI: Provider, + ZAi: Provider, + Fireworks: Provider, + Friendli: Provider, + VercelAiGateway: Provider, + OpenCodeGo: Provider, + Kenari: Provider, + ZooGateway: Provider, + MiniMax: Provider, + Mimo: Provider, + } +}) + +vi.mock("../providers/BedrockCustomArn", () => ({ + BedrockCustomArn: () =>
, +})) +vi.mock("../ModelPicker", () => ({ ModelPicker: () => null })) +vi.mock("../ApiErrorMessage", () => ({ ApiErrorMessage: () => null })) +vi.mock("../ThinkingBudget", () => ({ ThinkingBudget: () => null })) +vi.mock("../Verbosity", () => ({ Verbosity: () => null })) +vi.mock("../TodoListSettingsControl", () => ({ TodoListSettingsControl: () => null })) +vi.mock("../TemperatureControl", () => ({ TemperatureControl: () => null })) +vi.mock("../RateLimitSecondsControl", () => ({ RateLimitSecondsControl: () => null })) +vi.mock("../ConsecutiveMistakeLimitControl", () => ({ + ConsecutiveMistakeLimitControl: ({ value, onChange }: { value: number; onChange: (value: number) => void }) => ( +
+ onChange(Number(event.target.value))} /> +
+ ), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children, value, onInput, placeholder }: VSCodeTextFieldMockProps) => ( + + ), + VSCodeLink: ({ children }: ChildrenProps) => {children}, +})) + +vi.mock("@/components/ui", () => ({ + SearchableSelect: ({ value, onValueChange, options, "data-testid": testId }: SearchableSelectMockProps) => ( +
+ +
+ ), + Collapsible: ({ children }: ChildrenProps) =>
{children}
, + CollapsibleTrigger: ({ children }: ChildrenProps) =>
{children}
, + CollapsibleContent: ({ children }: ChildrenProps) =>
{children}
, + Select: ({ children }: ChildrenProps) =>
{children}
, + SelectTrigger: ({ children }: ChildrenProps) =>
{children}
, + SelectValue: () => null, + SelectContent: ({ children }: ChildrenProps) =>
{children}
, + SelectItem: ({ children }: ChildrenProps) =>
{children}
, +})) + +const renderApiOptions = (props: Partial = {}) => + render( + undefined} + uriScheme={undefined} + apiConfiguration={{}} + setApiConfigurationField={() => undefined} + {...props} + />, + ) + +describe("ApiOptions interactions", () => { + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + describe("debounced provider model refresh", () => { + it.each([ + { + provider: providerIdentifiers.openai, + configuration: { + openAiBaseUrl: "https://openai.example/v1", + openAiApiKey: "openai-key", + openAiHeaders: { "X-Custom": "header-value" }, + }, + expectedMessage: { + type: "requestOpenAiModels", + values: { + baseUrl: "https://openai.example/v1", + apiKey: "openai-key", + customHeaders: {}, + openAiHeaders: { "X-Custom": "header-value" }, + }, + }, + }, + { + provider: providerIdentifiers.ollama, + configuration: { ollamaBaseUrl: "http://ollama:11434", ollamaApiKey: "ollama-key" }, + expectedMessage: { + type: "requestOllamaModels", + values: { baseUrl: "http://ollama:11434", apiKey: "ollama-key" }, + }, + }, + { + provider: providerIdentifiers.vscodeLm, + configuration: {}, + expectedMessage: { type: "requestVsCodeLmModels" }, + }, + { + provider: providerIdentifiers.litellm, + configuration: { litellmBaseUrl: "http://litellm:4000", litellmApiKey: "litellm-key" }, + expectedMessage: { + type: "requestRouterModels", + values: { litellmApiKey: "litellm-key", litellmBaseUrl: "http://litellm:4000" }, + }, + }, + { + provider: providerIdentifiers.poe, + configuration: { poeApiKey: "poe-key", poeBaseUrl: "https://api.poe.example/v1" }, + expectedMessage: { type: "requestRouterModels" }, + }, + ])("requests models for $provider", ({ provider, configuration, expectedMessage }) => { + vi.useFakeTimers() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + + renderApiOptions({ apiConfiguration: { apiProvider: provider, ...configuration } }) + act(() => vi.advanceTimersByTime(249)) + expect(postMessage).not.toHaveBeenCalledWith(expectedMessage) + + act(() => vi.advanceTimersByTime(1)) + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith(expectedMessage) + }) + + it("requests LM Studio models using its configured base URL", () => { + vi.useFakeTimers() + renderApiOptions({ + apiConfiguration: { + apiProvider: providerIdentifiers.lmstudio, + lmStudioBaseUrl: "http://lmstudio:1234", + }, + }) + + act(() => vi.advanceTimersByTime(249)) + expect(requestLmStudioModels).not.toHaveBeenCalledWith("http://lmstudio:1234") + + act(() => vi.advanceTimersByTime(1)) + expect(requestLmStudioModels).toHaveBeenCalledTimes(1) + expect(requestLmStudioModels).toHaveBeenCalledWith("http://lmstudio:1234") + }) + + it("does not request dynamic models for a static provider", () => { + vi.useFakeTimers() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + + renderApiOptions({ apiConfiguration: { apiProvider: providerIdentifiers.anthropic } }) + act(() => vi.advanceTimersByTime(250)) + + expect(postMessage).not.toHaveBeenCalled() + }) + }) + + it.each([ + providerIdentifiers.requesty, + providerIdentifiers.unbound, + providerIdentifiers.anthropic, + providerIdentifiers.openaiCodex, + providerIdentifiers.openaiNative, + providerIdentifiers.mistral, + providerIdentifiers.baseten, + providerIdentifiers.bedrock, + providerIdentifiers.gemini, + providerIdentifiers.lmstudio, + providerIdentifiers.deepseek, + providerIdentifiers.qwenCode, + providerIdentifiers.moonshot, + providerIdentifiers.kimiCode, + providerIdentifiers.minimax, + providerIdentifiers.mimo, + providerIdentifiers.ollama, + providerIdentifiers.litellm, + providerIdentifiers.sambanova, + providerIdentifiers.zai, + providerIdentifiers.xai, + providerIdentifiers.fireworks, + providerIdentifiers.friendli, + providerIdentifiers.vercelAiGateway, + providerIdentifiers.opencodeGo, + ])("renders the canonical %s provider branch", (apiProvider) => { + const { unmount } = renderApiOptions({ apiConfiguration: { apiProvider } }) + unmount() + }) + + it("clears parent validation errors for Zoo Gateway", () => { + const setErrorMessage = vi.fn() + renderApiOptions({ apiConfiguration: { apiProvider: providerIdentifiers.zooGateway }, setErrorMessage }) + + expect(setErrorMessage).toHaveBeenCalledWith(undefined) + }) + + it("renders OpenRouter provider routing when provider metadata is available", () => { + useOpenRouterModelProvidersMock.mockReturnValue({ + data: { preferred: { label: "Preferred", contextWindow: 1, supportsPromptCache: false } }, + }) + + renderApiOptions({ + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "anthropic/claude-sonnet-4.5", + }, + }) + + expect(screen.getByText("settings:providers.openRouter.providerRouting.title")).toBeInTheDocument() + }) + + it("preserves the Bedrock custom ARN pseudo-model when switching to Bedrock", () => { + const setApiConfigurationField = vi.fn() + renderApiOptions({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "custom-arn" }, + setApiConfigurationField, + }) + + const providerSelect = screen.getByTestId("provider-select").querySelector("select") as HTMLSelectElement + fireEvent.change(providerSelect, { target: { value: providerIdentifiers.bedrock } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("apiProvider", providerIdentifiers.bedrock) + expect(setApiConfigurationField.mock.calls.filter(([field]) => field === "apiModelId")).toEqual([]) + }) + + it("resets an invalid ordinary model to the Bedrock default when switching providers", () => { + const setApiConfigurationField = vi.fn() + renderApiOptions({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "not-a-bedrock-model" }, + setApiConfigurationField, + }) + + const providerSelect = screen.getByTestId("provider-select").querySelector("select") as HTMLSelectElement + fireEvent.change(providerSelect, { target: { value: providerIdentifiers.bedrock } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("apiProvider", providerIdentifiers.bedrock) + expect(setApiConfigurationField).toHaveBeenCalledWith("apiModelId", bedrockDefaultModelId, false) + }) + + it("renders the custom ARN settings only for Bedrock's custom ARN pseudo-model", () => { + const { rerender } = render( + undefined} + uriScheme={undefined} + apiConfiguration={{ apiProvider: providerIdentifiers.bedrock, apiModelId: "custom-arn" }} + setApiConfigurationField={() => undefined} + />, + ) + + expect(screen.getByTestId("bedrock-custom-arn")).toBeInTheDocument() + + rerender( + undefined} + uriScheme={undefined} + apiConfiguration={{ apiProvider: providerIdentifiers.bedrock, apiModelId: bedrockDefaultModelId }} + setApiConfigurationField={() => undefined} + />, + ) + + expect(screen.queryByTestId("bedrock-custom-arn")).not.toBeInTheDocument() + }) + + it("updates the consecutive mistake limit from advanced settings", () => { + const setApiConfigurationField = vi.fn() + renderApiOptions({ apiConfiguration: {}, setApiConfigurationField }) + + fireEvent.change(within(screen.getByTestId("consecutive-mistake-limit-control")).getByRole("slider"), { + target: { value: "7" }, + }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("consecutiveMistakeLimit", 7) + }) + + it("renders and updates the Poe base URL in advanced settings", () => { + const setApiConfigurationField = vi.fn() + renderApiOptions({ + apiConfiguration: { apiProvider: providerIdentifiers.poe, poeBaseUrl: "https://api.poe.example/v1" }, + setApiConfigurationField, + }) + + const poeBaseUrl = screen.getByPlaceholderText("https://api.poe.com/v1") + expect(poeBaseUrl).toHaveValue("https://api.poe.example/v1") + + fireEvent.change(poeBaseUrl, { target: { value: "https://new.poe.example/v1" } }) + expect(setApiConfigurationField).toHaveBeenCalledWith("poeBaseUrl", "https://new.poe.example/v1") + }) +}) From d1c2e545f3023b7a3eebee7d31cf43d28cdf90c6 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 8 Aug 2026 20:10:33 +0000 Subject: [PATCH 2/3] test(webview): strengthen ApiOptions interaction coverage --- .../ApiOptions.interactions.spec.tsx | 241 ++++++++++++++---- 1 file changed, 188 insertions(+), 53 deletions(-) diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx index 02c6c1af02..d0dbdf10f4 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx @@ -10,10 +10,6 @@ import ApiOptions, { type ApiOptionsProps } from "../ApiOptions" type OpenRouterModelProvidersQueryResult = Pick, "data"> -const { useOpenRouterModelProvidersMock } = vi.hoisted(() => ({ - useOpenRouterModelProvidersMock: vi.fn<() => OpenRouterModelProvidersQueryResult>(() => ({ data: undefined })), -})) - type ChildrenProps = { children?: ReactNode } type VSCodeTextFieldMockProps = ChildrenProps & @@ -28,6 +24,24 @@ type SearchableSelectMockProps = { "data-testid"?: string } +type SelectMockProps = ChildrenProps & { + value?: string + onValueChange?: (value: string) => void +} + +type UseSelectedModelReturn = { provider?: string; id?: string; info: Record } + +const { useOpenRouterModelProvidersMock, useSelectedModelMock } = vi.hoisted(() => ({ + useOpenRouterModelProvidersMock: vi.fn<() => OpenRouterModelProvidersQueryResult>(() => ({ data: undefined })), + useSelectedModelMock: vi.fn( + (configuration: ProviderSettings): UseSelectedModelReturn => ({ + provider: configuration.apiProvider, + id: configuration.apiModelId, + info: {}, + }), + ), +})) + vi.mock("@src/context/ExtensionStateContext", () => ({ useExtensionState: () => ({ organizationAllowList: { allowAll: true, providers: {} }, @@ -51,11 +65,7 @@ vi.mock("@src/components/ui/hooks/useOpenRouterModelProviders", () => ({ })) vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ - useSelectedModel: (configuration: ProviderSettings) => ({ - provider: configuration.apiProvider, - id: configuration.apiModelId, - info: {}, - }), + useSelectedModel: useSelectedModelMock, })) vi.mock("@src/components/ui/hooks/useLmStudioModels", () => ({ @@ -63,40 +73,40 @@ vi.mock("@src/components/ui/hooks/useLmStudioModels", () => ({ })) vi.mock("../providers", () => { - const Provider = () => null + const provider = (testId: string) => () =>
return { - Anthropic: Provider, - Baseten: Provider, - Bedrock: Provider, - DeepSeek: Provider, - Gemini: Provider, - LMStudio: Provider, - LiteLLM: Provider, - Mistral: Provider, - Moonshot: Provider, - KimiCode: Provider, - Ollama: Provider, - OpenAI: Provider, - OpenAICompatible: Provider, - OpenAICodex: Provider, - OpenRouter: Provider, - Poe: Provider, - QwenCode: Provider, - Requesty: Provider, - SambaNova: Provider, - Unbound: Provider, - Vertex: Provider, - VSCodeLM: Provider, - XAI: Provider, - ZAi: Provider, - Fireworks: Provider, - Friendli: Provider, - VercelAiGateway: Provider, - OpenCodeGo: Provider, - Kenari: Provider, - ZooGateway: Provider, - MiniMax: Provider, - Mimo: Provider, + Anthropic: provider("provider-anthropic"), + Baseten: provider("provider-baseten"), + Bedrock: provider("provider-bedrock"), + DeepSeek: provider("provider-deepseek"), + Gemini: provider("provider-gemini"), + LMStudio: provider("provider-lmstudio"), + LiteLLM: provider("provider-litellm"), + Mistral: provider("provider-mistral"), + Moonshot: provider("provider-moonshot"), + KimiCode: provider("provider-kimi-code"), + Ollama: provider("provider-ollama"), + OpenAI: provider("provider-openai-native"), + OpenAICompatible: provider("provider-openai"), + OpenAICodex: provider("provider-openai-codex"), + OpenRouter: provider("provider-openrouter"), + Poe: provider("provider-poe"), + QwenCode: provider("provider-qwen-code"), + Requesty: provider("provider-requesty"), + SambaNova: provider("provider-sambanova"), + Unbound: provider("provider-unbound"), + Vertex: provider("provider-vertex"), + VSCodeLM: provider("provider-vscode-lm"), + XAI: provider("provider-xai"), + ZAi: provider("provider-zai"), + Fireworks: provider("provider-fireworks"), + Friendli: provider("provider-friendli"), + VercelAiGateway: provider("provider-vercel-ai-gateway"), + OpenCodeGo: provider("provider-opencode-go"), + Kenari: provider("provider-kenari"), + ZooGateway: provider("provider-zoo-gateway"), + MiniMax: provider("provider-minimax"), + Mimo: provider("provider-mimo"), } }) @@ -104,7 +114,9 @@ vi.mock("../providers/BedrockCustomArn", () => ({ BedrockCustomArn: () =>
, })) vi.mock("../ModelPicker", () => ({ ModelPicker: () => null })) -vi.mock("../ApiErrorMessage", () => ({ ApiErrorMessage: () => null })) +vi.mock("../ApiErrorMessage", () => ({ + ApiErrorMessage: ({ errorMessage }: { errorMessage: string }) =>
{String(errorMessage)}
, +})) vi.mock("../ThinkingBudget", () => ({ ThinkingBudget: () => null })) vi.mock("../Verbosity", () => ({ Verbosity: () => null })) vi.mock("../TodoListSettingsControl", () => ({ TodoListSettingsControl: () => null })) @@ -143,11 +155,17 @@ vi.mock("@/components/ui", () => ({ Collapsible: ({ children }: ChildrenProps) =>
{children}
, CollapsibleTrigger: ({ children }: ChildrenProps) =>
{children}
, CollapsibleContent: ({ children }: ChildrenProps) =>
{children}
, - Select: ({ children }: ChildrenProps) =>
{children}
, - SelectTrigger: ({ children }: ChildrenProps) =>
{children}
, + Select: ({ value, onValueChange, children }: SelectMockProps) => ( + + ), + SelectTrigger: ({ children }: ChildrenProps) => <>{children}, SelectValue: () => null, - SelectContent: ({ children }: ChildrenProps) =>
{children}
, - SelectItem: ({ children }: ChildrenProps) =>
{children}
, + SelectContent: ({ children }: ChildrenProps) => <>{children}, + SelectItem: ({ value, children }: { value?: string; children?: ReactNode }) => ( + + ), })) const renderApiOptions = (props: Partial = {}) => @@ -163,6 +181,15 @@ const renderApiOptions = (props: Partial = {}) => ) describe("ApiOptions interactions", () => { + beforeEach(() => { + useSelectedModelMock.mockImplementation((configuration: ProviderSettings) => ({ + provider: configuration.apiProvider, + id: configuration.apiModelId, + info: {}, + })) + useOpenRouterModelProvidersMock.mockImplementation(() => ({ data: undefined })) + }) + afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() @@ -226,6 +253,46 @@ describe("ApiOptions interactions", () => { expect(postMessage).toHaveBeenCalledWith(expectedMessage) }) + it("applies the header transform when requesting OpenAI models", () => { + vi.useFakeTimers() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + + renderApiOptions({ + apiConfiguration: { + apiProvider: providerIdentifiers.openai, + openAiBaseUrl: "https://openai.example/v1", + openAiApiKey: "openai-key", + openAiHeaders: { "": "ignored", "X-Keep": " kept" }, + }, + }) + act(() => vi.advanceTimersByTime(250)) + + expect(postMessage).toHaveBeenCalledWith({ + type: "requestOpenAiModels", + values: { + baseUrl: "https://openai.example/v1", + apiKey: "openai-key", + customHeaders: {}, + openAiHeaders: { "X-Keep": "kept" }, + }, + }) + }) + + it("syncs processed custom headers into the configuration", () => { + vi.useFakeTimers() + const setApiConfigurationField = vi.fn() + + // The empty header key is dropped by convertHeadersToObject, so the + // processed object differs from the stored one and the sync fires. + renderApiOptions({ + apiConfiguration: { apiProvider: providerIdentifiers.openai, openAiHeaders: { "": "ignored" } }, + setApiConfigurationField, + }) + act(() => vi.advanceTimersByTime(300)) + + expect(setApiConfigurationField).toHaveBeenCalledWith("openAiHeaders", {}, false) + }) + it("requests LM Studio models using its configured base URL", () => { vi.useFakeTimers() renderApiOptions({ @@ -255,6 +322,7 @@ describe("ApiOptions interactions", () => { }) it.each([ + providerIdentifiers.openrouter, providerIdentifiers.requesty, providerIdentifiers.unbound, providerIdentifiers.anthropic, @@ -263,7 +331,9 @@ describe("ApiOptions interactions", () => { providerIdentifiers.mistral, providerIdentifiers.baseten, providerIdentifiers.bedrock, + providerIdentifiers.vertex, providerIdentifiers.gemini, + providerIdentifiers.openai, providerIdentifiers.lmstudio, providerIdentifiers.deepseek, providerIdentifiers.qwenCode, @@ -271,18 +341,23 @@ describe("ApiOptions interactions", () => { providerIdentifiers.kimiCode, providerIdentifiers.minimax, providerIdentifiers.mimo, + providerIdentifiers.vscodeLm, providerIdentifiers.ollama, + providerIdentifiers.xai, providerIdentifiers.litellm, providerIdentifiers.sambanova, providerIdentifiers.zai, - providerIdentifiers.xai, - providerIdentifiers.fireworks, - providerIdentifiers.friendli, providerIdentifiers.vercelAiGateway, providerIdentifiers.opencodeGo, - ])("renders the canonical %s provider branch", (apiProvider) => { - const { unmount } = renderApiOptions({ apiConfiguration: { apiProvider } }) - unmount() + providerIdentifiers.kenari, + providerIdentifiers.zooGateway, + providerIdentifiers.fireworks, + providerIdentifiers.friendli, + providerIdentifiers.poe, + ])("renders the %s provider branch when selected", (apiProvider) => { + renderApiOptions({ apiConfiguration: { apiProvider } }) + + expect(screen.getByTestId(`provider-${apiProvider}`)).toBeInTheDocument() }) it("clears parent validation errors for Zoo Gateway", () => { @@ -292,6 +367,23 @@ describe("ApiOptions interactions", () => { expect(setErrorMessage).toHaveBeenCalledWith(undefined) }) + it("reports a validation error for a non-gateway provider with missing credentials", () => { + const setErrorMessage = vi.fn() + renderApiOptions({ apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, setErrorMessage }) + + expect(setErrorMessage).toHaveBeenCalled() + expect(setErrorMessage.mock.calls[0][0]).toBeTruthy() + }) + + it("renders the current validation error message", () => { + renderApiOptions({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + errorMessage: "settings:validation.apiKey", + }) + + expect(screen.getByText("settings:validation.apiKey")).toBeInTheDocument() + }) + it("renders OpenRouter provider routing when provider metadata is available", () => { useOpenRouterModelProvidersMock.mockReturnValue({ data: { preferred: { label: "Preferred", contextWindow: 1, supportsPromptCache: false } }, @@ -307,6 +399,37 @@ describe("ApiOptions interactions", () => { expect(screen.getByText("settings:providers.openRouter.providerRouting.title")).toBeInTheDocument() }) + it("updates the OpenRouter specific provider from the routing control", () => { + useOpenRouterModelProvidersMock.mockReturnValue({ + data: { preferred: { label: "Preferred", contextWindow: 1, supportsPromptCache: false } }, + }) + const setApiConfigurationField = vi.fn() + + renderApiOptions({ + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "anthropic/claude-sonnet-4.5", + }, + setApiConfigurationField, + }) + + fireEvent.change(screen.getByTestId("routing-select"), { target: { value: "preferred" } }) + expect(setApiConfigurationField).toHaveBeenCalledWith("openRouterSpecificProvider", "preferred") + }) + + it("hides OpenRouter provider routing when no provider metadata is available", () => { + useOpenRouterModelProvidersMock.mockReturnValue({ data: {} }) + + renderApiOptions({ + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "anthropic/claude-sonnet-4.5", + }, + }) + + expect(screen.queryByTestId("routing-select")).not.toBeInTheDocument() + }) + it("preserves the Bedrock custom ARN pseudo-model when switching to Bedrock", () => { const setApiConfigurationField = vi.fn() renderApiOptions({ @@ -361,6 +484,18 @@ describe("ApiOptions interactions", () => { expect(screen.queryByTestId("bedrock-custom-arn")).not.toBeInTheDocument() }) + it("syncs the selected model into the config when the model id differs", () => { + useSelectedModelMock.mockReturnValue({ provider: providerIdentifiers.anthropic, id: "claude-sonnet", info: {} }) + const setApiConfigurationField = vi.fn() + + renderApiOptions({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "old-model" }, + setApiConfigurationField, + }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("apiModelId", "claude-sonnet", false) + }) + it("updates the consecutive mistake limit from advanced settings", () => { const setApiConfigurationField = vi.fn() renderApiOptions({ apiConfiguration: {}, setApiConfigurationField }) From 291c4ef0e4a3be56a01dd5267fc06563bb543081 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 8 Aug 2026 20:11:01 +0000 Subject: [PATCH 3/3] fix(webview): use providerIdentifiers.openrouter in ApiOptions option sort --- webview-ui/src/components/settings/ApiOptions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 78161455c7..0cc61052db 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -398,7 +398,7 @@ const ApiOptions = ({ })) if (fromWelcomeView) { - const openRouterIndex = options.findIndex((opt) => opt.value === "openrouter") + const openRouterIndex = options.findIndex((opt) => opt.value === providerIdentifiers.openrouter) if (openRouterIndex > 0) { const [openRouterOption] = options.splice(openRouterIndex, 1) options.unshift(openRouterOption)