feat(local-model): GET /api/local-model and the harness facts

The read-only route behind the recommendation, and the one module that reads
the CLI registry for it.

- harness-facts.ts: one fact per catalogue harness row, resolved in the CLI
  registry at call time: the registry's label, `installed` from the existing
  availability probe (asked once per build), `installable` for a stock CLI
  whose install command for this platform needs no administrator rights, and
  `acceptsModelId`, the test a custom model launch applies to the model id.
  A row whose CLI is unknown, disabled or has no custom model mechanism gets
  no fact. No CLI id is compared with a literal.
- routes/local-model-routes.ts: answers { hardware, recommendation,
  catalogUpdated } in the usual envelope and reformats nothing. Admin only in
  multi-user mode (403), checked before anything is probed. A failed probe is
  an answer (200, the hardware's `source` says what was read); a 500 is left
  for a fault in the catalogue, the harness facts or recommend(). It writes no
  file and reads no setting.

Tests: test/local-model-harness-facts.test.ts,
test/routes/local-model-routes.test.ts (app.inject(), no port; the temp HOME
is byte-identical before and after the requests).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 16:27:49 +01:00
1 parent 92c20fe572
commit 34e95ceb62
4 files changed
+942 -29

No files matched your search

+153 -18
View File
@@ -3,28 +3,163 @@
* local model recommendation. Server side: this is the one module of src/local-model/ that
* reads the CLI registry, so `recommend()` can stay pure and name no CLI.
*
* SKELETON: the exported signature is final, the body is not written yet. `buildHarnessFacts()`
* will, for every `LocalHarnessEntry` of the catalogue:
* - resolve its `cli` in the CLI registry AT CALL TIME (never in a module-level constant), and
* leave out a row whose CLI is unknown, disabled, or has no custom model mechanism
* (`capabilities.customModelInjection.kind` is `unsupported`);
* - report `label` from the registry entry and `installed` from the same probe the other
* routes use;
* - report `installable` only for a CLI that is not installed and whose install command for
* this platform needs no administrator rights (not one that starts with `brew` or `sudo`);
* - supply `acceptsModelId`, the same test a custom model launch applies to the model id
* before anything is written (the launch spec's model pattern).
* `buildHarnessFacts()` walks the catalogue's `harnesses` rows and, for every row:
* - resolves its `cli` in the CLI registry AT CALL TIME (never in a module-level constant),
* and leaves the row out when the CLI is unknown, disabled, or has no custom model
* mechanism (`capabilities.customModelInjection.kind` is `unsupported`);
* - 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;
* - supplies `acceptsModelId` (`cliAcceptsCustomModelId()`), the test a custom model launch
* applies to the model id before anything is written.
*
* Rules:
* - No CLI id is compared with a literal here or anywhere: the id comes from the catalogue row
* and everything else from the registry entry it resolves to.
* - Read-only: nothing is installed, written or spawned beyond the existing availability probe.
* - No CLI id is compared with a literal here or anywhere: the id comes from the catalogue
* row and everything else from the registry entry it resolves to.
* - Read-only: nothing is installed, written or spawned beyond the existing availability
* probe. An install command is only read as text, never run.
* - Never throws: a probe that fails reads as "not installed" (with a warning in the log),
* and the registry's install data still answers `installable`.
*
* `cliAcceptsCustomModelId()` composes the two pure pieces the launch path itself calls
* (`buildCustomModelInjection()` and `matchesPattern()` on the entry's `model` launch param).
* The launch path keeps that composition inline at two sites in
* src/web/routes/session-routes.ts (the quick-start `customModel` branch and
* `POST /api/sessions/:id/custom-model`). A change to the rule there must be made here too,
* until the three share one helper.
*/
import type { HarnessFact, LocalModelCatalog } from '../types.js';
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 { 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';
/** What `buildHarnessFacts()` reads. Each member defaults to the real module; a test overrides what it needs. */
export interface HarnessFactsDeps {
/** One CLI of the registry, looked up when the facts are built. */
getCli: (id: string) => CliEntry | undefined;
/**
* The availability probe, asked ONCE per build: it answers with a test for single entries.
* Under vitest the real probe is inert (it finds no CLI), so a test that cares about
* `installed` passes its own.
*/
probeInstalled: () => Promise<(entry: CliEntry) => boolean>;
/** The install command the registry holds for this platform. Read as text, never run. */
installCommandFor: (entry: CliEntry) => string | undefined;
}
export const defaultHarnessFactsDeps: HarnessFactsDeps = {
getCli,
probeInstalled: async () => {
const stockAvailability = await probeStockCliAvailability();
return (entry) => isCliEntryInstalled(entry, stockAvailability);
},
installCommandFor: resolveInstallCommandForPlatform,
};
/**
* A command segment that starts with `sudo` or `brew`: at the start of the text, or after a
* pipe, `&&`, `||`, `;`, a line break or an opening parenthesis.
*/
const ADMIN_COMMAND = /(?:^|[;&|(\n])\s*(?:sudo|brew)(?=\s|$)/;
/**
* Whether an install command needs administrator rights, read off its text.
* - `sudo ...` asks for an administrator password.
* - `brew ...` needs Homebrew, and installing Homebrew needs an administrator (the same rule
* the installer's no-admin route applies to the CLI menu).
* `curl ... | bash` installers write into the home directory, and the install runner points an
* `npm install -g` at a prefix the server user can write, so neither counts.
*/
export function installCommandNeedsAdmin(command: string): boolean {
return ADMIN_COMMAND.test(command);
}
/** Stands in for a configured endpoint: the launch id a recipe forces depends on the model id alone. */
const PROBE_ENDPOINT: CustomModelEndpoint = {
id: 'local-model-probe',
label: 'local model probe',
baseUrl: 'http://127.0.0.1',
};
/**
* Whether a custom model launch of this CLI would accept `modelId`.
*
* A CLI whose config alone cannot select the model gets the id forced onto its `model` launch
* param (the recipe's `launchModel` template). The argv engine DROPS a token that fails the
* param's pattern, which would start the CLI on its own default provider, so the launch path
* refuses such an id up front. This is that test: false when the forced value does not match
* the pattern, and false for a CLI with no custom model mechanism at all.
*/
export function cliAcceptsCustomModelId(entry: Pick<CliEntry, 'capabilities' | 'launch'>, modelId: string): boolean {
try {
const injection = buildCustomModelInjection(entry, PROBE_ENDPOINT, modelId);
if (injection.kind === 'unsupported') return false;
const forcedLaunchModel = injection.launchModel;
const modelSpec = entry.launch.params.model;
return !(
forcedLaunchModel !== undefined &&
modelSpec?.type === 'token' &&
!matchesPattern(modelSpec.pattern, forcedLaunchModel)
);
} catch {
// A recipe that cannot be rendered cannot be launched either.
return false;
}
}
/** One fact per catalogue harness that resolves in the CLI registry, in catalogue order. */
export async function buildHarnessFacts(catalog: LocalModelCatalog): Promise<HarnessFact[]> {
void catalog;
throw new Error('not implemented: buildHarnessFacts');
export async function buildHarnessFacts(
catalog: LocalModelCatalog,
deps: Partial<HarnessFactsDeps> = {}
): Promise<HarnessFact[]> {
const resolved: HarnessFactsDeps = { ...defaultHarnessFactsDeps, ...deps };
let isInstalled: (entry: CliEntry) => boolean;
try {
isInstalled = await resolved.probeInstalled();
} catch (err) {
console.warn(
`[local-model] The CLI availability probe failed, reading every harness as not installed: ${getErrorMessage(err)}`
);
isInstalled = () => false;
}
const facts: HarnessFact[] = [];
const seen = new Set<string>();
for (const row of catalog.harnesses) {
// The catalogue lists a CLI once; a second row for it adds nothing a fact could carry.
if (seen.has(row.cli)) continue;
seen.add(row.cli);
const entry = resolved.getCli(row.cli);
if (!entry || !entry.enabled) continue;
if (entry.capabilities.customModelInjection.kind === 'unsupported') continue;
let installed = false;
try {
installed = isInstalled(entry);
} catch (err) {
console.warn(`[local-model] Probing ${entry.label} failed, reading it as not installed: ${getErrorMessage(err)}`);
}
let installable = false;
if (!installed && entry.stock) {
const command = resolved.installCommandFor(entry);
installable = command !== undefined && command.trim() !== '' && !installCommandNeedsAdmin(command);
}
facts.push({
cli: row.cli,
label: entry.label,
installed,
installable,
acceptsModelId: (modelId: string) => cliAcceptsCustomModelId(entry, modelId),
});
}
return facts;
}
+61 -11
View File
@@ -1,25 +1,33 @@
/**
* @fileoverview `GET /api/local-model`: what this Mac is and which local model fits it.
*
* SKELETON: the route is registered and answers 501 until its handler is written. It will
* answer `{ success: true, data: LocalModelStatus }`, built from the hardware probe, the
* Answers `{ success: true, data: LocalModelStatus }`, built from the hardware probe, the
* catalogue, the harness facts and the pure `recommend()`:
* `{ hardware, recommendation, catalogUpdated }`.
* `{ hardware, recommendation, catalogUpdated }`. Nothing is reformatted here: every size text
* and every sentence in the payload was chosen by the modules that produced it.
*
* Rules:
* - Read-only. It downloads nothing, installs nothing, writes no file and reads no setting.
* - Never a 500 for a probe that failed: a failed probe is an answer (the hardware's `source`
* says so and the recommendation is verdict `none`).
* - 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
* from what is known. Should the probe itself reject (it promises not to), the route logs
* it and answers with what `node:os` knows (`hardwareFromOs()`), under the same two
* `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`.
* same bar as `GET /api/doctor`. The gate runs before anything is probed.
* - 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.
*/
import { arch, totalmem } from 'node:os';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
ApiErrorCode,
createErrorResponse,
getErrorMessage,
type ApiResponse,
type HardwareFacts,
type HarnessFact,
@@ -27,7 +35,10 @@ import {
type LocalModelStatus,
type Recommendation,
} from '../../types.js';
import { isAdmin } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { getLocalModelCatalog } from '../../config/local-model-catalog.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 { recommend } from '../../local-model/recommend.js';
@@ -43,18 +54,57 @@ export interface LocalModelRouteDeps {
export const defaultLocalModelRouteDeps: LocalModelRouteDeps = {
readHardware: () => readHardware(),
getCatalog: getLocalModelCatalog,
buildHarnessFacts,
buildHarnessFacts: (catalog) => buildHarnessFacts(catalog),
recommend,
};
/**
* The hardware answer when the probe rejected: memory and architecture from `node:os`, and the
* `source` the probe itself uses for "nothing was read from sysctl" (`os-fallback` on macOS,
* `not-probed` anywhere else). Sizes go through the one formatter, like every other size.
*/
export function hardwareFromOs(): HardwareFacts {
const memBytes = totalmem();
return {
platform: process.platform,
arch: arch(),
memBytes,
memGb: formatGb(memBytes),
memClassGb: memClassGb(memBytes),
source: process.platform === 'darwin' ? 'os-fallback' : 'not-probed',
};
}
export function registerLocalModelRoutes(app: FastifyInstance, deps: Partial<LocalModelRouteDeps> = {}): void {
const resolved: LocalModelRouteDeps = { ...defaultLocalModelRouteDeps, ...deps };
void resolved;
app.get(
'/api/local-model',
async (_req: FastifyRequest, reply: FastifyReply): Promise<ApiResponse<LocalModelStatus>> => {
reply.code(501);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'GET /api/local-model is not implemented yet');
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');
}
let hardware: HardwareFacts;
try {
hardware = await resolved.readHardware();
} catch (err) {
console.warn(`[local-model] The hardware probe failed, answering from node:os: ${getErrorMessage(err)}`);
hardware = hardwareFromOs();
}
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 } };
} catch (err) {
reply.code(500);
return createErrorResponse(
ApiErrorCode.INTERNAL_ERROR,
`The local model recommendation failed: ${getErrorMessage(err)}`
);
}
}
);
}