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

+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();