pi-llamacpp-infra 1.0.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/src/index.ts ADDED
@@ -0,0 +1,2428 @@
1
+ /**
2
+ * extension: llamacpp-infra
3
+ * =========================
4
+ * Discovery, metrics and control of models served by llama.cpp and its
5
+ * variants (ZINC, DwarfStar/ds4, lucebox, OpenAI-compatible forks) on any
6
+ * number of machines — localhost, LAN or Tailscale.
7
+ *
8
+ * SCOPE
9
+ * -----
10
+ * This extension is exclusively for models served by llama.cpp-family
11
+ * servers. Every machine and port it talks to runs llama.cpp or a direct
12
+ * variant:
13
+ *
14
+ * llama.cpp — llama-server (single-model or router/multi-model mode)
15
+ * ZINC — llama.cpp-compatible runtime (owned_by: "zinc")
16
+ * DwarfStar — antirez's ds4-server for DeepSeek V4 (chat ping probe)
17
+ * lucebox — DeepSeek dflash server (rich /props metadata)
18
+ *
19
+ * Anything else (LM Studio, vLLM, Ollama, cloud APIs…) is out of scope.
20
+ *
21
+ * FEATURES
22
+ * --------
23
+ * 1. Discovery: probes every configured server:port via GET /v1/models and
24
+ * registers everything it finds into pi's native /model list.
25
+ * 2. Single- and multi-model (router) llama.cpp modes:
26
+ * - single-model: one GGUF per instance, enriched via GET /props
27
+ * (model_path → quant, modalities.vision, total_slots, draft).
28
+ * - router mode: /v1/models lists several models with per-model `path`,
29
+ * `status` (loaded/unloaded + the exact llama-server args) and
30
+ * `architecture.input_modalities`. Unloaded models can optionally be
31
+ * listed (they are excluded by default).
32
+ * 3. Per-model metadata, shown in /llamacpp-infra list|status and as badges:
33
+ * 👁️ vision (mmproj / modalities.vision / input_modalities)
34
+ * 🚀 drafter (speculative decoding draft model)
35
+ * 🗜️ model quant (from the GGUF filename, e.g. UD-Q3_K_XL)
36
+ * 🧠 KV cache quantization (--cache-type-k/-v, from server args or
37
+ * /proc for local servers)
38
+ * 4. Live metrics (integrated model-metrics): polls the active endpoint's
39
+ * Prometheus /metrics (or JSON /stats) and renders a compact widget with
40
+ * instantaneous prompt/gen throughput. Auto-activates for llamacpp-infra
41
+ * models only.
42
+ * 5. Per-model thinking budgets: llama.cpp accepts `thinking_budget_tokens`
43
+ * per request. Configure budgets per thinking level (minimal/low/medium/
44
+ * high) per model in the config menu; models with budgets are registered
45
+ * with reasoning enabled and pi sends the configured budget automatically.
46
+ * 6. Native configuration UI via /llamacpp-infra config (all in-pi dialogs).
47
+ * 7. Header warmup (pre-cache system prompt KV on llama.cpp-family servers).
48
+ * 8. ZINC workaround: ZINC rejects non-empty model ids; the payload hook
49
+ * rewrites the request accordingly and normalizes tool definitions.
50
+ *
51
+ * HISTORY
52
+ * -------
53
+ * Supersedes the earlier local-models extension.
54
+ *
55
+ * COMMANDS
56
+ * --------
57
+ * /llamacpp-infra quick status
58
+ * /llamacpp-infra config ⚙️ interactive configuration menu
59
+ * /llamacpp-infra scan rescan all servers now
60
+ * /llamacpp-infra status detailed per-endpoint report
61
+ * /llamacpp-infra list list discovered models with metadata
62
+ * /llamacpp-infra metrics toggle the live metrics widget
63
+ * /llamacpp-infra help command help
64
+ *
65
+ * Config: ~/.pi/agent/llamacpp-infra.json (auto-migrated from
66
+ * local-models.json on first run).
67
+ */
68
+
69
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
70
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
71
+ import { PromptWarmer, WarmupStatus } from "./prompt-warmup";
72
+ import * as http from "node:http";
73
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
74
+ import { homedir } from "node:os";
75
+ import { join } from "node:path";
76
+
77
+ // =============================================================================
78
+ // Types
79
+ // =============================================================================
80
+
81
+ /** One machine that may serve llama.cpp-family models on one or more ports. */
82
+ interface ServerConfig {
83
+ /** Unique short id (used in logs and endpoint bookkeeping). */
84
+ id: string;
85
+ /** Hostname, tailnet name or IP (e.g. "127.0.0.1", "myserver", "192.168.1.10"). */
86
+ host: string;
87
+ /** Optional friendly label shown in menus (defaults to host). */
88
+ label?: string;
89
+ /** Ports to probe on this machine. */
90
+ ports: number[];
91
+ /** Enabled servers are probed at startup. */
92
+ enabled: boolean;
93
+ /** Opt-in: when /v1/models fails, ping /v1/chat/completions (DwarfStar/ds4). */
94
+ probeDs4?: boolean;
95
+ /** Optional bearer token sent on discovery + per-model Authorization header. */
96
+ apiKey?: string;
97
+ }
98
+
99
+ /** Thinking budget (tokens) per pi thinking level, llama.cpp-style. */
100
+ interface ThinkingBudgets {
101
+ minimal?: number;
102
+ low?: number;
103
+ medium?: number;
104
+ high?: number;
105
+ }
106
+
107
+ /** Per-model options keyed by the registered model id. */
108
+ interface ModelOptions {
109
+ /** llama.cpp `thinking_budget_tokens` per thinking level. */
110
+ thinkingBudgets?: ThinkingBudgets;
111
+ }
112
+
113
+ interface SettingsConfig {
114
+ /** Per-request timeout during discovery (ms). */
115
+ discoveryTimeoutMs: number;
116
+ /** Background re-poll interval while servers are loading (ms). */
117
+ pollIntervalMs: number;
118
+ /** Total background polling budget (ms). */
119
+ pollMaxMs: number;
120
+ /** Keep polling at startup while nothing has ever answered (ms). */
121
+ startupGraceMs: number;
122
+ /** Consecutive failures before a known-good endpoint is dropped from polling. */
123
+ knownGoodFailLimit: number;
124
+ /** Scan /proc for local llama-server flags (mmproj, cache quants, draft). */
125
+ detectVision: boolean;
126
+ /** Prefix model ids with "host:port/" to avoid cross-server collisions. */
127
+ prefixModelIds: boolean;
128
+ /** Pre-cache the system prompt header on llama.cpp-family servers. */
129
+ warmup: boolean;
130
+ /** Show the live metrics widget for llamacpp-infra models. */
131
+ metricsEnabled: boolean;
132
+ /** Metrics polling interval (ms). */
133
+ metricsPollMs: number;
134
+ /** Router mode: also register models that are not currently loaded. */
135
+ includeUnloadedRouterModels: boolean;
136
+ /** Append metadata badges (👁️🚀💤) to model display names. */
137
+ showBadgesInNames: boolean;
138
+ }
139
+
140
+ interface InfraConfig {
141
+ servers: ServerConfig[];
142
+ settings: SettingsConfig;
143
+ /** Per-model options keyed by registered model id. */
144
+ modelOptions: Record<string, ModelOptions>;
145
+ }
146
+
147
+ interface LlamaCppMeta {
148
+ n_ctx?: number;
149
+ n_ctx_train?: number;
150
+ }
151
+
152
+ interface LlamaCppModel {
153
+ id: string;
154
+ name?: string;
155
+ object?: string;
156
+ owned_by?: string;
157
+ display_name?: string;
158
+ /** Router mode: path to the GGUF file. */
159
+ path?: string;
160
+ /** Router mode: load status of this model. */
161
+ status?: {
162
+ value?: string;
163
+ args?: string[];
164
+ preset?: string;
165
+ failed?: boolean;
166
+ exit_code?: number;
167
+ };
168
+ /** Router mode: modalities reported per model. */
169
+ architecture?: {
170
+ input_modalities?: string[];
171
+ output_modalities?: string[];
172
+ };
173
+ meta?: LlamaCppMeta;
174
+ context_window?: number;
175
+ context_length?: number;
176
+ max_tokens?: number;
177
+ }
178
+
179
+ interface LlamaCppModelsResponse {
180
+ models?: Array<{ name?: string; model?: string }>;
181
+ data: LlamaCppModel[];
182
+ object?: string;
183
+ }
184
+
185
+ /**
186
+ * Tolerant view of GET /props across llama.cpp builds and forks.
187
+ * Standard llama.cpp exposes model_path, modalities, default_generation_settings,
188
+ * total_slots, build_info, is_sleeping; lucebox adds model_alias, server.name
189
+ * and model.draft_path.
190
+ */
191
+ interface ServerProps {
192
+ model_path?: string | null;
193
+ model_alias?: string | null;
194
+ build_info?: string;
195
+ server?: { name?: string; version?: string };
196
+ role?: string;
197
+ max_instances?: number;
198
+ total_slots?: number;
199
+ is_sleeping?: boolean;
200
+ modalities?: { vision?: boolean; audio?: boolean };
201
+ default_generation_settings?: {
202
+ n_ctx?: number;
203
+ speculative?: boolean;
204
+ params?: Record<string, unknown>;
205
+ };
206
+ capabilities?: { reasoning_supported?: boolean; tools_supported?: boolean };
207
+ model?: { arch?: string | null; draft_path?: string | null };
208
+ draft_path?: string | null;
209
+ cache_type_k?: string;
210
+ cache_type_v?: string;
211
+ }
212
+
213
+ type ServerKind = "llamacpp" | "zinc" | "lucebox" | "dwarfstar";
214
+ type ServerMode = "single" | "router" | "unknown";
215
+
216
+ /** Per-model metadata extracted during discovery. */
217
+ interface ModelMetadata {
218
+ /** Model quant tag from the GGUF filename (e.g. "UD-Q3_K_XL"). */
219
+ quant?: string;
220
+ /** Vision input (mmproj / modalities / input_modalities). */
221
+ vision?: boolean;
222
+ /** Draft (drafter) model used for speculative decoding. */
223
+ drafter?: string;
224
+ /** KV cache K quantization (e.g. "q8_0"). */
225
+ cacheK?: string;
226
+ /** KV cache V quantization (e.g. "q8_0"). */
227
+ cacheV?: string;
228
+ /** Router mode load status (loaded / unloaded / loading / sleeping / failed). */
229
+ routerStatus?: string;
230
+ }
231
+
232
+ /** Result of probing one host:port endpoint. */
233
+ interface EndpointResult {
234
+ serverId: string;
235
+ host: string;
236
+ label: string;
237
+ port: number;
238
+ baseUrl: string;
239
+ server: ServerKind | "auto" | "unknown";
240
+ /** llama.cpp serving mode: single model or router (multi-model). */
241
+ mode: ServerMode;
242
+ models: LlamaCppModel[];
243
+ /** Per-model metadata, keyed by the raw model id. */
244
+ meta: Map<string, ModelMetadata>;
245
+ ok: boolean;
246
+ error?: string;
247
+ /** true if the endpoint responded 503 (model still loading). */
248
+ loading?: boolean;
249
+ latencyMs?: number;
250
+ nameMap?: Map<string, string>;
251
+ /** /props payload when available (single-model mode, lucebox). */
252
+ props?: ServerProps;
253
+ }
254
+
255
+ interface ScanResult {
256
+ endpoints: EndpointResult[];
257
+ totalModels: number;
258
+ serversUp: number;
259
+ serversTotal: number;
260
+ }
261
+
262
+ // =============================================================================
263
+ // Constants & defaults
264
+ // =============================================================================
265
+
266
+ const PROVIDER_NAME = "llamacpp-infra";
267
+ const STATUS_KEY = "llamacpp-infra";
268
+ const CONFIG_FILE = "llamacpp-infra.json";
269
+ const LEGACY_CONFIG_FILE = "local-models.json";
270
+ const METRICS_WIDGET_ID = "llamacpp-infra-metrics";
271
+ const DEFAULT_API_KEY = "no-auth";
272
+
273
+ /** Field llama.cpp accepts per request to cap thinking tokens. */
274
+ const THINKING_BUDGET_FIELD = "thinking_budget_tokens";
275
+
276
+ const DEFAULT_SETTINGS: SettingsConfig = {
277
+ discoveryTimeoutMs: 2000,
278
+ pollIntervalMs: 4000,
279
+ pollMaxMs: 90_000,
280
+ startupGraceMs: 40_000,
281
+ knownGoodFailLimit: 3,
282
+ detectVision: true,
283
+ prefixModelIds: true,
284
+ warmup: true,
285
+ metricsEnabled: true,
286
+ metricsPollMs: 5000,
287
+ includeUnloadedRouterModels: false,
288
+ showBadgesInNames: true,
289
+ };
290
+
291
+ /** First-run seed: a sensible localhost default. Users add their own
292
+ * machines (LAN/Tailscale hosts) through the /llamacpp-infra config UI. */
293
+ const DEFAULT_SERVERS: ServerConfig[] = [
294
+ {
295
+ id: "local",
296
+ host: "127.0.0.1",
297
+ label: "Local",
298
+ ports: [8000, 8001, 8002, 8080, 8081, 8082],
299
+ enabled: true,
300
+ probeDs4: false,
301
+ },
302
+ ];
303
+
304
+ const DEBUG = process.env.PI_LLAMACPP_INFRA_DEBUG === "1" || process.env.PI_LLAMACPP_INFRA_DEBUG === "true";
305
+ function debugLog(...args: unknown[]) {
306
+ if (DEBUG) console.debug(`[llamacpp-infra]`, ...args);
307
+ }
308
+
309
+ // =============================================================================
310
+ // Config persistence (with legacy local-models.json migration)
311
+ // =============================================================================
312
+
313
+ function getConfigPath(): string {
314
+ return join(getAgentDir(), CONFIG_FILE);
315
+ }
316
+
317
+ function loadConfig(): InfraConfig {
318
+ const defaults: InfraConfig = {
319
+ servers: DEFAULT_SERVERS,
320
+ settings: { ...DEFAULT_SETTINGS },
321
+ modelOptions: {},
322
+ };
323
+ const path = getConfigPath();
324
+ if (existsSync(path)) {
325
+ try {
326
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as Partial<InfraConfig>;
327
+ return {
328
+ servers: Array.isArray(raw.servers) ? raw.servers : defaults.servers,
329
+ settings: { ...DEFAULT_SETTINGS, ...(raw.settings ?? {}) },
330
+ modelOptions: raw.modelOptions ?? {},
331
+ };
332
+ } catch (err) {
333
+ console.error(`[llamacpp-infra] Config load error: ${err}`);
334
+ }
335
+ return defaults;
336
+ }
337
+ // Migrate legacy local-models.json (preserves servers edited via the old menu).
338
+ const legacyPath = join(getAgentDir(), LEGACY_CONFIG_FILE);
339
+ if (existsSync(legacyPath)) {
340
+ try {
341
+ const raw = JSON.parse(readFileSync(legacyPath, "utf-8")) as Partial<InfraConfig>;
342
+ const migrated: InfraConfig = {
343
+ servers: Array.isArray(raw.servers) ? raw.servers : defaults.servers,
344
+ settings: { ...DEFAULT_SETTINGS, ...(raw.settings ?? {}) },
345
+ modelOptions: raw.modelOptions ?? {},
346
+ };
347
+ debugLog(`migrated legacy config from ${legacyPath}`);
348
+ return migrated; // saved on next saveConfig()
349
+ } catch {
350
+ // fall through to defaults
351
+ }
352
+ }
353
+ return defaults;
354
+ }
355
+
356
+ function saveConfig(config: InfraConfig): void {
357
+ try {
358
+ const dir = getAgentDir();
359
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
360
+ writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), "utf-8");
361
+ debugLog(`config saved to ${getConfigPath()}`);
362
+ } catch (err) {
363
+ console.error(`[llamacpp-infra] Config save error: ${err}`);
364
+ }
365
+ }
366
+
367
+ // =============================================================================
368
+ // Small utilities
369
+ // =============================================================================
370
+
371
+ /** Select helper: maps labeled strings to ctx.ui.select() values. */
372
+ async function selectFrom<T>(
373
+ ctx: ExtensionContext,
374
+ title: string,
375
+ items: Array<{ value: T; label: string; description?: string }>,
376
+ ): Promise<T | undefined> {
377
+ const strings = items.map((it) => (it.description ? `${it.label} — ${it.description}` : it.label));
378
+ const picked = await ctx.ui.select(title, strings);
379
+ if (picked === undefined) return undefined;
380
+ const idx = strings.indexOf(picked);
381
+ return idx >= 0 ? items[idx].value : undefined;
382
+ }
383
+
384
+ /**
385
+ * Parse a ports string like "8000, 8080-8082, 9000" into a sorted unique list.
386
+ * Returns undefined if nothing valid was entered.
387
+ */
388
+ function parsePorts(input: string): number[] | undefined {
389
+ const ports = new Set<number>();
390
+ for (const part of input.split(/[,\s]+/)) {
391
+ const trimmed = part.trim();
392
+ if (!trimmed) continue;
393
+ const range = trimmed.match(/^(\d+)\s*-\s*(\d+)$/);
394
+ if (range) {
395
+ const lo = parseInt(range[1], 10);
396
+ const hi = parseInt(range[2], 10);
397
+ if (isNaN(lo) || isNaN(hi) || lo > hi || hi > 65535 || lo <= 0) continue;
398
+ for (let p = lo; p <= hi && p - lo < 256; p++) ports.add(p);
399
+ continue;
400
+ }
401
+ const n = parseInt(trimmed, 10);
402
+ if (!isNaN(n) && n > 0 && n < 65536) ports.add(n);
403
+ }
404
+ if (ports.size === 0) return undefined;
405
+ return [...ports].sort((a, b) => a - b);
406
+ }
407
+
408
+ function formatMs(ms: number): string {
409
+ if (ms < 1000) return `${ms}ms`;
410
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
411
+ return `${Math.round(ms / 60_000)}m`;
412
+ }
413
+
414
+ function formatCtx(tokens: number | undefined): string {
415
+ if (!tokens || tokens <= 0) return "";
416
+ return tokens >= 1024 ? `${Math.round(tokens / 1024)}k` : `${tokens}`;
417
+ }
418
+
419
+ function serverLabel(srv: ServerConfig): string {
420
+ return srv.label?.trim() || srv.host;
421
+ }
422
+
423
+ /** Model id fragment for a server: hosts like "127.0.0.1" are kept as-is. */
424
+ function idSafeHost(host: string): string {
425
+ return host.trim().toLowerCase() || "host";
426
+ }
427
+
428
+ function isLocalHost(host: string): boolean {
429
+ const h = host.trim().toLowerCase();
430
+ return h === "127.0.0.1" || h === "localhost" || h === "::1";
431
+ }
432
+
433
+ /** Basename of a path-ish string. */
434
+ function baseName(p: string): string {
435
+ const cleaned = p.replace(/\\/g, "/");
436
+ const last = cleaned.split("/").pop() || cleaned;
437
+ return last;
438
+ }
439
+
440
+ // =============================================================================
441
+ // GGUF quant tag extraction
442
+ // =============================================================================
443
+
444
+ const QUANT_TAG_RE =
445
+ /[-._\s]?((?:UD-|KL1-)?(?:I?Q[1-8][._][0-9A-Z_]+|IQ[1-4]_[0-9A-Z]+|TQ[1-4]_[0-9]|F16|F32|BF16|FP16|FP32|MXFP4[0-9A-Z_]*))$/i;
446
+
447
+ /**
448
+ * Extract the quantization tag from a GGUF filename or router model id.
449
+ * The quant tag is, by convention, the trailing token of the name:
450
+ * ".../Qwen3.6-27B-UD-Q3_K_XL.gguf" → "UD-Q3_K_XL"
451
+ * ".../Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf" → "Q4_K_M"
452
+ * "ggml-org/gemma-3-4b-it-GGUF:Q4_K_M" → "Q4_K_M" (router id suffix)
453
+ * "DeepSeek-V4-Flash-ROCMFP2-STRIX.gguf" → undefined (no quant in name)
454
+ */
455
+ function extractQuantTag(filenameOrId: string): string | undefined {
456
+ // Router ids use "repo/model:QUANT"
457
+ const colon = filenameOrId.lastIndexOf(":");
458
+ if (colon > 0) {
459
+ const suffix = filenameOrId.slice(colon + 1);
460
+ if (/^(I?Q[1-8]_|TQ[1-4]_|F16|F32|BF16|FP16|MXFP4)/i.test(suffix)) return suffix.toUpperCase();
461
+ }
462
+ const base = baseName(filenameOrId).replace(/\.(gguf|ggml)$/i, "");
463
+ const match = base.match(QUANT_TAG_RE);
464
+ return match?.[1]?.toUpperCase();
465
+ }
466
+
467
+ // =============================================================================
468
+ // llama-server flag parsing (server args from router status / /proc cmdline)
469
+ // =============================================================================
470
+
471
+ interface ParsedServerArgs {
472
+ cacheK?: string;
473
+ cacheV?: string;
474
+ draft?: string;
475
+ hasDraft?: boolean;
476
+ hasMmproj?: boolean;
477
+ mmprojPath?: string;
478
+ }
479
+
480
+ /** Values that mean "flag present without value" for value-optional flags. */
481
+ function parseServerArgs(tokens: string[]): ParsedServerArgs {
482
+ const out: ParsedServerArgs = {};
483
+ const take = (i: number): string | undefined => {
484
+ const v = tokens[i + 1];
485
+ return v && !v.startsWith("-") ? v : undefined;
486
+ };
487
+ for (let i = 0; i < tokens.length; i++) {
488
+ let tok = tokens[i];
489
+ if (!tok.startsWith("-")) continue;
490
+ let inline: string | undefined;
491
+ const eq = tok.indexOf("=");
492
+ if (eq > 0) {
493
+ inline = tok.slice(eq + 1);
494
+ tok = tok.slice(0, eq);
495
+ }
496
+ const val = inline ?? take(i);
497
+ const t = tok.toLowerCase();
498
+ if (t === "--cache-type-k" || t === "-ctk") out.cacheK = (val ?? "").toLowerCase() || undefined;
499
+ else if (t === "--cache-type-v" || t === "-ctv") out.cacheV = (val ?? "").toLowerCase() || undefined;
500
+ else if (t === "--model-draft" || t === "-md" || t === "--hf-repo-draft" || t === "-hfrd") {
501
+ out.hasDraft = true;
502
+ if (val) out.draft = baseName(val);
503
+ } else if (t === "--mmproj") {
504
+ out.hasMmproj = true;
505
+ if (val) out.mmprojPath = val;
506
+ }
507
+ }
508
+ return out;
509
+ }
510
+
511
+ // =============================================================================
512
+ // Local llama-server process scan (/proc) — loopback servers only
513
+ // =============================================================================
514
+
515
+ interface LocalServerInfo extends ParsedServerArgs {
516
+ port: number;
517
+ }
518
+
519
+ /**
520
+ * Scan /proc for local llama-server processes and extract the flags that are
521
+ * not exposed over HTTP: --mmproj (vision), --cache-type-k/-v (KV quant),
522
+ * --model-draft (drafter). Keyed by listen port. Only used for loopback
523
+ * endpoints; remote machines are covered by router status.args.
524
+ */
525
+ function scanLocalServers(): Map<number, LocalServerInfo> {
526
+ const found = new Map<number, LocalServerInfo>();
527
+ let pids: string[] = [];
528
+ try {
529
+ pids = readdirSync("/proc");
530
+ } catch {
531
+ return found; // not Linux
532
+ }
533
+ for (const pid of pids) {
534
+ if (!/^\d+$/.test(pid)) continue;
535
+ try {
536
+ const args = readFileSync(`/proc/${pid}/cmdline`, "utf-8").split("\0").filter(Boolean);
537
+ const bin = (args[0] ?? "").split("/").pop() ?? "";
538
+ if (!bin.startsWith("llama-server")) continue;
539
+ const i = args.indexOf("--port");
540
+ const port = i >= 0 ? parseInt(args[i + 1], 10) : NaN;
541
+ if (isNaN(port)) continue;
542
+ const parsed = parseServerArgs(args);
543
+ found.set(port, { port, ...parsed });
544
+ } catch {
545
+ // process vanished between readdir and read — ignore
546
+ }
547
+ }
548
+ return found;
549
+ }
550
+
551
+ // =============================================================================
552
+ // HTTP client (node:http — no fetch)
553
+ // =============================================================================
554
+ // fetch()/undici intermittently fails through podman's pasta proxy and adds
555
+ // headers ZINC chokes on. Raw http.request with forced IPv4 (Tailscale names
556
+ // resolve IPv6-first with unroutable link-local addresses) and fresh sockets.
557
+
558
+ interface HttpResult {
559
+ status: number;
560
+ body: string;
561
+ contentType?: string;
562
+ }
563
+
564
+ function httpRequest(
565
+ method: string,
566
+ url: string,
567
+ body?: string,
568
+ timeoutMs = 2000,
569
+ apiKey?: string,
570
+ ): Promise<HttpResult> {
571
+ return new Promise((resolve, reject) => {
572
+ const parsed = new URL(url);
573
+ const headers: http.OutgoingHttpHeaders = {
574
+ Accept: "application/json, text/plain",
575
+ "User-Agent": "pi-llamacpp-infra/1.0",
576
+ };
577
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
578
+ if (body) {
579
+ headers["Content-Type"] = "application/json";
580
+ headers["Content-Length"] = Buffer.byteLength(body).toString();
581
+ }
582
+ const options: http.RequestOptions = {
583
+ hostname: parsed.hostname,
584
+ port: parsed.port || 80,
585
+ path: parsed.pathname + parsed.search,
586
+ method,
587
+ headers,
588
+ timeout: timeoutMs,
589
+ // Force IPv4: avoids slow happy-eyeballs on IPv6-first DNS and
590
+ // matches podman pasta IPv4-only port forwarding.
591
+ family: 4,
592
+ agent: new http.Agent({ keepAlive: false, maxSockets: 1 }),
593
+ };
594
+
595
+ const req = http.request(options, (res) => {
596
+ const chunks: Buffer[] = [];
597
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
598
+ res.on("end", () =>
599
+ resolve({
600
+ status: res.statusCode ?? 0,
601
+ body: Buffer.concat(chunks).toString("utf-8"),
602
+ contentType: res.headers["content-type"],
603
+ }),
604
+ );
605
+ });
606
+
607
+ req.on("error", (err: Error) => reject(err));
608
+ req.on("timeout", () => {
609
+ req.destroy();
610
+ reject(new Error(`Timeout after ${timeoutMs}ms`));
611
+ });
612
+
613
+ if (body) req.write(body);
614
+ req.end();
615
+ });
616
+ }
617
+
618
+ /** GET with a single retry for transient errors (ECONNREFUSED is final). */
619
+ async function httpGet(url: string, timeoutMs: number, apiKey?: string): Promise<HttpResult> {
620
+ try {
621
+ return await httpRequest("GET", url, undefined, timeoutMs, apiKey);
622
+ } catch (err) {
623
+ const code = (err as NodeJS.ErrnoException).code;
624
+ if (code === "ECONNREFUSED" || code === "ENOTFOUND") throw err;
625
+ await new Promise((r) => setTimeout(r, 250));
626
+ return await httpRequest("GET", url, undefined, timeoutMs, apiKey);
627
+ }
628
+ }
629
+
630
+ function isNetworkError(msg: string): boolean {
631
+ return /timeout|socket hang up|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ECONNRESET|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH/i.test(msg);
632
+ }
633
+
634
+ // =============================================================================
635
+ // Model display names
636
+ // =============================================================================
637
+
638
+ /**
639
+ * Turn a raw model id / GGUF path into a readable display name.
640
+ * .../Qwen3.6-35B-A3B-Uncensored-HauhauCS-Q6_K_P.gguf → "Qwen3.6-35B-A3B"
641
+ * .../Ornith-1.0-35B-IQ3_M.gguf → "Ornith-1.0-35B-IQ3_M"
642
+ * .../DeepSeek-V4-Flash-ROCMFP2-STRIX.gguf → "DeepSeek-V4-Flash-ROCMFP2-STRIX"
643
+ */
644
+ function cleanModelName(rawId: string): string {
645
+ let name = rawId.split("/").pop() || rawId;
646
+ name = name.replace(/\.(gguf|ggml)(?:\.tar)?$/i, "");
647
+ name = name.replace(/[-_.][Ff](?:16|32)$/i, "");
648
+ name = name.replace(/[-_.][Qq]2_[Kk]$/i, "");
649
+ name = name.replace(/[-_.][Qq]3_[Kk]_[SLM]$/i, "");
650
+ name = name.replace(/[-_.][Qq]4_[01]|[-_.][Qq]4_[Kk]_[MS]$/i, "");
651
+ name = name.replace(/[-_.][Qq]5_[0K]_[MS]$/i, "");
652
+ name = name.replace(/[-_.][Qq]6_[Kk](_[A-Z])?$/i, "");
653
+ name = name.replace(/[-_.][Qq]8_[0O]$/i, "");
654
+ name = name.replace(/-(?:Uncensored|Alpaca|Instruct|Chat|Turbo|Base|MoE|UD)(?:-[a-zA-Z0-9]+)*$/i, "");
655
+ name = name.replace(/-(?:v[0-9.]+)(?:-[a-zA-Z0-9]+)*$/, "");
656
+ name = name.replace(/-+$/, "").trim();
657
+ return name || rawId;
658
+ }
659
+
660
+ // =============================================================================
661
+ // Server kind detection & /props
662
+ // =============================================================================
663
+
664
+ async function fetchServerProps(
665
+ baseUrl: string,
666
+ timeoutMs: number,
667
+ apiKey?: string,
668
+ model?: string,
669
+ ): Promise<ServerProps | undefined> {
670
+ const rootUrl = baseUrl.replace(/\/v1\/?$/, "");
671
+ let url = `${rootUrl}/props`;
672
+ // Router mode: per-model props. autoload=false guarantees we never trigger
673
+ // an expensive model load just by probing.
674
+ if (model !== undefined) url += `?model=${encodeURIComponent(model)}&autoload=false`;
675
+ try {
676
+ const { status, body } = await httpGet(url, timeoutMs, apiKey);
677
+ if (status >= 200 && status < 300) return JSON.parse(body) as ServerProps;
678
+ } catch {
679
+ // no /props → plain OpenAI-compatible server
680
+ }
681
+ return undefined;
682
+ }
683
+
684
+ /**
685
+ * Detect the server kind for an endpoint:
686
+ * 1. lucebox: /props with server.name "luce-*"
687
+ * 2. DwarfStar: /props build_info "dwarf*"/"ds4*" (else via chat probe)
688
+ * 3. ZINC: /v1/models with owned_by === "zinc"
689
+ * 4. llama.cpp: anything else serving models
690
+ */
691
+ async function detectServerKind(
692
+ baseUrl: string,
693
+ models: LlamaCppModel[],
694
+ props: ServerProps | undefined,
695
+ ): Promise<ServerKind | "unknown"> {
696
+ const serverName = String(props?.server?.name ?? props?.build_info ?? "").toLowerCase();
697
+ if (serverName.startsWith("luce")) return "lucebox";
698
+ if (serverName.startsWith("dwarf") || serverName.startsWith("ds4")) return "dwarfstar";
699
+ if (models.length > 0 && models[0].owned_by === "zinc") return "zinc";
700
+ if (models.length > 0) return "llamacpp";
701
+ return "unknown";
702
+ }
703
+
704
+ /**
705
+ * Probe a DwarfStar/ds4 server by pinging /v1/chat/completions (it does not
706
+ * implement /v1/models). Two attempts: without a model field, then "ping".
707
+ */
708
+ async function probeDs4Server(
709
+ ep: EndpointResult,
710
+ timeoutMs: number,
711
+ apiKey?: string,
712
+ ): Promise<EndpointResult> {
713
+ const chatUrl = `${ep.baseUrl}/chat/completions`;
714
+ const tryProbe = async (model?: string): Promise<string | null> => {
715
+ const reqBody: Record<string, unknown> = {
716
+ messages: [{ role: "user", content: "ping" }],
717
+ max_tokens: 1,
718
+ };
719
+ if (model) reqBody.model = model;
720
+ try {
721
+ const res = await httpRequest("POST", chatUrl, JSON.stringify(reqBody), timeoutMs, apiKey);
722
+ if (res.status < 200 || res.status >= 300) return null;
723
+ const p = JSON.parse(res.body) as { model?: string };
724
+ return p.model ?? model ?? "ds4-model";
725
+ } catch {
726
+ return null;
727
+ }
728
+ };
729
+
730
+ try {
731
+ let modelName = await tryProbe();
732
+ if (!modelName) modelName = await tryProbe("ping");
733
+ if (modelName) {
734
+ ep.ok = true;
735
+ ep.error = undefined;
736
+ ep.loading = false;
737
+ ep.server = "dwarfstar";
738
+ ep.mode = "single";
739
+ ep.models = [{ id: modelName, name: modelName }];
740
+ ep.meta.set(modelName, { quant: extractQuantTag(modelName) });
741
+ return ep;
742
+ }
743
+ ep.error = "ds4 probe: no response from /v1/chat/completions";
744
+ return ep;
745
+ } catch (error) {
746
+ ep.error = `ds4 probe: ${error instanceof Error ? error.message : String(error)}`;
747
+ return ep;
748
+ }
749
+ }
750
+
751
+ // =============================================================================
752
+ // Endpoint scanning
753
+ // =============================================================================
754
+
755
+ /**
756
+ * Build per-model metadata for a single /v1/models entry, combining:
757
+ * - router entry fields (path, status.args, architecture.input_modalities)
758
+ * - single-model /props (model_path, modalities.vision, draft_path)
759
+ * - /proc scan of local llama-server processes (mmproj, cache quants, draft)
760
+ */
761
+ function buildModelMetadata(
762
+ rawId: string,
763
+ entry: LlamaCppModel,
764
+ props: ServerProps | undefined,
765
+ local: LocalServerInfo | undefined,
766
+ ): ModelMetadata {
767
+ const meta: ModelMetadata = {};
768
+
769
+ // ── Model quant (GGUF filename / router id) ──
770
+ const sourcePath = entry.path ?? props?.model_path ?? rawId;
771
+ meta.quant = extractQuantTag(sourcePath);
772
+
773
+ // ── Vision ──
774
+ if (entry.architecture?.input_modalities?.includes("image")) meta.vision = true;
775
+ if (meta.vision === undefined && props?.modalities?.vision === true) meta.vision = true;
776
+ if (!meta.vision && local?.hasMmproj) meta.vision = true;
777
+
778
+ // ── Drafter (speculative decoding) ──
779
+ const argsInfo = entry.status?.args ? parseServerArgs(entry.status.args) : undefined;
780
+ if (argsInfo?.draft) meta.drafter = argsInfo.draft;
781
+ else if (argsInfo?.hasDraft) meta.drafter = "draft model";
782
+ if (!meta.drafter) {
783
+ const draftPath = props?.model?.draft_path ?? props?.draft_path;
784
+ if (draftPath) meta.drafter = baseName(draftPath);
785
+ else if (props?.default_generation_settings?.speculative === true) meta.drafter = "speculative";
786
+ }
787
+ if (!meta.drafter && local?.draft) meta.drafter = local.draft;
788
+ else if (!meta.drafter && local?.hasDraft) meta.drafter = "draft model";
789
+
790
+ // ── KV cache quantization ──
791
+ meta.cacheK = argsInfo?.cacheK ?? local?.cacheK ?? props?.cache_type_k?.toLowerCase();
792
+ meta.cacheV = argsInfo?.cacheV ?? local?.cacheV ?? props?.cache_type_v?.toLowerCase();
793
+
794
+ // ── Router load status ──
795
+ if (entry.status?.value) meta.routerStatus = entry.status.value;
796
+
797
+ return meta;
798
+ }
799
+
800
+ async function fetchModelsFromEndpoint(
801
+ srv: ServerConfig,
802
+ port: number,
803
+ settings: SettingsConfig,
804
+ localServers: Map<number, LocalServerInfo>,
805
+ ): Promise<EndpointResult> {
806
+ const baseUrl = `http://${srv.host}:${port}/v1`;
807
+ const ep: EndpointResult = {
808
+ serverId: srv.id,
809
+ host: srv.host,
810
+ label: serverLabel(srv),
811
+ port,
812
+ baseUrl,
813
+ server: "auto",
814
+ mode: "unknown",
815
+ models: [],
816
+ meta: new Map(),
817
+ ok: false,
818
+ };
819
+ const started = Date.now();
820
+ try {
821
+ const { status, body } = await httpGet(`${baseUrl}/models`, settings.discoveryTimeoutMs, srv.apiKey);
822
+ ep.latencyMs = Date.now() - started;
823
+ if (status < 200 || status >= 300) {
824
+ ep.error = `${status}: ${(body || "unknown error").slice(0, 200)}`;
825
+ ep.loading = status === 503; // server present, model still loading
826
+ if (srv.probeDs4 && !ep.loading) return probeDs4Server(ep, settings.discoveryTimeoutMs, srv.apiKey);
827
+ return ep;
828
+ }
829
+ const payload = JSON.parse(body) as LlamaCppModelsResponse;
830
+ const models = payload.data ?? [];
831
+
832
+ // llama.cpp provides a parallel models[] array with friendly names
833
+ const nameMap = new Map<string, string>();
834
+ if (Array.isArray(payload.models)) {
835
+ for (const m of payload.models) {
836
+ if (m.model && m.name) nameMap.set(m.model, m.name);
837
+ }
838
+ }
839
+
840
+ // ── Mode detection ──
841
+ // Router/multi-model entries carry path/status/architecture; the router's
842
+ // own /props answers with role: "router".
843
+ const isRouterShape = models.some((m) => m.path !== undefined || m.status !== undefined);
844
+ let props = await fetchServerProps(baseUrl, settings.discoveryTimeoutMs, srv.apiKey);
845
+ const routerProps = props?.role === "router";
846
+ const mode: ServerMode = isRouterShape || routerProps ? "router" : "single";
847
+ // In single-model mode props describes THE model; in router mode the root
848
+ // props is the router itself (useless for per-model metadata).
849
+ if (mode === "router") props = undefined;
850
+
851
+ const kind = await detectServerKind(baseUrl, models, props);
852
+ // lucebox always enriches via /props (its own schema, even without router).
853
+ if (kind === "lucebox") {
854
+ props = (await fetchServerProps(baseUrl, settings.discoveryTimeoutMs, srv.apiKey)) ?? props;
855
+ }
856
+
857
+ const local = isLocalHost(srv.host) && settings.detectVision ? localServers.get(port) : undefined;
858
+
859
+ // ── Per-model metadata ──
860
+ for (const model of models) {
861
+ const rawId = String(model.id ?? "");
862
+ let modelProps = props;
863
+ // Router mode: fetch per-model props ONLY when the entry lacks vision
864
+ // info and the model is loaded (autoload=false prevents loads).
865
+ if (mode === "router" && !model.architecture?.input_modalities && model.status?.value === "loaded") {
866
+ modelProps = (await fetchServerProps(baseUrl, settings.discoveryTimeoutMs, srv.apiKey, rawId)) ?? undefined;
867
+ }
868
+ ep.meta.set(rawId, buildModelMetadata(rawId, model, modelProps, local));
869
+ }
870
+
871
+ ep.server = kind;
872
+ ep.mode = mode;
873
+ ep.models = models;
874
+ ep.nameMap = nameMap;
875
+ ep.props = props;
876
+ ep.ok = true;
877
+ return ep;
878
+ } catch (err) {
879
+ ep.latencyMs = Date.now() - started;
880
+ const msg = err instanceof Error ? err.message : String(err);
881
+ ep.error = msg;
882
+ if (srv.probeDs4 && !isNetworkError(msg)) {
883
+ return probeDs4Server(ep, settings.discoveryTimeoutMs, srv.apiKey);
884
+ }
885
+ return ep;
886
+ }
887
+ }
888
+
889
+ // =============================================================================
890
+ // Model → pi registration
891
+ // =============================================================================
892
+
893
+ /** Compat profile per server kind. */
894
+ function makeCompat(kind: ServerKind | "unknown" | "auto") {
895
+ const usageInStreaming = kind !== "zinc";
896
+ return {
897
+ supportsDeveloperRole: false,
898
+ supportsReasoningEffort: false,
899
+ maxTokensField: "max_tokens" as const,
900
+ supportsUsageInStreaming: usageInStreaming,
901
+ supportsStrictMode: false,
902
+ // llama.cpp accepts a per-request `thinking_budget_tokens` cap.
903
+ // (Only honored by pi when the model is registered with reasoning: true.)
904
+ ...(kind === "llamacpp" || kind === "lucebox" ? { thinkingTokenBudgetField: THINKING_BUDGET_FIELD } : {}),
905
+ };
906
+ }
907
+
908
+ interface PiModel {
909
+ id: string;
910
+ name: string;
911
+ baseUrl: string;
912
+ reasoning: boolean;
913
+ input: ("text" | "image")[];
914
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
915
+ contextWindow: number;
916
+ maxTokens: number;
917
+ compat?: ReturnType<typeof makeCompat>;
918
+ headers?: Record<string, string>;
919
+ thinkingBudgets?: ThinkingBudgets;
920
+ /** Metadata badges are applied to the name at build time. */
921
+ endpoint: { serverId: string; host: string; port: number; kind: ServerKind | "unknown" | "auto"; mode: ServerMode };
922
+ quant?: string;
923
+ cacheK?: string;
924
+ cacheV?: string;
925
+ drafter?: string;
926
+ routerStatus?: string;
927
+ }
928
+
929
+ /** Compact badge string appended to display names when enabled. */
930
+ function badgeSuffix(meta: {
931
+ vision?: boolean;
932
+ drafter?: string;
933
+ routerStatus?: string;
934
+ }, showBadges: boolean): string {
935
+ if (!showBadges) return "";
936
+ let badges = "";
937
+ if (meta.vision) badges += "👁️";
938
+ if (meta.drafter) badges += "🚀";
939
+ if (meta.routerStatus && meta.routerStatus !== "loaded") badges += "💤";
940
+ return badges ? ` [${badges}]` : "";
941
+ }
942
+
943
+ function toPiModel(
944
+ model: LlamaCppModel,
945
+ ep: EndpointResult,
946
+ srv: ServerConfig,
947
+ settings: SettingsConfig,
948
+ modelMeta: ModelMetadata,
949
+ ): PiModel {
950
+ const rawId = String(model.id ?? "");
951
+ const hostPort = `${idSafeHost(srv.host)}:${ep.port}`;
952
+ const usePrefix = settings.prefixModelIds;
953
+ const prefixId = (id: string) => `${hostPort}/${id.replace(/^\/+/, "")}`;
954
+ const kind = ep.server;
955
+
956
+ const common = {
957
+ baseUrl: ep.baseUrl,
958
+ input: (modelMeta.vision ? ["text", "image"] : ["text"]) as ("text" | "image")[],
959
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
960
+ endpoint: { serverId: srv.id, host: srv.host, port: ep.port, kind, mode: ep.mode },
961
+ quant: modelMeta.quant,
962
+ cacheK: modelMeta.cacheK,
963
+ cacheV: modelMeta.cacheV,
964
+ drafter: modelMeta.drafter,
965
+ routerStatus: modelMeta.routerStatus,
966
+ ...(srv.apiKey ? { headers: { Authorization: `Bearer ${srv.apiKey}` } } : {}),
967
+ };
968
+
969
+ // ── lucebox: alias as source of truth, rich metadata from /props ──
970
+ if (kind === "lucebox") {
971
+ const modelPath = ep.props?.model_path || rawId;
972
+ const alias = ep.props?.model_alias || rawId;
973
+ const nCtx = ep.props?.default_generation_settings?.n_ctx;
974
+ const contextWindow = model.context_length ?? nCtx ?? 8192;
975
+ const budgets = config_ModelOptions()?.[usePrefix ? prefixId(alias) : alias]?.thinkingBudgets;
976
+ return {
977
+ ...common,
978
+ id: usePrefix ? prefixId(alias) : alias,
979
+ name: `${cleanModelName(modelPath)}${badgeSuffix(modelMeta, settings.showBadgesInNames)}`,
980
+ reasoning: ep.props?.capabilities?.reasoning_supported ?? true,
981
+ contextWindow,
982
+ maxTokens: model.max_tokens ?? Math.min(contextWindow, 8192),
983
+ compat: makeCompat(kind),
984
+ ...(budgets ? { thinkingBudgets: budgets } : {}),
985
+ };
986
+ }
987
+
988
+ // ── ZINC / llama.cpp / dwarfstar ──
989
+ const cleanName = cleanModelName(rawId);
990
+ // Router entries expose the GGUF path — prefer its basename for display.
991
+ const nameSource = model.path ?? rawId;
992
+ const cleanFromSource = cleanModelName(baseName(nameSource));
993
+ const baseName2 = model.name && String(model.name) !== rawId ? cleanModelName(String(model.name)) : cleanFromSource || cleanName;
994
+ const nameMapName = ep.nameMap?.get(rawId);
995
+ const displayName = nameMapName ? cleanModelName(nameMapName) : baseName2;
996
+
997
+ const contextWindow =
998
+ model.meta?.n_ctx ?? model.meta?.n_ctx_train ?? model.context_window ?? model.context_length ?? 32768;
999
+
1000
+ // Models with configured thinking budgets must be registered as reasoning
1001
+ // models so pi's thinking-level machinery (and budget field) engages.
1002
+ const modelId = usePrefix ? prefixId(rawId) : rawId;
1003
+ const budgets = config_ModelOptions()?.[modelId]?.thinkingBudgets;
1004
+ const isLlamaFamily = kind === "llamacpp";
1005
+
1006
+ return {
1007
+ ...common,
1008
+ id: modelId,
1009
+ name: `${displayName}${badgeSuffix(modelMeta, settings.showBadgesInNames)}`,
1010
+ reasoning: isLlamaFamily || Boolean(budgets),
1011
+ contextWindow,
1012
+ maxTokens: model.max_tokens ?? Math.min(contextWindow, 8192),
1013
+ compat: makeCompat(kind),
1014
+ ...(budgets ? { thinkingBudgets: budgets } : {}),
1015
+ };
1016
+ }
1017
+
1018
+ // --- config access used by the pure builders above ---------------------------
1019
+ let activeConfig: InfraConfig | undefined;
1020
+ function config_ModelOptions(): Record<string, ModelOptions> {
1021
+ return activeConfig?.modelOptions ?? {};
1022
+ }
1023
+
1024
+ // =============================================================================
1025
+ // Metrics module (integrated model-metrics — Prometheus/JSON over HTTP)
1026
+ // =============================================================================
1027
+
1028
+ const METRICS_CANDIDATE_PATHS = ["/metrics", "/v1/metrics", "/api/v1/metrics", "/stats"];
1029
+ const METRICS_FETCH_TIMEOUT_MS = 3000;
1030
+
1031
+ const KNOWN_METRICS: Record<string, { label: string; icon: string }> = {
1032
+ prompt_tokens_total: { label: "prompt tokens", icon: "📥" },
1033
+ prompt_seconds_total: { label: "prompt time (s)", icon: "" },
1034
+ prompt_tokens_seconds: { label: "prompt t/s", icon: "⚡" },
1035
+ tokens_predicted_total: { label: "gen tokens", icon: "📤" },
1036
+ tokens_predicted_seconds_total: { label: "gen time (s)", icon: "" },
1037
+ predicted_tokens_seconds: { label: "gen t/s", icon: "🔥" },
1038
+ tokens_predicted_seconds: { label: "gen t/s", icon: "🔥" },
1039
+ requests_processing: { label: "processing", icon: "▶" },
1040
+ requests_deferred: { label: "deferred", icon: "⏳" },
1041
+ n_decode_total: { label: "decodes", icon: "" },
1042
+ n_tokens_max: { label: "max ctx", icon: "📏" },
1043
+ n_busy_slots_per_decode: { label: "busy slots/decode", icon: "" },
1044
+ num_requests_total: { label: "total requests", icon: "" },
1045
+ request_success_total: { label: "ok reqs", icon: "✅" },
1046
+ request_failure_total: { label: "failed reqs", icon: "❌" },
1047
+ };
1048
+
1049
+ function isPrometheusFormat(text: string): boolean {
1050
+ const lines = text.split("\n").filter((l) => l.trim().length > 0);
1051
+ if (lines.length < 3) return false;
1052
+ let promCount = 0;
1053
+ for (const line of lines) {
1054
+ const t = line.trim();
1055
+ if (t.startsWith("#")) {
1056
+ promCount++;
1057
+ continue;
1058
+ }
1059
+ if (/^[a-zA-Z_:][a-zA-Z0-9_:]*\s*[{\[]?/.test(t)) promCount++;
1060
+ }
1061
+ return promCount >= Math.floor(lines.length * 0.5);
1062
+ }
1063
+
1064
+ function parsePrometheusMetrics(text: string): Map<string, number> {
1065
+ const metrics = new Map<string, number>();
1066
+ for (const line of text.split("\n")) {
1067
+ const trimmed = line.trim();
1068
+ if (!trimmed || trimmed.startsWith("#")) continue;
1069
+ const withLabels = trimmed.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)\s*\{[^}]*\}\s+([\d.eE+-]+)/);
1070
+ if (withLabels) {
1071
+ const value = parseFloat(withLabels[2]);
1072
+ if (!isNaN(value)) metrics.set(withLabels[1], (metrics.get(withLabels[1]) ?? 0) + value);
1073
+ continue;
1074
+ }
1075
+ const parts = trimmed.split(/\s+/);
1076
+ if (parts.length >= 2) {
1077
+ // strip llama.cpp's "llamacpp:" legacy prefix
1078
+ const name = parts[0].replace(/^llamacpp:/, "");
1079
+ const value = parseFloat(parts[1]);
1080
+ if (!isNaN(value)) metrics.set(name, (metrics.get(name) ?? 0) + value);
1081
+ }
1082
+ }
1083
+ return metrics;
1084
+ }
1085
+
1086
+ function parseJsonMetrics(data: unknown): Map<string, number> {
1087
+ const metrics = new Map<string, number>();
1088
+ function extract(obj: unknown, prefix = ""): void {
1089
+ if (typeof obj === "number") metrics.set(prefix, obj);
1090
+ else if (Array.isArray(obj)) obj.forEach((v, i) => extract(v, `${prefix}[${i}]`));
1091
+ else if (typeof obj === "object" && obj !== null) {
1092
+ for (const [key, val] of Object.entries(obj as Record<string, unknown>)) {
1093
+ extract(val, prefix ? `${prefix}.${key}` : key);
1094
+ }
1095
+ }
1096
+ }
1097
+ extract(data);
1098
+ return metrics;
1099
+ }
1100
+
1101
+ /** Cumulative counters used for instantaneous throughput deltas. */
1102
+ function counterValue(m: Map<string, number>, names: string[]): number | undefined {
1103
+ for (const n of names) {
1104
+ const v = m.get(n);
1105
+ if (typeof v === "number") return v;
1106
+ }
1107
+ return undefined;
1108
+ }
1109
+
1110
+ type ThemeFg = (color: any, text: string) => string;
1111
+ const safeFg: ThemeFg = (_color, text) => text;
1112
+
1113
+ interface MetricsEndpointDiscovered {
1114
+ url: string;
1115
+ format: "prometheus" | "json";
1116
+ }
1117
+
1118
+ // =============================================================================
1119
+ // Discovery engine
1120
+ // =============================================================================
1121
+
1122
+ /** Signature used to detect scan-result changes (avoids redundant re-registers). */
1123
+ function modelsSignature(endpoints: EndpointResult[]): string {
1124
+ return endpoints
1125
+ .map((r) => {
1126
+ const models = r.models
1127
+ .filter((m) => {
1128
+ const st = r.meta.get(String(m.id ?? ""))?.routerStatus;
1129
+ return !st || st === "loaded" || rIncludeUnloaded;
1130
+ })
1131
+ .map((m) => `${m.id}:${r.meta.get(String(m.id ?? ""))?.routerStatus ?? ""}`)
1132
+ .sort()
1133
+ .join(",");
1134
+ return `${r.host}:${r.port}:${r.ok ? r.server : r.loading ? "loading" : "down"}:${models}`;
1135
+ })
1136
+ .join("|");
1137
+ }
1138
+
1139
+ let rIncludeUnloaded = false; // set per scan from settings
1140
+
1141
+ async function scanAllServers(config: InfraConfig): Promise<ScanResult> {
1142
+ rIncludeUnloaded = config.settings.includeUnloadedRouterModels;
1143
+ const enabled = config.servers.filter((s) => s.enabled && s.ports.length > 0);
1144
+ const localServers = config.settings.detectVision ? scanLocalServers() : new Map<number, LocalServerInfo>();
1145
+ const tasks: Array<Promise<EndpointResult>> = [];
1146
+ for (const srv of enabled) {
1147
+ for (const port of srv.ports) {
1148
+ tasks.push(fetchModelsFromEndpoint(srv, port, config.settings, localServers));
1149
+ }
1150
+ }
1151
+ const endpoints = await Promise.all(tasks);
1152
+ const serversUp = new Set(endpoints.filter((e) => e.ok).map((e) => e.serverId)).size;
1153
+ const totalModels = endpoints.reduce((acc, e) => acc + (e.ok ? e.models.length : 0), 0);
1154
+ return { endpoints, totalModels, serversUp, serversTotal: enabled.length };
1155
+ }
1156
+
1157
+ /** ZINC model ids currently registered (for the payload workaround hook). */
1158
+ const zincModelIds = new Set<string>();
1159
+ /** modelId → baseUrl (for the header warmup capture hook). */
1160
+ const modelBaseUrls = new Map<string, string>();
1161
+ /** baseUrl → server kind (for the warmup request profile). */
1162
+ const endpointKinds = new Map<string, string>();
1163
+
1164
+ /**
1165
+ * Build pi models from a scan and (re)register the provider.
1166
+ * Returns the registered model list.
1167
+ */
1168
+ function buildAndRegisterProvider(pi: ExtensionAPI, scan: ScanResult, config: InfraConfig): PiModel[] {
1169
+ zincModelIds.clear();
1170
+ const settings = config.settings;
1171
+
1172
+ const piModels: PiModel[] = [];
1173
+ const seenIds = new Set<string>();
1174
+ for (const ep of scan.endpoints) {
1175
+ if (!ep.ok) continue;
1176
+ const srv = config.servers.find((s) => s.id === ep.serverId && s.host === ep.host);
1177
+ if (!srv) continue;
1178
+
1179
+ // Router mode: skip unloaded models unless explicitly included.
1180
+ const visibleModels = ep.models.filter((m) => {
1181
+ const st = ep.meta.get(String(m.id ?? ""))?.routerStatus;
1182
+ return !st || st === "loaded" || settings.includeUnloadedRouterModels;
1183
+ });
1184
+
1185
+ // Duplicate display names within one endpoint get the raw id appended.
1186
+ const nameCount = new Map<string, number>();
1187
+ for (const model of visibleModels) {
1188
+ const n = cleanModelName(String(model.name ?? model.id));
1189
+ nameCount.set(n, (nameCount.get(n) ?? 0) + 1);
1190
+ }
1191
+
1192
+ for (const model of visibleModels) {
1193
+ const rawId = String(model.id ?? "");
1194
+ const modelMeta = ep.meta.get(rawId) ?? {};
1195
+ const pm = toPiModel(model, ep, srv, settings, modelMeta);
1196
+
1197
+ // ID collision guard: force the "host:port/" prefix on collisions,
1198
+ // then a numeric suffix if even that collides.
1199
+ if (seenIds.has(pm.id)) {
1200
+ pm.id = `${idSafeHost(srv.host)}:${ep.port}/${pm.id.replace(/^\/+/, "")}`;
1201
+ let n = 2;
1202
+ while (seenIds.has(pm.id)) pm.id = `${pm.id}-${n++}`;
1203
+ }
1204
+ seenIds.add(pm.id);
1205
+
1206
+ const candidateName = cleanModelName(String(model.name ?? model.id));
1207
+ if (nameCount.get(candidateName)! > 1) {
1208
+ pm.name = `${candidateName}${badgeSuffix(modelMeta, settings.showBadgesInNames)} (${rawId})`;
1209
+ }
1210
+ piModels.push(pm);
1211
+ if (ep.server === "zinc") zincModelIds.add(pm.id);
1212
+ }
1213
+ }
1214
+
1215
+ // Maps for hooks
1216
+ endpointKinds.clear();
1217
+ for (const ep of scan.endpoints) {
1218
+ if (ep.ok) endpointKinds.set(ep.baseUrl, ep.server);
1219
+ }
1220
+ modelBaseUrls.clear();
1221
+ for (const pm of piModels) modelBaseUrls.set(pm.id, pm.baseUrl);
1222
+
1223
+ // Provider defaults: first enabled server/endpoint acts as the face of the provider.
1224
+ const first = config.servers.find((s) => s.enabled && s.ports.length > 0);
1225
+ const defaultBaseUrl =
1226
+ scan.endpoints.find((e) => e.ok)?.baseUrl ??
1227
+ `http://${first?.host ?? "127.0.0.1"}:${first?.ports[0] ?? 8080}/v1`;
1228
+ const defaultApiKey = first?.apiKey || DEFAULT_API_KEY;
1229
+
1230
+ try {
1231
+ pi.unregisterProvider(PROVIDER_NAME);
1232
+ } catch {
1233
+ // not registered yet
1234
+ }
1235
+
1236
+ pi.registerProvider(PROVIDER_NAME, {
1237
+ name: `🦙 llama.cpp-infra (${scan.totalModels} on ${scan.serversUp}/${scan.serversTotal} servers)`,
1238
+ baseUrl: defaultBaseUrl,
1239
+ apiKey: defaultApiKey,
1240
+ api: "openai-completions",
1241
+ // compat is set per model (model.compat is what pi honors).
1242
+ models: piModels,
1243
+ });
1244
+
1245
+ return piModels;
1246
+ }
1247
+
1248
+ // =============================================================================
1249
+ // Extension entry point
1250
+ // =============================================================================
1251
+
1252
+ export default function (pi: ExtensionAPI) {
1253
+ const config = loadConfig();
1254
+ activeConfig = config;
1255
+
1256
+ let registeredCount = 0;
1257
+ let lastError: string | undefined;
1258
+ let lastScan: ScanResult | undefined;
1259
+ let lastModels: PiModel[] = [];
1260
+ let lastSignature: string | undefined;
1261
+ let pollTimer: ReturnType<typeof setTimeout> | undefined;
1262
+ let polling = false;
1263
+ /** True while the registered provider has an empty model list. */
1264
+ let providerIsEmpty = true;
1265
+ /** Current thinking level (for per-model thinking budget injection). */
1266
+ let currentThinkingLevel: string | undefined;
1267
+
1268
+ /** Endpoints ("host:port") that ever answered OK, with consecutive-fail counts. */
1269
+ const knownGood = new Set<string>();
1270
+ const consecutiveFails = new Map<string, number>();
1271
+ let anyEverOk = false;
1272
+ const startedAt = Date.now();
1273
+
1274
+ const epKey = (host: string, port: number) => `${host}:${port}`;
1275
+
1276
+ // ── Header warmup (pre-cache system prompt on llama.cpp-family servers) ──
1277
+ // The ☕ footer indicator shows warmup progress. Fully optional; disable
1278
+ // via /llamacpp-infra config → ☕ Header warmup, or env PI_WARMUP=0.
1279
+ const warmupStatus = new WarmupStatus("warmup-llamacpp-infra");
1280
+ const warmer = new PromptWarmer({
1281
+ provider: PROVIDER_NAME,
1282
+ cacheFile: join(homedir(), ".pi", "agent", "warmup-llamacpp-infra.json"),
1283
+ kindFor: (baseUrl) => endpointKinds.get(baseUrl),
1284
+ onEvent: (ev) => warmupStatus.handle(ev),
1285
+ });
1286
+
1287
+ function safeSystemPrompt(ctx: { getSystemPrompt?: () => string }): string | undefined {
1288
+ try {
1289
+ return ctx.getSystemPrompt?.();
1290
+ } catch {
1291
+ return undefined;
1292
+ }
1293
+ }
1294
+
1295
+ // ── Live metrics (integrated model-metrics) ────────────────────
1296
+ let metricsTimer: ReturnType<typeof setInterval> | undefined;
1297
+ let metricsWidgetVisible = false;
1298
+ /** Discovered metrics endpoint per "host:port". */
1299
+ const metricsEndpoints = new Map<string, MetricsEndpointDiscovered>();
1300
+ /** Previous cumulative counters + timestamp, per "host:port". */
1301
+ const metricsPrev = new Map<string, { raw: Map<string, number>; ts: number }>();
1302
+ let metricsCurrentKey: string | undefined;
1303
+ let metricsCurrentBaseUrl: string | undefined;
1304
+ let metricsCurrentApiKey: string | undefined;
1305
+
1306
+ async function discoverMetricsEndpoint(baseUrl: string): Promise<MetricsEndpointDiscovered | undefined> {
1307
+ const rootUrl = baseUrl.replace(/\/v1\/?$/, "");
1308
+ for (const path of METRICS_CANDIDATE_PATHS) {
1309
+ const url = `${rootUrl}${path}`;
1310
+ try {
1311
+ const res = await httpGet(url, METRICS_FETCH_TIMEOUT_MS, metricsCurrentApiKey);
1312
+ if (res.status < 200 || res.status >= 300) continue;
1313
+ const isJson = (res.contentType ?? "").includes("json");
1314
+ const looksPrometheus = isPrometheusFormat(res.body);
1315
+ const format: "prometheus" | "json" = isJson ? "json" : looksPrometheus ? "prometheus" : "json";
1316
+ debugLog(`metrics endpoint discovered: ${url} (${format})`);
1317
+ return { url, format };
1318
+ } catch {
1319
+ continue;
1320
+ }
1321
+ }
1322
+ return undefined;
1323
+ }
1324
+
1325
+ function renderMetricsWidget(lines: string[] | undefined, ctx: ExtensionContext) {
1326
+ if (!ctx.hasUI) return;
1327
+ if (lines === undefined) {
1328
+ if (metricsWidgetVisible) {
1329
+ ctx.ui.setWidget(METRICS_WIDGET_ID, undefined);
1330
+ metricsWidgetVisible = false;
1331
+ }
1332
+ return;
1333
+ }
1334
+ ctx.ui.setWidget(METRICS_WIDGET_ID, lines, { placement: "belowEditor" });
1335
+ metricsWidgetVisible = true;
1336
+ }
1337
+
1338
+ function buildMetricsLine(
1339
+ raw: Map<string, number>,
1340
+ deltaSec: number,
1341
+ prev: { raw: Map<string, number> } | undefined,
1342
+ fg: ThemeFg,
1343
+ ): string[] {
1344
+ // Instantaneous throughput from cumulative counters.
1345
+ const promptNow = counterValue(raw, ["prompt_tokens_total"]);
1346
+ const genNow = counterValue(raw, ["predicted_tokens_total", "tokens_predicted_total"]);
1347
+ let promptTps: number | undefined;
1348
+ let genTps: number | undefined;
1349
+ if (prev && deltaSec > 0) {
1350
+ const promptPrev = counterValue(prev.raw, ["prompt_tokens_total"]);
1351
+ const genPrev = counterValue(prev.raw, ["predicted_tokens_total", "tokens_predicted_total"]);
1352
+ if (promptNow !== undefined && promptPrev !== undefined && promptNow >= promptPrev && promptNow > promptPrev)
1353
+ promptTps = (promptNow - promptPrev) / deltaSec;
1354
+ if (genNow !== undefined && genPrev !== undefined && genNow >= genPrev && genNow > genPrev)
1355
+ genTps = (genNow - genPrev) / deltaSec;
1356
+ }
1357
+ // Fallback: server-provided average rates.
1358
+ if (promptTps === undefined) promptTps = raw.get("prompt_tokens_seconds");
1359
+ if (genTps === undefined) genTps = raw.get("predicted_tokens_seconds") ?? raw.get("tokens_predicted_seconds");
1360
+
1361
+ const processing =
1362
+ raw.get("requests_processing") ?? counterValue(raw, ["num_requests_running"]) ?? 0;
1363
+
1364
+ const parts: string[] = [fg("accent", "📊")];
1365
+ if (promptTps !== undefined && promptTps > 0) {
1366
+ const c = promptTps > 500 ? "success" : promptTps > 20 ? "warning" : "muted";
1367
+ parts.push(fg(c, `⚡ ${Math.round(promptTps)} t/s`));
1368
+ }
1369
+ if (genTps !== undefined && genTps > 0) {
1370
+ const c = genTps > 100 ? "success" : genTps > 10 ? "warning" : "muted";
1371
+ parts.push(fg(c, `🔥 ${Math.round(genTps)} t/s`));
1372
+ }
1373
+ if (processing > 0) parts.push(fg("success", `▶ ${processing}`));
1374
+ if (parts.length === 1) parts.push(fg("muted", "⏸ idle"));
1375
+ return [parts.join(" · ")];
1376
+ }
1377
+
1378
+ async function pollMetrics(ctx: ExtensionContext): Promise<void> {
1379
+ try {
1380
+ const model = ctx.model;
1381
+ // Only our models have metrics; anything else hides the widget.
1382
+ if (!config.settings.metricsEnabled || model?.provider !== PROVIDER_NAME) {
1383
+ renderMetricsWidget(undefined, ctx);
1384
+ metricsCurrentKey = undefined;
1385
+ return;
1386
+ }
1387
+
1388
+ // Resolve the endpoint for the current model.
1389
+ const baseUrl = model.baseUrl ?? modelBaseUrls.get(model.id);
1390
+ if (!baseUrl) {
1391
+ renderMetricsWidget(undefined, ctx);
1392
+ return;
1393
+ }
1394
+ const parsed = new URL(baseUrl);
1395
+ const key = `${parsed.hostname}:${parsed.port}`;
1396
+ if (key !== metricsCurrentKey) {
1397
+ metricsCurrentKey = key;
1398
+ metricsCurrentBaseUrl = baseUrl;
1399
+ metricsPrev.clear();
1400
+ }
1401
+
1402
+ let endpoint = metricsEndpoints.get(key);
1403
+ if (!endpoint) {
1404
+ endpoint = await discoverMetricsEndpoint(baseUrl);
1405
+ if (endpoint) metricsEndpoints.set(key, endpoint);
1406
+ }
1407
+ if (!endpoint) {
1408
+ renderMetricsWidget([safeFg("muted", "📊 no metrics endpoint")], ctx);
1409
+ return;
1410
+ }
1411
+
1412
+ const res = await httpGet(endpoint.url, METRICS_FETCH_TIMEOUT_MS, metricsCurrentApiKey);
1413
+ if (res.status < 200 || res.status >= 300) {
1414
+ renderMetricsWidget([safeFg("muted", `📊 metrics HTTP ${res.status}`)], ctx);
1415
+ return;
1416
+ }
1417
+ let raw: Map<string, number>;
1418
+ if (endpoint.format === "prometheus") raw = parsePrometheusMetrics(res.body);
1419
+ else {
1420
+ try {
1421
+ raw = parseJsonMetrics(JSON.parse(res.body));
1422
+ } catch {
1423
+ renderMetricsWidget([safeFg("muted", "📊 metrics parse error")], ctx);
1424
+ return;
1425
+ }
1426
+ }
1427
+
1428
+ const now = Date.now();
1429
+ const prev = metricsPrev.get(key);
1430
+ const deltaSec = prev ? (now - prev.ts) / 1000 : 0;
1431
+ metricsPrev.set(key, { raw, ts: now });
1432
+
1433
+ const fg = ctx.hasUI ? ((ctx.ui.theme?.fg)?.bind(ctx.ui.theme) ?? safeFg) : safeFg;
1434
+ renderMetricsWidget(buildMetricsLine(raw, deltaSec, prev, fg), ctx);
1435
+ } catch (err) {
1436
+ const msg = err instanceof Error ? err.message : String(err);
1437
+ debugLog(`metrics poll failed: ${msg}`);
1438
+ const fg = ctx.hasUI ? ((ctx.ui.theme?.fg)?.bind(ctx.ui.theme) ?? safeFg) : safeFg;
1439
+ renderMetricsWidget([fg("muted", "📊 ⏸ idle")], ctx);
1440
+ }
1441
+ }
1442
+
1443
+ function startMetricsPolling(ctx: ExtensionContext): void {
1444
+ if (metricsTimer) return;
1445
+ void pollMetrics(ctx);
1446
+ metricsTimer = setInterval(() => void pollMetrics(ctx), config.settings.metricsPollMs);
1447
+ metricsTimer?.unref?.();
1448
+ }
1449
+
1450
+ function stopMetricsPolling(ctx?: ExtensionContext): void {
1451
+ if (metricsTimer) {
1452
+ clearInterval(metricsTimer);
1453
+ metricsTimer = undefined;
1454
+ }
1455
+ if (ctx) renderMetricsWidget(undefined, ctx);
1456
+ }
1457
+
1458
+ // ── Polling engine (discovery) ─────────────────────────────────
1459
+ /** Keep polling while: something is loading, a known-good endpoint went
1460
+ * quiet (server restarting), or startup grace hasn't expired yet. */
1461
+ function shouldContinuePolling(endpoints: EndpointResult[], hasLoading: boolean): boolean {
1462
+ if (hasLoading) return true;
1463
+ for (const key of knownGood) {
1464
+ const ep = endpoints.find((e) => epKey(e.host, e.port) === key);
1465
+ if (!ep || !ep.ok) return true;
1466
+ }
1467
+ if (!anyEverOk && Date.now() - startedAt < config.settings.startupGraceMs) return true;
1468
+ return false;
1469
+ }
1470
+
1471
+ /** Register an (empty) provider immediately so pi boots without waiting. */
1472
+ function registerEmptyProvider() {
1473
+ const first = config.servers.find((s) => s.enabled && s.ports.length > 0);
1474
+ try {
1475
+ pi.unregisterProvider(PROVIDER_NAME);
1476
+ } catch {
1477
+ // not registered yet
1478
+ }
1479
+ pi.registerProvider(PROVIDER_NAME, {
1480
+ name: "🦙 llama.cpp-infra (scanning…)",
1481
+ baseUrl: `http://${first?.host ?? "127.0.0.1"}:${first?.ports[0] ?? 8080}/v1`,
1482
+ apiKey: first?.apiKey || DEFAULT_API_KEY,
1483
+ api: "openai-completions",
1484
+ models: [],
1485
+ });
1486
+ providerIsEmpty = true;
1487
+ }
1488
+
1489
+ /** One discovery pass: scan, register, update known-good tracking. */
1490
+ async function discoverAndRegister(): Promise<{ scan: ScanResult; shouldPoll: boolean }> {
1491
+ try {
1492
+ const scan = await scanAllServers(config);
1493
+ lastScan = scan;
1494
+
1495
+ // Skip redundant re-registers while polling: only rebuild when the
1496
+ // discovery signature changed or the provider is still empty.
1497
+ const signature = modelsSignature(scan.endpoints);
1498
+ const changed = signature !== lastSignature || providerIsEmpty;
1499
+ lastSignature = signature;
1500
+ if (!changed) {
1501
+ return { scan, shouldPoll: shouldContinuePolling(scan.endpoints, scan.endpoints.some((e) => e.loading)) };
1502
+ }
1503
+
1504
+ const models = buildAndRegisterProvider(pi, scan, config);
1505
+ providerIsEmpty = models.length === 0;
1506
+ registeredCount = models.length;
1507
+ lastModels = models;
1508
+ lastError = undefined;
1509
+ debugLog(`discovery change: ${registeredCount} model(s), ${scan.serversUp}/${scan.serversTotal} servers up`);
1510
+
1511
+ for (const ep of scan.endpoints) {
1512
+ const key = epKey(ep.host, ep.port);
1513
+ if (ep.ok) {
1514
+ anyEverOk = true;
1515
+ knownGood.add(key);
1516
+ consecutiveFails.delete(key);
1517
+ } else if (knownGood.has(key)) {
1518
+ const fails = (consecutiveFails.get(key) ?? 0) + 1;
1519
+ consecutiveFails.set(key, fails);
1520
+ if (fails >= config.settings.knownGoodFailLimit) {
1521
+ knownGood.delete(key);
1522
+ consecutiveFails.delete(key);
1523
+ }
1524
+ }
1525
+ }
1526
+ return { scan, shouldPoll: shouldContinuePolling(scan.endpoints, scan.endpoints.some((e) => e.loading)) };
1527
+ } catch (err) {
1528
+ lastError = err instanceof Error ? err.message : String(err);
1529
+ return { scan: { endpoints: [], totalModels: 0, serversUp: 0, serversTotal: 0 }, shouldPoll: false };
1530
+ }
1531
+ }
1532
+
1533
+ /** Clock-bounded background polling while servers load / restart. */
1534
+ function schedulePolling(shouldPoll: boolean) {
1535
+ if (!shouldPoll || polling) return;
1536
+ polling = true;
1537
+ const deadline = Date.now() + config.settings.pollMaxMs;
1538
+ const tick = async () => {
1539
+ if (Date.now() >= deadline) {
1540
+ polling = false;
1541
+ return;
1542
+ }
1543
+ const r = await discoverAndRegister();
1544
+ if (r.shouldPoll) {
1545
+ pollTimer = setTimeout(tick, config.settings.pollIntervalMs);
1546
+ pollTimer?.unref?.();
1547
+ } else {
1548
+ polling = false;
1549
+ }
1550
+ };
1551
+ pollTimer = setTimeout(tick, config.settings.pollIntervalMs);
1552
+ pollTimer?.unref?.();
1553
+ }
1554
+
1555
+ function stopPolling() {
1556
+ if (pollTimer) clearTimeout(pollTimer);
1557
+ pollTimer = undefined;
1558
+ polling = false;
1559
+ }
1560
+
1561
+ /** Full rescan: reset polling, clear provider, discover, update footer. */
1562
+ async function rescan(ctx?: ExtensionContext) {
1563
+ ctx?.ui.setStatus(STATUS_KEY, "🔎 scanning…");
1564
+ stopPolling();
1565
+ registerEmptyProvider();
1566
+ const r = await discoverAndRegister();
1567
+ schedulePolling(r.shouldPoll);
1568
+ updateStatusFooter(ctx);
1569
+ }
1570
+
1571
+ function updateStatusFooter(ctx?: ExtensionContext) {
1572
+ if (!ctx?.hasUI) return;
1573
+ if (registeredCount > 0) {
1574
+ const up = lastScan?.serversUp ?? 0;
1575
+ const total = lastScan?.serversTotal ?? 0;
1576
+ ctx.ui.setStatus(STATUS_KEY, `🦙 ${registeredCount} models · ${up}/${total} ✓`);
1577
+ } else if (lastScan?.endpoints.some((e) => e.loading)) {
1578
+ ctx.ui.setStatus(STATUS_KEY, "⏳ loading…");
1579
+ } else if (lastError) {
1580
+ ctx.ui.setStatus(STATUS_KEY, "⚠️");
1581
+ } else {
1582
+ ctx.ui.setStatus(STATUS_KEY, "−");
1583
+ }
1584
+ }
1585
+
1586
+ // ── Initial non-blocking registration ──────────────────────────
1587
+ registerEmptyProvider();
1588
+ void discoverAndRegister().then((r) => schedulePolling(r.shouldPoll));
1589
+
1590
+ // ── Hook 1: ZINC payload workaround ────────────────────────────
1591
+ // ZINC rejects non-empty model ids and is picky about tool formats.
1592
+ function isZincModel(modelInPayload: unknown): boolean {
1593
+ if (typeof modelInPayload !== "string") return false;
1594
+ if (zincModelIds.has(modelInPayload)) return true;
1595
+ for (const zid of zincModelIds) {
1596
+ if (modelInPayload === zid || modelInPayload.includes(zid) || zid.includes(modelInPayload)) return true;
1597
+ }
1598
+ return false;
1599
+ }
1600
+
1601
+ pi.on("before_provider_request", (event, _ctx) => {
1602
+ const payload = event.payload as Record<string, unknown>;
1603
+ if (!isZincModel(payload.model)) return undefined;
1604
+
1605
+ const rawTools = payload.tools;
1606
+ const hasTools = "tools" in payload;
1607
+ const toolCount = Array.isArray(rawTools) ? rawTools.length : 0;
1608
+ debugLog(`ZINC model detected ("${payload.model}") → rewriting payload`);
1609
+
1610
+ const newPayload: Record<string, unknown> = { ...payload, model: "" };
1611
+
1612
+ if (hasTools && toolCount > 0 && newPayload.tool_choice === undefined) {
1613
+ newPayload.tool_choice = "auto";
1614
+ }
1615
+
1616
+ if (Array.isArray(rawTools)) {
1617
+ newPayload.tools = rawTools.map((tool: any) => {
1618
+ // Normalize to {type:"function", function:{...}} and drop "strict".
1619
+ if (tool?.type === "function" && tool?.function) {
1620
+ const cleaned: any = { type: "function", function: { ...tool.function } };
1621
+ delete cleaned.function.strict;
1622
+ return cleaned;
1623
+ }
1624
+ if (tool?.name) {
1625
+ const fn: any = { name: tool.name };
1626
+ if (tool.description) fn.description = tool.description;
1627
+ if (tool.parameters) fn.parameters = tool.parameters;
1628
+ delete (fn as any).strict;
1629
+ return { type: "function", function: fn };
1630
+ }
1631
+ return tool;
1632
+ });
1633
+ }
1634
+
1635
+ return newPayload;
1636
+ });
1637
+
1638
+ // ── Hook 2: per-model thinking budget (llama.cpp thinking_budget_tokens) ──
1639
+ // pi injects thinking_token budgets from its global settings; this hook
1640
+ // overrides the value with the per-model budgets configured for this model.
1641
+ // Registered after the ZINC hook so it sees the final payload.
1642
+ pi.on("before_provider_request", (event, ctx) => {
1643
+ const payload = event.payload as Record<string, unknown>;
1644
+ const modelId = typeof payload.model === "string" ? payload.model : undefined;
1645
+ if (!modelId) return undefined;
1646
+ const budgets = config.modelOptions[modelId]?.thinkingBudgets;
1647
+ if (!budgets) return undefined;
1648
+ const level = normalizeLevel(ctx.thinkingLevel ?? currentThinkingLevel);
1649
+ if (!level) return undefined;
1650
+ const value = budgets[level];
1651
+ if (typeof value !== "number") return undefined;
1652
+ debugLog(`thinking budget for ${modelId} [${level}] = ${value}`);
1653
+ return { ...payload, [THINKING_BUDGET_FIELD]: value };
1654
+ });
1655
+
1656
+ function normalizeLevel(level: string | undefined): "minimal" | "low" | "medium" | "high" | undefined {
1657
+ switch (level) {
1658
+ case "minimal":
1659
+ case "low":
1660
+ case "medium":
1661
+ case "high":
1662
+ return level;
1663
+ case "xhigh":
1664
+ case "max":
1665
+ return "high"; // pi clamps xhigh/max to high as well
1666
+ default:
1667
+ return undefined; // "off" / unset → no budget
1668
+ }
1669
+ }
1670
+
1671
+ // ── Hook 3: header warmup capture (runs last, sees final payload) ──
1672
+ pi.on("before_provider_request", (event, ctx) => {
1673
+ if (!config.settings.warmup) return undefined;
1674
+ const payload = event.payload as Record<string, unknown>;
1675
+ const modelId = typeof payload?.model === "string" ? payload.model : undefined;
1676
+ const baseUrl = modelId ? modelBaseUrls.get(modelId) : undefined;
1677
+ warmer.onProviderPayload(payload, baseUrl, ctx.cwd);
1678
+ return undefined;
1679
+ });
1680
+
1681
+ // ── Command: /llamacpp-infra ───────────────────────────────────
1682
+ pi.registerCommand("llamacpp-infra", {
1683
+ description: "llama.cpp-infra: discover llama.cpp/ZINC/DwarfStar models on any machine (config, scan, metrics…)",
1684
+ getArgumentCompletions: (prefix) =>
1685
+ ["config", "scan", "status", "list", "metrics", "help"]
1686
+ .filter((s) => s.startsWith(prefix))
1687
+ .map((s) => ({ value: s, label: s })),
1688
+ handler: async (args, ctx) => {
1689
+ const sub = (args ?? "").trim().toLowerCase();
1690
+ switch (sub) {
1691
+ case "":
1692
+ case "status":
1693
+ await showStatus(ctx);
1694
+ break;
1695
+ case "config":
1696
+ await showConfigMenu(ctx);
1697
+ break;
1698
+ case "scan":
1699
+ await rescan(ctx);
1700
+ ctx.ui.notify("🔎 llama.cpp-infra: scan complete", "info");
1701
+ break;
1702
+ case "list":
1703
+ await showModelList(ctx);
1704
+ break;
1705
+ case "metrics":
1706
+ await toggleMetrics(ctx);
1707
+ break;
1708
+ case "help":
1709
+ showHelp(ctx);
1710
+ break;
1711
+ default:
1712
+ ctx.ui.notify(`❓ Unknown subcommand "${sub}". Try /llamacpp-infra help`, "warning");
1713
+ }
1714
+ },
1715
+ });
1716
+
1717
+ async function toggleMetrics(ctx: ExtensionContext) {
1718
+ config.settings.metricsEnabled = !config.settings.metricsEnabled;
1719
+ saveConfig(config);
1720
+ if (config.settings.metricsEnabled) {
1721
+ startMetricsPolling(ctx);
1722
+ ctx.ui.notify("📊 Metrics widget enabled", "info");
1723
+ } else {
1724
+ stopMetricsPolling(ctx);
1725
+ ctx.ui.notify("📊 Metrics widget disabled", "info");
1726
+ }
1727
+ }
1728
+
1729
+ // ── Status report ──────────────────────────────────────────────
1730
+ function metadataBadges(m: ModelMetadata | undefined): string {
1731
+ if (!m) return "";
1732
+ const parts: string[] = [];
1733
+ if (m.quant) parts.push(`🗜️ ${m.quant}`);
1734
+ if (m.cacheK || m.cacheV) parts.push(`🧠 KV ${m.cacheK ?? "?"}/${m.cacheV ?? m.cacheK ?? "?"}`);
1735
+ if (m.vision) parts.push("👁️ vision");
1736
+ if (m.drafter) parts.push(`🚀 ${m.drafter}`);
1737
+ return parts.length > 0 ? ` — ${parts.join(" · ")}` : "";
1738
+ }
1739
+
1740
+ async function showStatus(ctx: ExtensionContext) {
1741
+ if (!lastScan) {
1742
+ ctx.ui.notify("🦙 llama.cpp-infra: still scanning, try again in a moment…", "info");
1743
+ return;
1744
+ }
1745
+ const lines: string[] = [];
1746
+ lines.push(`🦙 llama.cpp-infra — ${lastScan.totalModels} model(s) · ${lastScan.serversUp}/${lastScan.serversTotal} servers up`);
1747
+ lines.push("");
1748
+ for (const srv of config.servers) {
1749
+ if (!srv.enabled) {
1750
+ lines.push(`⛔ ${serverLabel(srv)} (${srv.host}) — disabled`);
1751
+ continue;
1752
+ }
1753
+ const eps = lastScan.endpoints.filter((e) => e.serverId === srv.id);
1754
+ const okEps = eps.filter((e) => e.ok);
1755
+ if (okEps.length === 0) {
1756
+ const loading = eps.find((e) => e.loading);
1757
+ const err = eps.find((e) => e.error)?.error;
1758
+ lines.push(loading ? `⏳ ${serverLabel(srv)} (${srv.host}) — model loading…` : `🔴 ${serverLabel(srv)} (${srv.host}) — offline${err ? ` (${err.slice(0, 60)})` : ""}`);
1759
+ continue;
1760
+ }
1761
+ const kinds = new Map<string, number>();
1762
+ for (const e of okEps) for (const _m of e.models) kinds.set(e.server, (kinds.get(e.server) ?? 0) + 1);
1763
+ const kindStr = [...kinds.entries()].map(([k, n]) => `${k} ×${n}`).join(", ");
1764
+ const models = okEps.reduce((a, e) => a + e.models.length, 0);
1765
+ lines.push(`🟢 ${serverLabel(srv)} (${srv.host}) — ${models} model(s) [${kindStr}]`);
1766
+ for (const e of okEps.sort((a, b) => a.port - b.port)) {
1767
+ const mode = e.mode === "router" ? " · 🌐 router mode" : e.mode === "single" ? " · single" : "";
1768
+ lines.push(` • :${e.port} → ${e.models.length} model(s) · ${e.server}${mode} · ${e.latencyMs ?? "?"}ms`);
1769
+ }
1770
+ }
1771
+ if (lastError) lines.push("", `⚠️ Last error: ${lastError}`);
1772
+ ctx.ui.notify(lines.join("\n"), "info");
1773
+ }
1774
+
1775
+ async function showModelList(ctx: ExtensionContext) {
1776
+ if (lastModels.length === 0) {
1777
+ ctx.ui.notify("🦙 llama.cpp-infra: no models discovered. Try /llamacpp-infra scan", "warning");
1778
+ return;
1779
+ }
1780
+ // Endpoint info (kind/mode) indexed by baseUrl.
1781
+ const epByBaseUrl = new Map<string, EndpointResult>();
1782
+ for (const ep of lastScan?.endpoints ?? []) epByBaseUrl.set(ep.baseUrl, ep);
1783
+ // Metadata is keyed by raw id inside each endpoint.
1784
+ const metaFor = (m: PiModel): ModelMetadata | undefined => {
1785
+ const ep = epByBaseUrl.get(m.baseUrl);
1786
+ if (!ep) return undefined;
1787
+ // Find the raw entry whose registered id ends with the raw id fragment.
1788
+ for (const [rawId, meta] of ep.meta) {
1789
+ if (m.id.endsWith(`/${rawId.replace(/^\/+/, "")}`) || m.id === rawId) return meta;
1790
+ }
1791
+ return undefined;
1792
+ };
1793
+
1794
+ const lines: string[] = [`🦙 llama.cpp-infra — ${lastModels.length} discovered`, ""];
1795
+ for (const m of lastModels) {
1796
+ const ep = epByBaseUrl.get(m.baseUrl);
1797
+ const where = ep ? `${ep.label}:${ep.port} (${ep.server}${ep.mode === "router" ? ", router" : ""})` : m.baseUrl;
1798
+ const meta = metaFor(m);
1799
+ const md = metadataBadges({
1800
+ quant: m.quant ?? meta?.quant,
1801
+ cacheK: m.cacheK ?? meta?.cacheK,
1802
+ cacheV: m.cacheV ?? meta?.cacheV,
1803
+ vision: m.input.includes("image") || meta?.vision,
1804
+ drafter: m.drafter ?? meta?.drafter,
1805
+ routerStatus: m.routerStatus ?? meta?.routerStatus,
1806
+ });
1807
+ const ctxWin = formatCtx(m.contextWindow);
1808
+ const status = m.routerStatus && m.routerStatus !== "loaded" ? ` · [${m.routerStatus}]` : "";
1809
+ lines.push(` • ${m.name}`, ` ${m.id} — ${where}${md} · ctx ${ctxWin || "?"}${status}`);
1810
+ }
1811
+ ctx.ui.notify(lines.join("\n"), "info");
1812
+ }
1813
+
1814
+ function showHelp(ctx: ExtensionContext) {
1815
+ ctx.ui.notify(
1816
+ [
1817
+ "🦙 llama.cpp-infra — models served by llama.cpp & variants (ZINC, DwarfStar/ds4, lucebox) on any machine",
1818
+ "",
1819
+ " /llamacpp-infra → quick status",
1820
+ " /llamacpp-infra config → ⚙️ configure servers, budgets & settings",
1821
+ " /llamacpp-infra scan → 🔎 rescan all servers now",
1822
+ " /llamacpp-infra status → 📊 detailed per-endpoint report",
1823
+ " /llamacpp-infra list → 📋 models with quant/vision/drafter/cache info",
1824
+ " /llamacpp-infra metrics → 📈 toggle the live throughput widget",
1825
+ "",
1826
+ `Config file: ${getConfigPath()}`,
1827
+ ].join("\n"),
1828
+ "info",
1829
+ );
1830
+ }
1831
+
1832
+ // ── Configuration UI ───────────────────────────────────────────
1833
+
1834
+ async function showConfigMenu(ctx: ExtensionContext) {
1835
+ for (;;) {
1836
+ const budgetCount = Object.values(config.modelOptions).filter((o) => o.thinkingBudgets).length;
1837
+ const action = await selectFrom(ctx, "🦙 llama.cpp-infra — Configuration", [
1838
+ { value: "servers", label: "🌐 Servers", description: `${config.servers.filter((s) => s.enabled).length}/${config.servers.length} enabled · manage machines & ports` },
1839
+ { value: "scan", label: "🔍 Scan now", description: "Rediscover models on all enabled servers" },
1840
+ { value: "models", label: "📋 Discovered models", description: `${registeredCount} currently registered in /model` },
1841
+ { value: "test", label: "🧪 Test connectivity", description: "Probe every endpoint and show latency" },
1842
+ { value: "budgets", label: "🧠 Thinking budgets", description: `${budgetCount} model(s) with per-model budgets (llama.cpp thinking_budget_tokens)` },
1843
+ { value: "metrics", label: `📈 Live metrics: ${config.settings.metricsEnabled ? "ON" : "OFF"}`, description: `throughput widget · poll ${formatMs(config.settings.metricsPollMs)}` },
1844
+ { value: "settings", label: "⚙️ Discovery settings", description: "Timeouts, polling, vision, badges…" },
1845
+ { value: "about", label: "ℹ️ About", description: "How this extension works" },
1846
+ { value: "close", label: "🚪 Close", description: "" },
1847
+ ]);
1848
+ if (action === undefined || action === "close") return;
1849
+ switch (action) {
1850
+ case "servers":
1851
+ await showServersMenu(ctx);
1852
+ break;
1853
+ case "scan":
1854
+ await rescan(ctx);
1855
+ ctx.ui.notify("🔎 Scan complete", "info");
1856
+ break;
1857
+ case "models":
1858
+ await showModelList(ctx);
1859
+ break;
1860
+ case "test":
1861
+ await testConnectivity(ctx);
1862
+ break;
1863
+ case "budgets":
1864
+ await showThinkingBudgetsMenu(ctx);
1865
+ break;
1866
+ case "metrics":
1867
+ await toggleMetricsMenu(ctx);
1868
+ break;
1869
+ case "settings":
1870
+ await showSettingsMenu(ctx);
1871
+ break;
1872
+ case "about":
1873
+ ctx.ui.notify(
1874
+ [
1875
+ "🦙 llama.cpp-infra",
1876
+ "",
1877
+ "Discovers models served by llama.cpp (single & router/multi-model),",
1878
+ "ZINC, DwarfStar (ds4-server) and lucebox on any number of machines,",
1879
+ "and registers them into pi's native /model list.",
1880
+ "",
1881
+ "Per-model metadata: vision, drafter, model quant, KV cache quant.",
1882
+ "Per-model thinking budgets via llama.cpp thinking_budget_tokens.",
1883
+ "Live throughput metrics from each server's /metrics endpoint.",
1884
+ "",
1885
+ `Config: ${getConfigPath()}`,
1886
+ ].join("\n"),
1887
+ "info",
1888
+ );
1889
+ break;
1890
+ }
1891
+ }
1892
+ }
1893
+
1894
+ async function showServersMenu(ctx: ExtensionContext) {
1895
+ for (;;) {
1896
+ const items: Array<{ value: string; label: string; description?: string }> = [];
1897
+ for (const srv of config.servers) {
1898
+ const state = !srv.enabled ? "⛔" : isServerUp(srv.id) ? "🟢" : "🔴";
1899
+ const models = countServerModels(srv.id);
1900
+ items.push({
1901
+ value: srv.id,
1902
+ label: `${state} ${serverLabel(srv)}`,
1903
+ description: `${srv.host} · ${srv.ports.length} port(s)${srv.enabled ? ` · ${models} model(s)` : " · disabled"}`,
1904
+ });
1905
+ }
1906
+ items.push({ value: "__add", label: "➕ Add server", description: "Register a new machine (host + ports)" });
1907
+ items.push({ value: "__back", label: "← Back", description: "" });
1908
+
1909
+ const picked = await selectFrom(ctx, "🌐 Servers — select to edit", items);
1910
+ if (picked === undefined || picked === "__back") return;
1911
+ if (picked === "__add") {
1912
+ await addServerFlow(ctx);
1913
+ continue;
1914
+ }
1915
+ const srv = config.servers.find((s) => s.id === picked);
1916
+ if (srv) await showServerMenu(ctx, srv);
1917
+ }
1918
+ }
1919
+
1920
+ function isServerUp(serverId: string): boolean {
1921
+ return lastScan?.endpoints.some((e) => e.serverId === serverId && e.ok) ?? false;
1922
+ }
1923
+
1924
+ function countServerModels(serverId: string): number {
1925
+ return lastScan?.endpoints.filter((e) => e.serverId === serverId).reduce((a, e) => a + (e.ok ? e.models.length : 0), 0) ?? 0;
1926
+ }
1927
+
1928
+ async function showServerMenu(ctx: ExtensionContext, srv: ServerConfig) {
1929
+ for (;;) {
1930
+ const state = !srv.enabled ? "⛔ disabled" : isServerUp(srv.id) ? "🟢 online" : "🔴 offline";
1931
+ const action = await selectFrom(
1932
+ ctx,
1933
+ `🖥️ ${serverLabel(srv)} — ${srv.host} · ${state}`,
1934
+ [
1935
+ { value: "host", label: "✏️ Change host", description: `currently: ${srv.host}` },
1936
+ { value: "label", label: "🏷️ Change label", description: `currently: ${serverLabel(srv)}` },
1937
+ { value: "ports", label: "🔌 Edit ports", description: `currently: ${srv.ports.join(", ")}` },
1938
+ {
1939
+ value: "toggle",
1940
+ label: srv.enabled ? "🔴 Disable server" : "🟢 Enable server",
1941
+ description: srv.enabled ? "stop probing this machine" : "start probing this machine",
1942
+ },
1943
+ {
1944
+ value: "ds4",
1945
+ label: `🕵️ ds4 (DwarfStar) probe: ${srv.probeDs4 ? "ON" : "OFF"}`,
1946
+ description: "ping /v1/chat/completions when /v1/models fails",
1947
+ },
1948
+ {
1949
+ value: "key",
1950
+ label: srv.apiKey ? "🔑 API key: set" : "🔑 API key: none",
1951
+ description: srv.apiKey ? "clear or replace bearer token" : "optional, for authenticated endpoints",
1952
+ },
1953
+ { value: "test", label: "🧪 Test this server", description: `probe ${srv.ports.length} port(s) now` },
1954
+ { value: "delete", label: "🗑️ Delete server", description: "remove from configuration" },
1955
+ { value: "__back", label: "← Back", description: "" },
1956
+ ],
1957
+ );
1958
+ if (action === undefined || action === "__back") return;
1959
+
1960
+ switch (action) {
1961
+ case "host": {
1962
+ const host = await ctx.ui.input("✏️ New host (IP or hostname)", srv.host);
1963
+ if (host === undefined) break;
1964
+ const trimmed = host.trim();
1965
+ if (!trimmed) {
1966
+ ctx.ui.notify("❌ Host cannot be empty", "error");
1967
+ break;
1968
+ }
1969
+ srv.host = trimmed;
1970
+ saveConfig(config);
1971
+ ctx.ui.notify(`✅ Host updated: ${srv.host}`, "info");
1972
+ await rescan(ctx);
1973
+ break;
1974
+ }
1975
+ case "label": {
1976
+ const label = await ctx.ui.input("🏷️ Label for menus", serverLabel(srv));
1977
+ if (label === undefined) break;
1978
+ srv.label = label.trim();
1979
+ saveConfig(config);
1980
+ ctx.ui.notify(`✅ Label updated: ${serverLabel(srv)}`, "info");
1981
+ break;
1982
+ }
1983
+ case "ports": {
1984
+ const raw = await ctx.ui.input("🔌 Ports (e.g. 8000, 8080-8082)", srv.ports.join(", "));
1985
+ if (raw === undefined) break;
1986
+ const ports = parsePorts(raw);
1987
+ if (!ports) {
1988
+ ctx.ui.notify("❌ No valid ports in input", "error");
1989
+ break;
1990
+ }
1991
+ srv.ports = ports;
1992
+ saveConfig(config);
1993
+ ctx.ui.notify(`✅ Ports updated: ${ports.join(", ")}`, "info");
1994
+ await rescan(ctx);
1995
+ break;
1996
+ }
1997
+ case "toggle":
1998
+ srv.enabled = !srv.enabled;
1999
+ saveConfig(config);
2000
+ ctx.ui.notify(`${srv.enabled ? "🟢 Enabled" : "🔴 Disabled"}: ${serverLabel(srv)}`, "info");
2001
+ await rescan(ctx);
2002
+ break;
2003
+ case "ds4":
2004
+ srv.probeDs4 = !srv.probeDs4;
2005
+ saveConfig(config);
2006
+ ctx.ui.notify(`🕵️ ds4 (DwarfStar) probe ${srv.probeDs4 ? "ON" : "OFF"} for ${serverLabel(srv)}`, "info");
2007
+ await rescan(ctx);
2008
+ break;
2009
+ case "key": {
2010
+ if (srv.apiKey) {
2011
+ const clear = await ctx.ui.confirm("🔑 API key", `A key is set for ${serverLabel(srv)}. Clear it?`);
2012
+ if (clear) {
2013
+ delete srv.apiKey;
2014
+ saveConfig(config);
2015
+ ctx.ui.notify("🔑 API key cleared", "info");
2016
+ await rescan(ctx);
2017
+ }
2018
+ } else {
2019
+ const key = await ctx.ui.input("🔑 API key (bearer token)", "sk-…");
2020
+ if (key === undefined) break;
2021
+ const trimmed = key.trim();
2022
+ if (!trimmed) break;
2023
+ srv.apiKey = trimmed;
2024
+ saveConfig(config);
2025
+ ctx.ui.notify("🔑 API key saved", "info");
2026
+ await rescan(ctx);
2027
+ }
2028
+ break;
2029
+ }
2030
+ case "test": {
2031
+ ctx.ui.setStatus(STATUS_KEY, "🧪 testing…");
2032
+ const localServers = config.settings.detectVision ? scanLocalServers() : new Map<number, LocalServerInfo>();
2033
+ const results = await Promise.all(
2034
+ srv.ports.map((port) => fetchModelsFromEndpoint(srv, port, config.settings, localServers)),
2035
+ );
2036
+ const lines = [`🧪 ${serverLabel(srv)} (${srv.host}) — ${results.filter((r) => r.ok).length}/${results.length} endpoints up`, ""];
2037
+ for (const r of results.sort((a, b) => a.port - b.port)) {
2038
+ if (r.ok) {
2039
+ const mode = r.mode === "router" ? " · router" : "";
2040
+ lines.push(`✅ :${r.port} — ${r.models.length} model(s) · ${r.server}${mode} · ${r.latencyMs ?? "?"}ms`);
2041
+ } else if (r.loading) {
2042
+ lines.push(`⏳ :${r.port} — loading model…`);
2043
+ } else {
2044
+ lines.push(`❌ :${r.port} — ${r.error?.slice(0, 70) ?? "unreachable"}`);
2045
+ }
2046
+ }
2047
+ ctx.ui.notify(lines.join("\n"), "info");
2048
+ updateStatusFooter(ctx);
2049
+ break;
2050
+ }
2051
+ case "delete": {
2052
+ const sure = await ctx.ui.confirm(
2053
+ "🗑️ Delete server",
2054
+ `Remove "${serverLabel(srv)}" (${srv.host}) from the configuration?`,
2055
+ );
2056
+ if (sure) {
2057
+ config.servers = config.servers.filter((s) => s.id !== srv.id);
2058
+ saveConfig(config);
2059
+ ctx.ui.notify(`🗑️ Deleted: ${serverLabel(srv)}`, "info");
2060
+ await rescan(ctx);
2061
+ return; // server no longer exists — leave its menu
2062
+ }
2063
+ break;
2064
+ }
2065
+ }
2066
+ }
2067
+ }
2068
+
2069
+ async function addServerFlow(ctx: ExtensionContext) {
2070
+ const host = await ctx.ui.input("➕ Add server — host (IP or tailnet name)", "e.g. 192.168.1.20 or mybox");
2071
+ if (host === undefined) return;
2072
+ const trimmedHost = host.trim();
2073
+ if (!trimmedHost) {
2074
+ ctx.ui.notify("❌ Host cannot be empty", "error");
2075
+ return;
2076
+ }
2077
+ if (config.servers.some((s) => s.host === trimmedHost)) {
2078
+ ctx.ui.notify(`⚠️ A server with host "${trimmedHost}" already exists`, "warning");
2079
+ return;
2080
+ }
2081
+ const portsRaw = await ctx.ui.input("➕ Ports to probe", "e.g. 8000, 8080-8082");
2082
+ if (portsRaw === undefined) return;
2083
+ const ports = parsePorts(portsRaw);
2084
+ if (!ports) {
2085
+ ctx.ui.notify("❌ No valid ports in input", "error");
2086
+ return;
2087
+ }
2088
+ const label = await ctx.ui.input("🏷️ Label (optional)", trimmedHost);
2089
+ if (label === undefined) return;
2090
+ const probeDs4 = await ctx.ui.confirm("🕵️ ds4 (DwarfStar) probe?", "Enable the chat-completions ping probe for this machine? (for DwarfStar/ds4-server hosts)");
2091
+
2092
+ let id = idSafeHost(trimmedHost).replace(/[^a-z0-9.-]/g, "-");
2093
+ let n = 2;
2094
+ while (config.servers.some((s) => s.id === id)) id = `${idSafeHost(trimmedHost).replace(/[^a-z0-9.-]/g, "-")}-${n++}`;
2095
+
2096
+ config.servers.push({ id, host: trimmedHost, label: label.trim() || undefined, ports, enabled: true, probeDs4 });
2097
+ saveConfig(config);
2098
+ ctx.ui.notify(`➕ Server added: ${label.trim() || trimmedHost} (${trimmedHost}) — ports ${ports.join(", ")}`, "info");
2099
+ await rescan(ctx);
2100
+ }
2101
+
2102
+ // ── Thinking budgets menu ──────────────────────────────────────
2103
+
2104
+ function budgetsSummary(b?: ThinkingBudgets): string {
2105
+ if (!b) return "none";
2106
+ const parts: string[] = [];
2107
+ for (const [k, v] of Object.entries(b)) {
2108
+ if (typeof v === "number") parts.push(`${k}: ${v}`);
2109
+ }
2110
+ return parts.length > 0 ? parts.join(", ") : "none";
2111
+ }
2112
+
2113
+ async function showThinkingBudgetsMenu(ctx: ExtensionContext) {
2114
+ for (;;) {
2115
+ const entries = Object.entries(config.modelOptions).filter(([, o]) => o.thinkingBudgets);
2116
+ const items: Array<{ value: string; label: string; description?: string }> = [];
2117
+ // Models currently discovered first (easy to pick), then configured-only ones.
2118
+ for (const m of lastModels) {
2119
+ const opts = config.modelOptions[m.id];
2120
+ items.push({
2121
+ value: m.id,
2122
+ label: `🧠 ${m.name}`,
2123
+ description: budgetsSummary(opts?.thinkingBudgets),
2124
+ });
2125
+ }
2126
+ for (const [id, opts] of entries) {
2127
+ if (lastModels.some((m) => m.id === id)) continue;
2128
+ items.push({ value: id, label: `🧠 ${id}`, description: `${budgetsSummary(opts.thinkingBudgets)} (not currently online)` });
2129
+ }
2130
+ if (items.length === 0) {
2131
+ const info = await ctx.ui.confirm(
2132
+ "🧠 Thinking budgets",
2133
+ "No models discovered yet. Models are scanned at startup — run /llamacpp-infra scan first, then come back. Open help?",
2134
+ );
2135
+ if (info) showHelp(ctx);
2136
+ return;
2137
+ }
2138
+ items.push({ value: "__back", label: "← Back", description: "" });
2139
+ const picked = await selectFrom(ctx, "🧠 Thinking budgets — pick a model", items);
2140
+ if (picked === undefined || picked === "__back") return;
2141
+ const changed = await editModelBudgets(ctx, picked);
2142
+ if (changed) {
2143
+ saveConfig(config);
2144
+ ctx.ui.notify("🧠 Thinking budgets saved — re-registering models", "info");
2145
+ await rescan(ctx);
2146
+ }
2147
+ }
2148
+ }
2149
+
2150
+ async function editModelBudgets(ctx: ExtensionContext, modelId: string): Promise<boolean> {
2151
+ let changed = false;
2152
+ for (;;) {
2153
+ const opts = (config.modelOptions[modelId] ??= {});
2154
+ opts.thinkingBudgets ??= {};
2155
+ const b = opts.thinkingBudgets;
2156
+ const action = await selectFrom(ctx, `🧠 Budgets for ${modelId}`, [
2157
+ { value: "minimal", label: `minimal: ${b.minimal ?? "—"}`, description: "pi thinking level → llama.cpp thinking_budget_tokens" },
2158
+ { value: "low", label: `low: ${b.low ?? "—"}`, description: "" },
2159
+ { value: "medium", label: `medium: ${b.medium ?? "—"}`, description: "" },
2160
+ { value: "high", label: `high: ${b.high ?? "—"}`, description: "xhigh/max clamp to this value" },
2161
+ { value: "clear", label: "🗑️ Clear all budgets for this model", description: "" },
2162
+ { value: "__back", label: "← Back", description: "" },
2163
+ ]);
2164
+ if (action === undefined || action === "__back") return changed;
2165
+
2166
+ if (action === "clear") {
2167
+ delete config.modelOptions[modelId];
2168
+ ctx.ui.notify(`🗑️ Budgets cleared for ${modelId}`, "info");
2169
+ return true;
2170
+ }
2171
+
2172
+ const current = b[action as keyof ThinkingBudgets];
2173
+ const value = await selectFrom(ctx, `🧠 ${action} budget (tokens)`, [
2174
+ ...(current !== undefined ? [{ value: -1, label: "❌ Clear this level", description: "fall back to pi's global budget" }] : []),
2175
+ { value: 512, label: "512" },
2176
+ { value: 1024, label: "1,024" },
2177
+ { value: 2048, label: "2,048" },
2178
+ { value: 4096, label: "4,096" },
2179
+ { value: 8192, label: "8,192" },
2180
+ { value: 16384, label: "16,384" },
2181
+ { value: 32768, label: "32,768" },
2182
+ { value: 65536, label: "65,536" },
2183
+ { value: -2, label: "✏️ Custom value…", description: "enter any token count" },
2184
+ ]);
2185
+ if (value === undefined) continue;
2186
+ if (value === -1) {
2187
+ delete b[action as keyof ThinkingBudgets];
2188
+ changed = true;
2189
+ ctx.ui.notify(`❌ ${action} budget cleared`, "info");
2190
+ } else if (value === -2) {
2191
+ const raw = await ctx.ui.input("✏️ Custom budget (tokens)", String(current ?? 4096));
2192
+ if (raw === undefined) continue;
2193
+ const parsed = parseInt(raw.trim(), 10);
2194
+ if (isNaN(parsed) || parsed < 0) {
2195
+ ctx.ui.notify("❌ Invalid token count", "error");
2196
+ continue;
2197
+ }
2198
+ b[action as keyof ThinkingBudgets] = parsed;
2199
+ changed = true;
2200
+ ctx.ui.notify(`🧠 ${action} budget = ${parsed}`, "info");
2201
+ } else {
2202
+ b[action as keyof ThinkingBudgets] = value;
2203
+ changed = true;
2204
+ ctx.ui.notify(`🧠 ${action} budget = ${value}`, "info");
2205
+ }
2206
+ if (Object.keys(b).length === 0) delete opts.thinkingBudgets;
2207
+ }
2208
+ }
2209
+
2210
+ // ── Metrics menu ───────────────────────────────────────────────
2211
+
2212
+ async function toggleMetricsMenu(ctx: ExtensionContext) {
2213
+ for (;;) {
2214
+ const action = await selectFrom(ctx, "📈 Live metrics", [
2215
+ { value: "toggle", label: config.settings.metricsEnabled ? "🔴 Disable metrics widget" : "🟢 Enable metrics widget", description: "auto-shows for llamacpp-infra models" },
2216
+ { value: "interval", label: `🔁 Poll interval: ${formatMs(config.settings.metricsPollMs)}`, description: "how often /metrics is fetched" },
2217
+ { value: "__back", label: "← Back", description: "" },
2218
+ ]);
2219
+ if (action === undefined || action === "__back") return;
2220
+ if (action === "toggle") {
2221
+ await toggleMetrics(ctx);
2222
+ } else if (action === "interval") {
2223
+ const v = await selectFrom(ctx, "🔁 Metrics poll interval", [2000, 3000, 5000, 10_000, 15_000].map((ms) => ({ value: ms, label: formatMs(ms) })));
2224
+ if (v !== undefined) {
2225
+ config.settings.metricsPollMs = v;
2226
+ saveConfig(config);
2227
+ stopMetricsPolling(ctx);
2228
+ if (config.settings.metricsEnabled) startMetricsPolling(ctx);
2229
+ ctx.ui.notify(`🔁 Metrics poll interval: ${formatMs(v)}`, "info");
2230
+ }
2231
+ }
2232
+ }
2233
+ }
2234
+
2235
+ // ── Settings menu ──────────────────────────────────────────────
2236
+
2237
+ async function showSettingsMenu(ctx: ExtensionContext) {
2238
+ for (;;) {
2239
+ const s = config.settings;
2240
+ const action = await selectFrom(ctx, "⚙️ Discovery settings", [
2241
+ { value: "timeout", label: `⏱️ Discovery timeout: ${formatMs(s.discoveryTimeoutMs)}`, description: "per-request timeout when probing endpoints" },
2242
+ { value: "interval", label: `🔁 Poll interval: ${formatMs(s.pollIntervalMs)}`, description: "background re-poll while servers load models" },
2243
+ { value: "budget", label: `⏳ Poll budget: ${formatMs(s.pollMaxMs)}`, description: "max total time the background poller runs" },
2244
+ { value: "grace", label: `🌅 Startup grace: ${formatMs(s.startupGraceMs)}`, description: "keep trying at startup while nothing has answered" },
2245
+ { value: "faillimit", label: `💀 Known-good fail limit: ${s.knownGoodFailLimit}`, description: "consecutive failures before a live endpoint is dropped" },
2246
+ { value: "vision", label: `👁️ Vision detection: ${s.detectVision ? "ON" : "OFF"}`, description: "/proc flags + server-reported modalities" },
2247
+ { value: "prefix", label: `🏷️ Prefix model IDs: ${s.prefixModelIds ? "ON" : "OFF"}`, description: 'ids like "host:8081/model" avoid cross-server collisions' },
2248
+ { value: "badges", label: `🏷️ Name badges: ${s.showBadgesInNames ? "ON" : "OFF"}`, description: "append 👁️🚀💤 badges to model names" },
2249
+ { value: "unloaded", label: `💤 Include unloaded router models: ${s.includeUnloadedRouterModels ? "ON" : "OFF"}`, description: "router mode: list models that are not loaded" },
2250
+ { value: "warmup", label: `☕ Header warmup: ${s.warmup ? "ON" : "OFF"}`, description: "pre-cache the system prompt on llama.cpp-family servers" },
2251
+ { value: "reset", label: "♻️ Reset all settings to defaults", description: "" },
2252
+ { value: "__back", label: "← Back", description: "" },
2253
+ ]);
2254
+ if (action === undefined || action === "__back") return;
2255
+
2256
+ const pickNumber = async (title: string, options: number[]): Promise<number | undefined> => {
2257
+ return selectFrom(ctx, title, options.map((v) => ({ value: v, label: v >= 1000 ? formatMs(v) : `${v}` })));
2258
+ };
2259
+
2260
+ switch (action) {
2261
+ case "timeout": {
2262
+ const v = await pickNumber("⏱️ Discovery timeout", [500, 1000, 1500, 2000, 3000, 5000]);
2263
+ if (v !== undefined) {
2264
+ config.settings.discoveryTimeoutMs = v;
2265
+ saveConfig(config);
2266
+ ctx.ui.notify(`⏱️ Discovery timeout: ${formatMs(v)}`, "info");
2267
+ }
2268
+ break;
2269
+ }
2270
+ case "interval": {
2271
+ const v = await pickNumber("🔁 Poll interval", [2000, 3000, 4000, 5000, 10_000]);
2272
+ if (v !== undefined) {
2273
+ config.settings.pollIntervalMs = v;
2274
+ saveConfig(config);
2275
+ ctx.ui.notify(`🔁 Poll interval: ${formatMs(v)}`, "info");
2276
+ }
2277
+ break;
2278
+ }
2279
+ case "budget": {
2280
+ const v = await pickNumber("⏳ Poll budget", [30_000, 60_000, 90_000, 120_000, 300_000]);
2281
+ if (v !== undefined) {
2282
+ config.settings.pollMaxMs = v;
2283
+ saveConfig(config);
2284
+ ctx.ui.notify(`⏳ Poll budget: ${formatMs(v)}`, "info");
2285
+ }
2286
+ break;
2287
+ }
2288
+ case "grace": {
2289
+ const v = await pickNumber("🌅 Startup grace", [10_000, 20_000, 40_000, 60_000, 120_000]);
2290
+ if (v !== undefined) {
2291
+ config.settings.startupGraceMs = v;
2292
+ saveConfig(config);
2293
+ ctx.ui.notify(`🌅 Startup grace: ${formatMs(v)}`, "info");
2294
+ }
2295
+ break;
2296
+ }
2297
+ case "faillimit": {
2298
+ const v = await pickNumber("💀 Known-good fail limit", [1, 2, 3, 5, 10]);
2299
+ if (v !== undefined) {
2300
+ config.settings.knownGoodFailLimit = v;
2301
+ saveConfig(config);
2302
+ ctx.ui.notify(`💀 Known-good fail limit: ${v}`, "info");
2303
+ }
2304
+ break;
2305
+ }
2306
+ case "vision":
2307
+ config.settings.detectVision = !config.settings.detectVision;
2308
+ saveConfig(config);
2309
+ ctx.ui.notify(`👁️ Vision detection ${config.settings.detectVision ? "ON" : "OFF"}`, "info");
2310
+ await rescan(ctx);
2311
+ break;
2312
+ case "prefix": {
2313
+ if (config.settings.prefixModelIds) {
2314
+ const ok = await ctx.ui.confirm(
2315
+ "🏷️ Prefix model IDs",
2316
+ "Turning the prefix OFF may cause ID collisions when the same model is served on several machines. Continue?",
2317
+ );
2318
+ if (!ok) break;
2319
+ }
2320
+ config.settings.prefixModelIds = !config.settings.prefixModelIds;
2321
+ saveConfig(config);
2322
+ ctx.ui.notify(`🏷️ Prefix model IDs ${config.settings.prefixModelIds ? "ON" : "OFF"}`, "info");
2323
+ await rescan(ctx);
2324
+ break;
2325
+ }
2326
+ case "badges":
2327
+ config.settings.showBadgesInNames = !config.settings.showBadgesInNames;
2328
+ saveConfig(config);
2329
+ ctx.ui.notify(`🏷️ Name badges ${config.settings.showBadgesInNames ? "ON" : "OFF"}`, "info");
2330
+ await rescan(ctx);
2331
+ break;
2332
+ case "unloaded":
2333
+ config.settings.includeUnloadedRouterModels = !config.settings.includeUnloadedRouterModels;
2334
+ saveConfig(config);
2335
+ ctx.ui.notify(`💤 Include unloaded router models ${config.settings.includeUnloadedRouterModels ? "ON" : "OFF"}`, "info");
2336
+ await rescan(ctx);
2337
+ break;
2338
+ case "warmup":
2339
+ config.settings.warmup = !config.settings.warmup;
2340
+ saveConfig(config);
2341
+ ctx.ui.notify(`☕ Header warmup ${config.settings.warmup ? "ON" : "OFF"}`, "info");
2342
+ break;
2343
+ case "reset": {
2344
+ const ok = await ctx.ui.confirm("♻️ Reset settings", "Restore all discovery settings to their defaults?");
2345
+ if (ok) {
2346
+ config.settings = { ...DEFAULT_SETTINGS };
2347
+ saveConfig(config);
2348
+ ctx.ui.notify("♻️ Settings reset to defaults", "info");
2349
+ await rescan(ctx);
2350
+ }
2351
+ break;
2352
+ }
2353
+ }
2354
+ }
2355
+ }
2356
+
2357
+ async function testConnectivity(ctx: ExtensionContext) {
2358
+ const enabled = config.servers.filter((s) => s.enabled && s.ports.length > 0);
2359
+ if (enabled.length === 0) {
2360
+ ctx.ui.notify("🧪 No enabled servers to test", "warning");
2361
+ return;
2362
+ }
2363
+ ctx.ui.setStatus(STATUS_KEY, "🧪 testing…");
2364
+ const localServers = config.settings.detectVision ? scanLocalServers() : new Map<number, LocalServerInfo>();
2365
+ const all: EndpointResult[] = [];
2366
+ for (const srv of enabled) {
2367
+ const results = await Promise.all(srv.ports.map((port) => fetchModelsFromEndpoint(srv, port, config.settings, localServers)));
2368
+ all.push(...results);
2369
+ }
2370
+ const lines: string[] = ["🧪 Connectivity report", ""];
2371
+ for (const srv of enabled) {
2372
+ const eps = all.filter((e) => e.serverId === srv.id).sort((a, b) => a.port - b.port);
2373
+ const up = eps.filter((e) => e.ok).length;
2374
+ lines.push(`${up === eps.length ? "🟢" : up > 0 ? "🟡" : "🔴"} ${serverLabel(srv)} (${srv.host}) — ${up}/${eps.length}`);
2375
+ for (const e of eps) {
2376
+ if (e.ok) lines.push(` ✅ :${e.port} — ${e.models.length} model(s) · ${e.server}${e.mode === "router" ? " (router)" : ""} · ${e.latencyMs ?? "?"}ms`);
2377
+ else if (e.loading) lines.push(` ⏳ :${e.port} — loading model…`);
2378
+ else lines.push(` ❌ :${e.port} — ${e.error?.slice(0, 70) ?? "unreachable"}`);
2379
+ }
2380
+ }
2381
+ ctx.ui.notify(lines.join("\n"), "info");
2382
+ updateStatusFooter(ctx);
2383
+ }
2384
+
2385
+ // ── Events ─────────────────────────────────────────────────────
2386
+
2387
+ pi.on("session_start", async (_event, ctx) => {
2388
+ if (ctx.hasUI) warmupStatus.bind(ctx.ui);
2389
+ if (config.settings.warmup) warmer.warmupForModel(ctx.model, safeSystemPrompt(ctx), ctx.cwd);
2390
+ currentThinkingLevel = ctx.thinkingLevel;
2391
+ // Metrics poller starts unconditionally: each tick re-checks the active
2392
+ // model and stays idle (no HTTP) while it is not ours. This covers the
2393
+ // race where session_start fires before discovery has registered models.
2394
+ if (config.settings.metricsEnabled) startMetricsPolling(ctx);
2395
+ if (!ctx.hasUI) return;
2396
+ // Re-scan in the background: models that finished loading appear on
2397
+ // their own, without needing /llamacpp-infra scan.
2398
+ void discoverAndRegister().then((r) => {
2399
+ schedulePolling(r.shouldPoll);
2400
+ updateStatusFooter(ctx);
2401
+ });
2402
+ });
2403
+
2404
+ pi.on("model_select", (event, ctx) => {
2405
+ if (event.model.provider !== PROVIDER_NAME) {
2406
+ stopMetricsPolling(ctx);
2407
+ return;
2408
+ }
2409
+ if (ctx.hasUI) warmupStatus.bind(ctx.ui);
2410
+ if (config.settings.warmup) warmer.warmupForModel(event.model, safeSystemPrompt(ctx), ctx.cwd);
2411
+ // New endpoint → reset metrics state and (re)start polling.
2412
+ metricsCurrentKey = undefined;
2413
+ metricsPrev.clear();
2414
+ metricsCurrentApiKey = undefined;
2415
+ if (config.settings.metricsEnabled) startMetricsPolling(ctx);
2416
+ });
2417
+
2418
+ pi.on("thinking_level_select", (event, _ctx) => {
2419
+ currentThinkingLevel = event.level;
2420
+ });
2421
+
2422
+ pi.on("session_shutdown", () => {
2423
+ stopPolling();
2424
+ stopMetricsPolling();
2425
+ warmer.dispose();
2426
+ warmupStatus.dispose();
2427
+ });
2428
+ }