opencode-localhost 0.1.0
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/LICENSE +21 -0
- package/README.md +130 -0
- package/package.json +57 -0
- package/src/server/backend.ts +38 -0
- package/src/server/index.ts +103 -0
- package/src/server/llamacpp/discover.ts +92 -0
- package/src/server/llamacpp/index.ts +188 -0
- package/src/server/llamacpp/models-ini.ts +160 -0
- package/src/server/llamacpp/server-ini.ts +110 -0
- package/src/shared/backends.ts +97 -0
- package/src/shared/ini.ts +97 -0
- package/src/shared/paths.ts +37 -0
- package/src/shared/types.ts +51 -0
- package/src/tui/hardware/nvidia.ts +52 -0
- package/src/tui/hardware/system.ts +68 -0
- package/src/tui/index.tsx +145 -0
- package/src/tui/panel.tsx +275 -0
- package/src/tui/setup.tsx +150 -0
- package/tsconfig.json +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dushyant30suthar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# opencode-localhost
|
|
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.
|
|
6
|
+
|
|
7
|
+
```
|
|
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
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
No fork of opencode, no patches. It is a plugin.
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
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
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
opencode loads server-side and TUI plugins separately, so it goes in two files.
|
|
26
|
+
|
|
27
|
+
```jsonc
|
|
28
|
+
// ~/.config/opencode/opencode.jsonc
|
|
29
|
+
{ "plugin": ["opencode-localhost"] }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```jsonc
|
|
33
|
+
// ~/.config/opencode/tui.jsonc
|
|
34
|
+
{ "plugin": ["opencode-localhost"] }
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Start opencode. The panel appears under the prompt and tells you what is still
|
|
38
|
+
missing.
|
|
39
|
+
|
|
40
|
+
## Setup
|
|
41
|
+
|
|
42
|
+
On first run it writes `~/.config/opencode/providers/llamacpp/server.ini`.
|
|
43
|
+
Exactly one thing has no sensible default:
|
|
44
|
+
|
|
45
|
+
```ini
|
|
46
|
+
[server]
|
|
47
|
+
bin = # empty = look on $PATH
|
|
48
|
+
models-dir = ~/models # ← set this
|
|
49
|
+
host = 127.0.0.1
|
|
50
|
+
port = 9337
|
|
51
|
+
models-max = 1
|
|
52
|
+
api-key =
|
|
53
|
+
```
|
|
54
|
+
|
|
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.
|
|
61
|
+
|
|
62
|
+
## Per-model settings
|
|
63
|
+
|
|
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.
|
|
69
|
+
|
|
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.
|
|
72
|
+
|
|
73
|
+
```ini
|
|
74
|
+
[unsloth/Qwen3.6-35B-A3B-GGUF]
|
|
75
|
+
model = /path/to/Qwen3.6-35B-A3B-Q4_K_M.gguf
|
|
76
|
+
ctx-size = 245760
|
|
77
|
+
ubatch-size = 2048
|
|
78
|
+
gpu-layers = 99
|
|
79
|
+
flash-attn = on
|
|
80
|
+
cache-type-k = q8_0
|
|
81
|
+
cache-type-v = q8_0
|
|
82
|
+
temp = 0.6
|
|
83
|
+
top-p = 0.95
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Any `llama-server` flag works as a key, without the leading dashes.
|
|
87
|
+
|
|
88
|
+
Two different lifecycles:
|
|
89
|
+
|
|
90
|
+
| Change | Takes effect |
|
|
91
|
+
|---|---|
|
|
92
|
+
| launch flags — `ctx-size`, `gpu-layers`, `tensor-split`, `cache-type-*` | next model load |
|
|
93
|
+
| sampling — `temp`, `top-p`, `top-k`, `min-p` | next message, no reload |
|
|
94
|
+
|
|
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.
|
|
97
|
+
|
|
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.
|
|
100
|
+
|
|
101
|
+
## Adding another backend
|
|
102
|
+
|
|
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>/`.
|
|
107
|
+
|
|
108
|
+
## Security
|
|
109
|
+
|
|
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.
|
|
114
|
+
|
|
115
|
+
## Limitations
|
|
116
|
+
|
|
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.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-localhost",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run local models in opencode. llama.cpp as a provider, with live GPU, VRAM and CPU telemetry in the sidebar.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"opencode",
|
|
7
|
+
"opencode-plugin",
|
|
8
|
+
"llama.cpp",
|
|
9
|
+
"llama-server",
|
|
10
|
+
"local-models",
|
|
11
|
+
"gguf",
|
|
12
|
+
"vllm",
|
|
13
|
+
"gpu",
|
|
14
|
+
"self-hosted"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "dushyant30suthar",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/dushyant30suthar/opencode-localhost.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/dushyant30suthar/opencode-localhost#readme",
|
|
23
|
+
"bugs": "https://github.com/dushyant30suthar/opencode-localhost/issues",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
"./server": "./src/server/index.ts",
|
|
27
|
+
"./tui": "./src/tui/index.tsx"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src",
|
|
31
|
+
"tsconfig.json",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
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
|
+
"devDependencies": {
|
|
48
|
+
"@opencode-ai/plugin": "^1.18.4",
|
|
49
|
+
"@opentui/solid": "^0.4.5",
|
|
50
|
+
"@types/node": "^26.1.1",
|
|
51
|
+
"solid-js": "^1.9.0",
|
|
52
|
+
"typescript": "^7.0.2"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=22"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ProviderStatus } from "../shared/types.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What every backend implements. llama.cpp today; vLLM and OpenVINO are new
|
|
5
|
+
* folders implementing this, with no change to the plugin around them.
|
|
6
|
+
*
|
|
7
|
+
* A backend owns exactly three things: knowing what models exist, getting its
|
|
8
|
+
* server running, and saying where to reach it. Everything backend-specific —
|
|
9
|
+
* config file format, launch flags, discovery layout — stays inside.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type DiscoveredModel = {
|
|
13
|
+
/** Stable id shown in opencode's picker and used as the API model id. */
|
|
14
|
+
id: string
|
|
15
|
+
name: string
|
|
16
|
+
/** Real context window this model loads with. opencode compacts against it. */
|
|
17
|
+
context: number
|
|
18
|
+
output: number
|
|
19
|
+
/** Sampling to send per request, so opencode's own defaults never override. */
|
|
20
|
+
sampling: Record<string, number>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Backend {
|
|
24
|
+
readonly id: string
|
|
25
|
+
readonly name: string
|
|
26
|
+
|
|
27
|
+
/** Never throws. "unconfigured" is a normal answer on a fresh install. */
|
|
28
|
+
status(): Promise<ProviderStatus>
|
|
29
|
+
|
|
30
|
+
/** Empty when unconfigured or when the directory holds nothing usable. */
|
|
31
|
+
models(): Promise<DiscoveredModel[]>
|
|
32
|
+
|
|
33
|
+
/** Idempotent: starts the server only if it is not already answering. */
|
|
34
|
+
start(): Promise<ProviderStatus>
|
|
35
|
+
|
|
36
|
+
baseURL(): string
|
|
37
|
+
apiKey(): string | undefined
|
|
38
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Backend, DiscoveredModel } from "./backend.ts"
|
|
2
|
+
import { create as llamacpp } from "./llamacpp/index.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The server half.
|
|
6
|
+
*
|
|
7
|
+
* opencode loads plugins before it reads `cfg.provider`, specifically so a
|
|
8
|
+
* plugin's config() hook can add to it. That is the whole integration: we
|
|
9
|
+
* discover models on disk, start the backend, and write a provider into the
|
|
10
|
+
* config opencode is about to read. From there it is an ordinary provider.
|
|
11
|
+
*
|
|
12
|
+
* Sampling is handled in chat.params rather than baked into the server launch,
|
|
13
|
+
* so changing `temp` in models.ini takes effect on the next message instead of
|
|
14
|
+
* requiring a model reload — and so opencode's own per-model heuristics, which
|
|
15
|
+
* know nothing about local models, never override it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const BACKENDS: Backend[] = [llamacpp()]
|
|
19
|
+
|
|
20
|
+
/** Cached per process: discovery walks the filesystem and starts a server. */
|
|
21
|
+
const sampling = new Map<string, Record<string, number>>()
|
|
22
|
+
|
|
23
|
+
function providerEntry(backend: Backend, models: DiscoveredModel[]) {
|
|
24
|
+
return {
|
|
25
|
+
name: `${backend.name} (local)`,
|
|
26
|
+
api: backend.baseURL(),
|
|
27
|
+
npm: "@ai-sdk/openai-compatible",
|
|
28
|
+
options: {
|
|
29
|
+
baseURL: backend.baseURL(),
|
|
30
|
+
// the SDK requires some value; an unauthenticated server ignores it
|
|
31
|
+
apiKey: backend.apiKey() ?? "local",
|
|
32
|
+
},
|
|
33
|
+
models: Object.fromEntries(
|
|
34
|
+
models.map((model) => [
|
|
35
|
+
model.id,
|
|
36
|
+
{
|
|
37
|
+
name: model.name,
|
|
38
|
+
limit: { context: model.context, output: model.output },
|
|
39
|
+
cost: { input: 0, output: 0 },
|
|
40
|
+
options: {},
|
|
41
|
+
},
|
|
42
|
+
]),
|
|
43
|
+
),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function register(input: any) {
|
|
48
|
+
for (const backend of BACKENDS) {
|
|
49
|
+
const status = await backend.status().catch(() => undefined)
|
|
50
|
+
// unconfigured or missing binary: contribute nothing. The panel explains why.
|
|
51
|
+
if (!status || status.state === "unconfigured" || status.state === "failed") continue
|
|
52
|
+
|
|
53
|
+
// Not up yet: start it in the background and register nothing this run.
|
|
54
|
+
//
|
|
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
|
+
}
|
|
64
|
+
|
|
65
|
+
const models = await backend.models().catch(() => [])
|
|
66
|
+
if (models.length === 0) continue
|
|
67
|
+
|
|
68
|
+
for (const model of models) {
|
|
69
|
+
if (Object.keys(model.sampling).length > 0) {
|
|
70
|
+
sampling.set(`${backend.id}/${model.id}`, model.sampling)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
input.provider = input.provider ?? {}
|
|
75
|
+
input.provider[backend.id] = providerEntry(backend, models)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const server = async () => ({
|
|
80
|
+
config: async (input: any) => {
|
|
81
|
+
await register(input).catch(() => {})
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
"chat.params": async (input: any, output: any) => {
|
|
85
|
+
const providerID = input?.model?.providerID
|
|
86
|
+
if (!BACKENDS.some((backend) => backend.id === providerID)) return
|
|
87
|
+
const values = sampling.get(`${providerID}/${input?.model?.id}`)
|
|
88
|
+
if (!values) return
|
|
89
|
+
// opencode names three of these directly and passes the rest through options
|
|
90
|
+
if ("temperature" in values) output.temperature = values["temperature"]
|
|
91
|
+
if ("top_p" in values) output.topP = values["top_p"]
|
|
92
|
+
if ("top_k" in values) output.topK = values["top_k"]
|
|
93
|
+
const extra = { ...values }
|
|
94
|
+
delete extra["temperature"]
|
|
95
|
+
delete extra["top_p"]
|
|
96
|
+
delete extra["top_k"]
|
|
97
|
+
if (Object.keys(extra).length > 0) {
|
|
98
|
+
output.options = { ...(output.options ?? {}), ...extra }
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
export default { id: "opencode-localhost", server }
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
import path from "path"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Find .gguf files under the models directory.
|
|
6
|
+
*
|
|
7
|
+
* llama-server's own --models-dir scan is one level deep, but the common
|
|
8
|
+
* layouts nest: LM Studio uses <root>/<publisher>/<repo>/*.gguf and a plain
|
|
9
|
+
* download folder is flat. We walk a bounded depth so both work without the
|
|
10
|
+
* user having to know which one they have.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MAX_DEPTH = 3
|
|
14
|
+
|
|
15
|
+
export type LocalModel = {
|
|
16
|
+
/** Section name: "publisher/repo:Q6_K", or "publisher/repo" when unambiguous. */
|
|
17
|
+
id: string
|
|
18
|
+
file: string
|
|
19
|
+
/** Vision projector beside the weights, if present. */
|
|
20
|
+
mmproj?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Found = {
|
|
24
|
+
dir: string
|
|
25
|
+
/** Path segments below the models root, for naming. */
|
|
26
|
+
relative: string[]
|
|
27
|
+
files: string[]
|
|
28
|
+
mmproj?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** "Qwen3.6-27B-Q6_K.gguf" -> "Q6_K". Multi-shard suffixes stripped first. */
|
|
32
|
+
export function quantOf(filename: string): string | undefined {
|
|
33
|
+
const stem = filename.replace(/\.gguf$/i, "").replace(/-\d{5}-of-\d{5}$/, "")
|
|
34
|
+
return stem.match(/[-.]((?:I?Q\d[\w.]*)|F16|F32|BF16|MXFP4[\w]*|UD-[\w.]+)$/i)?.[1]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function nameFor(relative: string[], filename: string, multiple: boolean): string {
|
|
38
|
+
const base = relative.length > 0 ? relative.join("/").replace(/-GGUF$/i, "") : "models"
|
|
39
|
+
if (!multiple) return base
|
|
40
|
+
const quant = quantOf(filename)
|
|
41
|
+
return `${base}:${quant ?? filename.replace(/\.gguf$/i, "")}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function walk(dir: string, relative: string[], out: Found[]): Promise<void> {
|
|
45
|
+
if (relative.length > MAX_DEPTH) return
|
|
46
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
47
|
+
const files: string[] = []
|
|
48
|
+
let mmproj: string | undefined
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
await walk(path.join(dir, entry.name), [...relative, entry.name], out)
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".gguf")) continue
|
|
55
|
+
if (/mmproj/i.test(entry.name)) {
|
|
56
|
+
mmproj = entry.name
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
// multi-shard models: only the first shard is passed to llama-server
|
|
60
|
+
if (/-\d{5}-of-\d{5}\.gguf$/i.test(entry.name)) {
|
|
61
|
+
if (entry.name.includes("-00001-of-")) files.push(entry.name)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
files.push(entry.name)
|
|
65
|
+
}
|
|
66
|
+
if (files.length > 0) out.push({ dir, relative, files: files.sort(), mmproj })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function scan(root: string): Promise<LocalModel[]> {
|
|
70
|
+
if (!root) return []
|
|
71
|
+
const found: Found[] = []
|
|
72
|
+
await walk(root, [], found)
|
|
73
|
+
|
|
74
|
+
const models: LocalModel[] = []
|
|
75
|
+
const taken = new Set<string>()
|
|
76
|
+
for (const group of found) {
|
|
77
|
+
// one entry per quant file, so every variant is separately selectable
|
|
78
|
+
const multiple = group.files.length > 1
|
|
79
|
+
for (const file of group.files) {
|
|
80
|
+
let id = nameFor(group.relative, file, multiple)
|
|
81
|
+
if (taken.has(id)) id = nameFor(group.relative, file, true)
|
|
82
|
+
if (taken.has(id)) continue
|
|
83
|
+
taken.add(id)
|
|
84
|
+
models.push({
|
|
85
|
+
id,
|
|
86
|
+
file: path.join(group.dir, file),
|
|
87
|
+
mmproj: group.mmproj ? path.join(group.dir, group.mmproj) : undefined,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return models.sort((a, b) => a.id.localeCompare(b.id))
|
|
92
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import { spawn } from "child_process"
|
|
4
|
+
import { stateDir, collapseHome } from "../../shared/paths.ts"
|
|
5
|
+
import type { ProviderStatus } from "../../shared/types.ts"
|
|
6
|
+
import type { Backend, DiscoveredModel } from "../backend.ts"
|
|
7
|
+
import * as Server from "./server-ini.ts"
|
|
8
|
+
import * as Models from "./models-ini.ts"
|
|
9
|
+
import { scan } from "./discover.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The llama.cpp backend: find llama-server, start it against models.ini, and
|
|
13
|
+
* report what it has. Everything llama.cpp-specific lives here and below.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const STATE = stateDir(Server.BACKEND)
|
|
17
|
+
const PID_FILE = path.join(STATE, "server.pid")
|
|
18
|
+
const LOG_FILE = path.join(STATE, "server.log")
|
|
19
|
+
|
|
20
|
+
const PROBE_TIMEOUT = 1_500
|
|
21
|
+
const START_TIMEOUT = 20_000
|
|
22
|
+
const POLL_INTERVAL = 400
|
|
23
|
+
|
|
24
|
+
/** Only $PATH. Guessing at build directories bakes one machine into everyone's. */
|
|
25
|
+
async function onPath(): Promise<string | undefined> {
|
|
26
|
+
const dirs = (process.env["PATH"] || "").split(path.delimiter).filter(Boolean)
|
|
27
|
+
for (const dir of dirs) {
|
|
28
|
+
const candidate = path.join(dir, "llama-server")
|
|
29
|
+
const stat = await fs.stat(candidate).catch(() => undefined)
|
|
30
|
+
if (stat?.isFile()) return candidate
|
|
31
|
+
}
|
|
32
|
+
return undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function executable(file: string): Promise<boolean> {
|
|
36
|
+
const stat = await fs.stat(file).catch(() => undefined)
|
|
37
|
+
return !!stat?.isFile()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Reachable when it answers /v1/models; undefined means nothing is listening. */
|
|
41
|
+
async function reachable(baseURL: string, apiKey: string, timeout: number): Promise<boolean> {
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(`${baseURL}/models`, {
|
|
44
|
+
signal: AbortSignal.timeout(timeout),
|
|
45
|
+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
|
46
|
+
})
|
|
47
|
+
return res.ok
|
|
48
|
+
} catch {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function create(): Backend {
|
|
54
|
+
let settings: Server.ServerSettings | undefined
|
|
55
|
+
let starting: Promise<ProviderStatus> | undefined
|
|
56
|
+
|
|
57
|
+
// Re-read every time rather than caching: server.ini is small, and a cached
|
|
58
|
+
// copy meant editing it did nothing until the process restarted — including
|
|
59
|
+
// for the panel, which polls this several times a minute.
|
|
60
|
+
const config = async () => (settings = await Server.load())
|
|
61
|
+
const baseURL = () => {
|
|
62
|
+
const host = settings?.host === "0.0.0.0" ? "127.0.0.1" : (settings?.host ?? "127.0.0.1")
|
|
63
|
+
return `http://${host}:${settings?.port ?? 9337}/v1`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function resolveBin(cfg: Server.ServerSettings): Promise<string | undefined> {
|
|
67
|
+
if (cfg.bin) return (await executable(cfg.bin)) ? cfg.bin : undefined
|
|
68
|
+
const found = await onPath()
|
|
69
|
+
// record it so the file always shows what is actually being used
|
|
70
|
+
if (found) await Server.update("bin", found)
|
|
71
|
+
return found
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function status(): Promise<ProviderStatus> {
|
|
75
|
+
const cfg = await config()
|
|
76
|
+
const bin = await resolveBin(cfg)
|
|
77
|
+
if (!bin) {
|
|
78
|
+
return {
|
|
79
|
+
state: "unconfigured",
|
|
80
|
+
missing: "binary",
|
|
81
|
+
message: "llama-server not found",
|
|
82
|
+
hint: `set bin in ${collapseHome(Server.FILE)}`,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!cfg.modelsDir) {
|
|
86
|
+
return {
|
|
87
|
+
state: "unconfigured",
|
|
88
|
+
missing: "models-dir",
|
|
89
|
+
message: "models-dir not set",
|
|
90
|
+
hint: `set it in ${collapseHome(Server.FILE)}`,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (await reachable(baseURL(), cfg.apiKey, PROBE_TIMEOUT)) {
|
|
94
|
+
return { state: "running", endpoint: baseURL() }
|
|
95
|
+
}
|
|
96
|
+
return { state: "stopped" }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function models(): Promise<DiscoveredModel[]> {
|
|
100
|
+
const cfg = await config()
|
|
101
|
+
if (!cfg.modelsDir) return []
|
|
102
|
+
const local = await scan(cfg.modelsDir)
|
|
103
|
+
if (local.length === 0) return []
|
|
104
|
+
const { names } = await Models.sync(local)
|
|
105
|
+
const perModel = await Models.settings()
|
|
106
|
+
return local.map((model) => {
|
|
107
|
+
// the section name governs, so a renamed section keeps its tuned values
|
|
108
|
+
const id = names.get(model.file) ?? model.id
|
|
109
|
+
const entry = perModel[id]
|
|
110
|
+
const context = entry?.context || 32_768
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
name: id.split("/").filter(Boolean).at(-1) ?? id,
|
|
114
|
+
context,
|
|
115
|
+
// thinking models spend the reasoning budget from the output allowance;
|
|
116
|
+
// half the window, capped, leaves room for a long plan without truncating
|
|
117
|
+
output: Math.min(32_768, Math.max(4_096, Math.floor(context / 2))),
|
|
118
|
+
sampling: entry?.sampling ?? {},
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function launch(cfg: Server.ServerSettings, bin: string): Promise<ProviderStatus> {
|
|
124
|
+
const local = await scan(cfg.modelsDir)
|
|
125
|
+
if (local.length === 0) {
|
|
126
|
+
return {
|
|
127
|
+
state: "unconfigured",
|
|
128
|
+
missing: "models-dir",
|
|
129
|
+
message: "no .gguf files found",
|
|
130
|
+
hint: collapseHome(cfg.modelsDir),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const { preset } = await Models.sync(local)
|
|
134
|
+
await fs.mkdir(STATE, { recursive: true }).catch(() => {})
|
|
135
|
+
const log = await fs.open(LOG_FILE, "a").catch(() => undefined)
|
|
136
|
+
try {
|
|
137
|
+
const child = spawn(bin, Server.argv(cfg, preset), {
|
|
138
|
+
detached: true,
|
|
139
|
+
stdio: ["ignore", log?.fd ?? "ignore", log?.fd ?? "ignore"],
|
|
140
|
+
})
|
|
141
|
+
child.on("error", () => {})
|
|
142
|
+
child.unref()
|
|
143
|
+
if (child.pid) await fs.writeFile(PID_FILE, `${child.pid}\n`).catch(() => {})
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return { state: "failed", message: "could not start", hint: String(error).slice(0, 60) }
|
|
146
|
+
} finally {
|
|
147
|
+
await log?.close().catch(() => {})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const deadline = Date.now() + START_TIMEOUT
|
|
151
|
+
while (Date.now() < deadline) {
|
|
152
|
+
if (await reachable(baseURL(), cfg.apiKey, PROBE_TIMEOUT)) {
|
|
153
|
+
return { state: "running", endpoint: baseURL() }
|
|
154
|
+
}
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL))
|
|
156
|
+
}
|
|
157
|
+
return { state: "failed", message: "server did not start", hint: collapseHome(LOG_FILE) }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Idempotent and single-flight: concurrent callers share one attempt. */
|
|
161
|
+
async function start(): Promise<ProviderStatus> {
|
|
162
|
+
if (starting) return starting
|
|
163
|
+
starting = (async () => {
|
|
164
|
+
const cfg = await config()
|
|
165
|
+
const bin = await resolveBin(cfg)
|
|
166
|
+
if (!bin || !cfg.modelsDir) return status()
|
|
167
|
+
if (await reachable(baseURL(), cfg.apiKey, PROBE_TIMEOUT)) {
|
|
168
|
+
return { state: "running", endpoint: baseURL() } as ProviderStatus
|
|
169
|
+
}
|
|
170
|
+
return launch(cfg, bin)
|
|
171
|
+
})()
|
|
172
|
+
try {
|
|
173
|
+
return await starting
|
|
174
|
+
} finally {
|
|
175
|
+
starting = undefined
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
id: Server.BACKEND,
|
|
181
|
+
name: "llama.cpp",
|
|
182
|
+
status,
|
|
183
|
+
models,
|
|
184
|
+
start,
|
|
185
|
+
baseURL,
|
|
186
|
+
apiKey: () => settings?.apiKey || undefined,
|
|
187
|
+
}
|
|
188
|
+
}
|