fix(llms): switch ollama provider package (#12892)

* fix(ollama): use native AI SDK provider

* fix(ollama): patch ollama-ai-provider-v2 wire contracts and lock them with real-provider tests

The pinned ollama-ai-provider-v2@4.0.1 breaks four native Ollama wire
contracts (review findings on #12892). Patch the package via Bun
patchedDependencies:

- omit think from the request when no reasoning setting resolves,
  instead of forcing think: false (lets the server default apply)
- surface mid-stream {"error": ...} objects as error stream parts with
  an error finish reason, instead of dropping them before a clean finish
- serialize attachment-only user turns as string content (""), not []
- include the documented tool_name field on tool result messages

Add ollama.wire.test.ts exercising doStream through the vendor module
against the real (patched) package with a stubbed fetch, asserting on
the actual /api/chat request bodies and parsed stream so regressions in
the dependency's request converter or stream parser are caught.

* fix ollama model list refresh

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
This commit is contained in:
Bee
2026-08-04 13:28:53 -07:00
committed by GitHub
parent 3af23c1c4c
commit 400ba47387
19 changed files with 1049 additions and 290 deletions
@@ -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();
@@ -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<HTMLButtonElement>(
'[aria-label="Thinking level"]',
@@ -125,6 +140,7 @@ describe("ChatInputBar", () => {
expect(
container.querySelectorAll<HTMLButtonElement>('[aria-label^="Model:"]'),
).toHaveLength(2);
expect(container.textContent).toContain("refreshed-model");
await act(async () =>
container
.querySelector<HTMLButtonElement>('[aria-label="Close model selector"]')
@@ -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) => {
@@ -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<HTMLInputElement>) => {
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({
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Models
</Label>
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
{form.models.map((model) => (
<span
key={model}
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
>
<span className="font-mono">{model}</span>
<Button
onClick={() => removeModel(model)}
className="text-primary/60 hover:text-primary transition-colors"
aria-label={`Remove ${model}`}
>
<X className="h-3 w-3" />
</Button>
</span>
))}
<input
type="text"
value={modelInput}
onChange={(e) => 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"
/>
</div>
<ModelIdInput models={form.models} onChange={updateModels} />
<p className="mt-1.5 text-xs text-muted-foreground">
Add at least one model or set a Model Source URL.
</p>
@@ -506,7 +457,7 @@ export function AddProviderContent({
<div className="flex items-center justify-end gap-3 pt-2">
<Button
onClick={onBack}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-primary transition-colors"
>
Cancel
</Button>
@@ -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 (
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
{models.map((model) => (
<span
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
key={model}
>
<span className="font-mono">{model}</span>
<Button
aria-label={`Remove ${model}`}
className="text-foreground hover:text-foreground"
disabled={disabled}
onClick={() => onChange(models.filter((entry) => entry !== model))}
type="button"
size="icon-sm"
>
<X className="size-2" />
</Button>
</span>
))}
<input
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
disabled={disabled}
onChange={(event) => 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}
/>
</div>
);
}
@@ -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(
<ProviderDetailContent
modelsError={null}
onBack={vi.fn()}
onLoadModels={vi.fn()}
onUpdate={vi.fn()}
onUpdateModels={onUpdateModels}
provider={provider}
/>,
);
});
await act(async () => {
container
.querySelector<HTMLButtonElement>('[aria-label="Favorite Beta"]')
?.click();
});
expect(
Array.from(
container.querySelectorAll<HTMLButtonElement>(
'[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<HTMLButtonElement>('[aria-label="Add model"]')
?.click();
});
const input = container.querySelector<HTMLInputElement>(
'[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<HTMLButtonElement>(
'[aria-label="New model ID"] + button',
)
?.click();
});
expect(onUpdateModels).toHaveBeenCalledWith(["alpha", "beta", "gamma"]);
});
});
@@ -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<string, string[]> {
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<string, string[]>): 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"
>
<p className="min-w-0 flex-1 truncate text-[17px] font-semibold text-foreground">
{prov.name}
</p>
<div className="flex min-w-0 flex-1 items-baseline gap-2">
<p className="truncate text-[17px] font-semibold text-foreground">
{prov.name}
</p>
<p className="shrink-0 truncate font-mono text-xs text-muted-foreground">
{prov.id}
</p>
</div>
<p className="shrink-0 text-[15px] text-muted-foreground">
{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<number | undefined>(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 (
<ScrollArea className="h-full">
<div
@@ -359,14 +432,19 @@ export function ProviderDetailContent({
<ArrowLeft className="h-4 w-4" />
)}
</Button>
<h1
className={cn(
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
isPanel ? "text-[24px]" : "text-[32px]",
)}
>
{provider.name}
</h1>
<div className="flex min-w-0 items-baseline gap-2">
<h1
className={cn(
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
isPanel ? "text-[24px]" : "text-[32px]",
)}
>
{provider.name}
</h1>
<p className="shrink-0 font-mono text-xs text-muted-foreground">
{provider.id}
</p>
</div>
</div>
{configFields.length > 0 ? (
@@ -523,30 +601,78 @@ export function ProviderDetailContent({
)}
>
<div className="flex h-12 items-center justify-between bg-muted/40 px-4">
<h2 className="text-[17px] font-medium text-muted-foreground">
Models
</h2>
<div className="flex items-center gap-1">
<Search className="size-4 text-muted-foreground" />
<h2 className="mr-1 text-[17px] font-medium text-muted-foreground">
Models
</h2>
<Button
aria-label="Refresh models"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
className="size-4 rounded-none p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground"
disabled={modelsLoading}
onClick={onLoadModels}
variant="ghost"
>
<RefreshCw
className={cn("size-3", modelsLoading && "animate-spin")}
className={cn("size-4", modelsLoading && "animate-spin")}
/>
</Button>
</div>
{onUpdateModels ? (
<Button
aria-label="Add model"
className="size-4 rounded-none p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground"
disabled={modelsLoading}
onClick={() =>
setAddModelState({ providerId: provider.id, value: "" })
}
variant="ghost"
>
<Plus className="size-4" />
</Button>
) : null}
</div>
{isAddingModel ? (
<div className="flex items-center gap-2 border-t px-4 py-3">
<Input
aria-label="New model ID"
autoFocus
className="h-9 flex-1 font-mono"
onChange={(event) =>
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}
/>
<Button
disabled={!newModelId.trim()}
onClick={addModel}
size="sm"
>
Add
</Button>
<Button
onClick={() => setAddModelState(null)}
size="sm"
variant="ghost"
>
Cancel
</Button>
</div>
) : null}
{modelsError ? (
<div className="rounded-lg border border-border px-4 py-8 text-center">
<div className="border-t border-destructive/30 bg-destructive/5 px-4 py-2">
<p className="text-sm text-destructive">{modelsError}</p>
</div>
) : modelList.length > 0 ? (
) : null}
{modelList.length > 0 ? (
<div className="space-y-3">
<div className="mx-4 mt-4 flex items-center gap-2 rounded border border-border bg-background px-3 py-2">
<Search className="size-4 shrink-0 text-muted-foreground" />
@@ -603,16 +729,28 @@ export function ProviderDetailContent({
</button>
</div>
{/* Action icons */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
aria-label={`Favorite ${model.name}`}
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
variant="ghost"
>
<Star className="h-3.5 w-3.5" />
</Button>
</div>
<Button
aria-label={
favoriteModelIds.has(model.id)
? `Unfavorite ${model.name}`
: `Favorite ${model.name}`
}
className={cn(
"ml-auto shrink-0 rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground",
favoriteModelIds.has(model.id)
? "text-amber-400"
: "text-muted-foreground",
)}
onClick={() => toggleFavoriteModel(model.id)}
variant="ghost"
>
<Star
className={cn(
"size-4",
favoriteModelIds.has(model.id) && "fill-current",
)}
/>
</Button>
</div>
))}
</div>
@@ -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)
@@ -59,6 +59,29 @@ let providerCatalogCache: {
promise: Promise<ProviderCatalogResponse>;
} | null = null;
type ProviderModelsListener = (
providerId: string,
models: ProviderModel[],
) => void;
const providerModelsListeners = new Set<ProviderModelsListener>();
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<ProviderCatalogResponse> {
+8 -11
View File
@@ -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=="],
+2 -1
View File
@@ -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"
}
}
+168
View File
@@ -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;
@@ -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)
+1 -1
View File
@@ -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": {
@@ -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<ProviderOptionRule> = [
clineReasoningDisabledThinkingRule,
kimiK26ThinkingRule,
deepSeekThinkingRule,
ollamaReasoningDefaultOnDisableRule,
ollamaNativeOptionsRule,
nonGlmProviderRoutingSuppressionRule,
nativeZaiGlmThinkingRule,
miniMaxThinkingRule,
@@ -21,6 +21,7 @@ type ContextOverrides = {
providerId?: string;
modelId?: string;
family?: string;
contextWindow?: number;
maxOutputTokens?: number;
reasoningOptions?: readonly ModelReasoningOption[];
modelMetadata?: NonNullable<GatewayProviderContext["model"]["metadata"]>;
@@ -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"] },
],
},
]);
});
+17 -52
View File
@@ -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();
});
});
+23 -31
View File
@@ -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<ProviderFactoryResult> {
// 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],
}),
};
@@ -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<Record<string, unknown>>;
think?: boolean;
options?: Record<string, unknown>;
[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<string, unknown> {
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<Record<string, unknown>>;
prompt: LanguageModelV4Prompt;
providerOptions?: LanguageModelV4CallOptions["providerOptions"];
}): Promise<WireResult> {
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);
});
});