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
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import * as Ini from "../../shared/ini.ts"
|
|
4
|
+
import { configDir } from "../../shared/paths.ts"
|
|
5
|
+
import type { LocalModel } from "./discover.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* models.ini — llama.cpp's file, not ours. Handed to
|
|
9
|
+
* `llama-server --models-preset` verbatim; llama.cpp parses it, we only append.
|
|
10
|
+
*
|
|
11
|
+
* The contract with the user: we add a section when a new .gguf appears and
|
|
12
|
+
* never modify or remove one afterwards. Everything in here is a llama-server
|
|
13
|
+
* flag, so any flag llama.cpp accepts works whether or not we know about it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const FILE = path.join(configDir("llamacpp"), "models.ini")
|
|
17
|
+
|
|
18
|
+
const HEADER = `version = 1
|
|
19
|
+
|
|
20
|
+
# Per-model llama-server settings. YOURS TO EDIT.
|
|
21
|
+
# New models get a section appended; existing sections are never touched.
|
|
22
|
+
# Any llama-server flag works as a key, without the leading dashes:
|
|
23
|
+
#
|
|
24
|
+
# ctx-size = 32768 context window, in tokens
|
|
25
|
+
# gpu-layers = 99 layers on GPU (99 = all)
|
|
26
|
+
# tensor-split = 0.6,0.4 split across multiple GPUs
|
|
27
|
+
# cache-type-k = q8_0 KV cache quantisation: f16 | q8_0 | q4_0
|
|
28
|
+
# flash-attn = on
|
|
29
|
+
# temp = 0.7 sampling; sent per-request, no reload needed
|
|
30
|
+
#
|
|
31
|
+
# Launch flags apply on the next model load. Sampling applies immediately.`
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Conservative on purpose: these must not OOM on a machine we know nothing
|
|
35
|
+
* about. 32k context with a quantised KV cache fits alongside most models on
|
|
36
|
+
* a single mid-range card. Tune upward per model; that is what the file is for.
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULTS: Record<string, string> = {
|
|
39
|
+
"ctx-size": "32768",
|
|
40
|
+
"gpu-layers": "99",
|
|
41
|
+
"flash-attn": "on",
|
|
42
|
+
"cache-type-k": "q8_0",
|
|
43
|
+
"cache-type-v": "q8_0",
|
|
44
|
+
jinja: "true",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ModelSettings = {
|
|
48
|
+
context: number
|
|
49
|
+
sampling: Record<string, number>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SAMPLING_KEYS = ["temp", "top-p", "top-k", "min-p", "repeat-penalty", "presence-penalty"] as const
|
|
53
|
+
|
|
54
|
+
/** llama.cpp flag names -> OpenAI request body fields. */
|
|
55
|
+
const SAMPLING_WIRE: Record<string, string> = {
|
|
56
|
+
temp: "temperature",
|
|
57
|
+
"top-p": "top_p",
|
|
58
|
+
"top-k": "top_k",
|
|
59
|
+
"min-p": "min_p",
|
|
60
|
+
"repeat-penalty": "repetition_penalty",
|
|
61
|
+
"presence-penalty": "presence_penalty",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function section(model: LocalModel): string {
|
|
65
|
+
const lines = [`[${model.id}]`, `model = ${model.file}`]
|
|
66
|
+
if (model.mmproj) lines.push(`mmproj = ${model.mmproj}`)
|
|
67
|
+
for (const [key, value] of Object.entries(DEFAULTS)) lines.push(`${key} = ${value}`)
|
|
68
|
+
return lines.join("\n")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type SyncResult = {
|
|
72
|
+
preset: string
|
|
73
|
+
/** Discovered file -> the section name that actually governs it. */
|
|
74
|
+
names: Map<string, string>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Append sections for models that do not have one yet.
|
|
79
|
+
*
|
|
80
|
+
* The section name IS the model id, so a section the user renamed keeps
|
|
81
|
+
* winning: we match on the `model =` path first and only fall back to the
|
|
82
|
+
* proposed name for genuinely new files. Getting this backwards would append a
|
|
83
|
+
* duplicate default section beside a hand-tuned one and then read `ctx-size`
|
|
84
|
+
* from the wrong one — silently compacting far below the real window.
|
|
85
|
+
*/
|
|
86
|
+
export async function sync(models: LocalModel[]): Promise<SyncResult> {
|
|
87
|
+
const existing = await fs.readFile(FILE, "utf8").catch(() => "")
|
|
88
|
+
const doc = Ini.parse(existing)
|
|
89
|
+
const names = new Set(doc.sections.map((item) => item.name))
|
|
90
|
+
|
|
91
|
+
const byFile = new Map<string, string>()
|
|
92
|
+
for (const item of doc.sections) {
|
|
93
|
+
const file = Ini.get(item, "model")?.trim()
|
|
94
|
+
if (file) byFile.set(file, item.name)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const added: string[] = []
|
|
98
|
+
const resolved = new Map<string, string>()
|
|
99
|
+
for (const model of models) {
|
|
100
|
+
const owner = byFile.get(model.file)
|
|
101
|
+
if (owner) {
|
|
102
|
+
resolved.set(model.file, owner)
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
// a section named for this model but pointing elsewhere still owns the name
|
|
106
|
+
let id = model.id
|
|
107
|
+
for (let n = 2; names.has(id); n++) id = `${model.id}#${n}`
|
|
108
|
+
names.add(id)
|
|
109
|
+
resolved.set(model.file, id)
|
|
110
|
+
added.push(section({ ...model, id }))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const body = existing.trim().length > 0 ? existing.replace(/\s+$/, "") : HEADER
|
|
114
|
+
const next = added.length > 0 ? [body, "", added.join("\n\n"), ""].join("\n") : body + "\n"
|
|
115
|
+
|
|
116
|
+
await fs.mkdir(path.dirname(FILE), { recursive: true }).catch(() => {})
|
|
117
|
+
await fs.writeFile(FILE, next).catch(() => {})
|
|
118
|
+
return { preset: FILE, names: resolved }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Per-model context and sampling, read back out of the file.
|
|
123
|
+
*
|
|
124
|
+
* Context matters beyond display: opencode compacts a conversation against the
|
|
125
|
+
* model's advertised window, so if we reported a default while the model
|
|
126
|
+
* actually loaded with 245k, it would compact at a fraction of the real limit.
|
|
127
|
+
*/
|
|
128
|
+
export async function settings(): Promise<Record<string, ModelSettings>> {
|
|
129
|
+
const text = await fs.readFile(FILE, "utf8").catch(() => "")
|
|
130
|
+
const out: Record<string, ModelSettings> = {}
|
|
131
|
+
for (const item of Ini.parse(text).sections) {
|
|
132
|
+
const raw = Ini.entries(item)
|
|
133
|
+
const context = Number.parseInt(raw["ctx-size"] ?? "", 10)
|
|
134
|
+
const sampling: Record<string, number> = {}
|
|
135
|
+
for (const key of SAMPLING_KEYS) {
|
|
136
|
+
const value = Number.parseFloat(raw[key] ?? "")
|
|
137
|
+
if (Number.isFinite(value)) sampling[SAMPLING_WIRE[key]] = value
|
|
138
|
+
}
|
|
139
|
+
out[item.name] = {
|
|
140
|
+
context: Number.isFinite(context) && context > 0 ? context : 0,
|
|
141
|
+
sampling,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Reset one section to the shipped defaults, keeping model/mmproj paths. */
|
|
148
|
+
export async function restoreDefaults(id: string): Promise<boolean> {
|
|
149
|
+
const text = await fs.readFile(FILE, "utf8").catch(() => "")
|
|
150
|
+
const doc = Ini.parse(text)
|
|
151
|
+
const target = Ini.find(doc, id)
|
|
152
|
+
if (!target) return false
|
|
153
|
+
const raw = Ini.entries(target)
|
|
154
|
+
const lines = [`model = ${raw["model"] ?? ""}`]
|
|
155
|
+
if (raw["mmproj"]) lines.push(`mmproj = ${raw["mmproj"]}`)
|
|
156
|
+
for (const [key, value] of Object.entries(DEFAULTS)) lines.push(`${key} = ${value}`)
|
|
157
|
+
target.lines = lines
|
|
158
|
+
await fs.writeFile(FILE, Ini.serialize(doc)).catch(() => {})
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import * as Ini from "../../shared/ini.ts"
|
|
4
|
+
import { configDir, expandHome, collapseHome } from "../../shared/paths.ts"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* server.ini — ours, not llama.cpp's. We read it and build llama-server's
|
|
8
|
+
* command line from it. llama-server never sees this file.
|
|
9
|
+
*
|
|
10
|
+
* Two values have no safe default and are left empty until set:
|
|
11
|
+
* bin — filled from $PATH if llama-server is there, else empty
|
|
12
|
+
* models-dir — always empty on a fresh install; only the user knows this
|
|
13
|
+
*
|
|
14
|
+
* Empty is not a failure mode to hide. The panel reads it and asks.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const BACKEND = "llamacpp"
|
|
18
|
+
export const FILE = path.join(configDir(BACKEND), "server.ini")
|
|
19
|
+
|
|
20
|
+
const TEMPLATE = `# llama-server settings for opencode-localhost.
|
|
21
|
+
# Changing anything here restarts the server.
|
|
22
|
+
#
|
|
23
|
+
# bin path to llama-server. empty = look on $PATH
|
|
24
|
+
# models-dir where your .gguf files live. REQUIRED, no default
|
|
25
|
+
# host 127.0.0.1 keeps it on this machine.
|
|
26
|
+
# 0.0.0.0 exposes it to your network — set api-key if you do
|
|
27
|
+
# port llama-server listen port
|
|
28
|
+
# models-max how many models may sit in VRAM at once. 1 swaps instead of stacking
|
|
29
|
+
# api-key required by the server when set. leave empty for localhost-only
|
|
30
|
+
|
|
31
|
+
[server]
|
|
32
|
+
bin =
|
|
33
|
+
models-dir =
|
|
34
|
+
host = 127.0.0.1
|
|
35
|
+
port = 9337
|
|
36
|
+
models-max = 1
|
|
37
|
+
api-key =
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
export type ServerSettings = {
|
|
41
|
+
bin: string
|
|
42
|
+
modelsDir: string
|
|
43
|
+
host: string
|
|
44
|
+
port: number
|
|
45
|
+
modelsMax: number
|
|
46
|
+
apiKey: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULTS: ServerSettings = {
|
|
50
|
+
bin: "",
|
|
51
|
+
modelsDir: "",
|
|
52
|
+
host: "127.0.0.1",
|
|
53
|
+
port: 9337,
|
|
54
|
+
modelsMax: 1,
|
|
55
|
+
apiKey: "",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function number(value: string | undefined, fallback: number) {
|
|
59
|
+
const parsed = Number.parseInt((value ?? "").trim(), 10)
|
|
60
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Creates the file on first call so there is always something to point at. */
|
|
64
|
+
export async function load(): Promise<ServerSettings> {
|
|
65
|
+
let text = await fs.readFile(FILE, "utf8").catch(() => undefined)
|
|
66
|
+
if (text === undefined) {
|
|
67
|
+
await fs.mkdir(path.dirname(FILE), { recursive: true }).catch(() => {})
|
|
68
|
+
await fs.writeFile(FILE, TEMPLATE).catch(() => {})
|
|
69
|
+
text = TEMPLATE
|
|
70
|
+
}
|
|
71
|
+
const section = Ini.find(Ini.parse(text), "server")
|
|
72
|
+
if (!section) return { ...DEFAULTS }
|
|
73
|
+
const raw = Ini.entries(section)
|
|
74
|
+
return {
|
|
75
|
+
bin: expandHome((raw["bin"] ?? "").trim()),
|
|
76
|
+
modelsDir: expandHome((raw["models-dir"] ?? "").trim()),
|
|
77
|
+
host: (raw["host"] || DEFAULTS.host).trim(),
|
|
78
|
+
port: number(raw["port"], DEFAULTS.port),
|
|
79
|
+
modelsMax: number(raw["models-max"], DEFAULTS.modelsMax),
|
|
80
|
+
apiKey: (raw["api-key"] ?? "").trim(),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Writes one key back, preserving comments and everything else in the file. */
|
|
85
|
+
export async function update(key: "bin" | "models-dir", value: string): Promise<void> {
|
|
86
|
+
const text = await fs.readFile(FILE, "utf8").catch(() => TEMPLATE)
|
|
87
|
+
const doc = Ini.parse(text)
|
|
88
|
+
const section = Ini.find(doc, "server")
|
|
89
|
+
if (!section) return
|
|
90
|
+
Ini.set(section, key, collapseHome(value))
|
|
91
|
+
await fs.mkdir(path.dirname(FILE), { recursive: true }).catch(() => {})
|
|
92
|
+
await fs.writeFile(FILE, Ini.serialize(doc)).catch(() => {})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** llama-server's command line, built from the settings plus the preset file. */
|
|
96
|
+
export function argv(settings: ServerSettings, preset: string): string[] {
|
|
97
|
+
return [
|
|
98
|
+
"--models-dir",
|
|
99
|
+
settings.modelsDir,
|
|
100
|
+
"--models-preset",
|
|
101
|
+
preset,
|
|
102
|
+
"--models-max",
|
|
103
|
+
String(settings.modelsMax),
|
|
104
|
+
"--host",
|
|
105
|
+
settings.host,
|
|
106
|
+
"--port",
|
|
107
|
+
String(settings.port),
|
|
108
|
+
...(settings.apiKey ? ["--api-key", settings.apiKey] : []),
|
|
109
|
+
]
|
|
110
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import os from "os"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The backends this plugin knows about.
|
|
7
|
+
*
|
|
8
|
+
* Only llama.cpp is implemented. The others are listed anyway so the setup
|
|
9
|
+
* screen can tell you what it found and what installing them would take —
|
|
10
|
+
* a greyed row with a real install command is more use than pretending the
|
|
11
|
+
* option does not exist.
|
|
12
|
+
*
|
|
13
|
+
* Note these are not the same kind of thing: llama.cpp ships a native binary
|
|
14
|
+
* with prebuilt releases, while vLLM, MLX and OpenVINO are Python packages.
|
|
15
|
+
* That is why this plugin never installs anything for you — "install" means
|
|
16
|
+
* something different for each, and picking the wrong llama.cpp build (CUDA
|
|
17
|
+
* version, GPU arch) silently costs a large multiple of performance.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type BackendSpec = {
|
|
21
|
+
id: string
|
|
22
|
+
name: string
|
|
23
|
+
/** Executable to look for on $PATH. */
|
|
24
|
+
binary: string
|
|
25
|
+
/** What a user would actually run to get it. */
|
|
26
|
+
install: string
|
|
27
|
+
/** Restricts availability; MLX is Apple Silicon only. */
|
|
28
|
+
requires?: { platform: NodeJS.Platform; arch: string }
|
|
29
|
+
implemented: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const BACKENDS: BackendSpec[] = [
|
|
33
|
+
{
|
|
34
|
+
id: "llamacpp",
|
|
35
|
+
name: "llama.cpp",
|
|
36
|
+
binary: "llama-server",
|
|
37
|
+
install: "build from source, or: brew install llama.cpp",
|
|
38
|
+
implemented: true,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "vllm",
|
|
42
|
+
name: "vLLM",
|
|
43
|
+
binary: "vllm",
|
|
44
|
+
install: "pip install vllm",
|
|
45
|
+
implemented: false,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "mlx",
|
|
49
|
+
name: "MLX",
|
|
50
|
+
binary: "mlx_lm.server",
|
|
51
|
+
install: "pip install mlx-lm",
|
|
52
|
+
requires: { platform: "darwin", arch: "arm64" },
|
|
53
|
+
implemented: false,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "openvino",
|
|
57
|
+
name: "OpenVINO",
|
|
58
|
+
binary: "ovms",
|
|
59
|
+
install: "pip install openvino-genai",
|
|
60
|
+
implemented: false,
|
|
61
|
+
},
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
export function supported(spec: BackendSpec): boolean {
|
|
65
|
+
if (!spec.requires) return true
|
|
66
|
+
return process.platform === spec.requires.platform && process.arch === spec.requires.arch
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** First match on $PATH, or undefined. We never guess at build directories. */
|
|
70
|
+
export async function onPath(binary: string): Promise<string | undefined> {
|
|
71
|
+
const dirs = (process.env["PATH"] || "").split(path.delimiter).filter(Boolean)
|
|
72
|
+
for (const dir of dirs) {
|
|
73
|
+
const candidate = path.join(dir, binary)
|
|
74
|
+
const stat = await fs.stat(candidate).catch(() => undefined)
|
|
75
|
+
if (stat?.isFile()) return candidate
|
|
76
|
+
}
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every match on $PATH — several llama.cpp builds commonly coexist. */
|
|
81
|
+
export async function allOnPath(binary: string): Promise<string[]> {
|
|
82
|
+
const dirs = (process.env["PATH"] || "").split(path.delimiter).filter(Boolean)
|
|
83
|
+
const found: string[] = []
|
|
84
|
+
for (const dir of dirs) {
|
|
85
|
+
const candidate = path.join(dir, binary)
|
|
86
|
+
const stat = await fs.stat(candidate).catch(() => undefined)
|
|
87
|
+
if (stat?.isFile() && !found.includes(candidate)) found.push(candidate)
|
|
88
|
+
}
|
|
89
|
+
return found
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function expand(value: string): string {
|
|
93
|
+
const trimmed = value.trim()
|
|
94
|
+
if (trimmed === "~") return os.homedir()
|
|
95
|
+
if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2))
|
|
96
|
+
return trimmed
|
|
97
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INI reader/writer that preserves everything it does not understand.
|
|
3
|
+
*
|
|
4
|
+
* These files are hand-edited — comments explaining why a model needs
|
|
5
|
+
* `ubatch-size = 2048` are the most valuable thing in them. A parse/serialize
|
|
6
|
+
* round trip that drops comments, ordering, or blank lines would quietly
|
|
7
|
+
* destroy that, so sections keep their raw lines and are only rewritten when a
|
|
8
|
+
* key in them actually changes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type Section = {
|
|
12
|
+
name: string
|
|
13
|
+
/** Raw lines between this header and the next, verbatim. */
|
|
14
|
+
lines: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Document = {
|
|
18
|
+
/** Lines before the first section header. */
|
|
19
|
+
preamble: string[]
|
|
20
|
+
sections: Section[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const HEADER = /^\s*\[([^\]]+)\]\s*$/
|
|
24
|
+
/** Keys may repeat (llama.cpp allows it); we address the first occurrence. */
|
|
25
|
+
const entry = (key: string) => new RegExp(`^(\\s*)${escape(key)}(\\s*=\\s*)(.*?)(\\s*)$`)
|
|
26
|
+
|
|
27
|
+
function escape(value: string) {
|
|
28
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parse(text: string): Document {
|
|
32
|
+
const doc: Document = { preamble: [], sections: [] }
|
|
33
|
+
let current: Section | undefined
|
|
34
|
+
for (const line of text.split("\n")) {
|
|
35
|
+
const header = line.match(HEADER)
|
|
36
|
+
if (header) {
|
|
37
|
+
current = { name: header[1], lines: [] }
|
|
38
|
+
doc.sections.push(current)
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (current) current.lines.push(line)
|
|
42
|
+
else doc.preamble.push(line)
|
|
43
|
+
}
|
|
44
|
+
return doc
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function serialize(doc: Document): string {
|
|
48
|
+
const out: string[] = [...doc.preamble]
|
|
49
|
+
for (const section of doc.sections) {
|
|
50
|
+
out.push(`[${section.name}]`)
|
|
51
|
+
out.push(...section.lines)
|
|
52
|
+
}
|
|
53
|
+
return out.join("\n").replace(/\n*$/, "\n")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function get(section: Section, key: string): string | undefined {
|
|
57
|
+
for (const line of section.lines) {
|
|
58
|
+
const match = line.match(entry(key))
|
|
59
|
+
if (match) return match[3]
|
|
60
|
+
}
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Set a key in place, keeping its original spacing. Undefined removes it.
|
|
66
|
+
* Appends after the last existing entry rather than at the very end, so a
|
|
67
|
+
* trailing comment block stays at the bottom where the user put it.
|
|
68
|
+
*/
|
|
69
|
+
export function set(section: Section, key: string, value: string | undefined): void {
|
|
70
|
+
const pattern = entry(key)
|
|
71
|
+
for (let i = 0; i < section.lines.length; i++) {
|
|
72
|
+
const match = section.lines[i].match(pattern)
|
|
73
|
+
if (!match) continue
|
|
74
|
+
if (value === undefined) section.lines.splice(i, 1)
|
|
75
|
+
else section.lines[i] = `${match[1]}${key}${match[2]}${value}`
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (value === undefined) return
|
|
79
|
+
let insert = section.lines.length
|
|
80
|
+
while (insert > 0 && !section.lines[insert - 1].includes("=")) insert--
|
|
81
|
+
section.lines.splice(insert, 0, `${key} = ${value}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function find(doc: Document, name: string): Section | undefined {
|
|
85
|
+
return doc.sections.find((section) => section.name === name)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Every `key = value` in a section, first occurrence wins. Comments skipped. */
|
|
89
|
+
export function entries(section: Section): Record<string, string> {
|
|
90
|
+
const out: Record<string, string> = {}
|
|
91
|
+
for (const line of section.lines) {
|
|
92
|
+
if (/^\s*[#;]/.test(line)) continue
|
|
93
|
+
const match = line.match(/^\s*([^=\s]+)\s*=\s*(.*?)\s*$/)
|
|
94
|
+
if (match && !(match[1] in out)) out[match[1]] = match[2]
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import os from "os"
|
|
2
|
+
import path from "path"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where a backend's files live. Config is what you edit; state is what we own.
|
|
6
|
+
*
|
|
7
|
+
* Config follows XDG so it sits next to opencode's own config, which is where
|
|
8
|
+
* people look for it. State holds pidfiles and logs — things you never edit and
|
|
9
|
+
* that should not survive in a dotfiles repo.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const xdg = (envVar: string, fallback: string) => process.env[envVar] || path.join(os.homedir(), fallback)
|
|
13
|
+
|
|
14
|
+
export const CONFIG_ROOT = path.join(xdg("XDG_CONFIG_HOME", ".config"), "opencode", "providers")
|
|
15
|
+
export const STATE_ROOT = path.join(xdg("XDG_STATE_HOME", ".local/state"), "opencode", "providers")
|
|
16
|
+
|
|
17
|
+
export function configDir(backend: string) {
|
|
18
|
+
return path.join(CONFIG_ROOT, backend)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function stateDir(backend: string) {
|
|
22
|
+
return path.join(STATE_ROOT, backend)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `~/models` and `$HOME/models` both appear in hand-edited config files. */
|
|
26
|
+
export function expandHome(value: string): string {
|
|
27
|
+
if (value === "~") return os.homedir()
|
|
28
|
+
if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2))
|
|
29
|
+
if (value.startsWith("$HOME/")) return path.join(os.homedir(), value.slice(6))
|
|
30
|
+
return value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Written back to config so paths stay readable rather than machine-specific. */
|
|
34
|
+
export function collapseHome(value: string): string {
|
|
35
|
+
const home = os.homedir()
|
|
36
|
+
return value.startsWith(home + path.sep) ? "~" + value.slice(home.length) : value
|
|
37
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Shapes the panel renders. Deliberately backend-agnostic. */
|
|
2
|
+
|
|
3
|
+
export type GpuStat = {
|
|
4
|
+
index: number
|
|
5
|
+
usedMiB: number
|
|
6
|
+
totalMiB: number
|
|
7
|
+
/** Percent 0-100. Undefined when the driver does not report it. */
|
|
8
|
+
utilization?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type SystemStat = {
|
|
12
|
+
usedMiB: number
|
|
13
|
+
totalMiB: number
|
|
14
|
+
utilization?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type LoadedModel = {
|
|
18
|
+
id: string
|
|
19
|
+
/** Launch flags the server reports, e.g. ctx-size, cache-type-k, gpu-layers. */
|
|
20
|
+
args: Record<string, string>
|
|
21
|
+
loading?: boolean
|
|
22
|
+
/** 0-1 while weights stream in. */
|
|
23
|
+
progress?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* "unconfigured" is a first-class state, not an error: a fresh install has no
|
|
28
|
+
* models directory and the panel is where the user is told so.
|
|
29
|
+
*/
|
|
30
|
+
export type ProviderStatus =
|
|
31
|
+
| { state: "unconfigured"; missing: "binary" | "models-dir"; message: string; hint?: string }
|
|
32
|
+
| { state: "stopped" }
|
|
33
|
+
| { state: "starting" }
|
|
34
|
+
| { state: "running"; endpoint: string; loaded?: LoadedModel }
|
|
35
|
+
| { state: "failed"; message: string; hint?: string }
|
|
36
|
+
|
|
37
|
+
export type PanelData = {
|
|
38
|
+
backend: { id: string; name: string }
|
|
39
|
+
status: ProviderStatus
|
|
40
|
+
/**
|
|
41
|
+
* Whether opencode has actually picked up the provider. False while the
|
|
42
|
+
* server is up but opencode has not re-read its config — the one case where
|
|
43
|
+
* a restart is genuinely required, so the panel has to say so.
|
|
44
|
+
*/
|
|
45
|
+
registered?: boolean
|
|
46
|
+
gpus: GpuStat[]
|
|
47
|
+
memory?: SystemStat
|
|
48
|
+
cpu?: SystemStat
|
|
49
|
+
/** Tokens per second, while generating. */
|
|
50
|
+
throughput?: number
|
|
51
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execFile } from "child_process"
|
|
2
|
+
import type { GpuStat } from "../../shared/types.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GPU stats via nvidia-smi. There is no push interface, so this is polled —
|
|
6
|
+
* but only while the panel is visible.
|
|
7
|
+
*
|
|
8
|
+
* Absent nvidia-smi is a normal state, not an error: plenty of machines run
|
|
9
|
+
* llama.cpp on CPU, Metal, or ROCm. We return nothing and the panel omits the
|
|
10
|
+
* GPU rows rather than showing a failure.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const QUERY = "index,memory.used,memory.total,utilization.gpu"
|
|
14
|
+
const TIMEOUT = 2_000
|
|
15
|
+
|
|
16
|
+
let available = true
|
|
17
|
+
|
|
18
|
+
function run(): Promise<string | undefined> {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
execFile(
|
|
21
|
+
"nvidia-smi",
|
|
22
|
+
[`--query-gpu=${QUERY}`, "--format=csv,noheader,nounits"],
|
|
23
|
+
{ timeout: TIMEOUT },
|
|
24
|
+
(error, stdout) => {
|
|
25
|
+
if (error) {
|
|
26
|
+
// ENOENT means no NVIDIA tooling at all; stop trying every tick
|
|
27
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") available = false
|
|
28
|
+
return resolve(undefined)
|
|
29
|
+
}
|
|
30
|
+
resolve(stdout)
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function gpus(): Promise<GpuStat[]> {
|
|
37
|
+
if (!available) return []
|
|
38
|
+
const output = await run()
|
|
39
|
+
if (!output) return []
|
|
40
|
+
const stats: GpuStat[] = []
|
|
41
|
+
for (const line of output.trim().split("\n")) {
|
|
42
|
+
const parts = line.split(",").map((value) => Number.parseInt(value.trim(), 10))
|
|
43
|
+
if (parts.length < 3 || parts.some((value, index) => index < 3 && !Number.isFinite(value))) continue
|
|
44
|
+
stats.push({
|
|
45
|
+
index: parts[0],
|
|
46
|
+
usedMiB: parts[1],
|
|
47
|
+
totalMiB: parts[2],
|
|
48
|
+
utilization: Number.isFinite(parts[3]) ? parts[3] : undefined,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
return stats
|
|
52
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
import os from "os"
|
|
3
|
+
import type { SystemStat } from "../../shared/types.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* System RAM and CPU.
|
|
7
|
+
*
|
|
8
|
+
* RAM uses MemAvailable rather than os.freemem(): free memory excludes page
|
|
9
|
+
* cache, so on a box that has just mmap'd a 20GB model it reads as almost
|
|
10
|
+
* nothing and looks alarming. MemAvailable is what "how much can I still use"
|
|
11
|
+
* actually means.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MIB = 1024 * 1024
|
|
15
|
+
|
|
16
|
+
type CpuSample = { idle: number; total: number }
|
|
17
|
+
|
|
18
|
+
let previous: CpuSample | undefined
|
|
19
|
+
|
|
20
|
+
async function meminfo(): Promise<SystemStat | undefined> {
|
|
21
|
+
const text = await fs.readFile("/proc/meminfo", "utf8").catch(() => undefined)
|
|
22
|
+
if (!text) {
|
|
23
|
+
// non-Linux: free memory is the best available approximation
|
|
24
|
+
const total = os.totalmem() / MIB
|
|
25
|
+
const used = (os.totalmem() - os.freemem()) / MIB
|
|
26
|
+
return { usedMiB: Math.round(used), totalMiB: Math.round(total) }
|
|
27
|
+
}
|
|
28
|
+
const read = (key: string) => {
|
|
29
|
+
const match = text.match(new RegExp(`^${key}:\\s+(\\d+) kB`, "m"))
|
|
30
|
+
return match ? Number.parseInt(match[1], 10) / 1024 : undefined
|
|
31
|
+
}
|
|
32
|
+
const total = read("MemTotal")
|
|
33
|
+
const availableKiB = read("MemAvailable")
|
|
34
|
+
if (total === undefined || availableKiB === undefined) return undefined
|
|
35
|
+
return { usedMiB: Math.round(total - availableKiB), totalMiB: Math.round(total) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Utilisation between the previous call and now; undefined on the first. */
|
|
39
|
+
async function cpuUtilisation(): Promise<number | undefined> {
|
|
40
|
+
const text = await fs.readFile("/proc/stat", "utf8").catch(() => undefined)
|
|
41
|
+
let sample: CpuSample | undefined
|
|
42
|
+
if (text) {
|
|
43
|
+
const line = text.split("\n")[0]
|
|
44
|
+
const values = line.split(/\s+/).slice(1).map(Number).filter(Number.isFinite)
|
|
45
|
+
if (values.length >= 5) {
|
|
46
|
+
const total = values.reduce((sum, value) => sum + value, 0)
|
|
47
|
+
sample = { idle: values[3] + (values[4] ?? 0), total }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!sample) {
|
|
51
|
+
const load = os.loadavg()[0]
|
|
52
|
+
const cores = os.cpus().length || 1
|
|
53
|
+
return Math.min(100, Math.round((load / cores) * 100))
|
|
54
|
+
}
|
|
55
|
+
const last = previous
|
|
56
|
+
previous = sample
|
|
57
|
+
if (!last) return undefined
|
|
58
|
+
const totalDelta = sample.total - last.total
|
|
59
|
+
const idleDelta = sample.idle - last.idle
|
|
60
|
+
if (totalDelta <= 0) return undefined
|
|
61
|
+
return Math.max(0, Math.min(100, Math.round(((totalDelta - idleDelta) / totalDelta) * 100)))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function system(): Promise<SystemStat | undefined> {
|
|
65
|
+
const memory = await meminfo()
|
|
66
|
+
if (!memory) return undefined
|
|
67
|
+
return { ...memory, utilization: await cpuUtilisation() }
|
|
68
|
+
}
|