opencode-localhost 0.1.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,78 +1,174 @@
1
1
  # opencode-localhost
2
2
 
3
- Run local models in [opencode](https://opencode.ai). Your `.gguf` files show up
4
- in the model picker as an ordinary provider, `llama-server` is started and
5
- supervised for you, and a panel shows GPU, VRAM and CPU while you work.
3
+ [![npm](https://img.shields.io/npm/v/opencode-localhost)](https://www.npmjs.com/package/opencode-localhost)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
5
+
6
+ Run local models in [opencode](https://opencode.ai).
7
+
8
+ Your GGUF files appear in the model picker like any other provider.
9
+ `llama-server` is started, supervised and stopped for you. A panel keeps GPU,
10
+ VRAM and CPU on screen while you work.
6
11
 
7
12
  ```
8
- HARDWARE memory compute │ PROVIDER MODEL
9
- GPU0 14.2/16.0G 87% llama.cpp │ Qwen3.6-35B-A3B
10
- GPU1 13.8/16.0G 82% ● running │ 245760 ctx · q8_0 KV
11
- CPU 18.1/31.0G 24% :9337 web off ngl 99 · 68.2 tok/s
13
+ HARDWARE memory compute │ llama.cpp lmstudio-community/
14
+ GPU0 14.3/15.9G 87% ● 127.0.0.1:9337 │ Qwen3.6-35B-A3B-GGUF
15
+ GPU1 13.7/15.9G 82% [stop] │ 245760 ctx · q8_0 KV
16
+ CPU 10.8/31.2G 31% │ [change]
12
17
  ```
13
18
 
14
- No fork of opencode, no patches. It is a plugin.
19
+ It is a plugin. It does not fork or patch opencode.
20
+
21
+ ## Contents
22
+
23
+ - [What it does](#what-it-does)
24
+ - [Requirements](#requirements)
25
+ - [Installation](#installation)
26
+ - [First run](#first-run)
27
+ - [Configuration](#configuration)
28
+ - [Daily use](#daily-use)
29
+ - [Troubleshooting](#troubleshooting)
30
+ - [Limitations](#limitations)
31
+ - [Contributing](#contributing)
32
+
33
+ ## What it does
34
+
35
+ **Discovers your models.** Scans a directory you choose for `.gguf` files,
36
+ including nested layouts like `<publisher>/<repo>/model.gguf`, and registers
37
+ each one — every quantisation as its own entry.
38
+
39
+ **Runs the server.** Starts `llama-server` on your first message and keeps it
40
+ running. Nothing is spawned at launch; `[start]` and `[stop]` are in the panel
41
+ when you want to decide yourself.
42
+
43
+ **Reports the truth about context.** opencode compacts a conversation against
44
+ the model's advertised window. The context you configure per model is what gets
45
+ advertised, so a model loaded at 245k is not compacted as though it were 32k.
46
+
47
+ **Shows what the hardware is doing.** Per-GPU memory and utilisation, system RAM
48
+ and CPU, the loaded model with its launch flags, and live progress while weights
49
+ stream into VRAM.
50
+
51
+ **Keeps settings in llama.cpp's own format.** Per-model options live in a
52
+ standard llama.cpp preset file, passed to `llama-server` unmodified. Any flag
53
+ llama.cpp accepts works, whether or not this plugin knows about it.
15
54
 
16
55
  ## Requirements
17
56
 
18
- - opencode
19
- - a `llama-server` binary — [build it](https://github.com/ggml-org/llama.cpp) or
20
- install it however you like, then make sure it is on `$PATH`
21
- - some `.gguf` files
57
+ | | |
58
+ |---|---|
59
+ | **opencode** | tested against 1.18.5; older versions are untested |
60
+ | **llama-server** | build [llama.cpp](https://github.com/ggml-org/llama.cpp) yourself, or install it (`brew install llama.cpp`, a release archive, your package manager) |
61
+ | **models** | one or more `.gguf` files in a directory |
62
+ | **GPU stats** | optional. NVIDIA only; without it the hardware rows are simply omitted |
22
63
 
23
- ## Install
64
+ ## Installation
65
+
66
+ Clone the repository and install its dependencies:
67
+
68
+ ```sh
69
+ git clone https://github.com/dushyant30suthar/opencode-localhost
70
+ cd opencode-localhost
71
+ bun install
72
+ ```
24
73
 
25
- opencode loads server-side and TUI plugins separately, so it goes in two files.
74
+ Then point opencode at the directory. It goes in **two files**, because opencode
75
+ loads server-side and terminal-side plugins through separate hosts:
26
76
 
27
77
  ```jsonc
28
78
  // ~/.config/opencode/opencode.jsonc
29
- { "plugin": ["opencode-localhost"] }
79
+ { "plugin": ["/absolute/path/to/opencode-localhost"] }
30
80
  ```
31
81
 
32
82
  ```jsonc
33
83
  // ~/.config/opencode/tui.jsonc
34
- { "plugin": ["opencode-localhost"] }
84
+ { "plugin": ["/absolute/path/to/opencode-localhost"] }
35
85
  ```
36
86
 
37
- Start opencode. The panel appears under the prompt and tells you what is still
38
- missing.
87
+ > **Why a path rather than the package name?**
88
+ >
89
+ > The package is published to npm, but installing it by name currently gives
90
+ > you the provider without the panel. opencode resolves an npm plugin against a
91
+ > wrapper `package.json` it generates in `~/.cache/opencode/packages/`, which
92
+ > carries no `exports` field, so the terminal-side entry point is never found
93
+ > and is skipped without an error. The server-side entry survives on a
94
+ > different fallback, which makes the result look half-broken rather than
95
+ > unconfigured.
96
+ >
97
+ > Referencing the directory avoids that resolution path entirely. When opencode
98
+ > resolves npm plugins against the real package, `{ "plugin":
99
+ > ["opencode-localhost"] }` will work with no change here.
100
+
101
+ ## First run
102
+
103
+ Start opencode. The panel appears below the prompt and reports what is missing:
104
+
105
+ ```
106
+ HARDWARE memory compute │ llama.cpp │ no model loaded
107
+ GPU0 0.4/15.9G 12% │ ✕ not set up │ [change]
108
+ GPU1 0.4/15.9G 1% │ models-dir not set│
109
+ CPU 7.1/31.2G 16% │ │
110
+ ```
111
+
112
+ Run `/localhost` and set two things:
113
+
114
+ ```
115
+ ✓ llama.cpp /usr/local/bin/llama-server
116
+ ✗ vLLM not installed — pip install vllm
117
+ ✗ MLX Apple Silicon only
118
+ ✗ OpenVINO not installed — pip install openvino-genai
119
+ ✓ Models directory ~/models
120
+ ○ Server stopped [start] 127.0.0.1:9337
121
+ ```
122
+
123
+ - **llama.cpp** — every `llama-server` found on `$PATH`, plus *Enter a path…*
124
+ for a build that is not on it
125
+ - **Models directory** — where your `.gguf` files live
126
+
127
+ Nothing is guessed. `$PATH` is searched for the binary and nothing else; the
128
+ models directory has no default, because only you know where it is.
129
+
130
+ Once both are set the models appear in the picker within a couple of seconds.
131
+ No restart.
132
+
133
+ ## Configuration
134
+
135
+ Two files, both under `~/.config/opencode/providers/llamacpp/`.
39
136
 
40
- ## Setup
137
+ ### `server.ini` — how the server is run
41
138
 
42
- On first run it writes `~/.config/opencode/providers/llamacpp/server.ini`.
43
- Exactly one thing has no sensible default:
139
+ Written on first start. Read by this plugin, which builds `llama-server`'s
140
+ command line from it.
44
141
 
45
142
  ```ini
46
143
  [server]
47
- bin = # empty = look on $PATH
48
- models-dir = ~/models # ← set this
49
- host = 127.0.0.1
50
- port = 9337
144
+ bin = /usr/local/bin/llama-server
145
+ models-dir = ~/models
146
+ host = 127.0.0.1
147
+ port = 9337
51
148
  models-max = 1
52
- api-key =
149
+ api-key =
53
150
  ```
54
151
 
55
- Set `models-dir` and save. No restart: the panel notices within a couple of
56
- seconds, scans your models, generates `models.ini`, starts `llama-server`, and
57
- asks opencode to re-read its config so the provider appears in the picker.
58
-
59
- Nothing is guessed. If `llama-server` is not on `$PATH`, set `bin` — the panel
60
- says so rather than failing silently.
152
+ | Key | Meaning |
153
+ |---|---|
154
+ | `bin` | path to `llama-server`. Empty means search `$PATH` |
155
+ | `models-dir` | directory to scan for `.gguf` files. Required |
156
+ | `host` | `127.0.0.1` keeps the server on this machine |
157
+ | `port` | listen port |
158
+ | `models-max` | how many models may occupy VRAM at once. `1` swaps instead of stacking |
159
+ | `api-key` | when set, `llama-server` enforces bearer auth |
61
160
 
62
- ## Per-model settings
161
+ ### `models.ini` — how each model is loaded
63
162
 
64
- `~/.config/opencode/providers/llamacpp/models.ini` is written **for you**, the
65
- first time your models are scanned you never create it by hand. It is a
66
- llama.cpp preset file, handed to `llama-server --models-preset` verbatim. A
67
- section is appended when a new `.gguf` appears and **never modified
68
- afterwards** — it is yours.
163
+ Generated for you the first time your models are scanned. This is a standard
164
+ llama.cpp preset file, handed to `llama-server --models-preset` unmodified.
69
165
 
70
- The section name is the model id, so renaming a section is safe: it keeps
71
- governing its file, and its settings keep being used.
166
+ A section is appended when a new `.gguf` appears and **is never edited
167
+ afterwards**. It is yours.
72
168
 
73
169
  ```ini
74
170
  [unsloth/Qwen3.6-35B-A3B-GGUF]
75
- model = /path/to/Qwen3.6-35B-A3B-Q4_K_M.gguf
171
+ model = /home/you/models/unsloth/Qwen3.6-35B-A3B-GGUF/Qwen3.6-35B-A3B-Q4_K_M.gguf
76
172
  ctx-size = 245760
77
173
  ubatch-size = 2048
78
174
  gpu-layers = 99
@@ -85,46 +181,109 @@ top-p = 0.95
85
181
 
86
182
  Any `llama-server` flag works as a key, without the leading dashes.
87
183
 
88
- Two different lifecycles:
184
+ The section name is the model's identity. Renaming a section is safe — it keeps
185
+ governing its file and its settings keep being used.
186
+
187
+ Two settings have different lifecycles:
89
188
 
90
189
  | Change | Takes effect |
91
190
  |---|---|
92
- | launch flags — `ctx-size`, `gpu-layers`, `tensor-split`, `cache-type-*` | next model load |
191
+ | launch flags — `ctx-size`, `gpu-layers`, `tensor-split`, `cache-type-*` | next time the model loads |
93
192
  | sampling — `temp`, `top-p`, `top-k`, `min-p` | next message, no reload |
94
193
 
95
- Sampling is sent per request rather than baked into the server, so opencode's
96
- own per-model defaults never override what you set here.
194
+ Sampling is attached to each request rather than baked into the server process,
195
+ so opencode's own per-model defaults never override what you set here, and
196
+ changing it does not cost a reload.
197
+
198
+ `ctx-size` is also what opencode compacts against. Set it to what the model
199
+ really loads with, or long conversations will compact far earlier than they
200
+ need to.
201
+
202
+ ## Daily use
203
+
204
+ Open the model picker (`/model`, or `[change]` in the panel) and choose a model
205
+ under **Localhost-llama.cpp**.
206
+
207
+ Send a message. The first one is slow — that is the server starting and the
208
+ weights streaming into VRAM. The panel shows it:
97
209
 
98
- `ctx-size` is also what opencode compacts against — set it to what the model
99
- really loads with, or long conversations will compact far too early.
210
+ ```
211
+ HARDWARE memory compute llama.cpp │ unsloth/
212
+ GPU0 8.1/15.9G 64% │ ● 127.0.0.1:9337 │ Laguna-S-2.1-GGUF
213
+ GPU1 7.4/15.9G 61% │ [stop] │ ████░░░░░░ 43%
214
+ CPU 11.2/31.2G 38% │ │ text_model
215
+ ```
216
+
217
+ Subsequent messages are warm.
218
+
219
+ The same panel appears in the session sidebar, stacked rather than in columns.
220
+
221
+ **Controls**
222
+
223
+ | | |
224
+ |---|---|
225
+ | `[start]` / `[stop]` | run or stop the server |
226
+ | `[change]` | opencode's model picker |
227
+ | `/localhost` | paths and server state |
228
+ | `ctrl+p` → *Local models* | the same actions, from the command palette |
100
229
 
101
- ## Adding another backend
230
+ Both actions are registered commands (`localhost.server.toggle` and opencode's
231
+ `model.list`), so they can be bound to keys in your keybinds config.
102
232
 
103
- `src/server/llamacpp/` implements `src/server/backend.ts`. vLLM or anything else
104
- is a sibling folder implementing the same three methods — discover models, start
105
- the server, say where it is. Its own config file, in its own native format,
106
- under `~/.config/opencode/providers/<backend>/`.
233
+ ## Troubleshooting
107
234
 
108
- ## Security
235
+ **The panel is missing entirely.** The terminal-side plugin is not loading.
236
+ Check `tui.jsonc` exists and points at the directory — it is a separate file
237
+ from `opencode.jsonc` and easy to miss. Confirm `bun install` has been run in
238
+ the clone; the panel needs its own dependencies.
109
239
 
110
- `host` defaults to `127.0.0.1`, so the server is reachable only from this
111
- machine. If you set `0.0.0.0` to use it from elsewhere on your network, set
112
- `api-key` as well `llama-server` then enforces bearer auth, and any
113
- OpenAI-compatible client can connect with it.
240
+ **No provider in the model picker.** The engine is not configured. Run
241
+ `/localhost` a backend with no binary or no models directory does not
242
+ register. If both are set and the picker is still empty, no `.gguf` files were
243
+ found under `models-dir`.
244
+
245
+ **A model fails to load and retries.** The server log has the reason:
246
+
247
+ ```sh
248
+ tail -40 ~/.local/state/opencode/providers/llamacpp/server.log
249
+ ```
250
+
251
+ The usual cause is a path in `models.ini` that `llama-server` cannot open.
252
+ Paths must be absolute — `llama-server` does not expand `~`.
253
+
254
+ **Conversations compact too early.** `ctx-size` for that model does not match
255
+ what it actually loads with. The panel shows the value in effect.
256
+
257
+ **Settings changed but nothing happened.** Launch flags apply on the next model
258
+ load, not immediately. Stop and start the server, or switch models and back.
114
259
 
115
260
  ## Limitations
116
261
 
117
- - **Provider refresh only happens from the home screen.** opencode's only
118
- reload path disposes live instances, so it is not fired mid-session; the
119
- panel shows **restart opencode** in that case instead.
120
- - **No warm-on-select.** opencode's TUI does not expose the selected model to
121
- plugins, so a model starts loading on your first message rather than the
122
- moment you pick it.
123
- - **Local only.** The panel reads `nvidia-smi` and the server directly, so it
124
- goes blank if the TUI runs on a different machine from opencode.
125
- - **NVIDIA only** for GPU stats. Everything else works without a GPU; the
126
- hardware rows are simply omitted.
262
+ **No warm-on-select.** opencode does not expose the selected model to plugins,
263
+ so a model begins loading on your first message rather than the moment you pick
264
+ it.
265
+
266
+ **Local only.** The panel reads `nvidia-smi` and the server directly, so it goes
267
+ blank if the terminal runs on a different machine from the model server.
268
+
269
+ **NVIDIA only for GPU statistics.** Everything else works without a GPU; the
270
+ hardware rows are omitted.
271
+
272
+ **llama.cpp only, for now.** The architecture is backend-agnostic and the setup
273
+ screen lists vLLM, MLX and OpenVINO with their install state, but only llama.cpp
274
+ is implemented.
275
+
276
+ ## Contributing
277
+
278
+ [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) covers how the two halves fit
279
+ together, the constraints opencode's plugin API imposes, and what implementing
280
+ another backend involves.
281
+
282
+ ```sh
283
+ bun install
284
+ bunx tsc --noEmit -p tsconfig.json
285
+ ```
127
286
 
128
287
  ## License
129
288
 
130
- MIT
289
+ MIT — see [LICENSE](./LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-localhost",
3
- "version": "0.1.0",
3
+ "version": "0.7.1",
4
4
  "description": "Run local models in opencode. llama.cpp as a provider, with live GPU, VRAM and CPU telemetry in the sidebar.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -32,26 +32,16 @@
32
32
  "README.md",
33
33
  "LICENSE"
34
34
  ],
35
- "peerDependencies": {
36
- "@opentui/solid": ">=0.4.5",
37
- "solid-js": ">=1.9.0"
38
- },
39
- "peerDependenciesMeta": {
40
- "@opentui/solid": {
41
- "optional": true
42
- },
43
- "solid-js": {
44
- "optional": true
45
- }
46
- },
47
35
  "devDependencies": {
48
36
  "@opencode-ai/plugin": "^1.18.4",
49
- "@opentui/solid": "^0.4.5",
50
37
  "@types/node": "^26.1.1",
51
- "solid-js": "^1.9.0",
52
38
  "typescript": "^7.0.2"
53
39
  },
54
40
  "engines": {
55
41
  "node": ">=22"
42
+ },
43
+ "dependencies": {
44
+ "@opentui/solid": "^0.4.5",
45
+ "solid-js": "^1.9.0"
56
46
  }
57
47
  }
@@ -1,4 +1,4 @@
1
- import type { ProviderStatus } from "../shared/types.ts"
1
+ import type { LoadEvent, LoadedModel, ProviderStatus } from "../shared/types.ts"
2
2
 
3
3
  /**
4
4
  * What every backend implements. llama.cpp today; vLLM and OpenVINO are new
@@ -22,8 +22,16 @@ export type DiscoveredModel = {
22
22
 
23
23
  export interface Backend {
24
24
  readonly id: string
25
+
26
+ /** Short, for the panel — everything there is local, so no prefix. */
25
27
  readonly name: string
26
28
 
29
+ /**
30
+ * How it appears in opencode's provider list, where it sits beside cloud
31
+ * providers and several engines should group together.
32
+ */
33
+ readonly providerName: string
34
+
27
35
  /** Never throws. "unconfigured" is a normal answer on a fresh install. */
28
36
  status(): Promise<ProviderStatus>
29
37
 
@@ -33,6 +41,19 @@ export interface Backend {
33
41
  /** Idempotent: starts the server only if it is not already answering. */
34
42
  start(): Promise<ProviderStatus>
35
43
 
44
+ /** Stops a server we started. Never touches one we did not spawn. */
45
+ stop(): Promise<boolean>
46
+
47
+ /** What the server currently holds, if anything. Undefined when it is down. */
48
+ loaded(): Promise<LoadedModel | undefined>
49
+
50
+ /**
51
+ * Live load progress. Polling cannot see this: a model streaming into VRAM
52
+ * takes tens of seconds and the REST listing only flips from unloaded to
53
+ * loaded at the end. Returns an unsubscribe.
54
+ */
55
+ watch(onEvent: (event: LoadEvent) => void): () => void
56
+
36
57
  baseURL(): string
37
58
  apiKey(): string | undefined
38
59
  }
@@ -22,7 +22,7 @@ const sampling = new Map<string, Record<string, number>>()
22
22
 
23
23
  function providerEntry(backend: Backend, models: DiscoveredModel[]) {
24
24
  return {
25
- name: `${backend.name} (local)`,
25
+ name: backend.providerName,
26
26
  api: backend.baseURL(),
27
27
  npm: "@ai-sdk/openai-compatible",
28
28
  options: {
@@ -50,17 +50,12 @@ async function register(input: any) {
50
50
  // unconfigured or missing binary: contribute nothing. The panel explains why.
51
51
  if (!status || status.state === "unconfigured" || status.state === "failed") continue
52
52
 
53
- // Not up yet: start it in the background and register nothing this run.
53
+ // Configured is enough. The models exist on disk whether or not a server
54
+ // happens to be up, and a picker that stays empty until you find and press
55
+ // [start] is indistinguishable from the plugin not working at all.
54
56
  //
55
- // Awaiting here would stall opencode's startup for as long as the server
56
- // takes to answer — once on a fresh setup, and on *every* launch if the
57
- // server is failing to start. opencode reads its config once per instance
58
- // and exposes no way to re-read it, so the provider genuinely cannot appear
59
- // until the next launch; the panel says so rather than leaving it silent.
60
- if (status.state !== "running") {
61
- void backend.start().catch(() => {})
62
- continue
63
- }
57
+ // Nothing is spawned here: the server comes up on the first request, in
58
+ // chat.params below.
64
59
 
65
60
  const models = await backend.models().catch(() => [])
66
61
  if (models.length === 0) continue
@@ -83,7 +78,14 @@ const server = async () => ({
83
78
 
84
79
  "chat.params": async (input: any, output: any) => {
85
80
  const providerID = input?.model?.providerID
86
- if (!BACKENDS.some((backend) => backend.id === providerID)) return
81
+ const backend = BACKENDS.find((item) => item.id === providerID)
82
+ if (!backend) return
83
+
84
+ // Bring the engine up if this is the first request against it. Sending a
85
+ // message is a clear enough signal of intent — the objection was to
86
+ // spawning a server at launch, not to starting one you are about to use.
87
+ const status = await backend.status().catch(() => undefined)
88
+ if (status && status.state === "stopped") await backend.start().catch(() => {})
87
89
  const values = sampling.get(`${providerID}/${input?.model?.id}`)
88
90
  if (!values) return
89
91
  // opencode names three of these directly and passes the rest through options
@@ -2,7 +2,7 @@ import fs from "fs/promises"
2
2
  import path from "path"
3
3
  import { spawn } from "child_process"
4
4
  import { stateDir, collapseHome } from "../../shared/paths.ts"
5
- import type { ProviderStatus } from "../../shared/types.ts"
5
+ import type { LoadEvent, LoadedModel, ProviderStatus } from "../../shared/types.ts"
6
6
  import type { Backend, DiscoveredModel } from "../backend.ts"
7
7
  import * as Server from "./server-ini.ts"
8
8
  import * as Models from "./models-ini.ts"
@@ -176,12 +176,171 @@ export function create(): Backend {
176
176
  }
177
177
  }
178
178
 
179
+ /**
180
+ * Stop the server we spawned. Checks /proc before signalling so a recycled
181
+ * pid is never killed, and only kills a process that is actually
182
+ * llama-server — the pidfile outlives crashes and reboots.
183
+ */
184
+ async function stop(): Promise<boolean> {
185
+ const raw = await fs.readFile(PID_FILE, "utf8").catch(() => "")
186
+ const pid = Number.parseInt(raw.trim(), 10)
187
+ if (!Number.isFinite(pid) || pid <= 0) return false
188
+ const exe = await fs.readlink(`/proc/${pid}/exe`).catch(() => "")
189
+ if (!exe.includes("llama-server")) {
190
+ await fs.rm(PID_FILE, { force: true }).catch(() => {})
191
+ return false
192
+ }
193
+ try {
194
+ process.kill(pid, "SIGTERM")
195
+ } catch {
196
+ return false
197
+ }
198
+ for (let i = 0; i < 40; i++) {
199
+ try {
200
+ process.kill(pid, 0)
201
+ } catch {
202
+ break
203
+ }
204
+ await new Promise((resolve) => setTimeout(resolve, 250))
205
+ }
206
+ try {
207
+ process.kill(pid, "SIGKILL")
208
+ } catch {
209
+ // already gone
210
+ }
211
+ await fs.rm(PID_FILE, { force: true }).catch(() => {})
212
+ return true
213
+ }
214
+
215
+ /** llama-server reports its launch flags as a flat argv array. */
216
+ function argsOf(argv: unknown): Record<string, string> {
217
+ if (!Array.isArray(argv)) return {}
218
+ const out: Record<string, string> = {}
219
+ for (let i = 0; i < argv.length; i++) {
220
+ const token = String(argv[i])
221
+ if (!token.startsWith("--")) continue
222
+ const next = argv[i + 1]
223
+ if (next === undefined || String(next).startsWith("--")) continue
224
+ out[token.slice(2)] = String(next)
225
+ }
226
+ return out
227
+ }
228
+
229
+ async function loaded(): Promise<LoadedModel | undefined> {
230
+ const cfg = await config()
231
+ try {
232
+ const res = await fetch(`${baseURL()}/models`, {
233
+ signal: AbortSignal.timeout(PROBE_TIMEOUT),
234
+ headers: cfg.apiKey ? { Authorization: `Bearer ${cfg.apiKey}` } : {},
235
+ })
236
+ if (!res.ok) return undefined
237
+ const body: any = await res.json()
238
+ const entries: any[] = Array.isArray(body?.data) ? body.data : []
239
+ // llama-server reports state as status.value: "loaded" | "unloaded" |
240
+ // "loading". Every preset carries args, so matching on args alone picked
241
+ // whichever model sorted first rather than the one actually loaded.
242
+ const active =
243
+ entries.find((entry) => entry?.status?.value === "loading") ??
244
+ entries.find((entry) => entry?.status?.value === "loaded")
245
+ if (!active) return undefined
246
+ // llama-server reports load progress as {stages, current, value} while
247
+ // weights stream in; value is 0-1 for the stage named by `current`
248
+ return {
249
+ id: String(active.id ?? ""),
250
+ args: argsOf(active?.status?.args),
251
+ loading: active?.status?.value === "loading",
252
+ }
253
+ } catch {
254
+ return undefined
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Stream model state from /models/sse. The REST listing only flips from
260
+ * unloaded to loaded at the very end, so a thirty-second load looks like a
261
+ * hang unless we read this.
262
+ *
263
+ * Captured from a real load: one "model_status" frame announces the load,
264
+ * then ~20 "status_change" frames carry the progress, ending with
265
+ * status "loaded". The outgoing model gets its own "unloaded" frame.
266
+ *
267
+ * data: {"model":"...","event":"status_change",
268
+ * "data":{"status":"loading",
269
+ * "progress":{"stages":["text_model"],"current":"text_model","value":0.19}}}
270
+ */
271
+ function watch(onEvent: (event: LoadEvent) => void): () => void {
272
+ const controller = new AbortController()
273
+ let stopped = false
274
+
275
+ const connect = async () => {
276
+ while (!stopped) {
277
+ try {
278
+ const cfg = await config()
279
+ const host = cfg.host === "0.0.0.0" ? "127.0.0.1" : cfg.host
280
+ const res = await fetch(`http://${host}:${cfg.port}/models/sse`, {
281
+ signal: controller.signal,
282
+ headers: cfg.apiKey ? { Authorization: `Bearer ${cfg.apiKey}` } : {},
283
+ })
284
+ if (!res.ok || !res.body) throw new Error("no stream")
285
+ const reader = res.body.getReader()
286
+ const decoder = new TextDecoder()
287
+ let buffer = ""
288
+ while (!stopped) {
289
+ const { done, value } = await reader.read()
290
+ if (done) break
291
+ buffer += decoder.decode(value, { stream: true })
292
+ // frames are separated by a blank line; keep any partial tail
293
+ const frames = buffer.split("\n\n")
294
+ buffer = frames.pop() ?? ""
295
+ for (const frame of frames) {
296
+ const line = frame.split("\n").find((item) => item.startsWith("data:"))
297
+ if (!line) continue
298
+ try {
299
+ const payload = JSON.parse(line.slice(5).trim())
300
+ // progress rides on status_change; model_status only announces
301
+ // the start. Filtering to model_status dropped every update.
302
+ const kind = payload?.event
303
+ if (kind && kind !== "model_status" && kind !== "status_change") continue
304
+ const body = payload?.data ?? {}
305
+ const progress = body?.progress
306
+ onEvent({
307
+ model: String(payload?.model ?? ""),
308
+ loading: body?.status === "loading",
309
+ failed: body?.status === "failed" || body?.status === "error",
310
+ loaded: body?.status === "loaded",
311
+ progress: typeof progress?.value === "number" ? progress.value : undefined,
312
+ stage: typeof progress?.current === "string" ? progress.current : undefined,
313
+ })
314
+ } catch {
315
+ // a malformed frame is not worth tearing the stream down for
316
+ }
317
+ }
318
+ }
319
+ } catch {
320
+ // server down or restarted; wait before reconnecting
321
+ }
322
+ if (stopped) return
323
+ await new Promise((resolve) => setTimeout(resolve, 2_000))
324
+ }
325
+ }
326
+
327
+ void connect()
328
+ return () => {
329
+ stopped = true
330
+ controller.abort()
331
+ }
332
+ }
333
+
179
334
  return {
180
335
  id: Server.BACKEND,
181
336
  name: "llama.cpp",
337
+ providerName: "Localhost-llama.cpp",
182
338
  status,
183
339
  models,
184
340
  start,
341
+ stop,
342
+ loaded,
343
+ watch,
185
344
  baseURL,
186
345
  apiKey: () => settings?.apiKey || undefined,
187
346
  }