mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49: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>
851 lines
43 KiB
TypeScript
851 lines
43 KiB
TypeScript
/**
|
|
* @fileoverview Types of the local model INSTALL: the runtime pin, the job's states, steps and
|
|
* error codes, the progress, the `install` object of `GET /api/local-model`, the bodies and
|
|
* answers of the install verbs, and the shape of the state file.
|
|
*
|
|
* The read side (hardware, catalogue, recommendation) is in src/types/local-model.ts. This
|
|
* file holds `LocalModelStatus`, the whole `data` of the GET, because it joins the two.
|
|
*
|
|
* Who produces what:
|
|
* - `LocalModelRuntimePin`: a compiled constant in src/local-model/runtime.ts. No file, no
|
|
* setting and no request can change which binary is downloaded.
|
|
* - `LocalModelStateFile`: src/local-model/state-store.ts, the ONE writer of
|
|
* `local-model.json` (mode 0600, temp file + rename).
|
|
* - `LocalModelInstallView`: src/local-model/install-job.ts (`buildInstallView()`, pure), from
|
|
* the state file plus what is live (the runtime child, the developer tools check).
|
|
* - `LocalModelStatus` and the verb answers: src/web/routes/local-model-routes.ts.
|
|
*
|
|
* The HTTP contract. Every route is admin only in multi-user mode (403 `FORBIDDEN`) and
|
|
* EXPERIMENTAL like the GET (docs/versioning-policy.md). Every verb answers
|
|
* `{ success: true, data: LocalModelInstallView }` with the view AFTER the verb.
|
|
*
|
|
* | Route | Body | Refusals |
|
|
* |----------------------------------|-----------------------------|-------------------------------------------------------|
|
|
* | `GET /api/local-model` | none | none (a failed probe is an answer) |
|
|
* | `POST /api/local-model/install` | `LocalModelInstallRequest` | 400 not a row the server offers, or a saved endpoint |
|
|
* | | | already has the reserved id; 409 `job-running`, |
|
|
* | | | `already-installed` (this model at this context is |
|
|
* | | | the one in use), `size-changed`, `sessions-open` |
|
|
* | `POST /api/local-model/use` | `LocalModelUseRequest` | 400 not a checked model of the library, or no coding |
|
|
* | | | agent for it is installed; 409 `job-running`, |
|
|
* | | | `wrong-state`, `already-installed`, `sessions-open` |
|
|
* | `POST /api/local-model/cancel` | none | 409 `wrong-state` (no job is running) |
|
|
* | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `job-running`, `wrong-state` (not paused or |
|
|
* | | | failed), `sessions-open` (another port while live |
|
|
* | | | sessions use the model) |
|
|
* | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | 409 `wrong-state` (not installed), `job-running`, |
|
|
* | | | `sessions-open` (stop, or another port, while live |
|
|
* | | | sessions use the model) |
|
|
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | 409 `job-running`, `sessions-open` |
|
|
* | `DELETE /api/local-model/models` | `LocalModelDeleteRequest` | 400 not a model of this Mac; 409 `job-running`, |
|
|
* | | | `wrong-state`, `model-active`, `runtime-not-running`; |
|
|
* | | | 500 the runtime did not delete it |
|
|
*
|
|
* A 409 is the error envelope plus `data: LocalModelConflict`:
|
|
* `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`. A 409
|
|
* WITHOUT `data` is a write of `settings.json` or of the endpoint store that was refused
|
|
* because the file does not parse; `error` names the fix. A 400 the service names carries its
|
|
* sentence the same way: `{ success: false, error, errorCode: 'INVALID_INPUT', data: {
|
|
* message } }` (`LocalModelRefusal`), so the window can print it in its own language.
|
|
*
|
|
* The rules of src/types/local-model.ts hold here too:
|
|
* 1. A size a person reads travels twice: as bytes and as decimal GB text from `formatGb()`
|
|
* (`completedGb`, `totalGb`, `downloadGb`). The browser prints the text and adds or
|
|
* divides nothing for display. `mbPerSecond` is the one other unit, decimal MB per second.
|
|
* 2. Every sentence the server chooses comes from `LOCAL_MODEL_INSTALL_LABELS`, with its
|
|
* numbers and names in `vars`. `{name}` is taken by the translator, so no slot has that name.
|
|
* 3. A CLI id is data (`LocalModelInstallView.cli`): the browser passes it on and compares it
|
|
* with nothing. What a person reads is `cliLabel`.
|
|
*/
|
|
|
|
import type { HardwareFacts, Recommendation } from './local-model.js';
|
|
|
|
// ---------- The runtime pin ----------
|
|
|
|
/**
|
|
* The one runtime build Codeman downloads and runs. A compiled constant
|
|
* (`OLLAMA_RUNTIME_PIN` in src/local-model/runtime.ts): the hash is compared before anything
|
|
* is unpacked, so the pin and the hash must come from the same reviewed source file.
|
|
*/
|
|
export interface LocalModelRuntimePin {
|
|
/** The runtime's family. One value today. */
|
|
id: 'ollama';
|
|
/** The release ('0.40.15'). `GET /api/version` of the running runtime must answer it. */
|
|
version: string;
|
|
/** Where the archive is downloaded from. https, a fixed release asset. */
|
|
url: string;
|
|
/** Size of the archive in bytes. A body that grows past it is refused before the hash is finished. */
|
|
bytes: number;
|
|
/** Lowercase hex sha256 of the archive. */
|
|
sha256: string;
|
|
/** What the unpacked folder takes on disk, in bytes (measured: about 579 MB). */
|
|
unpackedBytes: number;
|
|
/** The oldest macOS major version the runtime supports. */
|
|
minMacosMajor: number;
|
|
}
|
|
|
|
// ---------- States, steps, codes ----------
|
|
|
|
/**
|
|
* Where the install stands. The verb of the flow is the same throughout: install, installing,
|
|
* installed.
|
|
* - `idle`: nothing is installed and no job exists.
|
|
* - `installing`: the one job is running; `step` says where.
|
|
* - `paused`: the job stopped at `step` because of Cancel or a server restart. It never
|
|
* continues without a click.
|
|
* - `failed`: the job stopped at `step` with `error`.
|
|
* - `installed`: the model is connected. Whether the runtime runs is `running`, not a state.
|
|
* The state table (what is on disk, what a restart and Cancel do, the allowed actions) is
|
|
* `INSTALL_STATE_TABLE` in src/local-model/install-job.ts.
|
|
*/
|
|
export type LocalModelInstallState = 'idle' | 'installing' | 'paused' | 'failed' | 'installed';
|
|
|
|
/**
|
|
* The steps of a job, in the order they run.
|
|
* - `runtime`: get the pinned runtime (download, compare the hash, unpack), start it, read
|
|
* the measured memory budget and judge the chosen model against it.
|
|
* - `model`: download the model (the runtime's pull).
|
|
* - `verify`: load the model and check it: one tiny chat, the memory and context the runtime
|
|
* reports, one tool call.
|
|
* - `agent`: install the coding agent. Left out of a job whose coding agent is installed.
|
|
* - `connect`: save the connection (the endpoint record and the settings flag).
|
|
*/
|
|
export type LocalModelInstallStep = 'runtime' | 'model' | 'verify' | 'agent' | 'connect';
|
|
|
|
/** Every step, in order. A job runs these, without `agent` when the coding agent is installed. */
|
|
export const LOCAL_MODEL_INSTALL_STEPS: readonly LocalModelInstallStep[] = [
|
|
'runtime',
|
|
'model',
|
|
'verify',
|
|
'agent',
|
|
'connect',
|
|
];
|
|
|
|
/**
|
|
* Why a job failed, or (in state `installed`) what blocks a session. Each code has one
|
|
* sentence (`LOCAL_MODEL_ERROR_LABELS`) and one helpful next step (`LOCAL_MODEL_ERROR_NEXT`).
|
|
*
|
|
* | Code | Raised when |
|
|
* |---------------------------|--------------------------------------------------------------------------|
|
|
* | `runtime-download-failed` | the archive download still fails after its retries |
|
|
* | `runtime-damaged` | the archive's sha256 is not the pinned one (the file was deleted) |
|
|
* | `runtime-unpack-failed` | `/usr/bin/tar` failed |
|
|
* | `runtime-start-failed` | the runtime was started and never answered `/api/version`. Installed: |
|
|
* | | the last start failed (the detail says why), and Start tries again |
|
|
* | `runtime-stopped` | the runtime crashed and was restarted 3 times, then given up: on an |
|
|
* | | installed model, or during a job's download |
|
|
* | `runtime-not-running` | installed: the runtime is not running (the person stopped it). Only a |
|
|
* | | refused session start says it; the view shows no error for a stopped runtime |
|
|
* | `port-in-use` | another program listens on the stored port, or no port of the span is free |
|
|
* | `disk-full` | a write failed for lack of space |
|
|
* | `pull-failed` | the pull still ends on an error line or a cut stream after its retries |
|
|
* | `model-not-found` | the registry no longer has this model under this name |
|
|
* | `does-not-fit` | the measured budget rules the model out, or the runtime did not put all of it on the GPU |
|
|
* | `context-not-applied` | the runtime loaded the model with another context than the chosen one |
|
|
* | `load-failed` | the tiny chat that loads the model failed |
|
|
* | `no-tool-call` | the tool probe was answered without a tool call with parseable arguments |
|
|
* | `probe-unfinished` | the tool probe ended on its token limit, so it proves nothing |
|
|
* | `harness-failed` | the coding agent's install command failed, or its binary is still missing |
|
|
* | `endpoint-id-taken` | a saved endpoint with our id exists that this feature did not write |
|
|
* | `connect-failed` | the endpoint record or the settings flag could not be written |
|
|
* | `model-changed` | installed: the model's digest is not the one stored at install time. A |
|
|
* | | switch: the model's digest is not the one it was checked with |
|
|
* | `job-unfinished` | a job that changes the model in use is running or stopped. Only a |
|
|
* | | refused session start says it: nothing is started until the job ended |
|
|
* | `internal` | anything unexpected (the exception text is in `detail`) |
|
|
*/
|
|
export type LocalModelErrorCode =
|
|
| 'runtime-download-failed'
|
|
| 'runtime-damaged'
|
|
| 'runtime-unpack-failed'
|
|
| 'runtime-start-failed'
|
|
| 'runtime-stopped'
|
|
| 'runtime-not-running'
|
|
| 'port-in-use'
|
|
| 'disk-full'
|
|
| 'pull-failed'
|
|
| 'model-not-found'
|
|
| 'does-not-fit'
|
|
| 'context-not-applied'
|
|
| 'load-failed'
|
|
| 'no-tool-call'
|
|
| 'probe-unfinished'
|
|
| 'harness-failed'
|
|
| 'endpoint-id-taken'
|
|
| 'connect-failed'
|
|
| 'model-changed'
|
|
| 'job-unfinished'
|
|
| 'internal';
|
|
|
|
/**
|
|
* The ONE action that helps after an error. The window draws one button for it.
|
|
* - `retry`: "Try again". `POST /api/local-model/resume`.
|
|
* - `other-port`: "Use another port". `POST /api/local-model/resume` with `newPort: true`
|
|
* (state `failed`), or `POST /api/local-model/runtime` with `action: 'start'` and
|
|
* `newPort: true` (state `installed`).
|
|
* - `next-model`: "Try <model>". Back to the chooser with `error.nextModel` preselected; the
|
|
* person confirms its sizes and a new `POST /api/local-model/install` replaces the job.
|
|
* Without a `nextModel` the window offers the chooser and Remove.
|
|
* - `start`: "Start". `POST /api/local-model/runtime` with `action: 'start'`.
|
|
* - `remove`: "Remove". `DELETE /api/local-model`.
|
|
*
|
|
* `LOCAL_MODEL_ERROR_NEXT` is the default of a code. The view adapts it to the state the
|
|
* error is shown in, so the button is always one the server accepts there: a code whose
|
|
* default is `start` says `retry` on a failed job (there is no Start verb for a job), and
|
|
* `runtime-start-failed` says `start` on an installed model (there is nothing to resume).
|
|
*/
|
|
export type LocalModelNextStep = 'retry' | 'other-port' | 'next-model' | 'start' | 'remove';
|
|
|
|
/**
|
|
* What a client may do in the current state. The view's `actions` lists exactly the ones the
|
|
* server would accept right now, so the window shows a button only for those.
|
|
* - `install`: `POST /api/local-model/install`.
|
|
* - `cancel`: `POST /api/local-model/cancel`.
|
|
* - `resume`: `POST /api/local-model/resume`.
|
|
* - `start` and `stop`: `POST /api/local-model/runtime`.
|
|
* - `remove`: `DELETE /api/local-model`. In `idle` it is listed only while the model store
|
|
* still holds something (models an earlier Remove kept): it is then what frees that space.
|
|
* - `start-session`: not a route of this feature: the window starts a session on the endpoint
|
|
* `endpointId` with the coding agent `cli`. Offered only while the runtime runs and nothing
|
|
* blocks a session.
|
|
* - `use`: `POST /api/local-model/use`. Listed only while a row of `library` offers it.
|
|
* - `delete-model`: `DELETE /api/local-model/models`. Listed only while a row of `library`
|
|
* offers it.
|
|
* - `install-agent`: `POST /api/local-model/agent`. Listed only while a row of `harnesses`
|
|
* offers it.
|
|
*/
|
|
export type LocalModelAction =
|
|
| 'install'
|
|
| 'cancel'
|
|
| 'resume'
|
|
| 'start'
|
|
| 'stop'
|
|
| 'remove'
|
|
| 'start-session'
|
|
| 'use'
|
|
| 'delete-model'
|
|
| 'install-agent';
|
|
|
|
/** Whether the Mac has the developer tools (git, Python) a coding agent calls. */
|
|
export type LocalModelDeveloperTools =
|
|
/** `/usr/bin/xcode-select -p` succeeded. */
|
|
| 'present'
|
|
/** It failed: macOS will offer to install the tools the first time an agent needs one. */
|
|
| 'missing'
|
|
/** Not macOS, or the check did not answer in time. Nothing is said then. */
|
|
| 'unknown';
|
|
|
|
// ---------- Sentences ----------
|
|
|
|
/**
|
|
* Every sentence the server may put into the `install` object or a 409, in English (the i18n
|
|
* source language). Like `LOCAL_MODEL_LABELS`: the zh-CN dictionary in src/web/public/i18n.js
|
|
* has one entry per value, keyed by this exact text, and the i18n test lists them from here.
|
|
* A constant of its own, so the sentences of the recommendation and of the install can be
|
|
* listed apart.
|
|
*
|
|
* | Key | Slots (all text) |
|
|
* |------------------------|----------------------------------------------------------------------|
|
|
* | `errPortInUse` | `port`: the port number |
|
|
* | `errModelNotFound`, `errDoesNotFit`, `errLoadFailed`, `errNoToolCall`, `errProbeUnfinished` | `model`: the model's label |
|
|
* | `errContextNotApplied` | `model`; `actual`: the context the runtime reports; `wanted`: the chosen one |
|
|
* | `errHarnessFailed` | `cli`: the coding agent's label |
|
|
* | `errEndpointIdTaken` | `id`: the endpoint id this feature uses |
|
|
* | `refusedAgentNotInstallable` | `cli`: the coding agent's label |
|
|
* | `refusedOtherModel` | `model`: the label of the model that is in use |
|
|
* | `refusedAgentMissing` | `cli`: the coding agent's label |
|
|
* | `conflictModelActive`, `deleteFailed` | `model`: the model's label |
|
|
* | `conflictSizeChanged` | `size`: the server's own total as decimal GB |
|
|
* | `conflictSessionsOpen` | `sessions`: the session names, joined with ', ' |
|
|
*
|
|
* `developerToolsMissing` is not sent: the view carries `developerTools` and the window prints
|
|
* this sentence when it is `missing`. It is here so that its wording has one source.
|
|
*
|
|
* `endpointManaged` and `endpointIdReserved` are what the endpoint routes
|
|
* (src/web/routes/custom-model-routes.ts) answer for the record this feature manages and for
|
|
* its reserved id. They have no slot, so the App Settings toast that prints them translates
|
|
* them as it translates every other whole sentence.
|
|
*/
|
|
export const LOCAL_MODEL_INSTALL_LABELS = {
|
|
errRuntimeDownloadFailed:
|
|
'The download of the runtime stopped and could not be continued. Check the network connection, then try again.',
|
|
errRuntimeDamaged: 'The runtime download was damaged and has been deleted. Try again.',
|
|
errRuntimeUnpackFailed: 'The runtime could not be unpacked on this Mac.',
|
|
errRuntimeStartFailed: 'The runtime did not start on this Mac.',
|
|
errRuntimeStopped: 'The runtime keeps stopping, so Codeman no longer restarts it.',
|
|
errRuntimeNotRunning: 'The model on this Mac is not running. Start it first.',
|
|
errPortInUse: 'Port {port} is in use by another program.',
|
|
errDiskFull: 'The disk is full. Free up space, then try again.',
|
|
errPullFailed: 'The download keeps stopping. The network may be damaging data. Try again or use another network.',
|
|
errModelNotFound: '{model} is no longer offered under this name. Update Codeman or pick another model.',
|
|
errDoesNotFit: '{model} does not fit in the memory of this Mac after all.',
|
|
errContextNotApplied: '{model} loaded with a context of {actual} tokens instead of {wanted}.',
|
|
errLoadFailed: '{model} did not load on this Mac.',
|
|
errNoToolCall: '{model} loaded but did not call a tool, so a coding agent cannot work with it.',
|
|
errProbeUnfinished: 'The check of {model} did not finish.',
|
|
errHarnessFailed: '{cli} could not be installed.',
|
|
errEndpointIdTaken:
|
|
'A saved model connection named {id} already exists and was not created here. Rename or delete it in App Settings, then try again.',
|
|
errConnectFailed: 'Codeman could not save the connection to the model.',
|
|
errModelChanged: 'The model on this Mac changed since it was installed. Remove it and install it again.',
|
|
errJobUnfinished:
|
|
'The model on this Mac is being changed and is not ready. Finish or undo that in the local model window first.',
|
|
errInternal: 'The install stopped on an unexpected error.',
|
|
conflictJobRunning: 'An install is already running.',
|
|
conflictAlreadyInstalled: 'This model is already in use on this Mac.',
|
|
conflictModelActive: '{model} is the model in use. Switch to another model first, or remove the local model.',
|
|
conflictSizeChanged: 'The download is {size} GB now. Check the sizes and confirm again.',
|
|
conflictSessionsOpen: 'Sessions are still using this model: {sessions}. Close them first.',
|
|
conflictWrongState: 'That is not possible in the current state.',
|
|
refusedModelNotOffered: 'This model at this context is not offered for this Mac right now.',
|
|
refusedAgentNotOffered: 'This coding agent is not offered for this model.',
|
|
refusedAgentNotInstallable: '{cli} is not installed and cannot be installed from here.',
|
|
refusedOtherModel: 'Only {model} is in use on this Mac. Switch models in the local model window first.',
|
|
refusedNotInLibrary: 'This model is not one of the checked models on this Mac.',
|
|
refusedNotOnThisMac: 'This model is not on this Mac.',
|
|
deleteFailed: '{model} could not be deleted from this Mac.',
|
|
refusedAgentMissing: '{cli} is not installed on this Mac. Install a coding agent in the local model window first.',
|
|
endpointManaged:
|
|
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.',
|
|
endpointIdReserved: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.',
|
|
developerToolsMissing:
|
|
'Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer to install them the first time an agent needs one.',
|
|
} as const;
|
|
|
|
/** One of the texts in `LOCAL_MODEL_INSTALL_LABELS`. */
|
|
export type LocalModelInstallLabel = (typeof LOCAL_MODEL_INSTALL_LABELS)[keyof typeof LOCAL_MODEL_INSTALL_LABELS];
|
|
|
|
/** A sentence of the install: a text from `LOCAL_MODEL_INSTALL_LABELS` plus the values of its `{placeholder}` slots. */
|
|
export interface LocalModelInstallMessage {
|
|
text: LocalModelInstallLabel;
|
|
/** Slot values, already formatted as text. Absent when the text has no slot. */
|
|
vars?: Record<string, string>;
|
|
}
|
|
|
|
/** The sentence of each error code. */
|
|
export const LOCAL_MODEL_ERROR_LABELS: Record<LocalModelErrorCode, LocalModelInstallLabel> = {
|
|
'runtime-download-failed': LOCAL_MODEL_INSTALL_LABELS.errRuntimeDownloadFailed,
|
|
'runtime-damaged': LOCAL_MODEL_INSTALL_LABELS.errRuntimeDamaged,
|
|
'runtime-unpack-failed': LOCAL_MODEL_INSTALL_LABELS.errRuntimeUnpackFailed,
|
|
'runtime-start-failed': LOCAL_MODEL_INSTALL_LABELS.errRuntimeStartFailed,
|
|
'runtime-stopped': LOCAL_MODEL_INSTALL_LABELS.errRuntimeStopped,
|
|
'runtime-not-running': LOCAL_MODEL_INSTALL_LABELS.errRuntimeNotRunning,
|
|
'port-in-use': LOCAL_MODEL_INSTALL_LABELS.errPortInUse,
|
|
'disk-full': LOCAL_MODEL_INSTALL_LABELS.errDiskFull,
|
|
'pull-failed': LOCAL_MODEL_INSTALL_LABELS.errPullFailed,
|
|
'model-not-found': LOCAL_MODEL_INSTALL_LABELS.errModelNotFound,
|
|
'does-not-fit': LOCAL_MODEL_INSTALL_LABELS.errDoesNotFit,
|
|
'context-not-applied': LOCAL_MODEL_INSTALL_LABELS.errContextNotApplied,
|
|
'load-failed': LOCAL_MODEL_INSTALL_LABELS.errLoadFailed,
|
|
'no-tool-call': LOCAL_MODEL_INSTALL_LABELS.errNoToolCall,
|
|
'probe-unfinished': LOCAL_MODEL_INSTALL_LABELS.errProbeUnfinished,
|
|
'harness-failed': LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed,
|
|
'endpoint-id-taken': LOCAL_MODEL_INSTALL_LABELS.errEndpointIdTaken,
|
|
'connect-failed': LOCAL_MODEL_INSTALL_LABELS.errConnectFailed,
|
|
'model-changed': LOCAL_MODEL_INSTALL_LABELS.errModelChanged,
|
|
'job-unfinished': LOCAL_MODEL_INSTALL_LABELS.errJobUnfinished,
|
|
internal: LOCAL_MODEL_INSTALL_LABELS.errInternal,
|
|
};
|
|
|
|
/** The one helpful next step of each error code. */
|
|
export const LOCAL_MODEL_ERROR_NEXT: Record<LocalModelErrorCode, LocalModelNextStep> = {
|
|
'runtime-download-failed': 'retry',
|
|
'runtime-damaged': 'retry',
|
|
'runtime-unpack-failed': 'retry',
|
|
'runtime-start-failed': 'retry',
|
|
'runtime-stopped': 'start',
|
|
'runtime-not-running': 'start',
|
|
'port-in-use': 'other-port',
|
|
'disk-full': 'retry',
|
|
'pull-failed': 'retry',
|
|
'model-not-found': 'next-model',
|
|
'does-not-fit': 'next-model',
|
|
'context-not-applied': 'retry',
|
|
'load-failed': 'retry',
|
|
'no-tool-call': 'next-model',
|
|
'probe-unfinished': 'retry',
|
|
'harness-failed': 'retry',
|
|
'endpoint-id-taken': 'retry',
|
|
'connect-failed': 'retry',
|
|
'model-changed': 'remove',
|
|
'job-unfinished': 'retry',
|
|
internal: 'retry',
|
|
};
|
|
|
|
// ---------- The `install` object of the GET ----------
|
|
|
|
/**
|
|
* How far a download is. Present while the `runtime` step downloads the archive and while the
|
|
* `model` step pulls, once the total is known; kept on a paused or failed job as it stood.
|
|
* During the `model` step both byte figures come from the pull's own lines, never from the
|
|
* catalogue.
|
|
*/
|
|
export interface LocalModelProgress {
|
|
completedBytes: number;
|
|
totalBytes: number;
|
|
/** `completedBytes` as decimal GB text, no unit ('6.2'). */
|
|
completedGb: string;
|
|
/** `totalBytes` as decimal GB text, no unit ('23.1'). */
|
|
totalGb: string;
|
|
/** Download speed. Absent until it can be told (the first moments, a paused job). */
|
|
bytesPerSecond?: number;
|
|
/** `bytesPerSecond` as whole decimal megabytes per second, no unit ('58'). Present exactly when `bytesPerSecond` is. */
|
|
mbPerSecond?: string;
|
|
/** Seconds left at the current speed, rounded up. Absent when the speed is unknown or 0. */
|
|
secondsLeft?: number;
|
|
}
|
|
|
|
/** An error as the window shows it: the sentence, the one action that helps, and the raw detail. */
|
|
export interface LocalModelInstallError {
|
|
code: LocalModelErrorCode;
|
|
/** `LOCAL_MODEL_ERROR_LABELS[code]` with its slots filled. */
|
|
message: LocalModelInstallMessage;
|
|
/** `LOCAL_MODEL_ERROR_NEXT[code]`, adapted to the state the error is shown in (see `LocalModelNextStep`). */
|
|
next: LocalModelNextStep;
|
|
/**
|
|
* For `next-model`: the model to try instead. After `does-not-fit` it is one that needs LESS
|
|
* memory than the one that failed; after the other codes the best other model that fits.
|
|
* Absent when there is none.
|
|
*/
|
|
nextModel?: { modelId: string; modelLabel: string };
|
|
/**
|
|
* Raw text for the person who wants to know more: the last lines of an installer, the
|
|
* message of a refused write, an exception. Not translated; printed with `textContent`.
|
|
*/
|
|
detail?: string;
|
|
}
|
|
|
|
/** The model of the job or of the installation. */
|
|
export interface LocalModelInstalledModel {
|
|
/** `LocalModelEntry.id`. */
|
|
id: string;
|
|
/** `LocalModelEntry.label`. */
|
|
label: string;
|
|
}
|
|
|
|
/** The runtime, as the consent screen needs it. */
|
|
export interface LocalModelRuntimeView {
|
|
/** `LocalModelRuntimePin.version`. */
|
|
version: string;
|
|
/** The pinned runtime is unpacked on disk. Then an install downloads only the model. */
|
|
installed: boolean;
|
|
/** What an install would download for the runtime: the archive size, 0 when `installed`. */
|
|
downloadBytes: number;
|
|
/** `downloadBytes` as decimal GB text, no unit ('0.2'). */
|
|
downloadGb: string;
|
|
/** What the unpacked runtime takes on disk, as decimal GB text, no unit ('0.6'). */
|
|
unpackedGb: string;
|
|
}
|
|
|
|
/** What the runtime reported about the loaded model when the job checked it. The facts of the Done line. */
|
|
export interface LocalModelLoadedFacts {
|
|
/** The context length in tokens the runtime loaded the model with. */
|
|
context: number;
|
|
/** Memory the loaded model took, in bytes (`size` of the runtime's process list). */
|
|
memoryBytes: number;
|
|
/** `memoryBytes` as decimal GB text, no unit. */
|
|
memoryGb: string;
|
|
/** All of it is on the GPU (`size_vram` equals `size`). Always true after a job that passed. */
|
|
onGpu: boolean;
|
|
}
|
|
|
|
/**
|
|
* The `install` object of `GET /api/local-model`: where the install stands and what can be
|
|
* done about it. The window polls it once a second while it is open; there is no SSE event.
|
|
*
|
|
* Which members are present, by state:
|
|
*
|
|
* | Member | idle | installing | paused | failed | installed |
|
|
* |-----------------------------------|------|------------|--------|--------|-----------|
|
|
* | `step`, `steps` | | yes | yes | yes | |
|
|
* | `progress` | | during a download | as it stood, when known | as it stood, when known | |
|
|
* | `error` | | | | yes | only when something blocks a session |
|
|
* | `model`, `cli`, `cliLabel`, `context` | | yes | yes | yes | yes |
|
|
* | `port` | | once chosen | once chosen | once chosen | yes |
|
|
* | `loaded`, `endpointId` | | | | | yes |
|
|
*
|
|
* `running`, `developerTools`, `runtime`, `folder`, `library`, `harnesses` and `actions` are
|
|
* always present. `store` is present once the model store was measured. `harnesses` is empty
|
|
* in every state but `installed`.
|
|
*/
|
|
export interface LocalModelInstallView {
|
|
state: LocalModelInstallState;
|
|
/** The step the job is in, or stopped in. */
|
|
step?: LocalModelInstallStep;
|
|
/** The steps of THIS job in order, for the step list (`agent` is absent when the coding agent was already installed). */
|
|
steps?: LocalModelInstallStep[];
|
|
progress?: LocalModelProgress;
|
|
error?: LocalModelInstallError;
|
|
model?: LocalModelInstalledModel;
|
|
/**
|
|
* The coding agent of the job, and on an installed model the DEFAULT one: the agent it was
|
|
* installed with, which a client that names no agent starts. A CLI registry id, as data.
|
|
* `harnesses` lists every agent a session can be started with.
|
|
*/
|
|
cli?: string;
|
|
/** The coding agent's display label ('OpenCode'). */
|
|
cliLabel?: string;
|
|
/** The context length in tokens the runtime is started with. */
|
|
context?: number;
|
|
/** The loopback port of the runtime. */
|
|
port?: number;
|
|
/** The runtime child Codeman started is alive right now. */
|
|
running: boolean;
|
|
developerTools: LocalModelDeveloperTools;
|
|
runtime: LocalModelRuntimeView;
|
|
/** The absolute folder everything of this feature is kept in, for the "where it goes" line. */
|
|
folder: string;
|
|
loaded?: LocalModelLoadedFacts;
|
|
/** The id of the saved endpoint a session is started on. */
|
|
endpointId?: string;
|
|
/**
|
|
* The models on this Mac: every model Codeman checked (`LocalModelStateFile.library`), plus
|
|
* whatever else the running runtime lists. Empty in `idle` (no runtime is there to ask) and
|
|
* before anything was downloaded. The model in use comes first.
|
|
*/
|
|
library: LocalModelLibraryRow[];
|
|
/** What the model store takes on disk right now. Present in every state once it was measured. */
|
|
store?: LocalModelStoreView;
|
|
/**
|
|
* The coding agents a session on the model in use can be started with: every catalogue
|
|
* harness that works at the model's context and accepts its name, the default one (`cli`)
|
|
* first, then by catalogue rank. The agent is chosen per session, so a row is not a setting.
|
|
* Empty in every state but `installed`.
|
|
*/
|
|
harnesses: LocalModelHarnessRow[];
|
|
/** What the server accepts right now, see `LocalModelAction`. */
|
|
actions: LocalModelAction[];
|
|
}
|
|
|
|
/**
|
|
* How a coding agent of `harnesses` stands on this Mac.
|
|
* - `ready`: it can be started (the same answer the Run menu gets).
|
|
* - `installable`: it is not on this Mac, and `POST /api/local-model/agent` installs it with
|
|
* `installCommand`.
|
|
* - `installing`: that install is running. It runs beside the model: nothing about the model
|
|
* in use changes, and a session with another agent can be started meanwhile.
|
|
* - `setup-needed`: it is not startable and one command does not make it so (a launcher
|
|
* without a profile, or a command that needs an administrator). Its own setup is in the Run
|
|
* menu or in a terminal; nothing is offered here.
|
|
*/
|
|
export type LocalModelHarnessStatus = 'ready' | 'installable' | 'installing' | 'setup-needed';
|
|
|
|
/** What can be done with one row of `harnesses`: start a session with it, or `POST /api/local-model/agent`. */
|
|
export type LocalModelHarnessAction = 'start-session' | 'install';
|
|
|
|
/** One coding agent the model in use can be run with. Everything a client needs is here, so it compares no CLI id. */
|
|
export interface LocalModelHarnessRow {
|
|
/** The CLI registry id, as data: the `mode` of a session started with it, and the `cli` of `POST /api/local-model/agent`. */
|
|
cli: string;
|
|
/** The registry's display label ('OpenCode'). */
|
|
cliLabel: string;
|
|
/** True on the row `LocalModelInstallView.cli` names. */
|
|
default: boolean;
|
|
status: LocalModelHarnessStatus;
|
|
/**
|
|
* The literal command `POST /api/local-model/agent` would run: the STOCK command of the CLI
|
|
* registry for this platform. Present exactly when `status` is `installable` or `installing`.
|
|
* The consent prints it as it is.
|
|
*/
|
|
installCommand?: string;
|
|
/** The harness row's `slowFirstTurn`: the first answer takes minutes. */
|
|
slowFirstTurn?: boolean;
|
|
/**
|
|
* A literal with no white space the agent's composer draws once it takes input
|
|
* (`LocalHarnessEntry.readyMark`, else the CLI registry's `composerReadyMark`): what a script
|
|
* waits for on `GET /api/sessions/:id/wait-output` before it sends the first prompt. Absent
|
|
* for an agent with no measured mark.
|
|
*/
|
|
readyMark?: string;
|
|
/**
|
|
* The last install of this agent through `POST /api/local-model/agent` failed, and it is
|
|
* still not on this Mac. Kept in memory only: a restart of the server forgets it.
|
|
*/
|
|
error?: LocalModelAgentError;
|
|
/** What the server accepts for this row right now. */
|
|
actions: LocalModelHarnessAction[];
|
|
}
|
|
|
|
/** Why an install of a coding agent from an installed model failed. */
|
|
export interface LocalModelAgentError {
|
|
/** `errHarnessFailed` with the agent's label. */
|
|
message: LocalModelInstallMessage;
|
|
/** The end of what the installer printed, as it is (English or the tool's own language). */
|
|
detail?: string;
|
|
}
|
|
|
|
/**
|
|
* How a model of the library stands.
|
|
* - `ready`: checked by Codeman, and the runtime (when it was asked) still lists it with the
|
|
* digest that was stored.
|
|
* - `changed`: checked, but the runtime now lists another digest under the name. It is never
|
|
* started: `use` would fail with `model-changed`.
|
|
* - `missing`: checked, but the runtime no longer lists it (something deleted it through the
|
|
* runtime's port).
|
|
* - `unverified`: the runtime lists it and Codeman never checked it: a model an earlier
|
|
* Remove kept, the model of a job that has not passed its check yet, or one another program
|
|
* pulled through the runtime's port. Never startable.
|
|
*/
|
|
export type LocalModelLibraryStatus = 'ready' | 'changed' | 'missing' | 'unverified';
|
|
|
|
/** What can be done with one row of `library`: `POST /api/local-model/use` and `DELETE /api/local-model/models`. */
|
|
export type LocalModelLibraryAction = 'use' | 'delete';
|
|
|
|
/** One model on this Mac. */
|
|
export interface LocalModelLibraryRow {
|
|
/** The model's name exactly as the runtime lists it (`name:tag`). */
|
|
id: string;
|
|
/** Its catalogue label, or `id` for a model the catalogue does not know. */
|
|
label: string;
|
|
/** The context it was checked with. Absent for an `unverified` row. */
|
|
context?: number;
|
|
/** Its size on disk: what the runtime reports, else what was recorded when it was checked. */
|
|
sizeBytes?: number;
|
|
/** `sizeBytes` as decimal GB text, no unit. Present exactly when `sizeBytes` is. */
|
|
sizeGb?: string;
|
|
/** It is the model in use: the one of an `installed` state. */
|
|
active: boolean;
|
|
/** Codeman checked it (`LocalModelStateFile.library` holds it). */
|
|
verified: boolean;
|
|
status: LocalModelLibraryStatus;
|
|
/** What the server accepts for this row right now. `delete` needs the running runtime. */
|
|
actions: LocalModelLibraryAction[];
|
|
}
|
|
|
|
/** The disk use of the model store: the files directly in its blob folder, summed. */
|
|
export interface LocalModelStoreView {
|
|
bytes: number;
|
|
/** `bytes` as decimal GB text, no unit. */
|
|
gb: string;
|
|
}
|
|
|
|
/** The `data` of `GET /api/local-model`. Admin only in multi-user mode. */
|
|
export interface LocalModelStatus {
|
|
hardware: HardwareFacts;
|
|
recommendation: Recommendation;
|
|
/** `LocalModelCatalog.updated`. */
|
|
catalogUpdated: string;
|
|
/** Where the install stands. Always present. */
|
|
install: LocalModelInstallView;
|
|
}
|
|
|
|
// ---------- Request bodies and answers ----------
|
|
|
|
/**
|
|
* Body of `POST /api/local-model/install`. The three choices must name a row the server offers
|
|
* right now: a model of `recommendation.pick` or `recommendation.alternatives`, at that
|
|
* candidate's `context`, with one of its `harnesses`. Anything else is a 400 before any
|
|
* download.
|
|
*/
|
|
export interface LocalModelInstallRequest {
|
|
/** `Candidate.modelId`. */
|
|
modelId: string;
|
|
/** `CandidateHarness.cli`. */
|
|
cli: string;
|
|
/** `Candidate.context`. */
|
|
context: number;
|
|
/**
|
|
* The total the person saw and confirmed: `Candidate.installBytes`. The server compares it
|
|
* with its own total and answers 409 `size-changed` when they differ, so a consent is bound
|
|
* to the size that was shown.
|
|
*/
|
|
acknowledgedBytes: number;
|
|
}
|
|
|
|
/** Body of `POST /api/local-model/resume`. May be absent or empty. */
|
|
export interface LocalModelResumeRequest {
|
|
/** True: choose another port first (the answer to `port-in-use`). */
|
|
newPort?: boolean;
|
|
}
|
|
|
|
/** Body of `POST /api/local-model/runtime`. */
|
|
export interface LocalModelRuntimeRequest {
|
|
/** `stop` also keeps the runtime from being started at the next boot; `start` undoes that. */
|
|
action: 'start' | 'stop';
|
|
/** With `start`: choose another port first (the answer to `port-in-use`), and move the saved endpoint to it. */
|
|
newPort?: boolean;
|
|
}
|
|
|
|
/** Body of `DELETE /api/local-model`. May be absent or empty. */
|
|
export interface LocalModelRemoveRequest {
|
|
/** True: delete the downloaded model too. Default false: the model stays for a later install. */
|
|
removeModel?: boolean;
|
|
}
|
|
|
|
/** Body of `POST /api/local-model/use`: a checked model of `library` to switch to. Nothing is downloaded. */
|
|
export interface LocalModelUseRequest {
|
|
/** `LocalModelLibraryRow.id` of a row whose `actions` list `use`. */
|
|
modelId: string;
|
|
}
|
|
|
|
/** Body of `DELETE /api/local-model/models`: one model to delete from the store. */
|
|
export interface LocalModelDeleteRequest {
|
|
/** `LocalModelLibraryRow.id` of a row whose `actions` list `delete`. Never a path. */
|
|
modelId: string;
|
|
}
|
|
|
|
/**
|
|
* Body of `POST /api/local-model/agent`: a coding agent of `harnesses` to install beside an
|
|
* installed model. The click that sends it is the consent to the row's `installCommand`.
|
|
*/
|
|
export interface LocalModelAgentRequest {
|
|
/** `LocalModelHarnessRow.cli` of a row whose `actions` list `install`. */
|
|
cli: string;
|
|
}
|
|
|
|
/** The `data` of a 400 the service names: its sentence, for the window to print in its own language. */
|
|
export interface LocalModelRefusal {
|
|
message: LocalModelInstallMessage;
|
|
}
|
|
|
|
/** What every verb answers on success: the view after the verb. */
|
|
export type LocalModelVerbAnswer = LocalModelInstallView;
|
|
|
|
/**
|
|
* Why a verb was refused with 409.
|
|
* - `job-running`: a job is running (a second install, a resume, a remove or a runtime action).
|
|
* - `already-installed`: install or use of the model that is already the one in use (at the
|
|
* same context).
|
|
* - `size-changed`: `acknowledgedBytes` is not the server's total; `expectedBytes` and
|
|
* `expectedGb` carry that total, so the window can show it and ask again.
|
|
* - `sessions-open`: remove, stop, a change of port, an install of another model or a switch
|
|
* while live sessions use the endpoint; `sessions` names them. Their coding agents were
|
|
* given the runtime's address and the model's name when they started, so they would go on
|
|
* calling a port nothing of ours holds, or make the runtime swap models on every request.
|
|
* - `wrong-state`: the verb makes no sense in the current state (see the route table above).
|
|
* - `model-active`: a delete of the model the installation or the current job is about.
|
|
* - `runtime-not-running`: a delete while the runtime is down: only the runtime deletes a model.
|
|
*/
|
|
export type LocalModelConflictReason =
|
|
| 'job-running'
|
|
| 'already-installed'
|
|
| 'size-changed'
|
|
| 'sessions-open'
|
|
| 'wrong-state'
|
|
| 'model-active'
|
|
| 'runtime-not-running';
|
|
|
|
/** The `data` of a 409 of an install verb. */
|
|
export interface LocalModelConflict {
|
|
reason: LocalModelConflictReason;
|
|
/** The sentence for the window (`conflict...` of `LOCAL_MODEL_INSTALL_LABELS`). */
|
|
message: LocalModelInstallMessage;
|
|
/** `size-changed`: the server's own total in bytes. */
|
|
expectedBytes?: number;
|
|
/** `size-changed`: `expectedBytes` as decimal GB text, no unit. */
|
|
expectedGb?: string;
|
|
/** `sessions-open`: the names of the live sessions that use the endpoint. */
|
|
sessions?: string[];
|
|
}
|
|
|
|
// ---------- The state file ----------
|
|
|
|
/**
|
|
* `local-model.json` in the data dir: everything that must survive a restart. Mode 0600,
|
|
* written through a temp file and a rename, by src/local-model/state-store.ts alone.
|
|
*
|
|
* Rules:
|
|
* - It holds NO path. Every path of this feature is built from `dataPath()` constants
|
|
* (`localModelPaths()`), so a state file can never point a delete somewhere else.
|
|
* - Reading is total: a missing file, a file that does not parse or one with another `schema`
|
|
* reads as `{ schema: 1, state: 'idle' }`.
|
|
* - A file read at boot with state `installing` is a job a restart interrupted: it becomes
|
|
* `paused` before anything else looks at it.
|
|
*/
|
|
export interface LocalModelStateFile {
|
|
schema: 1;
|
|
state: LocalModelInstallState;
|
|
/** The step the job is in or stopped in. Absent in `idle` and `installed`. */
|
|
step?: LocalModelInstallStep;
|
|
/** The steps of the job, in order. Absent in `idle` and `installed`. */
|
|
steps?: LocalModelInstallStep[];
|
|
/** State `failed`: why. State `installed`: absent (a blocking problem is found live, never stored). */
|
|
error?: {
|
|
code: LocalModelErrorCode;
|
|
/** The slot values of the code's sentence. */
|
|
vars?: Record<string, string>;
|
|
detail?: string;
|
|
/** For a `next-model` code: the model to offer, when the job knew one. */
|
|
nextModelId?: string;
|
|
};
|
|
/** The model of the job or of the installation. */
|
|
model?: {
|
|
id: string;
|
|
label: string;
|
|
/** The context length in tokens the person chose. */
|
|
context: number;
|
|
/** The total the pull reported, once known. */
|
|
pullBytes?: number;
|
|
/** The digest `/api/tags` reported after the pull. Compared again at boot and before a session starts. */
|
|
digest?: string;
|
|
};
|
|
/**
|
|
* The model of the job this one replaced, when it was another model: what that job left in
|
|
* the model store is deleted once the runtime runs, before the new model is downloaded.
|
|
* Absent once that was done, and in `idle` and `installed`.
|
|
*/
|
|
replacedModelId?: string;
|
|
/** The coding agent of the job or of the installation. */
|
|
cli?: { id: string; label: string };
|
|
/** The runtime, once its port was chosen. */
|
|
runtime?: {
|
|
/**
|
|
* The runtime version this installation runs. A later build with another pin keeps
|
|
* starting this version for as long as its folder is unpacked.
|
|
*/
|
|
version: string;
|
|
/** The loopback port, chosen once by binding. */
|
|
port: number;
|
|
/**
|
|
* The pid of the child Codeman last started, stored the moment it is spawned. Used to
|
|
* tell our own runtime from another program: at boot, and before the folder is deleted.
|
|
*/
|
|
pid?: number;
|
|
/** False after Stop: the runtime is not started at boot. */
|
|
enabled: boolean;
|
|
/** The device total the runtime printed when it started, in bytes. */
|
|
measuredTotalBytes?: number;
|
|
};
|
|
/** How far the last download was when the job paused or failed. A restart may leave it absent. */
|
|
progress?: { completedBytes: number; totalBytes: number };
|
|
/** What the runtime reported when the job checked the loaded model. */
|
|
loaded?: { context: number; sizeBytes: number; sizeVramBytes: number };
|
|
/**
|
|
* Every model that PASSED the check through Codeman and is believed to be in the store, the
|
|
* model in use among them. An entry is written when the `verify` step passes and dropped
|
|
* when the model is deleted; `remove` drops the whole list. An `installed` file an earlier
|
|
* build wrote has none: its one model then counts as the library (`libraryOf()` in
|
|
* src/local-model/install-job.ts).
|
|
*/
|
|
library?: LocalModelLibraryEntry[];
|
|
/**
|
|
* Present once the `connect` step CLAIMED the connection, which it does before its two
|
|
* writes: what uninstall must undo. A claim with nothing behind it (the step was interrupted
|
|
* right after it) is harmless: a resumed job writes both, and uninstall removes nothing.
|
|
*/
|
|
connection?: {
|
|
/** The id of the endpoint record this feature wrote. */
|
|
endpointId: string;
|
|
/** The value `customModelEndpointsEnabled` had before this feature switched it on. */
|
|
flagWasEnabled: boolean;
|
|
};
|
|
/** ISO time of the last write. */
|
|
updatedAt?: string;
|
|
}
|
|
|
|
/** One checked model of `LocalModelStateFile.library`. It holds no path. */
|
|
export interface LocalModelLibraryEntry {
|
|
id: string;
|
|
label: string;
|
|
/** The context length in tokens it was checked with. */
|
|
context: number;
|
|
/** The digest `/api/tags` reported when it passed the check. A switch to it must find the same one. */
|
|
digest: string;
|
|
/** Its size on disk as the runtime reported it then, for a listing while the runtime is stopped. */
|
|
bytes?: number;
|
|
/** The coding agent of the job that checked it. A switch back takes it when it is installed. */
|
|
cli?: { id: string; label: string };
|
|
/** ISO time of the check. */
|
|
verifiedAt?: string;
|
|
}
|