From 95d0fca665c15c53728ef3c41afb4792bbe8d790 Mon Sep 17 00:00:00 2001 From: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:21:29 -0600 Subject: [PATCH 1/3] =?UTF-8?q?fix(opencode-go):=20fetch=20models=20uncond?= =?UTF-8?q?itionally=20=E2=80=94=20the=20/models=20endpoint=20is=20public?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Opencode Go model fetch in `requestRouterModels` was gated behind `if (opencodeGoApiKey)` on the assumption that its `/models` endpoint requires auth. It does not: the endpoint returns the full model list (HTTP 200) with no Authorization header. Gating meant `routerModels["opencode-go"]` stayed `{}` whenever the key wasn't present in `apiConfiguration` at fetch time, so the model picker showed an empty list, fell back to the hardcoded default (`glm-5.1`), and offered no model to select. Fetch Opencode Go unconditionally like the other public routers (`openrouter`, `vercel-ai-gateway`), forwarding the API key when present and still flushing the cache when a new key is supplied. Updates the affected `requestRouterModels` tests and adds a regression test for the keyless path. --- .../__tests__/webviewMessageHandler.spec.ts | 38 +++++++++++++++++-- src/core/webview/webviewMessageHandler.ts | 23 ++++++----- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index ebea3c90b6..0c4dc62bbb 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -357,6 +357,8 @@ describe("webviewMessageHandler - requestRouterModels", () => { apiKey: "litellm-key", baseUrl: "http://localhost:4000", }) + // Opencode Go's /models endpoint is public, so it is fetched like the other no-auth routers. + expect(mockGetModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "opencode-go" })) // Verify response was sent expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ @@ -371,12 +373,41 @@ describe("webviewMessageHandler - requestRouterModels", () => { "vercel-ai-gateway": mockModels, poe: {}, deepseek: {}, - "opencode-go": {}, + "opencode-go": mockModels, }, values: undefined, }) }) + it("fetches Opencode Go models without an API key (public /models endpoint, regression for empty picker)", async () => { + mockClineProvider.getState = vi.fn().mockResolvedValue({ + apiConfiguration: { + openRouterApiKey: "openrouter-key", + // Deliberately no opencodeGoApiKey — the endpoint is public. + }, + }) + + const mockModels: ModelRecord = { + "glm-5.1": { + maxTokens: 4096, + contextWindow: 8192, + supportsPromptCache: false, + description: "GLM 5.1", + }, + } + mockGetModels.mockResolvedValue(mockModels) + + await webviewMessageHandler(mockClineProvider, { type: "requestRouterModels" }) + + // Must be fetched despite no configured key, forwarding apiKey: undefined. + expect(mockGetModels).toHaveBeenCalledWith({ provider: "opencode-go", apiKey: undefined }) + + const routerModelsCall = (mockClineProvider.postMessageToWebview as any).mock.calls.find( + ([msg]: [{ type: string }]) => msg.type === "routerModels", + ) + expect(routerModelsCall?.[0].routerModels["opencode-go"]).toEqual(mockModels) + }) + it("handles LiteLLM models with values from message when config is missing", async () => { mockClineProvider.getState = vi.fn().mockResolvedValue({ apiConfiguration: { @@ -458,7 +489,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { "vercel-ai-gateway": mockModels, poe: {}, deepseek: {}, - "opencode-go": {}, + "opencode-go": mockModels, }, values: undefined, }) @@ -481,6 +512,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { .mockResolvedValueOnce(mockModels) // unbound .mockResolvedValueOnce(mockModels) // vercel-ai-gateway .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm + .mockResolvedValueOnce(mockModels) // opencode-go await webviewMessageHandler(mockClineProvider, { type: "requestRouterModels", @@ -514,7 +546,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { "vercel-ai-gateway": mockModels, poe: {}, deepseek: {}, - "opencode-go": {}, + "opencode-go": mockModels, }, values: undefined, }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index ad22c28933..bfae94de34 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1013,20 +1013,23 @@ export const webviewMessageHandler = async ( }) } - // Opencode Go is conditional on apiKey (its /models endpoint requires auth) + // Opencode Go's /models endpoint is public — it returns the full model list with no + // Authorization header — so it's fetched unconditionally like openrouter/vercel-ai-gateway + // above. Gating it behind a key meant the picker stayed empty (and fell back to the default + // model) whenever the key wasn't yet in apiConfiguration at fetch time. The key is still + // forwarded when present. const opencodeGoApiKey = message?.values?.opencodeGoApiKey ?? apiConfiguration.opencodeGoApiKey - if (opencodeGoApiKey) { - if (message?.values?.opencodeGoApiKey) { - await flushModels({ provider: "opencode-go", apiKey: opencodeGoApiKey }, true) - } - - candidates.push({ - key: "opencode-go", - options: { provider: "opencode-go", apiKey: opencodeGoApiKey }, - }) + // Refresh the cache when a new key is explicitly provided (e.g. the Refresh Models button). + if (message?.values?.opencodeGoApiKey) { + await flushModels({ provider: "opencode-go", apiKey: opencodeGoApiKey }, true) } + candidates.push({ + key: "opencode-go", + options: { provider: "opencode-go", apiKey: opencodeGoApiKey }, + }) + // Apply single provider filter if specified const modelFetchPromises = providerFilter ? candidates.filter(({ key }) => key === providerFilter) From e06e845b0bc3d9415c67ad98abfec5fed214a973 Mon Sep 17 00:00:00 2001 From: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:01:57 -0600 Subject: [PATCH 2/3] test: update ClineProvider.spec.ts assertions for unconditional opencode-go fetch Mirror changes already made in webviewMessageHandler.spec.ts: - successful responses: expect opencode-go call, assert mockModels - individual provider failures: add mockResolvedValueOnce for opencode-go - skips LiteLLM: assert mockModels instead of empty object --- src/core/webview/__tests__/ClineProvider.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 4fe9e131be..fa995cbcf7 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2465,6 +2465,8 @@ describe("ClineProvider - Router Models", () => { apiKey: "litellm-key", baseUrl: "http://localhost:4000", }) + // Opencode Go's /models endpoint is public, so it is fetched like the other no-auth routers. + expect(getModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "opencode-go" })) // Verify response was sent expect(mockPostMessage).toHaveBeenCalledWith({ @@ -2479,7 +2481,7 @@ describe("ClineProvider - Router Models", () => { "vercel-ai-gateway": mockModels, poe: {}, deepseek: {}, - "opencode-go": {}, + "opencode-go": mockModels, }, values: undefined, }) @@ -2510,6 +2512,7 @@ describe("ClineProvider - Router Models", () => { .mockResolvedValueOnce(mockModels) // unbound success .mockResolvedValueOnce(mockModels) // vercel-ai-gateway success .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm fail + .mockResolvedValueOnce(mockModels) // opencode-go (public endpoint) await messageHandler({ type: "requestRouterModels" }) @@ -2526,7 +2529,7 @@ describe("ClineProvider - Router Models", () => { "vercel-ai-gateway": mockModels, poe: {}, deepseek: {}, - "opencode-go": {}, + "opencode-go": mockModels, }, values: undefined, }) @@ -2622,7 +2625,7 @@ describe("ClineProvider - Router Models", () => { "vercel-ai-gateway": mockModels, poe: {}, deepseek: {}, - "opencode-go": {}, + "opencode-go": mockModels, }, values: undefined, }) From 4a5684aa3207e37e645dfcfce704dd1e5c90e62b Mon Sep 17 00:00:00 2001 From: Naved Date: Thu, 18 Jun 2026 21:07:14 -0700 Subject: [PATCH 3/3] add refresh button and update coverage --- .../settings/providers/OpenCodeGo.tsx | 72 ++++++++- .../providers/__tests__/OpenCodeGo.spec.tsx | 145 +++++++++++++++++- 2 files changed, 215 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/components/settings/providers/OpenCodeGo.tsx b/webview-ui/src/components/settings/providers/OpenCodeGo.tsx index 8927c2e13f..f004d32f91 100644 --- a/webview-ui/src/components/settings/providers/OpenCodeGo.tsx +++ b/webview-ui/src/components/settings/providers/OpenCodeGo.tsx @@ -1,15 +1,20 @@ -import { useCallback } from "react" +import { useCallback, useState, useEffect, useRef } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { type ProviderSettings, type OrganizationAllowList, type RouterModels, + type ExtensionMessage, opencodeGoDefaultModelId, } from "@roo-code/types" +import type { RouterName } from "@roo/api" + +import { vscode } from "@src/utils/vscode" import { useAppTranslation } from "@src/i18n/TranslationContext" import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" +import { Button } from "@src/components/ui" import { inputEventTransform } from "../transforms" import { ModelPicker } from "../ModelPicker" @@ -32,6 +37,34 @@ export const OpenCodeGo = ({ simplifySettings, }: OpenCodeGoProps) => { const { t } = useAppTranslation() + const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshError, setRefreshError] = useState() + const errorJustReceived = useRef(false) + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "singleRouterModelFetchResponse" && !message.success) { + const providerName = message.values?.provider as RouterName + if (providerName === "opencode-go") { + errorJustReceived.current = true + setRefreshStatus("error") + setRefreshError(message.error) + } + } else if (message.type === "routerModels") { + if (refreshStatus === "loading") { + if (!errorJustReceived.current) { + setRefreshStatus("success") + } + } + } + } + + window.addEventListener("message", handleMessage) + return () => { + window.removeEventListener("message", handleMessage) + } + }, [refreshStatus]) const handleInputChange = useCallback( ( @@ -44,6 +77,16 @@ export const OpenCodeGo = ({ [setApiConfigurationField], ) + const handleRefreshModels = useCallback(() => { + errorJustReceived.current = false + setRefreshStatus("loading") + setRefreshError(undefined) + vscode.postMessage({ + type: "requestRouterModels", + values: { provider: "opencode-go", refresh: true, opencodeGoApiKey: apiConfiguration.opencodeGoApiKey }, + }) + }, [apiConfiguration.opencodeGoApiKey]) + return ( <> )} + + {refreshStatus === "loading" && ( +
+ {t("settings:providers.refreshModels.loading")} +
+ )} + {refreshStatus === "success" && ( +
{t("settings:providers.refreshModels.success")}
+ )} + {refreshStatus === "error" && ( +
+ {refreshError || t("settings:providers.refreshModels.error")} +
+ )} ({ ), })) +const { postMessageMock } = vi.hoisted(() => ({ + postMessageMock: vi.fn(), +})) + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: postMessageMock, + }, +})) + +// Stub the shared Button so we can assert onClick/disabled without its styling deps. +vi.mock("@src/components/ui", () => ({ + Button: ({ children, onClick, disabled, className }: any) => ( + + ), +})) + describe("OpenCodeGo", () => { const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } const mockSetApiConfigurationField = vi.fn() @@ -87,4 +106,128 @@ describe("OpenCodeGo", () => { expect(picker).toHaveAttribute("data-model-id-key", "opencodeGoModelId") expect(picker).toHaveAttribute("data-service-name", "Opencode Go") }) + + describe("refresh models", () => { + const dispatchMessage = (data: any) => + act(() => { + window.dispatchEvent(new MessageEvent("message", { data })) + }) + + it("renders the refresh button in idle state", () => { + renderComponent({ opencodeGoApiKey: "key" }) + + const button = screen.getByTestId("refresh-button") + expect(button).not.toBeDisabled() + expect(button.querySelector(".codicon-refresh")).not.toBeNull() + expect(screen.getByText("settings:providers.refreshModels.label")).toBeInTheDocument() + }) + + it("sends requestRouterModels with the current api key when clicked", () => { + renderComponent({ opencodeGoApiKey: "my-key" }) + + fireEvent.click(screen.getByTestId("refresh-button")) + + expect(postMessageMock).toHaveBeenCalledWith({ + type: "requestRouterModels", + values: { provider: "opencode-go", refresh: true, opencodeGoApiKey: "my-key" }, + }) + }) + + it("enters loading state and disables the button while refreshing", () => { + renderComponent({ opencodeGoApiKey: "key" }) + + fireEvent.click(screen.getByTestId("refresh-button")) + + const button = screen.getByTestId("refresh-button") + expect(button).toBeDisabled() + expect(button.querySelector(".codicon-loading")).not.toBeNull() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + it("shows success state when routerModels arrives while loading", () => { + renderComponent({ opencodeGoApiKey: "key" }) + + fireEvent.click(screen.getByTestId("refresh-button")) + dispatchMessage({ type: "routerModels" }) + + expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument() + }) + + it("shows error state with the received error message on fetch failure", () => { + renderComponent({ opencodeGoApiKey: "key" }) + + fireEvent.click(screen.getByTestId("refresh-button")) + dispatchMessage({ + type: "singleRouterModelFetchResponse", + success: false, + values: { provider: "opencode-go" }, + 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({ opencodeGoApiKey: "key" }) + + fireEvent.click(screen.getByTestId("refresh-button")) + dispatchMessage({ + type: "singleRouterModelFetchResponse", + success: false, + values: { provider: "opencode-go" }, + }) + + expect(screen.getByText("settings:providers.refreshModels.error")).toBeInTheDocument() + }) + + it("ignores fetch failures for other providers", () => { + renderComponent({ opencodeGoApiKey: "key" }) + + fireEvent.click(screen.getByTestId("refresh-button")) + dispatchMessage({ + type: "singleRouterModelFetchResponse", + success: false, + values: { provider: "openrouter" }, + 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({ opencodeGoApiKey: "key" }) + + fireEvent.click(screen.getByTestId("refresh-button")) + + // 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: "singleRouterModelFetchResponse", + success: false, + values: { provider: "opencode-go" }, + error: "boom", + }, + }), + ) + window.dispatchEvent(new MessageEvent("message", { data: { type: "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({ opencodeGoApiKey: "key" }) + + // No refresh initiated; an unsolicited routerModels message should be a no-op. + dispatchMessage({ type: "routerModels" }) + + expect(screen.queryByText("settings:providers.refreshModels.success")).not.toBeInTheDocument() + expect(screen.queryByText("settings:providers.refreshModels.loading")).not.toBeInTheDocument() + }) + }) })