mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
Merge branch 'feat/local-model-recommendation' into test/desktop-macbook
This commit is contained in:
commit
bce9fde8df
32 files changed
+7427
-4
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user