mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +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
@@ -192,6 +192,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL, read-only stage): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ **Read-only**: it downloads nothing, installs nothing, writes no file and adds no settings key; the install flow is a later stage and nothing here may grow a write before it. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested` and is sent only when the pick's model has a `tested` entry for exactly this chip, memory size and harness; no row has one today. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Card rules: `src/web/CLAUDE.md` (Model on this Mac). → `docs/api-reference.md` (Local model recommendation), `docs/versioning-policy.md`
|
||||
|
||||
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
|
||||
|
||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) and a Discord reply (`DiscordBridge.onGatewayMessage`, through the bridge's `sendInput`) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||
|
||||
@@ -1676,6 +1676,41 @@ Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge cre
|
||||
|
||||
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
|
||||
|
||||
## Local model recommendation
|
||||
|
||||
`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, and with which agent CLI. It covers Apple silicon macOS only; anywhere else the answer is still a `200`, with verdict `none`. **Read-only:** the route downloads nothing, installs nothing, writes no file and reads or changes no setting (setting a model up from Codeman is a later step and is not built yet). It names the host's hardware, so it is admin only in multi-user mode (`403`, answered before anything is probed). The models, their sizes and the agent CLIs it considers are a catalogue compiled into the release; there is no override file.
|
||||
|
||||
**Experimental.** This endpoint and its payload are labeled experimental until the step that sets a model up ships: that step will add to and may change the fields below, the verdicts and the sentences, in a MINOR or PATCH release (see [versioning-policy.md](versioning-policy.md), "What SemVer does NOT cover", item 3). Do not build on its shape yet.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| ------ | ------------------ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated }`. `403 FORBIDDEN` for a non-admin in multi-user mode. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. |
|
||||
|
||||
A probe that fails is an answer, not an error: a server that is not on macOS, or a Mac whose `sysctl` call failed or timed out, still gets a `200`, and `hardware.source` says what was read.
|
||||
|
||||
`data`:
|
||||
|
||||
- `hardware`: what the server sees of the machine it runs on.
|
||||
- `platform`, `arch`: `process.platform` and `os.arch()` of the server process. `arm64: true` means the machine is Apple silicon whatever `arch` says; `translated: true` means the server process runs under Rosetta.
|
||||
- `memBytes`, `memGb` (the bytes as decimal GB text, `"51.5"` for a 48 GB Mac) and `memClassGb` (the memory size the Mac is sold as: 16, 32, 48; `0` when the size could not be read). `memClassGb` is what to show as the installed memory: it is the number on the box and in About This Mac. It is the one size here that is not decimal GB.
|
||||
- `chip`, `model`, `macosVersion`, `macosMajor`, `wiredLimitMb` (`0` means not set), `freeDiskBytes` and `freeDiskGb` (free space on the filesystem of Codeman's data directory). Each is absent when it could not be read.
|
||||
- `source`: `sysctl` (the one bounded `sysctl` call answered), `os-fallback` (macOS, but that call failed: memory, architecture, the chip name and the free disk space come from Node, and nothing only `sysctl` knows, such as `arm64`, `translated`, `model`, `macosVersion`, `macosMajor` or `wiredLimitMb`, is present) or `not-probed` (not macOS, nothing was run).
|
||||
- `recommendation`:
|
||||
- `verdict`: `ok`, `limited` or `none`. `limited` is a statement about the Mac: no model with 12 billion parameters or more fits its memory, so what it can run handles small edits and questions and does not replace a cloud model. It is judged on every model that fits, before the free disk space is looked at and whatever the pick is. With `ok` the pick can still be a small model (a larger one may fit only tightly, have no speed figure or have no room on the disk); `notes` then says so.
|
||||
- `reason`, present only with `none`: `not-macos`, `intel`, `memory`, `disk`, `macos` (too old) or `no-harness` (no agent CLI that can be connected to a local model is installed or installable). `memory` has three sentences: nothing fits at all (slot `memory`, the size the Mac is sold as), a model fits but only below the context length an agent CLI needs (slots `budget` and `need`, so a Mac that misses by a little reads as that), and the memory size could not be read (no slot).
|
||||
- `headline` and `notes[]`: `{ text, vars? }`. `text` is an English sentence that may hold `{placeholder}` slots, and `vars` carries the slot values as text. Notes, in order: the pick is a small model although larger ones fit (verdict `ok` only); a model that fits the memory was left out because its download would not leave 10 GiB free on the disk (slots `model` and `missing`: the best-ranked such model and the GB to free up); nobody tested the pick on a Mac like this one.
|
||||
- `pick` (absent with `none`) and `alternatives[]` (at most four, best first; empty with `none`), each one model at one context length with one agent CLI. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing):
|
||||
- `modelId` (`name:tag`), `modelLabel`, `license`, `paramsB` (parameters in billions), `context` (tokens);
|
||||
- `cli`, `cliLabel`, `cliInstalled` (`false`: the CLI is not on this machine yet but can be installed without administrator rights), `slowFirstTurn?`;
|
||||
- `needBytes` and `needGb` (memory the model needs at that context), `downloadBytes` and `downloadGb`, `budgetShare` (need divided by `budgetBytes`, rounded to three decimals);
|
||||
- `fit`: `good` (up to 0.85 of the budget) or `tight` (up to 0.98), with its label `fitLabel`;
|
||||
- `speed`: `ok`, `slow` (under 10 tokens per second) or `unknown`, with `speedLabel` (absent for `ok`) and `tokensPerSecond` (rounded to one decimal, absent for `unknown`; `speed` is decided on the unrounded figure, so `10` can be `slow`). `unknown` means the chip is in no row of the catalogue's chip table, or the catalogue has no speed figure for the model: a speed is never guessed;
|
||||
- `tested`: we ran this model with this CLI on a Mac like this one. No catalogue row has such an entry yet, so it is `false` today.
|
||||
- `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `wired-limit` (taken from `iogpu.wired_limit_mb`) or `estimated` (computed from the installed memory).
|
||||
- `catalogUpdated`: ISO date on which the catalogue's rows were last checked.
|
||||
|
||||
Every computed size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. The installed memory is the exception named above: show `memClassGb`.
|
||||
|
||||
## Voice dictation
|
||||
|
||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||
|
||||
@@ -86,6 +86,19 @@ covered like any other stable behavior. Other agent commands and API routes keep
|
||||
their existing guarantees. Promotion to stable requires an explicit documented
|
||||
decision.
|
||||
|
||||
### Experimental local model recommendation
|
||||
|
||||
`GET /api/v1/local-model` (and its `/api` alias) is labeled experimental, so item 3
|
||||
above applies to it: its path, its payload (the hardware facts, the verdicts and
|
||||
reasons, the candidate fields and the English sentences in `headline` and
|
||||
`notes`) and its behavior may change or be removed in a MINOR or PATCH release.
|
||||
It is read-only today: it looks at the Mac the server runs on and says which
|
||||
local model fits. The step that sets a model up from Codeman is not built yet
|
||||
and will change what the route reports, which is why the shape is not promised.
|
||||
It is not behind a setting (it writes nothing and adds no settings key); it is
|
||||
admin only in multi-user mode. Promotion to stable requires an explicit
|
||||
documented decision.
|
||||
|
||||
## Deprecation policy
|
||||
|
||||
When we need to change a covered surface:
|
||||
|
||||
@@ -193,6 +193,20 @@ attached.
|
||||
section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
|
||||
instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-Endpoints).
|
||||
|
||||
**Model on this Mac** is not a setting: it is a read-only answer. When the Codeman server
|
||||
runs on a Mac with Apple silicon, it shows that Mac's chip, memory and free disk space, the
|
||||
local coding model that fits it best with the agent CLI to run it, what the model needs of
|
||||
the memory the Mac can give one, its download size and an estimated speed, and the other
|
||||
models that fit (Good or Tight for the memory, Slow or Speed unknown for the speed). A Mac
|
||||
that can only run a model under 12 billion parameters is told so in plain words, and a Mac
|
||||
with too little memory, too little free disk space or an Intel processor is told why nothing
|
||||
is offered. The numbers are estimates from a catalogue that ships with each release; nothing
|
||||
has been tested on your Mac. **Nothing is downloaded or installed from here yet**: setting
|
||||
the model up from Codeman is the next step and is not built. The same answer appears as a
|
||||
card on the welcome screen, which you can hide per device (it stays here). The group is not
|
||||
shown when the server runs on Linux or Windows, and in multi-user mode only an admin sees
|
||||
it. The "Mac" is always the machine the server runs on, not the device you are reading on.
|
||||
|
||||
### Agents & CLIs
|
||||
|
||||
| Setting | Notes |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -167,7 +167,6 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
|
||||
),
|
||||
"tui/tui-render.ts::mode !== 'claude'": allow(1, 'cosmetic: suppress the mode badge for the default mode'),
|
||||
|
||||
|
||||
// Legacy <Mode>Config plumbing, same category as the `===` entries above.
|
||||
"web/routes/session-routes.ts::mode !== 'omp'": allow(
|
||||
2,
|
||||
@@ -212,8 +211,10 @@ const ID_ALT = IDS.join('|');
|
||||
*/
|
||||
const BRANCH_PATTERN = new RegExp(
|
||||
[
|
||||
// mode === 'codex' / mode !== 'codex'
|
||||
`\\b(?:mode|id|agentType)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
|
||||
// mode === 'codex' / mode !== 'codex'. `cli` is the name the local model catalogue gives
|
||||
// the same thing (`LocalHarnessEntry.cli`, `Candidate.cli`), so `harness.cli === 'claude'`
|
||||
// is the same banned branch under another field name.
|
||||
`\\b(?:mode|id|agentType|cli)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
|
||||
// case 'codex':
|
||||
`\\bcase\\s+'(?:${ID_ALT})'\\s*:`,
|
||||
// ['codex', 'gemini'].includes(mode) — the id list IS the branch, wherever `mode` sits
|
||||
@@ -267,7 +268,7 @@ function scan(): { findings: Finding[]; filesScanned: number } {
|
||||
lines.forEach((line, i) => {
|
||||
BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts
|
||||
for (const match of line.matchAll(BRANCH_PATTERN)) {
|
||||
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType)/, 'mode');
|
||||
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType|cli)/, 'mode');
|
||||
const posix = rel.split(sep).join('/');
|
||||
findings.push({ file: posix, expression, line: i + 1, key: `${posix}::${expression}` });
|
||||
}
|
||||
@@ -337,6 +338,8 @@ describe('no CLI-id branching outside the stock catalog', () => {
|
||||
"if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }",
|
||||
"switch (mode) { case 'gemini': return 1; }",
|
||||
"if (['codex', 'gemini'].includes(mode)) { doSomething(); }",
|
||||
"if (harness.cli === 'claude') { doSomething(); }",
|
||||
"const others = candidates.filter((c) => c.cli !== 'opencode');",
|
||||
];
|
||||
for (const sample of samples) {
|
||||
BRANCH_PATTERN.lastIndex = 0;
|
||||
|
||||
@@ -38,6 +38,9 @@ const SCANNED_FILES = [
|
||||
'panels-ui.js',
|
||||
// A row's mode comes from the same catalog, never from a list of ids here.
|
||||
'foreign-sessions.js',
|
||||
// The harness a local model is paired with arrives as a label in the payload
|
||||
// (`Candidate.cliLabel`); the card names no CLI and compares no id.
|
||||
'local-model-ui.js',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @fileoverview Tests for src/config/local-model-catalog.ts: the compiled catalogue of models,
|
||||
* harnesses and chips, its schema, and the chip lookup.
|
||||
*
|
||||
* Three things are pinned:
|
||||
*
|
||||
* 1. The stock constant PARSES. `getLocalModelCatalog()` validates it on first use and throws on
|
||||
* a bad row, so this test is what keeps a bad row from ever reaching the route.
|
||||
* 2. The schema refuses what must never get in: a model id that could name another registry
|
||||
* (a slash, a host), an unknown key (a `runtime` block with a download URL), and rows that
|
||||
* break a rule across rows (a repeated id, contexts in the wrong order, a chip row that
|
||||
* hides a more specific one).
|
||||
* 3. The chip lookup never guesses: a chip that no row names has no bandwidth, including one
|
||||
* whose name merely contains a known name ('Apple M10', 'Apple M6 Pro').
|
||||
*
|
||||
* Port: N/A (pure data and functions).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
chipBandwidthGbps,
|
||||
chipNameMatches,
|
||||
getLocalModelCatalog,
|
||||
LOCAL_MODEL_ID_PATTERN,
|
||||
LocalModelCatalogSchema,
|
||||
STOCK_LOCAL_MODEL_CATALOG,
|
||||
} from '../src/config/local-model-catalog.js';
|
||||
import type { LocalModelCatalog, LocalModelEntry } from '../src/types.js';
|
||||
|
||||
/** A deep copy of the stock catalogue that a test may break. */
|
||||
function copy(): LocalModelCatalog {
|
||||
return structuredClone(STOCK_LOCAL_MODEL_CATALOG);
|
||||
}
|
||||
|
||||
/** The message of every issue the schema raises for `catalog`, or an empty list when it parses. */
|
||||
function issuesOf(catalog: unknown): string[] {
|
||||
const result = LocalModelCatalogSchema.safeParse(catalog);
|
||||
return result.success ? [] : result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`);
|
||||
}
|
||||
|
||||
/** The stock catalogue with its first model changed. */
|
||||
/** `url` is no field of a model row: one test passes it to prove the schema refuses an unknown key. */
|
||||
function withFirstModel(change: Partial<Record<keyof LocalModelEntry | 'url', unknown>>): unknown {
|
||||
const catalog = copy();
|
||||
return { ...catalog, models: [{ ...catalog.models[0], ...change }, ...catalog.models.slice(1)] };
|
||||
}
|
||||
|
||||
describe('the stock catalogue', () => {
|
||||
it('passes its own schema', () => {
|
||||
expect(issuesOf(STOCK_LOCAL_MODEL_CATALOG)).toEqual([]);
|
||||
});
|
||||
|
||||
it('is what the getter returns, validated once', () => {
|
||||
const first = getLocalModelCatalog();
|
||||
expect(first).toEqual(STOCK_LOCAL_MODEL_CATALOG);
|
||||
// Parsed on first use, then reused: the same object, not a new parse per request.
|
||||
expect(getLocalModelCatalog()).toBe(first);
|
||||
});
|
||||
|
||||
it('lists the seven models of the design, without flint:40b', () => {
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => m.id)).toEqual([
|
||||
'qwen3.5:9b',
|
||||
'qwen3.5:9b-q8_0',
|
||||
'gpt-oss:20b',
|
||||
'qwen3.6:35b-a3b',
|
||||
'qwen3.8:27b',
|
||||
'qwen3.8:27b-q8_0',
|
||||
'qwen3.5:122b-a10b',
|
||||
]);
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.models.some((m) => m.id.startsWith('flint'))).toBe(false);
|
||||
});
|
||||
|
||||
it('carries the byte sizes of the registry manifests', () => {
|
||||
// The sum of the layer sizes of each tag's manifest, as fetched on 2026-11-06.
|
||||
expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.bytes]))).toEqual({
|
||||
'qwen3.5:9b': 6_550_825_373,
|
||||
'qwen3.5:9b-q8_0': 10_449_217_949,
|
||||
'gpt-oss:20b': 13_793_441_427,
|
||||
'qwen3.6:35b-a3b': 22_621_314_161,
|
||||
'qwen3.8:27b': 17_741_871_939,
|
||||
'qwen3.8:27b-q8_0': 29_978_241_837,
|
||||
'qwen3.5:122b-a10b': 81_370_035_877,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the KV cost per token of each model', () => {
|
||||
expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.kvBytesPerToken]))).toEqual({
|
||||
'qwen3.5:9b': 32_768,
|
||||
'qwen3.5:9b-q8_0': 32_768,
|
||||
'gpt-oss:20b': 24_576,
|
||||
'qwen3.6:35b-a3b': 20_480,
|
||||
'qwen3.8:27b': 65_536,
|
||||
'qwen3.8:27b-q8_0': 65_536,
|
||||
'qwen3.5:122b-a10b': 24_576,
|
||||
});
|
||||
});
|
||||
|
||||
it('gives every model a parameter count and an empty tested list', () => {
|
||||
for (const model of STOCK_LOCAL_MODEL_CATALOG.models) {
|
||||
expect(model.paramsB, model.id).toBeGreaterThan(0);
|
||||
// Nobody has run any of these with a harness on a Mac yet.
|
||||
expect(model.tested, model.id).toEqual([]);
|
||||
expect(model.id).toMatch(LOCAL_MODEL_ID_PATTERN);
|
||||
expect(model.license).toBe('Apache-2.0');
|
||||
}
|
||||
// The two 9B rows are the ones under the 12 billion line of the `limited` verdict.
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.models.filter((m) => m.paramsB < 12).map((m) => m.id)).toEqual([
|
||||
'qwen3.5:9b',
|
||||
'qwen3.5:9b-q8_0',
|
||||
]);
|
||||
});
|
||||
|
||||
it('has a speed figure only where one was measured or scaled from a measurement', () => {
|
||||
expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.speedPer100Gbps ?? null]))).toEqual({
|
||||
'qwen3.5:9b': 9.3,
|
||||
'qwen3.5:9b-q8_0': 5.8,
|
||||
'gpt-oss:20b': null,
|
||||
'qwen3.6:35b-a3b': 13.6,
|
||||
'qwen3.8:27b': 2.95,
|
||||
'qwen3.8:27b-q8_0': 1.7,
|
||||
'qwen3.5:122b-a10b': null,
|
||||
});
|
||||
});
|
||||
|
||||
it('lists its harnesses as data rows, one of them gated', () => {
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.harnesses).toEqual([
|
||||
{ cli: 'opencode', minContext: 65536, rank: 3 },
|
||||
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
|
||||
{ cli: 'pi', minContext: 32768, rank: 1, gated: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts the prompt cache as 512 MiB below 32 GB and 2 GiB from 32 GB', () => {
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.promptCacheTiers).toEqual([
|
||||
{ minMemGb: 0, bytes: 512 * 1024 ** 2 },
|
||||
{ minMemGb: 32, bytes: 2 * 1024 ** 3 },
|
||||
]);
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.minMacosMajor).toBe(14);
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.schema).toBe(1);
|
||||
});
|
||||
|
||||
it('holds no download data: no URL, no digest, no runtime block', () => {
|
||||
const text = JSON.stringify(STOCK_LOCAL_MODEL_CATALOG);
|
||||
expect(text).not.toMatch(/https?:/);
|
||||
expect(text).not.toMatch(/sha256/i);
|
||||
expect(Object.keys(STOCK_LOCAL_MODEL_CATALOG).sort()).toEqual(
|
||||
['chips', 'harnesses', 'minMacosMajor', 'models', 'promptCacheTiers', 'schema', 'updated'].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the model id rule', () => {
|
||||
it('accepts name:tag in lowercase', () => {
|
||||
for (const id of ['qwen3.5:9b', 'gpt-oss:20b', 'qwen3.8:27b-q8_0', 'a:b', 'x_y.z-1:2.3_4']) {
|
||||
expect(LOCAL_MODEL_ID_PATTERN.test(id), id).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses anything that could name another registry, namespace or no tag at all', () => {
|
||||
const bad = [
|
||||
'evil.example/x/y:z',
|
||||
'library/qwen3.5:9b',
|
||||
'registry.ollama.ai/library/qwen3.5:9b',
|
||||
'Qwen3.5:9b',
|
||||
'qwen3.5:9B',
|
||||
'qwen3.5',
|
||||
'qwen3.5:',
|
||||
':9b',
|
||||
'qwen3.5:9b:extra',
|
||||
'qwen 3.5:9b',
|
||||
'-qwen:9b',
|
||||
'qwen:-9b',
|
||||
'qwen3.5:9b\n',
|
||||
'',
|
||||
];
|
||||
for (const id of bad) expect(LOCAL_MODEL_ID_PATTERN.test(id), JSON.stringify(id)).toBe(false);
|
||||
});
|
||||
|
||||
it('makes the schema reject a catalogue with such an id', () => {
|
||||
for (const id of ['evil.example/x/y:z', 'Qwen3.5:9b', 'qwen3.5', 'qwen3.5:9b ']) {
|
||||
const issues = issuesOf(withFirstModel({ id }));
|
||||
expect(issues, id).toHaveLength(1);
|
||||
expect(issues[0]).toContain('models.0.id');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the schema, field by field', () => {
|
||||
it('refuses a key it does not know, at the top and inside a row', () => {
|
||||
// The whole point of having no `runtime` block: a data row can never carry a download URL.
|
||||
expect(issuesOf({ ...copy(), runtime: { url: 'https://example.com/x.tgz', sha256: 'abc' } })).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ url: 'https://example.com/model' }))).not.toEqual([]);
|
||||
const catalog = copy();
|
||||
expect(issuesOf({ ...catalog, harnesses: [{ ...catalog.harnesses[0], dialect: 'openai' }] })).not.toEqual([]);
|
||||
expect(issuesOf({ ...catalog, chips: [{ match: 'M4', gbps: 120, cores: 10 }] })).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('requires paramsB and tested on every model row', () => {
|
||||
for (const field of ['paramsB', 'tested'] as const) {
|
||||
const row: Record<string, unknown> = { ...copy().models[0] };
|
||||
delete row[field];
|
||||
const issues = issuesOf({ ...copy(), models: [row] });
|
||||
expect(issues, field).toHaveLength(1);
|
||||
expect(issues[0]).toContain(`models.0.${field}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses sizes and counts that are not positive whole numbers', () => {
|
||||
expect(issuesOf(withFirstModel({ bytes: 0 }))).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ bytes: 1.5 }))).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ kvBytesPerToken: -1 }))).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ contexts: [] }))).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ paramsB: 0 }))).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ speedPer100Gbps: 0 }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts a tested entry and checks its shape', () => {
|
||||
const good = { chip: 'Apple M4', memGb: 16, cli: 'opencode', date: '2026-11-06' };
|
||||
expect(issuesOf(withFirstModel({ tested: [good] }))).toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ tested: [{ ...good, date: '6 November 2026' }] }))).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ tested: [{ ...good, memGb: 0 }] }))).not.toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ tested: [{ ...good, result: 'pass' }] }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses another schema version and a date that is not ISO', () => {
|
||||
expect(issuesOf({ ...copy(), schema: 2 })).not.toEqual([]);
|
||||
expect(issuesOf({ ...copy(), updated: 'today' })).not.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the schema, across rows', () => {
|
||||
it('refuses a repeated model id', () => {
|
||||
const catalog = copy();
|
||||
catalog.models[1] = { ...catalog.models[1], id: catalog.models[0].id };
|
||||
expect(issuesOf(catalog)).toEqual(['models.1.id: model ids must be unique']);
|
||||
});
|
||||
|
||||
it('refuses a repeated model rank, which would leave the order of two models to chance', () => {
|
||||
const catalog = copy();
|
||||
catalog.models[1] = { ...catalog.models[1], rank: catalog.models[0].rank };
|
||||
expect(issuesOf(catalog)).toEqual(['models.1.rank: model ranks must be unique']);
|
||||
});
|
||||
|
||||
it('refuses a harness listed twice and a repeated harness rank', () => {
|
||||
const twice = copy();
|
||||
twice.harnesses.push({ cli: twice.harnesses[0].cli, minContext: 32768, rank: 9 });
|
||||
expect(issuesOf(twice)).toEqual(['harnesses.3.cli: a harness may be listed only once']);
|
||||
|
||||
const sameRank = copy();
|
||||
sameRank.harnesses[1] = { ...sameRank.harnesses[1], rank: sameRank.harnesses[0].rank };
|
||||
expect(issuesOf(sameRank)).toEqual(['harnesses.1.rank: harness ranks must be unique']);
|
||||
});
|
||||
|
||||
it('refuses contexts that are not listed largest first, or that repeat', () => {
|
||||
expect(issuesOf(withFirstModel({ contexts: [32768, 65536] }))).toEqual([
|
||||
'models.0.contexts.1: contexts must be listed largest first, without repeats',
|
||||
]);
|
||||
expect(issuesOf(withFirstModel({ contexts: [65536, 65536] }))).toEqual([
|
||||
'models.0.contexts.1: contexts must be listed largest first, without repeats',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses a tested entry for a harness the catalogue does not list', () => {
|
||||
const entry = { chip: 'Apple M4', memGb: 16, cli: 'not-a-harness', date: '2026-11-06' };
|
||||
expect(issuesOf(withFirstModel({ tested: [entry] }))).toEqual([
|
||||
'models.0.tested.0.cli: a tested entry must name a harness of this catalogue',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses chips that are not listed most specific first', () => {
|
||||
const catalog = copy();
|
||||
catalog.chips = [
|
||||
{ match: 'M4', gbps: 120 },
|
||||
{ match: 'M4 Max', gbps: 410 },
|
||||
];
|
||||
expect(issuesOf(catalog)).toEqual([
|
||||
"chips.1.match: chips must be listed most specific first: 'M4' comes before 'M4 Max'",
|
||||
]);
|
||||
|
||||
const repeated = copy();
|
||||
repeated.chips = [
|
||||
{ match: 'M4', gbps: 120 },
|
||||
{ match: 'm4', gbps: 130 },
|
||||
];
|
||||
expect(issuesOf(repeated)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses prompt cache tiers that do not start at 0 or do not ascend', () => {
|
||||
const late = copy();
|
||||
late.promptCacheTiers = [{ minMemGb: 16, bytes: 1 }];
|
||||
expect(issuesOf(late)).toEqual(['promptCacheTiers.0.minMemGb: the first prompt cache tier must start at 0']);
|
||||
|
||||
const unordered = copy();
|
||||
unordered.promptCacheTiers = [
|
||||
{ minMemGb: 0, bytes: 1 },
|
||||
{ minMemGb: 32, bytes: 2 },
|
||||
{ minMemGb: 24, bytes: 3 },
|
||||
];
|
||||
expect(issuesOf(unordered)).toEqual(['promptCacheTiers.2.minMemGb: prompt cache tiers must ascend by minMemGb']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the chips table', () => {
|
||||
const chips = STOCK_LOCAL_MODEL_CATALOG.chips;
|
||||
|
||||
it('holds every chip of the source table that shipped', () => {
|
||||
// Bandwidth column of https://github.com/ggml-org/llama.cpp/discussions/4167, read on
|
||||
// 2026-11-06. A name that covers two bandwidths has the lower one.
|
||||
expect(Object.fromEntries(chips.map((row) => [row.match, row.gbps]))).toEqual({
|
||||
'M5 Ultra': 1228,
|
||||
'M3 Ultra': 800,
|
||||
'M2 Ultra': 800,
|
||||
'M1 Ultra': 800,
|
||||
'M5 Max': 460,
|
||||
'M4 Max': 410,
|
||||
'M3 Max': 300,
|
||||
'M2 Max': 400,
|
||||
'M1 Max': 400,
|
||||
'M5 Pro': 307,
|
||||
'M4 Pro': 273,
|
||||
'M3 Pro': 150,
|
||||
'M2 Pro': 200,
|
||||
'M1 Pro': 200,
|
||||
M5: 154,
|
||||
M4: 120,
|
||||
M3: 100,
|
||||
M2: 100,
|
||||
M1: 68,
|
||||
});
|
||||
});
|
||||
|
||||
it('finds each row under the name macOS reports for that chip', () => {
|
||||
// No row is hidden by an earlier one: 'Apple M4 Max' is 410, not the 120 of 'M4'.
|
||||
for (const row of chips) {
|
||||
expect(chipBandwidthGbps(chips, `Apple ${row.match}`), row.match).toBe(row.gbps);
|
||||
}
|
||||
});
|
||||
|
||||
it('knows the chips of the two test Macs', () => {
|
||||
expect(chipBandwidthGbps(chips, 'Apple M4')).toBe(120);
|
||||
expect(chipBandwidthGbps(chips, 'Apple M5 Pro')).toBe(307);
|
||||
});
|
||||
|
||||
it('never guesses: a chip that no row names has no bandwidth', () => {
|
||||
const unknown = [
|
||||
// A base chip that is not in the table.
|
||||
'Apple M6',
|
||||
// A variant the table has never heard of must not borrow the figure of its base chip.
|
||||
'Apple M6 Pro',
|
||||
'Apple M4 Ultra',
|
||||
'Apple M5 Extreme',
|
||||
// A name that merely contains a known name.
|
||||
'Apple M10',
|
||||
'Apple M12 Pro',
|
||||
'Apple M1 Max (Virtual)',
|
||||
'Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz',
|
||||
'VirtualApple @ 2.50GHz processor',
|
||||
'Max',
|
||||
'',
|
||||
];
|
||||
for (const chip of unknown) expect(chipBandwidthGbps(chips, chip), chip).toBeUndefined();
|
||||
expect(chipBandwidthGbps(chips, undefined)).toBeUndefined();
|
||||
expect(chipBandwidthGbps([], 'Apple M4')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chipNameMatches', () => {
|
||||
it('matches the whole chip name at the end of the string, ignoring case and padding', () => {
|
||||
expect(chipNameMatches('Apple M4 Pro', 'M4 Pro')).toBe(true);
|
||||
expect(chipNameMatches('Apple M4', 'M4')).toBe(true);
|
||||
expect(chipNameMatches('M4', 'M4')).toBe(true);
|
||||
expect(chipNameMatches(' apple m4 pro ', 'M4 PRO')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match a part of a longer name', () => {
|
||||
expect(chipNameMatches('Apple M4 Pro', 'M4')).toBe(false);
|
||||
expect(chipNameMatches('Apple M14', 'M4')).toBe(false);
|
||||
expect(chipNameMatches('Apple M10', 'M1')).toBe(false);
|
||||
expect(chipNameMatches('Apple XM4', 'M4')).toBe(false);
|
||||
expect(chipNameMatches('Apple M4', '')).toBe(false);
|
||||
expect(chipNameMatches('', 'M4')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @fileoverview Tests for src/local-model/format.ts: the ONE place a byte count becomes a size a
|
||||
* person reads, and the memory class a Mac is sold as.
|
||||
*
|
||||
* What is pinned here is a contract with the browser: it prints the text it was sent and adds
|
||||
* ' GB' itself, so the text must be decimal GB, carry no unit, and round the same way everywhere.
|
||||
*
|
||||
* Port: N/A (pure functions).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatGb, GB, GIB, memClassGb, MIB } from '../src/local-model/format.js';
|
||||
|
||||
describe('byte units', () => {
|
||||
it('keeps decimal GB (what a person reads) apart from the binary units of the budget math', () => {
|
||||
expect(GB).toBe(1_000_000_000);
|
||||
expect(GIB).toBe(1_073_741_824);
|
||||
expect(MIB).toBe(1_048_576);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatGb', () => {
|
||||
it('prints decimal GB, so a Mac sold as 16 GB reads 17.2 and one sold as 48 GB reads 51.5', () => {
|
||||
expect(formatGb(17_179_869_184)).toBe('17.2');
|
||||
expect(formatGb(51_539_607_552)).toBe('51.5');
|
||||
});
|
||||
|
||||
it('prints the registry sizes of the catalogue models as the registry shows them', () => {
|
||||
expect(formatGb(6_550_825_373)).toBe('6.6');
|
||||
expect(formatGb(13_793_441_427)).toBe('13.8');
|
||||
expect(formatGb(17_741_871_939)).toBe('17.7');
|
||||
expect(formatGb(22_621_314_161)).toBe('22.6');
|
||||
expect(formatGb(81_370_035_877)).toBe('81.4');
|
||||
});
|
||||
|
||||
it('keeps one decimal below 100 and none from 100', () => {
|
||||
expect(formatGb(1 * GB)).toBe('1.0');
|
||||
expect(formatGb(99.94 * GB)).toBe('99.9');
|
||||
// Rounds to 100.0, which is already "from 100".
|
||||
expect(formatGb(99.96 * GB)).toBe('100');
|
||||
expect(formatGb(100 * GB)).toBe('100');
|
||||
// The free disk of the two test Macs, from their `df -k` lines.
|
||||
expect(formatGb(890_926_084 * 1024)).toBe('912');
|
||||
expect(formatGb(41_752_848 * 1024)).toBe('42.8');
|
||||
});
|
||||
|
||||
it('never prints a unit, a thousands separator or an exponent', () => {
|
||||
for (const bytes of [1, 5 * MIB, 3 * GIB, 250 * GB, 4000 * GB, 2.5e15]) {
|
||||
expect(formatGb(bytes)).toMatch(/^\d+(\.\d)?$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads '0.0' for anything that is not a positive size", () => {
|
||||
for (const bytes of [0, -1, -5 * GB, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
|
||||
expect(formatGb(bytes)).toBe('0.0');
|
||||
}
|
||||
});
|
||||
|
||||
it('shows a size too small for one decimal as 0.0, never as an empty text', () => {
|
||||
expect(formatGb(10 * MIB)).toBe('0.0');
|
||||
expect(formatGb(512 * MIB)).toBe('0.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('memClassGb', () => {
|
||||
it('names the size a Mac is sold as: whole binary gigabytes', () => {
|
||||
for (const sold of [8, 16, 18, 24, 32, 36, 48, 64, 96, 128, 192]) {
|
||||
expect(memClassGb(sold * GIB)).toBe(sold);
|
||||
}
|
||||
// The exact `hw.memsize` of the two test Macs.
|
||||
expect(memClassGb(17_179_869_184)).toBe(16);
|
||||
expect(memClassGb(51_539_607_552)).toBe(48);
|
||||
});
|
||||
|
||||
it('rounds a size that is not a whole number of gigabytes', () => {
|
||||
// `hw.memsize_usable` of the 16 GB test Mac is a little below 16 GiB.
|
||||
expect(memClassGb(16_631_808_000)).toBe(15);
|
||||
expect(memClassGb(16 * GIB - 100 * MIB)).toBe(16);
|
||||
});
|
||||
|
||||
it('reads 0 for anything that is not a positive size', () => {
|
||||
for (const bytes of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
expect(memClassGb(bytes)).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,749 @@
|
||||
/**
|
||||
* @fileoverview Tests for src/local-model/hardware.ts: the probe that tells the recommendation
|
||||
* what this Mac is.
|
||||
*
|
||||
* Two halves, mirroring the module:
|
||||
*
|
||||
* 1. `parseSysctl()`, pure. Fed the REAL output of the probe command on the two test Macs (a
|
||||
* MacBook Pro with an M5 Pro and 48 GB, a Mac mini with an M4 and 16 GB), copied here
|
||||
* character for character with the extra lines the capture holds, plus an Intel-shaped
|
||||
* output (three of the seven keys do not exist there) and outputs cut off mid-line.
|
||||
* 2. `readHardware()`, with every piece of IO injected: nothing is spawned and no real disk is
|
||||
* read. What is pinned: a failed spawn is an ANSWER (the `os-fallback` shape), nothing is
|
||||
* spawned off macOS, the result is cached for 60 s and concurrent callers share one probe.
|
||||
*
|
||||
* The IO itself is tested apart, against things this suite controls:
|
||||
*
|
||||
* 3. `spawnSysctl()` runs a small shell script of the test in place of sysctl. That proves what
|
||||
* a fake cannot: the arguments are the seven keys and nothing else, the output of a program
|
||||
* that exits 1 is still read (an Intel Mac lacks three keys, so sysctl exits 1 there), a
|
||||
* program that hangs is given up on, and the server's environment is not handed over.
|
||||
* 4. `dataDirFreeBytes()` with injected probes: a folder that does not exist yet, a mount that
|
||||
* does not answer, and a `statfs` that never comes back, which must not be started twice.
|
||||
*
|
||||
* One test at the end runs the real defaults once (the real spawn and the real `statfs`), to
|
||||
* prove they cannot reject on whatever machine runs the suite. On Linux `sysctl` knows none of
|
||||
* the keys, so that run ends in the fallback; on a Mac it ends in a real answer. Both are fine.
|
||||
*
|
||||
* Port: N/A (no server).
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
dataDirFreeBytes,
|
||||
FREE_DISK_TIMEOUT_MS,
|
||||
freeDiskBytesOf,
|
||||
HARDWARE_CACHE_TTL_MS,
|
||||
parseSysctl,
|
||||
readHardware,
|
||||
resetHardwareCache,
|
||||
spawnSysctl,
|
||||
SYSCTL_KEYS,
|
||||
SYSCTL_PATH,
|
||||
SYSCTL_TIMEOUT_MS,
|
||||
type HardwareProbeDeps,
|
||||
} from '../src/local-model/hardware.js';
|
||||
|
||||
/**
|
||||
* The capture of the MacBook Pro (Mac17,13), 2026-11-06. The last four lines are not part of
|
||||
* the probe: two extra keys the capture asked for, the shell's exit status and a `df -k` line.
|
||||
*/
|
||||
const MACBOOK_SYSCTL = `hw.memsize: 51539607552
|
||||
machdep.cpu.brand_string: Apple M5 Pro
|
||||
hw.optional.arm64: 1
|
||||
sysctl.proc_translated: 0
|
||||
iogpu.wired_limit_mb: 0
|
||||
kern.osproductversion: 27.2
|
||||
hw.model: Mac17,13
|
||||
hw.perflevel0.physicalcpu: 6
|
||||
hw.perflevel1.physicalcpu: 12
|
||||
exit=0
|
||||
/dev/disk3s5 971094180 54383564 890926084 6% 542146 8909260840 0% /System/Volumes/Data
|
||||
`;
|
||||
|
||||
/** The capture of the Mac mini (Mac16,10), 2026-11-06, with the same four extra lines. */
|
||||
const MACMINI_SYSCTL = `hw.memsize: 17179869184
|
||||
machdep.cpu.brand_string: Apple M4
|
||||
hw.optional.arm64: 1
|
||||
sysctl.proc_translated: 0
|
||||
iogpu.wired_limit_mb: 0
|
||||
kern.osproductversion: 27.2
|
||||
hw.model: Mac16,10
|
||||
hw.perflevel0.physicalcpu: 4
|
||||
hw.perflevel1.physicalcpu: 6
|
||||
exit=0
|
||||
/dev/disk3s5 239362496 172954124 41752848 81% 2353838 417528480 1% /System/Volumes/Data
|
||||
`;
|
||||
|
||||
/** Free disk of the two Macs: the "available" column of their `df -k` lines, in bytes. */
|
||||
const MACBOOK_FREE_DISK = 890_926_084 * 1024;
|
||||
const MACMINI_FREE_DISK = 41_752_848 * 1024;
|
||||
|
||||
/**
|
||||
* What an Intel Mac prints: `hw.optional.arm64`, `sysctl.proc_translated` and
|
||||
* `iogpu.wired_limit_mb` do not exist there, so sysctl leaves their lines out (and exits 1).
|
||||
* NOT a capture: no Intel Mac was available, the shape is recalled.
|
||||
*/
|
||||
const INTEL_SYSCTL = `hw.memsize: 17179869184
|
||||
machdep.cpu.brand_string: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
|
||||
kern.osproductversion: 13.6.7
|
||||
hw.model: MacBookPro15,1
|
||||
`;
|
||||
|
||||
/** Deps that spawn nothing and read no disk. A test overrides what it is about. */
|
||||
function deps(overrides: HardwareProbeDeps = {}): HardwareProbeDeps {
|
||||
return {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
runSysctl: async () => MACMINI_SYSCTL,
|
||||
freeDiskBytes: async () => MACMINI_FREE_DISK,
|
||||
totalMemBytes: () => 8 * 1024 ** 3,
|
||||
cpuModel: () => 'Fallback CPU',
|
||||
now: () => 1_000_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetHardwareCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetHardwareCache();
|
||||
});
|
||||
|
||||
describe('the probe command', () => {
|
||||
it('is an absolute path and asks for exactly the seven keys, never with -n', () => {
|
||||
expect(isAbsolute(SYSCTL_PATH)).toBe(true);
|
||||
expect(SYSCTL_PATH).toBe('/usr/sbin/sysctl');
|
||||
expect([...SYSCTL_KEYS]).toEqual([
|
||||
'hw.memsize',
|
||||
'machdep.cpu.brand_string',
|
||||
'hw.optional.arm64',
|
||||
'sysctl.proc_translated',
|
||||
'iogpu.wired_limit_mb',
|
||||
'kern.osproductversion',
|
||||
'hw.model',
|
||||
]);
|
||||
// Every argument is a key name. `-n` would print bare values, and a key missing on a
|
||||
// machine would then shift every value after it onto the wrong key.
|
||||
for (const key of SYSCTL_KEYS) expect(key).toMatch(/^[a-z][a-z0-9_.]*$/);
|
||||
});
|
||||
|
||||
it('is bounded, and its result is reused for 60 seconds', () => {
|
||||
expect(SYSCTL_TIMEOUT_MS).toBe(2000);
|
||||
expect(HARDWARE_CACHE_TTL_MS).toBe(60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSysctl on the two real Macs', () => {
|
||||
it('reads the MacBook Pro: M5 Pro, 48 GB, macOS 27.2, no wired limit set', () => {
|
||||
expect(parseSysctl(MACBOOK_SYSCTL)).toEqual({
|
||||
memBytes: 51_539_607_552,
|
||||
chip: 'Apple M5 Pro',
|
||||
arm64: true,
|
||||
translated: false,
|
||||
wiredLimitMb: 0,
|
||||
macosVersion: '27.2',
|
||||
macosMajor: 27,
|
||||
model: 'Mac17,13',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the Mac mini: M4, 16 GB, macOS 27.2, no wired limit set', () => {
|
||||
expect(parseSysctl(MACMINI_SYSCTL)).toEqual({
|
||||
memBytes: 17_179_869_184,
|
||||
chip: 'Apple M4',
|
||||
arm64: true,
|
||||
translated: false,
|
||||
wiredLimitMb: 0,
|
||||
macosVersion: '27.2',
|
||||
macosMajor: 27,
|
||||
model: 'Mac16,10',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the lines it does not know: other keys, the exit status, the df line', () => {
|
||||
const facts = parseSysctl(MACMINI_SYSCTL);
|
||||
expect(Object.keys(facts).sort()).toEqual(
|
||||
['arm64', 'chip', 'macosMajor', 'macosVersion', 'memBytes', 'model', 'translated', 'wiredLimitMb'].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a wired limit of 0 as 0 (not set), and a set one as its number', () => {
|
||||
expect(parseSysctl(MACMINI_SYSCTL).wiredLimitMb).toBe(0);
|
||||
expect(parseSysctl('iogpu.wired_limit_mb: 14000\n').wiredLimitMb).toBe(14000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSysctl on other shapes', () => {
|
||||
it('reads an Intel Mac, whose output lacks three of the keys, without shifting a value', () => {
|
||||
expect(parseSysctl(INTEL_SYSCTL)).toEqual({
|
||||
memBytes: 17_179_869_184,
|
||||
chip: 'Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz',
|
||||
macosVersion: '13.6.7',
|
||||
macosMajor: 13,
|
||||
model: 'MacBookPro15,1',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads an Intel Mac that does print the arm64 flag, as 0', () => {
|
||||
const facts = parseSysctl(`hw.optional.arm64: 0\n${INTEL_SYSCTL}`);
|
||||
expect(facts.arm64).toBe(false);
|
||||
expect(facts.translated).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads a process under Rosetta: the machine is arm64 and the process is translated', () => {
|
||||
const facts = parseSysctl(MACMINI_SYSCTL.replace('sysctl.proc_translated: 0', 'sysctl.proc_translated: 1'));
|
||||
expect(facts.arm64).toBe(true);
|
||||
expect(facts.translated).toBe(true);
|
||||
});
|
||||
|
||||
it('drops a line that was cut off: a truncated output never yields half a value', () => {
|
||||
// Cut in the middle of the chip name. The memory line before it is whole.
|
||||
const cutInChip = 'hw.memsize: 17179869184\nmachdep.cpu.brand_string: Apple M';
|
||||
expect(parseSysctl(cutInChip)).toEqual({ memBytes: 17_179_869_184 });
|
||||
|
||||
// Cut in the middle of the memory figure: '1717' would be a valid, wrong number.
|
||||
expect(parseSysctl('hw.memsize: 1717')).toEqual({});
|
||||
|
||||
// Cut right after a whole line: everything up to there counts.
|
||||
const cutAfterLine = MACMINI_SYSCTL.slice(0, MACMINI_SYSCTL.indexOf('hw.optional.arm64'));
|
||||
expect(parseSysctl(cutAfterLine)).toEqual({ memBytes: 17_179_869_184, chip: 'Apple M4' });
|
||||
});
|
||||
|
||||
it('reads nothing from an empty output or from error text', () => {
|
||||
expect(parseSysctl('')).toEqual({});
|
||||
expect(parseSysctl('\n\n')).toEqual({});
|
||||
expect(
|
||||
parseSysctl("sysctl: unknown oid 'hw.memsize'\nsysctl: cannot stat /proc/sys/hw/memsize: No such file\n")
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('leaves out a value that does not parse instead of guessing', () => {
|
||||
const facts = parseSysctl(
|
||||
[
|
||||
'hw.memsize: lots',
|
||||
'machdep.cpu.brand_string: ',
|
||||
'hw.optional.arm64: yes',
|
||||
'sysctl.proc_translated: 2',
|
||||
'iogpu.wired_limit_mb: -1',
|
||||
'kern.osproductversion: Sequoia',
|
||||
`hw.model: ${'x'.repeat(200)}`,
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
expect(facts).toEqual({});
|
||||
});
|
||||
|
||||
it('refuses a memory size of 0 or one too large to be a number of bytes', () => {
|
||||
expect(parseSysctl('hw.memsize: 0\n').memBytes).toBeUndefined();
|
||||
expect(parseSysctl('hw.memsize: 99999999999999999999999\n').memBytes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts Windows line ends and padding, and the first line of a key wins', () => {
|
||||
const facts = parseSysctl('hw.memsize: 17179869184 \r\nhw.model: Mac16,10\r\nhw.model: Other9,9\r\n');
|
||||
expect(facts).toEqual({ memBytes: 17_179_869_184, model: 'Mac16,10' });
|
||||
});
|
||||
|
||||
it('reads the macOS major version from one, two or three numbers', () => {
|
||||
expect(parseSysctl('kern.osproductversion: 14\n')).toMatchObject({ macosVersion: '14', macosMajor: 14 });
|
||||
expect(parseSysctl('kern.osproductversion: 15.7\n')).toMatchObject({ macosVersion: '15.7', macosMajor: 15 });
|
||||
expect(parseSysctl('kern.osproductversion: 13.6.7\n')).toMatchObject({ macosVersion: '13.6.7', macosMajor: 13 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('readHardware on the two real Macs', () => {
|
||||
it('describes the MacBook Pro', async () => {
|
||||
const hw = await readHardware(
|
||||
deps({ runSysctl: async () => MACBOOK_SYSCTL, freeDiskBytes: async () => MACBOOK_FREE_DISK })
|
||||
);
|
||||
expect(hw).toEqual({
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
arm64: true,
|
||||
translated: false,
|
||||
memBytes: 51_539_607_552,
|
||||
memGb: '51.5',
|
||||
memClassGb: 48,
|
||||
chip: 'Apple M5 Pro',
|
||||
model: 'Mac17,13',
|
||||
macosVersion: '27.2',
|
||||
macosMajor: 27,
|
||||
wiredLimitMb: 0,
|
||||
freeDiskBytes: MACBOOK_FREE_DISK,
|
||||
freeDiskGb: '912',
|
||||
source: 'sysctl',
|
||||
});
|
||||
});
|
||||
|
||||
it('describes the Mac mini', async () => {
|
||||
const hw = await readHardware(deps());
|
||||
expect(hw).toEqual({
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
arm64: true,
|
||||
translated: false,
|
||||
memBytes: 17_179_869_184,
|
||||
memGb: '17.2',
|
||||
memClassGb: 16,
|
||||
chip: 'Apple M4',
|
||||
model: 'Mac16,10',
|
||||
macosVersion: '27.2',
|
||||
macosMajor: 27,
|
||||
wiredLimitMb: 0,
|
||||
freeDiskBytes: MACMINI_FREE_DISK,
|
||||
freeDiskGb: '42.8',
|
||||
source: 'sysctl',
|
||||
});
|
||||
});
|
||||
|
||||
it('is plain JSON: it survives a round trip unchanged', async () => {
|
||||
const hw = await readHardware(deps());
|
||||
expect(JSON.parse(JSON.stringify(hw))).toEqual(hw);
|
||||
});
|
||||
|
||||
it('describes an Intel Mac from its shorter output: no arm64 flag, an x64 process', async () => {
|
||||
const hw = await readHardware(deps({ arch: 'x64', runSysctl: async () => INTEL_SYSCTL }));
|
||||
expect(hw.source).toBe('sysctl');
|
||||
expect(hw.arch).toBe('x64');
|
||||
expect(hw.chip).toBe('Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz');
|
||||
expect(hw.macosMajor).toBe(13);
|
||||
expect(hw).not.toHaveProperty('arm64');
|
||||
expect(hw).not.toHaveProperty('translated');
|
||||
expect(hw).not.toHaveProperty('wiredLimitMb');
|
||||
});
|
||||
|
||||
it('takes the chip name from node:os when sysctl printed everything but that', async () => {
|
||||
const hw = await readHardware(
|
||||
deps({ runSysctl: async () => MACMINI_SYSCTL.replace('machdep.cpu.brand_string: Apple M4\n', '') })
|
||||
);
|
||||
expect(hw.source).toBe('sysctl');
|
||||
expect(hw.chip).toBe('Fallback CPU');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readHardware when the probe fails', () => {
|
||||
it('answers from node:os when the spawn could not run or timed out', async () => {
|
||||
const hw = await readHardware(
|
||||
deps({
|
||||
runSysctl: async () => undefined,
|
||||
totalMemBytes: () => 34_359_738_368,
|
||||
cpuModel: () => 'Apple M2 Max',
|
||||
})
|
||||
);
|
||||
expect(hw).toEqual({
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
memBytes: 34_359_738_368,
|
||||
memGb: '34.4',
|
||||
memClassGb: 32,
|
||||
chip: 'Apple M2 Max',
|
||||
freeDiskBytes: MACMINI_FREE_DISK,
|
||||
freeDiskGb: '42.8',
|
||||
source: 'os-fallback',
|
||||
});
|
||||
});
|
||||
|
||||
it('never rejects when a dependency throws or rejects', async () => {
|
||||
const hw = await readHardware(
|
||||
deps({
|
||||
runSysctl: () => Promise.reject(new Error('spawn EACCES')),
|
||||
freeDiskBytes: () => {
|
||||
throw new Error('statfs failed');
|
||||
},
|
||||
cpuModel: () => {
|
||||
throw new Error('no cpus');
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(hw.source).toBe('os-fallback');
|
||||
expect(hw.memBytes).toBe(8 * 1024 ** 3);
|
||||
expect(hw).not.toHaveProperty('chip');
|
||||
expect(hw).not.toHaveProperty('freeDiskBytes');
|
||||
expect(hw).not.toHaveProperty('freeDiskGb');
|
||||
});
|
||||
|
||||
it('still answers when even the memory size cannot be read', async () => {
|
||||
const hw = await readHardware(
|
||||
deps({
|
||||
runSysctl: async () => undefined,
|
||||
totalMemBytes: () => {
|
||||
throw new Error('no memory figure');
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(hw).toMatchObject({ source: 'os-fallback', memBytes: 0, memGb: '0.0', memClassGb: 0 });
|
||||
});
|
||||
|
||||
it('trusts nothing of an output that has no memory size', async () => {
|
||||
// A truncated or foreign output: the chip and version lines are whole, hw.memsize is not there.
|
||||
const withoutMemory = MACMINI_SYSCTL.replace('hw.memsize: 17179869184\n', '');
|
||||
const hw = await readHardware(deps({ runSysctl: async () => withoutMemory }));
|
||||
expect(hw.source).toBe('os-fallback');
|
||||
expect(hw.memBytes).toBe(8 * 1024 ** 3);
|
||||
expect(hw.chip).toBe('Fallback CPU');
|
||||
expect(hw).not.toHaveProperty('macosVersion');
|
||||
expect(hw).not.toHaveProperty('macosMajor');
|
||||
expect(hw).not.toHaveProperty('model');
|
||||
expect(hw).not.toHaveProperty('wiredLimitMb');
|
||||
expect(hw).not.toHaveProperty('arm64');
|
||||
});
|
||||
|
||||
it('falls back when the output was cut off inside the memory figure', async () => {
|
||||
const hw = await readHardware(deps({ runSysctl: async () => 'hw.memsize: 1717' }));
|
||||
expect(hw.source).toBe('os-fallback');
|
||||
expect(hw.memBytes).toBe(8 * 1024 ** 3);
|
||||
});
|
||||
|
||||
it('leaves the free disk out when it is unknown, and keeps the rest', async () => {
|
||||
const hw = await readHardware(deps({ freeDiskBytes: async () => undefined }));
|
||||
expect(hw.source).toBe('sysctl');
|
||||
expect(hw).not.toHaveProperty('freeDiskBytes');
|
||||
expect(hw).not.toHaveProperty('freeDiskGb');
|
||||
});
|
||||
|
||||
it('reports a full disk as 0 bytes free, which is a figure and not "unknown"', async () => {
|
||||
const hw = await readHardware(deps({ freeDiskBytes: async () => 0 }));
|
||||
expect(hw.freeDiskBytes).toBe(0);
|
||||
expect(hw.freeDiskGb).toBe('0.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readHardware off macOS', () => {
|
||||
it('spawns nothing and reads no disk', async () => {
|
||||
let spawned = 0;
|
||||
let diskRead = 0;
|
||||
const hw = await readHardware(
|
||||
deps({
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
runSysctl: async () => {
|
||||
spawned++;
|
||||
return MACMINI_SYSCTL;
|
||||
},
|
||||
freeDiskBytes: async () => {
|
||||
diskRead++;
|
||||
return 1;
|
||||
},
|
||||
totalMemBytes: () => 67_430_023_168,
|
||||
})
|
||||
);
|
||||
expect(spawned).toBe(0);
|
||||
expect(diskRead).toBe(0);
|
||||
expect(hw).toEqual({
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
memBytes: 67_430_023_168,
|
||||
memGb: '67.4',
|
||||
memClassGb: 63,
|
||||
source: 'not-probed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the cache of readHardware', () => {
|
||||
it('reuses one result for 60 seconds, then probes again', async () => {
|
||||
let clock = 5_000_000;
|
||||
let spawned = 0;
|
||||
const counting = deps({
|
||||
now: () => clock,
|
||||
runSysctl: async () => {
|
||||
spawned++;
|
||||
return spawned === 1 ? MACMINI_SYSCTL : MACBOOK_SYSCTL;
|
||||
},
|
||||
});
|
||||
|
||||
const first = await readHardware(counting);
|
||||
clock += HARDWARE_CACHE_TTL_MS - 1;
|
||||
const second = await readHardware(counting);
|
||||
expect(spawned).toBe(1);
|
||||
expect(second).toBe(first);
|
||||
|
||||
clock += 1;
|
||||
const third = await readHardware(counting);
|
||||
expect(spawned).toBe(2);
|
||||
expect(third.chip).toBe('Apple M5 Pro');
|
||||
});
|
||||
|
||||
it('caches a failed probe too: a broken sysctl is not spawned on every request', async () => {
|
||||
let spawned = 0;
|
||||
const failing = deps({
|
||||
runSysctl: async () => {
|
||||
spawned++;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
expect((await readHardware(failing)).source).toBe('os-fallback');
|
||||
expect((await readHardware(failing)).source).toBe('os-fallback');
|
||||
expect(spawned).toBe(1);
|
||||
});
|
||||
|
||||
it('lets concurrent callers share one probe', async () => {
|
||||
let spawned = 0;
|
||||
let release: (text: string) => void = () => {};
|
||||
const slow = deps({
|
||||
runSysctl: () => {
|
||||
spawned++;
|
||||
return new Promise<string>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
const callers = [readHardware(slow), readHardware(slow), readHardware(slow)];
|
||||
// Let the three calls reach the spawn before it answers.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
release(MACMINI_SYSCTL);
|
||||
const results = await Promise.all(callers);
|
||||
expect(spawned).toBe(1);
|
||||
expect(results[1]).toBe(results[0]);
|
||||
expect(results[2]).toBe(results[0]);
|
||||
});
|
||||
|
||||
it('probes again after a reset, and does not trust a clock that ran backwards', async () => {
|
||||
let clock = 9_000_000;
|
||||
let spawned = 0;
|
||||
const counting = deps({
|
||||
now: () => clock,
|
||||
runSysctl: async () => {
|
||||
spawned++;
|
||||
return MACMINI_SYSCTL;
|
||||
},
|
||||
});
|
||||
await readHardware(counting);
|
||||
resetHardwareCache();
|
||||
await readHardware(counting);
|
||||
expect(spawned).toBe(2);
|
||||
|
||||
clock -= 10_000;
|
||||
await readHardware(counting);
|
||||
expect(spawned).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// The scripts below are POSIX shell. Codeman's server does not run on Windows.
|
||||
describe.skipIf(process.platform === 'win32')('spawnSysctl, against a script of the test', () => {
|
||||
let folder: string;
|
||||
|
||||
beforeEach(() => {
|
||||
folder = mkdtempSync(join(tmpdir(), 'codeman-local-model-spawn-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(folder, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** An executable shell script standing in for sysctl. */
|
||||
function script(body: string, mode = 0o755): string {
|
||||
const path = join(folder, 'fake-sysctl');
|
||||
writeFileSync(path, `#!/bin/sh\n${body}\n`, { mode });
|
||||
return path;
|
||||
}
|
||||
|
||||
it('passes the seven keys as arguments and nothing else: no -n, no shell', async () => {
|
||||
// printf, not echo: echo would swallow a `-n` argument, the very thing this test looks for.
|
||||
const output = await spawnSysctl(script('printf \'%s\\n\' "$@"'));
|
||||
expect(output?.trimEnd().split('\n')).toEqual([...SYSCTL_KEYS]);
|
||||
});
|
||||
|
||||
it('reads the output of a program that exits 1, as sysctl does on an Intel Mac', async () => {
|
||||
// Three keys are unknown there: their errors go to stderr, the other four lines to stdout.
|
||||
const output = await spawnSysctl(
|
||||
script(
|
||||
[
|
||||
"echo 'hw.memsize: 17179869184'",
|
||||
"echo 'machdep.cpu.brand_string: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz'",
|
||||
`echo "sysctl: unknown oid 'hw.optional.arm64'" >&2`,
|
||||
`echo "sysctl: unknown oid 'sysctl.proc_translated'" >&2`,
|
||||
`echo "sysctl: unknown oid 'iogpu.wired_limit_mb'" >&2`,
|
||||
"echo 'kern.osproductversion: 13.6.7'",
|
||||
"echo 'hw.model: MacBookPro15,1'",
|
||||
'exit 1',
|
||||
].join('\n')
|
||||
)
|
||||
);
|
||||
expect(output).toBeDefined();
|
||||
expect(output).not.toContain('unknown oid');
|
||||
expect(parseSysctl(output as string)).toEqual({
|
||||
memBytes: 17_179_869_184,
|
||||
chip: 'Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz',
|
||||
macosVersion: '13.6.7',
|
||||
macosMajor: 13,
|
||||
model: 'MacBookPro15,1',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers undefined for a program that does not exist or may not be run', async () => {
|
||||
expect(await spawnSysctl(join(folder, 'no-such-program'))).toBeUndefined();
|
||||
expect(await spawnSysctl(script('echo "hw.memsize: 1"', 0o644))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives up on a program that hangs, and does not hand over half an output', async () => {
|
||||
const started = Date.now();
|
||||
// `exec` so the signal reaches the sleeper itself and nothing is left behind.
|
||||
const output = await spawnSysctl(script("echo 'hw.memsize: 17179869184'\nexec sleep 20"), 200);
|
||||
expect(output).toBeUndefined();
|
||||
expect(Date.now() - started).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it('answers undefined for an output far larger than seven lines', async () => {
|
||||
expect(await spawnSysctl(script("head -c 200000 /dev/zero | tr '\\0' 'x'"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not pass the server's environment to the program", async () => {
|
||||
const name = 'CODEMAN_LOCAL_MODEL_TEST_SECRET';
|
||||
process.env[name] = 'hunter2';
|
||||
try {
|
||||
const output = await spawnSysctl(script(`echo "secret=\${${name}:-unset}"`));
|
||||
expect(output?.trim()).toBe('secret=unset');
|
||||
} finally {
|
||||
delete process.env[name];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('dataDirFreeBytes', () => {
|
||||
let folder: string;
|
||||
|
||||
beforeEach(() => {
|
||||
folder = mkdtempSync(join(tmpdir(), 'codeman-local-model-disk-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(folder, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads the free bytes of the data dir with the real probe and the real statfs', async () => {
|
||||
const free = await dataDirFreeBytes({ dataDir: () => folder });
|
||||
expect(typeof free).toBe('number');
|
||||
expect(free).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('uses the nearest existing parent when the data dir does not exist yet', async () => {
|
||||
const asked: string[] = [];
|
||||
const free = await dataDirFreeBytes({
|
||||
dataDir: () => join(folder, 'not', 'yet', 'there'),
|
||||
freeBytesOf: async (path) => {
|
||||
asked.push(path);
|
||||
return 123;
|
||||
},
|
||||
});
|
||||
expect(free).toBe(123);
|
||||
expect(asked).toEqual([folder]);
|
||||
});
|
||||
|
||||
it('does not touch a folder whose mount does not answer, or a path that is a file', async () => {
|
||||
for (const kind of ['unknown', 'file'] as const) {
|
||||
let touched = 0;
|
||||
const free = await dataDirFreeBytes({
|
||||
dataDir: () => folder,
|
||||
probeKind: async () => kind,
|
||||
freeBytesOf: async () => {
|
||||
touched++;
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
expect(free, kind).toBeUndefined();
|
||||
expect(touched, kind).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('stops walking up after a few parents', async () => {
|
||||
let probes = 0;
|
||||
const free = await dataDirFreeBytes({
|
||||
dataDir: () => '/a/b/c/d/e/f/g/h/i/j/k/l',
|
||||
probeKind: async () => {
|
||||
probes++;
|
||||
return 'absent';
|
||||
},
|
||||
freeBytesOf: async () => 1,
|
||||
});
|
||||
expect(free).toBeUndefined();
|
||||
expect(probes).toBe(7);
|
||||
});
|
||||
|
||||
it('gives up on a statfs that does not come back, and never starts a second one meanwhile', async () => {
|
||||
let started = 0;
|
||||
let finish: (bytes: number) => void = () => {};
|
||||
const hanging = {
|
||||
dataDir: () => folder,
|
||||
freeBytesOf: () => {
|
||||
started++;
|
||||
return new Promise<number>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
},
|
||||
timeoutMs: 20,
|
||||
};
|
||||
|
||||
// The first read waits its timeout and answers "unknown".
|
||||
expect(await dataDirFreeBytes(hanging)).toBeUndefined();
|
||||
expect(started).toBe(1);
|
||||
|
||||
// While that statfs is still out, later reads answer at once and start nothing.
|
||||
const before = Date.now();
|
||||
expect(await dataDirFreeBytes(hanging)).toBeUndefined();
|
||||
expect(await dataDirFreeBytes({ ...hanging, timeoutMs: 60_000 })).toBeUndefined();
|
||||
expect(Date.now() - before).toBeLessThan(1000);
|
||||
expect(started).toBe(1);
|
||||
|
||||
// Once it finally answers, the next read works again.
|
||||
finish(5);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(await dataDirFreeBytes({ ...hanging, freeBytesOf: async () => 42 })).toBe(42);
|
||||
});
|
||||
|
||||
it('never rejects when one of its reads does', async () => {
|
||||
const failing = async (): Promise<never> => {
|
||||
throw new Error('EIO');
|
||||
};
|
||||
expect(await dataDirFreeBytes({ dataDir: () => folder, freeBytesOf: failing })).toBeUndefined();
|
||||
expect(await dataDirFreeBytes({ dataDir: () => folder, probeKind: failing })).toBeUndefined();
|
||||
expect(
|
||||
await dataDirFreeBytes({
|
||||
dataDir: () => {
|
||||
throw new Error('no home');
|
||||
},
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is bounded by default', () => {
|
||||
expect(FREE_DISK_TIMEOUT_MS).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the real defaults', () => {
|
||||
let folder: string;
|
||||
|
||||
beforeEach(() => {
|
||||
folder = mkdtempSync(join(tmpdir(), 'codeman-local-model-hw-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(folder, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads the free bytes of a real folder, and undefined for one that does not exist', async () => {
|
||||
const free = await freeDiskBytesOf(folder);
|
||||
expect(typeof free).toBe('number');
|
||||
expect(free).toBeGreaterThanOrEqual(0);
|
||||
expect(await freeDiskBytesOf(join(folder, 'missing', 'deeper'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never rejects with the real spawn and the real disk read, whatever machine this is', async () => {
|
||||
// Pretend to be a Mac so the spawn and the disk read really run. On Linux /usr/sbin/sysctl
|
||||
// either does not exist or knows none of the keys: the answer is then the fallback.
|
||||
const hw = await readHardware({ platform: 'darwin' });
|
||||
expect(['sysctl', 'os-fallback']).toContain(hw.source);
|
||||
expect(hw.memBytes).toBeGreaterThan(0);
|
||||
expect(hw.memGb).toMatch(/^\d+(\.\d)?$/);
|
||||
if (hw.freeDiskBytes !== undefined) {
|
||||
expect(hw.freeDiskBytes).toBeGreaterThanOrEqual(0);
|
||||
expect(hw.freeDiskGb).toMatch(/^\d+(\.\d)?$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* @fileoverview `buildHarnessFacts()`: what the server knows about each catalogue harness on
|
||||
* this machine (src/local-model/harness-facts.ts), read from the CLI registry at call time.
|
||||
*
|
||||
* The catalogue here is the test's own: the stock one is filled by another module and its rows
|
||||
* are not what this file is about. The registry is the REAL stock registry unless a test says
|
||||
* otherwise, so the model id anchors below are regression anchors on shipped data.
|
||||
*
|
||||
* Two things a reader should know before trusting a green run:
|
||||
* - Under vitest the real availability probe is inert (it finds no CLI). A test that cares
|
||||
* about `installed` therefore injects `probeInstalled`.
|
||||
* - The registry's install command resolver reads `process.platform` and falls back to the
|
||||
* Linux command, so on a Linux machine it never returns a macOS command. The macOS cases
|
||||
* inject `installCommandFor` and read the entry's own `darwin` command.
|
||||
*
|
||||
* Port: none (no server; the on-disk case uses the per-file temp HOME from test/setup.ts).
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import {
|
||||
buildHarnessFacts,
|
||||
cliAcceptsCustomModelId,
|
||||
installCommandNeedsAdmin,
|
||||
} from '../src/local-model/harness-facts.js';
|
||||
import { getCli, registryFilePath, reloadCliRegistry } from '../src/config/cli-registry/registry.js';
|
||||
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
||||
import type { LocalModelCatalog } from '../src/types.js';
|
||||
|
||||
/** A catalogue whose harness rows are `clis`, in that order. Nothing else in it matters here. */
|
||||
function catalogWith(clis: string[]): LocalModelCatalog {
|
||||
return {
|
||||
schema: 1,
|
||||
updated: '2026-11-06',
|
||||
minMacosMajor: 14,
|
||||
promptCacheTiers: [{ minMemGb: 0, bytes: 512 * 1024 * 1024 }],
|
||||
models: [],
|
||||
harnesses: clis.map((cli, index) => ({ cli, minContext: 32768, rank: clis.length - index })),
|
||||
chips: [],
|
||||
};
|
||||
}
|
||||
|
||||
function stockEntry(id: string): CliEntry {
|
||||
const entry = getCli(id);
|
||||
if (!entry) throw new Error(`${id} is missing from the CLI registry`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The command the registry holds for macOS, whatever machine the test runs on. */
|
||||
const darwinCommand = (entry: CliEntry): string | undefined => entry.discovery.install.command.darwin;
|
||||
|
||||
/** A probe that reports exactly these CLIs as installed. */
|
||||
const installedOnly =
|
||||
(...ids: string[]) =>
|
||||
async () =>
|
||||
(entry: CliEntry) =>
|
||||
ids.includes(entry.id as string);
|
||||
|
||||
afterEach(() => {
|
||||
const path = registryFilePath();
|
||||
if (existsSync(path)) rmSync(path);
|
||||
reloadCliRegistry();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('installCommandNeedsAdmin', () => {
|
||||
it.each([
|
||||
['brew install can1357/tap/omp', true],
|
||||
[' brew install something', true],
|
||||
['sudo apt install something', true],
|
||||
['curl -fsSL https://example.com/install.sh | sudo bash', true],
|
||||
['npm install -g thing && sudo ln -s a b', true],
|
||||
['echo first; brew install thing', true],
|
||||
['echo first\nsudo make install', true],
|
||||
['(brew install thing)', true],
|
||||
['curl -fsSL https://opencode.ai/install | bash', false],
|
||||
['curl -fsSL https://claude.ai/install.sh | bash', false],
|
||||
['npm install -g --ignore-scripts @earendil-works/pi-coding-agent', false],
|
||||
['npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)', false],
|
||||
// The words inside a URL, a package name or an argument are not a command.
|
||||
['curl -fsSL https://brew.sh/install.sh | bash', false],
|
||||
['npm install -g homebrew-sudo-helper', false],
|
||||
['echo sudo', false],
|
||||
])('%s -> %s', (command, expected) => {
|
||||
expect(installCommandNeedsAdmin(command)).toBe(expected);
|
||||
});
|
||||
|
||||
it('agrees with the registry on every stock macOS command: only a Homebrew one needs an administrator', () => {
|
||||
expect(installCommandNeedsAdmin(darwinCommand(stockEntry('omp')) ?? '')).toBe(true);
|
||||
for (const id of ['claude', 'opencode', 'codex', 'pi']) {
|
||||
expect(installCommandNeedsAdmin(darwinCommand(stockEntry(id)) ?? '')).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cliAcceptsCustomModelId (the launch path’s model id test, on the stock registry)', () => {
|
||||
it('accepts a registry style `name:tag` id for the CLIs that can carry a colon', () => {
|
||||
// opencode and claude select the model in their config or environment: no launch id at all.
|
||||
// pi and codex force the id onto `--model`, and both of their patterns allow a colon.
|
||||
for (const id of ['opencode', 'claude', 'pi', 'codex']) {
|
||||
expect(cliAcceptsCustomModelId(stockEntry(id), 'qwen3.5:9b')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a colon for a CLI whose `--model` pattern has none, and accepts the same CLI without it', () => {
|
||||
const omp = stockEntry('omp');
|
||||
expect(cliAcceptsCustomModelId(omp, 'qwen3.5:9b')).toBe(false);
|
||||
expect(cliAcceptsCustomModelId(omp, 'qwen3-coder')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses the ids the launch routes’ own tests refuse', () => {
|
||||
// test/routes/quick-start-custom-model.test.ts pins both pairs against the real route.
|
||||
expect(cliAcceptsCustomModelId(stockEntry('pi'), 'qwen 3 with spaces')).toBe(false);
|
||||
expect(cliAcceptsCustomModelId(stockEntry('codex'), 'org/model@q4_k_m')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a CLI with no custom model mechanism', () => {
|
||||
expect(cliAcceptsCustomModelId(stockEntry('shell'), 'qwen3.5:9b')).toBe(false);
|
||||
expect(cliAcceptsCustomModelId(stockEntry('antigravity'), 'qwen3.5:9b')).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the entry it is given, so a changed pattern changes the answer (no id is looked at)', () => {
|
||||
const omp = stockEntry('omp');
|
||||
const widened: CliEntry = {
|
||||
...omp,
|
||||
launch: { ...omp.launch, params: { ...omp.launch.params, model: { type: 'token', pattern: 'model-pi' } } },
|
||||
};
|
||||
expect(cliAcceptsCustomModelId(widened, 'qwen3.5:9b')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHarnessFacts', () => {
|
||||
it('answers one fact per catalogue harness, in catalogue order, with the registry’s label', async () => {
|
||||
const facts = await buildHarnessFacts(catalogWith(['pi', 'opencode', 'claude']), {
|
||||
probeInstalled: installedOnly('opencode'),
|
||||
installCommandFor: darwinCommand,
|
||||
});
|
||||
expect(facts.map((f) => f.cli)).toEqual(['pi', 'opencode', 'claude']);
|
||||
expect(facts.map((f) => f.label)).toEqual(['pi', 'opencode', 'claude'].map((id) => stockEntry(id).label));
|
||||
expect(facts.map((f) => f.installed)).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
it('leaves out a CLI the registry does not know, one with no custom model mechanism, and a disabled one', async () => {
|
||||
const disabled = (id: string): CliEntry | undefined => {
|
||||
const entry = getCli(id);
|
||||
return entry && id === 'claude' ? { ...entry, enabled: false } : entry;
|
||||
};
|
||||
const facts = await buildHarnessFacts(catalogWith(['no-such-cli', 'shell', 'antigravity', 'claude', 'opencode']), {
|
||||
getCli: disabled,
|
||||
probeInstalled: installedOnly(),
|
||||
});
|
||||
expect(facts.map((f) => f.cli)).toEqual(['opencode']);
|
||||
});
|
||||
|
||||
it('keeps one fact for a CLI the catalogue lists twice', async () => {
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode', 'opencode']), { probeInstalled: installedOnly() });
|
||||
expect(facts.map((f) => f.cli)).toEqual(['opencode']);
|
||||
});
|
||||
|
||||
it('installable: not installed, a stock CLI, and a macOS command that needs no administrator', async () => {
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi', 'omp']), {
|
||||
probeInstalled: installedOnly('claude'),
|
||||
installCommandFor: darwinCommand,
|
||||
});
|
||||
const byCli = new Map(facts.map((f) => [f.cli, f]));
|
||||
// curl installer into the home directory.
|
||||
expect(byCli.get('opencode')).toMatchObject({ installed: false, installable: true });
|
||||
// Already there: nothing to install.
|
||||
expect(byCli.get('claude')).toMatchObject({ installed: true, installable: false });
|
||||
// npm: the install runner points it at a prefix the server user can write.
|
||||
expect(byCli.get('pi')).toMatchObject({ installed: false, installable: true });
|
||||
// Homebrew on macOS.
|
||||
expect(byCli.get('omp')).toMatchObject({ installed: false, installable: false });
|
||||
});
|
||||
|
||||
it('installable is false without an install command, for a sudo command, and for a custom entry', async () => {
|
||||
const command = vi.fn<(entry: CliEntry) => string | undefined>();
|
||||
command.mockReturnValueOnce(undefined);
|
||||
expect(
|
||||
(
|
||||
await buildHarnessFacts(catalogWith(['opencode']), {
|
||||
probeInstalled: installedOnly(),
|
||||
installCommandFor: command,
|
||||
})
|
||||
)[0].installable
|
||||
).toBe(false);
|
||||
command.mockReturnValueOnce(' ');
|
||||
expect(
|
||||
(
|
||||
await buildHarnessFacts(catalogWith(['opencode']), {
|
||||
probeInstalled: installedOnly(),
|
||||
installCommandFor: command,
|
||||
})
|
||||
)[0].installable
|
||||
).toBe(false);
|
||||
command.mockReturnValueOnce('sudo installer -pkg agent.pkg -target /');
|
||||
expect(
|
||||
(
|
||||
await buildHarnessFacts(catalogWith(['opencode']), {
|
||||
probeInstalled: installedOnly(),
|
||||
installCommandFor: command,
|
||||
})
|
||||
)[0].installable
|
||||
).toBe(false);
|
||||
|
||||
// A custom entry's install command is never run by the install runner, so it is never
|
||||
// offered, however harmless the text looks.
|
||||
const custom = (id: string): CliEntry | undefined => {
|
||||
const entry = getCli(id);
|
||||
return entry ? { ...entry, stock: false } : entry;
|
||||
};
|
||||
const harmless = vi.fn(() => 'curl -fsSL https://example.com/install | bash');
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode']), {
|
||||
getCli: custom,
|
||||
probeInstalled: installedOnly(),
|
||||
installCommandFor: harmless,
|
||||
});
|
||||
expect(facts[0].installable).toBe(false);
|
||||
expect(harmless).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never reads an install command for a CLI that is already installed', async () => {
|
||||
const command = vi.fn(() => 'curl -fsSL https://example.com/install | bash');
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode']), {
|
||||
probeInstalled: installedOnly('opencode'),
|
||||
installCommandFor: command,
|
||||
});
|
||||
expect(facts[0]).toMatchObject({ installed: true, installable: false });
|
||||
expect(command).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('acceptsModelId is the launch path’s test for that harness', async () => {
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi', 'omp']), {
|
||||
probeInstalled: installedOnly(),
|
||||
});
|
||||
const accepts = (cli: string, modelId: string) => facts.find((f) => f.cli === cli)?.acceptsModelId(modelId);
|
||||
expect(accepts('opencode', 'qwen3.5:9b')).toBe(true);
|
||||
expect(accepts('claude', 'qwen3.8:27b-q8_0')).toBe(true);
|
||||
expect(accepts('pi', 'gpt-oss:20b')).toBe(true);
|
||||
expect(accepts('omp', 'gpt-oss:20b')).toBe(false);
|
||||
});
|
||||
|
||||
it('asks the availability probe once per build, not once per harness', async () => {
|
||||
const probe = vi.fn(installedOnly('opencode'));
|
||||
await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi']), { probeInstalled: probe });
|
||||
expect(probe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a probe that fails is an answer: every harness reads as not installed, and install data still counts', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode', 'omp']), {
|
||||
probeInstalled: async () => {
|
||||
throw new Error('login shell did not answer');
|
||||
},
|
||||
installCommandFor: darwinCommand,
|
||||
});
|
||||
expect(facts.map((f) => [f.cli, f.installed, f.installable])).toEqual([
|
||||
['opencode', false, true],
|
||||
['omp', false, false],
|
||||
]);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(String(warn.mock.calls[0][0])).toContain('login shell did not answer');
|
||||
});
|
||||
|
||||
it('a probe that throws for one harness does not take the others down', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude']), {
|
||||
probeInstalled: async () => (entry) => {
|
||||
if (entry.label === stockEntry('opencode').label) throw new Error('boom');
|
||||
return true;
|
||||
},
|
||||
});
|
||||
expect(facts.map((f) => [f.cli, f.installed])).toEqual([
|
||||
['opencode', false],
|
||||
['claude', true],
|
||||
]);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads the registry when it is called, not when the module loaded', async () => {
|
||||
const before = await buildHarnessFacts(catalogWith(['opencode', 'claude']));
|
||||
expect(before.map((f) => f.cli)).toEqual(['opencode', 'claude']);
|
||||
|
||||
// The user disables a CLI in ~/.codeman/clis.json after the module was imported.
|
||||
const path = registryFilePath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify({ schemaVersion: 1, clis: { opencode: { enabled: false } } }), { mode: 0o600 });
|
||||
reloadCliRegistry();
|
||||
|
||||
const after = await buildHarnessFacts(catalogWith(['opencode', 'claude']));
|
||||
expect(after.map((f) => f.cli)).toEqual(['claude']);
|
||||
});
|
||||
|
||||
it('with its default dependencies it spawns nothing under vitest and finds no CLI installed', async () => {
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi']));
|
||||
expect(facts.map((f) => f.cli)).toEqual(['opencode', 'claude', 'pi']);
|
||||
expect(facts.every((f) => f.installed === false)).toBe(true);
|
||||
// opencode's install command is a curl installer on every platform the registry lists.
|
||||
expect(facts[0].installable).toBe(true);
|
||||
});
|
||||
|
||||
it('an empty harness list is an empty answer', async () => {
|
||||
expect(await buildHarnessFacts(catalogWith([]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @fileoverview The "Model on this Mac" card and its App Settings group in Chinese (zh-CN).
|
||||
*
|
||||
* Three sources of text, all checked against the real dictionary in i18n.js:
|
||||
* - the module's own strings, HARVESTED from the real local-model-ui.js (every literal it
|
||||
* passes to `localModelT`), so a new string without an entry fails here instead of showing
|
||||
* up English on one surface;
|
||||
* - every sentence and label the SERVER may send (`LOCAL_MODEL_LABELS`, imported from the
|
||||
* contract, so a label added there fails here until it has an entry);
|
||||
* - the static heading and hint of the Settings group, read from the real index.html and run
|
||||
* through the real DOM translator.
|
||||
*
|
||||
* Both containers carry data-i18n-skip (model, harness and chip names are someone else's
|
||||
* text), so nothing in them is reached by the DOM translator: these strings translate only
|
||||
* through the module's own call, which is why they are checked through `t()` here.
|
||||
*
|
||||
* Beyond "it is Chinese now", each entry must keep its `{placeholder}` slots: a translation
|
||||
* that drops one prints a sentence without its number.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { LOCAL_MODEL_LABELS, LOCAL_MODEL_NONE_LABELS } from '../src/types.js';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
|
||||
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
const MODULE = readFileSync(resolve(PUBLIC, 'local-model-ui.js'), 'utf8');
|
||||
|
||||
type Api = {
|
||||
configure(o: Record<string, string>): void;
|
||||
start(): void;
|
||||
t(source: string, vars?: Record<string, string>): string;
|
||||
};
|
||||
|
||||
/** Every literal the module hands to its translator call (Prettier may break after the paren). */
|
||||
const FROM_MODULE = [...new Set([...MODULE.matchAll(/localModelT\(\s*'([^']+)'/g)].map((m) => m[1]))];
|
||||
/** Every text the server may send. */
|
||||
const FROM_SERVER: string[] = Object.values(LOCAL_MODEL_LABELS);
|
||||
/** Already in the dictionary for the outside-sessions block; reused here for a failed request. */
|
||||
const SHARED = ['Could not reach the server.'];
|
||||
const OWN = FROM_MODULE.filter((source) => !SHARED.includes(source));
|
||||
|
||||
/** A page with only the translator in it: enough for `t()`, and cheap to re-configure. */
|
||||
const small = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
});
|
||||
vm.runInContext(I18N, small.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (small.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||
api.configure({ language: 'zh-CN' });
|
||||
|
||||
/** The real page, translated once, for the strings that live in index.html. */
|
||||
const page = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
|
||||
vm.runInContext(I18N, page.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const pageApi = (page.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||
pageApi.start();
|
||||
pageApi.configure({ language: 'zh-CN' });
|
||||
|
||||
afterAll(() => {
|
||||
small.window.close();
|
||||
page.window.close();
|
||||
});
|
||||
|
||||
/** The Settings group's static strings, as index.html ships them (English). */
|
||||
const GROUP_MARKUP = INDEX.match(
|
||||
/<div class="set-group" id="localModelGroup">\s*<div class="set-group-head"><h4>([^<]+)<\/h4><\/div>\s*<p class="set-group-hint">([^<]+)<\/p>/
|
||||
);
|
||||
const STATIC = GROUP_MARKUP ? [GROUP_MARKUP[1].trim(), GROUP_MARKUP[2].trim()] : [];
|
||||
|
||||
const ALL = [...new Set([...OWN, ...FROM_SERVER, ...STATIC])];
|
||||
|
||||
/** The `{slot}` names of a source text. */
|
||||
const slots = (text: string) => [...text.matchAll(/\{([a-zA-Z]\w*)\}/g)].map((m) => m[1]);
|
||||
|
||||
/** U+2013, U+2014 and U+2015, built from their code points so this file holds none of them. */
|
||||
const LONG_DASH = new RegExp(`[${String.fromCharCode(0x2013, 0x2014, 0x2015)}]`);
|
||||
|
||||
/** What may stay Latin: the product, the platform and its vendors, the unit, and Token (the dictionary's own word). */
|
||||
const leftover = (text: string) =>
|
||||
text.replace(/\b(Codeman|macOS|Mac|GB|Apple|Intel|Token)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
describe('the local model strings in zh-CN', () => {
|
||||
it('harvests the module (the check is not vacuous)', () => {
|
||||
expect(FROM_MODULE).toContain('Model on this Mac');
|
||||
expect(FROM_MODULE).toContain('{model} ({id}) with {harness}');
|
||||
expect(FROM_MODULE).toContain('Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).');
|
||||
expect(FROM_MODULE).toContain(
|
||||
'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.'
|
||||
);
|
||||
// What the fit and speed labels measure, and the speed of an alternative.
|
||||
expect(FROM_MODULE).toContain('Memory fit: needs {share}% of what this Mac can give a model');
|
||||
expect(FROM_MODULE).toContain('No speed estimate for this model on this chip');
|
||||
expect(FROM_MODULE).toContain('about {speed} tokens per second');
|
||||
expect(FROM_MODULE.length).toBeGreaterThanOrEqual(20);
|
||||
// The only other way the module calls its translator is with payload text (a server
|
||||
// sentence or label), so no string of its own can reach the page unharvested.
|
||||
const code = MODULE.split('\n')
|
||||
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
|
||||
.join('\n');
|
||||
const calls = code.split('localModelT(').length - 1;
|
||||
const literalCalls = [...code.matchAll(/localModelT\(\s*'/g)].length;
|
||||
const payloadCalls = [...code.matchAll(/localModelT\((message\.text, message\.vars|label)\)/g)].length;
|
||||
// One more: the function's own declaration.
|
||||
expect(calls).toBe(literalCalls + payloadCalls + 1);
|
||||
expect(payloadCalls).toBe(2);
|
||||
});
|
||||
|
||||
it('lists every server label, the none reasons included', () => {
|
||||
// A label added to the contract later fails below by its text, not here by a count.
|
||||
expect(FROM_SERVER.length).toBeGreaterThanOrEqual(18);
|
||||
for (const text of Object.values(LOCAL_MODEL_NONE_LABELS)) expect(FROM_SERVER).toContain(text);
|
||||
expect(FROM_SERVER).toContain('Best fit for this Mac');
|
||||
expect(FROM_SERVER).toContain('Speed unknown');
|
||||
// The two other sentences of the `memory` reason and the two notes are not in the
|
||||
// reason map, so they are named here.
|
||||
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noneMemoryContext);
|
||||
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noneMemoryUnknown);
|
||||
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noteSmallPick);
|
||||
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noteDiskDropped);
|
||||
});
|
||||
|
||||
it('never uses the translator slot {name} for a value of its own', () => {
|
||||
for (const text of ALL) expect(slots(text), text).not.toContain('name');
|
||||
});
|
||||
|
||||
it.each(ALL)('translates %s, with no English left', (text) => {
|
||||
const chinese = api.t(text);
|
||||
expect(chinese).not.toBe(text);
|
||||
expect(leftover(chinese)).toEqual([]);
|
||||
// No long dash in any form: commas, colons and parentheses only.
|
||||
expect(chinese).not.toMatch(LONG_DASH);
|
||||
});
|
||||
|
||||
it.each(ALL.filter((text) => slots(text).length > 0))('keeps every slot of %s', (text) => {
|
||||
// Digits only, so a marker is neither translated nor counted as leftover English.
|
||||
const vars = Object.fromEntries(slots(text).map((slot, i) => [slot, `90${i}1`]));
|
||||
const chinese = api.t(text, vars);
|
||||
for (const value of Object.values(vars)) expect(chinese, `${text} lost a slot`).toContain(value);
|
||||
expect(chinese).not.toMatch(/[{}]/);
|
||||
});
|
||||
|
||||
it('keeps the product name a slot, so a custom display name reaches the Chinese text', () => {
|
||||
api.configure({ language: 'zh-CN', displayName: 'Acme' });
|
||||
try {
|
||||
for (const text of ALL.filter((candidate) => candidate.includes('Codeman'))) {
|
||||
const chinese = api.t(text);
|
||||
expect(chinese, text).toContain('Acme');
|
||||
expect(chinese, text).not.toContain('Codeman');
|
||||
}
|
||||
} finally {
|
||||
api.configure({ language: 'zh-CN', displayName: 'Codeman' });
|
||||
}
|
||||
});
|
||||
|
||||
it('adds each key to the dictionary once', () => {
|
||||
// A repeated key in the object literal silently replaces the first. Prettier leaves a key
|
||||
// that is one word unquoted.
|
||||
for (const text of ALL) {
|
||||
const bare = /^\w+$/.test(text) ? I18N.split(`\n ${text}: `).length - 1 : 0;
|
||||
expect(I18N.split(`'${text}':`).length - 1 + bare, text).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('translates the Settings group heading and hint in the page, and leaves both containers alone', () => {
|
||||
expect(STATIC).toHaveLength(2);
|
||||
expect(STATIC[0]).toBe('Model on this Mac');
|
||||
const translated = page.window.document.getElementById('localModelGroup')!;
|
||||
const heading = translated.querySelector('.set-group-head h4')!.textContent!.trim();
|
||||
const hint = translated.querySelector('.set-group-hint')!.textContent!.trim();
|
||||
expect(heading).toBe('此 Mac 上的模型');
|
||||
expect(hint).not.toBe(STATIC[1]);
|
||||
expect(hint).toBe(api.t(STATIC[1]));
|
||||
expect(leftover(hint)).toEqual([]);
|
||||
// Written only by the module, through its own translator call.
|
||||
expect(INDEX).toMatch(/id="localModelCard"[^>]*data-i18n-skip/);
|
||||
expect(INDEX).toMatch(/id="localModelSettings"[^>]*data-i18n-skip/);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* @fileoverview GET /api/local-model: what this Mac is and which local model fits it.
|
||||
*
|
||||
* The route only composes four injected functions (the hardware probe, the catalogue getter,
|
||||
* the harness facts and the pure `recommend()`), so every test passes its own and no test
|
||||
* depends on what those modules compute. What is pinned here is the route's own contract:
|
||||
* the envelope, the admin gate in multi-user mode, that an unsupported platform and a failed
|
||||
* probe are answers (200) and never a 500, what does answer 500, and that a request writes
|
||||
* nothing.
|
||||
*
|
||||
* Port: N/A (app.inject()).
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { arch, homedir, totalmem } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import {
|
||||
defaultLocalModelRouteDeps,
|
||||
hardwareFromOs,
|
||||
registerLocalModelRoutes,
|
||||
type LocalModelRouteDeps,
|
||||
} from '../../src/web/routes/local-model-routes.js';
|
||||
import { buildHarnessFacts } from '../../src/local-model/harness-facts.js';
|
||||
import { formatGb, memClassGb } from '../../src/local-model/format.js';
|
||||
import { getDataDir } from '../../src/config/instance.js';
|
||||
import {
|
||||
LOCAL_MODEL_LABELS,
|
||||
type HardwareFacts,
|
||||
type HarnessFact,
|
||||
type LocalModelCatalog,
|
||||
type Recommendation,
|
||||
} from '../../src/types.js';
|
||||
|
||||
const CATALOG: LocalModelCatalog = {
|
||||
schema: 1,
|
||||
updated: '2026-11-06',
|
||||
minMacosMajor: 14,
|
||||
promptCacheTiers: [
|
||||
{ minMemGb: 0, bytes: 512 * 1024 * 1024 },
|
||||
{ minMemGb: 32, bytes: 2 * 1024 ** 3 },
|
||||
],
|
||||
models: [],
|
||||
harnesses: [
|
||||
{ cli: 'opencode', minContext: 65536, rank: 3 },
|
||||
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
|
||||
],
|
||||
chips: [],
|
||||
};
|
||||
|
||||
/** A 48 GB Apple silicon Mac whose chip is in no chips row, as the probe would report it. */
|
||||
const MAC: HardwareFacts = {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
arm64: true,
|
||||
translated: false,
|
||||
memBytes: 51539607552,
|
||||
memGb: '51.5',
|
||||
memClassGb: 48,
|
||||
chip: 'Apple M5 Pro',
|
||||
model: 'Mac17,13',
|
||||
macosVersion: '27.2',
|
||||
macosMajor: 27,
|
||||
wiredLimitMb: 0,
|
||||
freeDiskBytes: 912308310016,
|
||||
freeDiskGb: '912',
|
||||
source: 'sysctl',
|
||||
};
|
||||
|
||||
/** The same Mac after a `sysctl` spawn that failed: `node:os` supplied what little is known. */
|
||||
const MAC_FALLBACK: HardwareFacts = {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
memBytes: 51539607552,
|
||||
memGb: '51.5',
|
||||
memClassGb: 48,
|
||||
chip: 'Apple M5 Pro',
|
||||
source: 'os-fallback',
|
||||
};
|
||||
|
||||
const LINUX: HardwareFacts = {
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
memBytes: 67430404096,
|
||||
memGb: '67.4',
|
||||
memClassGb: 63,
|
||||
source: 'not-probed',
|
||||
};
|
||||
|
||||
const OK: Recommendation = {
|
||||
verdict: 'ok',
|
||||
headline: { text: LOCAL_MODEL_LABELS.headlineOk },
|
||||
notes: [{ text: LOCAL_MODEL_LABELS.noteUntested }],
|
||||
pick: {
|
||||
modelId: 'qwen3.8:27b',
|
||||
modelLabel: 'Qwen3.8 27B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 27.8,
|
||||
context: 131072,
|
||||
cli: 'opencode',
|
||||
cliLabel: 'OpenCode',
|
||||
cliInstalled: true,
|
||||
needBytes: 29551160064,
|
||||
needGb: '29.6',
|
||||
downloadBytes: 17740000000,
|
||||
downloadGb: '17.7',
|
||||
budgetShare: 0.79,
|
||||
fit: 'good',
|
||||
fitLabel: LOCAL_MODEL_LABELS.fitGood,
|
||||
speed: 'unknown',
|
||||
speedLabel: LOCAL_MODEL_LABELS.speedUnknown,
|
||||
tested: false,
|
||||
},
|
||||
alternatives: [],
|
||||
budgetBytes: 37580963840,
|
||||
budgetGb: '37.6',
|
||||
budgetSource: 'estimated',
|
||||
};
|
||||
|
||||
const NOT_MACOS: Recommendation = {
|
||||
verdict: 'none',
|
||||
reason: 'not-macos',
|
||||
headline: { text: LOCAL_MODEL_LABELS.noneNotMacos },
|
||||
notes: [],
|
||||
alternatives: [],
|
||||
budgetBytes: 0,
|
||||
budgetGb: '0.0',
|
||||
budgetSource: 'estimated',
|
||||
};
|
||||
|
||||
const FACTS: HarnessFact[] = [
|
||||
{ cli: 'opencode', label: 'OpenCode', installed: true, installable: false, acceptsModelId: () => true },
|
||||
];
|
||||
|
||||
interface Fakes {
|
||||
deps: LocalModelRouteDeps;
|
||||
readHardware: Mock<LocalModelRouteDeps['readHardware']>;
|
||||
getCatalog: Mock<LocalModelRouteDeps['getCatalog']>;
|
||||
buildHarnessFacts: Mock<LocalModelRouteDeps['buildHarnessFacts']>;
|
||||
recommend: Mock<LocalModelRouteDeps['recommend']>;
|
||||
}
|
||||
|
||||
function fakes(hardware: HardwareFacts = MAC, recommendation: Recommendation = OK): Fakes {
|
||||
const readHardware = vi.fn<LocalModelRouteDeps['readHardware']>(async () => hardware);
|
||||
const getCatalog = vi.fn<LocalModelRouteDeps['getCatalog']>(() => CATALOG);
|
||||
const buildFacts = vi.fn<LocalModelRouteDeps['buildHarnessFacts']>(async () => FACTS);
|
||||
const recommend = vi.fn<LocalModelRouteDeps['recommend']>(() => recommendation);
|
||||
return {
|
||||
deps: { readHardware, getCatalog, buildHarnessFacts: buildFacts, recommend },
|
||||
readHardware,
|
||||
getCatalog,
|
||||
buildHarnessFacts: buildFacts,
|
||||
recommend,
|
||||
};
|
||||
}
|
||||
|
||||
const harness = (deps: Partial<LocalModelRouteDeps>, authUser?: { username: string; role: 'admin' | 'user' }) =>
|
||||
createRouteTestHarness((app) => registerLocalModelRoutes(app, deps), authUser ? { authUser } : undefined);
|
||||
|
||||
const get = async (deps: Partial<LocalModelRouteDeps>, authUser?: { username: string; role: 'admin' | 'user' }) => {
|
||||
const { app } = await harness(deps, authUser);
|
||||
return app.inject({ method: 'GET', url: '/api/local-model' });
|
||||
};
|
||||
|
||||
/** Every file and folder under `root`, with its size and modification time. */
|
||||
function snapshot(root: string): string[] {
|
||||
if (!existsSync(root)) return [];
|
||||
const rows: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const path = join(dir, name);
|
||||
const stat = lstatSync(path);
|
||||
rows.push(`${path} ${stat.isDirectory() ? 'dir' : stat.size} ${stat.mtimeMs}`);
|
||||
if (stat.isDirectory()) walk(path);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return rows;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/local-model', () => {
|
||||
it('answers the success envelope with the hardware, the recommendation and the catalogue date', async () => {
|
||||
const f = fakes();
|
||||
const res = await get(f.deps);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({
|
||||
success: true,
|
||||
data: { hardware: MAC, recommendation: OK, catalogUpdated: '2026-11-06' },
|
||||
});
|
||||
});
|
||||
|
||||
it('hands recommend() exactly what the probe, the catalogue and the harness facts answered', async () => {
|
||||
const f = fakes();
|
||||
await get(f.deps);
|
||||
expect(f.readHardware).toHaveBeenCalledTimes(1);
|
||||
expect(f.buildHarnessFacts).toHaveBeenCalledTimes(1);
|
||||
expect(f.buildHarnessFacts).toHaveBeenCalledWith(CATALOG);
|
||||
expect(f.recommend).toHaveBeenCalledTimes(1);
|
||||
const [hw, catalog, facts] = f.recommend.mock.calls[0];
|
||||
expect(hw).toBe(MAC);
|
||||
expect(catalog).toBe(CATALOG);
|
||||
expect(facts).toBe(FACTS);
|
||||
});
|
||||
|
||||
it('sends the payload as the modules built it: no size is reformatted and no function leaks', async () => {
|
||||
const res = await get(fakes().deps);
|
||||
const { data } = res.json();
|
||||
expect(Object.keys(data).sort()).toEqual(['catalogUpdated', 'hardware', 'recommendation']);
|
||||
expect(data.hardware.memGb).toBe('51.5');
|
||||
expect(data.hardware.freeDiskGb).toBe('912');
|
||||
expect(data.recommendation.pick.needGb).toBe('29.6');
|
||||
expect(data.recommendation.budgetGb).toBe('37.6');
|
||||
// Harness facts hold a function and stay on the server.
|
||||
expect(res.body).not.toContain('acceptsModelId');
|
||||
expect(res.body).not.toContain('installable');
|
||||
});
|
||||
|
||||
it('registers a GET and nothing else: this stage has no route that changes anything', async () => {
|
||||
const { app } = await harness(fakes().deps);
|
||||
expect((await app.inject({ method: 'POST', url: '/api/local-model', payload: {} })).statusCode).toBe(404);
|
||||
expect((await app.inject({ method: 'DELETE', url: '/api/local-model' })).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
describe('multi-user mode', () => {
|
||||
it('refuses a non-admin with 403 before anything is probed', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const f = fakes();
|
||||
const res = await get(f.deps, { username: 'bob', role: 'user' });
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json()).toMatchObject({ success: false, errorCode: 'FORBIDDEN' });
|
||||
expect(res.body).not.toContain('Apple M5 Pro');
|
||||
expect(f.readHardware).not.toHaveBeenCalled();
|
||||
expect(f.getCatalog).not.toHaveBeenCalled();
|
||||
expect(f.buildHarnessFacts).not.toHaveBeenCalled();
|
||||
expect(f.recommend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers an admin', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const res = await get(fakes().deps, { username: 'root', role: 'admin' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.hardware.chip).toBe('Apple M5 Pro');
|
||||
});
|
||||
|
||||
it('is not gated while multi-user mode is off, whoever asks', async () => {
|
||||
const res = await get(fakes().deps, { username: 'bob', role: 'user' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an unsupported platform is an answer', () => {
|
||||
it('answers 200 with verdict none and reason not-macos on a server that is not a Mac', async () => {
|
||||
const f = fakes(LINUX, NOT_MACOS);
|
||||
const res = await get(f.deps);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { success, data } = res.json();
|
||||
expect(success).toBe(true);
|
||||
expect(data.hardware).toEqual(LINUX);
|
||||
expect(data.hardware.source).toBe('not-probed');
|
||||
expect(data.recommendation.verdict).toBe('none');
|
||||
expect(data.recommendation.reason).toBe('not-macos');
|
||||
expect(data.recommendation.pick).toBeUndefined();
|
||||
expect(data.recommendation.alternatives).toEqual([]);
|
||||
expect(data.recommendation.headline.text).toBe(LOCAL_MODEL_LABELS.noneNotMacos);
|
||||
expect(data.catalogUpdated).toBe('2026-11-06');
|
||||
expect(f.recommend.mock.calls[0][0]).toBe(LINUX);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a failed probe is an answer', () => {
|
||||
it('answers 200 when the probe reports that sysctl failed (source os-fallback)', async () => {
|
||||
const f = fakes(MAC_FALLBACK, OK);
|
||||
const res = await get(f.deps);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.hardware).toEqual(MAC_FALLBACK);
|
||||
expect(res.json().data.recommendation.verdict).toBe('ok');
|
||||
});
|
||||
|
||||
it('answers 200 from node:os when the probe itself rejects, and still recommends', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const f = fakes();
|
||||
f.readHardware.mockRejectedValue(new Error('spawn EAGAIN'));
|
||||
const res = await get(f.deps);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { success, data } = res.json();
|
||||
expect(success).toBe(true);
|
||||
expect(data.hardware).toEqual(hardwareFromOs());
|
||||
expect(data.hardware.memBytes).toBe(totalmem());
|
||||
expect(data.recommendation).toEqual(OK);
|
||||
expect(f.recommend).toHaveBeenCalledTimes(1);
|
||||
expect(f.recommend.mock.calls[0][0]).toEqual(hardwareFromOs());
|
||||
expect(String(warn.mock.calls[0][0])).toContain('spawn EAGAIN');
|
||||
});
|
||||
|
||||
it('hardwareFromOs() is a complete answer of its own, sized by the one formatter', () => {
|
||||
const hw = hardwareFromOs();
|
||||
expect(hw.platform).toBe(process.platform);
|
||||
expect(hw.arch).toBe(arch());
|
||||
expect(hw.memBytes).toBe(totalmem());
|
||||
expect(hw.memGb).toBe(formatGb(totalmem()));
|
||||
expect(hw.memClassGb).toBe(memClassGb(totalmem()));
|
||||
expect(hw.source).toBe(process.platform === 'darwin' ? 'os-fallback' : 'not-probed');
|
||||
// Nothing that only sysctl or statfs can know is invented.
|
||||
expect(Object.keys(hw).sort()).toEqual(['arch', 'memBytes', 'memClassGb', 'memGb', 'platform', 'source']);
|
||||
});
|
||||
|
||||
it('answers 200 when no harness fact could be built (the facts are just empty)', async () => {
|
||||
const none: Recommendation = {
|
||||
...NOT_MACOS,
|
||||
reason: 'no-harness',
|
||||
headline: { text: LOCAL_MODEL_LABELS.noneNoHarness },
|
||||
};
|
||||
const f = fakes(MAC, none);
|
||||
f.buildHarnessFacts.mockResolvedValue([]);
|
||||
const res = await get(f.deps);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.recommendation.reason).toBe('no-harness');
|
||||
expect(f.recommend.mock.calls[0][2]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a fault that is not a probe is a 500 in the envelope, never a made-up answer', () => {
|
||||
it.each([
|
||||
[
|
||||
'recommend() throws',
|
||||
(f: Fakes) =>
|
||||
f.recommend.mockImplementation(() => {
|
||||
throw new Error('rank table is empty');
|
||||
}),
|
||||
'rank table is empty',
|
||||
],
|
||||
[
|
||||
'the catalogue getter throws',
|
||||
(f: Fakes) =>
|
||||
f.getCatalog.mockImplementation(() => {
|
||||
throw new Error('catalogue did not parse');
|
||||
}),
|
||||
'catalogue did not parse',
|
||||
],
|
||||
[
|
||||
'the harness facts reject',
|
||||
(f: Fakes) => f.buildHarnessFacts.mockRejectedValue(new Error('registry unreadable')),
|
||||
'registry unreadable',
|
||||
],
|
||||
])('%s', async (_label, breakIt, message) => {
|
||||
const f = fakes();
|
||||
breakIt(f);
|
||||
const res = await get(f.deps);
|
||||
expect(res.statusCode).toBe(500);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('INTERNAL_ERROR');
|
||||
expect(body.error).toContain(message);
|
||||
expect(body.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('read-only', () => {
|
||||
it('writes nothing: the home directory and the data dir are byte for byte what they were', async () => {
|
||||
// The real harness facts (they read the CLI registry and run the availability probe),
|
||||
// the route's own composition, and a hardware answer and a recommendation of the test's.
|
||||
const f = fakes();
|
||||
const { app } = await harness({ ...f.deps, buildHarnessFacts: (catalog) => buildHarnessFacts(catalog) });
|
||||
|
||||
const home = homedir();
|
||||
const before = snapshot(home);
|
||||
const dataDirExisted = existsSync(getDataDir());
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/local-model' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
// A refused request and a failing one write nothing either.
|
||||
f.recommend.mockImplementationOnce(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect((await app.inject({ method: 'GET', url: '/api/local-model' })).statusCode).toBe(500);
|
||||
|
||||
expect(snapshot(home)).toEqual(before);
|
||||
expect(existsSync(getDataDir())).toBe(dataDirExisted);
|
||||
expect(existsSync(join(getDataDir(), 'settings.json'))).toBe(false);
|
||||
expect(existsSync(join(getDataDir(), 'local-model.json'))).toBe(false);
|
||||
expect(existsSync(join(getDataDir(), 'local-models.json'))).toBe(false);
|
||||
// The facts really were built from the registry for the catalogue's two harnesses.
|
||||
expect(f.recommend.mock.calls[0][2].map((fact) => fact.cli)).toEqual(['opencode', 'claude']);
|
||||
|
||||
// The comparison above is not vacuous: one new file in the home directory shows up.
|
||||
const probe = join(home, 'local-model-route-test-probe');
|
||||
writeFileSync(probe, 'x');
|
||||
try {
|
||||
expect(snapshot(home)).not.toEqual(before);
|
||||
} finally {
|
||||
rmSync(probe);
|
||||
}
|
||||
});
|
||||
|
||||
it('is registered on the real server through the routes barrel', () => {
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const server = readFileSync(join(root, 'src/web/server.ts'), 'utf8');
|
||||
const barrel = readFileSync(join(root, 'src/web/routes/index.ts'), 'utf8');
|
||||
expect(barrel).toContain("export { registerLocalModelRoutes } from './local-model-routes.js';");
|
||||
// No second argument: the server runs the real probe, catalogue, facts and recommend().
|
||||
expect(server).toContain('registerLocalModelRoutes(this.app);');
|
||||
});
|
||||
|
||||
it('the default dependencies are the real modules, read at call time', () => {
|
||||
expect(Object.keys(defaultLocalModelRouteDeps).sort()).toEqual([
|
||||
'buildHarnessFacts',
|
||||
'getCatalog',
|
||||
'readHardware',
|
||||
'recommend',
|
||||
]);
|
||||
for (const dep of Object.values(defaultLocalModelRouteDeps)) expect(typeof dep).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user