diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index 3bba9f8d7b..c2a8bfd323 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -43,6 +43,7 @@ import { setDisabledTools, setTelemetryOptOutGlobally, toggleDisabledTool, + updateLocalProvider, updateMcpSettingsFileSync, } from "@cline/core"; import { @@ -1522,6 +1523,17 @@ export async function handleCommand( : undefined, }); } + if (command === "update_provider_models") { + const providerId = String(args?.provider ?? "").trim(); + const manager = new ProviderSettingsManager(); + await ensureCustomProvidersLoaded(manager); + return await updateLocalProvider(manager, { + providerId, + models: Array.isArray(args?.models) + ? (args.models as string[]) + : undefined, + }); + } if (command === "run_provider_oauth_login") { const providerId = normalizeOAuthProvider(String(args?.provider ?? "")); const manager = new ProviderSettingsManager(); diff --git a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx index bc309db69b..940fea4a5b 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx @@ -7,16 +7,20 @@ import { WorkspaceProvider } from "@/contexts/workspace-context"; import type { ChatSessionStatus } from "@/lib/chat-schema"; import { ChatInputBar } from "./chat-input-bar"; -const { loadProviderModelCatalogMock, loadProviderModelsMock } = vi.hoisted( - () => ({ - loadProviderModelCatalogMock: vi.fn(), - loadProviderModelsMock: vi.fn(), - }), -); +const { + loadProviderModelCatalogMock, + loadProviderModelsMock, + subscribeToProviderModelsMock, +} = vi.hoisted(() => ({ + loadProviderModelCatalogMock: vi.fn(), + loadProviderModelsMock: vi.fn(), + subscribeToProviderModelsMock: vi.fn(() => vi.fn()), +})); vi.mock("@/lib/provider-model-catalog", () => ({ loadProviderModelCatalog: loadProviderModelCatalogMock, loadProviderModels: loadProviderModelsMock, + subscribeToProviderModels: subscribeToProviderModelsMock, })); let container: HTMLDivElement; @@ -31,6 +35,7 @@ beforeEach(() => { providerReasoningModels: { cline: [] }, }); loadProviderModelsMock.mockReset().mockResolvedValue([]); + subscribeToProviderModelsMock.mockReset().mockReturnValue(vi.fn()); HTMLElement.prototype.scrollIntoView = vi.fn(); HTMLElement.prototype.hasPointerCapture = vi.fn(() => false); HTMLElement.prototype.setPointerCapture = vi.fn(); @@ -100,6 +105,16 @@ describe("ChatInputBar", () => { }; await render("idle"); + await vi.waitFor(() => { + expect(loadProviderModelsMock).toHaveBeenCalledWith("cline"); + }); + const providerModelsListener = + subscribeToProviderModelsMock.mock.calls[0]?.[0]; + await act(async () => { + providerModelsListener?.("cline", [ + { id: "refreshed-model", name: "Refreshed model" }, + ]); + }); await vi.waitFor(() => { const trigger = container.querySelector( '[aria-label="Thinking level"]', @@ -125,6 +140,7 @@ describe("ChatInputBar", () => { expect( container.querySelectorAll('[aria-label^="Model:"]'), ).toHaveLength(2); + expect(container.textContent).toContain("refreshed-model"); await act(async () => container .querySelector('[aria-label="Close model selector"]') diff --git a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx index dd9b0c52b3..a3c97305ec 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx @@ -38,6 +38,7 @@ import { normalizeProviderId } from "@/lib/provider-id"; import { loadProviderModelCatalog, loadProviderModels, + subscribeToProviderModels, } from "@/lib/provider-model-catalog"; import { cn } from "@/lib/utils"; import { WorkspaceSelector as WorkspaceSelectorImpl } from "./workspace-selector"; @@ -1114,7 +1115,7 @@ const ModelSelector = memo(function ModelSelector({ let cancelled = false; setReasoningCapabilitySource("loading"); - async function loadCatalog() { + async function loadCatalogAndActiveModels() { try { const payload = await loadProviderModelCatalog(); if (cancelled) { @@ -1138,41 +1139,24 @@ const ModelSelector = memo(function ModelSelector({ } catch { if (!cancelled) setReasoningCapabilitySource("fallback"); } - } - void loadCatalog(); - return () => { - cancelled = true; - }; - }, [normalizedProvider]); - - useEffect(() => { - if (!normalizedProvider) { - return; - } - if ((providerModels[normalizedProvider] ?? []).length > 0) { - return; - } - - let cancelled = false; - - async function loadModelsForProvider() { + if (!normalizedProvider || cancelled) { + return; + } try { const models = await loadProviderModels(normalizedProvider); if (cancelled || models.length === 0) { return; } - const modelIds = models.map((entry) => entry.id); - const reasoningModelIds = models - .filter((entry) => entry.supportsReasoning) - .map((entry) => entry.id); setProviderModels((current) => ({ ...current, - [normalizedProvider]: modelIds, + [normalizedProvider]: models.map((entry) => entry.id), })); setProviderReasoningModels((current) => ({ ...current, - [normalizedProvider]: reasoningModelIds, + [normalizedProvider]: models + .filter((entry) => entry.supportsReasoning) + .map((entry) => entry.id), })); setReasoningCapabilitySource("catalog"); setEnabledProviderIds((current) => @@ -1181,15 +1165,34 @@ const ModelSelector = memo(function ModelSelector({ : [...current, normalizedProvider], ); } catch { - // Keep existing values when provider-specific model loading fails. + // Keep the catalog values when provider-specific loading fails. } } - void loadModelsForProvider(); + void loadCatalogAndActiveModels(); return () => { cancelled = true; }; - }, [normalizedProvider, providerModels]); + }, [normalizedProvider]); + + useEffect(() => { + return subscribeToProviderModels((providerId, models) => { + const normalizedId = normalizeProviderId(providerId); + setProviderModels((current) => ({ + ...current, + [normalizedId]: models.map((entry) => entry.id), + })); + setProviderReasoningModels((current) => ({ + ...current, + [normalizedId]: models + .filter((entry) => entry.supportsReasoning) + .map((entry) => entry.id), + })); + setEnabledProviderIds((current) => + current.includes(normalizedId) ? current : [...current, normalizedId], + ); + }); + }, []); useEffect(() => { setLastSelection((prev) => { diff --git a/apps/examples/desktop-app/webview/components/views/settings/add-provider.tsx b/apps/examples/desktop-app/webview/components/views/settings/add-provider.tsx index 83de774c0b..f78eb73ddb 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/add-provider.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/add-provider.tsx @@ -8,13 +8,13 @@ import { EyeOff, Plus, Trash2, - X, } from "lucide-react"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; import { PageFrame, PageHeader } from "../page-layout"; +import { ModelIdInput } from "./model-id-input"; const CAPABILITY_OPTIONS = [ "streaming", @@ -73,7 +73,6 @@ export function AddProviderContent({ timeoutMs: "", capabilities: ["streaming", "tools"], }); - const [modelInput, setModelInput] = useState(""); const [showApiKey, setShowApiKey] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); const [saving, setSaving] = useState(false); @@ -95,36 +94,14 @@ export function AddProviderContent({ (hasManualModels || hasModelsSource) && !duplicateProviderId; - const handleAddModel = (e: React.KeyboardEvent) => { - if ((e.key === "Enter" || e.key === ",") && modelInput.trim()) { - e.preventDefault(); - const value = modelInput.trim().replace(/,/g, ""); - if (value && !form.models.includes(value)) { - setForm((prev) => ({ - ...prev, - models: [...prev.models, value], - defaultModel: prev.defaultModel || value, - })); - } - setModelInput(""); - } else if (e.key === "Backspace" && !modelInput && form.models.length > 0) { - setForm((prev) => ({ - ...prev, - models: prev.models.slice(0, -1), - })); - } - }; - - const removeModel = (model: string) => { + const updateModels = (nextModels: string[]) => { setForm((prev) => { - const nextModels = prev.models.filter((m) => m !== model); return { ...prev, models: nextModels, - defaultModel: - prev.defaultModel === model - ? (nextModels[0] ?? "") - : prev.defaultModel, + defaultModel: !nextModels.includes(prev.defaultModel) + ? (nextModels[0] ?? "") + : prev.defaultModel || nextModels[0] || "", }; }); }; @@ -307,33 +284,7 @@ export function AddProviderContent({ -
- {form.models.map((model) => ( - - {model} - - - ))} - setModelInput(e.target.value)} - onKeyDown={handleAddModel} - placeholder={ - form.models.length === 0 ? "Type model ID and press Enter" : "" - } - className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none" - /> -
+

Add at least one model or set a Model Source URL.

@@ -506,7 +457,7 @@ export function AddProviderContent({
diff --git a/apps/examples/desktop-app/webview/components/views/settings/model-id-input.tsx b/apps/examples/desktop-app/webview/components/views/settings/model-id-input.tsx new file mode 100644 index 0000000000..789b325e66 --- /dev/null +++ b/apps/examples/desktop-app/webview/components/views/settings/model-id-input.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { X } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +export function ModelIdInput({ + models, + onChange, + disabled = false, +}: { + models: string[]; + onChange: (models: string[]) => void; + disabled?: boolean; +}) { + const [modelInput, setModelInput] = useState(""); + + const addPendingModel = () => { + const value = modelInput.trim().replace(/,/g, ""); + if (value && !models.includes(value)) { + onChange([...models, value]); + } + setModelInput(""); + }; + + return ( +
+ {models.map((model) => ( + + {model} + + + ))} + setModelInput(event.target.value)} + onKeyDown={(event) => { + if ( + (event.key === "Enter" || event.key === ",") && + modelInput.trim() + ) { + event.preventDefault(); + addPendingModel(); + } else if ( + event.key === "Backspace" && + !modelInput && + models.length > 0 + ) { + onChange(models.slice(0, -1)); + } + }} + placeholder={models.length === 0 ? "Type model ID and press Enter" : ""} + type="text" + value={modelInput} + /> +
+ ); +} diff --git a/apps/examples/desktop-app/webview/components/views/settings/provider-list-view.test.tsx b/apps/examples/desktop-app/webview/components/views/settings/provider-list-view.test.tsx new file mode 100644 index 0000000000..51a06bd6ef --- /dev/null +++ b/apps/examples/desktop-app/webview/components/views/settings/provider-list-view.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Provider } from "@/lib/provider-schema"; +import { ProviderDetailContent } from "./provider-list-view"; + +const provider: Provider = { + id: "ollama", + name: "Ollama", + models: 2, + color: "#000", + letter: "OL", + enabled: true, + modelList: [ + { id: "alpha", name: "Alpha" }, + { id: "beta", name: "Beta" }, + ], +}; + +describe("ProviderDetailContent models", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + window.localStorage.clear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it("persists favorites, sorts them first, and adds models", async () => { + const onUpdateModels = vi.fn(); + await act(async () => { + root.render( + , + ); + }); + + await act(async () => { + container + .querySelector('[aria-label="Favorite Beta"]') + ?.click(); + }); + expect( + Array.from( + container.querySelectorAll( + '[aria-label^="Copy model ID"]', + ), + ).map((button) => button.getAttribute("aria-label")), + ).toEqual(["Copy model ID beta", "Copy model ID alpha"]); + expect( + container.querySelector('[aria-label="Unfavorite Beta"] svg')?.classList, + ).toContain("fill-current"); + + await act(async () => { + container + .querySelector('[aria-label="Add model"]') + ?.click(); + }); + const input = container.querySelector( + '[aria-label="New model ID"]', + ); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + setter?.call(input, "gamma"); + input?.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => { + container + .querySelector( + '[aria-label="New model ID"] + button', + ) + ?.click(); + }); + expect(onUpdateModels).toHaveBeenCalledWith(["alpha", "beta", "gamma"]); + }); +}); diff --git a/apps/examples/desktop-app/webview/components/views/settings/provider-list-view.tsx b/apps/examples/desktop-app/webview/components/views/settings/provider-list-view.tsx index bf76812b94..8c8b8d244b 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/provider-list-view.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/provider-list-view.tsx @@ -10,6 +10,7 @@ import { ImageIcon, Link as LinkIcon, Loader2, + Plus, PlusCircle, RefreshCw, Search, @@ -29,6 +30,36 @@ import type { } from "@/lib/provider-schema"; import { cn } from "@/lib/utils"; +const FAVORITE_MODELS_STORAGE_KEY = "cline.favorite-provider-models.v1"; + +function readFavoriteModels(): Record { + if (typeof window === "undefined") return {}; + try { + const value = JSON.parse( + window.localStorage.getItem(FAVORITE_MODELS_STORAGE_KEY) ?? "{}", + ); + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, string[]] => + typeof entry[0] === "string" && + Array.isArray(entry[1]) && + entry[1].every((modelId) => typeof modelId === "string"), + ), + ); + } catch { + return {}; + } +} + +function writeFavoriteModels(value: Record): void { + if (typeof window === "undefined") return; + window.localStorage.setItem( + FAVORITE_MODELS_STORAGE_KEY, + JSON.stringify(value), + ); +} + // ----------------------------------------------------------- // Provider LIST content (the grid of all providers) // ----------------------------------------------------------- @@ -203,9 +234,14 @@ export function ProviderListContent({ onClick={() => onConfigure(prov.id)} type="button" > -

- {prov.name} -

+
+

+ {prov.name} +

+

+ {prov.id} +

+

{prov.models === null ? "Models load on demand" @@ -238,6 +274,7 @@ export function ProviderDetailContent({ onBack, onUpdate, onLoadModels, + onUpdateModels, modelsLoading = false, modelsError, onOAuthLogin, @@ -248,6 +285,7 @@ export function ProviderDetailContent({ onBack: () => void; onUpdate: (updates: ProviderSettingsUpdate) => void; onLoadModels?: () => void; + onUpdateModels?: (models: string[]) => void; modelsLoading?: boolean; modelsError?: string | null; onOAuthLogin?: () => void; @@ -266,6 +304,11 @@ export function ProviderDetailContent({ modelId: string; providerId: string; } | null>(null); + const [addModelState, setAddModelState] = useState<{ + providerId: string; + value: string; + } | null>(null); + const [favoriteModels, setFavoriteModels] = useState(readFavoriteModels); const copiedModelTimeoutRef = useRef(undefined); const configFields = provider.configFields ?? []; @@ -277,14 +320,21 @@ export function ProviderDetailContent({ copiedModelState?.providerId === provider.id ? copiedModelState.modelId : null; + const isAddingModel = addModelState?.providerId === provider.id; + const newModelId = isAddingModel ? addModelState.value : ""; const modelSearchQuery = modelSearch.trim().toLowerCase(); - const filteredModelList = modelSearchQuery + const matchingModelList = modelSearchQuery ? modelList.filter( (model) => model.name.toLowerCase().includes(modelSearchQuery) || model.id.toLowerCase().includes(modelSearchQuery), ) : modelList; + const favoriteModelIds = new Set(favoriteModels[provider.id] ?? []); + const filteredModelList = [...matchingModelList].sort( + (a, b) => + Number(favoriteModelIds.has(b.id)) - Number(favoriteModelIds.has(a.id)), + ); const isPanel = variant === "panel"; useEffect( @@ -335,6 +385,29 @@ export function ProviderDetailContent({ }); }; + const addModel = () => { + const modelId = newModelId.trim(); + if (!modelId || modelList.some((model) => model.id === modelId)) { + return; + } + onUpdateModels?.([...modelList.map((model) => model.id), modelId]); + setAddModelState(null); + }; + + const toggleFavoriteModel = (modelId: string) => { + setFavoriteModels((current) => { + const providerFavorites = new Set(current[provider.id] ?? []); + if (providerFavorites.has(modelId)) providerFavorites.delete(modelId); + else providerFavorites.add(modelId); + const next = { + ...current, + [provider.id]: Array.from(providerFavorites), + }; + writeFavoriteModels(next); + return next; + }); + }; + return (

)} -

- {provider.name} -

+
+

+ {provider.name} +

+

+ {provider.id} +

+
{configFields.length > 0 ? ( @@ -523,30 +601,78 @@ export function ProviderDetailContent({ )} >
-

- Models -

- +

+ Models +

+ {onUpdateModels ? ( + + ) : null}
+ {isAddingModel ? ( +
+ + setAddModelState({ + providerId: provider.id, + value: event.target.value, + }) + } + onKeyDown={(event) => { + if (event.key === "Enter") addModel(); + if (event.key === "Escape") setAddModelState(null); + }} + placeholder="Model ID" + value={newModelId} + /> + + +
+ ) : null} {modelsError ? ( -
+

{modelsError}

- ) : modelList.length > 0 ? ( + ) : null} + {modelList.length > 0 ? (
@@ -603,16 +729,28 @@ export function ProviderDetailContent({
- {/* Action icons */} -
- -
+
))}
diff --git a/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx b/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx index fc90790e96..a52e190be5 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx @@ -12,7 +12,10 @@ import { } from "@/lib/app-icon"; import { desktopClient } from "@/lib/desktop-client"; import { resetOnboarding } from "@/lib/onboarding"; -import { invalidateProviderCatalogCache } from "@/lib/provider-model-catalog"; +import { + invalidateProviderCatalogCache, + publishProviderModels, +} from "@/lib/provider-model-catalog"; import type { Provider, ProviderCatalogResponse, @@ -276,6 +279,7 @@ export function SettingsView({ : provider, ), ); + publishProviderModels(id, payload.models); } catch (error) { const message = error instanceof Error ? error.message : String(error); setModelsErrorByProvider((prev) => ({ ...prev, [id]: message })); @@ -286,6 +290,26 @@ export function SettingsView({ [setProvidersWithCache], ); + const updateProviderModels = useCallback( + async (id: string, models: string[]) => { + setModelsLoadingByProvider((prev) => ({ ...prev, [id]: true })); + setModelsErrorByProvider((prev) => ({ ...prev, [id]: null })); + try { + await desktopClient.invoke("update_provider_models", { + provider: id, + models, + }); + await loadProviderModels(id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setModelsErrorByProvider((prev) => ({ ...prev, [id]: message })); + } finally { + setModelsLoadingByProvider((prev) => ({ ...prev, [id]: false })); + } + }, + [loadProviderModels], + ); + const selectedProvider = selectedProviderId ? (providers.find((p) => p.id === selectedProviderId) ?? null) : null; @@ -331,17 +355,11 @@ export function SettingsView({ if (!selectedProviderId) { return; } - const selected = providers.find( - (provider) => provider.id === selectedProviderId, - ); - if (!selected || (selected.modelList?.length ?? 0) > 0) { - return; - } const timeoutId = window.setTimeout(() => { void loadProviderModels(selectedProviderId); }, 0); return () => window.clearTimeout(timeoutId); - }, [loadProviderModels, providers, selectedProviderId]); + }, [loadProviderModels, selectedProviderId]); const backToProviderList = () => { onNavigateSection("Models"); @@ -410,6 +428,9 @@ export function SettingsView({ oauthLoginPending={oauthSigningProviderId === selectedProvider.id} onBack={backToProviderList} onLoadModels={() => void loadProviderModels(selectedProvider.id)} + onUpdateModels={(models) => + void updateProviderModels(selectedProvider.id, models) + } onOAuthLogin={ usesOAuth(selectedProvider) ? () => void runOAuthProviderLogin(selectedProvider.id) diff --git a/apps/examples/desktop-app/webview/lib/provider-model-catalog.ts b/apps/examples/desktop-app/webview/lib/provider-model-catalog.ts index 39a0ec9e6d..31edc7fa02 100644 --- a/apps/examples/desktop-app/webview/lib/provider-model-catalog.ts +++ b/apps/examples/desktop-app/webview/lib/provider-model-catalog.ts @@ -59,6 +59,29 @@ let providerCatalogCache: { promise: Promise; } | null = null; +type ProviderModelsListener = ( + providerId: string, + models: ProviderModel[], +) => void; +const providerModelsListeners = new Set(); + +export function publishProviderModels( + providerId: string, + models: ProviderModel[], +): void { + invalidateProviderCatalogCache(); + for (const listener of providerModelsListeners) { + listener(providerId, models); + } +} + +export function subscribeToProviderModels( + listener: ProviderModelsListener, +): () => void { + providerModelsListeners.add(listener); + return () => providerModelsListeners.delete(listener); +} + export function fetchProviderCatalog(options?: { fresh?: boolean; }): Promise { diff --git a/bun.lock b/bun.lock index 85fece25c5..e4dfb80a86 100644 --- a/bun.lock +++ b/bun.lock @@ -695,10 +695,10 @@ "@opentelemetry/sdk-trace-node": "^2.6.1", "@streamparser/json": "^0.0.21", "ai": "^7", - "ai-sdk-ollama": "^4", "ai-sdk-provider-opencode-sdk": "^3.0.1", "dify-ai-provider": "^1.1.0", "nanoid": "^5.1.7", + "ollama-ai-provider-v2": "^4", "zod": "^4.3.6", }, "devDependencies": { @@ -772,6 +772,7 @@ ], "patchedDependencies": { "@opentui-ui/dialog@0.1.2": "patches/@opentui-ui%2Fdialog@0.1.2.patch", + "ollama-ai-provider-v2@4.0.1": "patches/ollama-ai-provider-v2@4.0.1.patch", }, "overrides": { "@ai-sdk/provider-utils": ">=4.0.0", @@ -808,7 +809,7 @@ "@ai-sdk/anthropic": ["@ai-sdk/anthropic@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ecrPWkYf+sHDLHErHAU/yjJfNevUzT0i6TuoQLb58oadnquSHcLQ5NMLLWjbr6d1rxYdzI7ftulIJ03VH9yM3w=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.37", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YBCTsSlX0ETVsjo0ihfBOeJ3p3y1wXKXDzF9Ygp9dscUY06dzOGmyWJSGsKg+khKVArzBWUW4UCSAKms20ZDmg=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-BMxiGGfXlmUQeDCCW/7XsxBTVPBlw4gyk63RGM1qVyTt5DrMeQGeCxkscS/EnUzCvmLCcFCl8f8Eos61PIag9g=="], "@ai-sdk/google": ["@ai-sdk/google@4.0.31", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AFyO4MuHryrzr/ZEMHm/dEXwqABqoi4yIa9cfZxfI4VYM+dyPeZIXU4/qjvxiD6vzbitQhYf1jutQL/d71bymw=="], @@ -2728,9 +2729,7 @@ "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], - "ai": ["ai@7.0.48", "", { "dependencies": { "@ai-sdk/gateway": "4.0.37", "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-QmqshWFEDdkFFqSgdJ4cogaoDIYaedqbv73q/NXEYNkSIocJCzXnGFVyM9lrtAGTSBfm+5hq3/292ooXJ1jDSw=="], - - "ai-sdk-ollama": ["ai-sdk-ollama@4.1.0", "", { "dependencies": { "@ai-sdk/provider": "^4.0.3", "@ai-sdk/provider-utils": "^5.0.11", "jsonrepair": "^3.15.0", "ollama": "^0.6.3" }, "peerDependencies": { "ai": "^7.0.31" } }, "sha512-WBucDWhYVpahhoY5wAsPy4vbEYc34D7xRJOtfpE5VWkD/cRKG4eY1y3pkyP3pCK/Zmp3cHBuiHWu1vHZ81ljEA=="], + "ai": ["ai@7.0.49", "", { "dependencies": { "@ai-sdk/gateway": "4.0.38", "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Mskw8P+pS23N5BkWtdc1qzrWr2//BjO0LJDvCc7cgzPU/bKOZhsmFc1sCFASvA+62zHbM/BJQI7D1QKBRIDSLw=="], "ai-sdk-provider-claude-code": ["ai-sdk-provider-claude-code@4.0.1", "", { "dependencies": { "@ai-sdk/provider": "^4.0.2", "@ai-sdk/provider-utils": "^5.0.5", "@anthropic-ai/claude-agent-sdk": "0.3.205" }, "peerDependencies": { "zod": "^4.1.8" } }, "sha512-ZC/nSCG7INgZvLzaxBEQ03tkTKaaPqoZ1uQ2ozIPBiMlKrDLxWAAKa3Dpg4WygcVrzetxDLLS01itcBD5HEYRA=="], @@ -4174,7 +4173,7 @@ "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], - "ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="], + "ollama-ai-provider-v2": ["ollama-ai-provider-v2@4.0.1", "", { "dependencies": { "@ai-sdk/provider": "^4.0.2", "@ai-sdk/provider-utils": "^5.0.5" }, "peerDependencies": { "ai": "^7.0.0", "zod": "^4.0.16" } }, "sha512-JHw/Knt4kOTSwQ0oqorbdgzukK6iE73U3MPUyOuCEBpAQGAzm80CQDwyaXMwF80yFjY21RY9qfzkoLyPFFts/w=="], "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], @@ -5004,8 +5003,6 @@ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], - "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], @@ -5088,10 +5085,10 @@ "@adobe/react-spectrum/@react-types/shared": ["@react-types/shared@3.36.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ=="], - "@ai-sdk/otel/ai": ["ai@7.0.49", "", { "dependencies": { "@ai-sdk/gateway": "4.0.38", "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Mskw8P+pS23N5BkWtdc1qzrWr2//BjO0LJDvCc7cgzPU/bKOZhsmFc1sCFASvA+62zHbM/BJQI7D1QKBRIDSLw=="], - "@ai-sdk/provider-utils/@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], + "@ai-sdk/react/ai": ["ai@7.0.48", "", { "dependencies": { "@ai-sdk/gateway": "4.0.37", "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-QmqshWFEDdkFFqSgdJ4cogaoDIYaedqbv73q/NXEYNkSIocJCzXnGFVyM9lrtAGTSBfm+5hq3/292ooXJ1jDSw=="], + "@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -6192,7 +6189,7 @@ "yazl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], - "@ai-sdk/otel/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-BMxiGGfXlmUQeDCCW/7XsxBTVPBlw4gyk63RGM1qVyTt5DrMeQGeCxkscS/EnUzCvmLCcFCl8f8Eos61PIag9g=="], + "@ai-sdk/react/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.37", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YBCTsSlX0ETVsjo0ihfBOeJ3p3y1wXKXDzF9Ygp9dscUY06dzOGmyWJSGsKg+khKVArzBWUW4UCSAKms20ZDmg=="], "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], diff --git a/package.json b/package.json index be19b1c7a8..dfc20ccd0e 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ ], "packageManager": "bun@1.3.13", "patchedDependencies": { - "@opentui-ui/dialog@0.1.2": "patches/@opentui-ui%2Fdialog@0.1.2.patch" + "@opentui-ui/dialog@0.1.2": "patches/@opentui-ui%2Fdialog@0.1.2.patch", + "ollama-ai-provider-v2@4.0.1": "patches/ollama-ai-provider-v2@4.0.1.patch" } } diff --git a/patches/ollama-ai-provider-v2@4.0.1.patch b/patches/ollama-ai-provider-v2@4.0.1.patch new file mode 100644 index 0000000000..7b0fe673d2 --- /dev/null +++ b/patches/ollama-ai-provider-v2@4.0.1.patch @@ -0,0 +1,168 @@ +diff --git a/dist/index.js b/dist/index.js +index aafa72c4167302af45db22e41a227cbaf02f792f..091d1a62fd4f40c1ed9f72f846969244c0c03be1 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -154,7 +154,7 @@ function resolveOllamaThinkFlag({ + } + return reasoning !== "none"; + } +- return false; ++ return void 0; + } + function fileDataToBase64String(data) { + var _a; +@@ -280,12 +280,20 @@ function createNdjsonStreamResponseHandler(schema) { + if (buffer.trim()) { + try { + const parsed = JSON.parse(buffer.trim()); +- const validated = schema.parse(parsed); +- controller.enqueue({ +- success: true, +- value: validated, +- rawValue: validated +- }); ++ if (parsed && typeof parsed === "object" && "error" in parsed) { ++ controller.enqueue({ ++ success: true, ++ value: parsed, ++ rawValue: parsed ++ }); ++ } else { ++ const validated = schema.parse(parsed); ++ controller.enqueue({ ++ success: true, ++ value: validated, ++ rawValue: validated ++ }); ++ } + } catch (e) { + } + } +@@ -300,12 +308,20 @@ function createNdjsonStreamResponseHandler(schema) { + if (trimmedLine) { + try { + const parsed = JSON.parse(trimmedLine); +- const validated = schema.parse(parsed); +- controller.enqueue({ +- success: true, +- value: validated, +- rawValue: validated +- }); ++ if (parsed && typeof parsed === "object" && "error" in parsed) { ++ controller.enqueue({ ++ success: true, ++ value: parsed, ++ rawValue: parsed ++ }); ++ } else { ++ const validated = schema.parse(parsed); ++ controller.enqueue({ ++ success: true, ++ value: validated, ++ rawValue: validated ++ }); ++ } + } catch (error) { + console.warn("Failed to parse NDJSON line:", error); + } +@@ -843,7 +859,7 @@ function convertToOllamaChatMessages({ + }).map((part) => extractOllamaFileData(part.data)); + messages.push({ + role: "user", +- content: userText.length > 0 ? userText : [], ++ content: userText, + images: images.length > 0 ? images : void 0 + }); + break; +@@ -904,6 +920,7 @@ function convertToOllamaChatMessages({ + messages.push({ + role: "tool", + tool_call_id: toolResponse.toolCallId, ++ tool_name: toolResponse.toolName, + content: formatOllamaToolResultOutput(toolResponse.output) + }); + break; +diff --git a/dist/index.mjs b/dist/index.mjs +index 5aa396bef7354dcd523cfeabc69fe7d8ada7d4c6..77e695b555d4772d0e8de3c0df247b1fcd29838e 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -143,7 +143,7 @@ function resolveOllamaThinkFlag({ + } + return reasoning !== "none"; + } +- return false; ++ return void 0; + } + function fileDataToBase64String(data) { + var _a; +@@ -271,12 +271,20 @@ function createNdjsonStreamResponseHandler(schema) { + if (buffer.trim()) { + try { + const parsed = JSON.parse(buffer.trim()); +- const validated = schema.parse(parsed); +- controller.enqueue({ +- success: true, +- value: validated, +- rawValue: validated +- }); ++ if (parsed && typeof parsed === "object" && "error" in parsed) { ++ controller.enqueue({ ++ success: true, ++ value: parsed, ++ rawValue: parsed ++ }); ++ } else { ++ const validated = schema.parse(parsed); ++ controller.enqueue({ ++ success: true, ++ value: validated, ++ rawValue: validated ++ }); ++ } + } catch (e) { + } + } +@@ -291,12 +299,20 @@ function createNdjsonStreamResponseHandler(schema) { + if (trimmedLine) { + try { + const parsed = JSON.parse(trimmedLine); +- const validated = schema.parse(parsed); +- controller.enqueue({ +- success: true, +- value: validated, +- rawValue: validated +- }); ++ if (parsed && typeof parsed === "object" && "error" in parsed) { ++ controller.enqueue({ ++ success: true, ++ value: parsed, ++ rawValue: parsed ++ }); ++ } else { ++ const validated = schema.parse(parsed); ++ controller.enqueue({ ++ success: true, ++ value: validated, ++ rawValue: validated ++ }); ++ } + } catch (error) { + console.warn("Failed to parse NDJSON line:", error); + } +@@ -847,7 +863,7 @@ function convertToOllamaChatMessages({ + }).map((part) => extractOllamaFileData(part.data)); + messages.push({ + role: "user", +- content: userText.length > 0 ? userText : [], ++ content: userText, + images: images.length > 0 ? images : void 0 + }); + break; +@@ -908,6 +924,7 @@ function convertToOllamaChatMessages({ + messages.push({ + role: "tool", + tool_call_id: toolResponse.toolCallId, ++ tool_name: toolResponse.toolName, + content: formatOllamaToolResultOutput(toolResponse.output) + }); + break; diff --git a/sdk/packages/core/src/services/providers/local-provider-service.ts b/sdk/packages/core/src/services/providers/local-provider-service.ts index 73c8baec68..ac83e9e5b8 100644 --- a/sdk/packages/core/src/services/providers/local-provider-service.ts +++ b/sdk/packages/core/src/services/providers/local-provider-service.ts @@ -479,6 +479,10 @@ export async function updateLocalProvider( let existingEntry = modelsState.providers[providerId]; if (!existingEntry) { const existingSettings = manager.getProviderSettings(providerId); + const registeredCollection = LlmsModels.MODEL_COLLECTIONS_BY_PROVIDER_ID[ + providerId + ] as LlmsModels.ModelCollection | undefined; + const registeredProvider = registeredCollection?.provider; if (!existingSettings) { throw new Error(`provider "${providerId}" does not exist`); } @@ -495,13 +499,21 @@ export async function updateLocalProvider( // Ephemeral seed for the existing update path; final state is computed and written below. existingEntry = { provider: { - name: request.name?.trim() || titleCaseFromId(providerId), + name: + request.name?.trim() || + registeredProvider?.name || + titleCaseFromId(providerId), baseUrl: - request.baseUrl?.trim() ?? existingSettings.baseUrl?.trim() ?? "", - defaultModelId: seedModelId, - protocol: existingSettings.protocol, - client: existingSettings.client, - capabilities: existingSettings.capabilities, + request.baseUrl?.trim() ?? + existingSettings.baseUrl?.trim() ?? + registeredProvider?.baseUrl?.trim() ?? + "", + defaultModelId: seedModelId ?? registeredProvider?.defaultModelId, + protocol: existingSettings.protocol ?? registeredProvider?.protocol, + client: existingSettings.client ?? registeredProvider?.client, + capabilities: + existingSettings.capabilities ?? registeredProvider?.capabilities, + modelsSourceUrl: registeredProvider?.modelsSourceUrl, }, models: seedModelId ? buildProviderModels([seedModelId], existingSettings.capabilities) diff --git a/sdk/packages/llms/package.json b/sdk/packages/llms/package.json index 922ff6cd9d..ecf1e4c03c 100644 --- a/sdk/packages/llms/package.json +++ b/sdk/packages/llms/package.json @@ -67,10 +67,10 @@ "@opentelemetry/sdk-trace-node": "^2.6.1", "@streamparser/json": "^0.0.21", "ai": "^7", - "ai-sdk-ollama": "^4", "ai-sdk-provider-opencode-sdk": "^3.0.1", "dify-ai-provider": "^1.1.0", "nanoid": "^5.1.7", + "ollama-ai-provider-v2": "^4", "zod": "^4.3.6" }, "peerDependencies": { diff --git a/sdk/packages/llms/src/providers/routing/provider-option-rules.ts b/sdk/packages/llms/src/providers/routing/provider-option-rules.ts index bb72afa659..9b1e9637d2 100644 --- a/sdk/packages/llms/src/providers/routing/provider-option-rules.ts +++ b/sdk/packages/llms/src/providers/routing/provider-option-rules.ts @@ -1,4 +1,5 @@ import { isClineProvider } from "@cline/shared"; +import { OLLAMA_DEFAULT_CONTEXT_WINDOW } from "../builtins"; import { getModelReasoningControls, isDeepSeekFamily, @@ -52,19 +53,27 @@ function isMiniMaxM3(input: ProviderOptionMatchInput): boolean { return isMiniMaxM3Model(input.request, input.context); } -function isOllamaReasoningDefaultOnDisable( - input: ProviderOptionMatchInput, -): boolean { +function isOllamaThinkingCapable(input: ProviderOptionMatchInput): boolean { return ( - input.request.providerId === "ollama" && - input.request.reasoning?.enabled === false && - modelReasoningDefaultsOn({ - request: input.request, - context: input.context, - }) + input.target === "ollama" && + (input.context.model.capabilities?.includes("reasoning") === true || + modelReasoningDefaultsOn({ + request: input.request, + context: input.context, + })) ); } +function resolveOllamaThink( + input: ProviderOptionMatchInput, +): boolean | undefined { + const explicitlyEnabled = input.request.reasoning?.enabled; + if (typeof explicitlyEnabled === "boolean") { + return explicitlyEnabled; + } + return isOllamaThinkingCapable(input) ? true : undefined; +} + function usesGlmThinkingProviderRouting( input: ProviderOptionMatchInput, ): boolean { @@ -496,7 +505,7 @@ const deepSeekThinkingRule: ProviderOptionRule = { applies: (input) => input.request.providerId !== "openrouter" && isDeepSeekModelOrProviderDefault(input) && - !isOllamaReasoningDefaultOnDisable(input), + input.target !== "ollama", suppresses: { genericThinking: true }, build: (input) => { const thinkingType = resolveFamilyThinkingType(input, undefined); @@ -510,24 +519,31 @@ const deepSeekThinkingRule: ProviderOptionRule = { }, }; -const ollamaReasoningDefaultOnDisableRule: ProviderOptionRule = { - id: "provider.ollama.reasoning-default-on.disable-none", +const ollamaNativeOptionsRule: ProviderOptionRule = { + id: "provider.ollama.native-options", phase: "provider-reasoning", description: - "Ollama models whose reasoning defaults on need reasoningEffort=none when request reasoning is disabled.", - applies: isOllamaReasoningDefaultOnDisable, + "Ollama receives its context window and supported thinking toggle through native provider options.", + applies: (input) => input.target === "ollama", + suppresses: { genericThinking: true, genericEffort: true }, build: (input) => { + const contextWindow = + input.context.model.contextWindow ?? + input.context.model.maxInputTokens ?? + OLLAMA_DEFAULT_CONTEXT_WINDOW; + const numCtx = + typeof contextWindow === "number" && + Number.isFinite(contextWindow) && + contextWindow > 0 + ? Math.floor(contextWindow) + : OLLAMA_DEFAULT_CONTEXT_WINDOW; + const think = resolveOllamaThink(input); const bucketOptions = { - reasoningEffort: "none", - reasoning: { effort: "none" }, + options: { num_ctx: numCtx }, + ...(think === undefined ? {} : { think }), }; return { - ...buildProviderAndAliasPatch({ - providerId: input.request.providerId, - providerOptionsKey: input.providerOptionsKey, - bucketOptions, - }), - openaiCompatible: bucketOptions, + ollama: bucketOptions, }; }, }; @@ -613,7 +629,7 @@ export const PROVIDER_OPTION_RULES: ReadonlyArray = [ clineReasoningDisabledThinkingRule, kimiK26ThinkingRule, deepSeekThinkingRule, - ollamaReasoningDefaultOnDisableRule, + ollamaNativeOptionsRule, nonGlmProviderRoutingSuppressionRule, nativeZaiGlmThinkingRule, miniMaxThinkingRule, diff --git a/sdk/packages/llms/src/providers/routing/provider-options.test.ts b/sdk/packages/llms/src/providers/routing/provider-options.test.ts index 52dca213cb..41dcf793c8 100644 --- a/sdk/packages/llms/src/providers/routing/provider-options.test.ts +++ b/sdk/packages/llms/src/providers/routing/provider-options.test.ts @@ -21,6 +21,7 @@ type ContextOverrides = { providerId?: string; modelId?: string; family?: string; + contextWindow?: number; maxOutputTokens?: number; reasoningOptions?: readonly ModelReasoningOption[]; modelMetadata?: NonNullable; @@ -91,6 +92,7 @@ function makeContext(options?: ContextOverrides): GatewayProviderContext { name: modelId, providerId, maxOutputTokens: options?.maxOutputTokens, + contextWindow: options?.contextWindow, reasoningOptions: options?.reasoningOptions, capabilities: options?.capabilities, metadata: modelMetadata, @@ -1754,7 +1756,7 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () => }, // Ollama Qwen3: model behavior fact first, documented dynamic fallback second. { - name: "ollama metadata reasoningDefaultOn disabled -> reasoningEffort none", + name: "ollama metadata reasoningDefaultOn disabled -> think false", request: { providerId: "ollama", modelId: "local-known-reasoner:latest", @@ -1764,22 +1766,16 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () => expect: [ { bucket: "ollama", - has: { - reasoningEffort: "none", - reasoning: { effort: "none" }, - }, + has: { think: false, options: { num_ctx: 32768 } }, }, { bucket: "openaiCompatible", - has: { - reasoningEffort: "none", - reasoning: { effort: "none" }, - }, + lacks: ["think", "reasoningEffort", "reasoning"], }, ], }, { - name: "ollama qwen3 fallback reasoning disabled -> reasoningEffort none", + name: "ollama qwen3 fallback reasoning disabled -> think false", request: { providerId: "ollama", modelId: "qwen3-coder:30b", @@ -1788,52 +1784,58 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () => expect: [ { bucket: "ollama", - has: { - reasoningEffort: "none", - reasoning: { effort: "none" }, - }, + has: { think: false, options: { num_ctx: 32768 } }, }, { bucket: "openaiCompatible", - has: { - reasoningEffort: "none", - reasoning: { effort: "none" }, - }, + lacks: ["think", "reasoningEffort", "reasoning"], }, ], }, { - name: "ollama qwen3 fallback reasoning enabled -> no disable patch", + name: "ollama qwen3 fallback reasoning enabled -> think true", request: { providerId: "ollama", modelId: "qwen3-coder:30b", reasoning: { enabled: true }, }, expect: [ - { bucket: "ollama", lacks: ["reasoningEffort", "reasoning"] }, + { + bucket: "ollama", + has: { think: true, options: { num_ctx: 32768 } }, + lacks: ["reasoningEffort", "reasoning"], + }, { bucket: "openaiCompatible", lacks: ["reasoningEffort", "reasoning"] }, ], }, { - name: "ollama qwen3 fallback with unset reasoning -> no disable patch", + name: "ollama qwen3 fallback with unset reasoning -> think true", request: { providerId: "ollama", modelId: "qwen3-coder:30b", }, expect: [ - { bucket: "ollama", lacks: ["reasoningEffort", "reasoning"] }, + { + bucket: "ollama", + has: { think: true, options: { num_ctx: 32768 } }, + lacks: ["reasoningEffort", "reasoning"], + }, { bucket: "openaiCompatible", lacks: ["reasoningEffort", "reasoning"] }, ], }, { - name: "ollama metadata reasoningDefaultOn with unset reasoning -> no disable patch", + name: "ollama metadata reasoningDefaultOn with unset reasoning -> think true", request: { providerId: "ollama", modelId: "local-known-reasoner:latest", }, context: { modelMetadata: { reasoningDefaultOn: true } }, expect: [ - { bucket: "ollama", lacks: ["reasoningEffort", "reasoning"] }, + { + bucket: "ollama", + has: { think: true, options: { num_ctx: 32768 } }, + lacks: ["reasoningEffort", "reasoning"], + }, { bucket: "openaiCompatible", lacks: ["reasoningEffort", "reasoning"] }, ], }, @@ -1851,24 +1853,17 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () => expect: [ { bucket: "ollama", - has: { - reasoningEffort: "none", - reasoning: { effort: "none" }, - }, + has: { think: false, options: { num_ctx: 32768 } }, lacks: ["thinking"], }, { bucket: "openaiCompatible", - has: { - reasoningEffort: "none", - reasoning: { effort: "none" }, - }, - lacks: ["thinking"], + lacks: ["think", "thinking", "reasoningEffort", "reasoning"], }, ], }, { - name: "ollama metadata reasoningDefaultOn false prevents qwen3 fallback", + name: "ollama explicit disable overrides metadata reasoningDefaultOn false", request: { providerId: "ollama", modelId: "qwen3-coder:30b", @@ -1876,22 +1871,62 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () => }, context: { modelMetadata: { reasoningDefaultOn: false } }, expect: [ - { bucket: "ollama", lacks: ["reasoningEffort", "reasoning"] }, + { + bucket: "ollama", + has: { think: false, options: { num_ctx: 32768 } }, + lacks: ["reasoningEffort", "reasoning"], + }, { bucket: "openaiCompatible", lacks: ["reasoningEffort", "reasoning"] }, ], }, { - name: "ollama non-default reasoning disabled -> no special disable patch", + name: "ollama local model explicit reasoning disabled -> think false", request: { providerId: "ollama", modelId: "llama3.1:8b", reasoning: { enabled: false }, }, + context: { contextWindow: 65536 }, expect: [ - { bucket: "ollama", lacks: ["reasoningEffort", "reasoning"] }, + { + bucket: "ollama", + has: { think: false, options: { num_ctx: 65536 } }, + lacks: ["reasoningEffort", "reasoning"], + }, { bucket: "openaiCompatible", lacks: ["reasoningEffort", "reasoning"] }, ], }, + { + name: "ollama unregistered deepseek-r1 explicit reasoning enabled -> think true", + request: { + providerId: "ollama", + modelId: "deepseek-r1:latest", + reasoning: { enabled: true }, + }, + expect: [ + { + bucket: "ollama", + has: { think: true, options: { num_ctx: 32768 } }, + lacks: ["reasoningEffort", "reasoning"], + }, + { bucket: "openaiCompatible", lacks: ["think", "reasoning"] }, + ], + }, + { + name: "ollama unregistered model with unset reasoning omits think", + request: { + providerId: "ollama", + modelId: "local-unknown:latest", + }, + expect: [ + { + bucket: "ollama", + has: { options: { num_ctx: 32768 } }, + lacks: ["think", "reasoningEffort", "reasoning"], + }, + { bucket: "openaiCompatible", lacks: ["think", "reasoning"] }, + ], + }, ]); }); diff --git a/sdk/packages/llms/src/providers/vendors/ollama.test.ts b/sdk/packages/llms/src/providers/vendors/ollama.test.ts index a6e056e049..e2c2943cc8 100644 --- a/sdk/packages/llms/src/providers/vendors/ollama.test.ts +++ b/sdk/packages/llms/src/providers/vendors/ollama.test.ts @@ -6,51 +6,49 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createOllamaProviderModule, normalizeOllamaBaseUrl, - OLLAMA_DEFAULT_NUM_CTX, OLLAMA_DEFAULT_TIMEOUT_MS, - readOllamaNumCtx, readOllamaTimeoutMs, withOllamaResponseTimeout, } from "./ollama"; const createOllamaMock = vi.hoisted(() => vi.fn()); const ollamaModelMock = vi.hoisted(() => - vi.fn((modelId: string, _settings?: unknown) => ({ + vi.fn((modelId: string) => ({ specificationVersion: "v4", provider: "ollama", modelId, })), ); -vi.mock("ai-sdk-ollama", () => ({ +vi.mock("ollama-ai-provider-v2", () => ({ createOllama: createOllamaMock, })); describe("normalizeOllamaBaseUrl", () => { - it("passes a bare origin through (the ollama client appends /api itself)", () => { + it("appends the native API root to a bare origin", () => { expect(normalizeOllamaBaseUrl("http://localhost:11434")).toBe( - "http://localhost:11434", + "http://localhost:11434/api", ); expect(normalizeOllamaBaseUrl("https://ollama.com")).toBe( - "https://ollama.com", + "https://ollama.com/api", ); }); it("strips a legacy OpenAI-compat /v1 suffix", () => { expect(normalizeOllamaBaseUrl("http://localhost:11434/v1")).toBe( - "http://localhost:11434", + "http://localhost:11434/api", ); }); it("strips a native-API /api suffix", () => { expect(normalizeOllamaBaseUrl("http://localhost:11434/api")).toBe( - "http://localhost:11434", + "http://localhost:11434/api", ); }); it("strips trailing slashes", () => { expect(normalizeOllamaBaseUrl("http://localhost:11434/")).toBe( - "http://localhost:11434", + "http://localhost:11434/api", ); }); @@ -60,26 +58,6 @@ describe("normalizeOllamaBaseUrl", () => { }); }); -describe("readOllamaNumCtx", () => { - it("reads the resolved model's context window", () => { - expect(readOllamaNumCtx(context({ contextWindow: 500000 }))).toBe(500000); - }); - - it("falls back to maxInputTokens when contextWindow is absent", () => { - expect(readOllamaNumCtx(context({ maxInputTokens: 128000 }))).toBe(128000); - }); - - it("falls back to the default for missing or invalid values", () => { - expect(readOllamaNumCtx(context({}))).toBe(OLLAMA_DEFAULT_NUM_CTX); - expect(readOllamaNumCtx(context({ contextWindow: 0 }))).toBe( - OLLAMA_DEFAULT_NUM_CTX, - ); - expect(readOllamaNumCtx(context({ contextWindow: -1 }))).toBe( - OLLAMA_DEFAULT_NUM_CTX, - ); - }); -}); - describe("readOllamaTimeoutMs", () => { it("reads a configured timeout", () => { expect(readOllamaTimeoutMs(config({ timeoutMs: 180000 }))).toBe(180000); @@ -166,7 +144,7 @@ describe("withOllamaResponseTimeout", () => { describe("createOllamaProviderModule", () => { beforeEach(() => { createOllamaMock.mockReset(); - createOllamaMock.mockReturnValue(ollamaModelMock); + createOllamaMock.mockReturnValue({ chat: ollamaModelMock }); ollamaModelMock.mockClear(); }); @@ -179,43 +157,30 @@ describe("createOllamaProviderModule", () => { expect(createOllamaMock).toHaveBeenCalledWith( expect.objectContaining({ - baseURL: "https://ollama.com", - apiKey: "ollama-key", + baseURL: "https://ollama.com/api", + headers: { Authorization: "Bearer ollama-key" }, + compatibility: "strict", }), ); - expect(ollamaModelMock).toHaveBeenCalledWith( - "minimax-m3:cloud", - expect.anything(), - ); + expect(ollamaModelMock).toHaveBeenCalledWith("minimax-m3:cloud"); }); - it("requests num_ctx from the resolved model's context window", async () => { + it("constructs a native chat model without request-scoped settings", async () => { const provider = await createOllamaProviderModule( config({}), context({ contextWindow: 65536 }), ); provider.model("qwen3-coder:30b"); - expect(ollamaModelMock).toHaveBeenCalledWith("qwen3-coder:30b", { - options: { num_ctx: 65536 }, - }); + expect(ollamaModelMock).toHaveBeenCalledWith("qwen3-coder:30b"); }); - it("requests the default num_ctx when the model has no context window", async () => { - const provider = await createOllamaProviderModule(config({}), context({})); - provider.model("llama3.1"); - - expect(ollamaModelMock).toHaveBeenCalledWith("llama3.1", { - options: { num_ctx: OLLAMA_DEFAULT_NUM_CTX }, - }); - }); - - it("omits baseURL and apiKey for a default local server", async () => { + it("omits baseURL and authorization headers for a default local server", async () => { await createOllamaProviderModule(config({}), context({})); const call = createOllamaMock.mock.calls[0][0]; expect(call.baseURL).toBeUndefined(); - expect(call.apiKey).toBeUndefined(); + expect(call.headers).toBeUndefined(); }); }); diff --git a/sdk/packages/llms/src/providers/vendors/ollama.ts b/sdk/packages/llms/src/providers/vendors/ollama.ts index 925853d8d1..8b78eff8b2 100644 --- a/sdk/packages/llms/src/providers/vendors/ollama.ts +++ b/sdk/packages/llms/src/providers/vendors/ollama.ts @@ -1,5 +1,5 @@ // Ollama vendor backed by the native Ollama API (`/api/chat`) via the -// `ai-sdk-ollama` AI SDK provider (which wraps the official `ollama` client). +// `ollama-ai-provider-v2` AI SDK provider. // // Ollama cannot be driven through the generic OpenAI-compatible path // (`/v1/chat/completions`): that endpoint ignores Ollama's proprietary @@ -9,25 +9,29 @@ // `options.num_ctx` per request; this boundary maps the provider-neutral // model `contextWindow` onto it. -import type { LanguageModelV4 } from "@ai-sdk/provider"; import type { GatewayProviderContext, GatewayResolvedProviderConfig, } from "@cline/shared"; import { wrapLanguageModel } from "ai"; -import { createOllama } from "ai-sdk-ollama"; -import { OLLAMA_DEFAULT_CONTEXT_WINDOW } from "../builtins"; +// The installed package is patched (see +// `patches/ollama-ai-provider-v2@4.0.1.patch`) to preserve four native wire +// contracts the upstream 4.0.1 release breaks: an unset `think` must be +// omitted rather than sent as `false`, mid-stream `{"error": ...}` objects +// must surface as stream errors instead of being dropped before a clean +// finish, attachment-only user turns must send string `content` (not `[]`), +// and tool results must carry the documented `tool_name` field. +// `ollama.wire.test.ts` locks each contract at the real provider boundary; +// drop the patch once an upstream release covers them. +import { createOllama } from "ollama-ai-provider-v2"; import { ensureFetch, resolveApiKey } from "../http"; import { createRetryEmptyResponseMiddleware } from "../middleware/retry-empty-response"; import { splitToolImagesMiddleware } from "../middleware/split-tool-images"; import type { ProviderFactoryResult } from "./types"; -/** See {@link OLLAMA_DEFAULT_CONTEXT_WINDOW} — re-exported under the wire-format name. */ -export const OLLAMA_DEFAULT_NUM_CTX = OLLAMA_DEFAULT_CONTEXT_WINDOW; - /** - * Normalize a configured base URL to the origin the `ollama` client expects - * as its `host` (the client appends `/api/...` itself). + * Normalize a configured base URL to the native Ollama API root expected by + * the provider (it appends endpoint paths such as `/chat`). * * Users configure hosts like `http://localhost:11434` or * `https://ollama.com`; configs saved by the 4.0.0 OpenAI-compatible @@ -40,21 +44,7 @@ export function normalizeOllamaBaseUrl( if (!trimmed) { return undefined; } - return trimmed.replace(/\/(?:v1|api)$/, ""); -} - -/** - * Resolve the `num_ctx` to request from the resolved model's context window. - * `num_ctx` stays an Ollama wire-format detail: callers express intent through - * the provider-neutral model `contextWindow` (from the model catalog or the - * user's configured context window), and this boundary maps it onto the wire. - */ -export function readOllamaNumCtx(context: GatewayProviderContext): number { - const value = context.model?.contextWindow ?? context.model?.maxInputTokens; - if (typeof value === "number" && Number.isFinite(value) && value > 0) { - return Math.floor(value); - } - return OLLAMA_DEFAULT_NUM_CTX; + return `${trimmed.replace(/\/(?:v1|api)$/, "")}/api`; } /** @@ -135,19 +125,23 @@ export async function createOllamaProviderModule( ): Promise { // An API key is only needed for Ollama Cloud (ollama.com); local servers // accept unauthenticated requests, so a missing key is not an error. - // `ai-sdk-ollama` turns `apiKey` into an `Authorization: Bearer` header. + // The provider accepts auth through headers. An explicit configured header + // wins over the convenience API-key setting. const apiKey = await resolveApiKey(config); const baseURL = normalizeOllamaBaseUrl(config.baseUrl); + const headers = { + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + ...config.headers, + }; const provider = createOllama({ ...(baseURL ? { baseURL } : {}), - ...(apiKey ? { apiKey } : {}), - ...(config.headers ? { headers: config.headers } : {}), + ...(Object.keys(headers).length > 0 ? { headers } : {}), + compatibility: "strict", fetch: withOllamaResponseTimeout( ensureFetch(config.fetch), readOllamaTimeoutMs(config), ), }); - const numCtx = readOllamaNumCtx(context); // Retry empty responses (a common local-backend glitch that otherwise // hard-fails the task). Outermost so each retry re-runs the whole request. // `splitToolImagesMiddleware` is inner, for the same reason as the @@ -159,9 +153,7 @@ export async function createOllamaProviderModule( return { model: (modelId) => wrapLanguageModel({ - model: provider(modelId, { - options: { num_ctx: numCtx }, - }) as LanguageModelV4, + model: provider.chat(modelId), middleware: [retryEmptyResponseMiddleware, splitToolImagesMiddleware], }), }; diff --git a/sdk/packages/llms/src/providers/vendors/ollama.wire.test.ts b/sdk/packages/llms/src/providers/vendors/ollama.wire.test.ts new file mode 100644 index 0000000000..85894149d2 --- /dev/null +++ b/sdk/packages/llms/src/providers/vendors/ollama.wire.test.ts @@ -0,0 +1,243 @@ +// Wire-contract tests for the Ollama vendor, exercised through the *real* +// `ollama-ai-provider-v2` package (patched via +// `patches/ollama-ai-provider-v2@4.0.1.patch`) rather than a mocked +// constructor. Each test drives `doStream` through the vendor module with a +// stubbed fetch and asserts on the actual `/api/chat` request body or the +// parsed stream, so regressions inside the dependency's request converter and +// stream parser are caught here: +// +// 1. an unset thinking setting must be *omitted* from the request (not sent +// as `think: false`), so the Ollama server default applies; +// 2. a mid-stream `{"error": ...}` object must surface as an error stream +// part with an `error` finish reason — not be dropped and reported as a +// clean finish; +// 3. an attachment-only user turn must serialize `content` as a string +// (Ollama declares `Message.content` as a string), never `[]`; +// 4. tool results must carry the documented `tool_name` field. +import type { + LanguageModelV4CallOptions, + LanguageModelV4Prompt, + LanguageModelV4StreamPart, +} from "@ai-sdk/provider"; +import type { + GatewayProviderContext, + GatewayResolvedProviderConfig, +} from "@cline/shared"; +import { describe, expect, it } from "vitest"; +import { createOllamaProviderModule } from "./ollama"; + +interface OllamaChatRequest { + model: string; + messages: Array>; + think?: boolean; + options?: Record; + [key: string]: unknown; +} + +interface WireResult { + /** Bodies of every `/api/chat` request the vendor issued. */ + requests: OllamaChatRequest[]; + /** All stream parts surfaced to the consumer. */ + parts: LanguageModelV4StreamPart[]; +} + +const DONE_CHUNK = { + model: "test-model", + created_at: "2024-01-01T00:00:00Z", + done: true, + done_reason: "stop", + message: { role: "assistant", content: "" }, + prompt_eval_count: 1, + eval_count: 1, +}; + +function textChunk(content: string): Record { + return { + model: "test-model", + created_at: "2024-01-01T00:00:00Z", + done: false, + message: { role: "assistant", content }, + }; +} + +/** + * Run one `doStream` turn through the vendor module against a canned NDJSON + * response, capturing the outgoing request bodies and the resulting stream. + */ +async function streamThroughVendor({ + responseLines, + prompt, + providerOptions, +}: { + responseLines: Array>; + prompt: LanguageModelV4Prompt; + providerOptions?: LanguageModelV4CallOptions["providerOptions"]; +}): Promise { + const requests: OllamaChatRequest[] = []; + const fetchStub = (async (_input, init) => { + requests.push(JSON.parse(init?.body as string)); + const body = `${responseLines.map((line) => JSON.stringify(line)).join("\n")}\n`; + return new Response(body, { + status: 200, + headers: { "content-type": "application/x-ndjson" }, + }); + }) as typeof fetch; + + const module = await createOllamaProviderModule( + { providerId: "ollama", fetch: fetchStub } as GatewayResolvedProviderConfig, + { + provider: { id: "ollama", name: "Ollama", defaultModelId: "", models: [] }, + model: { id: "test-model", name: "test-model", providerId: "ollama" }, + } as unknown as GatewayProviderContext, + ); + const model = module.model("test-model"); + const result = await model.doStream({ + prompt, + ...(providerOptions ? { providerOptions } : {}), + } as LanguageModelV4CallOptions); + + const parts: LanguageModelV4StreamPart[] = []; + const reader = result.stream.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + parts.push(value); + } + return { requests, parts }; +} + +function userText(text: string): LanguageModelV4Prompt { + return [{ role: "user", content: [{ type: "text", text }] }]; +} + +describe("ollama wire contract (real provider package)", () => { + it("omits think from the request when no reasoning setting is given", async () => { + const { requests } = await streamThroughVendor({ + responseLines: [textChunk("hi"), DONE_CHUNK], + prompt: userText("hello"), + }); + + expect(requests).toHaveLength(1); + // An omitted `think` lets the Ollama server default (auto-thinking for + // capable models) apply; `think: false` would force-disable it. + expect(requests[0]).not.toHaveProperty("think"); + }); + + it("passes explicit think settings through to the request body", async () => { + const enabled = await streamThroughVendor({ + responseLines: [textChunk("hi"), DONE_CHUNK], + prompt: userText("hello"), + providerOptions: { ollama: { think: true } }, + }); + expect(enabled.requests[0].think).toBe(true); + + const disabled = await streamThroughVendor({ + responseLines: [textChunk("hi"), DONE_CHUNK], + prompt: userText("hello"), + providerOptions: { ollama: { think: false } }, + }); + expect(disabled.requests[0].think).toBe(false); + }); + + it("routes options.num_ctx through the provider bucket", async () => { + const { requests } = await streamThroughVendor({ + responseLines: [textChunk("hi"), DONE_CHUNK], + prompt: userText("hello"), + providerOptions: { ollama: { options: { num_ctx: 65536 } } }, + }); + expect(requests[0].options).toEqual({ num_ctx: 65536 }); + }); + + it("serializes an attachment-only user turn with string content", async () => { + const { requests } = await streamThroughVendor({ + responseLines: [textChunk("hi"), DONE_CHUNK], + prompt: [ + { + role: "user", + content: [ + { + type: "file", + mediaType: "image/png", + data: { type: "data", data: "iVBORw0KGgoAAAANSUhEUg==" }, + }, + ], + }, + ], + }); + + const [message] = requests[0].messages; + expect(message.role).toBe("user"); + // Ollama declares `Message.content` as a string; `[]` is off-contract. + expect(message.content).toBe(""); + expect(message.images).toHaveLength(1); + }); + + it("includes tool_name on tool result messages", async () => { + const { requests } = await streamThroughVendor({ + responseLines: [textChunk("done"), DONE_CHUNK], + prompt: [ + ...userText("read a file"), + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "read_file", + input: { path: "a.ts" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "read_file", + output: { type: "text", value: "file contents" }, + }, + ], + }, + ], + }); + + const toolMessage = requests[0].messages.find( + (message) => message.role === "tool", + ); + expect(toolMessage).toMatchObject({ + tool_call_id: "call-1", + tool_name: "read_file", + content: "file contents", + }); + }); + + it("surfaces a mid-stream error object as an error part, not a clean finish", async () => { + const { requests, parts } = await streamThroughVendor({ + responseLines: [ + textChunk("partial "), + { error: "model crashed while generating" }, + ], + prompt: userText("hello"), + }); + + const errorPart = parts.find((part) => part.type === "error"); + expect(errorPart).toBeDefined(); + expect((errorPart as { error: unknown }).error).toBe( + "model crashed while generating", + ); + + const finishPart = parts.find((part) => part.type === "finish"); + expect(finishPart).toBeDefined(); + expect( + (finishPart as { finishReason: { unified: string } }).finishReason + .unified, + ).toBe("error"); + + // The empty-response retry middleware must not mask the failure by + // re-issuing the request. + expect(requests).toHaveLength(1); + }); +});