feat: Ollama models command, with load/pull/delete and unload (#16)
This commit was merged in pull request #16.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SYSTEM_PROMPT, RECENCY_HINT } from "../lib/constants";
|
||||
import { buildMessages, composeSystemPrompt } from "../lib/history";
|
||||
import { buildMessages, composeSystemPrompt, describeRuntime, type RuntimeContext } from "../lib/history";
|
||||
import type { ChatTurn } from "../lib/types";
|
||||
|
||||
function turn(prompt: string, response: string): ChatTurn {
|
||||
@@ -52,18 +52,97 @@ describe("buildMessages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** The setup a test is composing for: the user's own machine, which is the case that exposed the gaps. */
|
||||
function context(overrides: Partial<RuntimeContext> = {}): RuntimeContext {
|
||||
return {
|
||||
model: "ornith-1.5:9b",
|
||||
host: "http://localhost:11434",
|
||||
selfHosted: true,
|
||||
webTools: true,
|
||||
vision: "yes",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("composeSystemPrompt", () => {
|
||||
it("uses the default prompt when the user has not set one", () => {
|
||||
expect(composeSystemPrompt("", false)).toBe(DEFAULT_SYSTEM_PROMPT);
|
||||
expect(composeSystemPrompt("", context())).toContain(DEFAULT_SYSTEM_PROMPT);
|
||||
});
|
||||
|
||||
it("prefers the user's prompt when set", () => {
|
||||
expect(composeSystemPrompt("Be terse.", false)).toBe("Be terse.");
|
||||
it("prefers the user's prompt when set, and still says what the model is", () => {
|
||||
// The runtime facts are not a matter of style, so a custom prompt must not
|
||||
// drop them. This is the case that would otherwise silently lose them.
|
||||
const composed = composeSystemPrompt("Be terse.", context());
|
||||
|
||||
expect(composed).toContain("Be terse.");
|
||||
expect(composed).not.toContain(DEFAULT_SYSTEM_PROMPT);
|
||||
expect(composed).toContain("You are Lam");
|
||||
});
|
||||
|
||||
it("appends the recency hint only when web tools are on", () => {
|
||||
expect(composeSystemPrompt("Be terse.", true)).toBe(`Be terse.\n\n${RECENCY_HINT}`);
|
||||
expect(composeSystemPrompt("Be terse.", false)).not.toContain(RECENCY_HINT);
|
||||
expect(composeSystemPrompt("Be terse.", context({ webTools: true }))).toContain(RECENCY_HINT);
|
||||
expect(composeSystemPrompt("Be terse.", context({ webTools: false }))).not.toContain(RECENCY_HINT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeRuntime", () => {
|
||||
it("names the model and the server for a local setup", () => {
|
||||
const text = describeRuntime(context());
|
||||
|
||||
expect(text).toContain("`ornith-1.5:9b`");
|
||||
expect(text).toContain("http://localhost:11434");
|
||||
expect(text).toContain("reachable on this machine");
|
||||
});
|
||||
|
||||
it("says Ollama Cloud when that is the host", () => {
|
||||
const text = describeRuntime(
|
||||
context({ host: "https://ollama.com", selfHosted: false, model: "gpt-oss:120b" }),
|
||||
);
|
||||
|
||||
expect(text).toContain("Ollama Cloud");
|
||||
expect(text).not.toContain("reachable on this machine");
|
||||
});
|
||||
|
||||
it("explains the :cloud convention only where it applies", () => {
|
||||
// A local daemon proxying a cloud model is the case where a model that
|
||||
// assumed it was local would tell the user their conversation stays put.
|
||||
expect(describeRuntime(context({ selfHosted: true }))).toContain("`:cloud`");
|
||||
expect(describeRuntime(context({ selfHosted: false }))).not.toContain("leaves this machine");
|
||||
});
|
||||
|
||||
it("tells it to say so when there are no web tools, rather than offering", () => {
|
||||
expect(describeRuntime(context({ webTools: true }))).toContain("You have `web_search`");
|
||||
expect(describeRuntime(context({ webTools: false }))).toContain("no web tools in this session");
|
||||
});
|
||||
|
||||
it("uses the confirmed answer about images when there is one", () => {
|
||||
expect(describeRuntime(context({ vision: "yes" }))).toContain("You can read images");
|
||||
expect(describeRuntime(context({ vision: "no" }))).toContain("You cannot read images");
|
||||
});
|
||||
|
||||
it("tells a model whose eyes are unconfirmed to describe what it sees rather than reason about it", () => {
|
||||
// The reported failure: handed a screenshot and an unconfirmed answer, the
|
||||
// model wrote two paragraphs about whether it could see instead of looking.
|
||||
const text = describeRuntime(context({ vision: "unknown" }));
|
||||
|
||||
expect(text).toContain("has not been confirmed");
|
||||
expect(text).toContain("describe what you see");
|
||||
});
|
||||
|
||||
it("always denies access it does not have", () => {
|
||||
const text = describeRuntime(context());
|
||||
|
||||
expect(text).toContain("You see only what the user types or attaches");
|
||||
expect(text).toContain("cannot read their screen, files, clipboard");
|
||||
});
|
||||
|
||||
it("never claims the model is Vicinae, which was the actual confusion", () => {
|
||||
// The reported answer: "Vicinae is a desktop launcher for Ollama". Vicinae is
|
||||
// the launcher; Ollama is what serves the model, and they are not the same.
|
||||
const text = describeRuntime(context());
|
||||
|
||||
expect(text).toContain("running inside the Vicinae launcher");
|
||||
expect(text).not.toMatch(/You are Vicinae/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { modelCapabilities, normalizeHost, parseNdjsonStream, supportsVision } from "../lib/ollama";
|
||||
import {
|
||||
deleteModel,
|
||||
describePullProgress,
|
||||
isSelfHosted,
|
||||
listModels,
|
||||
listRunningModels,
|
||||
modelCapabilities,
|
||||
normalizeHost,
|
||||
parseNdjsonStream,
|
||||
pullModel,
|
||||
supportsVision,
|
||||
unloadModel,
|
||||
webFetch,
|
||||
webSearch,
|
||||
} from "../lib/ollama";
|
||||
|
||||
/**
|
||||
* The NDJSON reader is the part of the client most likely to break in a way
|
||||
@@ -151,3 +165,438 @@ describe("modelCapabilities", () => {
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/show");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSelfHosted", () => {
|
||||
it("is true for a server the user runs", () => {
|
||||
expect(isSelfHosted("http://localhost:11434")).toBe(true);
|
||||
expect(isSelfHosted("https://box.local:11434")).toBe(true);
|
||||
expect(isSelfHosted("http://192.168.1.10:11434/")).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for Ollama Cloud, which has nothing to pull or delete", () => {
|
||||
expect(isSelfHosted("https://ollama.com")).toBe(false);
|
||||
expect(isSelfHosted("https://ollama.com/")).toBe(false);
|
||||
expect(isSelfHosted("https://api.ollama.com")).toBe(false);
|
||||
expect(isSelfHosted(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an unparseable host as local, because every request fails on its own terms anyway", () => {
|
||||
expect(isSelfHosted("not a url")).toBe(true);
|
||||
});
|
||||
|
||||
it("goes by hostname, so a local server behind https still counts", () => {
|
||||
expect(isSelfHosted("https://gpu-box.internal:443")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listModels", () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** Trimmed from a real `GET /api/tags` on Ollama 0.35.1, mixed local and proxied. */
|
||||
const realPayload = {
|
||||
models: [
|
||||
{
|
||||
name: "ornith-1.5:9b",
|
||||
model: "ornith-1.5:9b",
|
||||
size: 6550813657,
|
||||
details: { parameter_size: "9.0B" },
|
||||
capabilities: ["tools", "thinking", "completion", "vision"],
|
||||
},
|
||||
{
|
||||
name: "deepseek-v4.1-flash:cloud",
|
||||
model: "deepseek-v4.1-flash:cloud",
|
||||
remote_model: "deepseek-v4.1-flash",
|
||||
remote_host: "https://ollama.com",
|
||||
size: 326,
|
||||
details: { parameter_size: "763B" },
|
||||
capabilities: ["completion", "thinking", "tools", "vision"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("reads the size, capabilities and remote host off each entry", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(realPayload), { status: 200 }));
|
||||
|
||||
const models = await listModels({ host: "http://localhost:11434", apiKey: "k" });
|
||||
|
||||
expect(models).toEqual([
|
||||
{
|
||||
name: "deepseek-v4.1-flash:cloud",
|
||||
parameterSize: "763B",
|
||||
size: 326,
|
||||
capabilities: ["completion", "thinking", "tools", "vision"],
|
||||
remoteHost: "https://ollama.com",
|
||||
},
|
||||
{
|
||||
name: "ornith-1.5:9b",
|
||||
parameterSize: "9.0B",
|
||||
size: 6550813657,
|
||||
capabilities: ["tools", "thinking", "completion", "vision"],
|
||||
remoteHost: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves capabilities absent rather than empty when the server does not send them", async () => {
|
||||
// An absent list means "unknown", and a row that read it as [] would claim a
|
||||
// model cannot see images when the server simply said nothing.
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ models: [{ name: "old-model:1b" }] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const [model] = await listModels({ host: "http://localhost:11434", apiKey: "k" });
|
||||
|
||||
expect(model.capabilities).toBeUndefined();
|
||||
expect(model.size).toBeUndefined();
|
||||
expect(model.remoteHost).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the model field and drops nameless entries", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ models: [{ model: "from-model-field" }, { details: {} }] }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(listModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([
|
||||
{ name: "from-model-field", parameterSize: undefined, size: undefined, capabilities: undefined, remoteHost: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces a rejected key instead of an empty list", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }));
|
||||
|
||||
await expect(listModels({ host: "https://ollama.com", apiKey: "k" })).rejects.toThrow(
|
||||
/rejected the API key/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteModel", () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("sends DELETE, because Ollama answers the POST form of this route with 405", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
|
||||
await deleteModel({ host: "http://localhost:11434", apiKey: "k", model: "llama3.2:3b" });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("http://localhost:11434/api/delete");
|
||||
expect(init.method).toBe("DELETE");
|
||||
expect(JSON.parse(init.body)).toEqual({ model: "llama3.2:3b" });
|
||||
});
|
||||
|
||||
it("says the model is not there rather than swallowing a 404 into a success", async () => {
|
||||
// Reported as a failure on purpose: for a delete, "no model called that"
|
||||
// usually means something else removed it, or the host is not the one the
|
||||
// user thinks it is.
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: "model 'x:1' not found" }), { status: 404 }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
deleteModel({ host: "http://localhost:11434", apiKey: "k", model: "x:1" }),
|
||||
).rejects.toThrow(/has no model called x:1/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pullModel", () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function ndjson(events: unknown[]): Response {
|
||||
const body = `${events.map((event) => JSON.stringify(event)).join("\n")}\n`;
|
||||
return new Response(body, { status: 200, headers: { "Content-Type": "application/x-ndjson" } });
|
||||
}
|
||||
|
||||
async function collectPull(): Promise<{ status: string; total?: number; completed?: number }[]> {
|
||||
const out: { status: string; total?: number; completed?: number }[] = [];
|
||||
for await (const progress of pullModel({ host: "http://localhost:11434", apiKey: "k", model: "qwen3:8b" })) {
|
||||
out.push(progress);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it("streams progress as it arrives", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
ndjson([
|
||||
{ status: "pulling manifest" },
|
||||
{ status: "downloading", total: 1000, completed: 250 },
|
||||
{ status: "downloading", total: 1000, completed: 1000 },
|
||||
{ status: "success" },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(collectPull()).resolves.toEqual([
|
||||
{ status: "pulling manifest", total: undefined, completed: undefined },
|
||||
{ status: "downloading", total: 1000, completed: 250 },
|
||||
{ status: "downloading", total: 1000, completed: 1000 },
|
||||
{ status: "success", total: undefined, completed: undefined },
|
||||
]);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("http://localhost:11434/api/pull");
|
||||
expect(JSON.parse(init.body)).toEqual({ model: "qwen3:8b", stream: true });
|
||||
});
|
||||
|
||||
it("throws on the error field, which arrives with HTTP 200 rather than a status", async () => {
|
||||
// Measured against 0.35.1: a bad model name answers 200 and puts the failure
|
||||
// in the body, so a check of the status line alone reports a clean pull.
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
ndjson([{ status: "pulling manifest" }, { error: "pull model manifest: file does not exist" }]),
|
||||
);
|
||||
|
||||
await expect(collectPull()).rejects.toThrow(/file does not exist/);
|
||||
});
|
||||
|
||||
it("throws a plain message when the server refuses outright", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 }));
|
||||
|
||||
await expect(collectPull()).rejects.toThrow(/HTTP 500/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describePullProgress", () => {
|
||||
it("passes a status through when there is nothing to measure yet", () => {
|
||||
expect(describePullProgress({ status: "pulling manifest" })).toBe("pulling manifest");
|
||||
expect(describePullProgress({ status: "verifying sha256 digest" })).toBe("verifying sha256 digest");
|
||||
});
|
||||
|
||||
it("adds a percentage and the transferred size once a total is known", () => {
|
||||
expect(describePullProgress({ status: "downloading", total: 1_000, completed: 420 })).toBe(
|
||||
"downloading · 42% · 420 B / 1.0 kB",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not divide by a total of zero", () => {
|
||||
// A tick can arrive with the total not yet filled in, and printing
|
||||
// "Infinity%" at the user is the kind of thing that only shows up in the wild.
|
||||
expect(describePullProgress({ status: "downloading", total: 0, completed: 10 })).toBe("downloading");
|
||||
expect(describePullProgress({ status: "downloading", total: 100, completed: Number.NaN })).toBe(
|
||||
"downloading",
|
||||
);
|
||||
});
|
||||
|
||||
it("never reports more than complete", () => {
|
||||
expect(describePullProgress({ status: "downloading", total: 100, completed: 140 })).toContain("100%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("web tool paths", () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function json(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), { status });
|
||||
}
|
||||
|
||||
it("uses the plain path on Ollama Cloud", async () => {
|
||||
fetchMock.mockResolvedValueOnce(json({ results: [] }));
|
||||
|
||||
await webSearch({ host: "https://ollama.com", apiKey: "k", query: "q" });
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("https://ollama.com/api/web_search");
|
||||
});
|
||||
|
||||
it("uses the experimental path on a local server, which is the one it serves", async () => {
|
||||
// The reported bug: this used to post to /api/web_search on every host, so
|
||||
// pointing the extension at a local server made every web call a 404 and the
|
||||
// model reported that its web tools were not configured.
|
||||
fetchMock.mockResolvedValueOnce(json({ results: [] }));
|
||||
|
||||
await webSearch({ host: "http://localhost:11434", apiKey: "k", query: "q" });
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/experimental/web_search");
|
||||
});
|
||||
|
||||
it("does the same for web_fetch", async () => {
|
||||
fetchMock.mockResolvedValueOnce(json({ title: "T", content: "C", links: null }));
|
||||
|
||||
await expect(
|
||||
webFetch({ host: "http://localhost:11434", apiKey: "k", url: "https://example.com" }),
|
||||
).resolves.toEqual({ title: "T", content: "C", links: [] });
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/experimental/web_fetch");
|
||||
});
|
||||
|
||||
it("falls back to the other path when the preferred one is missing", async () => {
|
||||
// A server built the other way round, or a future rename: a 404 means the
|
||||
// route is absent, so trying the other one cannot double-send anything.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response("404 page not found", { status: 404 }))
|
||||
.mockResolvedValueOnce(json({ results: [{ title: "t", url: "u", content: "c" }] }));
|
||||
|
||||
await expect(webSearch({ host: "http://localhost:11434", apiKey: "k", query: "q" })).resolves.toHaveLength(
|
||||
1,
|
||||
);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"http://localhost:11434/api/experimental/web_search",
|
||||
"http://localhost:11434/api/web_search",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not fall back on anything other than a 404", async () => {
|
||||
// A 401 or a 500 is the server answering, and retrying the other path would
|
||||
// just send the same refused request twice.
|
||||
fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }));
|
||||
|
||||
await expect(webSearch({ host: "https://ollama.com", apiKey: "k", query: "q" })).rejects.toThrow(
|
||||
/rejected the API key/,
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports the failure from the path it actually tried last", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response("404 page not found", { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response("404 page not found", { status: 404 }));
|
||||
|
||||
await expect(webSearch({ host: "http://localhost:11434", apiKey: "k", query: "q" })).rejects.toThrow(
|
||||
/404/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listRunningModels", () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** Trimmed from a real `GET /api/ps` on Ollama 0.35.1 with one model in VRAM. */
|
||||
const realPayload = {
|
||||
models: [
|
||||
{
|
||||
name: "ornith-1.5:9b",
|
||||
model: "ornith-1.5:9b",
|
||||
size: 10413733314,
|
||||
size_vram: 10413733314,
|
||||
expires_at: "2026-10-05T17:44:11.125356+08:00",
|
||||
context_length: 131072,
|
||||
details: { parameter_size: "9.0B" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("reads what is in memory, including when it will go on its own", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(realPayload), { status: 200 }));
|
||||
|
||||
const running = await listRunningModels({ host: "http://localhost:11434", apiKey: "k" });
|
||||
|
||||
expect(running).toEqual([
|
||||
{
|
||||
name: "ornith-1.5:9b",
|
||||
parameterSize: "9.0B",
|
||||
sizeVram: 10413733314,
|
||||
expiresAt: Date.parse("2026-10-05T17:44:11.125356+08:00"),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/ps");
|
||||
});
|
||||
|
||||
it("is empty, not an error, when the server does not serve /api/ps", async () => {
|
||||
// Older builds answer 404. Taking the whole model list down because the
|
||||
// *running* list is unavailable would be a bad trade.
|
||||
fetchMock.mockResolvedValueOnce(new Response("404 page not found", { status: 404 }));
|
||||
|
||||
await expect(listRunningModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("is empty when the request fails outright", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("connection refused"));
|
||||
|
||||
await expect(listRunningModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("copes with a model holding no VRAM figure, as a CPU-offloaded one would", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ models: [{ name: "cpu-model:1b", size_vram: 0 }] }), { status: 200 }),
|
||||
);
|
||||
|
||||
await expect(listRunningModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([
|
||||
{ name: "cpu-model:1b", parameterSize: undefined, sizeVram: 0, expiresAt: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unloadModel", () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("posts keep_alive 0 with no prompt, which is how Ollama is told to release a model", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ done: true, done_reason: "unload" }), { status: 200 }),
|
||||
);
|
||||
|
||||
await unloadModel({ host: "http://localhost:11434", apiKey: "k", model: "ornith-1.5:9b" });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("http://localhost:11434/api/generate");
|
||||
expect(init.method).toBe("POST");
|
||||
// No `prompt` key at all, so this can never generate a completion.
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
model: "ornith-1.5:9b",
|
||||
keep_alive: 0,
|
||||
stream: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a refusal rather than pretending the memory came back", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 }));
|
||||
|
||||
await expect(
|
||||
unloadModel({ host: "http://localhost:11434", apiKey: "k", model: "x:1" }),
|
||||
).rejects.toThrow(/HTTP 500/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { __resetLocalStorage, __setPreferenceValues } from "../__mocks__/@vicinae/api";
|
||||
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL } from "../lib/constants";
|
||||
import {
|
||||
clearModelSelection,
|
||||
describeAutoDelete,
|
||||
describeMaxToolRounds,
|
||||
describeResumeTimeout,
|
||||
resolveAutoDelete,
|
||||
resolveMaxToolRounds,
|
||||
resolvePreferences,
|
||||
loadPreferences,
|
||||
readModelSelection,
|
||||
resolveResumeTimeout,
|
||||
selectModel,
|
||||
} from "../lib/preferences";
|
||||
|
||||
const MINUTE = 60_000;
|
||||
@@ -203,3 +208,91 @@ describe("describeMaxToolRounds", () => {
|
||||
expect(describeMaxToolRounds(1)).toBe("1 round per answer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the model selection", () => {
|
||||
beforeEach(() => {
|
||||
__resetLocalStorage();
|
||||
__setPreferenceValues({ apiKey: "k", model: "from-preference:1b" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__setPreferenceValues({});
|
||||
});
|
||||
|
||||
it("uses the preference when nothing has been picked", async () => {
|
||||
const resolved = await loadPreferences();
|
||||
|
||||
expect(resolved.model).toBe("from-preference:1b");
|
||||
expect(resolved.modelSource).toBe("preference");
|
||||
expect(resolved.preferenceModel).toBe("from-preference:1b");
|
||||
});
|
||||
|
||||
it("prefers a picked model over the preference, and says so", async () => {
|
||||
// The whole reason this exists: an extension cannot write its own
|
||||
// preferences, so the pick has to live somewhere else and win.
|
||||
await selectModel("picked:9b");
|
||||
|
||||
const resolved = await loadPreferences();
|
||||
|
||||
expect(resolved.model).toBe("picked:9b");
|
||||
expect(resolved.modelSource).toBe("selection");
|
||||
// Both values are carried, so a screen can answer "what would a reset give
|
||||
// me back".
|
||||
expect(resolved.preferenceModel).toBe("from-preference:1b");
|
||||
});
|
||||
|
||||
it("round-trips a pick through storage", async () => {
|
||||
await selectModel("first:1b");
|
||||
await expect(readModelSelection()).resolves.toBe("first:1b");
|
||||
|
||||
await selectModel("second:2b");
|
||||
await expect(readModelSelection()).resolves.toBe("second:2b");
|
||||
});
|
||||
|
||||
it("hands the model back to the preference when the selection is cleared", async () => {
|
||||
await selectModel("picked:9b");
|
||||
await clearModelSelection();
|
||||
|
||||
const resolved = await loadPreferences();
|
||||
|
||||
expect(resolved.model).toBe("from-preference:1b");
|
||||
expect(resolved.modelSource).toBe("preference");
|
||||
});
|
||||
|
||||
it("ignores a stored selection that is empty or whitespace", async () => {
|
||||
// A blank value is not a model name, and treating it as one would leave the
|
||||
// chat asking for "" on every turn.
|
||||
await selectModel(" ");
|
||||
|
||||
await expect(readModelSelection()).resolves.toBeUndefined();
|
||||
await expect(loadPreferences()).resolves.toMatchObject({ model: "from-preference:1b" });
|
||||
});
|
||||
|
||||
it("trims a stored selection", async () => {
|
||||
await selectModel(" spaced:1b ");
|
||||
|
||||
await expect(readModelSelection()).resolves.toBe("spaced:1b");
|
||||
});
|
||||
|
||||
it("falls back to the preference when storage cannot be read", async () => {
|
||||
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
|
||||
const original = LocalStorage.getItem;
|
||||
LocalStorage.getItem = async () => {
|
||||
throw new Error("storage is unavailable");
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(loadPreferences()).resolves.toMatchObject({ model: "from-preference:1b" });
|
||||
} finally {
|
||||
LocalStorage.getItem = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the model source as the preference when resolving without storage", () => {
|
||||
expect(resolvePreferences({ model: "plain:1b" })).toMatchObject({
|
||||
model: "plain:1b",
|
||||
modelSource: "preference",
|
||||
preferenceModel: "plain:1b",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
errorMessage,
|
||||
formatBytes,
|
||||
formatRelativeTime,
|
||||
recencyGroup,
|
||||
titleFromPrompt,
|
||||
@@ -98,3 +99,35 @@ describe("errorMessage", () => {
|
||||
expect(typeof errorMessage(circular)).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("matches what `ollama list` prints for the same bytes", () => {
|
||||
// Captured from this machine: `ollama list` shows ornith-1.5:9b as 6.6 GB,
|
||||
// and a size that disagreed with the terminal would read as our bug.
|
||||
expect(formatBytes(6_550_813_657)).toBe("6.6 GB");
|
||||
});
|
||||
|
||||
it("keeps small counts in bytes", () => {
|
||||
expect(formatBytes(0)).toBe("0 B");
|
||||
expect(formatBytes(326)).toBe("326 B");
|
||||
expect(formatBytes(999)).toBe("999 B");
|
||||
});
|
||||
|
||||
it("steps up one unit at a time", () => {
|
||||
expect(formatBytes(1_500)).toBe("1.5 kB");
|
||||
expect(formatBytes(2_500_000)).toBe("2.5 MB");
|
||||
expect(formatBytes(3_000_000_000_000)).toBe("3.0 TB");
|
||||
});
|
||||
|
||||
it("stops at terabytes rather than inventing a unit", () => {
|
||||
expect(formatBytes(9_000_000_000_000_000)).toBe("9000.0 TB");
|
||||
});
|
||||
|
||||
it("has no answer for a size that is absent or nonsense", () => {
|
||||
// undefined rather than "NaN B", so a caller leaves the field out.
|
||||
expect(formatBytes(undefined)).toBeUndefined();
|
||||
expect(formatBytes(-1)).toBeUndefined();
|
||||
expect(formatBytes(Number.NaN)).toBeUndefined();
|
||||
expect(formatBytes(Number.POSITIVE_INFINITY)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user