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
94 changes: 94 additions & 0 deletions src/core/webview/__tests__/webviewMessageHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,9 @@ describe("webviewMessageHandler - image mentions", () => {
describe("webviewMessageHandler - requestOllamaModels", () => {
beforeEach(() => {
vi.clearAllMocks()
mockFlushModels.mockReset()
mockFlushModels.mockResolvedValue(undefined)
mockGetModels.mockReset()
mockClineProvider.getState = vi.fn().mockResolvedValue({
apiConfiguration: {
ollamaModelId: "model-1",
Expand Down Expand Up @@ -331,6 +334,97 @@ 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(
"[requestOllamaModels] Failed to read models for http://localhost:1234: Connection refused",
)
})

it("distinguishes a model cache refresh failure from a model read failure", async () => {
mockFlushModels.mockRejectedValue(new Error("Cache write failed"))

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

expect(mockGetModels).not.toHaveBeenCalled()
expect(mockClineProvider.log).toHaveBeenCalledWith(
"[requestOllamaModels] Failed to refresh model cache for https://ollama.example.com: Cache write failed",
)
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "ollamaModels",
ollamaModels: {},
error: "Cache write failed",
})
})

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(mockFlushModels).toHaveBeenCalledWith(
{
provider: "ollama",
baseUrl: "https://ollama.example.com",
apiKey: "secret-key",
},
true,
)
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
47 changes: 36 additions & 11 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1221,23 +1221,48 @@ 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
const logBaseUrl = baseUrl || "http://localhost:11434"
const ollamaOptions = {
provider: "ollama" as const,
baseUrl,
apiKey,
}
try {
const ollamaOptions = {
provider: "ollama" as const,
baseUrl: ollamaApiConfig.ollamaBaseUrl,
apiKey: ollamaApiConfig.ollamaApiKey,
}
// Flush cache and refresh to ensure fresh models.
// Refresh the cache before reading the models. Keep this error
// separate from the read below so diagnostics identify which
// cache operation failed.
await flushModels(ollamaOptions, true)
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
provider.log(`[requestOllamaModels] Failed to refresh model cache for ${logBaseUrl}: ${errorMsg}`)
provider.postMessageToWebview({
type: "ollamaModels",
ollamaModels: {},
error: errorMsg,
})
break
}

try {
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 })
} 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)
const errorMsg = error instanceof Error ? error.message : String(error)
provider.log(`[requestOllamaModels] Failed to read models for ${logBaseUrl}: ${errorMsg}`)
provider.postMessageToWebview({
type: "ollamaModels",
ollamaModels: {},
error: errorMsg,
})
}
break
}
Expand Down
9 changes: 8 additions & 1 deletion webview-ui/src/components/settings/ApiOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,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 All @@ -243,6 +249,7 @@ const ApiOptions = ({
apiConfiguration?.openAiBaseUrl,
apiConfiguration?.openAiApiKey,
apiConfiguration?.ollamaBaseUrl,
apiConfiguration?.ollamaApiKey,
apiConfiguration?.lmStudioBaseUrl,
apiConfiguration?.litellmBaseUrl,
apiConfiguration?.litellmApiKey,
Expand Down
74 changes: 63 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 { useState, useCallback, useMemo, useEffect, useRef } from "react"
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,9 @@ 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 refreshStatusRef = useRef(refreshStatus)
const routerModels = useRouterModels()

const handleInputChange = useCallback(
Expand All @@ -35,20 +38,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") {
if (!message.error) {
setOllamaModels(message.ollamaModels ?? {})
}

switch (message.type) {
case "ollamaModels":
{
const newModels = message.ollamaModels ?? {}
setOllamaModels(newModels)
if (refreshStatusRef.current === "loading") {
const nextStatus = message.error ? "error" : "success"
refreshStatusRef.current = nextStatus
setRefreshStatus(nextStatus)
setRefreshError(message.error)
}
break
}
}

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

useEvent("message", onMessage)
const handleRefreshModels = useCallback(() => {
refreshStatusRef.current = "loading"
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 +127,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