mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
1 parent
92c20fe572
commit
34e95ceb62
4 files changed
+942
-29
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user