Merge branch 'feat/local-model-recommendation' into test/desktop-macbook

This commit is contained in:
Codeman maintainer committed 2026-11-06 18:00:31 +01:00
commit bce9fde8df
32 files changed
+7427 -4

No files matched your search

+373
View File
@@ -0,0 +1,373 @@
/**
* @fileoverview The local model catalogue: which models, harnesses and chips the recommendation
* knows, as a compiled constant validated by a Zod schema.
*
* Rules:
* - There is NO override file and no runtime download data in this stage: nothing here reads
* the data dir, and a data file can never decide what gets recommended.
* - A model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`), so it can
* only ever name a model of the default registry and namespace.
* - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`). The `cli` is a CLI
* registry id as data: no code may compare it with a literal. Whether that CLI exists and can
* take a custom model is checked against the registry at call time, in
* src/local-model/harness-facts.ts, never here.
* - The schema checks more than fields. Across rows it requires: unique model ids and ranks,
* unique harness `cli`s and ranks, `contexts` largest first, a `tested` entry naming a harness
* row, `chips` most specific first, and `promptCacheTiers` ascending from 0.
* - `getLocalModelCatalog()` parses the constant once, on first use and never at import, and
* throws on a bad row. test/local-model-catalog.test.ts pins that the stock constant parses,
* so a bad row fails the gate and never reaches the route.
*
* Where each figure of the stock rows comes from, and what was checked (2026-11-06):
*
* | Figure | Source | Checked |
* |----------------------|-----------------------------------------------------------|--------------------------------------|
* | `id`, `bytes` | registry manifest of the tag, sum of its layer sizes | yes, to the byte, by the facts review |
* | `license` | the model card (all Apache-2.0) | read by the research, not re-checked |
* | `paramsB` | 27.8 from the model card; the others are the count in the | no. Only the 12 billion line reads |
* | | model's name | it, and no row is near that line |
* | `kvBytesPerToken` | COMPUTED from each model's config: 2 x full-attention | no. Not measured in a running |
* | | layers x KV heads x head size x 2 bytes | runtime. The review called the |
* | | | gpt-oss figure plausible, no more |
* | `contexts`, `rank` | a judgement of the design | no |
* | `speedPer100Gbps` | one published run on an M1 Max (400 GB/s): generation | the run was read. It stands for less |
* | | only, short prompt. The two q8_0 rows are SCALED by file | than a session: no long prompt, no |
* | | size, not measured | thinking tokens |
* | `minMacosMajor` | the runtime's documentation (macOS 14) | yes, by the facts review |
* | `promptCacheTiers` | a decision of the design, not a measurement | no |
* | harness `minContext` | 65536: the runtime's documentation for both harnesses. | 65536 yes. 32768 no, which is why |
* | | 32768: recalled | that row is gated |
* | `chips` | the bandwidth column of | yes, read at the source for this |
* | | https://github.com/ggml-org/llama.cpp/discussions/4167 | file |
*
* `tested` is empty on every row: nobody has run any of these models with a harness on a Mac
* yet. Until a row has an entry, the recommendation never says "Recommended".
*/
import { z } from 'zod';
import type { ChipEntry, LocalModelCatalog } from '../types.js';
/** A model id: lowercase name, one colon, a tag. No slash, so no registry host or namespace can ride along. */
export const LOCAL_MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$/;
/**
* Whether `chip` (a `machdep.cpu.brand_string`, 'Apple M4 Pro') is the chip a `ChipEntry.match`
* ('M4 Pro') names: the string must END with the match, and the match must start at the
* beginning or after a space. Case is ignored.
*
* Stricter than "the match occurs in the string" on purpose. 'M4' is not the chip of
* 'Apple M4 Pro', 'M1' is not the chip of 'Apple M10', and a variant this table has never heard
* of ('Apple M6 Pro') matches no row instead of borrowing the bandwidth of its base chip.
*/
export function chipNameMatches(chip: string, match: string): boolean {
const name = chip.trim().toLowerCase();
const wanted = match.trim().toLowerCase();
if (wanted === '' || !name.endsWith(wanted)) return false;
const start = name.length - wanted.length;
return start === 0 || name[start - 1] === ' ';
}
/**
* The memory bandwidth (GB/s) of `chip`, from the first row that names it. `undefined` when the
* chip string is absent or no row names it: the speed is then UNKNOWN, a bandwidth is never guessed.
*/
export function chipBandwidthGbps(chips: readonly ChipEntry[], chip: string | undefined): number | undefined {
if (!chip) return undefined;
return chips.find((row) => chipNameMatches(chip, row.match))?.gbps;
}
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'must be an ISO date (YYYY-MM-DD)');
const positiveInt = z.number().int().positive();
const TestedOnSchema = z
.object({
chip: z.string().min(1).max(80),
memGb: positiveInt,
cli: z.string().min(1).max(24),
date: isoDate,
})
.strict();
const LocalModelEntrySchema = z
.object({
id: z.string().max(100).regex(LOCAL_MODEL_ID_PATTERN, 'must be name:tag in lowercase, without a slash'),
label: z.string().min(1).max(80),
license: z.string().min(1).max(40),
paramsB: z.number().positive(),
bytes: positiveInt,
kvBytesPerToken: positiveInt,
contexts: z.array(positiveInt).min(1),
rank: z.number().int(),
speedPer100Gbps: z.number().positive().optional(),
tested: z.array(TestedOnSchema),
})
.strict();
const LocalHarnessEntrySchema = z
.object({
cli: z.string().min(1).max(24),
minContext: positiveInt,
rank: z.number().int(),
gated: z.boolean().optional(),
slowFirstTurn: z.boolean().optional(),
})
.strict();
const ChipEntrySchema = z
.object({
match: z.string().min(1).max(40),
gbps: z.number().positive(),
})
.strict();
const PromptCacheTierSchema = z
.object({
minMemGb: z.number().int().nonnegative(),
bytes: z.number().int().nonnegative(),
})
.strict();
/** The index of the first value that already occurred earlier in the list, or -1. */
function firstRepeat<T>(values: readonly T[]): number {
const seen = new Set<T>();
for (let i = 0; i < values.length; i++) {
if (seen.has(values[i])) return i;
seen.add(values[i]);
}
return -1;
}
/**
* Validates a whole catalogue: every field, then the rules that hold across rows (see the file
* header). Typed against `LocalModelCatalog`, so the two cannot drift apart.
*/
export const LocalModelCatalogSchema: z.ZodType<LocalModelCatalog> = z
.object({
schema: z.literal(1),
updated: isoDate,
minMacosMajor: positiveInt,
promptCacheTiers: z.array(PromptCacheTierSchema).min(1),
models: z.array(LocalModelEntrySchema),
harnesses: z.array(LocalHarnessEntrySchema),
chips: z.array(ChipEntrySchema),
})
.strict()
.superRefine((catalog, ctx) => {
const issue = (path: (string | number)[], message: string): void => {
ctx.addIssue({ code: 'custom', path, message });
};
// A repeated id would make "the model" ambiguous, a repeated rank would make the order of
// two models depend on where they sit in the array.
const repeatedId = firstRepeat(catalog.models.map((m) => m.id));
if (repeatedId >= 0) issue(['models', repeatedId, 'id'], 'model ids must be unique');
const repeatedRank = firstRepeat(catalog.models.map((m) => m.rank));
if (repeatedRank >= 0) issue(['models', repeatedRank, 'rank'], 'model ranks must be unique');
const harnessClis = catalog.harnesses.map((h) => h.cli);
const repeatedCli = firstRepeat(harnessClis);
if (repeatedCli >= 0) issue(['harnesses', repeatedCli, 'cli'], 'a harness may be listed only once');
const repeatedHarnessRank = firstRepeat(catalog.harnesses.map((h) => h.rank));
if (repeatedHarnessRank >= 0) issue(['harnesses', repeatedHarnessRank, 'rank'], 'harness ranks must be unique');
catalog.models.forEach((model, m) => {
for (let i = 1; i < model.contexts.length; i++) {
if (model.contexts[i] >= model.contexts[i - 1]) {
issue(['models', m, 'contexts', i], 'contexts must be listed largest first, without repeats');
break;
}
}
// An entry for a harness the catalogue does not list could never match a candidate: a typo.
model.tested.forEach((entry, t) => {
if (!harnessClis.includes(entry.cli)) {
issue(['models', m, 'tested', t, 'cli'], 'a tested entry must name a harness of this catalogue');
}
});
});
// Most specific first ('M4 Max' before 'M4'): a row whose text sits inside a LATER row's text
// is the less specific of the two, so it is in the wrong place. A repeated match is the same
// mistake.
for (let later = 1; later < catalog.chips.length; later++) {
const laterMatch = catalog.chips[later].match.toLowerCase();
const shadowing = catalog.chips.slice(0, later).find((row) => laterMatch.includes(row.match.toLowerCase()));
if (shadowing) {
issue(
['chips', later, 'match'],
`chips must be listed most specific first: '${shadowing.match}' comes before '${catalog.chips[later].match}'`
);
break;
}
}
// The tiers are read as "the last one whose minimum is not above this Mac's memory", so they
// must start at 0 (every Mac has a tier) and ascend.
if (catalog.promptCacheTiers[0].minMemGb !== 0) {
issue(['promptCacheTiers', 0, 'minMemGb'], 'the first prompt cache tier must start at 0');
}
for (let i = 1; i < catalog.promptCacheTiers.length; i++) {
if (catalog.promptCacheTiers[i].minMemGb <= catalog.promptCacheTiers[i - 1].minMemGb) {
issue(['promptCacheTiers', i, 'minMemGb'], 'prompt cache tiers must ascend by minMemGb');
break;
}
}
});
const KIB = 1024;
const MIB = 1024 ** 2;
const GIB = 1024 ** 3;
/**
* The catalogue that ships with Codeman. Never read directly outside this file and its test:
* callers use `getLocalModelCatalog()`, which validates it first.
*/
export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
schema: 1,
updated: '2026-11-06',
// The oldest macOS the model runtime supports.
minMacosMajor: 14,
// Host memory the runtime's prompt cache may take beside the model: 512 MiB on a Mac below
// 32 GB, 2 GiB from 32 GB. Part of the need of every candidate.
promptCacheTiers: [
{ minMemGb: 0, bytes: 512 * MIB },
{ minMemGb: 32, bytes: 2 * GIB },
],
// Ordered by rank. The higher rank wins among models that fit equally well and are equally
// fast. `flint:40b` is left out on purpose: its plain tag is the slow build on a Mac and it
// has an open bug with tool arguments.
models: [
{
id: 'qwen3.5:9b',
label: 'Qwen3.5 9B',
license: 'Apache-2.0',
paramsB: 9,
bytes: 6_550_825_373,
kvBytesPerToken: 32 * KIB,
contexts: [65536, 32768],
rank: 1,
// 37.1 tokens per second measured on 400 GB/s.
speedPer100Gbps: 9.3,
tested: [],
},
{
id: 'qwen3.5:9b-q8_0',
label: 'Qwen3.5 9B (Q8)',
license: 'Apache-2.0',
paramsB: 9,
bytes: 10_449_217_949,
kvBytesPerToken: 32 * KIB,
contexts: [65536, 32768],
rank: 2,
// Not measured: the 9.3 of the row above scaled by file size.
speedPer100Gbps: 5.8,
tested: [],
},
{
id: 'gpt-oss:20b',
label: 'GPT-OSS 20B',
license: 'Apache-2.0',
paramsB: 20,
bytes: 13_793_441_427,
kvBytesPerToken: 24 * KIB,
contexts: [131072, 65536],
rank: 3,
// No speed figure: its speed is unknown on every chip.
tested: [],
},
{
id: 'qwen3.6:35b-a3b',
label: 'Qwen3.6 35B-A3B',
license: 'Apache-2.0',
paramsB: 35,
bytes: 22_621_314_161,
kvBytesPerToken: 20 * KIB,
contexts: [131072, 65536],
rank: 4,
// 54.5 tokens per second measured on 400 GB/s (3 billion parameters are active per token).
speedPer100Gbps: 13.6,
tested: [],
},
{
id: 'qwen3.8:27b',
label: 'Qwen3.8 27B',
license: 'Apache-2.0',
paramsB: 27.8,
bytes: 17_741_871_939,
kvBytesPerToken: 64 * KIB,
contexts: [131072, 65536, 32768],
rank: 5,
// 11.8 tokens per second measured on 400 GB/s.
speedPer100Gbps: 2.95,
tested: [],
},
{
id: 'qwen3.8:27b-q8_0',
label: 'Qwen3.8 27B (Q8)',
license: 'Apache-2.0',
paramsB: 27.8,
bytes: 29_978_241_837,
kvBytesPerToken: 64 * KIB,
contexts: [262144, 131072],
rank: 6,
// Not measured: the 2.95 of the row above scaled by file size.
speedPer100Gbps: 1.7,
tested: [],
},
{
id: 'qwen3.5:122b-a10b',
label: 'Qwen3.5 122B-A10B',
license: 'Apache-2.0',
paramsB: 122,
bytes: 81_370_035_877,
kvBytesPerToken: 24 * KIB,
contexts: [131072],
rank: 7,
// No speed figure: its speed is unknown on every chip.
tested: [],
},
],
// The higher rank wins when a model can run with more than one harness.
harnesses: [
{ cli: 'opencode', minContext: 65536, rank: 3 },
// Sends a fixed prompt of about 36,000 tokens, so the first answer takes minutes.
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
// Gated: neither its 32768 minimum nor its handling of a `name:tag` model id is verified.
{ cli: 'pi', minContext: 32768, rank: 1, gated: true },
],
// Memory bandwidth in GB/s, most specific first. Only chips that shipped and are in the source
// table are listed. Where one name covers two bandwidths (M3 Max 300 or 400, M4 Max 410 or 546,
// M5 Max 460 or 614) the LOWER one stands, so a speed computed from it is a floor.
chips: [
{ match: 'M5 Ultra', gbps: 1228 },
{ match: 'M3 Ultra', gbps: 800 },
{ match: 'M2 Ultra', gbps: 800 },
{ match: 'M1 Ultra', gbps: 800 },
{ match: 'M5 Max', gbps: 460 },
{ match: 'M4 Max', gbps: 410 },
{ match: 'M3 Max', gbps: 300 },
{ match: 'M2 Max', gbps: 400 },
{ match: 'M1 Max', gbps: 400 },
{ match: 'M5 Pro', gbps: 307 },
{ match: 'M4 Pro', gbps: 273 },
{ match: 'M3 Pro', gbps: 150 },
{ match: 'M2 Pro', gbps: 200 },
{ match: 'M1 Pro', gbps: 200 },
{ match: 'M5', gbps: 154 },
{ match: 'M4', gbps: 120 },
{ match: 'M3', gbps: 100 },
{ match: 'M2', gbps: 100 },
{ match: 'M1', gbps: 68 },
],
};
let validated: LocalModelCatalog | undefined;
/**
* The catalogue in force: the stock constant, validated once on first use. Callers go through
* this function, so a later stage can change where the catalogue comes from without touching
* them. Throws when the constant breaks its own schema, which the catalogue test rules out.
*/
export function getLocalModelCatalog(): LocalModelCatalog {
validated ??= LocalModelCatalogSchema.parse(STOCK_LOCAL_MODEL_CATALOG);
return validated;
}
+48
View File
@@ -0,0 +1,48 @@
/**
* @fileoverview The formatters of the local model recommendation, and its byte units.
*
* `formatGb()` is the ONE place a byte count becomes a size a person reads: decimal GB (the
* unit the Finder and the model registry use). `hardware.ts` and `recommend.ts` call it to fill
* the `...Gb` text fields of the payload (`memGb`, `freeDiskGb`, `needGb`, `downloadGb`,
* `budgetGb`, and the size slots of a `LocalModelMessage`). Nothing else may turn bytes into a
* size for display, the browser included: it prints the text it was sent.
*
* GiB and MiB exist only for the budget math (reserves, the wired limit, the prompt cache) and
* never reach a person, with one exception: `memClassGb()`, the memory size a Mac is sold as,
* is how the card names the installed memory ("48 GB memory", what About This Mac shows). It
* is a name of the machine, not a computed size: `formatGb()` of the same bytes reads 51.5,
* which no owner recognises. Every computed size (need, budget, download, free disk) is
* `formatGb()`.
*
* Pure: no IO, no state.
*/
/** One decimal gigabyte, the unit a person reads. */
export const GB = 1_000_000_000;
/** One binary gigabyte. Budget math only. */
export const GIB = 1024 ** 3;
/** One binary megabyte. Budget math only. */
export const MIB = 1024 ** 2;
/**
* A byte count as decimal GB text, WITHOUT the unit: one decimal below 100 ('6.6', '17.7',
* '25.5'), a whole number from 100 ('212'). The caller's sentence supplies ' GB'.
* Anything that is not a positive finite number reads '0.0'.
*/
export function formatGb(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0.0';
const tenths = Math.round((bytes / GB) * 10) / 10;
return tenths >= 100 ? String(Math.round(bytes / GB)) : tenths.toFixed(1);
}
/**
* The memory size a Mac is sold as: whole binary gigabytes (17179869184 bytes is 16). This is
* `HardwareFacts.memClassGb`: the key of the tier rules ("below 36 GB") and of `TestedOn.memGb`,
* and what the card and the `noneMemory` sentence call the installed memory. It names a class
* of Mac; 0 means the size is unknown and is never shown. Any other size for display is
* `formatGb()`.
*/
export function memClassGb(memBytes: number): number {
if (!Number.isFinite(memBytes) || memBytes <= 0) return 0;
return Math.round(memBytes / GIB);
}
+409
View File
@@ -0,0 +1,409 @@
/**
* @fileoverview The hardware probe of the local model recommendation: what this Mac is.
*
* - `parseSysctl()`: a pure parser of `key: value` lines. The command runs WITHOUT `-n`, so a
* key that is missing on a machine (`sysctl.proc_translated` on Intel) cannot shift the
* others, and a line it does not know is ignored.
* - `readHardware()`: ONE bounded async spawn of `SYSCTL_PATH` with `SYSCTL_KEYS`, the free disk
* of the data dir through `fs.promises.statfs`, fallbacks from `node:os` when the spawn
* fails, and a 60 s cache.
*
* Rules:
* - Never throws and never rejects: a failed probe is an answer (`source: 'os-fallback'`), and
* off macOS nothing is spawned and no disk is read (`source: 'not-probed'`).
* - Never `system_profiler` (tens of milliseconds and a parse of free text), never a second
* spawn, never a synchronous exec on the server's event loop.
* - A non-zero exit is tolerated once `hw.memsize` parsed (a missing key makes sysctl exit 1).
* Without `hw.memsize` nothing else of that output is trusted: the answer is the fallback.
* - Only a line that ends in a newline counts. A cut-off tail is not a value: half of
* `hw.memsize` would read as a smaller, valid number.
* - `iogpu.wired_limit_mb` of 0 means "not set": it is reported as 0 and only a value above 0
* is ever used as a limit.
* - The data dir can sit on a network mount that stopped answering, so the disk read is
* bounded too: the folder is probed with the bounded path probe first, the `statfs` is raced
* against a timeout, and a `statfs` that never came back is not started a second time.
* - `memGb`, `memClassGb` and `freeDiskGb` are filled here through src/local-model/format.ts,
* the one place a size is formatted.
*/
import { execFile } from 'node:child_process';
import { statfs } from 'node:fs/promises';
import os from 'node:os';
import { dirname } from 'node:path';
import type { HardwareFacts } from '../types.js';
import { getDataDir } from '../config/instance.js';
import { probePathKind, type PathProbeKind } from '../utils/bounded-path-probe.js';
import { formatGb, memClassGb } from './format.js';
/** Absolute path, so the server's PATH (minimal under a service manager) cannot change what runs. */
export const SYSCTL_PATH = '/usr/sbin/sysctl';
/** The keys of the one spawn, in the order they are passed. */
export const SYSCTL_KEYS = [
'hw.memsize',
'machdep.cpu.brand_string',
'hw.optional.arm64',
'sysctl.proc_translated',
'iogpu.wired_limit_mb',
'kern.osproductversion',
'hw.model',
] as const;
/** Upper bound of the spawn (it answers in about 2 ms on a real Mac). */
export const SYSCTL_TIMEOUT_MS = 2000;
/** How long one probe result is reused. */
export const HARDWARE_CACHE_TTL_MS = 60_000;
/** Upper bound of the wait for the free disk figure. */
export const FREE_DISK_TIMEOUT_MS = 2000;
/** Seven short lines are well under this. A larger output is not sysctl's. */
const SYSCTL_MAX_OUTPUT_BYTES = 64 * 1024;
/** Longest text value kept (a chip name, a model identifier, a version). */
const MAX_TEXT_VALUE_LENGTH = 100;
/** How many parent folders are tried when the data dir does not exist yet. */
const FREE_DISK_MAX_PARENT_STEPS = 6;
/** What `parseSysctl()` read. Every field is absent when its key was missing or its value did not parse. */
export interface SysctlFacts {
/** `hw.memsize`. */
memBytes?: number;
/** `machdep.cpu.brand_string`. */
chip?: string;
/** `hw.optional.arm64` is 1. */
arm64?: boolean;
/** `sysctl.proc_translated` is 1. */
translated?: boolean;
/** `iogpu.wired_limit_mb`, as reported (0 stays 0). */
wiredLimitMb?: number;
/** `kern.osproductversion`. */
macosVersion?: string;
/** The leading number of `macosVersion`. */
macosMajor?: number;
/** `hw.model`. */
model?: string;
}
/** The IO of `readHardware()`, injectable so a test spawns nothing and reads no real disk. Each member defaults to the real thing. */
export interface HardwareProbeDeps {
/** Default `process.platform`. */
platform?: string;
/** Default `os.arch()`. */
arch?: string;
/** Runs the one spawn. Resolves its stdout, also after a non-zero exit, or `undefined` when it could not run or timed out. Never rejects. */
runSysctl?: () => Promise<string | undefined>;
/** Free bytes on the data dir's filesystem, or `undefined` when unknown. Never rejects. */
freeDiskBytes?: () => Promise<number | undefined>;
/** Default `os.totalmem()`. */
totalMemBytes?: () => number;
/** Default `os.cpus()[0]?.model`. */
cpuModel?: () => string | undefined;
/** Default `Date.now`. The clock of the cache. */
now?: () => number;
}
/** A whole number without sign, as sysctl prints one. */
function parseCount(value: string): number | undefined {
if (!/^\d+$/.test(value)) return undefined;
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : undefined;
}
/** sysctl prints a flag as 0 or 1. Anything else is not a flag. */
function parseFlag(value: string): boolean | undefined {
if (value === '1') return true;
if (value === '0') return false;
return undefined;
}
function parseText(value: string): string | undefined {
return value !== '' && value.length <= MAX_TEXT_VALUE_LENGTH ? value : undefined;
}
/**
* Parse the output of `sysctl <keys>` (no `-n`): one `key: value` line per key it knows.
* Pure. Unknown lines, blank lines and error lines are skipped, and so is a last line that does
* not end in a newline. The first line of a key wins.
*/
export function parseSysctl(text: string): SysctlFacts {
const facts: SysctlFacts = {};
const lines = text.split('\n');
// What follows the last newline is either empty or a cut-off line.
lines.pop();
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, '');
const colon = line.indexOf(':');
if (colon <= 0) continue;
const key = line.slice(0, colon).trim();
const value = line.slice(colon + 1).trim();
switch (key) {
case 'hw.memsize': {
const bytes = parseCount(value);
if (bytes !== undefined && bytes > 0) facts.memBytes ??= bytes;
break;
}
case 'machdep.cpu.brand_string':
facts.chip ??= parseText(value);
break;
case 'hw.optional.arm64':
facts.arm64 ??= parseFlag(value);
break;
case 'sysctl.proc_translated':
facts.translated ??= parseFlag(value);
break;
case 'iogpu.wired_limit_mb':
facts.wiredLimitMb ??= parseCount(value);
break;
case 'kern.osproductversion': {
const version = /^(\d+)(?:\.\d+)*$/.exec(value);
if (version && value.length <= MAX_TEXT_VALUE_LENGTH && facts.macosVersion === undefined) {
facts.macosVersion = value;
facts.macosMajor = Number(version[1]);
}
break;
}
case 'hw.model':
facts.model ??= parseText(value);
break;
default:
break;
}
}
return facts;
}
/**
* The one spawn: `command` with `SYSCTL_KEYS` as its arguments and nothing else (no `-n`).
* Resolves stdout when the program ran, also after a non-zero exit (a key this machine lacks
* makes sysctl exit 1 while the other keys are printed). Resolves `undefined` when it could not
* be started, was killed (the timeout) or printed too much. Never rejects.
*
* The two parameters exist for the test, which runs a script of its own in place of sysctl.
*/
export function spawnSysctl(
command: string = SYSCTL_PATH,
timeoutMs: number = SYSCTL_TIMEOUT_MS
): Promise<string | undefined> {
return new Promise((resolve) => {
try {
execFile(
command,
[...SYSCTL_KEYS],
{
timeout: timeoutMs,
maxBuffer: SYSCTL_MAX_OUTPUT_BYTES,
encoding: 'utf8',
windowsHide: true,
// sysctl needs nothing from the server's environment, which can hold a password.
env: { LC_ALL: 'C' },
},
(error, stdout) => {
if (!error) return resolve(stdout);
// A numeric code is an exit status: the program ran. A string code (ENOENT, EACCES,
// the buffer limit) or a signal means it did not run to its end.
const ranToEnd = typeof error.code === 'number' && !error.killed && !error.signal;
resolve(ranToEnd ? stdout : undefined);
}
);
} catch {
resolve(undefined);
}
});
}
/**
* Free bytes on the filesystem that holds `path` (`bavail * bsize`: what an unprivileged
* process can use), or `undefined` when the filesystem does not say. Not bounded by itself: see
* `dataDirFreeBytes()`.
*/
export async function freeDiskBytesOf(path: string): Promise<number | undefined> {
try {
const stat = await statfs(path);
const free = Number(stat.bavail) * Number(stat.bsize);
return Number.isFinite(free) && free >= 0 ? free : undefined;
} catch {
return undefined;
}
}
/** The one `statfs` that may be pending. While it is, no second one is started. */
let pendingStatfs: Promise<number | undefined> | undefined;
/** The IO of `dataDirFreeBytes()`. Each member defaults to the real thing; a test passes its own. */
export interface FreeDiskDeps {
/** Default `getDataDir()`. */
dataDir?: () => string;
/** Default the bounded path probe (`probePathKind`). */
probeKind?: (path: string) => Promise<PathProbeKind>;
/** Default `freeDiskBytesOf`. */
freeBytesOf?: (path: string) => Promise<number | undefined>;
/** Default `FREE_DISK_TIMEOUT_MS`. */
timeoutMs?: number;
}
/**
* Free bytes on the data dir's filesystem, bounded. The folder (or, when it does not exist yet,
* its nearest existing parent) is found with the bounded path probe, which never waits on a
* dead mount twice; the `statfs` itself is raced against a timeout. `undefined` when the
* filesystem does not answer in time, or is not known to be a folder.
*/
export async function dataDirFreeBytes(deps: FreeDiskDeps = {}): Promise<number | undefined> {
// Concurrent probes share one promise in `readHardware()`, so a `statfs` that is still pending
// here was started by an EARLIER probe and has outlived its timeout: a dead mount. It holds
// one threadpool worker, and that is the most this module may ever hold.
if (pendingStatfs) return undefined;
const probeKind = deps.probeKind ?? probePathKind;
let folder: string;
try {
folder = (deps.dataDir ?? getDataDir)();
let kind = await probeKind(folder);
for (let step = 0; kind === 'absent' && step < FREE_DISK_MAX_PARENT_STEPS; step++) {
const parent = dirname(folder);
if (parent === folder) break;
folder = parent;
kind = await probeKind(folder);
}
// 'unknown' is a mount that does not answer (or an unreadable path): do not touch it.
if (kind !== 'directory') return undefined;
} catch {
return undefined;
}
const started = (deps.freeBytesOf ?? freeDiskBytesOf)(folder).catch(() => undefined);
pendingStatfs = started;
const forget = (): void => {
if (pendingStatfs === started) pendingStatfs = undefined;
};
started.then(forget, forget);
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
started,
new Promise<undefined>((resolve) => {
timer = setTimeout(() => resolve(undefined), deps.timeoutMs ?? FREE_DISK_TIMEOUT_MS);
timer.unref?.();
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function defaultCpuModel(): string | undefined {
return os.cpus()[0]?.model;
}
/** A dependency that must never break the probe: its value, or `undefined` when it threw or rejected. */
async function attempt<T>(read: () => T | Promise<T>): Promise<T | undefined> {
try {
return await read();
} catch {
return undefined;
}
}
/** Memory and the three fields derived from it. */
function memoryFields(memBytes: number | undefined): Pick<HardwareFacts, 'memBytes' | 'memGb' | 'memClassGb'> {
const bytes = memBytes !== undefined && Number.isFinite(memBytes) && memBytes > 0 ? memBytes : 0;
return { memBytes: bytes, memGb: formatGb(bytes), memClassGb: memClassGb(bytes) };
}
async function probeHardware(deps: HardwareProbeDeps): Promise<HardwareFacts> {
const platform = deps.platform ?? process.platform;
const arch = deps.arch ?? os.arch();
const totalMemBytes = deps.totalMemBytes ?? os.totalmem;
const cpuModel = deps.cpuModel ?? defaultCpuModel;
if (platform !== 'darwin') {
return { platform, arch, ...memoryFields(await attempt(totalMemBytes)), source: 'not-probed' };
}
const [stdout, freeDisk] = await Promise.all([
attempt(deps.runSysctl ?? (() => spawnSysctl())),
attempt(deps.freeDiskBytes ?? (() => dataDirFreeBytes())),
]);
const disk: Pick<HardwareFacts, 'freeDiskBytes' | 'freeDiskGb'> =
freeDisk !== undefined && Number.isFinite(freeDisk) && freeDisk >= 0
? { freeDiskBytes: freeDisk, freeDiskGb: formatGb(freeDisk) }
: {};
const facts = typeof stdout === 'string' ? parseSysctl(stdout) : {};
if (facts.memBytes === undefined) {
const chip = await attempt(cpuModel);
return {
platform,
arch,
...memoryFields(await attempt(totalMemBytes)),
...(chip ? { chip } : {}),
...disk,
source: 'os-fallback',
};
}
const chip = facts.chip ?? (await attempt(cpuModel));
return {
platform,
arch,
...(facts.arm64 !== undefined ? { arm64: facts.arm64 } : {}),
...(facts.translated !== undefined ? { translated: facts.translated } : {}),
...memoryFields(facts.memBytes),
...(chip ? { chip } : {}),
...(facts.model !== undefined ? { model: facts.model } : {}),
...(facts.macosVersion !== undefined ? { macosVersion: facts.macosVersion } : {}),
...(facts.macosMajor !== undefined ? { macosMajor: facts.macosMajor } : {}),
...(facts.wiredLimitMb !== undefined ? { wiredLimitMb: facts.wiredLimitMb } : {}),
...disk,
source: 'sysctl',
};
}
let cached: { at: number; facts: HardwareFacts } | undefined;
let inFlight: Promise<HardwareFacts> | undefined;
/**
* What this machine is. Cached for `HARDWARE_CACHE_TTL_MS`; concurrent callers share one probe.
* Never rejects.
*
* The cache is one per process and does not look at `deps`: within the 60 s a second call
* returns the first call's answer whatever it injects. A test resets it first.
*/
export async function readHardware(deps: HardwareProbeDeps = {}): Promise<HardwareFacts> {
const now = deps.now ?? Date.now;
if (cached) {
const age = now() - cached.at;
if (age >= 0 && age < HARDWARE_CACHE_TTL_MS) return cached.facts;
}
if (inFlight) return inFlight;
const probe = probeHardware(deps)
.catch(
// Unreachable by design (every read above is guarded), and still an answer if it is reached.
(): HardwareFacts => ({
platform: deps.platform ?? process.platform,
arch: deps.arch ?? os.arch(),
...memoryFields(undefined),
source: (deps.platform ?? process.platform) === 'darwin' ? 'os-fallback' : 'not-probed',
})
)
.then((facts) => {
cached = { at: now(), facts };
return facts;
})
.finally(() => {
if (inFlight === probe) inFlight = undefined;
});
inFlight = probe;
return probe;
}
/** Drop the cached probe result and the memory of a pending disk read (tests, and nothing else). */
export function resetHardwareCache(): void {
cached = undefined;
inFlight = undefined;
pendingStatfs = undefined;
}
+165
View File
@@ -0,0 +1,165 @@
/**
* @fileoverview What the server knows about each catalogue harness on this machine, for the
* 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.
*
* `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. 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 { 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,
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;
}
+405
View File
@@ -0,0 +1,405 @@
/**
* @fileoverview The recommendation itself: which model, at which context, with which harness
* fits this Mac. Pure: no IO, no clock, no CLI registry, no data dir.
*
* The math, in the order it runs:
* - `deviceBudget()`: L is the wired limit when it is above 0, else 2/3 of memory below 36 GB
* and 3/4 from 36 GB. B = min(L - 1 GiB, memory - reserve), with a reserve of 6 GiB up to
* 18 GB, 8 GiB up to 48 GB and 12 GiB above.
* - `needBytes()`: F = weights + kvBytesPerToken * ctx + 1 GiB + the prompt cache tier of the
* catalogue for this Mac's memory.
* - Fit: F/B up to 0.85 is good, up to 0.98 is tight, above is out.
* - Speed: tokens per second = the model's `speedPer100Gbps` x the chip's bandwidth / 100.
* Below 10 is slow, decided on the unrounded product (9.96 is slow although it prints as
* 10). A chip that is in no chips row, or a model without a speed figure, is UNKNOWN: a
* bandwidth is never guessed.
* - Candidates: (model, context, harness) with `ctx >= harness.minContext`, the harness not
* gated, installed or installable, and accepting the model id.
* - Ranking, lexicographic: good before tight; ok before unknown before slow; the higher model
* rank; the higher harness rank; the larger context. The pick is the first candidate, the
* alternatives are the next four MODELS, each at its own best context and harness.
* - Disk: a model whose download would leave less than 10 GiB free is not offered. It is not
* dropped in silence: the best-ranked model left out that way is named in `noteDiskDropped`
* with what is missing.
*
* Verdict `none`, in this order (the first that applies is the reason):
* 1. `not-macos`: the server does not run on macOS.
* 2. `intel`: a Mac without Apple silicon.
* 3. `macos`: macOS is older than the catalogue's minimum (an unknown version does not block).
* 4. `memory`: no model fits the budget at any of its contexts, whatever the harness
* (`noneMemory`, or `noneMemoryUnknown` when the memory size could not be read).
* 5. `no-harness`: no harness row is usable (not gated, known to the caller, installed or
* installable).
* 6. `memory`: models fit, but none at a context a usable harness needs. Its own sentence
* (`noneMemoryContext`) with the budget and the smallest need at such a context, because
* this Mac can miss by a hair (the 16 GB Mac mini: 10.3 of 10.4 GB) and "too little
* memory" would hide that the answer rests on an estimate.
* 7. `no-harness`: models fit at such a context, but no usable harness accepts their id.
* 8. `disk`: for every remaining model, free disk minus the download is below 10 GiB (an
* unknown free disk does not block).
*
* Rules:
* - Verdict `limited` when NO model with 12 billion parameters or more fits this Mac's memory
* at a context a usable harness works at. It is a statement about the Mac ("This Mac can run
* a small model"), so it is judged on everything that fits, BEFORE the disk filter and
* whatever the pick is: a 36 GB Mac whose pick is a 9B model while a 20B, a 27B and a 35B
* are listed under it is not a small-model Mac, and a 48 GB Mac does not become one because
* its disk is full. A small pick under verdict `ok` carries `noteSmallPick` instead.
* - The word "Recommended" (`LOCAL_MODEL_LABELS.headlineTested`) only for verdict `ok` with a
* pick whose model has a `tested` entry for exactly this chip, this memory size and this
* harness. Otherwise `headlineOk` (or `headlineLimited`) plus `noteUntested`.
* - Every label in the result comes from `LOCAL_MODEL_LABELS`, every computed size text from
* `formatGb()`. The one size that is not decimal GB is the installed memory in `noneMemory`:
* `HardwareFacts.memClassGb`, the size the Mac is sold as. No number is spliced into a
* sentence here.
* - No CLI id is ever compared with a literal: a harness is a catalogue row plus a
* `HarnessFact`.
*/
import {
LOCAL_MODEL_LABELS,
LOCAL_MODEL_NONE_LABELS,
type Candidate,
type DeviceBudget,
type FitGrade,
type HardwareFacts,
type HarnessFact,
type LocalHarnessEntry,
type LocalModelCatalog,
type LocalModelEntry,
type LocalModelLabel,
type LocalModelMessage,
type NoneReason,
type Recommendation,
type SpeedGrade,
} from '../types.js';
import { chipBandwidthGbps } from '../config/local-model-catalog.js';
import { formatGb, GIB, MIB } from './format.js';
/** Need over budget up to this share is a `good` fit. */
export const FIT_GOOD_MAX_SHARE = 0.85;
/** Need over budget up to this share is a `tight` fit; above it the model is out. */
export const FIT_TIGHT_MAX_SHARE = 0.98;
/** An estimated speed below this many tokens per second is `slow`. */
export const SLOW_BELOW_TOKENS_PER_SECOND = 10;
/** When no model that fits has at least this many billion parameters, the verdict is `limited`. A pick below it is a "small model". */
export const LIMITED_BELOW_PARAMS_B = 12;
/** What must stay free on the disk after the model download. */
export const DISK_HEADROOM_BYTES = 10 * GIB;
/** What the runtime needs beside the weights, the KV cache and the prompt cache (compute buffers). */
export const RUNTIME_OVERHEAD_BYTES = GIB;
/** How many other models are listed under the pick. */
export const MAX_ALTERNATIVES = 4;
/** A memory size of at least this many GB (as sold) gets 3/4 of its memory as device limit, below it 2/3. */
const LARGE_LIMIT_FROM_GB = 36;
/** The memory the system, Codeman, a browser and the agent keep for themselves, by the size the Mac is sold as. */
function reserveBytes(memClassGb: number): number {
if (memClassGb <= 18) return 6 * GIB;
if (memClassGb <= 48) return 8 * GIB;
return 12 * GIB;
}
/** The memory a model may use on this Mac: the device limit L, the budget B and where L came from. */
export function deviceBudget(hw: HardwareFacts): DeviceBudget {
const memBytes = Number.isFinite(hw.memBytes) && hw.memBytes > 0 ? hw.memBytes : 0;
// 0 means "not set" (both test Macs report 0): only a value above 0 is a limit.
const wired = hw.wiredLimitMb !== undefined && Number.isFinite(hw.wiredLimitMb) && hw.wiredLimitMb > 0;
const limitBytes = wired
? Math.floor((hw.wiredLimitMb as number) * MIB)
: Math.floor(memBytes * (hw.memClassGb < LARGE_LIMIT_FROM_GB ? 2 / 3 : 3 / 4));
const budgetBytes = Math.max(0, Math.min(limitBytes - GIB, memBytes - reserveBytes(hw.memClassGb)));
return { limitBytes, budgetBytes, source: wired ? 'wired-limit' : 'estimated' };
}
/** The prompt cache of the last tier whose minimum is not above this Mac's memory size. */
function promptCacheBytes(catalog: LocalModelCatalog, memClassGb: number): number {
let bytes = 0;
for (const tier of catalog.promptCacheTiers) {
if (tier.minMemGb <= memClassGb) bytes = tier.bytes;
}
return bytes;
}
/** F: the bytes `model` needs in memory at a context of `ctx` tokens on this Mac. */
export function needBytes(model: LocalModelEntry, ctx: number, catalog: LocalModelCatalog, hw: HardwareFacts): number {
return model.bytes + model.kvBytesPerToken * ctx + RUNTIME_OVERHEAD_BYTES + promptCacheBytes(catalog, hw.memClassGb);
}
function fitGrade(need: number, budgetBytes: number): FitGrade {
if (budgetBytes <= 0) return 'out';
const share = need / budgetBytes;
if (share <= FIT_GOOD_MAX_SHARE) return 'good';
if (share <= FIT_TIGHT_MAX_SHARE) return 'tight';
return 'out';
}
/**
* Whether the MACHINE is Apple silicon. `hw.optional.arm64` decides when it was read. Without
* it: a process under Rosetta, an arm64 process or an Apple chip name each prove Apple silicon
* (`arch` alone cannot say no, because Node under Rosetta reports x64).
*/
function isAppleSilicon(hw: HardwareFacts): boolean {
if (hw.arm64 !== undefined) return hw.arm64;
if (hw.translated === true || hw.arch === 'arm64') return true;
return /^Apple\b/i.test(hw.chip ?? '');
}
function round(value: number, decimals: number): number {
const factor = 10 ** decimals;
return Math.round(value * factor) / factor;
}
/** A candidate plus its catalogue row and the two ranks the ordering needs, none of which is part of the payload. */
interface Ranked {
candidate: Candidate;
model: LocalModelEntry;
modelRank: number;
harnessRank: number;
}
/** A catalogue harness row with the caller's fact about it. */
interface UsableHarness {
row: LocalHarnessEntry;
fact: HarnessFact;
}
const FIT_ORDER: Record<Candidate['fit'], number> = { good: 0, tight: 1 };
const SPEED_ORDER: Record<SpeedGrade, number> = { ok: 0, unknown: 1, slow: 2 };
/** Best first. */
function compareRanked(a: Ranked, b: Ranked): number {
return (
FIT_ORDER[a.candidate.fit] - FIT_ORDER[b.candidate.fit] ||
SPEED_ORDER[a.candidate.speed] - SPEED_ORDER[b.candidate.speed] ||
b.modelRank - a.modelRank ||
b.harnessRank - a.harnessRank ||
b.candidate.context - a.candidate.context
);
}
/** A function supplied by the caller must not be able to break a pure answer. */
function accepts(fact: HarnessFact, modelId: string): boolean {
try {
return fact.acceptsModelId(modelId) === true;
} catch {
return false;
}
}
function buildCandidate(
model: LocalModelEntry,
ctx: number,
harness: UsableHarness,
need: number,
fit: Candidate['fit'],
hw: HardwareFacts,
catalog: LocalModelCatalog,
budgetBytes: number
): Candidate {
const gbps = chipBandwidthGbps(catalog.chips, hw.chip);
const rawSpeed =
gbps !== undefined && model.speedPer100Gbps !== undefined ? (model.speedPer100Gbps * gbps) / 100 : undefined;
// Graded on the unrounded figure: 9.96 rounds to 10.0 for the payload and is still slow.
const speed: SpeedGrade =
rawSpeed === undefined ? 'unknown' : rawSpeed < SLOW_BELOW_TOKENS_PER_SECOND ? 'slow' : 'ok';
const tokensPerSecond = rawSpeed === undefined ? undefined : round(rawSpeed, 1);
// "A Mac like this" is exact: the same chip string, the same memory size as sold, the same harness.
const tested = model.tested.some(
(entry) => entry.chip === hw.chip && entry.memGb === hw.memClassGb && entry.cli === harness.row.cli
);
return {
modelId: model.id,
modelLabel: model.label,
license: model.license,
paramsB: model.paramsB,
context: ctx,
cli: harness.row.cli,
cliLabel: harness.fact.label,
cliInstalled: harness.fact.installed,
...(harness.row.slowFirstTurn ? { slowFirstTurn: true } : {}),
needBytes: need,
needGb: formatGb(need),
downloadBytes: model.bytes,
downloadGb: formatGb(model.bytes),
budgetShare: round(need / budgetBytes, 3),
fit,
fitLabel: fit === 'good' ? LOCAL_MODEL_LABELS.fitGood : LOCAL_MODEL_LABELS.fitTight,
speed,
...(speed === 'slow' ? { speedLabel: LOCAL_MODEL_LABELS.speedSlow } : {}),
...(speed === 'unknown' ? { speedLabel: LOCAL_MODEL_LABELS.speedUnknown } : {}),
...(tokensPerSecond !== undefined ? { tokensPerSecond } : {}),
tested,
};
}
/** A `none` answer. `text` replaces the reason's default sentence (the `memory` reason has three). */
function none(
reason: NoneReason,
budget: DeviceBudget,
vars?: Record<string, string>,
text: LocalModelLabel = LOCAL_MODEL_NONE_LABELS[reason]
): Recommendation {
return {
verdict: 'none',
reason,
headline: { text, ...(vars ? { vars } : {}) },
notes: [],
alternatives: [],
budgetBytes: budget.budgetBytes,
budgetGb: formatGb(budget.budgetBytes),
budgetSource: budget.source,
};
}
/**
* The recommendation for this Mac. `harnessFacts` holds one fact per catalogue harness the
* caller could resolve; a catalogue harness without a fact is not offered.
*/
export function recommend(
hw: HardwareFacts,
catalog: LocalModelCatalog,
harnessFacts: readonly HarnessFact[]
): Recommendation {
// Off macOS and on an Intel Mac there is no budget to compute: a model has no memory to run in.
const noBudget: DeviceBudget = { limitBytes: 0, budgetBytes: 0, source: 'estimated' };
if (hw.platform !== 'darwin') return none('not-macos', noBudget);
if (!isAppleSilicon(hw)) return none('intel', noBudget);
const budget = deviceBudget(hw);
if (hw.macosMajor !== undefined && hw.macosMajor < catalog.minMacosMajor) {
return none('macos', budget, {
version: hw.macosVersion ?? String(hw.macosMajor),
minimum: String(catalog.minMacosMajor),
});
}
// Every (model, context) that fits the budget, before any harness is looked at.
const fitting: { model: LocalModelEntry; ctx: number; need: number; fit: Candidate['fit'] }[] = [];
for (const model of catalog.models) {
for (const ctx of model.contexts) {
const need = needBytes(model, ctx, catalog, hw);
const fit = fitGrade(need, budget.budgetBytes);
if (fit !== 'out') fitting.push({ model, ctx, need, fit });
}
}
// Nothing fits at any context. The installed memory is named by the size the Mac is sold
// as; a size that could not be read is not stated at all (never "0 GB of memory").
const tooLittleMemory = (): Recommendation =>
Number.isFinite(hw.memClassGb) && hw.memClassGb > 0
? none('memory', budget, { memory: String(hw.memClassGb) })
: none('memory', budget, undefined, LOCAL_MODEL_LABELS.noneMemoryUnknown);
if (fitting.length === 0) return tooLittleMemory();
// A gated row is never offered, whatever the caller knows about its CLI.
const usable: UsableHarness[] = [];
for (const row of catalog.harnesses) {
if (row.gated) continue;
const fact = harnessFacts.find((candidate) => candidate.cli === row.cli);
if (fact && (fact.installed || fact.installable)) usable.push({ row, fact });
}
if (usable.length === 0) return none('no-harness', budget);
const atHarnessContext = fitting.flatMap((entry) =>
usable.filter((harness) => entry.ctx >= harness.row.minContext).map((harness) => ({ ...entry, harness }))
);
if (atHarnessContext.length === 0) {
// Models fit, but only below the context a harness needs. Say both numbers: the smallest
// need at such a context can sit a hair over the line, and the budget is an estimate.
const smallestContext = Math.min(...usable.map((harness) => harness.row.minContext));
let smallestNeed: number | undefined;
for (const model of catalog.models) {
for (const ctx of model.contexts) {
if (ctx < smallestContext) continue;
const need = needBytes(model, ctx, catalog, hw);
if (smallestNeed === undefined || need < smallestNeed) smallestNeed = need;
}
}
if (smallestNeed === undefined) return tooLittleMemory();
return none(
'memory',
budget,
{ budget: formatGb(budget.budgetBytes), need: formatGb(smallestNeed) },
LOCAL_MODEL_LABELS.noneMemoryContext
);
}
const accepted = atHarnessContext.filter((entry) => accepts(entry.harness.fact, entry.model.id));
if (accepted.length === 0) return none('no-harness', budget);
// The download must leave headroom on the disk. An unknown free disk does not block.
const free = hw.freeDiskBytes;
const hasRoom = (model: LocalModelEntry): boolean => free === undefined || free - model.bytes >= DISK_HEADROOM_BYTES;
if (free !== undefined && !accepted.some((entry) => hasRoom(entry.model))) {
const smallest = Math.min(...accepted.map((entry) => entry.model.bytes));
return none('disk', budget, {
download: formatGb(smallest),
reserve: formatGb(DISK_HEADROOM_BYTES),
free: hw.freeDiskGb ?? formatGb(free),
missing: formatGb(smallest + DISK_HEADROOM_BYTES - free),
});
}
// Everything that fits is ranked, the models without room on the disk included: the best of
// those is named in a note, so a full disk never removes a model without a word.
const ranked: Ranked[] = accepted
.map((entry) => ({
candidate: buildCandidate(
entry.model,
entry.ctx,
entry.harness,
entry.need,
entry.fit,
hw,
catalog,
budget.budgetBytes
),
model: entry.model,
modelRank: entry.model.rank,
harnessRank: entry.harness.row.rank,
}))
.sort(compareRanked);
// The first candidate of each model is that model at its best context and harness.
const bestPerModel: Ranked[] = [];
for (const entry of ranked) {
if (!bestPerModel.some((kept) => kept.model.id === entry.model.id)) bestPerModel.push(entry);
}
const offered = bestPerModel.filter((entry) => hasRoom(entry.model)).map((entry) => entry.candidate);
const leftOutForDisk = bestPerModel.find((entry) => !hasRoom(entry.model));
const [pick, ...others] = offered;
// About the Mac, not the pick, and judged before the disk filter (see the file header).
const limited = !accepted.some((entry) => entry.model.paramsB >= LIMITED_BELOW_PARAMS_B);
const headline = limited
? LOCAL_MODEL_LABELS.headlineLimited
: pick.tested
? LOCAL_MODEL_LABELS.headlineTested
: LOCAL_MODEL_LABELS.headlineOk;
const notes: LocalModelMessage[] = [];
// The limited headline already says it. Under `ok` a small pick must still be called small.
if (!limited && pick.paramsB < LIMITED_BELOW_PARAMS_B) notes.push({ text: LOCAL_MODEL_LABELS.noteSmallPick });
if (leftOutForDisk && free !== undefined) {
notes.push({
text: LOCAL_MODEL_LABELS.noteDiskDropped,
vars: {
model: leftOutForDisk.model.label,
missing: formatGb(leftOutForDisk.model.bytes + DISK_HEADROOM_BYTES - free),
},
});
}
if (!pick.tested) notes.push({ text: LOCAL_MODEL_LABELS.noteUntested });
return {
verdict: limited ? 'limited' : 'ok',
headline: { text: headline },
notes,
pick,
alternatives: others.slice(0, MAX_ALTERNATIVES),
budgetBytes: budget.budgetBytes,
budgetGb: formatGb(budget.budgetBytes),
budgetSource: budget.source,
};
}
+2
View File
@@ -29,6 +29,7 @@
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
* | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` |
* | local-model | HardwareFacts, LocalModelCatalog, Candidate, Recommendation, LocalModelStatus, LOCAL_MODEL_LABELS | In-memory only (nothing persisted) → `GET /api/local-model` |
*
* ## Cross-domain relationship map
*
@@ -77,3 +78,4 @@ export * from './webview.js';
export * from './intent.js';
export * from './foreign-tmux.js';
export * from './mcp-sync.js';
export * from './local-model.js';
+396
View File
@@ -0,0 +1,396 @@
/**
* @fileoverview Types of the hardware-aware local model recommendation: what the server sees of
* this Mac, the catalogue of models and harnesses, and the answer of `GET /api/local-model`.
*
* This stage is read-only: Codeman looks at the Mac and recommends. It downloads nothing,
* installs nothing and writes no file.
*
* Who produces what:
* - `HardwareFacts`: src/local-model/hardware.ts (one bounded `sysctl` spawn, never throws).
* - `LocalModelCatalog` and its rows: src/config/local-model-catalog.ts (a compiled constant).
* - `HarnessFact`: src/local-model/harness-facts.ts (server only, read from the CLI registry at
* call time; it holds a function, so it never goes over HTTP).
* - `Recommendation`, `Candidate`, `DeviceBudget`: src/local-model/recommend.ts (pure).
* - `LocalModelStatus`: src/web/routes/local-model-routes.ts (the `data` of the GET).
*
* Three rules every producer and consumer follows:
*
* 1. ONE unit for a computed size a person reads: decimal GB, computed from bytes by
* `formatGb()` in src/local-model/format.ts. Every such size travels twice, as bytes
* (`needBytes`) and as the text to show (`needGb`, a number without the unit, such as
* '25.5'). The browser prints the text and never divides bytes itself. GiB appears only
* inside the budget math. The one exception is the NAME of the installed memory: the card
* says `HardwareFacts.memClassGb` (48 for a Mac sold with 48 GB), see that field.
* 2. Every sentence or label the SERVER chooses comes from `LOCAL_MODEL_LABELS` below, so the
* browser can translate it (`codemanT(text, vars)`) and its i18n test can list every string.
* Numbers are never spliced into a sentence here: they travel in `vars` and fill the
* `{placeholder}` slots.
* 3. A CLI id is DATA (`LocalHarnessEntry.cli`, `Candidate.cli`). No code compares one with a
* literal: what differs between harnesses is a catalogue field or a `HarnessFact`.
*/
// ---------- Hardware ----------
/**
* How `HardwareFacts` was obtained.
* - `sysctl`: the spawn answered and `hw.memsize` parsed.
* - `os-fallback`: on macOS, but the spawn failed or timed out; memory, architecture and the chip
* string come from `node:os` and the free disk from `statfs`. What only sysctl knows (`arm64`,
* `translated`, `model`, `macosVersion`, `macosMajor`, `wiredLimitMb`) is absent.
* - `not-probed`: not macOS, so `sysctl` was never spawned.
*/
export type HardwareProbeSource = 'sysctl' | 'os-fallback' | 'not-probed';
/**
* What the server knows about the machine it runs on. Plain JSON (it is part of the GET payload),
* and always an answer: a failed probe yields the `os-fallback` or `not-probed` shape, never an
* exception.
*/
export interface HardwareFacts {
/** `process.platform` of the server. Anything but `darwin` ends in verdict `none`, reason `not-macos`. */
platform: string;
/** `os.arch()` of the server process. `x64` on Apple silicon means Node runs under Rosetta. */
arch: string;
/** `hw.optional.arm64` is 1: the MACHINE is Apple silicon whatever `arch` says. Absent when the key was not read. */
arm64?: boolean;
/** `sysctl.proc_translated` is 1: this process runs under Rosetta. Absent when the key was not read (Intel Macs lack it). */
translated?: boolean;
/** Installed memory in bytes (`hw.memsize`, else `os.totalmem()`). The ONLY memory input of the budget math. */
memBytes: number;
/**
* `memBytes` in the unit of every computed size: decimal GB from `formatGb()`, without the unit
* ('17.2' for a Mac sold as 16 GB). NOT what the card calls the installed memory: that is
* `memClassGb`, see there.
*/
memGb: string;
/**
* The memory size the Mac is sold as: whole binary gigabytes (16, 32, 48), 0 when the size is
* unknown. Tier rules and `TestedOn.memGb` use it, and it is the ONE size a person reads that
* is not decimal GB: the hardware line and the `memory` slot of `noneMemory` print it, because
* it is the number on the box and in About This Mac ("48 GB"), and '51.5 GB memory' reads as a
* different machine. Every computed size (need, budget, download, free disk) stays decimal GB.
*/
memClassGb: number;
/** `machdep.cpu.brand_string` ('Apple M4 Pro'), else `os.cpus()[0].model`. */
chip?: string;
/** `hw.model` ('Mac16,10'). */
model?: string;
/** `kern.osproductversion` ('27.2'). */
macosVersion?: string;
/** The leading number of `macosVersion` (27). */
macosMajor?: number;
/** `iogpu.wired_limit_mb` exactly as reported. 0 means "not set" (both test Macs report 0); only a value above 0 is a limit. */
wiredLimitMb?: number;
/** Free bytes on the filesystem of Codeman's data dir (`statfs`: `bavail * bsize`). */
freeDiskBytes?: number;
/** `freeDiskBytes` as a person reads it (decimal GB, no unit). Present exactly when `freeDiskBytes` is. */
freeDiskGb?: string;
source: HardwareProbeSource;
}
// ---------- Catalogue ----------
/** One Mac a model and harness pair was really run on by us. A model row with a matching entry may be called "Recommended". */
export interface TestedOn {
/** The chip, as `machdep.cpu.brand_string` reports it ('Apple M4'). */
chip: string;
/** The Mac's memory as it is sold (`HardwareFacts.memClassGb`: 16, 32, 48). */
memGb: number;
/** The harness it was tested with: a CLI registry id, as data. */
cli: string;
/** ISO date of the test ('2026-11-06'). */
date: string;
}
/** One model of the catalogue. */
export interface LocalModelEntry {
/** `name:tag` exactly as the model registry lists it. Lowercase, one colon, no slash (`LOCAL_MODEL_ID_PATTERN`). */
id: string;
/** The name a person reads ('Qwen3.8 27B'). */
label: string;
/** SPDX id of the license ('Apache-2.0'). */
license: string;
/** Parameter count in billions. When no model that fits has 12 or more, the verdict is `limited`, never `ok`. */
paramsB: number;
/** Size of the weights in bytes: both the download and what they take in memory. */
bytes: number;
/** Bytes of KV cache one token of context costs. */
kvBytesPerToken: number;
/** Context lengths (tokens) the model may be run at, largest first. */
contexts: number[];
/** Preference among models that fit: the HIGHER rank wins. A judgement, not a measurement. */
rank: number;
/** Measured tokens per second per 100 GB/s of memory bandwidth. Absent: the speed of this model is unknown. */
speedPer100Gbps?: number;
/** Macs this model was really run on. Empty today for every row. */
tested: TestedOn[];
}
/** One harness (coding agent CLI) that can be connected to a local model. All of it is data. */
export interface LocalHarnessEntry {
/** A CLI registry id. Data only: never compared with a literal anywhere. */
cli: string;
/** The smallest context (tokens) at which this harness works. */
minContext: number;
/** Preference among harnesses: the HIGHER rank wins. */
rank: number;
/** True: listed in the catalogue but never offered yet (its use with a local model is unverified). */
gated?: boolean;
/** True: the first answer takes minutes, because the harness sends a very large fixed prompt. */
slowFirstTurn?: boolean;
}
/**
* Memory bandwidth of one chip family. `LocalModelCatalog.chips` is ordered most specific first
* ('M4 Max' before 'M4') and the first row that names `HardwareFacts.chip` wins. A row names a
* chip when the chip string ENDS with its `match`, starting at the beginning or after a space,
* case ignored (`chipNameMatches()` in src/config/local-model-catalog.ts): 'M4' is not the chip
* of 'Apple M4 Pro', and a variant the table has never heard of ('Apple M6 Pro') matches no row.
* A chip that matches no row has an UNKNOWN speed: a bandwidth is never guessed.
*/
export interface ChipEntry {
/** The chip name the chip string must end with ('M4 Max'). */
match: string;
/** Memory bandwidth in GB/s. */
gbps: number;
}
/**
* Host memory the runtime's prompt cache takes, by the size of the Mac. Tiers are ordered by
* `minMemGb` ascending and the last tier with `minMemGb <= HardwareFacts.memClassGb` applies.
*/
export interface PromptCacheTier {
/** Smallest memory size (as sold, binary gigabytes) this tier applies to. The first tier has 0. */
minMemGb: number;
bytes: number;
}
/** The catalogue: a compiled constant, validated by `LocalModelCatalogSchema`. No override file, no download data. */
export interface LocalModelCatalog {
schema: 1;
/** ISO date the rows were last checked ('2026-11-06'). Reported as `LocalModelStatus.catalogUpdated`. */
updated: string;
/** Oldest macOS major version a local model runs on. Below it: verdict `none`, reason `macos`. */
minMacosMajor: number;
promptCacheTiers: PromptCacheTier[];
models: LocalModelEntry[];
harnesses: LocalHarnessEntry[];
chips: ChipEntry[];
}
// ---------- Harness facts (server only) ----------
/**
* What the server knows about one catalogue harness on this machine. Built from the CLI registry
* at call time, so `recommend()` names no CLI. NOT serializable (it holds a function): it never
* leaves the server. `Candidate` carries the parts a browser needs.
*/
export interface HarnessFact {
/** The CLI registry id. */
cli: string;
/** The registry's display label ('OpenCode'). */
label: string;
/** The CLI's binary was found on this machine. */
installed: boolean;
/** Not installed, but the registry has an install command for this platform that needs no administrator rights. */
installable: boolean;
/** Whether this CLI can be launched with that model id (its command-line pattern accepts it). */
acceptsModelId: (modelId: string) => boolean;
}
// ---------- Recommendation ----------
/** Need over budget: up to 0.85 is `good`, up to 0.98 is `tight`, above is `out`. */
export type FitGrade = 'good' | 'tight' | 'out';
/**
* Expected generation speed. `unknown` when the chip is in no `ChipEntry` or the model has no
* `speedPer100Gbps`. In the ranking `unknown` sits between `ok` and `slow`.
*/
export type SpeedGrade = 'ok' | 'slow' | 'unknown';
/**
* Why the verdict is `none`.
* - `not-macos`: the server does not run on macOS (the card is hidden).
* - `intel`: a Mac without Apple silicon.
* - `memory`: no model fits the memory budget at a context a usable harness needs. Three
* headlines share this reason: `noneMemory` (nothing fits at any context), `noneMemoryContext`
* (a model fits, but only below the context a harness needs: the sentence carries the budget
* and the need) and `noneMemoryUnknown` (the memory size could not be read).
* - `disk`: models fit the memory, but for every one of them free disk minus the download is below 10 GiB.
* - `macos`: macOS is older than `LocalModelCatalog.minMacosMajor`.
* - `no-harness`: no catalogue harness is installed or installable and accepts a model.
*/
export type NoneReason = 'not-macos' | 'intel' | 'memory' | 'disk' | 'macos' | 'no-harness';
/** Where the device budget came from: a formula (`estimated`) or `iogpu.wired_limit_mb` above 0 (`wired-limit`). */
export type BudgetSource = 'estimated' | 'wired-limit';
/** The memory a model may use on this Mac. */
export interface DeviceBudget {
/** L: the wired limit when above 0, else 2/3 of memory below 36 GB and 3/4 from 36 GB. */
limitBytes: number;
/** B = min(L - 1 GiB, memory - reserve). What `Candidate.budgetShare` is measured against. */
budgetBytes: number;
source: BudgetSource;
}
/**
* Every sentence and label the server may put into the payload, in English (the i18n source
* language). The zh-CN dictionary in src/web/public/i18n.js has one entry per value, keyed by
* this exact text, and test/local-model-i18n.test.ts imports this constant to list them. Adding
* a key is fine; changing a text means changing its zh-CN key too.
*
* `{placeholder}` slots are filled by the browser from `LocalModelMessage.vars`. `{name}` is
* taken (the translator fills it with the display name), so no slot here is called `name`.
*
* | Key | Slots (all text; sizes are decimal GB without the unit) |
* |---------------------|---------------------------------------------------------------------------|
* | `noteDiskDropped` | `model`: label of the best model left out for disk space; `missing`: its |
* | | download + the disk reserve - free |
* | `noneMemory` | `memory`: `HardwareFacts.memClassGb`, the size the Mac is sold as |
* | `noneMemoryContext` | `budget`: `Recommendation.budgetGb`; `need`: the smallest need of any |
* | | model at a context a usable harness works at |
* | `noneDisk` | `download`: smallest download among the models that fit the memory; |
* | | `reserve`: what must stay free after it (10 GiB, as decimal GB); `free`: |
* | | `HardwareFacts.freeDiskGb`; `missing`: download + reserve - free |
* | `noneMacos` | `version`: `HardwareFacts.macosVersion`; `minimum`: the catalogue minimum |
*
* The word "Recommended" exists only in `headlineTested`, which may be used only when the
* pick's `tested` is true. No catalogue row has a tested entry today, so it is never sent.
*/
export const LOCAL_MODEL_LABELS = {
/** Verdict `ok`, pick not tested by us on a Mac like this one. */
headlineOk: 'Best fit for this Mac',
/** Verdict `ok`, pick tested by us on a Mac like this one. */
headlineTested: 'Recommended for this Mac',
/** Verdict `limited`: no model with 12 billion parameters or more fits this Mac. */
headlineLimited:
'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.',
/** A note under an `ok` or `limited` pick whose `tested` is false. */
noteUntested: 'Not yet tested by us on a Mac like this.',
/** A note under an `ok` pick that is itself a small model: larger ones fit this Mac, but rank lower or have no room on the disk. */
noteSmallPick:
'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.',
/** A note when a model that fits the memory was left out because its download would not leave the disk reserve. */
noteDiskDropped:
'{model} fits the memory of this Mac too, but its download needs about {missing} GB more free disk space.',
fitGood: 'Good',
fitTight: 'Tight',
speedSlow: 'Slow',
speedUnknown: 'Speed unknown',
noneNotMacos: 'Codeman recommends a local model only on a Mac with Apple silicon.',
noneIntel: 'This Mac has an Intel processor. Local coding models need Apple silicon.',
/** Reason `memory`: nothing fits at any context. */
noneMemory:
'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.',
/** Reason `memory`: a model fits, but only below the context a harness needs. Says both numbers, and that they are an estimate. */
noneMemoryContext:
'Codeman estimates that this Mac can give a model about {budget} GB of memory. The smallest model a coding agent can work with needs about {need} GB. That leaves no safe margin, so Codeman recommends none here.',
/** Reason `memory`: the memory size could not be read, so no size is stated. */
noneMemoryUnknown: 'Codeman could not read how much memory this Mac has, so it recommends no model here.',
noneDisk:
'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.',
noneMacos: 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.',
noneNoHarness: 'No coding agent that Codeman can connect to a local model is available on this Mac.',
} as const;
/** One of the texts in `LOCAL_MODEL_LABELS`. The payload's label fields are typed with it, so an off-list string does not compile. */
export type LocalModelLabel = (typeof LOCAL_MODEL_LABELS)[keyof typeof LOCAL_MODEL_LABELS];
/** The headline of each `none` reason. `memory` has two more (`noneMemoryContext`, `noneMemoryUnknown`), see `NoneReason`. */
export const LOCAL_MODEL_NONE_LABELS: Record<NoneReason, LocalModelLabel> = {
'not-macos': LOCAL_MODEL_LABELS.noneNotMacos,
intel: LOCAL_MODEL_LABELS.noneIntel,
memory: LOCAL_MODEL_LABELS.noneMemory,
disk: LOCAL_MODEL_LABELS.noneDisk,
macos: LOCAL_MODEL_LABELS.noneMacos,
'no-harness': LOCAL_MODEL_LABELS.noneNoHarness,
};
/** A sentence for the card: a text from `LOCAL_MODEL_LABELS` plus the values of its `{placeholder}` slots. */
export interface LocalModelMessage {
text: LocalModelLabel;
/** Slot values, already formatted as text. Absent when the text has no slot. */
vars?: Record<string, string>;
}
/** One model at one context with one harness, measured against this Mac. Plain JSON. */
export interface Candidate {
/** `LocalModelEntry.id`. */
modelId: string;
/** `LocalModelEntry.label`. */
modelLabel: string;
license: string;
paramsB: number;
/** Context length in tokens. */
context: number;
/** The harness: a CLI registry id, as data. */
cli: string;
/** The harness's display label, from the CLI registry. */
cliLabel: string;
/** False: the harness is not on this machine yet (it is installable, or it would not be a candidate). */
cliInstalled: boolean;
/** The harness row's `slowFirstTurn`. */
slowFirstTurn?: boolean;
/** F = weights + kvBytesPerToken * context + 1 GiB + prompt cache. */
needBytes: number;
/** `needBytes` as decimal GB text, no unit. */
needGb: string;
/** The model download in bytes (`LocalModelEntry.bytes`). */
downloadBytes: number;
/** `downloadBytes` as decimal GB text, no unit. */
downloadGb: string;
/** `needBytes / Recommendation.budgetBytes`, rounded to three decimals (0.83). The fit grade is decided on the unrounded ratio. */
budgetShare: number;
/** A candidate in a `Recommendation` is never `out`. */
fit: Exclude<FitGrade, 'out'>;
/** `fitGood` or `fitTight`. */
fitLabel: LocalModelLabel;
speed: SpeedGrade;
/** `speedSlow` or `speedUnknown`. Absent when `speed` is `ok`. */
speedLabel?: LocalModelLabel;
/** Estimated generation speed, rounded to one decimal. Present exactly when `speed` is not `unknown`. `speed` is decided on the unrounded figure. */
tokensPerSecond?: number;
/** A `TestedOn` entry of the model matches this Mac (same chip, same `memClassGb`) and this harness. */
tested: boolean;
}
/**
* The answer of `recommend()`.
* - `ok`: `pick` is the best candidate, and at least one model with 12 billion parameters or
* more fits this Mac's memory at a context a usable harness works at. The pick itself can be
* smaller (a larger model may fit only tightly, have no speed figure, or have no room on the
* disk): `notes` then carries `noteSmallPick`.
* - `limited`: `pick` is the best candidate, and NO model with 12 billion parameters or more
* fits. The card says so and never says "Recommended".
* - `none`: no pick; `reason` says why.
*/
export interface Recommendation {
verdict: 'ok' | 'limited' | 'none';
/** Present exactly when `verdict` is `none`. */
reason?: NoneReason;
/** What the card says first: `headlineOk`, `headlineTested`, `headlineLimited` or a `none` sentence. */
headline: LocalModelMessage;
/** Further sentences under the pick, in this order: `noteSmallPick`, `noteDiskDropped`, `noteUntested`. May be empty. */
notes: LocalModelMessage[];
/** Present exactly when `verdict` is `ok` or `limited`. */
pick?: Candidate;
/** The next models that fit and have room on the disk (at most four, each at its best context and harness), best first. Empty when `verdict` is `none`. */
alternatives: Candidate[];
/** `DeviceBudget.budgetBytes`. 0 when no budget is computed: not macOS, or a Mac without Apple silicon. */
budgetBytes: number;
/** `budgetBytes` as decimal GB text, no unit. */
budgetGb: string;
budgetSource: BudgetSource;
}
// ---------- API ----------
/** The `data` of `GET /api/local-model`. Read-only; admin only in multi-user mode. */
export interface LocalModelStatus {
hardware: HardwareFacts;
recommendation: Recommendation;
/** `LocalModelCatalog.updated`. */
catalogUpdated: string;
}
+2
View File
@@ -48,6 +48,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module).
**Model on this Mac** (`local-model-ui.js`; welcome card `#localModelCard` + App Settings > Models group `#localModelGroup`; server rules in the root CLAUDE.md, Local model recommendation): a READ-ONLY view of `GET /api/local-model`. ONE builder (`localModelBody`) paints both containers, so the card and the Settings row can never name a different pick; the card folds the alternatives, Settings lists them. ⚠️ Every dynamic string goes in through `textContent` (model, harness and chip names are someone else's text), both containers carry `data-i18n-skip`, and the module translates its own sentences at render time, so a language or display-name change must repaint from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or harnesses, never compares a CLI id, and never contains the word "Recommended" (that headline is the server's to send). ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, and the only non-literal calls are the two payload ones. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never divided here; the installed memory alone is `memClassGb`, a number or nothing (an unknown size is left out, never `0 GB`). ⚠️ The card ships `hidden` and shows only once the server answered for a Mac; the card AND the Settings group stay hidden for `not-macos` (the server is usually Linux, and "this Mac" always means the machine the server runs on), for a refusal and while the first request is in flight; a failed first request shows one sentence in Settings only. ⚠️ Refused means `403` with `errorCode: FORBIDDEN` (a non-admin in multi-user mode: stop asking); the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request that is asked again on `codeman:me`. ⚠️ Each fit and speed pill carries what it measures as `title` + `aria-label` ("Good" is the memory fit, not a rating), and the line saying Codeman cannot download or install yet sits right under the pick and its notes, in `--text-dim` like every other line. The dismissal is a per-device localStorage key named for this stage (`codeman:local-model-dismissed:readonly`), never a server setting: the stage that can set a model up takes a new key so its offer shows once more. At most one request a minute (the server caches its probe for 60 s), shared while in flight, and a failed refresh keeps the answer on screen. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`.
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
+64
View File
@@ -1415,6 +1415,70 @@
'该 tmux 会话在此处打开时所在的位置。它是在 Codeman 之外启动的,关闭此标签页后仍会继续运行。',
'Git status is not available for a tmux session opened outside Codeman.':
'在 Codeman 之外打开的 tmux 会话无法使用 Git 状态。',
// Model on this Mac (local-model-ui.js: the welcome card and its App Settings group). The first
// block is the module's own text; sizes and names fill the {placeholder} slots.
'Model on this Mac': '此 Mac 上的模型',
'What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.':
'运行 {name} 的这台 Mac 可以运行哪种本地编程模型。只读:此处不会下载或安装任何内容。',
'The Mac that Codeman runs on, which is not always the device you are reading on.':
'指运行 {name} 的那台 Mac,不一定是你正在使用的这台设备。',
Hide: '隐藏',
'Hide this card on this device. It stays in App Settings, under Models.':
'在此设备上隐藏此卡片。它仍保留在“应用设置”的“模型”中。',
'{chip}, {memory} GB memory, {free} GB free': '{chip},内存 {memory} GB,可用磁盘空间 {free} GB',
'{chip}, {memory} GB memory': '{chip},内存 {memory} GB',
'{memory} GB memory, {free} GB free': '内存 {memory} GB,可用磁盘空间 {free} GB',
'{memory} GB memory': '内存 {memory} GB',
'{model} ({id}) with {harness}': '{model}({id}),搭配 {harness}',
'Context {context} tokens': '上下文 {context} 个 Token',
'{need} GB of memory': '占用内存 {need} GB',
'{download} GB download': '下载 {download} GB',
'Needs about {need} GB of the {budget} GB this Mac can give a model.':
'大约需要 {need} GB,此 Mac 可分配给模型的内存为 {budget} GB。',
'Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).':
'大约需要 {need} GB,此 Mac 可分配给模型的内存约为 {budget} GB(估算)。',
'About {speed} tokens per second (estimated).': '每秒约 {speed} 个 Token(估算)。',
'{harness} is not installed on this Mac yet.': '此 Mac 上尚未安装 {harness}。',
'With {harness}, the first answer can take minutes.': '使用 {harness} 时,第一次回答可能需要几分钟。',
'about {speed} tokens per second': '每秒约 {speed} 个 Token',
// What the fit and speed labels measure (their tooltip and accessible name).
'Memory fit: needs {share}% of what this Mac can give a model':
'内存占用:需要此 Mac 可分配给模型的内存的 {share}%',
'No speed estimate for this model on this chip': '此芯片上没有该模型的速度估算',
'Other models that fit': '其他装得下的模型',
'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.':
'{name} 目前还不能为你下载或安装它。这是下一步的功能,尚未实现。',
// The sentences and labels the SERVER picks (LOCAL_MODEL_LABELS in src/types/local-model.ts), keyed
// by their exact English text: changing a text there means changing its key here.
'Best fit for this Mac': '最适合此 Mac 的选择',
'Recommended for this Mac': '为此 Mac 推荐',
'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.':
'此 Mac 可以运行小型模型。它能处理小幅修改和问答,但不能替代云端模型。',
'Not yet tested by us on a Mac like this.': '我们尚未在类似的 Mac 上测试过。',
'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.':
'最适合的是一个小型模型。它能处理小幅修改和问答,但不能替代云端模型。',
'{model} fits the memory of this Mac too, but its download needs about {missing} GB more free disk space.':
'{model} 也能装入此 Mac 的内存,但下载它还需要再腾出约 {missing} GB 的可用磁盘空间。',
Good: '良好',
Tight: '偏紧',
Slow: '较慢',
'Speed unknown': '速度未知',
'Codeman recommends a local model only on a Mac with Apple silicon.':
'{name} 仅在搭载 Apple 芯片的 Mac 上推荐本地模型。',
'This Mac has an Intel processor. Local coding models need Apple silicon.':
'此 Mac 使用 Intel 处理器。本地编程模型需要 Apple 芯片。',
'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.':
'此 Mac 有 {memory} GB 内存,不足以运行能够驱动编程智能体的模型,因此 {name} 在此不推荐任何模型。',
'Codeman estimates that this Mac can give a model about {budget} GB of memory. The smallest model a coding agent can work with needs about {need} GB. That leaves no safe margin, so Codeman recommends none here.':
'据 {name} 估算,此 Mac 可分配给模型的内存约为 {budget} GB。编程智能体能够使用的最小模型约需 {need} GB。这样没有留出安全余量,因此 {name} 在此不推荐任何模型。',
'Codeman could not read how much memory this Mac has, so it recommends no model here.':
'{name} 无法读取此 Mac 的内存大小,因此在此不推荐任何模型。',
'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.':
'可用磁盘空间不足。适合此 Mac 的最小模型需要下载 {download} GB,下载后还必须保留 {reserve} GB 的可用空间。当前可用空间为 {free} GB。请腾出约 {missing} GB。',
'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.':
'此 Mac 运行的是 macOS {version}。本地模型需要 macOS {minimum} 或更高版本。',
'No coding agent that Codeman can connect to a local model is available on this Mac.':
'此 Mac 上没有 {name} 可以连接到本地模型的编程智能体。',
});
const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value]));
+27
View File
@@ -583,6 +583,14 @@
</div>
<div class="history-list" id="historyList"></div>
</div>
<!-- What the Mac the server runs on can run as a local coding model
(local-model-ui.js). Read-only. Ships `hidden` and only that module
reveals it, once the server has answered for a Mac: a server on
another system never shows it, and the user can hide it per device.
The module writes every string through its own translator call
(model and chip names are not ours to translate), hence
data-i18n-skip. -->
<section class="local-model-card" id="localModelCard" aria-labelledby="localModelCardTitle" data-i18n-skip hidden></section>
<!-- Sessions a human started outside Codeman (foreign-sessions.js).
Hidden until a scan finds one; never rendered empty on a fresh
install, where an empty block reads as a broken feature. -->
@@ -2867,6 +2875,24 @@
</div>
</div>
</div>
<!-- What the Mac the server runs on can run as a local model (local-model-ui.js).
Read-only: the module fills the row from GET /api/local-model, the same body
the welcome card shows, through its own translator call (hence data-i18n-skip
on the row's text). The module shows the group only once the server has
answered for a Mac: it hides it for a server on another system (the title
and hint are about "this Mac"), while the first request is in flight, and
for a non-admin in multi-user mode (the route is admin-only). Not gated on
the endpoints toggle above: the answer does not depend on it. -->
<div class="set-group" id="localModelGroup">
<div class="set-group-head"><h4>Model on this Mac</h4></div>
<p class="set-group-hint">What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.</p>
<div class="set-group-body">
<div class="set-row" data-search="local model mac apple silicon memory llm recommendation offline">
<div class="set-row-text" id="localModelSettings" data-i18n-skip aria-live="polite"></div>
</div>
</div>
</div>
</section>
<!-- ══ Agents &amp; CLIs ═════════════════════════════════════════ -->
@@ -4685,6 +4711,7 @@
<script defer src="foreign-sessions.js"></script>
<script defer src="git-status-ui.js"></script>
<script defer src="edit-history-ui.js"></script>
<script defer src="local-model-ui.js"></script>
<script defer src="entrance-animations.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
+454
View File
@@ -0,0 +1,454 @@
/**
* @fileoverview The "model on this Mac" card of the welcome screen and its row in App Settings.
*
* Reads `GET /api/local-model` (hardware of the machine the server runs on, the recommendation,
* the catalogue date) and shows, read-only:
* - the hardware line (chip, memory, free disk);
* - the verdict: the headline the server sent, then the pick with its numbers (model, harness,
* context, memory need against the budget, download size, speed) and the notes;
* - one line saying that Codeman cannot download or install the model yet: that is the next
* step and is not built. It sits right under the pick, where a reader looks for a button;
* - the other models that fit, each with its fit and speed label. A label says what it
* measures in its tooltip and accessible name ("Good" is the memory fit, not a rating of
* the model), and an alternative with a speed estimate prints the figure.
*
* ## One body, two containers
*
* The welcome card (`#localModelCard`) and the App Settings row (`#localModelSettings`) are
* painted from the same payload by the same builder (`localModelBody`), so the two can never
* name a different pick. They differ only in chrome: the card has a title and a Hide button and
* folds the alternatives; the row lists them open.
*
* ## When the card shows
*
* The card ships `hidden` and appears only once the server has answered for a Mac. It stays
* hidden while the first request is in flight and after a failed one (nothing is known about the
* machine then, and an error card would be the only thing this feature ever showed on a server
* that is not a Mac), when the answer is `not-macos`, when the request is refused (a non-admin in
* multi-user mode gets a `403 FORBIDDEN`, which is an answer: stop asking) and after the user hid
* it on this device.
*
* The Settings group ("Model on this Mac") follows the same knowledge: it is hidden for
* `not-macos` (most servers are not a Mac, and a group about "this Mac" there is false), for a
* refused request and while the first request is in flight (nothing says yet that the server is
* a Mac). It shows the answer for a Mac, and one failure sentence when the first request
* failed. It is the way back to the numbers after a Hide.
*
* "Refused" means the envelope's `errorCode` is `FORBIDDEN`. The auth layer has a second 403 for
* every /api route (`PASSWORD_CHANGE_REQUIRED`, an admin who must change the password first):
* that one is a failed request, asked again on the next show and on `codeman:me`.
*
* Rules:
* - Read-only: no control here downloads, installs or writes anything but the per-device
* dismissal (a localStorage key, never a server setting).
* - Every dynamic string goes in through `textContent`. Model, harness and chip names are
* someone else's text, so both containers carry `data-i18n-skip` and this file translates
* its own sentences at render time. A language or display-name change therefore re-renders
* (`<html lang>` is written by every i18n `configure()`).
* - Names and labels come from the payload: this file holds no list of models or harnesses and
* never compares a CLI id with a literal.
* - Sizes are printed exactly as sent (`needGb`, `downloadGb`, `budgetGb`, `freeDiskGb`:
* decimal GB as text). Bytes are never divided here. The installed memory is the one size
* that is not decimal GB: the hardware line prints `memClassGb`, the size the Mac is sold as
* (48, not 51.5), which is what About This Mac shows.
* - A sentence the server chose arrives as `{ text, vars }` and goes through
* `localModelT(text, vars)`; this file's own strings are literals in that same call, in
* `{placeholder}` form, so the i18n test can harvest them. Both kinds need a zh-CN entry in
* i18n.js. `{name}` is the translator's own slot (the display name) and is never used here.
* - The word "Recommended" is not in this file. Which headline a Mac gets is the server's call.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp)
* @dependency api-client.js (_api)
* @dependency constants.js (CodemanBase, optional)
* @dependency i18n.js (codemanT, optional)
* @loadorder 12.59 of 16, after edit-history-ui.js, before entrance-animations.js
*/
/**
* Per-device dismissal of the welcome card, named for THIS stage (the read-only card). The stage
* that can set a model up takes a key of its own, so hiding a card that offers nothing yet does
* not hide the offer later.
*/
const LOCAL_MODEL_DISMISS_KEY = 'codeman:local-model-dismissed:readonly';
/** The server caches its hardware probe for a minute, so asking again sooner cannot change the answer. */
const LOCAL_MODEL_MAX_AGE_MS = 60000;
/** i18n.js's translator with the `{placeholder}` slots filled, or the English source filled the same way. */
function localModelT(source, vars) {
const values = vars && typeof vars === 'object' ? vars : {};
if (typeof codemanT === 'function') return codemanT(source, values);
return String(source).replace(/\{([a-zA-Z]\w*)\}/g, (_match, key) => String(values[key] ?? ''));
}
/** The route, under the reverse-proxy base path when there is one. */
function localModelUrl(path) {
return typeof CodemanBase !== 'undefined' && CodemanBase ? CodemanBase.url(path) : path;
}
function localModelEl(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}
/** A sentence the server chose: `{ text, vars }` from the payload. */
function localModelMessage(message) {
if (!message || typeof message.text !== 'string') return '';
return localModelT(message.text, message.vars);
}
/** A token count as people write it (131,072). */
function localModelCount(value) {
return Number.isFinite(value) ? Number(value).toLocaleString('en-US') : String(value ?? '');
}
/** Tokens per second: whole from 10, one decimal below. Empty when the server sent none. */
function localModelSpeed(value) {
if (!Number.isFinite(value) || value <= 0) return '';
return String(value >= 10 ? Math.round(value) : Math.round(value * 10) / 10);
}
/** Whether the page already knows the route will refuse it (a non-admin in multi-user mode). */
function localModelNotAdmin() {
const me = (typeof window !== 'undefined' && window.__codemanUser) || {};
return !!(me.multiUser && me.role !== 'admin');
}
/**
* Chip, memory and free disk in one line. The memory is the size the Mac is sold as
* (`memClassGb`: 48 for a Mac whose 48 x 2^30 bytes are 51.5 decimal GB), a number or nothing:
* an unknown size (0) is left out, never printed.
*/
function localModelHardwareLine(hw) {
const chip = String(hw.chip || hw.model || '');
const memory = Number.isFinite(hw.memClassGb) && hw.memClassGb > 0 ? String(hw.memClassGb) : '';
const sizes = { chip, memory, free: hw.freeDiskGb };
if (!memory) return chip;
if (chip && hw.freeDiskGb) return localModelT('{chip}, {memory} GB memory, {free} GB free', sizes);
if (chip) return localModelT('{chip}, {memory} GB memory', sizes);
if (hw.freeDiskGb) return localModelT('{memory} GB memory, {free} GB free', sizes);
return localModelT('{memory} GB memory', sizes);
}
/** What the fit label measures: the share of the memory budget the model needs. */
function localModelFitHint(candidate) {
if (!Number.isFinite(candidate.budgetShare)) return '';
const share = String(Math.round(candidate.budgetShare * 100));
return localModelT('Memory fit: needs {share}% of what this Mac can give a model', { share });
}
/** What the speed label stands for: the estimate behind "Slow", or that there is none. */
function localModelSpeedHint(candidate) {
const speed = localModelSpeed(candidate.tokensPerSecond);
if (speed) return localModelT('About {speed} tokens per second (estimated).', { speed });
return localModelT('No speed estimate for this model on this chip');
}
/**
* "Qwen3.8 27B (qwen3.8:27b) with OpenCode", then the fit and speed labels the server chose.
* A bare "Good" next to a model name reads as a rating of the model, so each label carries what
* it measures as its tooltip and its accessible name.
*/
function localModelCandidateHead(candidate) {
const head = localModelEl('div', 'local-model-name-row');
const name = localModelT('{model} ({id}) with {harness}', {
model: candidate.modelLabel || candidate.modelId,
id: candidate.modelId,
harness: candidate.cliLabel || candidate.cli,
});
head.appendChild(localModelEl('span', 'local-model-name', name));
const pills = [
[candidate.fitLabel, localModelFitHint],
[candidate.speedLabel, localModelSpeedHint],
];
for (const [label, hint] of pills) {
if (typeof label !== 'string' || !label) continue;
const pill = localModelEl('span', 'local-model-pill', localModelT(label));
const meaning = hint(candidate);
if (meaning) {
pill.title = meaning;
pill.setAttribute('role', 'note');
pill.setAttribute('aria-label', `${pill.textContent}. ${meaning}`);
}
head.appendChild(pill);
}
return head;
}
/**
* The dim line under a candidate's name: context, then for an alternative its memory need and,
* when there is an estimate, its speed (so "Slow" comes with a number on a screen without
* hover), the download, and for the pick the license.
*/
function localModelMeta(candidate, { alternative = false } = {}) {
const bits = [localModelT('Context {context} tokens', { context: localModelCount(candidate.context) })];
if (alternative && candidate.needGb) bits.push(localModelT('{need} GB of memory', { need: candidate.needGb }));
if (candidate.downloadGb) bits.push(localModelT('{download} GB download', { download: candidate.downloadGb }));
const speed = alternative ? localModelSpeed(candidate.tokensPerSecond) : '';
if (speed) bits.push(localModelT('about {speed} tokens per second', { speed }));
if (!alternative && candidate.license) bits.push(String(candidate.license));
return bits.join(' · ');
}
/** The pick with its numbers. */
function localModelPick(pick, recommendation) {
const box = localModelEl('div', 'local-model-pick');
box.appendChild(localModelCandidateHead(pick));
box.appendChild(localModelEl('div', 'local-model-line', localModelMeta(pick)));
const sizes = { need: pick.needGb, budget: recommendation.budgetGb };
// A wired limit is a setting of this Mac; anything else is the formula's estimate, and says so.
const need =
recommendation.budgetSource === 'wired-limit'
? localModelT('Needs about {need} GB of the {budget} GB this Mac can give a model.', sizes)
: localModelT('Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).', sizes);
box.appendChild(localModelEl('div', 'local-model-line', need));
const speed = localModelSpeed(pick.tokensPerSecond);
if (speed) {
const line = localModelT('About {speed} tokens per second (estimated).', { speed });
box.appendChild(localModelEl('div', 'local-model-line', line));
}
const harness = pick.cliLabel || pick.cli;
if (pick.cliInstalled === false) {
const line = localModelT('{harness} is not installed on this Mac yet.', { harness });
box.appendChild(localModelEl('div', 'local-model-line', line));
}
if (pick.slowFirstTurn === true) {
const line = localModelT('With {harness}, the first answer can take minutes.', { harness });
box.appendChild(localModelEl('div', 'local-model-line', line));
}
return box;
}
/**
* The other models that fit. Folded behind a native disclosure on the welcome card, open in
* Settings. No count beside the title: the server lists four at most, so the number of rows is
* not the number of models that fit.
*/
function localModelAlternatives(alternatives, fold) {
const wrap = localModelEl(fold ? 'details' : 'div', 'local-model-alts');
const title = localModelEl(fold ? 'summary' : 'div', 'local-model-alts-title', localModelT('Other models that fit'));
wrap.appendChild(title);
for (const candidate of alternatives) {
const row = localModelEl('div', 'local-model-alt');
row.appendChild(localModelCandidateHead(candidate));
row.appendChild(localModelEl('div', 'local-model-line', localModelMeta(candidate, { alternative: true })));
wrap.appendChild(row);
}
return wrap;
}
/**
* Everything the server said, in reading order. The ONLY builder: the card and the Settings row
* both call it, see @fileoverview.
*/
function localModelBody(status, { fold }) {
const hardware = status.hardware || {};
const recommendation = status.recommendation || {};
const body = localModelEl('div', 'local-model-body');
// Not a Mac: one plain sentence, and no line calling another machine's parts "this Mac".
const hardwareLine = recommendation.reason === 'not-macos' ? '' : localModelHardwareLine(hardware);
if (hardwareLine) body.appendChild(localModelEl('div', 'local-model-hw', hardwareLine));
const headline = localModelMessage(recommendation.headline);
if (headline) body.appendChild(localModelEl('div', 'local-model-headline', headline));
const pick = recommendation.verdict === 'none' ? null : recommendation.pick;
if (pick) body.appendChild(localModelPick(pick, recommendation));
for (const note of Array.isArray(recommendation.notes) ? recommendation.notes : []) {
const text = localModelMessage(note);
if (text) body.appendChild(localModelEl('div', 'local-model-note', text));
}
// Right under the pick and its notes, above the list: the pick names a download and maybe a
// harness that is not installed, so this is where a reader looks for a button.
if (pick) {
const next = localModelT(
'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.'
);
body.appendChild(localModelEl('div', 'local-model-next', next));
}
const alternatives = pick && Array.isArray(recommendation.alternatives) ? recommendation.alternatives : [];
if (alternatives.length) body.appendChild(localModelAlternatives(alternatives, fold));
return body;
}
/** The `data` of the GET, or null for anything that is not one. */
function localModelPayload(body) {
const data = body && body.success === true ? body.data : null;
const recommendation = data && typeof data === 'object' ? data.recommendation : null;
if (!recommendation || typeof recommendation !== 'object' || typeof recommendation.verdict !== 'string') return null;
return data;
}
Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
// Data
// ═══════════════════════════════════════════════════════════════
/**
* Ask the server what this Mac can run, at most once a minute, and paint both containers.
* Called when the welcome screen shows and when App Settings opens. Idempotent: a request in
* flight is shared, and a fresh answer is only re-rendered.
*/
loadLocalModel() {
this._wireLocalModel();
if (this._localModelRefused()) {
this.renderLocalModel();
return Promise.resolve();
}
if (this._localModelInFlight) return this._localModelInFlight;
const fresh = this._localModelStatus && Date.now() - (this._localModelLoadedAt || 0) < LOCAL_MODEL_MAX_AGE_MS;
if (fresh) {
this.renderLocalModel();
return Promise.resolve();
}
// An answer already on screen stays there while it is refreshed.
if (!this._localModelStatus) this._localModelState = 'loading';
this.renderLocalModel();
this._localModelInFlight = this._fetchLocalModel().finally(() => {
this._localModelInFlight = null;
});
return this._localModelInFlight;
},
async _fetchLocalModel() {
// `_api`, not `_apiJson`: a refusal (admin-only in multi-user mode) is an answer and must be
// told apart from a request that failed.
const res = await this._api(localModelUrl('/api/local-model'));
let body = null;
if (res?.ok || res?.status === 403) {
try {
body = await res.json();
} catch {
body = null;
}
}
// Only the route's own refusal is final. The auth layer answers 403 too, with
// PASSWORD_CHANGE_REQUIRED, for an admin who must change the password first: that is a
// failed request, asked again once the password is changed.
if (res?.status === 403 && body?.errorCode === 'FORBIDDEN') {
this._localModelState = 'refused';
this._localModelStatus = null;
this.renderLocalModel();
return;
}
const data = res?.ok ? localModelPayload(body) : null;
if (!data) {
// A failed refresh must not blank an answer the user is reading: keep the last good one.
if (!this._localModelStatus) this._localModelState = 'failed';
this.renderLocalModel();
return;
}
this._localModelStatus = data;
this._localModelState = 'ready';
this._localModelLoadedAt = Date.now();
this.renderLocalModel();
},
// ═══════════════════════════════════════════════════════════════
// Dismissal (per device)
// ═══════════════════════════════════════════════════════════════
isLocalModelCardDismissed() {
if (this._localModelDismissed === true) return true;
try {
return localStorage.getItem(LOCAL_MODEL_DISMISS_KEY) === '1';
} catch {
return false;
}
},
/** Hide the welcome card on this device. The Settings row keeps showing the same answer. */
dismissLocalModelCard() {
// Remembered on the instance too: with storage blocked the card still stays away until a reload.
this._localModelDismissed = true;
try {
localStorage.setItem(LOCAL_MODEL_DISMISS_KEY, '1');
} catch {
/* storage blocked: hidden for this page load only */
}
this.renderLocalModel();
},
// ═══════════════════════════════════════════════════════════════
// Render
// ═══════════════════════════════════════════════════════════════
/** Paint every mounted container (welcome card + App Settings row). */
renderLocalModel() {
const card = document.getElementById('localModelCard');
if (card) this._renderLocalModelCard(card);
const row = document.getElementById('localModelSettings');
if (row) this._renderLocalModelSettings(row);
},
_localModelRefused() {
return this._localModelState === 'refused' || localModelNotAdmin();
},
_renderLocalModelCard(card) {
// The one piece of state the card holds itself: whether the alternatives were unfolded.
const wasOpen = card.querySelector('details')?.open === true;
card.textContent = '';
const status = this._localModelStatus;
const hidden =
!status ||
this._localModelRefused() ||
status.recommendation.reason === 'not-macos' ||
this.isLocalModelCardDismissed();
card.hidden = hidden;
if (hidden) return;
const head = localModelEl('div', 'local-model-head');
const title = localModelEl('h3', 'local-model-title', localModelT('Model on this Mac'));
title.id = 'localModelCardTitle';
// The page can be open on another laptop or a tablet: "this Mac" is the server's.
title.title = localModelT('The Mac that Codeman runs on, which is not always the device you are reading on.');
head.appendChild(title);
const hide = localModelEl('button', 'local-model-hide', localModelT('Hide'));
hide.type = 'button';
hide.title = localModelT('Hide this card on this device. It stays in App Settings, under Models.');
hide.addEventListener('click', () => this.dismissLocalModelCard());
head.appendChild(hide);
card.appendChild(head);
const box = localModelEl('div', 'local-model-box');
box.appendChild(localModelBody(status, { fold: true }));
card.appendChild(box);
const details = card.querySelector('details');
if (details && wasOpen) details.open = true;
},
_renderLocalModelSettings(row) {
row.textContent = '';
const status = this._localModelStatus;
const failed = !status && this._localModelState === 'failed';
// The whole group goes for a non-admin, the way the other admin-only groups of this modal
// do, and for a server that is not a Mac: its title and hint are about "this Mac". Until
// the first answer nothing says the server is one, so the group waits for it.
const shown = !this._localModelRefused() && (failed || (!!status && status.recommendation.reason !== 'not-macos'));
const group = document.getElementById('localModelGroup');
if (group) group.style.display = shown ? '' : 'none';
if (!shown) return;
if (status) {
row.appendChild(localModelBody(status, { fold: false }));
return;
}
row.appendChild(localModelEl('div', 'local-model-headline', localModelT('Could not reach the server.')));
},
/** Listeners that outlive a render, wired once. */
_wireLocalModel() {
if (this._localModelWired) return;
this._localModelWired = true;
// Both containers are data-i18n-skip (see @fileoverview), so the DOM translator never
// revisits them: a language or display-name change has to repaint them from here.
if (typeof MutationObserver === 'function' && document.documentElement) {
const observer = new MutationObserver(() => this.renderLocalModel());
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] });
}
// The real role can arrive after the first render (admin-ui.js fetches /api/me): a non-admin
// loses the card and the group, and an admin the page had taken for one is asked for at last.
document.addEventListener?.('codeman:me', () => void this.loadLocalModel());
},
});
+21
View File
@@ -1857,6 +1857,17 @@ html.mobile-init .file-browser-panel {
margin-top: 0.75rem;
}
/* Model on this Mac (local-model-ui.js). Only reached with the phone overview
switched off. The card has no fixed width and every line in it wraps, so
the narrow column only takes some of the spacing back. */
.local-model-card {
margin-top: 0.6rem;
}
.local-model-box {
padding: 0.5rem 0.55rem;
}
/* Ralph wizard responsive */
.modal-wizard {
display: flex;
@@ -3969,6 +3980,16 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
max-width: none;
}
/* Model on this Mac (local-model-ui.js): its row follows the label size above. */
#localModelSettings .local-model-headline,
#localModelSettings .local-model-name {
font-size: 0.84rem;
}
#localModelSettings .local-model-alt .local-model-name {
font-size: 0.78rem;
}
/* Fields go full width under their label instead of fighting for the row */
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row.has-field {
flex-direction: column;
+3
View File
@@ -550,6 +550,9 @@ Object.assign(CodemanApp.prototype, {
// Assigning .checked above does not fire onchange, so the body's visibility
// (and its lazy load) needs an explicit sync on every open, not just a save.
this.applyCustomModelEndpointsVisibility();
// Model on this Mac (local-model-ui.js): a read-only row, not a setting. Server state,
// asked for at most once a minute; nothing here is loaded or saved by id.
this.loadLocalModel?.();
// CLI management (docs/cli-enable-disable-plan.md): synced, default OFF.
document.getElementById('appSettingsCliManagement').checked = settings.cliManagementEnabled === true;
// Same reasoning as applyCustomModelEndpointsVisibility above: assigning
+190
View File
@@ -22303,6 +22303,196 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
line-height: 1.35;
}
/* ═══════════════════════════════════════════════════════════════
Model on this Mac (local-model-ui.js): what the server's Mac can run as
a local coding model. One body (`.local-model-body`), drawn in two places:
the welcome card and a row of App Settings > Models.
Same vocabulary as the two blocks above it on the welcome screen: a dim
heading row, then one bordered surface with a name over dim meta lines.
Theme tokens only (`--control-bg`, never a white alpha: that fill is
invisible on the light skins), and the fit and speed labels are neutral
pills: the words carry the meaning, so no skin needs a colour of its own.
Nothing here is keyed on a CLI or a model id, and nothing animates.
═══════════════════════════════════════════════════════════════ */
.local-model-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
width: 100%;
margin-top: 0.75rem;
text-align: left;
}
/* A flex container, so `[hidden]` needs re-asserting or the module's only
visibility lever does nothing (same trap as .foreign-sessions). */
.local-model-card[hidden] {
display: none;
}
.local-model-head {
display: flex;
align-items: center;
gap: 0.5rem;
}
.local-model-title {
margin: 0;
font-size: 0.85rem;
color: var(--text-dim);
font-weight: 500;
}
.local-model-hide {
margin-left: auto;
font: inherit;
font-size: 0.68rem;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.12rem 0.5rem;
cursor: pointer;
}
.local-model-hide:hover {
color: var(--text);
background: var(--control-bg-hover);
}
.local-model-box {
padding: 0.55rem 0.65rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--control-bg);
min-width: 0;
}
.local-model-body {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
/* The line that says nothing can be set up from here yet (.local-model-next) is
the one a reader must not miss, so it reads like every other line: same size,
same --text-dim. --text-muted on this surface was 3.9:1. */
.local-model-hw,
.local-model-line,
.local-model-note,
.local-model-next {
font-size: 0.68rem;
line-height: 1.45;
color: var(--text-dim);
overflow-wrap: anywhere;
}
.local-model-headline {
font-size: 0.82rem;
line-height: 1.45;
color: var(--text);
overflow-wrap: anywhere;
}
/* The pick is the one thing in the card set apart: a rule in the accent, on the
reading side (logical, so it follows a right-to-left page). */
.local-model-pick {
display: flex;
flex-direction: column;
gap: 0.12rem;
padding-inline-start: 0.55rem;
border-inline-start: 2px solid var(--accent);
}
.local-model-name-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.2rem 0.4rem;
min-width: 0;
}
.local-model-name {
font-size: 0.82rem;
font-weight: 600;
color: var(--text);
overflow-wrap: anywhere;
min-width: 0;
}
/* Each pill carries what it measures as its tooltip (a title), hence the cursor. */
.local-model-pill {
flex: 0 0 auto;
font-size: 0.62rem;
line-height: 1.5;
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0 0.42rem;
white-space: nowrap;
cursor: help;
}
.local-model-alts-title {
font-size: 0.72rem;
color: var(--text-dim);
}
/* The welcome card folds the list behind a native disclosure (keyboard and
screen-reader behaviour for free); the summary keeps `display: list-item`
so its marker stays. */
.local-model-alts > summary {
cursor: pointer;
}
.local-model-alts > summary:hover {
color: var(--text);
}
.local-model-alt {
display: flex;
flex-direction: column;
gap: 0.1rem;
margin-top: 0.3rem;
padding-top: 0.3rem;
border-top: 1px solid var(--border);
}
.local-model-alt .local-model-name {
font-size: 0.76rem;
font-weight: 500;
}
/* In App Settings the body sits inside an ordinary .set-row, so it takes that
surface's type scale (label 0.76rem over a muted description) and the row
supplies the border. */
#localModelSettings {
padding: 3px 0;
}
#localModelSettings .local-model-headline,
#localModelSettings .local-model-name {
font-size: 0.76rem;
}
#localModelSettings .local-model-alt .local-model-name {
font-size: 0.72rem;
}
/* Touch: the two things in the card that take a press get a finger's height. */
@media (pointer: coarse) {
.local-model-hide {
min-height: 32px;
padding: 0.3rem 0.8rem;
}
.local-model-alts > summary {
padding: 0.4rem 0;
}
}
/* XLSX preview in the file-preview overlay (spreadsheet-preview.js). Read-only; cells are
absolutely positioned inside a virtualized grid, so only the visible tile is in the DOM. */
.spreadsheet-preview-shell {
+3
View File
@@ -2964,6 +2964,9 @@ Object.assign(CodemanApp.prototype, {
// up (stopped in hideWelcome), see foreign-sessions.js.
this.wireForeignSessions?.();
this.startForeignPolling?.();
// What this Mac can run as a local model (local-model-ui.js). One request,
// at most once a minute, no timer: nothing to stop in hideWelcome.
this.loadLocalModel?.();
}
// Home screen has no input target — hide the CJK textarea (activeSessionId
// is null by the time we get here). Guarded: defined on the app object.
+1
View File
@@ -49,5 +49,6 @@ export {
type CustomModelSwapDisplacement,
} from './custom-model-routes.js';
export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js';
export { registerLocalModelRoutes } from './local-model-routes.js';
export { registerAgentSessionRoutes } from './agent-session-routes.js';
+115
View File
@@ -0,0 +1,115 @@
/**
* @fileoverview `GET /api/local-model`: what this Mac is and which local model fits 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.
*
* 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.
*
* Rules:
* - Read-only. It downloads nothing, installs nothing, writes no file and reads no setting.
* - 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`. 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,
type LocalModelCatalog,
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';
/** What the route calls. 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;
}
export const defaultLocalModelRouteDeps: LocalModelRouteDeps = {
readHardware: () => readHardware(),
getCatalog: getLocalModelCatalog,
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 };
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');
}
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)}`
);
}
}
);
}
+2
View File
@@ -264,6 +264,7 @@ import {
pruneIdleLlamaSwapLogTails,
tryWebviewRefererFallback,
registerCliRegistryRoutes,
registerLocalModelRoutes,
} from './routes/index.js';
import { isLostWebviewFrameNavigation } from './webview-proxy.js';
import { CronService } from '../cron/cron-service.js';
@@ -1417,6 +1418,7 @@ export class WebServer extends EventEmitter {
registerDoctorRoutes(this.app);
registerCustomModelRoutes(this.app);
registerCliRegistryRoutes(this.app);
registerLocalModelRoutes(this.app);
// Cron: build the service from the same context, recompute
// due times for any persisted jobs, then expose it to its routes.