Files
Codeman/src/types/local-model.ts
T
Codeman maintainer 2cd3cbd6a0 fix(local-model): count what llama-server keeps beside the K/V cache, and bound its checkpoints
The llama.cpp memory rule of the catalogue left out two allocations that the llama.cpp the
runtime bundles (b12111, the runtime's LLAMA_CPP_VERSION) makes for every GGUF row:

- Context checkpoints. For a model whose state cannot be rolled back (the recurrent state of
  the Qwen-family rows, the sliding window of North Mini Code), llama-server keeps host copies
  of that state, two or three per request, up to 32 per slot by default
  (tools/server/server-context.cpp create_checkpoint). For the 27B that is 32 x 157 MB = 5 GB,
  none of which was counted. The runtime now sets LLAMA_ARG_CTX_CHECKPOINTS=8 (read by
  llama-server from its environment like LLAMA_ARG_CACHE_RAM; 8 was llama.cpp's own default
  before it went to 32), and every llamacpp row counts 8 copies of its state.
- Multi-token prediction. The two -mtp tags and qwen3.8:27b-q8_0 carry a prediction layer
  (nextn_predict_layers 1, read from the first bytes of each GGUF; the 9B tags and North Mini
  Code have none). The runtime then always passes --spec-type draft-mtp --spec-draft-n-max 4,
  so llama.cpp keeps 4 rollback copies of the recurrent state (need_n_rs_seq(),
  llama-memory-recurrent.cpp) and the prediction layer's cache in the draft context's cache
  type, f16 by default. The rows counted one state and that layer at q8_0.

What moves (test/local-model-recommend.test.ts, "every memory size", re-pinned):

- 16 GB: limited -> none. MiMo 9B at 65,536 tokens needs 10.2 GB, 0.987 of the 10.4 GB
  estimate. With the budget the Mac mini measured (10.7 GB after the reserve) it is offered,
  0.954, tight; that test is kept. With 4 checkpoints instead of 8 it would be 0.966 on the
  estimate: this size sits on the edge, and only a run on such a Mac settles it.
- 18 GB: the same pick, 0.964 instead of 0.928.
- 36 GB: the same picks, 0.966 (27B on M4/M5 Max) and 0.976 (35B-A3B GGUF on M3).
- 48 GB on a Max chip: the 27B at 262,144 tokens is tight (0.853) instead of good.
- 32 GB with a measured budget of 0.74: North Mini Code at 131,072 tokens (was 262,144), the
  27B at 65,536 on a Max chip (was 131,072).
- Every MLX row, and everything from 64 GB up, is unchanged.

test/local-model-catalog.test.ts recomputes the new rule from the architecture table and holds
the catalogue to LLAMA_ARG_CTX_CHECKPOINTS. The catalogue header, runtime.ts, the types,
docs/local-models.md, CLAUDE.md and architecture-invariants say the same. Read in the source,
not measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:26:57 +01:00

594 lines
30 KiB
TypeScript

/**
* @fileoverview Types of the hardware-aware local model recommendation: what the server sees of
* this Mac, the catalogue of models and harnesses, and the recommendation part of the answer of
* `GET /api/local-model`.
*
* Everything here is the READ side: Codeman looks at the Mac and recommends. The types of the
* install (the job states, the progress, the `install` object of the GET, the request bodies,
* the state file, the runtime pin) and `LocalModelStatus`, the whole `data` of the GET, are in
* src/types/local-model-install.ts. Both files go out through the barrel (`../types.js`).
*
* 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).
*
* A field that is optional here is optional by meaning, and its comment says when it is
* absent (a tested entry without a measured speed, a harness that needs no install command, a
* row without a tag). Everything else is always there, so the chooser window and the install
* job may rely on it.
*
* 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 candidate that matches an entry
* (the same chip string, the same memory size as sold, the same harness) is `tested`: it ranks
* before every candidate that is not, and as the pick it 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;
/**
* The generation speed measured in that test, in tokens per second (67.7 for the
* 2026-11-06 run on an Apple M5 Pro with 48 GB). A candidate that matches this entry reports
* this figure as `Candidate.tokensPerSecond` in place of the estimate from the chip's memory
* bandwidth: a measurement on the same Mac beats a formula.
*/
tokensPerSecond?: number;
}
/** The runner of the model runtime that runs a model on a Mac, by the registry's name for it. See `LocalModelEntry.runner`. */
export type LocalModelRunner = 'mlx' | 'llamacpp';
/** 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;
/**
* The runner the runtime uses for this tag ON A MAC, as the registry names it: `mlx` (a
* safetensors build, or a manifest list with an MLX child, which a Mac always takes) or
* `llamacpp` (a GGUF build, run by llama-server on Metal). The two spend memory in different
* ways, so a row's memory figures are only meaningful together with its runner: llama.cpp
* allocates the whole K/V cache for the context when the model loads (in the runtime's
* `OLLAMA_KV_CACHE_TYPE`), MLX grows it with the tokens actually used and keeps snapshots of
* it besides.
*/
runner: LocalModelRunner;
/**
* Size of the weights in bytes: both the download and what they take in memory.
*
* It is the size of the build the runtime really downloads ON A MAC: the build of `runner`,
* every layer of its manifest plus its config, which is the `total` a real pull reports.
* That is not always the first manifest the registry answers with: `qwen3.6:35b-a3b` is an
* MLX build of 23,105,798,956 bytes on Apple silicon, 2% more than its GGUF build of
* 22,621,314,381.
*/
bytes: number;
/**
* Bytes that stay HELD in memory per token of context. For a `llamacpp` row: the K/V cache
* of its attention layers in the runtime's cache type, which llama.cpp allocates for the
* whole context at load (a multi-token-prediction layer's in f16). For an `mlx` row: the K/V cache in bf16 plus the snapshots of the
* recurrent state the runner keeps every 8,192 tokens, which it grows with use. Measured
* on one row (36,000 to 38,000 on the MLX runner, so about 37,000); computed from the
* model's config for the others, see `figures` and the catalogue header.
*/
heldBytesPerToken: number;
/**
* What the runner holds beside the weights and the context, whatever the context: its own
* buffers, the live recurrent or sliding-window caches, and the caches it keeps of earlier
* prompts (for `llamacpp` the 1024 MiB host prompt cache `LLAMA_ARG_CACHE_RAM` allows and
* the copies of the recurrent or sliding-window state in its `LLAMA_ARG_CTX_CHECKPOINTS`
* context checkpoints, for an estimated `mlx` row the 8 GiB of prefix snapshots the runner
* may keep). Measured on one row (about 3 GB); see the catalogue header for the others.
*/
overheadBytes: number;
/**
* The TRANSIENT extra the runner takes while it processes a long prompt, on top of
* what stays held (measured on the MLX runner: about 5 GB; 0 for `llamacpp`, which
* allocates its buffers at load). The fit is judged on the peak:
* `bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`. For
* `qwen3.6:35b-a3b` at 131072 tokens that is about 36 GB, which is tight on a 48 GB Mac.
*/
peakExtraBytes: number;
/**
* Where the three memory figures above come from: `measured` on a Mac by us, or
* `estimated` (computed from the model's architecture for its runner, see the catalogue
* header). `bytes` is the registry's size of the build a Mac pulls in both cases.
*/
figures: 'measured' | 'estimated';
/** 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 for every row but one. */
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;
}
/** 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;
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;
/**
* The literal install command the install job would run for this CLI: the STOCK
* command of the CLI registry for this platform, never one a `clis.json` override supplies.
* Present exactly when `installable` is true. The consent screen prints it
* (`CandidateHarness.installCommand`).
*/
installCommand?: string;
/** 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`. The need is
* the PEAK, so the grade says how much room is left at the worst moment. It decides whether a
* model is offered at all (`out` never is) and what its row says (`tight`). It does not sort
* the list.
*/
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.
* - `estimated`: a formula over the installed memory. All there is before the runtime ran.
* - `wired-limit`: `iogpu.wired_limit_mb` above 0.
* - `measured`: the device total the runtime itself printed when it started
* (`RecommendOptions.measuredTotalBytes`). The chooser says "estimated" next to the memory
* bar for the first value only.
*/
export type BudgetSource = 'estimated' | 'wired-limit' | 'measured';
/** The memory a model may use on this Mac. */
export interface DeviceBudget {
/**
* L: the measured device total when there is one, else the wired limit when above 0, else
* 2/3 of memory below 36 GB and 3/4 from 36 GB.
*/
limitBytes: number;
/**
* What `Candidate.budgetShare` is measured against. Estimated or from the wired limit:
* B = min(L - 1 GiB, memory - reserve). Measured: B = min(L, memory - reserve), without the
* 1 GiB, because the runtime's figure is already what it will really use (37.4 GiB on the
* 48 GB test Mac, which reads '40.2').
*/
budgetBytes: number;
source: BudgetSource;
}
/**
* What the caller of `recommend()` knows beyond the hardware probe. Both members are optional:
* an empty object means the budget is the estimate and an install downloads the model alone.
*/
export interface RecommendOptions {
/**
* The device total the runtime reported in its log line "inference compute"
* (`total="37.4 GiB"`), in bytes: `parseInferenceComputeTotal()` in
* src/local-model/runtime.ts. Present: the budget is `measured`. The install job passes it
* right after the runtime started, before the model is downloaded, and the GET passes the
* stored figure from then on.
*/
measuredTotalBytes?: number;
/**
* What an install adds to every candidate's download beside the model: the size of the
* pinned runtime archive while the runtime is not installed, 0 once it is. It is the
* difference between `Candidate.installBytes` and `Candidate.downloadBytes`. Default 0.
*/
runtimeDownloadBytes?: number;
}
/**
* 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: one catalogue row has a tested entry (`qwen3.6:35b-a3b` on an Apple
* M5 Pro with 48 GB), so one kind of Mac is told so.
*/
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: 'Codeman has not run this on a Mac like this yet.',
/** 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: 'Local coding models need Apple silicon, and this Mac has an Intel processor.',
/** Reason `memory`: nothing fits at any context. */
noneMemory: 'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.',
/** Reason `memory`: a model fits, but only below the context a harness needs. Says both numbers, and ("about") that they are estimates. */
noneMemoryContext:
'This Mac can give a model about {budget} GB of memory, and the smallest model a coding agent can work with needs about {need} GB.',
/** 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.',
/** Reason `disk`. One number: what to do. The other sizes travel in `vars` for a client that wants them. */
noneDisk: 'Free up about {missing} GB of disk space to install the smallest model that fits this Mac.',
noneMacos: 'Local models need macOS {minimum} or newer, and this Mac runs macOS {version}.',
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>;
}
/**
* The one tag a model row of the chooser carries, by what it says.
* - `tested`: `Candidate.tested` is true. Replaces every other tag.
* - `best-tight`: the pick, when its `fit` is `tight`. The words must not say less than the
* memory bar beside them, which is nearly full for such a row.
* - `best`: the pick.
* - `tight`: `Candidate.fit` is `tight`.
* - `slow`: `Candidate.speed` is `slow`.
* - `small`: the model has fewer than 12 billion parameters.
* A row gets the FIRST kind of this list that applies and no tag when none does. `tested`,
* `best` and `best-tight` are the tags of the row the window opens on, drawn in the accent
* colour.
*/
export type CandidateTagKind = 'tested' | 'best-tight' | 'best' | 'tight' | 'slow' | 'small';
/**
* The text of each tag, in English (the i18n source language), like `LOCAL_MODEL_LABELS`. A
* constant of its own so that a consumer can list the tag texts apart from the sentences.
*/
export const LOCAL_MODEL_TAG_LABELS = {
tested: 'Tested on a Mac like this',
'best-tight': 'Best fit, tight',
best: 'Best fit',
tight: 'Tight',
slow: 'Slow',
small: 'Small model',
} as const satisfies Record<CandidateTagKind, string>;
/** One of the texts in `LOCAL_MODEL_TAG_LABELS`. */
export type LocalModelTagLabel = (typeof LOCAL_MODEL_TAG_LABELS)[CandidateTagKind];
/** The tag of a model row: what kind it is (for the styling) and the text to print. */
export interface CandidateTag {
kind: CandidateTagKind;
/** `LOCAL_MODEL_TAG_LABELS[kind]`. */
label: LocalModelTagLabel;
}
/**
* One coding agent a candidate's model can be run with on this Mac, for the "Runs
* with" select of the chooser. Everything a browser needs is here, so it compares no CLI id.
*/
export interface CandidateHarness {
/** The CLI registry id, as data: what `POST /api/local-model/install` is sent as `cli`. */
cli: string;
/** The registry's display label ('OpenCode'). */
cliLabel: string;
/** False: the install job installs it first (it is installable, or it would not be listed). */
installed: boolean;
/**
* The literal command the install job would run to install it (`HarnessFact.installCommand`).
* Present exactly when `installed` is false. The consent screen prints it as it is.
*/
installCommand?: string;
/** The harness row's `slowFirstTurn`. */
slowFirstTurn?: boolean;
}
/** 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;
/**
* What the model needs in memory at this context: the PEAK, `bytes + heldBytesPerToken *
* context + overheadBytes + peakExtraBytes` of the catalogue row. The memory bar of the
* chooser is filled to `budgetShare`, which is this over the budget.
*/
needBytes: number;
/** `needBytes` as decimal GB text, no unit. */
needGb: string;
/**
* What `needBytes` is made of, for the chooser's memory map: the model (`bytes +
* overheadBytes`), its context in use (`heldBytesPerToken * context`) and the brief peaks
* (`peakExtraBytes`). The three add up to `needBytes`.
*/
memory?: { modelBytes: number; contextBytes: number; peakBytes: number };
/** 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;
/**
* Generation speed in tokens per second, rounded to one decimal. Present exactly when `speed`
* is not `unknown`; `speed` is decided on the unrounded figure. An estimate from the chip's
* memory bandwidth, except for a candidate that is `tested`, which reports the
* speed measured in that test (`TestedOn.tokensPerSecond`).
*/
tokensPerSecond?: number;
/**
* A `TestedOn` entry of the model matches this Mac (same chip, same `memClassGb`) and this
* harness. Such a candidate ranks first, and its `tag` says so in place of any other tag.
*/
tested: boolean;
/** `LocalModelEntry.figures` of the model: whether `needBytes` rests on a measurement of this model. */
needSource: 'measured' | 'estimated';
/** The row's one tag, see `CandidateTagKind`. Absent when no kind applies. */
tag?: CandidateTag;
/**
* Every coding agent this model can be run with at `context` on this Mac, best
* first. The first entry is the one the flat fields above name (`cli`, `cliLabel`,
* `cliInstalled`, `slowFirstTurn`). Never empty.
*/
harnesses: CandidateHarness[];
/**
* The whole download a click on Install starts for this row: `downloadBytes` plus
* `RecommendOptions.runtimeDownloadBytes`. It is the server's own total, and exactly the
* number `POST /api/local-model/install` must be sent as `acknowledgedBytes`.
*/
installBytes: number;
/** `installBytes` as decimal GB text, no unit. */
installGb: string;
}
/**
* 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), best first: tested
* before not tested, then ok before unknown before slow speed, then the higher model rank.
* Each is at the largest context it fits at, with the best harness that can run it there.
* 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;
}
// `LocalModelStatus`, the `data` of `GET /api/local-model`, is in src/types/local-model-install.ts.