Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/core/webview/__tests__/webviewMessageHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,70 @@ describe("webviewMessageHandler - requestOllamaModels", () => {
ollamaModels: mockModels,
})
})

it("posts empty models response when no models are found", async () => {
mockGetModels.mockResolvedValue({})

await webviewMessageHandler(mockClineProvider, {
type: "requestOllamaModels",
})

expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "ollamaModels",
ollamaModels: {},
})
})

it("posts empty models response with error message and logs to output on fetch failure", async () => {
mockGetModels.mockRejectedValue(new Error("Connection refused"))

await webviewMessageHandler(mockClineProvider, {
type: "requestOllamaModels",
})

expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "ollamaModels",
ollamaModels: {},
error: "Connection refused",
})

expect(mockClineProvider.log).toHaveBeenCalledWith(
expect.stringContaining("[requestOllamaModels] Failed to fetch models: Connection refused"),
)
})

it("uses baseUrl from message values over saved state", async () => {
const mockModels: ModelRecord = {
"remote-model": {
maxTokens: 4096,
contextWindow: 8192,
supportsPromptCache: false,
description: "Remote model",
},
}

mockGetModels.mockResolvedValue(mockModels)

await webviewMessageHandler(mockClineProvider, {
type: "requestOllamaModels",
values: {
baseUrl: "https://ollama.example.com",
apiKey: "secret-key",
},
})

// Should use the URL from message values, not the saved state
expect(mockGetModels).toHaveBeenCalledWith({
provider: "ollama",
baseUrl: "https://ollama.example.com",
apiKey: "secret-key",
})

expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mockGetModels is asserted with the right options, but mockFlushModels isn’t checked here. If flushModels used the stale saved-state URL while getModels used the form-edited one, this test would still pass. Worth adding:

expect(mockFlushModels).toHaveBeenCalledWith(
  { provider: "ollama", baseUrl: "https://ollama.example.com", apiKey: "secret-key" },
  true
)

type: "ollamaModels",
ollamaModels: mockModels,
})
})
})

describe("webviewMessageHandler - requestRouterModels", () => {
Expand Down
29 changes: 22 additions & 7 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1203,23 +1203,38 @@ export const webviewMessageHandler = async (
case "requestOllamaModels": {
// Specific handler for Ollama models only.
const { apiConfiguration: ollamaApiConfig } = await provider.getState()
// Prefer the baseUrl/apiKey from the message values (which reflect
// the user's unsaved edits in the settings form) over the saved
// state, so the refresh uses the URL the user is actually looking
// at — not the stale one from before they started editing.
const baseUrl = message.values?.baseUrl ?? ollamaApiConfig.ollamaBaseUrl
const apiKey = message.values?.apiKey ?? ollamaApiConfig.ollamaApiKey
try {
const ollamaOptions = {
provider: "ollama" as const,
baseUrl: ollamaApiConfig.ollamaBaseUrl,
apiKey: ollamaApiConfig.ollamaApiKey,
baseUrl,
apiKey,
}
// Flush cache and refresh to ensure fresh models.
await flushModels(ollamaOptions, true)

const ollamaModels = await getModels(ollamaOptions)

if (Object.keys(ollamaModels).length > 0) {
provider.postMessageToWebview({ type: "ollamaModels", ollamaModels: ollamaModels })
}
// Always post a response so the webview refresh status can
// transition out of "loading" — even when no models are found.
provider.postMessageToWebview({ type: "ollamaModels", ollamaModels: ollamaModels })
} catch (error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both flushModels and getModels can throw into this catch, but the log says "Failed to fetch models" in both cases. A cache-layer failure and a network failure would look identical in the output channel — worth distinguishing?

// Silently fail - user hasn't configured Ollama yet
console.debug("Ollama models fetch failed:", error)
// Log the error to the output channel for debugging, but still
// post an empty response so the webview doesn't stay stuck in
// the loading state. Include the error message so the webview
// can display it directly in the settings panel.
const errorMsg = error instanceof Error ? error.message : String(error)
provider.log(`[requestOllamaModels] Failed to fetch models: ${errorMsg}`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the user has typed a new URL but not saved, this log won’t show which host was actually attempted. Worth including baseUrl?

Suggested change
provider.log(`[requestOllamaModels] Failed to fetch models: ${errorMsg}`)
provider.log(`[requestOllamaModels] Failed to fetch models from ${baseUrl}: ${errorMsg}`)

provider.postMessageToWebview({
type: "ollamaModels",
ollamaModels: {},
error: errorMsg,
})
}
break
}
Expand Down
8 changes: 7 additions & 1 deletion webview-ui/src/components/settings/ApiOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,13 @@ const ApiOptions = ({
},
})
} else if (selectedProvider === "ollama") {
vscode.postMessage({ type: "requestOllamaModels" })
vscode.postMessage({
type: "requestOllamaModels",
values: {
baseUrl: apiConfiguration?.ollamaBaseUrl,
apiKey: apiConfiguration?.ollamaApiKey,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ollamaApiKey is now forwarded in values, but it’s not in the useDebounce dep array (further down around the dep list). Should it be, so a key-only change also triggers a re-fetch?

},
})
} else if (selectedProvider === "lmstudio") {
requestLmStudioModels(apiConfiguration?.lmStudioBaseUrl)
} else if (selectedProvider === "vscode-lm") {
Expand Down
73 changes: 62 additions & 11 deletions webview-ui/src/components/settings/providers/Ollama.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { useState, useCallback, useMemo, useEffect } from "react"
import { useEvent } from "react-use"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { Checkbox } from "vscrui"

import { type ProviderSettings, type ExtensionMessage, type ModelRecord, ollamaDefaultModelInfo } from "@roo-code/types"

import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useRouterModels } from "@src/components/ui/hooks/useRouterModels"
import { Button } from "@src/components/ui"
import { vscode } from "@src/utils/vscode"

import { inputEventTransform } from "../transforms"
Expand All @@ -22,6 +22,8 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
const { t } = useAppTranslation()

const [ollamaModels, setOllamaModels] = useState<ModelRecord>({})
const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
const [refreshError, setRefreshError] = useState<string | undefined>()
const routerModels = useRouterModels()

const handleInputChange = useCallback(
Expand All @@ -35,20 +37,42 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
[setApiConfigurationField],
)

const onMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message: ExtensionMessage = event.data

if (message.type === "ollamaModels") {
const newModels = message.ollamaModels ?? {}
setOllamaModels(newModels)

switch (message.type) {
case "ollamaModels":
{
const newModels = message.ollamaModels ?? {}
setOllamaModels(newModels)
if (refreshStatus === "loading") {
if (Object.keys(newModels).length > 0) {
setRefreshStatus("success")
} else {
setRefreshStatus("error")
Comment on lines +49 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When Ollama is running but no models are installed, the backend posts { ollamaModels: {} } with no error field — so message.error is undefined here and the component shows the generic error string. Is a healthy-but-empty Ollama server really the same as a fetch failure?

Suggested change
if (Object.keys(newModels).length > 0) {
setRefreshStatus("success")
} else {
setRefreshStatus("error")
} else if (message.error) {
setRefreshStatus("error")
setRefreshError(message.error)
} else {
// Ollama reachable but no models installed yet
setRefreshStatus("success")

setRefreshError(message.error)
}
}
break
}
}
}, [])

useEvent("message", onMessage)
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [refreshStatus])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The [refreshStatus] dep tears down and re-adds the listener on the idle→loading transition — which fires synchronously right before vscode.postMessage(...) at line 66. If Ollama responds very quickly, could the response arrive in the brief gap and be silently dropped, leaving the UI stuck in loading?

A useRef for refreshStatus with a stable [] dep would avoid the re-subscription.


const handleRefreshModels = useCallback(() => {
setRefreshStatus("loading")
setRefreshError(undefined)
vscode.postMessage({
type: "requestOllamaModels",
values: {
baseUrl: apiConfiguration?.ollamaBaseUrl,
apiKey: apiConfiguration?.ollamaApiKey,
},
})
}, [apiConfiguration?.ollamaBaseUrl, apiConfiguration?.ollamaApiKey])

// Refresh models on mount
useEffect(() => {
Expand Down Expand Up @@ -102,6 +126,33 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
</div>
</VSCodeTextField>
)}
<Button
variant="outline"
onClick={handleRefreshModels}
disabled={refreshStatus === "loading"}
className="w-full">
<div className="flex items-center gap-2">
{refreshStatus === "loading" ? (
<span className="codicon codicon-loading codicon-modifier-spin" />
) : (
<span className="codicon codicon-refresh" />
)}
{t("settings:providers.refreshModels.label")}
</div>
</Button>
{refreshStatus === "loading" && (
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.refreshModels.loading")}
</div>
)}
{refreshStatus === "success" && (
<div className="text-sm text-vscode-foreground">{t("settings:providers.refreshModels.success")}</div>
)}
{refreshStatus === "error" && (
<div className="text-sm text-vscode-errorForeground">
{refreshError || t("settings:providers.refreshModels.error")}
</div>
)}
<ModelPicker
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
Expand Down
Loading
Loading