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 +227 -68
- package/package.json +5 -15
- package/src/server/backend.ts +22 -1
- package/src/server/index.ts +14 -12
- package/src/server/llamacpp/index.ts +160 -1
- package/src/server/llamacpp/models-ini.ts +21 -3
- package/src/shared/types.ts +27 -6
- package/src/tui/backends.ts +14 -0
- package/src/tui/index.tsx +69 -17
- package/src/tui/panel.tsx +228 -148
- package/src/tui/setup.tsx +16 -3
package/README.md
CHANGED
|
@@ -1,78 +1,174 @@
|
|
|
1
1
|
# opencode-localhost
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/opencode-localhost)
|
|
4
|
+
[](./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
|
|
9
|
-
GPU0 14.
|
|
10
|
-
GPU1 13.
|
|
11
|
-
CPU
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
137
|
+
### `server.ini` — how the server is run
|
|
41
138
|
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
48
|
-
models-dir = ~/models
|
|
49
|
-
host
|
|
50
|
-
port
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
161
|
+
### `models.ini` — how each model is loaded
|
|
63
162
|
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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 = /
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
}
|
package/src/server/backend.ts
CHANGED
|
@@ -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
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
56
|
-
//
|
|
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
|
-
|
|
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
|
}
|