mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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:
1 parent
a864acc52c
commit
3af94df4f6
17 files changed
+6290
-156
No files matched your search
+133
-16
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
+1406
-60
File diff suppressed because it is too large.
Load diff
+231
-12
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user