mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
An installed model knew one coding agent: the one it was installed with.
The launch check never asked about the agent, only about the endpoint
and the model, so nothing stopped a second one. What was missing is the
list, and a way to get an agent that is not on the Mac yet.
The install view gains `harnesses`: every catalogue harness the model in
use can be run with, each with how it stands (`ready`, `installable`,
`installing`, `setup-needed`), the literal install command, a readiness
mark for scripts, and the actions the server accepts for it. `cli` stays
the default agent, and names one that can be started whenever one can,
so a client that reads only `cli` never starts an agent that is gone.
`POST /api/local-model/agent { cli }` installs one more agent beside an
installed model. It is not a job and not an event of the reducer: the
state stays `installed` whatever the installer does, so a failed npm
install never blocks a session on a model that works. Same install slot
and same compiled stock command as the `agent` step, one at a time, the
outcome in memory only.
The catalogue:
- `pi` is no longer gated. Its two open questions were answered by a run
against a real model (the `name:tag` id reaches the runtime unchanged;
its first request is 2,096 tokens), and Codeman now passes it the
window. Its minimum is 65536 like the others: nothing below was run,
and no model row offers less, so no Mac's pick changes.
- `deepseek` is new. It is a launcher: its install command leaves it
with nothing to launch, so it is never reported installable (read off
`discovery.launcherProfile`, not the id) and a Mac is offered it only
where it was already set up. Its tool round trip was seen on a
scripted endpoint only; the header says how to gate it again.
- a row may carry `readyMark`; `opencode` has the measured one.
A switch no longer refuses for the agent a model was checked with when
another agent that can run it is on the Mac.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
620 lines
31 KiB
TypeScript
620 lines
31 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;
|
|
/**
|
|
* A literal with no white space that this harness's composer draws once it takes input, and
|
|
* nothing before it does: what a script waits for before it sends the first prompt
|
|
* (`GET /api/sessions/:id/wait-output`). Only a mark that was MEASURED on a session started
|
|
* on a custom model goes here. Absent: the CLI registry's own
|
|
* `capabilities.composerReadyMark` is used when it declares one, and a harness with neither
|
|
* has no mark.
|
|
*/
|
|
readyMark?: string;
|
|
}
|
|
|
|
/**
|
|
* 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 can be started on this machine: the same answer the Run menu gets. For a launcher
|
|
* that starts nothing by itself (`discovery.launcherProfile` in the CLI registry) that is its
|
|
* binary AND something for it to launch.
|
|
*/
|
|
installed: boolean;
|
|
/**
|
|
* Not installed, but ONE command makes it startable: the registry has an install command for
|
|
* this platform that needs no administrator rights, and the CLI is not a launcher. A launcher
|
|
* needs a second install the command does not make (a profile, which is third-party code
|
|
* with a consent of its own), so it is never installable from here.
|
|
*/
|
|
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;
|
|
/** The registry's `capabilities.composerReadyMark`, when the CLI declares one and it holds no white space. */
|
|
readyMark?: 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;
|
|
/**
|
|
* Set by the route, never by `recommend()`: this model at this context is a checked model of
|
|
* the library on this Mac (`LocalModelInstallView.library`). `active`: it is the model in
|
|
* use. `ready`: `POST /api/local-model/use` switches to it, and nothing is downloaded.
|
|
*/
|
|
library?: 'active' | 'ready';
|
|
}
|
|
|
|
/**
|
|
* 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.
|