mirror of
https://github.com/cline/cline.git
synced 2026-08-28 19:48:08 +08:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
+95
@@ -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"]);
|
||||
});
|
||||
});
|
||||
+168
-30
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user