Files
Codeman/test/custom-model-endpoint-rediscovery.test.ts
T
Codeman maintainer a864acc52c fix(custom-model): serialize every write of the endpoint store
The four endpoint routes and the 5-minute discovery sweep each read
custom-model-hosts.json, changed their copy and wrote it back through one
shared temp file name. Two of them at once could tear the file or drop each
other's record: ten creates sent together did not all land.

Every write now goes through mutateCustomModelHosts() (one promise chain, a
random temp name, mode 0600), with each check inside the same step as its
write. A file that does not parse is refused with 409 and never replaced.
A delete of an unknown id no longer rewrites the file. A guard test fails
any other writer in src/.

The id `local-ollama` is reserved for the model Codeman runs on this Mac: a
new endpoint may not take it, and while the record is the managed one it is
not edited, deleted or re-discovered from these routes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 22:16:38 +01:00

710 lines
31 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Tests for `refreshAllCustomModelHosts()`, the periodic
* background sweep behind server.ts's "custom model endpoint re-discovery"
* timer (docs/custom-model-endpoints-plan.md). Kept in its own file rather
* than folded into test/routes/custom-model-routes.test.ts: that file's data
* dir is shared across every test in it (one temp HOME per FILE, not per
* test — test/setup.ts), and a sweep that walks every saved host would pick
* up every host any other test in that file happened to create, making an
* exact call-count or exact-host assertion meaningless. A dedicated file
* gets its own clean temp HOME.
*
* Port: N/A (no server; drives readCustomModelHosts/writeCustomModelHosts
* directly plus the mocked webviewFetch dispatcher).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dataPath, getDataDir } from '../src/config/instance.js';
import { customModelHostsPath } from '../src/custom-model-hosts.js';
import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
import { clearPropsProbeCache, refreshAllCustomModelHosts } from '../src/web/routes/custom-model-routes.js';
import { webviewFetch } from '../src/web/webview-egress.js';
vi.mock('../src/web/webview-egress.js', async () => {
const actual = await vi.importActual<typeof import('../src/web/webview-egress.js')>('../src/web/webview-egress.js');
return { ...actual, webviewFetch: vi.fn() };
});
const fetchMock = vi.mocked(webviewFetch);
function host(overrides: Partial<CustomModelHost> & Pick<CustomModelHost, 'id' | 'baseUrl'>): CustomModelHost {
return { label: overrides.id, ...overrides };
}
beforeEach(() => {
fetchMock.mockReset();
clearPropsProbeCache();
});
describe('refreshAllCustomModelHosts (the periodic re-discovery sweep)', () => {
it('refreshes every saved endpoint, best-effort — one unreachable host does not stop the others', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [
host({ id: 'ok', baseUrl: 'http://localhost:8080' }),
host({ id: 'down', baseUrl: 'http://localhost:8081' }),
]);
fetchMock.mockImplementation(async (url: URL) => {
if (url.href.includes('8081')) throw new TypeError('fetch failed', { cause: new Error('ECONNREFUSED') });
return new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 });
});
await refreshAllCustomModelHosts();
const hosts = await readCustomModelHosts(dir);
const ok = hosts.find((h) => h.id === 'ok');
const down = hosts.find((h) => h.id === 'down');
expect(ok?.models).toEqual(['qwen3']);
expect(ok?.lastDiscoveredAt).toBeTruthy();
expect(down?.models ?? []).toEqual([]);
expect(down?.lastDiscoveredAt).toBeFalsy();
});
it('skips a host whose baseUrl is blocked, without making a request', async () => {
const dir = getDataDir();
// Written directly rather than through the POST route, which already
// refuses this at save time — this simulates a record that pre-dates the
// guard, or was hand-edited on disk. The sweep must not trust it either.
await writeCustomModelHosts(dir, [host({ id: 'meta', baseUrl: 'http://169.254.169.254/' })]);
fetchMock.mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'x' }] }), { status: 200 }));
await refreshAllCustomModelHosts();
expect(fetchMock).not.toHaveBeenCalled();
});
it('drops a stale default and preserves lastDiscoveredAt semantics, same as manual discovery', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [
host({ id: 'ep', baseUrl: 'http://localhost:8080', models: ['qwen3'], defaultModelId: 'qwen3' }),
]);
fetchMock.mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'llama3' }] }), { status: 200 }));
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.models).toEqual(['llama3']);
expect(updated.defaultModelId).toBeUndefined();
expect(updated.lastDiscoveredAt).toBeTruthy();
});
it('keeps a default that is still present after the sweep', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [
host({ id: 'ep', baseUrl: 'http://localhost:8080', models: ['qwen3'], defaultModelId: 'qwen3' }),
]);
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: [{ id: 'qwen3' }, { id: 'llama3' }] }), { status: 200 })
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.defaultModelId).toBe('qwen3');
});
it('does not resurrect an endpoint deleted while the sweep was in flight', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'deleted', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async () => {
// Simulate an admin deleting the endpoint between the sweep's fetch and
// its read-modify-write — the delete must win, not be overwritten by a
// refresh that started before it.
const current = await readCustomModelHosts(dir);
await writeCustomModelHosts(
dir,
current.filter((h) => h.id !== 'deleted')
);
return new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 });
});
await expect(refreshAllCustomModelHosts()).resolves.toBeUndefined();
const hosts = await readCustomModelHosts(dir);
expect(hosts.find((h) => h.id === 'deleted')).toBeUndefined();
});
it('leaves the store untouched when there are no saved endpoints at all', async () => {
await expect(refreshAllCustomModelHosts()).resolves.toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama-swap /props)', () => {
it('probes /props?model= only for a model reported loaded, and stores its n_ctx', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(
JSON.stringify({
data: [
{ id: 'loaded-model', status: { value: 'loaded' } },
{ id: 'unloaded-model', status: { value: 'unloaded' } },
],
}),
{ status: 200 }
);
}
if (url.pathname === '/props') {
// Must never be reached for the unloaded model — asserted below by call count.
expect(url.searchParams.get('model')).toBe('loaded-model');
return new Response(JSON.stringify({ n_ctx: 16384 }), { status: 200 });
}
throw new Error(`unexpected request: ${url.href}`);
});
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toEqual({ 'loaded-model': 16384 });
const propsCalls = fetchMock.mock.calls.filter(([url]) => (url as URL).pathname === '/props');
expect(propsCalls).toHaveLength(1);
});
it('never probes /props at all when no entry mentions status — feature-detected, not assumed unloaded', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 }));
await refreshAllCustomModelHosts();
expect(fetchMock).toHaveBeenCalledTimes(1); // /v1/models only
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toBeUndefined();
});
it('keeps a previously-learned context length for a model no longer loaded, drops it once the model disappears entirely', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [
host({
id: 'ep',
baseUrl: 'http://localhost:8080',
models: ['a', 'b'],
modelContextLengths: { a: 8192, b: 4096 },
}),
]);
// This round: 'a' is loaded (re-confirmed), 'b' is gone from the list entirely.
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(JSON.stringify({ data: [{ id: 'a', status: { value: 'loaded' } }] }), { status: 200 });
}
return new Response(JSON.stringify({ n_ctx: 8192 }), { status: 200 });
});
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toEqual({ a: 8192 });
});
it('a failed /props probe for the loaded model is swallowed, leaving no context length rather than failing the sweep', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(JSON.stringify({ data: [{ id: 'a', status: { value: 'loaded' } }] }), { status: 200 });
}
return new Response('nope', { status: 500 });
});
await expect(refreshAllCustomModelHosts()).resolves.toBeUndefined();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toBeUndefined();
});
it('prefers the REAL configured context size parsed from /running’s launch command over /props’s unreliable n_ctx', async () => {
// Confirmed live: llama-swap launched a model with --fit-ctx 16384 (the real, working
// limit — the actual server then refused a request over it), but /props reported
// n_ctx: 154112 for the same model, well over what it would really accept. The context
// must therefore keep coming from the /running command; /props is now also probed, but
// only for `total_slots`, which the command does not state.
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(JSON.stringify({ data: [{ id: 'qwen3.8-27b', status: { value: 'loaded' } }] }), {
status: 200,
});
}
if (url.pathname === '/running') {
return new Response(
JSON.stringify({
running: [
{
model: 'qwen3.8-27b',
state: 'ready',
cmd: 'llama-server -m /models/Qwen3.8-27B.gguf --flash-attn on --jinja --fit-ctx 16384 --host 0.0.0.0 --port 5840',
},
],
}),
{ status: 200 }
);
}
if (url.pathname === '/props') {
return new Response(JSON.stringify({ n_ctx: 154112, total_slots: 4 }), { status: 200 });
}
throw new Error(`unexpected request: ${url.href}`);
});
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toEqual({ 'qwen3.8-27b': 16384 });
expect(updated.modelSlotCounts).toEqual({ 'qwen3.8-27b': 4 });
// /props's 154112 never becomes the context, and nothing else stores it
});
it('falls back to /props when /running has no cmd, or the cmd states no recognizable context flag', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(JSON.stringify({ data: [{ id: 'a', status: { value: 'loaded' } }] }), { status: 200 });
}
if (url.pathname === '/running') {
return new Response(
JSON.stringify({ running: [{ model: 'a', state: 'ready', cmd: 'llama-server -m /models/a.gguf' }] }),
{ status: 200 }
);
}
if (url.pathname === '/props') return new Response(JSON.stringify({ n_ctx: 8192 }), { status: 200 });
throw new Error(`unexpected request: ${url.href}`);
});
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toEqual({ a: 8192 });
});
it('also recognizes a plain -c/--ctx-size flag, not just llama-swap’s own --fit-ctx', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(JSON.stringify({ data: [{ id: 'a', status: { value: 'loaded' } }] }), { status: 200 });
}
if (url.pathname === '/running') {
return new Response(
JSON.stringify({
running: [{ model: 'a', state: 'ready', cmd: 'llama-server -m /models/a.gguf --ctx-size 8192' }],
}),
{ status: 200 }
);
}
if (url.pathname === '/props') return new Response('not found', { status: 404 }); // slot probe only
throw new Error(`unexpected request: ${url.href}`);
});
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toEqual({ a: 8192 });
expect(updated.modelSlotCounts).toBeUndefined();
});
});
describe('refreshAllCustomModelHosts: parallel-slot enrichment (llama.cpp splits its context across --parallel slots)', () => {
/** One loaded model whose launch command is `cmd`; `/props` behaviour is supplied by the test. */
function serve(cmd: string, props: () => Response) {
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'loaded' } }] }), { status: 200 });
}
if (url.pathname === '/running') {
return new Response(JSON.stringify({ running: [{ model: 'm', state: 'ready', cmd }] }), { status: 200 });
}
if (url.pathname === '/props') return props();
throw new Error(`unexpected request: ${url.href}`);
});
}
const propsMustNotBeReached = () => {
throw new Error('/props must not be probed: the launch command already answered both questions');
};
it.each([
['--parallel 1', 1],
['-np 2', 2],
['--parallel=3', 3],
['--fit-ctx 65536 --parallel 1 --host 0.0.0.0', 1],
])('reads an explicit %s from the launch command without probing /props', async (flags, slots) => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
serve(`llama-server -m /models/m.gguf --ctx-size 65536 ${flags}`, propsMustNotBeReached);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ m: slots });
expect(updated.modelContextLengths).toEqual({ m: 65536 });
});
it('asks /props for total_slots when the command states no --parallel (llama-server picks the count itself)', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
serve(
'llama-server -m /models/m.gguf --fit-ctx 65536',
() => new Response(JSON.stringify({ n_ctx: 133888, total_slots: 4 }), { status: 200 })
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ m: 4 });
// the context still comes from the command's floor, never from /props's n_ctx
expect(updated.modelContextLengths).toEqual({ m: 65536 });
});
it('probes /props once per launch command: a repeat sweep reuses the answer, a changed command probes again', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
let probes = 0;
const answer = () => (probes++, new Response(JSON.stringify({ n_ctx: 133888, total_slots: 4 }), { status: 200 }));
serve('llama-server -m /models/m.gguf --fit-ctx 65536', answer);
await refreshAllCustomModelHosts();
await refreshAllCustomModelHosts();
expect(probes).toBe(1); // a /props call refreshes the model's last-use time in llama-swap, so not every sweep
expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 4 });
serve('llama-server -m /models/m.gguf --fit-ctx 65536 --flash-attn on', answer);
await refreshAllCustomModelHosts();
expect(probes).toBe(2);
});
it('does not divide a /props context a second time when --parallel is explicit and the command states no context', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
// with an explicit count llama.cpp reports n_ctx per slot: 65,536 each is already what one request gets
serve(
'llama-server -m /models/m.gguf --parallel 4',
() => new Response(JSON.stringify({ n_ctx: 65536, total_slots: 4 }), { status: 200 })
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toEqual({ m: 65536 });
expect(updated.modelSlotCounts).toEqual({ m: 1 }); // already per slot, so one request gets all of it
});
it('a later round with an explicit --parallel replaces the slot count an earlier round learned', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
// round 1: auto slots, the server chose 4 sharing 131,072 tokens
serve(
'llama-server -m /models/m.gguf',
() => new Response(JSON.stringify({ n_ctx: 131072, total_slots: 4 }), { status: 200 })
);
await refreshAllCustomModelHosts();
expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 4 });
// round 2: the user followed the dialog and added --parallel 1
serve(
'llama-server -m /models/m.gguf --parallel 1',
() => new Response(JSON.stringify({ n_ctx: 131072, total_slots: 1 }), { status: 200 })
);
await refreshAllCustomModelHosts();
expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 1 });
// round 3: --parallel 2 with a per-slot /props context must not bring a pool back
serve(
'llama-server -m /models/m.gguf --parallel 2',
() => new Response(JSON.stringify({ n_ctx: 65536, total_slots: 2 }), { status: 200 })
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ m: 1 });
expect(updated.modelContextLengths).toEqual({ m: 65536 });
});
it('remembers a /props that answers non-2xx per launch command, but retries a network failure', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
let probes = 0;
serve('llama-server -m /models/m.gguf --fit-ctx 65536', () => (probes++, new Response('nope', { status: 404 })));
await refreshAllCustomModelHosts();
await refreshAllCustomModelHosts();
expect(probes).toBe(1); // no probe every sweep: a /props call keeps a model with a ttl loaded
clearPropsProbeCache();
probes = 0;
serve('llama-server -m /models/m.gguf --fit-ctx 65536', () => {
probes++;
throw new TypeError('fetch failed');
});
await refreshAllCustomModelHosts();
await refreshAllCustomModelHosts();
expect(probes).toBe(2); // a network error says nothing about the server, so it is tried again
});
it('treats -np -1 (llama.cpp auto) as unstated and falls back to /props', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
serve(
'llama-server -m /models/m.gguf --fit-ctx 65536 -np -1',
() => new Response(JSON.stringify({ total_slots: 4 }), { status: 200 })
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ m: 4 });
});
it('a failed or slot-less /props leaves no slot count rather than failing the sweep or guessing one', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
serve(
'llama-server -m /models/m.gguf --fit-ctx 65536',
() => new Response(JSON.stringify({ n_ctx: 8192 }), { status: 200 })
);
await expect(refreshAllCustomModelHosts()).resolves.toBeUndefined();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toBeUndefined();
expect(updated.modelContextLengths).toEqual({ m: 65536 });
});
it('keeps a previously-learned slot count for a model that is no longer loaded, and drops it once the model disappears', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [
host({
id: 'ep',
baseUrl: 'http://localhost:8080',
models: ['a', 'b'],
modelContextLengths: { a: 65536, b: 65536 },
modelSlotCounts: { a: 4, b: 2 },
}),
]);
fetchMock.mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'a' }] }), { status: 200 }));
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ a: 4 });
});
it('a round where only the listed figure answers resets the slot count a loaded round learned', async () => {
// The false-warning scenario, measured on a real llama-swap: round 1 has the model
// loaded under `--ctx-size 262144 --parallel 4`: a TOTAL window shared across slots,
// correctly stored with its count. Round 2 finds it unloaded and takes the LISTED
// `context_length`, which llama-swap documents as the PER-SLOT window. If the stored 4
// survived, the floor check would divide the per-slot figure a second time
// (65536/4 = 16384) and warn about a model that serves 65536 to every request.
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'loaded' } }] }), { status: 200 });
}
if (url.pathname === '/running') {
return new Response(
JSON.stringify({
running: [
{ model: 'm', state: 'ready', cmd: 'llama-server -m /models/m.gguf --ctx-size 262144 --parallel 4' },
],
}),
{ status: 200 }
);
}
throw new Error(`unexpected request: ${url.href}`); // the command answers both questions
});
await refreshAllCustomModelHosts();
let [round1] = await readCustomModelHosts(dir);
expect(round1.modelContextLengths).toEqual({ m: 262144 });
expect(round1.modelSlotCounts).toEqual({ m: 4 });
// Round 2: unloaded, no /running or /props probe in order, listing its per-slot figure.
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'unloaded' }, context_length: 65536 }] }), {
status: 200,
})
);
await refreshAllCustomModelHosts();
const [round2] = await readCustomModelHosts(dir);
expect(round2.modelContextLengths).toEqual({ m: 65536 });
// 1 slot, not the stale 4: 65536 per slot with 1 recorded slot is the honest pair, so
// the floor check reads 65536/request: exactly what the server gives a single request.
expect(round2.modelSlotCounts).toEqual({ m: 1 });
});
});
describe('refreshAllCustomModelHosts: model-size enrichment (parsed from /v1/models description)', () => {
it('parses a GB figure out of an auto-discovered model’s description', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: [{ id: 'qwen3.8-27b', description: 'Auto-discovered 16.35 GB - parameters auto-fitted by llama.cpp' }],
}),
{ status: 200 }
)
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSizesGB).toEqual({ 'qwen3.8-27b': 16.35 });
});
it('gets no size at all for a hand-configured profile whose own description states none', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: [{ id: 'big', description: 'General-purpose reasoning model, MoE CPU-offloaded. Default profile.' }],
}),
{ status: 200 }
)
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSizesGB).toBeUndefined();
});
it('populated regardless of loaded state — unlike context length, no /props probe is needed', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(
JSON.stringify({
data: [{ id: 'unloaded-model', description: 'Auto-discovered 4.91 GB - parameters auto-fitted' }],
}),
{ status: 200 }
);
}
throw new Error(`unexpected request: ${url.href}`); // /props must never be reached for this
});
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSizesGB).toEqual({ 'unloaded-model': 4.91 });
});
it('keeps a previously-learned size for a model still present, drops it once the model disappears entirely', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [
host({ id: 'ep', baseUrl: 'http://localhost:8080', models: ['a', 'b'], modelSizesGB: { a: 8, b: 16 } }),
]);
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: [{ id: 'a', description: 'no GB figure here' }] }), { status: 200 })
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSizesGB).toEqual({ a: 8 }); // 'a' kept from before, 'b' dropped (gone from the list)
});
});
describe('the sweep and the serialized writer', () => {
it('an endpoint created while the sweep was probing another one is kept', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'probed', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async () => {
// An admin saves a second endpoint between the sweep's read and its write. The sweep
// must splice its result onto the list as it is then, not write back the one it read.
const current = await readCustomModelHosts(dir);
if (!current.some((h) => h.id === 'added')) {
await writeCustomModelHosts(dir, [...current, host({ id: 'added', baseUrl: 'http://localhost:8082' })]);
}
return new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 });
});
await refreshAllCustomModelHosts();
const hosts = await readCustomModelHosts(dir);
expect(hosts.map((h) => h.id)).toEqual(['probed', 'added']);
expect(hosts[0].models).toEqual(['qwen3']);
});
it('never writes over a store that stopped parsing while the sweep ran', async () => {
const dir = getDataDir();
vi.spyOn(console, 'warn').mockImplementation(() => {});
await writeCustomModelHosts(dir, [
host({ id: 'a', baseUrl: 'http://localhost:8080', apiKey: 'sk-keep' }),
host({ id: 'b', baseUrl: 'http://localhost:8081' }),
]);
const broken = '[{"id":"a","apiKey":"sk-keep"},';
fetchMock.mockImplementation(async () => {
writeFileSync(customModelHostsPath(dir), broken);
return new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 });
});
await expect(refreshAllCustomModelHosts()).resolves.toBeUndefined();
// It used to read the broken file as an empty list and carry on; it is left as it is.
expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(broken);
// And the sweep stopped there: the second endpoint was not probed for nothing.
expect(fetchMock.mock.calls.every(([url]) => !(url as URL).href.includes('8081'))).toBe(true);
vi.restoreAllMocks();
});
});
describe('the sweep leaves the local model record alone', () => {
const STATE = () => dataPath('local-model.json');
const MANAGED: CustomModelHost = {
id: 'local-ollama',
label: 'This Mac',
baseUrl: 'http://127.0.0.1:11435',
authStyle: 'bearer',
models: ['qwen3.6:35b-a3b'],
defaultModelId: 'qwen3.6:35b-a3b',
defaultContextLength: 131072,
};
beforeEach(() => {
rmSync(STATE(), { force: true });
});
it('while it is managed: it is not probed, and a model somebody pulled through the port does not become launchable', async () => {
const dir = getDataDir();
writeFileSync(
STATE(),
JSON.stringify({
schema: 1,
state: 'installed',
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 },
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, enabled: true },
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
})
);
await writeCustomModelHosts(dir, [MANAGED, host({ id: 'other', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(
async () =>
new Response(JSON.stringify({ data: [{ id: 'qwen3.6:35b-a3b' }, { id: 'sneaked-in:7b' }] }), { status: 200 })
);
await refreshAllCustomModelHosts();
const hosts = await readCustomModelHosts(dir);
// Our record is exactly what the install wrote: no new model, no discovery stamp.
expect(hosts.find((h) => h.id === 'local-ollama')).toEqual(MANAGED);
// Every other endpoint is refreshed as before.
expect(hosts.find((h) => h.id === 'other')?.models).toEqual(['qwen3.6:35b-a3b', 'sneaked-in:7b']);
for (const [url] of fetchMock.mock.calls) expect((url as URL).href).not.toContain('11435');
rmSync(STATE(), { force: true });
});
it('a record with that id that nobody installed is refreshed like any other', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [{ ...MANAGED, models: [], defaultModelId: undefined }]);
fetchMock.mockImplementation(
async () => new Response(JSON.stringify({ data: [{ id: 'mine:1b' }] }), { status: 200 })
);
await refreshAllCustomModelHosts();
expect((await readCustomModelHosts(dir))[0].models).toEqual(['mine:1b']);
});
});