Skip to content

Commit 0306f2b

Browse files
fix(opencode-go): fetch models unconditionally — the /models endpoint is public (#437)
* fix(opencode-go): fetch models unconditionally — the /models endpoint is public 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. * 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 * add refresh button and update coverage --------- Co-authored-by: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Co-authored-by: Naved Merchant <naved.merchant@gmail.com>
1 parent f792c1b commit 0306f2b

5 files changed

Lines changed: 269 additions & 18 deletions

File tree

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2580,6 +2580,8 @@ describe("ClineProvider - Router Models", () => {
25802580
apiKey: "litellm-key",
25812581
baseUrl: "http://localhost:4000",
25822582
})
2583+
// Opencode Go's /models endpoint is public, so it is fetched like the other no-auth routers.
2584+
expect(getModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "opencode-go" }))
25832585

25842586
// Verify response was sent
25852587
expect(mockPostMessage).toHaveBeenCalledWith({
@@ -2595,7 +2597,7 @@ describe("ClineProvider - Router Models", () => {
25952597
lmstudio: {},
25962598
poe: {},
25972599
deepseek: {},
2598-
"opencode-go": {},
2600+
"opencode-go": mockModels,
25992601
},
26002602
values: undefined,
26012603
})
@@ -2627,6 +2629,7 @@ describe("ClineProvider - Router Models", () => {
26272629
.mockResolvedValueOnce(mockModels) // vercel-ai-gateway success
26282630
.mockResolvedValueOnce(mockModels) // zoo-gateway success
26292631
.mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm fail
2632+
.mockResolvedValueOnce(mockModels) // opencode-go (public endpoint)
26302633

26312634
await messageHandler({ type: "requestRouterModels" })
26322635

@@ -2644,7 +2647,7 @@ describe("ClineProvider - Router Models", () => {
26442647
litellm: {},
26452648
poe: {},
26462649
deepseek: {},
2647-
"opencode-go": {},
2650+
"opencode-go": mockModels,
26482651
},
26492652
values: undefined,
26502653
})
@@ -2741,7 +2744,7 @@ describe("ClineProvider - Router Models", () => {
27412744
lmstudio: {},
27422745
poe: {},
27432746
deepseek: {},
2744-
"opencode-go": {},
2747+
"opencode-go": mockModels,
27452748
},
27462749
values: undefined,
27472750
})

src/core/webview/__tests__/webviewMessageHandler.spec.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,8 @@ describe("webviewMessageHandler - requestRouterModels", () => {
366366
apiKey: "litellm-key",
367367
baseUrl: "http://localhost:4000",
368368
})
369+
// Opencode Go's /models endpoint is public, so it is fetched like the other no-auth routers.
370+
expect(mockGetModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "opencode-go" }))
369371

370372
// Verify response was sent
371373
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
@@ -381,12 +383,41 @@ describe("webviewMessageHandler - requestRouterModels", () => {
381383
lmstudio: {},
382384
poe: {},
383385
deepseek: {},
384-
"opencode-go": {},
386+
"opencode-go": mockModels,
385387
},
386388
values: undefined,
387389
})
388390
})
389391

392+
it("fetches Opencode Go models without an API key (public /models endpoint, regression for empty picker)", async () => {
393+
mockClineProvider.getState = vi.fn().mockResolvedValue({
394+
apiConfiguration: {
395+
openRouterApiKey: "openrouter-key",
396+
// Deliberately no opencodeGoApiKey — the endpoint is public.
397+
},
398+
})
399+
400+
const mockModels: ModelRecord = {
401+
"glm-5.1": {
402+
maxTokens: 4096,
403+
contextWindow: 8192,
404+
supportsPromptCache: false,
405+
description: "GLM 5.1",
406+
},
407+
}
408+
mockGetModels.mockResolvedValue(mockModels)
409+
410+
await webviewMessageHandler(mockClineProvider, { type: "requestRouterModels" })
411+
412+
// Must be fetched despite no configured key, forwarding apiKey: undefined.
413+
expect(mockGetModels).toHaveBeenCalledWith({ provider: "opencode-go", apiKey: undefined })
414+
415+
const routerModelsCall = (mockClineProvider.postMessageToWebview as any).mock.calls.find(
416+
([msg]: [{ type: string }]) => msg.type === "routerModels",
417+
)
418+
expect(routerModelsCall?.[0].routerModels["opencode-go"]).toEqual(mockModels)
419+
})
420+
390421
it("handles LiteLLM models with values from message when config is missing", async () => {
391422
mockClineProvider.getState = vi.fn().mockResolvedValue({
392423
apiConfiguration: {
@@ -469,7 +500,7 @@ describe("webviewMessageHandler - requestRouterModels", () => {
469500
lmstudio: {},
470501
poe: {},
471502
deepseek: {},
472-
"opencode-go": {},
503+
"opencode-go": mockModels,
473504
},
474505
values: undefined,
475506
})
@@ -493,6 +524,7 @@ describe("webviewMessageHandler - requestRouterModels", () => {
493524
.mockResolvedValueOnce(mockModels) // vercel-ai-gateway
494525
.mockResolvedValueOnce(mockModels) // zoo-gateway
495526
.mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm
527+
.mockResolvedValueOnce(mockModels) // opencode-go
496528

497529
await webviewMessageHandler(mockClineProvider, {
498530
type: "requestRouterModels",
@@ -527,7 +559,7 @@ describe("webviewMessageHandler - requestRouterModels", () => {
527559
lmstudio: {},
528560
poe: {},
529561
deepseek: {},
530-
"opencode-go": {},
562+
"opencode-go": mockModels,
531563
},
532564
values: undefined,
533565
})

src/core/webview/webviewMessageHandler.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,20 +1034,23 @@ export const webviewMessageHandler = async (
10341034
})
10351035
}
10361036

1037-
// Opencode Go is conditional on apiKey (its /models endpoint requires auth)
1037+
// Opencode Go's /models endpoint is public — it returns the full model list with no
1038+
// Authorization header — so it's fetched unconditionally like openrouter/vercel-ai-gateway
1039+
// above. Gating it behind a key meant the picker stayed empty (and fell back to the default
1040+
// model) whenever the key wasn't yet in apiConfiguration at fetch time. The key is still
1041+
// forwarded when present.
10381042
const opencodeGoApiKey = message?.values?.opencodeGoApiKey ?? apiConfiguration.opencodeGoApiKey
10391043

1040-
if (opencodeGoApiKey) {
1041-
if (message?.values?.opencodeGoApiKey) {
1042-
await flushModels({ provider: "opencode-go", apiKey: opencodeGoApiKey }, true)
1043-
}
1044-
1045-
candidates.push({
1046-
key: "opencode-go",
1047-
options: { provider: "opencode-go", apiKey: opencodeGoApiKey },
1048-
})
1044+
// Refresh the cache when a new key is explicitly provided (e.g. the Refresh Models button).
1045+
if (message?.values?.opencodeGoApiKey) {
1046+
await flushModels({ provider: "opencode-go", apiKey: opencodeGoApiKey }, true)
10491047
}
10501048

1049+
candidates.push({
1050+
key: "opencode-go",
1051+
options: { provider: "opencode-go", apiKey: opencodeGoApiKey },
1052+
})
1053+
10511054
// Apply single provider filter if specified
10521055
const modelFetchPromises = providerFilter
10531056
? candidates.filter(({ key }) => key === providerFilter)

webview-ui/src/components/settings/providers/OpenCodeGo.tsx

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
import { useCallback } from "react"
1+
import { useCallback, useState, useEffect, useRef } from "react"
22
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
33

44
import {
55
type ProviderSettings,
66
type OrganizationAllowList,
77
type RouterModels,
8+
type ExtensionMessage,
89
opencodeGoDefaultModelId,
910
} from "@roo-code/types"
1011

12+
import type { RouterName } from "@roo/api"
13+
14+
import { vscode } from "@src/utils/vscode"
1115
import { useAppTranslation } from "@src/i18n/TranslationContext"
1216
import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
17+
import { Button } from "@src/components/ui"
1318

1419
import { inputEventTransform } from "../transforms"
1520
import { ModelPicker } from "../ModelPicker"
@@ -32,6 +37,34 @@ export const OpenCodeGo = ({
3237
simplifySettings,
3338
}: OpenCodeGoProps) => {
3439
const { t } = useAppTranslation()
40+
const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
41+
const [refreshError, setRefreshError] = useState<string | undefined>()
42+
const errorJustReceived = useRef(false)
43+
44+
useEffect(() => {
45+
const handleMessage = (event: MessageEvent<ExtensionMessage>) => {
46+
const message = event.data
47+
if (message.type === "singleRouterModelFetchResponse" && !message.success) {
48+
const providerName = message.values?.provider as RouterName
49+
if (providerName === "opencode-go") {
50+
errorJustReceived.current = true
51+
setRefreshStatus("error")
52+
setRefreshError(message.error)
53+
}
54+
} else if (message.type === "routerModels") {
55+
if (refreshStatus === "loading") {
56+
if (!errorJustReceived.current) {
57+
setRefreshStatus("success")
58+
}
59+
}
60+
}
61+
}
62+
63+
window.addEventListener("message", handleMessage)
64+
return () => {
65+
window.removeEventListener("message", handleMessage)
66+
}
67+
}, [refreshStatus])
3568

3669
const handleInputChange = useCallback(
3770
<K extends keyof ProviderSettings, E>(
@@ -44,6 +77,16 @@ export const OpenCodeGo = ({
4477
[setApiConfigurationField],
4578
)
4679

80+
const handleRefreshModels = useCallback(() => {
81+
errorJustReceived.current = false
82+
setRefreshStatus("loading")
83+
setRefreshError(undefined)
84+
vscode.postMessage({
85+
type: "requestRouterModels",
86+
values: { provider: "opencode-go", refresh: true, opencodeGoApiKey: apiConfiguration.opencodeGoApiKey },
87+
})
88+
}, [apiConfiguration.opencodeGoApiKey])
89+
4790
return (
4891
<>
4992
<VSCodeTextField
@@ -62,6 +105,33 @@ export const OpenCodeGo = ({
62105
{t("settings:providers.getOpencodeGoApiKey")}
63106
</VSCodeButtonLink>
64107
)}
108+
<Button
109+
variant="outline"
110+
onClick={handleRefreshModels}
111+
disabled={refreshStatus === "loading"}
112+
className="w-full">
113+
<div className="flex items-center gap-2">
114+
{refreshStatus === "loading" ? (
115+
<span className="codicon codicon-loading codicon-modifier-spin" />
116+
) : (
117+
<span className="codicon codicon-refresh" />
118+
)}
119+
{t("settings:providers.refreshModels.label")}
120+
</div>
121+
</Button>
122+
{refreshStatus === "loading" && (
123+
<div className="text-sm text-vscode-descriptionForeground">
124+
{t("settings:providers.refreshModels.loading")}
125+
</div>
126+
)}
127+
{refreshStatus === "success" && (
128+
<div className="text-sm text-vscode-foreground">{t("settings:providers.refreshModels.success")}</div>
129+
)}
130+
{refreshStatus === "error" && (
131+
<div className="text-sm text-vscode-errorForeground">
132+
{refreshError || t("settings:providers.refreshModels.error")}
133+
</div>
134+
)}
65135
<ModelPicker
66136
apiConfiguration={apiConfiguration}
67137
setApiConfigurationField={setApiConfigurationField}

0 commit comments

Comments
 (0)