pi-lemonade-link 1.0.2 → 2.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/README.md +68 -21
- package/docs/README.md +23 -0
- package/docs/anthropic.md +183 -6
- package/docs/internal.md +1080 -0
- package/docs/lemonade.md +2453 -1665
- package/docs/llamacpp.md +385 -252
- package/docs/mcp.md +232 -128
- package/docs/ollama.md +967 -15
- package/docs/openai.md +1734 -634
- package/docs/router.md +201 -0
- package/index.ts +2038 -306
- package/lemonade.example.json +24 -2
- package/package.json +3 -3
package/index.ts
CHANGED
|
@@ -9,24 +9,30 @@
|
|
|
9
9
|
* with capability metadata (reasoning / vision / context window) and
|
|
10
10
|
* live loaded-state annotations. Chat-capable models only, by default.
|
|
11
11
|
* 2. Multimodal agent tools — transcribe_audio, generate_image, edit_image,
|
|
12
|
-
* vary_image, upscale_image, text_to_speech
|
|
13
|
-
* generate_3d_model, all backed by
|
|
14
|
-
* Lemonade-specific endpoints. Unloaded
|
|
15
|
-
* container audio formats are converted to
|
|
12
|
+
* vary_image, upscale_image, text_to_speech (incl. voice cloning and
|
|
13
|
+
* voice design), generate_audio, and generate_3d_model, all backed by
|
|
14
|
+
* lemonade's OpenAI-compatible and Lemonade-specific endpoints. Unloaded
|
|
15
|
+
* models auto-load on demand; container audio formats are converted to
|
|
16
|
+
* wav via ffmpeg first.
|
|
16
17
|
* 3. /lemonade-setup — a navigable TUI menu for live status, endpoint
|
|
17
|
-
* configuration, server discovery,
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* configuration, server discovery, and administration: a live model
|
|
19
|
+
* browser (inline status, type-to-search, contextual actions, animated
|
|
20
|
+
* busy markers with esc-to-detach), download-job management (terminal
|
|
21
|
+
* jobs auto-removed), a recipe-options editor (per-model ctx/backend/
|
|
22
|
+
* args, saved without loading), pin/unpin, model updates (check + bulk
|
|
23
|
+
* sync or per-model pull), recipe-backend installs with live progress,
|
|
24
|
+
* Hugging Face search & install, filter, refresh.
|
|
21
25
|
* 4. A lemonade status bar — while a lemonade model is active, an extra
|
|
22
26
|
* below-editor row appears (never touching the footer): instance name,
|
|
23
27
|
* tok/s and prefix-cache hit % derived locally from the session's own
|
|
24
|
-
* messages, plus polled busy/queue,
|
|
25
|
-
* instance.
|
|
28
|
+
* messages, plus polled busy/queue, whole-box active requests + live
|
|
29
|
+
* tok/s, CPU/GPU/NPU and VRAM from the active instance.
|
|
26
30
|
* Switching to a non-lemonade model removes the row.
|
|
27
31
|
* 5. Pulls use lemonade's server-owned download jobs (stream + subscribe:
|
|
28
32
|
* false + GET /v1/downloads) with a live progress view, Esc to cancel —
|
|
29
|
-
* no blocking 30-minute waits.
|
|
33
|
+
* no blocking 30-minute waits. Errored/cancelled jobs are removed from
|
|
34
|
+
* the server's list when the view closes (the spec keeps them until a
|
|
35
|
+
* client does). Blocking pull is kept as a fallback.
|
|
30
36
|
*
|
|
31
37
|
* Nothing is hardcoded: every endpoint lives in ~/.pi/agent/lemonade.json,
|
|
32
38
|
* editable at runtime via /lemonade-setup (changes re-register the provider
|
|
@@ -38,7 +44,7 @@
|
|
|
38
44
|
* are allowed in the config file; note that /lemonade-setup rewrites strip
|
|
39
45
|
* them.
|
|
40
46
|
*
|
|
41
|
-
* Endpoints confirmed against lemonade
|
|
47
|
+
* Endpoints confirmed against lemonade 2026.46.2 (see README for full list).
|
|
42
48
|
*/
|
|
43
49
|
|
|
44
50
|
import * as fs from "node:fs";
|
|
@@ -81,6 +87,24 @@ interface LemonadeConfig {
|
|
|
81
87
|
audioGenerationPath: string;
|
|
82
88
|
mesh3dPath: string;
|
|
83
89
|
classifyPath: string;
|
|
90
|
+
/** LEMONADE_ADMIN_API_KEY — required by /internal/* endpoints (pin, model
|
|
91
|
+
* sync, config) on servers that set one. Empty = fall back to apiKey. */
|
|
92
|
+
adminApiKey: string;
|
|
93
|
+
/** Per-model recipe options: read/save/reset without loading. `{id}` is
|
|
94
|
+
* replaced with the model id. */
|
|
95
|
+
modelOptionsPath: string;
|
|
96
|
+
/** Check downloaded models for newer registry commits (no download). */
|
|
97
|
+
checkUpdatesPath: string;
|
|
98
|
+
/** Pin/unpin a loaded model without reloading it (admin). */
|
|
99
|
+
pinPath: string;
|
|
100
|
+
/** Bulk model-update sync — check + download in one job (admin). */
|
|
101
|
+
modelSyncPath: string;
|
|
102
|
+
/** Install/update a recipe backend (or register a cloud provider). */
|
|
103
|
+
installPath: string;
|
|
104
|
+
/** Resolve backend install metadata without downloading. */
|
|
105
|
+
installDryRunPath: string;
|
|
106
|
+
/** Remove a backend or cloud provider registration. */
|
|
107
|
+
uninstallPath: string;
|
|
84
108
|
beaconPort: number;
|
|
85
109
|
chatOnly: boolean;
|
|
86
110
|
defaultTranscriptionModel: string;
|
|
@@ -108,6 +132,11 @@ interface LemonadeConfig {
|
|
|
108
132
|
// info notice, deduplicated to once per (instance, new release) — the
|
|
109
133
|
// TUI always shows the live truth. Separately toggleable; default on.
|
|
110
134
|
upstreamVersionCheck: boolean;
|
|
135
|
+
// Outage cache: when a probe can't reach an instance, register its last
|
|
136
|
+
// good catalog (persisted on every successful probe) instead of nothing —
|
|
137
|
+
// provenance-marked "(cached <time>)" in /model, warned at session start,
|
|
138
|
+
// and re-synced with a diff notice once the box answers again.
|
|
139
|
+
cacheOnOutage: boolean;
|
|
111
140
|
discoveryTimeoutMs: number;
|
|
112
141
|
beaconTimeoutMs: number;
|
|
113
142
|
loadTimeoutMs: number;
|
|
@@ -126,6 +155,7 @@ export interface ServerEntry {
|
|
|
126
155
|
name: string; // lowercase alnum + hyphens; "default" and duplicate names are reserved
|
|
127
156
|
baseUrl: string;
|
|
128
157
|
apiKey?: string; // falls back to the top-level (shared) apiKey when omitted
|
|
158
|
+
adminApiKey?: string; // falls back to the top-level adminApiKey, then the apiKey
|
|
129
159
|
description?: string; // free-form context for the user's own reminder's sake
|
|
130
160
|
}
|
|
131
161
|
|
|
@@ -154,8 +184,17 @@ const DEFAULT_CONFIG: LemonadeConfig = {
|
|
|
154
184
|
audioGenerationPath: "/v1/audio/generations",
|
|
155
185
|
mesh3dPath: "/v1/3d/generations",
|
|
156
186
|
classifyPath: "/v1/classify",
|
|
187
|
+
adminApiKey: "", // empty = use apiKey for admin endpoints too
|
|
188
|
+
modelOptionsPath: "/v1/models/{id}/options",
|
|
189
|
+
checkUpdatesPath: "/v1/models/check-updates",
|
|
190
|
+
pinPath: "/internal/pin",
|
|
191
|
+
modelSyncPath: "/internal/models/sync",
|
|
192
|
+
installPath: "/v1/install",
|
|
193
|
+
installDryRunPath: "/v1/install/dry-run",
|
|
194
|
+
uninstallPath: "/v1/uninstall",
|
|
157
195
|
beaconPort: 13305,
|
|
158
196
|
chatOnly: true,
|
|
197
|
+
cacheOnOutage: false,
|
|
159
198
|
defaultTranscriptionModel: "Whisper-Large-v3",
|
|
160
199
|
defaultImageModel: "",
|
|
161
200
|
defaultUpscaleModel: "RealESRGAN-x4plus",
|
|
@@ -309,7 +348,12 @@ function listInstances(config: LemonadeConfig): InstanceView[] {
|
|
|
309
348
|
name: s.name,
|
|
310
349
|
description: s.description ?? "",
|
|
311
350
|
isDefault: i === 0,
|
|
312
|
-
config: {
|
|
351
|
+
config: {
|
|
352
|
+
...config,
|
|
353
|
+
baseUrl: s.baseUrl,
|
|
354
|
+
apiKey: s.apiKey || config.apiKey,
|
|
355
|
+
adminApiKey: s.adminApiKey || config.adminApiKey || s.apiKey || config.apiKey,
|
|
356
|
+
},
|
|
313
357
|
}));
|
|
314
358
|
}
|
|
315
359
|
|
|
@@ -323,7 +367,12 @@ function instanceView(config: LemonadeConfig, name?: string): LemonadeConfig | s
|
|
|
323
367
|
if (!target) {
|
|
324
368
|
return `Unknown lemonade instance "${name}". Available instances: ${config.servers.map((s) => s.name).join(", ")}.`;
|
|
325
369
|
}
|
|
326
|
-
return {
|
|
370
|
+
return {
|
|
371
|
+
...config,
|
|
372
|
+
baseUrl: target.baseUrl,
|
|
373
|
+
apiKey: target.apiKey || config.apiKey,
|
|
374
|
+
adminApiKey: target.adminApiKey || config.adminApiKey || target.apiKey || config.apiKey,
|
|
375
|
+
};
|
|
327
376
|
}
|
|
328
377
|
|
|
329
378
|
/** pi provider id for an instance — uniformly `lemonade-<name>` for EVERY
|
|
@@ -343,6 +392,22 @@ interface LemonadeModel {
|
|
|
343
392
|
size?: number;
|
|
344
393
|
downloaded?: boolean;
|
|
345
394
|
max_context_window?: number;
|
|
395
|
+
/** Tokens the model handles in one request: loaded/configured ctx_size,
|
|
396
|
+
* else max_context_window; more current than max_context_window alone. */
|
|
397
|
+
context_length?: number;
|
|
398
|
+
/** Whether the model's registry has a newer commit than the local copy. */
|
|
399
|
+
update_available?: boolean;
|
|
400
|
+
/** Whether the model is recommended for general use. */
|
|
401
|
+
suggested?: boolean;
|
|
402
|
+
checkpoint?: string;
|
|
403
|
+
/** Every checkpoint by role (main, mmproj, vae, …). */
|
|
404
|
+
checkpoints?: Record<string, string>;
|
|
405
|
+
/** The model's own options: registry defaults with saved recipe_options on top. */
|
|
406
|
+
recipe_options?: Record<string, unknown>;
|
|
407
|
+
/** One entry per checkpoint slot: present/required/registry — an
|
|
408
|
+
* optional checkpoint (npu_cache, voicegen, …) can be missing without
|
|
409
|
+
* blocking loads. */
|
|
410
|
+
checkpoint_status?: Array<{ type?: string; present?: boolean; required?: boolean; registry?: string }>;
|
|
346
411
|
}
|
|
347
412
|
|
|
348
413
|
interface LoadedModelInfo {
|
|
@@ -363,6 +428,8 @@ interface LoadedModelInfo {
|
|
|
363
428
|
slot_pool?: string;
|
|
364
429
|
residency_class?: string;
|
|
365
430
|
watchdog_reset?: boolean;
|
|
431
|
+
watchdog_reset_reason?: string;
|
|
432
|
+
launch_command?: Array<string | number>;
|
|
366
433
|
}
|
|
367
434
|
|
|
368
435
|
interface LemonadeHealth {
|
|
@@ -375,6 +442,14 @@ interface LemonadeHealth {
|
|
|
375
442
|
pinned_models?: Record<string, number>;
|
|
376
443
|
pinned_helper_models?: Record<string, number>;
|
|
377
444
|
telemetry?: { enabled?: boolean; captures?: string[] };
|
|
445
|
+
/** Rises whenever the model list changes (downloaded/update_available
|
|
446
|
+
* flip) — a polling client re-reads /v1/models when it moves. */
|
|
447
|
+
models_generation?: number;
|
|
448
|
+
/** The model sync in flight, if any (status/sync_id/completed_sync_id). */
|
|
449
|
+
model_sync?: { status?: string; sync_id?: number; completed_sync_id?: number };
|
|
450
|
+
/** Whether the startup model-update check has finished — until then,
|
|
451
|
+
* per-model update_available flags can be incomplete. */
|
|
452
|
+
update_check_done?: boolean;
|
|
378
453
|
}
|
|
379
454
|
|
|
380
455
|
interface SystemStats {
|
|
@@ -383,6 +458,18 @@ interface SystemStats {
|
|
|
383
458
|
gpu_percent?: number | null;
|
|
384
459
|
vram_gb?: number | null;
|
|
385
460
|
npu_percent?: number | null;
|
|
461
|
+
/** Per-GPU detail when the host reports more than one (id, name,
|
|
462
|
+
* memory_total_gb/memory_used_gb, memory_type, vendor). */
|
|
463
|
+
gpus?: Array<{
|
|
464
|
+
id?: string;
|
|
465
|
+
name?: string;
|
|
466
|
+
gpu_percent?: number;
|
|
467
|
+
memory_total_gb?: number;
|
|
468
|
+
memory_used_gb?: number;
|
|
469
|
+
memory_type?: string;
|
|
470
|
+
vendor?: string;
|
|
471
|
+
}>;
|
|
472
|
+
primary_gpu_id?: string;
|
|
386
473
|
}
|
|
387
474
|
|
|
388
475
|
interface PerfStats {
|
|
@@ -391,6 +478,10 @@ interface PerfStats {
|
|
|
391
478
|
input_tokens?: number;
|
|
392
479
|
output_tokens?: number;
|
|
393
480
|
prompt_tokens?: number | null;
|
|
481
|
+
/** Whole-box liveness (≥2026.46): requests in flight right now, and the
|
|
482
|
+
* server's own live throughput estimate across ALL clients — complements
|
|
483
|
+
* this session's own tok/s, which is timed locally from message events. */
|
|
484
|
+
live?: { active_requests?: number; estimated_tokens_per_second?: number };
|
|
394
485
|
cache_tokens?: number | null;
|
|
395
486
|
request_count_total?: number;
|
|
396
487
|
input_tokens_total?: number;
|
|
@@ -417,13 +508,19 @@ interface SystemInfo {
|
|
|
417
508
|
Processor?: string;
|
|
418
509
|
"Physical Memory"?: string;
|
|
419
510
|
"Windows Power Setting"?: string;
|
|
420
|
-
model_storage?: { path?: string; used_bytes?: number; total_bytes?: number; free_bytes?: number };
|
|
511
|
+
model_storage?: { path?: string; used_bytes?: number; total_bytes?: number; free_bytes?: number; error?: string };
|
|
421
512
|
devices?: {
|
|
422
513
|
cpu?: { name?: string; cores?: number; threads?: number; available?: boolean };
|
|
423
514
|
amd_gpu?: GpuInfo[];
|
|
424
515
|
nvidia_gpu?: GpuInfo[];
|
|
425
516
|
amd_npu?: { name?: string; family?: string; power_mode?: string; tops_max_int?: number; utilization?: number; available?: boolean };
|
|
426
517
|
};
|
|
518
|
+
/** Recipe → backend support matrix (lemonade ≥2026.46): every backend the
|
|
519
|
+
* recipe can use on THIS system, with install state, tier, version. */
|
|
520
|
+
recipes?: Record<string, { default_backend?: string; backends?: Record<string, BackendInfo> }>;
|
|
521
|
+
/** Recipes whose backends are all unavailable/unsupported on this system. */
|
|
522
|
+
unavailable_recipes?: string[];
|
|
523
|
+
cloud?: { providers?: Array<Record<string, unknown>> };
|
|
427
524
|
}
|
|
428
525
|
|
|
429
526
|
interface DiscoveredServer {
|
|
@@ -437,6 +534,7 @@ interface DownloadJob {
|
|
|
437
534
|
model_name?: string;
|
|
438
535
|
status: string; // downloading | paused | cancelled | completed | error
|
|
439
536
|
running?: boolean;
|
|
537
|
+
type?: string; // currently "model" for server-owned jobs
|
|
440
538
|
file?: string;
|
|
441
539
|
file_index?: number;
|
|
442
540
|
total_files?: number;
|
|
@@ -444,7 +542,10 @@ interface DownloadJob {
|
|
|
444
542
|
bytes_total?: number;
|
|
445
543
|
percent?: number;
|
|
446
544
|
cumulative_bytes_downloaded?: number;
|
|
545
|
+
overall_bytes_downloaded?: number;
|
|
447
546
|
total_download_size?: number;
|
|
547
|
+
bytes_previously_downloaded?: number;
|
|
548
|
+
completed_files_bytes?: number;
|
|
448
549
|
complete?: boolean;
|
|
449
550
|
error?: string;
|
|
450
551
|
}
|
|
@@ -456,6 +557,10 @@ interface RegistryResult {
|
|
|
456
557
|
downloads?: number;
|
|
457
558
|
likes?: number;
|
|
458
559
|
tags?: string[];
|
|
560
|
+
has_gguf?: boolean;
|
|
561
|
+
task?: string;
|
|
562
|
+
source?: string;
|
|
563
|
+
repository_type?: string;
|
|
459
564
|
}
|
|
460
565
|
|
|
461
566
|
interface PullVariants {
|
|
@@ -464,6 +569,9 @@ interface PullVariants {
|
|
|
464
569
|
suggested_name?: string;
|
|
465
570
|
suggested_labels?: string[];
|
|
466
571
|
mmproj_files?: string[];
|
|
572
|
+
draft_files?: string[];
|
|
573
|
+
repo_kind?: string;
|
|
574
|
+
source?: string;
|
|
467
575
|
variants: Array<{ name: string; primary_file: string; files: string[]; sharded: boolean; size_bytes: number }>;
|
|
468
576
|
}
|
|
469
577
|
|
|
@@ -583,10 +691,200 @@ async function deleteModel(config: LemonadeConfig, id: string): Promise<string>
|
|
|
583
691
|
return `Deleted ${id} from disk.`;
|
|
584
692
|
}
|
|
585
693
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
694
|
+
// ─── Recipe options, pinning, updates, backends (lemonade ≥2026.46) ─────────
|
|
695
|
+
|
|
696
|
+
/** GET /v1/models/{id}/options — a model's recipe options by layer. */
|
|
697
|
+
interface ModelOptionsLayers {
|
|
698
|
+
model_name?: string;
|
|
699
|
+
recipe?: string;
|
|
700
|
+
saved?: Record<string, unknown>;
|
|
701
|
+
effective?: Record<string, unknown>;
|
|
702
|
+
defaults?: Record<string, unknown>;
|
|
703
|
+
resolved_ctx_size?: number;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Headers for /internal/* endpoints: they demand LEMONADE_ADMIN_API_KEY
|
|
707
|
+
* when the server sets one; otherwise the ordinary key is accepted. The
|
|
708
|
+
* config view already resolved the fallback chain, so this is a plain
|
|
709
|
+
* Bearer of adminApiKey. */
|
|
710
|
+
function adminHeaders(config: LemonadeConfig): Record<string, string> {
|
|
711
|
+
return config.adminApiKey
|
|
712
|
+
? { Authorization: `Bearer ${config.adminApiKey}` }
|
|
713
|
+
: authHeaders(config);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** The server rejects /internal/* calls without its admin key with a plain
|
|
717
|
+
* 403-shaped body — surface that as actionable text, not a raw dump. */
|
|
718
|
+
function adminKeyErrorMessage(config: LemonadeConfig, body: string): string | null {
|
|
719
|
+
return /admin API key/i.test(body)
|
|
720
|
+
? "The server requires LEMONADE_ADMIN_API_KEY for this action, and the configured key is missing or wrong — " +
|
|
721
|
+
"set adminApiKey in ~/.pi/agent/lemonade.json (or Server settings → Admin API key), then retry."
|
|
722
|
+
: null;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function modelOptionsUrl(config: LemonadeConfig, id: string): string {
|
|
726
|
+
return url(config, config.modelOptionsPath.replace("{id}", encodeURIComponent(id)));
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Read a model's recipe options without loading it (saved/effective/defaults). */
|
|
730
|
+
async function fetchModelOptions(config: LemonadeConfig, id: string): Promise<ModelOptionsLayers> {
|
|
731
|
+
const res = await fetch(modelOptionsUrl(config, id), {
|
|
732
|
+
headers: authHeaders(config),
|
|
733
|
+
signal: AbortSignal.timeout(config.discoveryTimeoutMs),
|
|
734
|
+
});
|
|
735
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
736
|
+
return (await res.json()) as ModelOptionsLayers;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Save (merge) recipe options for a model without loading it.
|
|
740
|
+
* `dryRun: true` validates + resolves but persists nothing. */
|
|
741
|
+
async function saveModelOptions(
|
|
742
|
+
config: LemonadeConfig,
|
|
743
|
+
id: string,
|
|
744
|
+
options: Record<string, unknown>,
|
|
745
|
+
dryRun = false
|
|
746
|
+
): Promise<Record<string, unknown>> {
|
|
747
|
+
const res = await fetch(modelOptionsUrl(config, id), {
|
|
748
|
+
method: "POST",
|
|
749
|
+
headers: { ...authHeaders(config), "Content-Type": "application/json" },
|
|
750
|
+
body: JSON.stringify({ ...options, ...(dryRun ? { dry_run: true } : {}) }),
|
|
751
|
+
signal: AbortSignal.timeout(config.discoveryTimeoutMs * 2),
|
|
752
|
+
});
|
|
753
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
754
|
+
return (await res.json()) as Record<string, unknown>;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Reset a model's saved recipe options to defaults. */
|
|
758
|
+
async function resetModelOptions(config: LemonadeConfig, id: string): Promise<void> {
|
|
759
|
+
const res = await fetch(modelOptionsUrl(config, id), {
|
|
760
|
+
method: "DELETE",
|
|
761
|
+
headers: authHeaders(config),
|
|
762
|
+
signal: AbortSignal.timeout(config.discoveryTimeoutMs),
|
|
763
|
+
});
|
|
764
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** POST /internal/pin — pin/unpin a loaded model in place (no reload). */
|
|
768
|
+
async function setPinned(config: LemonadeConfig, id: string, pinned: boolean): Promise<void> {
|
|
769
|
+
const res = await fetch(url(config, config.pinPath), {
|
|
770
|
+
method: "POST",
|
|
771
|
+
headers: { ...adminHeaders(config), "Content-Type": "application/json" },
|
|
772
|
+
body: JSON.stringify({ model_name: id, pinned }),
|
|
773
|
+
signal: AbortSignal.timeout(config.discoveryTimeoutMs),
|
|
774
|
+
});
|
|
775
|
+
if (!res.ok) {
|
|
776
|
+
const body = await res.text();
|
|
777
|
+
throw new Error(adminKeyErrorMessage(config, body) ?? `HTTP ${res.status}: ${body.slice(0, 200)}`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** POST /v1/models/check-updates — report downloaded models with newer
|
|
782
|
+
* upstream commits (no download happens). */
|
|
783
|
+
interface CheckUpdatesResult {
|
|
784
|
+
status?: string;
|
|
785
|
+
updates_available?: number;
|
|
786
|
+
models?: string[];
|
|
787
|
+
failed_models?: Record<string, string>;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function checkModelUpdates(config: LemonadeConfig): Promise<CheckUpdatesResult> {
|
|
791
|
+
const res = await fetch(url(config, config.checkUpdatesPath), {
|
|
792
|
+
method: "POST",
|
|
793
|
+
headers: authHeaders(config),
|
|
794
|
+
signal: AbortSignal.timeout(120_000),
|
|
795
|
+
});
|
|
796
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
797
|
+
return (await res.json()) as CheckUpdatesResult;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** POST /internal/models/sync — bulk update downloaded models from their
|
|
801
|
+
* registries (admin). dryRun reports without downloading. */
|
|
802
|
+
interface ModelSyncResult {
|
|
803
|
+
status?: string;
|
|
804
|
+
sync_id?: number;
|
|
805
|
+
completed_sync_id?: number;
|
|
806
|
+
already_in_progress?: boolean;
|
|
807
|
+
dry_run?: boolean;
|
|
808
|
+
models_updated?: string[];
|
|
809
|
+
models_up_to_date?: string[];
|
|
810
|
+
failed_models?: Record<string, string>;
|
|
811
|
+
checked_count?: number;
|
|
812
|
+
updated_count?: number;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function syncModelUpdates(
|
|
816
|
+
config: LemonadeConfig,
|
|
817
|
+
options: { models?: string[]; dryRun?: boolean } = {}
|
|
818
|
+
): Promise<ModelSyncResult> {
|
|
819
|
+
const res = await fetch(url(config, config.modelSyncPath), {
|
|
820
|
+
method: "POST",
|
|
821
|
+
headers: { ...adminHeaders(config), "Content-Type": "application/json" },
|
|
822
|
+
body: JSON.stringify(options),
|
|
823
|
+
signal: AbortSignal.timeout(300_000),
|
|
824
|
+
});
|
|
825
|
+
if (!res.ok) {
|
|
826
|
+
const body = await res.text();
|
|
827
|
+
throw new Error(adminKeyErrorMessage(config, body) ?? `HTTP ${res.status}: ${body.slice(0, 200)}`);
|
|
828
|
+
}
|
|
829
|
+
return (await res.json()) as ModelSyncResult;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** One backend of a recipe, as system-info reports it. */
|
|
833
|
+
interface BackendInfo {
|
|
834
|
+
state?: "installed" | "installable" | "unsupported" | string;
|
|
835
|
+
tier?: string;
|
|
836
|
+
format?: string;
|
|
837
|
+
version?: string;
|
|
838
|
+
message?: string;
|
|
839
|
+
action?: string;
|
|
840
|
+
can_uninstall?: boolean;
|
|
841
|
+
devices?: string[];
|
|
842
|
+
download_filename?: string;
|
|
843
|
+
release_url?: string;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Install (or update) a recipe backend as a server-owned download job —
|
|
847
|
+
* same /v1/downloads machinery as pulls, so the same progress view tracks it. */
|
|
848
|
+
async function installBackendJob(
|
|
849
|
+
config: LemonadeConfig,
|
|
850
|
+
recipe: string,
|
|
851
|
+
backend: string
|
|
852
|
+
): Promise<DownloadJob> {
|
|
853
|
+
const res = await fetch(url(config, config.installPath), {
|
|
854
|
+
method: "POST",
|
|
855
|
+
headers: { ...authHeaders(config), "Content-Type": "application/json" },
|
|
856
|
+
body: JSON.stringify({ recipe, backend, stream: true, subscribe: false }),
|
|
857
|
+
signal: AbortSignal.timeout(config.discoveryTimeoutMs * 4),
|
|
858
|
+
});
|
|
859
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
860
|
+
return (await res.json()) as DownloadJob;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Resolve what installing a backend would fetch, without downloading. */
|
|
864
|
+
async function installBackendDryRun(
|
|
865
|
+
config: LemonadeConfig,
|
|
866
|
+
recipe: string,
|
|
867
|
+
backend: string
|
|
868
|
+
): Promise<Record<string, unknown>> {
|
|
869
|
+
const res = await fetch(url(config, config.installDryRunPath), {
|
|
870
|
+
method: "POST",
|
|
871
|
+
headers: { ...authHeaders(config), "Content-Type": "application/json" },
|
|
872
|
+
body: JSON.stringify({ recipe, backend }),
|
|
873
|
+
signal: AbortSignal.timeout(config.discoveryTimeoutMs * 4),
|
|
874
|
+
});
|
|
875
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
876
|
+
return (await res.json()) as Record<string, unknown>;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** Uninstall a backend (or remove a cloud provider registration). */
|
|
880
|
+
async function uninstallBackend(config: LemonadeConfig, recipe: string, backend: string): Promise<void> {
|
|
881
|
+
const res = await fetch(url(config, config.uninstallPath), {
|
|
882
|
+
method: "POST",
|
|
883
|
+
headers: { ...authHeaders(config), "Content-Type": "application/json" },
|
|
884
|
+
body: JSON.stringify({ recipe, backend }),
|
|
885
|
+
signal: AbortSignal.timeout(config.discoveryTimeoutMs * 4),
|
|
886
|
+
});
|
|
887
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
590
888
|
}
|
|
591
889
|
|
|
592
890
|
/** Parse "32k", "1m", "262144" → number of tokens. */
|
|
@@ -705,7 +1003,17 @@ async function discoverViaHttp(config: LemonadeConfig): Promise<DiscoveredServer
|
|
|
705
1003
|
* network failure — a hygiene check must never cry wolf on offline
|
|
706
1004
|
* startups: unknown ≠ out of date. */
|
|
707
1005
|
|
|
708
|
-
const DOCS_FILES = [
|
|
1006
|
+
const DOCS_FILES = [
|
|
1007
|
+
"README.md",
|
|
1008
|
+
"anthropic.md",
|
|
1009
|
+
"internal.md",
|
|
1010
|
+
"lemonade.md",
|
|
1011
|
+
"llamacpp.md",
|
|
1012
|
+
"mcp.md",
|
|
1013
|
+
"ollama.md",
|
|
1014
|
+
"openai.md",
|
|
1015
|
+
"router.md",
|
|
1016
|
+
];
|
|
709
1017
|
const DOCS_UPSTREAM_BASE = "https://github.com/lemonade-sdk/lemonade/blob/main/docs/api/";
|
|
710
1018
|
const DOCS_GH_CONTENTS = (file: string) =>
|
|
711
1019
|
`https://api.github.com/repos/lemonade-sdk/lemonade/contents/docs/api/${file}`;
|
|
@@ -752,6 +1060,105 @@ function saveUpstreamSnapshot(snap: UpstreamVersionSnapshot): void {
|
|
|
752
1060
|
}
|
|
753
1061
|
}
|
|
754
1062
|
|
|
1063
|
+
// ─── Outage catalog cache (cacheOnOutage) ────────────────────────────────
|
|
1064
|
+
|
|
1065
|
+
interface CatalogCacheEntry {
|
|
1066
|
+
fetchedAt: number; // epoch ms of the successful probe this snapshot came from
|
|
1067
|
+
catalog: LemonadeModel[];
|
|
1068
|
+
health: LemonadeHealth | null;
|
|
1069
|
+
/** Durable last-known-state: the most recent probe pass for this instance
|
|
1070
|
+
* FAILED. Gates instant cache-serving (hybrid SWR) at the next activation —
|
|
1071
|
+
* an instance known down serves its cached listing with zero probe wait,
|
|
1072
|
+
* then reconciles in the background. Cleared on every successful probe.
|
|
1073
|
+
* Only meaningful alongside a catalog: a snapshotless instance is never
|
|
1074
|
+
* flagged (there is nothing to serve anyway). */
|
|
1075
|
+
unreachableAtLastProbe?: boolean;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
interface CatalogCacheFile {
|
|
1079
|
+
instances: Record<string, CatalogCacheEntry>;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function catalogCachePath(): string {
|
|
1083
|
+
return path.join(getAgentDir(), "cache", "pi-lemonade-link", "catalog-cache.json");
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Defensively validated load — a corrupt/absent cache is an empty cache,
|
|
1087
|
+
* never a load failure. */
|
|
1088
|
+
function loadCatalogCache(): CatalogCacheFile {
|
|
1089
|
+
try {
|
|
1090
|
+
const raw = JSON.parse(fs.readFileSync(catalogCachePath(), "utf8")) as CatalogCacheFile;
|
|
1091
|
+
if (!raw || typeof raw !== "object" || typeof raw.instances !== "object" || raw.instances === null) {
|
|
1092
|
+
return { instances: {} };
|
|
1093
|
+
}
|
|
1094
|
+
const instances: Record<string, CatalogCacheEntry> = {};
|
|
1095
|
+
for (const [name, e] of Object.entries(raw.instances)) {
|
|
1096
|
+
if (
|
|
1097
|
+
e && typeof e === "object" && typeof e.fetchedAt === "number" && Array.isArray(e.catalog) &&
|
|
1098
|
+
e.catalog.every((m) => m && typeof (m as LemonadeModel).id === "string")
|
|
1099
|
+
) {
|
|
1100
|
+
instances[name] = {
|
|
1101
|
+
fetchedAt: e.fetchedAt,
|
|
1102
|
+
catalog: e.catalog as LemonadeModel[],
|
|
1103
|
+
health: (e.health ?? null) as LemonadeHealth | null,
|
|
1104
|
+
...(e.unreachableAtLastProbe === true ? { unreachableAtLastProbe: true } : {}),
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
return { instances };
|
|
1109
|
+
} catch {
|
|
1110
|
+
return { instances: {} };
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function saveCatalogCacheEntry(name: string, entry: CatalogCacheEntry): void {
|
|
1115
|
+
try {
|
|
1116
|
+
const cache = loadCatalogCache();
|
|
1117
|
+
cache.instances[name] = entry;
|
|
1118
|
+
fs.mkdirSync(path.dirname(catalogCachePath()), { recursive: true });
|
|
1119
|
+
fs.writeFileSync(catalogCachePath(), JSON.stringify(cache, null, 2) + "\n");
|
|
1120
|
+
} catch {
|
|
1121
|
+
/* cache dir unwritable → the outage fallback simply has nothing to serve */
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** Mark the durable last-known-state WITHOUT touching the snapshot: the
|
|
1126
|
+
* most recent probe for this instance failed (true) / succeeded (false).
|
|
1127
|
+
* Keeps the catalog/fetchedAt/health of the last successful probe — the
|
|
1128
|
+
* flag and the snapshot age independently by design. */
|
|
1129
|
+
function flagCatalogCacheEntry(name: string, unreachableAtLastProbe: boolean): void {
|
|
1130
|
+
try {
|
|
1131
|
+
const cache = loadCatalogCache();
|
|
1132
|
+
const entry = cache.instances[name];
|
|
1133
|
+
if (!entry) return; // no snapshot → nothing to serve, flag would be meaningless
|
|
1134
|
+
entry.unreachableAtLastProbe = unreachableAtLastProbe;
|
|
1135
|
+
fs.mkdirSync(path.dirname(catalogCachePath()), { recursive: true });
|
|
1136
|
+
fs.writeFileSync(catalogCachePath(), JSON.stringify(cache, null, 2) + "\n");
|
|
1137
|
+
} catch {
|
|
1138
|
+
/* unwritable cache — the next activation just probes live instead */
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function clearCatalogCache(): void {
|
|
1143
|
+
try {
|
|
1144
|
+
fs.rmSync(catalogCachePath(), { force: true });
|
|
1145
|
+
} catch {
|
|
1146
|
+
/* nothing to clear / unwritable — loadCatalogCache still returns {} */
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** Human stamp for a cached snapshot: "14:32" same-day, "Sep 12 14:32"
|
|
1151
|
+
* older. The age IS the provenance — users discount old snapshots themselves. */
|
|
1152
|
+
function formatCacheStamp(ts: number): string {
|
|
1153
|
+
const d = new Date(ts);
|
|
1154
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
1155
|
+
const hms = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
1156
|
+
const now = new Date();
|
|
1157
|
+
const sameDay =
|
|
1158
|
+
d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
|
|
1159
|
+
return sameDay ? hms : `${d.toLocaleString("en", { month: "short" })} ${d.getDate()}, ${hms}`;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
755
1162
|
/** The latest published lemonade version, or undefined when unreachable
|
|
756
1163
|
* (unknown ≠ outdated — never cry wolf). */
|
|
757
1164
|
async function latestUpstreamVersion(config: LemonadeConfig): Promise<string | undefined> {
|
|
@@ -822,7 +1229,7 @@ function saveDocsSnapshot(snap: DocsSpecSnapshot): void {
|
|
|
822
1229
|
* config: it is a property of the build — updated with each extension
|
|
823
1230
|
* release after re-verification — never a user preference to move. The
|
|
824
1231
|
* server's own version is probed live from /v1/health; this is the peg. */
|
|
825
|
-
const EXTENSION_CONFIRMED_SERVER_VERSION = "
|
|
1232
|
+
const EXTENSION_CONFIRMED_SERVER_VERSION = "2026.46.2";
|
|
826
1233
|
|
|
827
1234
|
/** This extension's own version, from package.json (kept beside the
|
|
828
1235
|
* provider.apiVerifiedAgainst record — see the README versioning
|
|
@@ -839,11 +1246,20 @@ const PKG_VERSION = (() => {
|
|
|
839
1246
|
}
|
|
840
1247
|
})();
|
|
841
1248
|
|
|
1249
|
+
/** Canonical comparable form of a lemonade version: strips dev-build
|
|
1250
|
+
* suffixes ("2026.45.0~1719.034d3f52" → "2026.45.0") so a dev build of a
|
|
1251
|
+
* release compares against that release. Lemonade switched to calendar
|
|
1252
|
+
* versioning (2026.MM.patch) after 11.7.0; both dotted forms compare. Pure. */
|
|
1253
|
+
function canonicalVersion(v: string): string {
|
|
1254
|
+
return v.split("~")[0].trim();
|
|
1255
|
+
}
|
|
1256
|
+
|
|
842
1257
|
/** Compare dotted numeric versions: >0 if a is newer than b, <0 if older,
|
|
843
|
-
* null when either side isn't dotted-numeric (no direction, just "differs").
|
|
1258
|
+
* null when either side isn't dotted-numeric (no direction, just "differs").
|
|
1259
|
+
* Dev-build suffixes are canonicalized away first. Pure. */
|
|
844
1260
|
function versionCompare(a: string, b: string): number | null {
|
|
845
|
-
const pa = a.split(".").map(Number);
|
|
846
|
-
const pb = b.split(".").map(Number);
|
|
1261
|
+
const pa = canonicalVersion(a).split(".").map(Number);
|
|
1262
|
+
const pb = canonicalVersion(b).split(".").map(Number);
|
|
847
1263
|
if (pa.some(Number.isNaN) || pb.some(Number.isNaN)) return null;
|
|
848
1264
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
849
1265
|
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
@@ -882,7 +1298,11 @@ const serverVersionsSeen = new Map<string, string>();
|
|
|
882
1298
|
function recordServerVersion(config: LemonadeConfig, instance: string, version: string | undefined): void {
|
|
883
1299
|
const i = serverVersionMismatches.findIndex((m) => m.instance === instance);
|
|
884
1300
|
const cmp = version ? versionCompare(version, EXTENSION_CONFIRMED_SERVER_VERSION) : null;
|
|
885
|
-
|
|
1301
|
+
// Compare the canonical form: a dev build of the verified release
|
|
1302
|
+
// ("2026.46.2~1719.034d3f52") IS that release for endpoint purposes —
|
|
1303
|
+
// only a genuinely different version warns.
|
|
1304
|
+
const mismatched =
|
|
1305
|
+
config.serverVersionCheck && !!version && canonicalVersion(version) !== EXTENSION_CONFIRMED_SERVER_VERSION;
|
|
886
1306
|
if (version) serverVersionsSeen.set(instance, version);
|
|
887
1307
|
else serverVersionsSeen.delete(instance);
|
|
888
1308
|
if (mismatched && version) {
|
|
@@ -1066,59 +1486,133 @@ function registerSpecDriftWarnings(pi: ExtensionAPI, configRef: { current: Lemon
|
|
|
1066
1486
|
|
|
1067
1487
|
// ─── Provider registration ──────────────────────────────────────────────────
|
|
1068
1488
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1489
|
+
/** One instance's registration-pass probe: catalog + health fetched in
|
|
1490
|
+
* parallel (null = unreachable). Probes are fetched once per pass — all
|
|
1491
|
+
* instances at once — and shared by every consumer of that pass (provider
|
|
1492
|
+
* registration AND the tool-registration classifier hint), so a full
|
|
1493
|
+
* activation costs one network round per instance even though two
|
|
1494
|
+
* consumers need the catalog: an unreachable box stalls startup once,
|
|
1495
|
+
* not once per consumer, and a fleet of instances stalls in parallel,
|
|
1496
|
+
* not serially. */
|
|
1497
|
+
export interface InstanceProbe {
|
|
1498
|
+
view: InstanceView;
|
|
1499
|
+
catalog: LemonadeModel[] | null;
|
|
1500
|
+
health: LemonadeHealth | null;
|
|
1501
|
+
/** Set when catalog/health come from the on-disk outage cache rather
|
|
1502
|
+
* than a live probe (see withCacheFallback): the epoch ms of the fetch
|
|
1503
|
+
* the cache entry was written at. */
|
|
1504
|
+
cachedAt?: number;
|
|
1077
1505
|
}
|
|
1078
1506
|
|
|
1079
|
-
function
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1507
|
+
async function probeInstance(view: InstanceView): Promise<InstanceProbe> {
|
|
1508
|
+
try {
|
|
1509
|
+
const [catalog, health] = await Promise.all([
|
|
1510
|
+
fetchCatalog(view.config),
|
|
1511
|
+
fetchHealth(view.config),
|
|
1512
|
+
]);
|
|
1513
|
+
if (view.config.cacheOnOutage) {
|
|
1514
|
+
// Fresh truth — persist the snapshot the outage fallback will serve
|
|
1515
|
+
// if a future probe can't reach this box, and clear the down-flag.
|
|
1516
|
+
saveCatalogCacheEntry(view.name, { fetchedAt: Date.now(), catalog, health, unreachableAtLastProbe: false });
|
|
1517
|
+
}
|
|
1518
|
+
return { view, catalog, health };
|
|
1519
|
+
} catch {
|
|
1520
|
+
// Unreachable. What that means for /model depends on the outage cache
|
|
1521
|
+
// (withCacheFallback decides right after) — here we only report the
|
|
1522
|
+
// network truth: the box did not answer within discoveryTimeoutMs.
|
|
1523
|
+
// Durable last-known-state for the hybrid SWR activation path.
|
|
1524
|
+
if (view.config.cacheOnOutage) flagCatalogCacheEntry(view.name, true);
|
|
1525
|
+
console.error(`[pi-lemonade-link] instance "${view.name}" unreachable at ${view.config.baseUrl}`);
|
|
1526
|
+
return { view, catalog: null, health: null };
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/** Probe every configured instance in one parallel pass. */
|
|
1531
|
+
function probeInstances(config: LemonadeConfig): Promise<InstanceProbe[]> {
|
|
1532
|
+
return Promise.all(listInstances(config).map(probeInstance));
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/** Outage fallback (cacheOnOutage): an unreachable instance serves its last
|
|
1536
|
+
* successfully cached catalog — provenance-marked "(cached <stamp>)" at
|
|
1537
|
+
* registration time — instead of listing nothing. Live truth always wins:
|
|
1538
|
+
* reachable instances pass through untouched, and a box with no cached
|
|
1539
|
+
* snapshot yet still lists nothing (no cache, nothing honest to serve).
|
|
1540
|
+
* Idempotent: probes that already carry a cached catalog pass through. */
|
|
1541
|
+
function withCacheFallback(config: LemonadeConfig, probes: InstanceProbe[]): InstanceProbe[] {
|
|
1542
|
+
if (!config.cacheOnOutage) return probes;
|
|
1543
|
+
const cache = loadCatalogCache();
|
|
1544
|
+
return probes.map((p) => {
|
|
1545
|
+
if (p.catalog) return p;
|
|
1546
|
+
const entry = cache.instances[p.view.name];
|
|
1547
|
+
if (!entry || !entry.catalog.length) return p;
|
|
1548
|
+
return { ...p, catalog: entry.catalog, health: entry.health, cachedAt: entry.fetchedAt };
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/** Hybrid stale-while-revalidate probe pass (cacheOnOutage): instances whose
|
|
1553
|
+
* LAST probe failed (durable flag, see flagCatalogCacheEntry) serve their
|
|
1554
|
+
* cached catalog instantly — no probe wait, no discoveryTimeoutMs stall —
|
|
1555
|
+
* while the rest probe live in one parallel pass (failures still get the
|
|
1556
|
+
* withCacheFallback safety net, so the transition INTO an outage is the
|
|
1557
|
+
* only stall left). The caller reconciles instant-served instances with a
|
|
1558
|
+
* background probe pass. Without the flag (or without a snapshot)
|
|
1559
|
+
* everything probes live: probe-first is both the honest and the fast path
|
|
1560
|
+
* when the box was up last time. */
|
|
1561
|
+
async function instantOrLiveProbes(config: LemonadeConfig): Promise<InstanceProbe[]> {
|
|
1562
|
+
if (!config.cacheOnOutage) return await probeInstances(config);
|
|
1563
|
+
const cache = loadCatalogCache();
|
|
1564
|
+
const instant: InstanceProbe[] = [];
|
|
1565
|
+
const live: InstanceView[] = [];
|
|
1566
|
+
for (const view of listInstances(config)) {
|
|
1567
|
+
const entry = cache.instances[view.name];
|
|
1568
|
+
if (entry && entry.catalog.length && entry.unreachableAtLastProbe) {
|
|
1569
|
+
instant.push({ view, catalog: entry.catalog, health: entry.health, cachedAt: entry.fetchedAt });
|
|
1570
|
+
} else {
|
|
1571
|
+
live.push(view);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
if (!instant.length) return withCacheFallback(config, await probeInstances(config));
|
|
1575
|
+
return withCacheFallback(config, [...instant, ...(await Promise.all(live.map(probeInstance)))]);
|
|
1098
1576
|
}
|
|
1099
1577
|
|
|
1100
|
-
|
|
1578
|
+
/** Instances whose current /model listing is served from the outage cache
|
|
1579
|
+
* (probe failed, cache served): name → when it was cached + the listed
|
|
1580
|
+
* ids. Single source of truth: every registration pass (registerInstanceProvider
|
|
1581
|
+
* records; registerLemonadeProvider resets) — same discipline as
|
|
1582
|
+
* serverVersionMismatches. Drives the session-start warning and the
|
|
1583
|
+
* background re-sync's recovery diff. */
|
|
1584
|
+
const cachedServing = new Map<string, { fetchedAt: number; ids: string[] }>();
|
|
1585
|
+
|
|
1586
|
+
/** Register one instance's provider from a probe that already ran — no
|
|
1587
|
+
* network here; the probe pass owns all registration-time I/O. */
|
|
1588
|
+
function registerInstanceProvider(pi: ExtensionAPI, probe: InstanceProbe): number {
|
|
1589
|
+
const view = probe.view;
|
|
1101
1590
|
const config = view.config;
|
|
1102
1591
|
const providerId = instanceProviderId(view);
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1592
|
+
const catalog = probe.catalog ?? [];
|
|
1593
|
+
const loaded = new Set((probe.health?.all_models_loaded ?? []).map((m) => m.model_name));
|
|
1594
|
+
const cachedStamp = probe.cachedAt !== undefined ? formatCacheStamp(probe.cachedAt) : undefined;
|
|
1595
|
+
if (cachedStamp) {
|
|
1596
|
+
// Outage-cache serving: the listing is a provenance-marked snapshot, not
|
|
1597
|
+
// live truth — record it so the session-start warning and the background
|
|
1598
|
+
// re-sync know this instance is listed from cache.
|
|
1599
|
+
console.error(
|
|
1600
|
+
`[pi-lemonade-link] instance "${view.name}" unreachable — serving cached catalog from ${cachedStamp}`
|
|
1601
|
+
);
|
|
1602
|
+
cachedServing.set(view.name, { fetchedAt: probe.cachedAt!, ids: catalog.map((m) => m.id) });
|
|
1603
|
+
recordServerVersion(config, view.name, probe.health?.version); // cached version — self-heals on re-sync
|
|
1604
|
+
} else if (probe.catalog) {
|
|
1605
|
+
cachedServing.delete(view.name);
|
|
1606
|
+
recordServerVersion(config, view.name, probe.health?.version);
|
|
1607
|
+
} else {
|
|
1608
|
+
// Unreachable with no cache to serve: contribute nothing to /model. No
|
|
1609
|
+
// placeholders: an unreachable model is not listable. Sessions pinned to
|
|
1610
|
+
// a lemonade model will hard-fail model resolution until the box is
|
|
1611
|
+
// reachable again — the honest tradeoff when there is no cached truth.
|
|
1119
1612
|
console.error(
|
|
1120
|
-
`[pi-lemonade-link] instance "${view.name}"
|
|
1613
|
+
`[pi-lemonade-link] instance "${view.name}" has no cached catalog — registering no models`
|
|
1121
1614
|
);
|
|
1615
|
+
cachedServing.delete(view.name);
|
|
1122
1616
|
}
|
|
1123
1617
|
|
|
1124
1618
|
const advertised = config.chatOnly ? catalog.filter(isChatModel) : catalog;
|
|
@@ -1135,12 +1629,53 @@ async function registerInstanceProvider(pi: ExtensionAPI, view: InstanceView): P
|
|
|
1135
1629
|
apiKey: config.apiKey || "lemonade",
|
|
1136
1630
|
authHeader: true,
|
|
1137
1631
|
api: "openai-completions",
|
|
1138
|
-
models: advertised.map((m) => toProviderModel(m, loaded, providerId)),
|
|
1632
|
+
models: advertised.map((m) => toProviderModel(m, loaded, providerId, cachedStamp)),
|
|
1139
1633
|
});
|
|
1140
1634
|
|
|
1141
1635
|
return advertised.length;
|
|
1142
1636
|
}
|
|
1143
1637
|
|
|
1638
|
+
function isChatModel(m: LemonadeModel): boolean {
|
|
1639
|
+
return (
|
|
1640
|
+
(m.labels ?? []).includes("chat") ||
|
|
1641
|
+
m.recipe === "llamacpp" ||
|
|
1642
|
+
// Omni collections bundle a chat LLM with server-side tools (image gen,
|
|
1643
|
+
// TTS) and are driven through chat completions like any chat model.
|
|
1644
|
+
m.recipe === "collection.omni"
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
function toProviderModel(m: LemonadeModel, loaded: Set<string>, providerId = "lemonade", cachedStamp?: string) {
|
|
1649
|
+
const labels = m.labels ?? [];
|
|
1650
|
+
// context_length is the live truth (loaded/configured ctx_size, else the
|
|
1651
|
+
// metadata ceiling); max_context_window is the static ceiling alone.
|
|
1652
|
+
const contextWindow = m.context_length ?? m.max_context_window ?? 128000;
|
|
1653
|
+
const badges = [
|
|
1654
|
+
...(labels.includes("hot") ? ["★"] : []),
|
|
1655
|
+
...(labels.includes("tool-calling") ? ["tools"] : []),
|
|
1656
|
+
...(labels.includes("chat-transcription") ? ["audio-in-chat"] : []),
|
|
1657
|
+
];
|
|
1658
|
+
const badgeText = badges.length ? ` ${badges.join(" ")}` : "";
|
|
1659
|
+
return {
|
|
1660
|
+
id: m.id,
|
|
1661
|
+
name: cachedStamp
|
|
1662
|
+
? `${m.id}${badgeText} (${providerId}, cached ${cachedStamp})`
|
|
1663
|
+
: loaded.has(m.id)
|
|
1664
|
+
? `${m.id}${badgeText} (${providerId}, loaded)`
|
|
1665
|
+
: `${m.id}${badgeText} (${providerId}, on-demand)`,
|
|
1666
|
+
reasoning: labels.includes("reasoning"),
|
|
1667
|
+
input: labels.includes("vision") ? (["text", "image"] as ("text" | "image")[]) : (["text"] as ("text" | "image")[]),
|
|
1668
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
1669
|
+
contextWindow,
|
|
1670
|
+
maxTokens: Math.min(contextWindow, 32768),
|
|
1671
|
+
compat: {
|
|
1672
|
+
supportsDeveloperRole: false,
|
|
1673
|
+
supportsReasoningEffort: false,
|
|
1674
|
+
maxTokensField: "max_tokens" as const,
|
|
1675
|
+
},
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1144
1679
|
/** Provider ids used by past versions of this extension. unregisterProvider
|
|
1145
1680
|
* only works within the running pi process, and ids changed over the
|
|
1146
1681
|
* extension's evolution, so a long-lived session (or /reload after an
|
|
@@ -1150,22 +1685,114 @@ async function registerInstanceProvider(pi: ExtensionAPI, view: InstanceView): P
|
|
|
1150
1685
|
const LEGACY_PROVIDER_IDS = ["lemonade", "lemonade-undefined"];
|
|
1151
1686
|
|
|
1152
1687
|
/** Register (or re-register) every configured instance. Each becomes its own
|
|
1153
|
-
* pi provider under the uniform lemonade-<name> id.
|
|
1154
|
-
|
|
1688
|
+
* pi provider under the uniform lemonade-<name> id. Probes are shared per
|
|
1689
|
+
* pass: pass the caller's probeInstances() result to reuse the network
|
|
1690
|
+
* round (activation does exactly that — one pass feeds both provider and
|
|
1691
|
+
* tool registration); omitted, a fresh parallel pass runs. */
|
|
1692
|
+
async function registerLemonadeProvider(
|
|
1693
|
+
pi: ExtensionAPI,
|
|
1694
|
+
config: LemonadeConfig,
|
|
1695
|
+
probes?: InstanceProbe[]
|
|
1696
|
+
): Promise<number> {
|
|
1155
1697
|
serverVersionMismatches.length = 0; // (re)collected per registration pass
|
|
1156
1698
|
serverVersionsSeen.clear();
|
|
1699
|
+
cachedServing.clear();
|
|
1157
1700
|
for (const staleId of LEGACY_PROVIDER_IDS) {
|
|
1158
1701
|
try {
|
|
1159
1702
|
pi.unregisterProvider(staleId);
|
|
1160
1703
|
} catch { /* never registered; fine */ }
|
|
1161
1704
|
}
|
|
1162
1705
|
let total = 0;
|
|
1163
|
-
for (const
|
|
1164
|
-
total +=
|
|
1706
|
+
for (const probe of probes ?? withCacheFallback(config, await probeInstances(config))) {
|
|
1707
|
+
total += registerInstanceProvider(pi, probe);
|
|
1165
1708
|
}
|
|
1166
1709
|
return total;
|
|
1167
1710
|
}
|
|
1168
1711
|
|
|
1712
|
+
// ─── Outage re-sync (cacheOnOutage) ─────────────────────────────────────
|
|
1713
|
+
|
|
1714
|
+
let resyncBusy = false;
|
|
1715
|
+
let lastResyncProbeAt = 0;
|
|
1716
|
+
|
|
1717
|
+
/** Fire-and-forget outage re-sync: probe all instances and re-register.
|
|
1718
|
+
* Throttled to one probe pass per discoveryTimeoutMs window (turn_start
|
|
1719
|
+
* fires once per turn — agentic loops fire it rapidly) and a no-op while
|
|
1720
|
+
* every instance is live (cachedServing empty). Recovery is announced
|
|
1721
|
+
* only when the live catalog differs from the cached one — state-steady
|
|
1722
|
+
* means silent. force (the setup menu's Refresh connection) bypasses both
|
|
1723
|
+
* guards and always re-registers. Returns the registered model count
|
|
1724
|
+
* (−1 when a pass is already in flight). */
|
|
1725
|
+
async function resyncFromOutage(
|
|
1726
|
+
pi: ExtensionAPI,
|
|
1727
|
+
configRef: { current: LemonadeConfig },
|
|
1728
|
+
notify?: (message: string) => void,
|
|
1729
|
+
force = false
|
|
1730
|
+
): Promise<number> {
|
|
1731
|
+
if (resyncBusy) return -1;
|
|
1732
|
+
if (!force) {
|
|
1733
|
+
if (cachedServing.size === 0) return 0;
|
|
1734
|
+
if (Date.now() - lastResyncProbeAt < configRef.current.discoveryTimeoutMs) return 0;
|
|
1735
|
+
}
|
|
1736
|
+
resyncBusy = true;
|
|
1737
|
+
lastResyncProbeAt = Date.now();
|
|
1738
|
+
try {
|
|
1739
|
+
const cachedBefore = new Map(cachedServing);
|
|
1740
|
+
const probes = await probeInstances(configRef.current);
|
|
1741
|
+
const registered = await registerLemonadeProvider(
|
|
1742
|
+
pi,
|
|
1743
|
+
configRef.current,
|
|
1744
|
+
withCacheFallback(configRef.current, probes)
|
|
1745
|
+
);
|
|
1746
|
+
// Recovery notice — only for instances that answered, and only when
|
|
1747
|
+
// their live catalog actually differs from the served snapshot.
|
|
1748
|
+
for (const [name, before] of cachedBefore) {
|
|
1749
|
+
const recovered = probes.find((p) => p.view.name === name && p.catalog && p.cachedAt === undefined);
|
|
1750
|
+
if (!recovered?.catalog) continue; // still unreachable (or still cache-served)
|
|
1751
|
+
const beforeIds = new Set(before.ids);
|
|
1752
|
+
const afterIds = new Set(recovered.catalog.map((m) => m.id));
|
|
1753
|
+
const added = recovered.catalog.filter((m) => !beforeIds.has(m.id)).map((m) => m.id);
|
|
1754
|
+
const removed = before.ids.filter((id) => !afterIds.has(id));
|
|
1755
|
+
if (!added.length && !removed.length) continue;
|
|
1756
|
+
notify?.(
|
|
1757
|
+
`pi-lemonade-link: instance "${name}" reachable again — re-synced: ` +
|
|
1758
|
+
`${added.length ? `+${added.join(", ")}` : ""}${added.length && removed.length ? "; " : ""}${removed.length ? `−${removed.join(", ")}` : ""}`
|
|
1759
|
+
);
|
|
1760
|
+
}
|
|
1761
|
+
return registered;
|
|
1762
|
+
} finally {
|
|
1763
|
+
resyncBusy = false;
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/** Event wiring for the outage re-sync: prompt submit (turn_start), model
|
|
1768
|
+
* selection, and session start/resume each trigger a throttled,
|
|
1769
|
+
* cached-state-only probe pass (never blocking the turn). The status bar's
|
|
1770
|
+
* own health poll is the free continuous detector (see registerLemonadeBar's
|
|
1771
|
+
* tick). The session-start handler also warns, one line per outage-cached
|
|
1772
|
+
* instance, while the state persists — same shape as the tier-1 version
|
|
1773
|
+
* mismatch warning. */
|
|
1774
|
+
function registerOutageResync(pi: ExtensionAPI, configRef: { current: LemonadeConfig }): void {
|
|
1775
|
+
type ExtCtx = import("@earendil-works/pi-coding-agent").ExtensionContext;
|
|
1776
|
+
const trigger = (ctx: ExtCtx) => {
|
|
1777
|
+
if (cachedServing.size === 0) return; // every instance live — nothing to re-sync
|
|
1778
|
+
void resyncFromOutage(pi, configRef, ctx.hasUI ? (m) => ctx.ui.notify(m, "info") : undefined);
|
|
1779
|
+
};
|
|
1780
|
+
pi.on("turn_start", (_e, ctx) => trigger(ctx as ExtCtx));
|
|
1781
|
+
pi.on("model_select", (_e, ctx) => trigger(ctx as ExtCtx));
|
|
1782
|
+
pi.on("session_start", (_e, ctx) => {
|
|
1783
|
+
trigger(ctx as ExtCtx);
|
|
1784
|
+
if (!(ctx as ExtCtx).hasUI) return;
|
|
1785
|
+
for (const [instance, served] of cachedServing) {
|
|
1786
|
+
(ctx as ExtCtx).ui.notify(
|
|
1787
|
+
`pi-lemonade-link: instance "${instance}" is unreachable — /model lists its cached catalog from ` +
|
|
1788
|
+
`${formatCacheStamp(served.fetchedAt)}; requests to it will fail until the box answers. ` +
|
|
1789
|
+
`It re-syncs automatically on the next prompt, model selection, or session start. cacheOnOutage: false disables.`,
|
|
1790
|
+
"warning"
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1169
1796
|
// ─── Multimodal agent tools ──────────────────────────────────────────────────
|
|
1170
1797
|
|
|
1171
1798
|
/** Combine an operation timeout with the caller's cancel signal (if any),
|
|
@@ -1427,7 +2054,11 @@ function runFfmpeg(args: string[], timeoutMs: number): Promise<void> {
|
|
|
1427
2054
|
});
|
|
1428
2055
|
}
|
|
1429
2056
|
|
|
1430
|
-
async function registerAgentTools(
|
|
2057
|
+
async function registerAgentTools(
|
|
2058
|
+
pi: ExtensionAPI,
|
|
2059
|
+
gconfig: LemonadeConfig,
|
|
2060
|
+
probes?: InstanceProbe[]
|
|
2061
|
+
): Promise<void> {
|
|
1431
2062
|
// Harness-level self-awareness: these lines are merged into the Guidelines
|
|
1432
2063
|
// section of every chat's system prompt, so even a virgin chat knows where
|
|
1433
2064
|
// its lemonade machinery lives without having to hunt for it. (pi dedupes
|
|
@@ -1456,19 +2087,20 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
|
|
|
1456
2087
|
{
|
|
1457
2088
|
const ids = new Set<string>();
|
|
1458
2089
|
const pulled = new Set<string>();
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
2090
|
+
// Share the activation-pass probes when the caller has them (provider
|
|
2091
|
+
// registration just fetched the same catalogs); otherwise one fresh
|
|
2092
|
+
// parallel pass — never a serial per-instance round.
|
|
2093
|
+
for (const probe of probes ?? withCacheFallback(gconfig, await probeInstances(gconfig))) {
|
|
2094
|
+
if (!probe.catalog) {
|
|
2095
|
+
continue; // instance unreachable at registration time — hint stays generic for it
|
|
2096
|
+
}
|
|
2097
|
+
for (const m of probe.catalog.filter(
|
|
2098
|
+
(m) => m.recipe === "onnxruntime" || (m.labels ?? []).includes("classification")
|
|
2099
|
+
)) {
|
|
2100
|
+
ids.add(m.id);
|
|
2101
|
+
if (m.downloaded !== false) pulled.add(m.id);
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
1472
2104
|
if (ids.size) {
|
|
1473
2105
|
classifierHint =
|
|
1474
2106
|
`Classifiers on this server: ${[...ids].join(", ")}. ` +
|
|
@@ -1676,13 +2308,39 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
|
|
|
1676
2308
|
label: "Text to Speech",
|
|
1677
2309
|
description:
|
|
1678
2310
|
"Convert text to spoken audio using a TTS model on the lemonade server " +
|
|
1679
|
-
"(e.g. OpenMOSS-TTS).
|
|
2311
|
+
"(e.g. OpenMOSS-TTS). Supports three voice modes: a named voice, cloning " +
|
|
2312
|
+
"a voice from a reference audio file, or designing one from a free-text " +
|
|
2313
|
+
"description (OpenMOSS models). Saves the audio file and returns its path.",
|
|
1680
2314
|
promptSnippet: "[lemonade] Convert text to spoken audio (TTS), saves mp3/wav to disk",
|
|
1681
2315
|
parameters: Type.Object({
|
|
1682
2316
|
server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
|
|
1683
2317
|
text: Type.String({ description: "The text to speak." }),
|
|
1684
2318
|
model: Type.Optional(Type.String({ description: "TTS model id. Omit to auto-pick from catalog." })),
|
|
1685
|
-
voice: Type.Optional(
|
|
2319
|
+
voice: Type.Optional(
|
|
2320
|
+
Type.String({
|
|
2321
|
+
description:
|
|
2322
|
+
"Voice selection. Kokoro-style models take a named voice ('alloy', " +
|
|
2323
|
+
"'af_sky'); OpenMOSS models take free-text voice or style instruction, " +
|
|
2324
|
+
"e.g. 'a calm, deep male narrator voice'.",
|
|
2325
|
+
})
|
|
2326
|
+
),
|
|
2327
|
+
reference_voice_path: Type.Optional(
|
|
2328
|
+
Type.String({
|
|
2329
|
+
description:
|
|
2330
|
+
"Voice cloning (OpenMOSS models): path to a local WAV sample of the " +
|
|
2331
|
+
"voice to clone, e.g. a few seconds of clean speech. Takes precedence " +
|
|
2332
|
+
"over voice_design_description.",
|
|
2333
|
+
})
|
|
2334
|
+
),
|
|
2335
|
+
voice_design_description: Type.Optional(
|
|
2336
|
+
Type.String({
|
|
2337
|
+
description:
|
|
2338
|
+
"Voice design (OpenMOSS models): describe the voice to invent, e.g. " +
|
|
2339
|
+
"'a warm low female voice with a British accent'. The server renders a " +
|
|
2340
|
+
"short sample in that voice and uses it as the reference. Ignored when " +
|
|
2341
|
+
"reference_voice_path is given.",
|
|
2342
|
+
})
|
|
2343
|
+
),
|
|
1686
2344
|
speed: Type.Optional(Type.Number({ description: "Speaking speed (default 1.0)." })),
|
|
1687
2345
|
response_format: Type.Optional(Type.String({ description: "Output format: mp3 (default), wav, opus, pcm." })),
|
|
1688
2346
|
}),
|
|
@@ -1698,6 +2356,22 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
|
|
|
1698
2356
|
const body: Record<string, unknown> = { model, input: params.text, response_format: fmt };
|
|
1699
2357
|
if (params.voice) body.voice = params.voice;
|
|
1700
2358
|
if (params.speed) body.speed = params.speed;
|
|
2359
|
+
if (params.reference_voice_path) {
|
|
2360
|
+
// Voice cloning: the server wants the sample inline as base64 WAV.
|
|
2361
|
+
try {
|
|
2362
|
+
const refPath = path.resolve(params.reference_voice_path);
|
|
2363
|
+
const refBuf = await readRequiredFile(refPath);
|
|
2364
|
+
body.reference_wav_b64 = refBuf.toString("base64");
|
|
2365
|
+
} catch (error) {
|
|
2366
|
+
return toolError(
|
|
2367
|
+
`Could not read the voice sample '${params.reference_voice_path}': ${
|
|
2368
|
+
error instanceof Error ? error.message : String(error)
|
|
2369
|
+
}`
|
|
2370
|
+
);
|
|
2371
|
+
}
|
|
2372
|
+
} else if (params.voice_design_description) {
|
|
2373
|
+
body.voice_design_description = params.voice_design_description;
|
|
2374
|
+
}
|
|
1701
2375
|
return postJsonSaveBinary(config, config.speechPath, body, "speech", fmt === "pcm" ? "pcm" : fmt, signal, `model: ${model}`);
|
|
1702
2376
|
},
|
|
1703
2377
|
});
|
|
@@ -1716,6 +2390,8 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
|
|
|
1716
2390
|
prompt: Type.String({ description: "Style description: genre, mood, tempo, instruments, voice character." }),
|
|
1717
2391
|
model: Type.Optional(Type.String({ description: "Audio model id (e.g. ThinkSound-SFX, ACE-Step-Music). Omit to auto-pick." })),
|
|
1718
2392
|
duration: Type.Optional(Type.Number({ description: "Clip length in seconds." })),
|
|
2393
|
+
steps: Type.Optional(Type.Number({ description: "Inference steps; lower is faster, higher can improve quality." })),
|
|
2394
|
+
cfg: Type.Optional(Type.Number({ description: "Classifier-free guidance strength (ThinkSound only)." })),
|
|
1719
2395
|
lyrics: Type.Optional(Type.String({ description: "Lyrics to sing (music models only). Omit for instrumental." })),
|
|
1720
2396
|
vocal_language: Type.Optional(Type.String({ description: "BCP-47 language of lyrics, e.g. 'en' (music models only)." })),
|
|
1721
2397
|
seed: Type.Optional(Type.Number({ description: "Random seed for reproducibility." })),
|
|
@@ -1724,12 +2400,14 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
|
|
|
1724
2400
|
const instCfg = instanceView(gconfig, params.server);
|
|
1725
2401
|
if (typeof instCfg === "string") return toolError(instCfg);
|
|
1726
2402
|
const config = instCfg; // instance view — every reference below targets the selected box
|
|
1727
|
-
const auto = await pickModelByLabels(config, ["music", "sfx", "audio", "tts-audio"]);
|
|
2403
|
+
const auto = await pickModelByLabels(config, ["audio-generation", "music", "sfx", "audio", "tts-audio"]);
|
|
1728
2404
|
if (auto === null) return toolError(unreachableMessage(config));
|
|
1729
2405
|
const model = params.model || auto;
|
|
1730
2406
|
if (!model) return toolError("No audio-generation model found on the lemonade server. Pull one first (e.g. ThinkSound-SFX).");
|
|
1731
2407
|
const body: Record<string, unknown> = { model, prompt: params.prompt, response_format: "wav" };
|
|
1732
2408
|
if (params.duration) body.duration = params.duration;
|
|
2409
|
+
if (params.steps) body.steps = params.steps;
|
|
2410
|
+
if (params.cfg) body.cfg = params.cfg;
|
|
1733
2411
|
if (params.lyrics) body.lyrics = params.lyrics;
|
|
1734
2412
|
if (params.vocal_language) body.vocal_language = params.vocal_language;
|
|
1735
2413
|
if (params.seed !== undefined) body.seed = params.seed;
|
|
@@ -1779,12 +2457,15 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
|
|
|
1779
2457
|
name: "classify_text",
|
|
1780
2458
|
label: "Classify Text",
|
|
1781
2459
|
description:
|
|
1782
|
-
"Classify input TEXT with
|
|
1783
|
-
"
|
|
1784
|
-
"
|
|
1785
|
-
"
|
|
1786
|
-
"
|
|
1787
|
-
"
|
|
2460
|
+
"Classify input TEXT with a classifier model on the lemonade server. Two " +
|
|
2461
|
+
"kinds exist: fixed-label encoders (recipe onnxruntime — BERT-family), " +
|
|
2462
|
+
"each answering exactly one question decided by its baked-in label set " +
|
|
2463
|
+
"(the response's labels reveal that universe — if they don't fit the " +
|
|
2464
|
+
"question, retry with a different model), and zero-shot decision models " +
|
|
2465
|
+
"(e.g. LFM2 prompt routers) that REQUIRE the labels parameter to name the " +
|
|
2466
|
+
"choices per request. Text-only by architecture: image questions go to " +
|
|
2467
|
+
"vision-capable chat models (read the image), audio must be transcribed " +
|
|
2468
|
+
"first (transcribe_audio). " +
|
|
1788
2469
|
(classifierHint ||
|
|
1789
2470
|
"Common classifier types: phishing/PII/prompt-injection detection. Pull via /lemonade-setup.") +
|
|
1790
2471
|
" Returns all labels with confidence scores, ranked highest to lowest.",
|
|
@@ -1794,7 +2475,19 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
|
|
|
1794
2475
|
server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
|
|
1795
2476
|
input: Type.String({ description: "The text to classify." }),
|
|
1796
2477
|
model: Type.Optional(
|
|
1797
|
-
Type.String({
|
|
2478
|
+
Type.String({
|
|
2479
|
+
description:
|
|
2480
|
+
"Classifier model id. Omit to auto-pick (prefers pulled fixed-label " +
|
|
2481
|
+
"onnxruntime classifiers). Pass a zero-shot model's id when using labels.",
|
|
2482
|
+
})
|
|
2483
|
+
),
|
|
2484
|
+
labels: Type.Optional(
|
|
2485
|
+
Type.Array(Type.String(), {
|
|
2486
|
+
description:
|
|
2487
|
+
"Zero-shot models only: the labels (candidate answers) to score the " +
|
|
2488
|
+
"input against, e.g. [\"phishing\", \"legitimate\"]. Required by zero-shot " +
|
|
2489
|
+
"decision models; refused by fixed-label encoders — omit it for those.",
|
|
2490
|
+
})
|
|
1798
2491
|
),
|
|
1799
2492
|
top_k: Type.Optional(
|
|
1800
2493
|
Type.Number({ description: "Only return the highest-scoring k labels." })
|
|
@@ -1804,16 +2497,23 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
|
|
|
1804
2497
|
const instCfg = instanceView(gconfig, params.server);
|
|
1805
2498
|
if (typeof instCfg === "string") return toolError(instCfg);
|
|
1806
2499
|
const config = instCfg; // instance view — every reference below targets the selected box
|
|
1807
|
-
|
|
2500
|
+
// Zero-shot requests (labels given) auto-pick a zero-shot decision model
|
|
2501
|
+
// (LFM2 routers, llama.cpp/FLM decision models); fixed-label requests
|
|
2502
|
+
// auto-pick an onnxruntime encoder. An explicit model id always wins.
|
|
2503
|
+
const auto = params.labels?.length
|
|
2504
|
+
? ((await pickModelByLabels(config, ["zero-shot"])) ?? (await pickModelByRecipe(config, "onnxruntime")))
|
|
2505
|
+
: await pickModelByRecipe(config, "onnxruntime");
|
|
1808
2506
|
if (auto === null) return toolError(unreachableMessage(config));
|
|
1809
2507
|
const model = params.model || config.defaultClassifierModel || auto;
|
|
1810
2508
|
if (!model) {
|
|
1811
2509
|
return toolError(
|
|
1812
|
-
"No classifier model
|
|
1813
|
-
"Pull one first, e.g.
|
|
2510
|
+
"No classifier model found on the lemonade server (no onnxruntime " +
|
|
2511
|
+
"encoder and no zero-shot decision model). Pull one first, e.g. " +
|
|
2512
|
+
"Bert-Phishing-ONNX or Phishing-Email-Detection-ONNX."
|
|
1814
2513
|
);
|
|
1815
2514
|
}
|
|
1816
2515
|
const body: Record<string, unknown> = { model, input: params.input };
|
|
2516
|
+
if (params.labels?.length) body.labels = params.labels;
|
|
1817
2517
|
if (params.top_k) body.top_k = params.top_k;
|
|
1818
2518
|
const res = await toolFetch(config, config.classifyPath, {
|
|
1819
2519
|
method: "POST",
|
|
@@ -1870,6 +2570,11 @@ interface BarMetrics {
|
|
|
1870
2570
|
npuPct: number | null;
|
|
1871
2571
|
npuSupported: boolean | null; // null = unknown yet; false = box has no NPU (segment dropped)
|
|
1872
2572
|
vramGb: number | null;
|
|
2573
|
+
/** Whole-box liveness from /v1/stats (≥2026.46): requests in flight and
|
|
2574
|
+
* the server's own live tok/s estimate across ALL clients — null until
|
|
2575
|
+
* the first poll lands (placeholder dots, same as every other segment). */
|
|
2576
|
+
activeReqs: number | null;
|
|
2577
|
+
serverToksPerSec: number | null;
|
|
1873
2578
|
}
|
|
1874
2579
|
|
|
1875
2580
|
/**
|
|
@@ -1883,9 +2588,10 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
1883
2588
|
const KEY = "lemonade-status-bar";
|
|
1884
2589
|
type ExtCtx = import("@earendil-works/pi-coding-agent").ExtensionContext;
|
|
1885
2590
|
|
|
1886
|
-
const metrics: BarMetrics = { toksPerSec: null, cachePct: null, busy: null, cpuPct: null, gpuPct: null, npuPct: null, npuSupported: null, vramGb: null };
|
|
2591
|
+
const metrics: BarMetrics = { toksPerSec: null, cachePct: null, busy: null, cpuPct: null, gpuPct: null, npuPct: null, npuSupported: null, vramGb: null, activeReqs: null, serverToksPerSec: null };
|
|
1887
2592
|
let activeInstance: string | null = null;
|
|
1888
2593
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
2594
|
+
let barCtx: ExtCtx | undefined; // ctx of the installed row — for re-sync notices
|
|
1889
2595
|
let tuiRef: { requestRender(): void } | undefined;
|
|
1890
2596
|
let msgStartAt: number | null = null;
|
|
1891
2597
|
|
|
@@ -1905,6 +2611,8 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
1905
2611
|
metrics.npuPct = null;
|
|
1906
2612
|
metrics.npuSupported = null;
|
|
1907
2613
|
metrics.vramGb = null;
|
|
2614
|
+
metrics.activeReqs = null;
|
|
2615
|
+
metrics.serverToksPerSec = null;
|
|
1908
2616
|
msgStartAt = null; // a stream started under another/removed row must not leak into the next
|
|
1909
2617
|
};
|
|
1910
2618
|
|
|
@@ -1920,10 +2628,11 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
1920
2628
|
tuiRef?.requestRender();
|
|
1921
2629
|
return;
|
|
1922
2630
|
}
|
|
1923
|
-
const [health, sys, metricsBody] = await Promise.all([
|
|
2631
|
+
const [health, sys, metricsBody, perf] = await Promise.all([
|
|
1924
2632
|
fetchHealth(view),
|
|
1925
2633
|
fetchJsonOrNull<SystemStats>(view, "/v1/system-stats"),
|
|
1926
2634
|
fetchTextOrNull(view, "/metrics"),
|
|
2635
|
+
fetchJsonOrNull<PerfStats>(view, "/v1/stats"),
|
|
1927
2636
|
]);
|
|
1928
2637
|
if (activeInstance !== instance) return; // row switched while fetching: discard
|
|
1929
2638
|
if (!health) {
|
|
@@ -1947,6 +2656,26 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
1947
2656
|
metrics.npuSupported = false; // box reported system-stats with no NPU
|
|
1948
2657
|
}
|
|
1949
2658
|
if (typeof sys?.vram_gb === "number") metrics.vramGb = sys.vram_gb;
|
|
2659
|
+
// Whole-box liveness (≥2026.46): requests in flight and the server's own
|
|
2660
|
+
// tok/s estimate across every client — the bar's own tok/s segment stays
|
|
2661
|
+
// session-local; this one says whether the box is streaming for ANYONE.
|
|
2662
|
+
if (perf?.live) {
|
|
2663
|
+
if (typeof perf.live.active_requests === "number") metrics.activeReqs = perf.live.active_requests;
|
|
2664
|
+
if (typeof perf.live.estimated_tokens_per_second === "number") {
|
|
2665
|
+
metrics.serverToksPerSec = perf.live.estimated_tokens_per_second;
|
|
2666
|
+
}
|
|
2667
|
+
} else {
|
|
2668
|
+
// Pre-2026.46 servers have no live block — drop the placeholders.
|
|
2669
|
+
metrics.activeReqs = metrics.activeReqs ?? 0;
|
|
2670
|
+
metrics.serverToksPerSec = metrics.serverToksPerSec ?? 0;
|
|
2671
|
+
}
|
|
2672
|
+
// Free liveness detector: this poll already proves the box answers —
|
|
2673
|
+
// while its /model listing is served from the outage cache, trigger
|
|
2674
|
+
// the throttled re-sync (throttle/in-flight guards live inside
|
|
2675
|
+
// resyncFromOutage; the notice goes through barCtx while installed).
|
|
2676
|
+
if (cachedServing.has(instance)) {
|
|
2677
|
+
void resyncFromOutage(pi, configRef, (m) => barCtx?.ui.notify(m, "info"));
|
|
2678
|
+
}
|
|
1950
2679
|
}
|
|
1951
2680
|
tuiRef?.requestRender();
|
|
1952
2681
|
};
|
|
@@ -1954,6 +2683,7 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
1954
2683
|
const teardown = (ctx: ExtCtx) => {
|
|
1955
2684
|
stopTimer();
|
|
1956
2685
|
tuiRef = undefined;
|
|
2686
|
+
barCtx = undefined;
|
|
1957
2687
|
activeInstance = null;
|
|
1958
2688
|
resetLocal();
|
|
1959
2689
|
try {
|
|
@@ -1967,6 +2697,7 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
1967
2697
|
stopTimer();
|
|
1968
2698
|
resetLocal();
|
|
1969
2699
|
activeInstance = instance;
|
|
2700
|
+
barCtx = ctx;
|
|
1970
2701
|
ctx.ui.setWidget(
|
|
1971
2702
|
KEY,
|
|
1972
2703
|
(tui, theme) => {
|
|
@@ -1989,6 +2720,15 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
1989
2720
|
if (metrics.busy === null) parts.push(dim("…"));
|
|
1990
2721
|
else if (metrics.busy === "idle") parts.push(dim("idle"));
|
|
1991
2722
|
else parts.push(theme.fg("warning", metrics.busy));
|
|
2723
|
+
// Whole-box segment (≥2026.46): in-flight requests + the server's
|
|
2724
|
+
// live tok/s across all clients. 0/0 is honest quiet, not "…".
|
|
2725
|
+
if (metrics.activeReqs !== null) {
|
|
2726
|
+
const liveBits: string[] = [];
|
|
2727
|
+
if (metrics.activeReqs > 0) liveBits.push(`${metrics.activeReqs} active`);
|
|
2728
|
+
if (metrics.serverToksPerSec !== null && metrics.serverToksPerSec > 0)
|
|
2729
|
+
liveBits.push(`${metrics.serverToksPerSec.toFixed(0)} tok/s (box)`);
|
|
2730
|
+
if (liveBits.length) parts.push(theme.fg("warning", liveBits.join(" · ")));
|
|
2731
|
+
}
|
|
1992
2732
|
parts.push(dim(`CPU ${metrics.cpuPct !== null ? `${metrics.cpuPct.toFixed(0)}%` : "…"}`));
|
|
1993
2733
|
parts.push(dim(`GPU ${metrics.gpuPct !== null ? `${metrics.gpuPct.toFixed(0)}%` : "…"}`));
|
|
1994
2734
|
// Boxes without an NPU report npu_percent: null — the segment is
|
|
@@ -2082,6 +2822,8 @@ interface MenuItem<T extends string> {
|
|
|
2082
2822
|
|
|
2083
2823
|
/** Show a SelectList menu framed with DynamicBorder. Returns chosen value or null on Esc. */
|
|
2084
2824
|
function menu<T extends string>(ctx: UiCtx, title: string, items: MenuItem<T>[]): Promise<T | null> {
|
|
2825
|
+
// Long lists get type-to-search for free — same component, one threshold.
|
|
2826
|
+
if (items.length > 12) return filterMenu(ctx, { title, items });
|
|
2085
2827
|
return ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
|
|
2086
2828
|
const container = new Container();
|
|
2087
2829
|
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
@@ -2106,27 +2848,294 @@ function menu<T extends string>(ctx: UiCtx, title: string, items: MenuItem<T>[])
|
|
|
2106
2848
|
});
|
|
2107
2849
|
}
|
|
2108
2850
|
|
|
2109
|
-
/**
|
|
2110
|
-
interface
|
|
2111
|
-
|
|
2851
|
+
/** Options for filterMenu — the live, type-to-search menu. */
|
|
2852
|
+
interface FilterMenuOptions<T extends string> {
|
|
2853
|
+
title: string;
|
|
2854
|
+
items: MenuItem<T>[];
|
|
2855
|
+
/** Rebuild the item list in place while the menu is open (live status).
|
|
2856
|
+
* The filter and the cursor (matched by value) survive a refresh. */
|
|
2857
|
+
refetch?: () => Promise<MenuItem<T>[]>;
|
|
2858
|
+
/** Refresh cadence; default 2000 ms. Ignored without refetch. */
|
|
2112
2859
|
pollMs?: number;
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
/**
|
|
2117
|
-
|
|
2860
|
+
/** Extra single-key actions, tried before typing/filtering; return true when
|
|
2861
|
+
* consumed. The banner line above the list is the natural status outlet. */
|
|
2862
|
+
onKey?: (data: string, api: { setBanner: (t: string) => void }) => boolean;
|
|
2863
|
+
/** Initial banner line (updated via onKey's setBanner). */
|
|
2864
|
+
banner?: string;
|
|
2865
|
+
/** Extra footer hint text. */
|
|
2866
|
+
hint?: string;
|
|
2867
|
+
/** maxVisible; defaults to min(items, 14). */
|
|
2868
|
+
maxVisible?: number;
|
|
2118
2869
|
}
|
|
2119
2870
|
|
|
2120
|
-
/**
|
|
2121
|
-
*
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
)
|
|
2129
|
-
|
|
2871
|
+
/** A menu you can TYPE into: printable input filters items live (substring,
|
|
2872
|
+
* case-insensitive, over label + description); backspace edits; Esc clears
|
|
2873
|
+
* the filter first, then exits. Arrows/Enter go to the list. Optional
|
|
2874
|
+
* refetch keeps items live (inline statuses refresh while you scroll). */
|
|
2875
|
+
function filterMenu<T extends string>(ctx: UiCtx, opts: FilterMenuOptions<T>): Promise<T | null> {
|
|
2876
|
+
return ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
|
|
2877
|
+
const container = new Container();
|
|
2878
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
2879
|
+
const titleText = new Text(theme.fg("accent", theme.bold(opts.title)), 1, 0);
|
|
2880
|
+
container.addChild(titleText);
|
|
2881
|
+
const bannerText = new Text(opts.banner ? theme.fg("muted", opts.banner) : "", 0, 0);
|
|
2882
|
+
container.addChild(bannerText);
|
|
2883
|
+
const filterText = new Text(theme.fg("dim", ""), 0, 0);
|
|
2884
|
+
container.addChild(filterText);
|
|
2885
|
+
const list = new SelectList(opts.items, opts.maxVisible ?? 14, {
|
|
2886
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
2887
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
2888
|
+
description: (t) => theme.fg("muted", t),
|
|
2889
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
2890
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
2891
|
+
});
|
|
2892
|
+
// The list's own confirm path — without this, Enter is a silent no-op
|
|
2893
|
+
// (SelectList fires onSelect only if it exists; arrows keep working,
|
|
2894
|
+
// which is exactly why a missing wire is easy to ship by accident).
|
|
2895
|
+
list.onSelect = (item) => { cleanup(); done(item.value as T); };
|
|
2896
|
+
list.onCancel = () => { cleanup(); done(null); };
|
|
2897
|
+
container.addChild(list);
|
|
2898
|
+
const footer = new Text("", 1, 0);
|
|
2899
|
+
container.addChild(footer);
|
|
2900
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
2901
|
+
|
|
2902
|
+
let filter = "";
|
|
2903
|
+
let items = opts.items;
|
|
2904
|
+
let closed = false;
|
|
2905
|
+
|
|
2906
|
+
// SelectList's declared surface hides its item state (private in the
|
|
2907
|
+
// .d.ts, plain fields at runtime). This narrow structural view is the
|
|
2908
|
+
// minimum needed for live item swaps + substring filtering over
|
|
2909
|
+
// label+description — setFilter() alone only does startsWith-on-value.
|
|
2910
|
+
const listState = list as unknown as {
|
|
2911
|
+
items: MenuItem<T>[];
|
|
2912
|
+
filteredItems: MenuItem<T>[];
|
|
2913
|
+
selectedIndex: number;
|
|
2914
|
+
};
|
|
2915
|
+
|
|
2916
|
+
const applyFilter = (): void => {
|
|
2917
|
+
const f = filter.toLowerCase();
|
|
2918
|
+
const matched = f
|
|
2919
|
+
? items.filter(
|
|
2920
|
+
(it) =>
|
|
2921
|
+
it.label.toLowerCase().includes(f) ||
|
|
2922
|
+
(it.description ?? "").toLowerCase().includes(f)
|
|
2923
|
+
)
|
|
2924
|
+
: items;
|
|
2925
|
+
listState.filteredItems = matched;
|
|
2926
|
+
list.setSelectedIndex(0);
|
|
2927
|
+
filterText.setText(
|
|
2928
|
+
theme.fg("dim", `filter: ${filter}${filter ? " · " : ""}${matched.length}/${items.length} — type to search, ⌫ edit`)
|
|
2929
|
+
);
|
|
2930
|
+
footer.setText(
|
|
2931
|
+
theme.fg(
|
|
2932
|
+
"dim",
|
|
2933
|
+
`↑↓ navigate • enter select • esc ${filter ? "clear filter" : "back"}${opts.hint ? ` • ${opts.hint}` : ""}`
|
|
2934
|
+
)
|
|
2935
|
+
);
|
|
2936
|
+
};
|
|
2937
|
+
applyFilter();
|
|
2938
|
+
|
|
2939
|
+
// Live refresh: swap items in place; keep the filter and the cursor
|
|
2940
|
+
// (matched by value) so nothing jumps while statuses update underneath.
|
|
2941
|
+
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
2942
|
+
const refetch = opts.refetch;
|
|
2943
|
+
if (refetch) {
|
|
2944
|
+
const tickRefresh = async (): Promise<void> => {
|
|
2945
|
+
if (closed) return;
|
|
2946
|
+
try {
|
|
2947
|
+
const fresh = await refetch();
|
|
2948
|
+
if (closed) return;
|
|
2949
|
+
const prevValue = listState.filteredItems[listState.selectedIndex]?.value;
|
|
2950
|
+
items = fresh;
|
|
2951
|
+
listState.items = fresh;
|
|
2952
|
+
applyFilter();
|
|
2953
|
+
if (prevValue !== undefined) {
|
|
2954
|
+
const idx = listState.filteredItems.findIndex((it) => it.value === prevValue);
|
|
2955
|
+
if (idx >= 0) list.setSelectedIndex(idx);
|
|
2956
|
+
}
|
|
2957
|
+
tui.requestRender();
|
|
2958
|
+
} catch {
|
|
2959
|
+
/* a failed refresh keeps the last good snapshot */
|
|
2960
|
+
}
|
|
2961
|
+
};
|
|
2962
|
+
refreshTimer = setInterval(() => void tickRefresh(), Math.max(500, opts.pollMs ?? 2000));
|
|
2963
|
+
void tickRefresh(); // first refresh immediately — a 2s empty-gap on open reads as broken
|
|
2964
|
+
}
|
|
2965
|
+
const cleanup = (): void => {
|
|
2966
|
+
closed = true;
|
|
2967
|
+
if (refreshTimer !== undefined) clearInterval(refreshTimer);
|
|
2968
|
+
};
|
|
2969
|
+
|
|
2970
|
+
return {
|
|
2971
|
+
render: (w) => container.render(w),
|
|
2972
|
+
invalidate: () => container.invalidate(),
|
|
2973
|
+
dispose: cleanup,
|
|
2974
|
+
handleInput: (data) => {
|
|
2975
|
+
try {
|
|
2976
|
+
if (matchesKey(data, Key.backspace)) {
|
|
2977
|
+
filter = filter.slice(0, -1);
|
|
2978
|
+
applyFilter();
|
|
2979
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
2980
|
+
if (filter) {
|
|
2981
|
+
filter = "";
|
|
2982
|
+
applyFilter();
|
|
2983
|
+
} else {
|
|
2984
|
+
cleanup();
|
|
2985
|
+
done(null);
|
|
2986
|
+
}
|
|
2987
|
+
} else if (
|
|
2988
|
+
opts.onKey &&
|
|
2989
|
+
opts.onKey(data, {
|
|
2990
|
+
setBanner: (t) => {
|
|
2991
|
+
bannerText.setText(t ? theme.fg("muted", t) : "");
|
|
2992
|
+
},
|
|
2993
|
+
})
|
|
2994
|
+
) {
|
|
2995
|
+
/* consumed by the caller's key hook — hotkeys run before typing */
|
|
2996
|
+
} else if (
|
|
2997
|
+
// Printable input (single keypress, no ctrl/meta, not an escape
|
|
2998
|
+
// sequence) feeds the filter. Space is typeable; use ctrl-combos
|
|
2999
|
+
// for menu hotkeys so they never collide with search text.
|
|
3000
|
+
data.length >= 1 &&
|
|
3001
|
+
!/[\x00-\x1f\x7f]/.test(data) &&
|
|
3002
|
+
!data.startsWith("\x1b")
|
|
3003
|
+
) {
|
|
3004
|
+
filter += data;
|
|
3005
|
+
applyFilter();
|
|
3006
|
+
} else {
|
|
3007
|
+
list.handleInput(data); // arrows, enter, page keys
|
|
3008
|
+
}
|
|
3009
|
+
tui.requestRender();
|
|
3010
|
+
} catch {
|
|
3011
|
+
/* never let a key crash the process */
|
|
3012
|
+
}
|
|
3013
|
+
},
|
|
3014
|
+
};
|
|
3015
|
+
});
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
/** Braille spinner frames for busyView. */
|
|
3019
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
3020
|
+
|
|
3021
|
+
/** Outcome of a busyView run. */
|
|
3022
|
+
interface BusyOutcome<T> {
|
|
3023
|
+
/** The operation's result on success. */
|
|
3024
|
+
result?: T;
|
|
3025
|
+
/** The operation's error text on failure. */
|
|
3026
|
+
error?: string;
|
|
3027
|
+
/** True when the user pressed Esc and the operation was allowed to keep
|
|
3028
|
+
* running server-side (loads, pulls) — the caller returns to its menu and
|
|
3029
|
+
* live views pick up the state change from polling. */
|
|
3030
|
+
detached: boolean;
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
/** A visible, animated blocking indicator: spinner + label, plus an optional
|
|
3034
|
+
* live status line polled from the server (e.g. a model's load state), and
|
|
3035
|
+
* optional Esc-to-detach for operations that safely continue server-side.
|
|
3036
|
+
* Replaces the invisible setStatus-only wait for anything that can take
|
|
3037
|
+
* more than a moment. */
|
|
3038
|
+
function busyView<T>(
|
|
3039
|
+
ctx: UiCtx,
|
|
3040
|
+
label: string,
|
|
3041
|
+
op: () => Promise<T>,
|
|
3042
|
+
opts: { liveStatus?: () => Promise<string | null>; livePollMs?: number; escDetach?: boolean } = {}
|
|
3043
|
+
): Promise<BusyOutcome<T>> {
|
|
3044
|
+
return ctx.ui.custom<BusyOutcome<T>>((tui, theme, _kb, done) => {
|
|
3045
|
+
const container = new Container();
|
|
3046
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
3047
|
+
const spinnerText = new Text(theme.fg("accent", `${SPINNER_FRAMES[0]} ${label}`), 1, 0);
|
|
3048
|
+
container.addChild(spinnerText);
|
|
3049
|
+
const liveText = new Text("", 0, 0);
|
|
3050
|
+
container.addChild(liveText);
|
|
3051
|
+
container.addChild(
|
|
3052
|
+
new Text(theme.fg("dim", opts.escDetach ? "esc continue in background" : "esc cancel"), 1, 0)
|
|
3053
|
+
);
|
|
3054
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
3055
|
+
|
|
3056
|
+
let finished = false;
|
|
3057
|
+
let frame = 0;
|
|
3058
|
+
const finish = (outcome: BusyOutcome<T>): void => {
|
|
3059
|
+
if (finished) return;
|
|
3060
|
+
finished = true;
|
|
3061
|
+
clearInterval(spinTimer);
|
|
3062
|
+
clearInterval(liveTimer);
|
|
3063
|
+
done(outcome);
|
|
3064
|
+
};
|
|
3065
|
+
|
|
3066
|
+
const spinTimer = setInterval(() => {
|
|
3067
|
+
frame = (frame + 1) % SPINNER_FRAMES.length;
|
|
3068
|
+
spinnerText.setText(theme.fg("accent", `${SPINNER_FRAMES[frame]} ${label}`));
|
|
3069
|
+
tui.requestRender();
|
|
3070
|
+
}, 90);
|
|
3071
|
+
|
|
3072
|
+
let liveTimer: ReturnType<typeof setInterval> | undefined;
|
|
3073
|
+
if (opts.liveStatus) {
|
|
3074
|
+
const pollLive = async (): Promise<void> => {
|
|
3075
|
+
if (finished) return;
|
|
3076
|
+
try {
|
|
3077
|
+
const s = await opts.liveStatus!();
|
|
3078
|
+
if (finished) return;
|
|
3079
|
+
liveText.setText(s ? theme.fg("muted", s) : "");
|
|
3080
|
+
tui.requestRender();
|
|
3081
|
+
} catch {
|
|
3082
|
+
/* keep the last good live line */
|
|
3083
|
+
}
|
|
3084
|
+
};
|
|
3085
|
+
void pollLive();
|
|
3086
|
+
liveTimer = setInterval(() => void pollLive(), Math.max(500, opts.livePollMs ?? 2000));
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
void op()
|
|
3090
|
+
.then((result) => finish({ result, detached: false }))
|
|
3091
|
+
.catch((error) => finish({ error: error instanceof Error ? error.message : String(error), detached: false }));
|
|
3092
|
+
|
|
3093
|
+
return {
|
|
3094
|
+
render: (w) => container.render(w),
|
|
3095
|
+
invalidate: () => container.invalidate(),
|
|
3096
|
+
dispose: () => {
|
|
3097
|
+
finished = true;
|
|
3098
|
+
clearInterval(spinTimer);
|
|
3099
|
+
if (liveTimer !== undefined) clearInterval(liveTimer);
|
|
3100
|
+
},
|
|
3101
|
+
handleInput: (data) => {
|
|
3102
|
+
if (matchesKey(data, Key.escape)) {
|
|
3103
|
+
if (opts.escDetach) {
|
|
3104
|
+
// Leave the operation running; the server keeps its state and
|
|
3105
|
+
// live views pick it up. There is no way back to this promise —
|
|
3106
|
+
// the caller decides how to report the eventual outcome.
|
|
3107
|
+
finish({ detached: true });
|
|
3108
|
+
} else {
|
|
3109
|
+
spinnerText.setText(theme.fg("warning", "⏳ working — this step can't be interrupted, please wait"));
|
|
3110
|
+
tui.requestRender();
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
},
|
|
3114
|
+
};
|
|
3115
|
+
});
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
/** Live-refresh wiring for the scrollable textView (status panel). */
|
|
3119
|
+
interface TextViewLive {
|
|
3120
|
+
/** Poll interval in ms; failed/empty refetches keep the last good snapshot. */
|
|
3121
|
+
pollMs?: number;
|
|
3122
|
+
refetch?: () => Promise<string[]>;
|
|
3123
|
+
/** Extra footer key hint (e.g. "n/p instance"), shown when multiple targets exist. */
|
|
3124
|
+
extraHint?: string;
|
|
3125
|
+
/** Hook for keys beyond scroll/quit; return true when the key was consumed. */
|
|
3126
|
+
onKey?: (data: string, api: { setTitle: (t: string) => void }) => boolean;
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
/** Full-screen framed text view; any key returns. `scrollable` adds keyboard
|
|
3130
|
+
* paging; `live` adds polling + a key hook (scrollable mode only). */
|
|
3131
|
+
async function textView(
|
|
3132
|
+
ctx: UiCtx,
|
|
3133
|
+
title: string,
|
|
3134
|
+
lines: string[],
|
|
3135
|
+
scrollable = false,
|
|
3136
|
+
live?: TextViewLive
|
|
3137
|
+
): Promise<void> {
|
|
3138
|
+
await ctx.ui.custom<null>((tui, theme, _kb, done) => {
|
|
2130
3139
|
const container = new Container();
|
|
2131
3140
|
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
2132
3141
|
const titleText = new Text(theme.fg("accent", theme.bold(title)), 1, 0);
|
|
@@ -2356,6 +3365,15 @@ async function trackDownload(
|
|
|
2356
3365
|
const finish = (v: "complete" | "cancelled" | "error") => {
|
|
2357
3366
|
if (finished) return;
|
|
2358
3367
|
finished = true;
|
|
3368
|
+
// The server keeps errored/cancelled jobs listed until a client removes
|
|
3369
|
+
// them (spec: GET /v1/downloads) — this extension is that client, so a
|
|
3370
|
+
// terminal outcome here also clears the job from the registry. Partial
|
|
3371
|
+
// files stay on disk; a retry resumes from them.
|
|
3372
|
+
if (v === "error" || v === "cancelled") {
|
|
3373
|
+
void controlDownload(config, jobId, "remove").catch(() => {
|
|
3374
|
+
/* removal is best-effort cleanup, never a failure of the outcome */
|
|
3375
|
+
});
|
|
3376
|
+
}
|
|
2359
3377
|
done(v);
|
|
2360
3378
|
};
|
|
2361
3379
|
|
|
@@ -2651,6 +3669,14 @@ function formatStatus(
|
|
|
2651
3669
|
if (typeof perf.output_tokens_total === "number") lifetime.push(`out ${fmtTokens(perf.output_tokens_total)}`);
|
|
2652
3670
|
if (typeof perf.prompt_tokens_total === "number") lifetime.push(`prompt ${fmtTokens(perf.prompt_tokens_total)}`);
|
|
2653
3671
|
if (typeof perf.cache_tokens_total === "number") lifetime.push(`cache ${fmtTokens(perf.cache_tokens_total)}`);
|
|
3672
|
+
if (perf.live && (typeof perf.live.active_requests === "number" || typeof perf.live.estimated_tokens_per_second === "number")) {
|
|
3673
|
+
lines.push(
|
|
3674
|
+
` live: ${perf.live.active_requests ?? 0} active request(s)` +
|
|
3675
|
+
(typeof perf.live.estimated_tokens_per_second === "number"
|
|
3676
|
+
? ` · ${(perf.live.estimated_tokens_per_second || 0).toFixed(1)} tok/s (whole box)`
|
|
3677
|
+
: "")
|
|
3678
|
+
);
|
|
3679
|
+
}
|
|
2654
3680
|
if (lifetime.length) lines.push(`Lifetime: ${lifetime.join(" · ")}`);
|
|
2655
3681
|
if (typeof perf.routing_decisions_total === "number" || typeof perf.routing_switches_total === "number") {
|
|
2656
3682
|
lines.push(
|
|
@@ -2937,6 +3963,751 @@ async function addInstanceFlow(
|
|
|
2937
3963
|
return nameInput;
|
|
2938
3964
|
}
|
|
2939
3965
|
|
|
3966
|
+
// ─── Model browser, downloads, backends, updates (lemonade ≥2026.46 TUI) ────
|
|
3967
|
+
|
|
3968
|
+
/** One live snapshot of everything the model browser shows. */
|
|
3969
|
+
interface BrowserState {
|
|
3970
|
+
catalog: LemonadeModel[];
|
|
3971
|
+
/** Loaded models by name, with load state (status/pinned/busy/backend). */
|
|
3972
|
+
loaded: Map<string, LoadedModelInfo>;
|
|
3973
|
+
/** Server-owned download jobs, or null when the box was unreachable. */
|
|
3974
|
+
jobs: DownloadJob[] | null;
|
|
3975
|
+
/** False when health didn't answer — the snapshot may be partial. */
|
|
3976
|
+
reachable: boolean;
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
async function fetchBrowserState(config: LemonadeConfig): Promise<BrowserState> {
|
|
3980
|
+
const [health, jobs, catalog] = await Promise.all([
|
|
3981
|
+
fetchHealth(config),
|
|
3982
|
+
listDownloads(config),
|
|
3983
|
+
fetchCatalog(config, true).catch(() => [] as LemonadeModel[]),
|
|
3984
|
+
]);
|
|
3985
|
+
const loaded = new Map<string, LoadedModelInfo>();
|
|
3986
|
+
for (const m of health?.all_models_loaded ?? []) loaded.set(m.model_name, m);
|
|
3987
|
+
return { catalog, loaded, jobs, reachable: health != null };
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3990
|
+
/** The download job (if any) currently downloading a model. */
|
|
3991
|
+
function jobForModel(state: BrowserState, id: string): DownloadJob | undefined {
|
|
3992
|
+
return (state.jobs ?? []).find((j) => j.model_name === id && j.status !== "completed" && j.status !== "error" && j.status !== "cancelled");
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3995
|
+
/** Inline status glyph for one catalog row. */
|
|
3996
|
+
function modelGlyph(m: LemonadeModel, state: BrowserState): string {
|
|
3997
|
+
const job = jobForModel(state, m.id);
|
|
3998
|
+
if (job) return job.status === "paused" ? "⏸" : "↓";
|
|
3999
|
+
if (state.loaded.has(m.id)) return "●";
|
|
4000
|
+
if (m.downloaded === false) return "◌";
|
|
4001
|
+
return "○";
|
|
4002
|
+
}
|
|
4003
|
+
|
|
4004
|
+
const MODEL_ORDER = (m: LemonadeModel, state: BrowserState): number => {
|
|
4005
|
+
const job = jobForModel(state, m.id);
|
|
4006
|
+
if (job && job.status !== "paused") return 0; // actively downloading first
|
|
4007
|
+
if (job) return 1; // paused
|
|
4008
|
+
if (state.loaded.has(m.id)) return 2;
|
|
4009
|
+
if (m.downloaded === false) return 4;
|
|
4010
|
+
return 3; // downloaded, not loaded
|
|
4011
|
+
};
|
|
4012
|
+
|
|
4013
|
+
/** Build the browser's live menu items from one snapshot. */
|
|
4014
|
+
function browserItems(state: BrowserState, downloadedOnly: boolean): MenuItem<string>[] {
|
|
4015
|
+
const models = state.catalog
|
|
4016
|
+
.filter((m) => !downloadedOnly || m.downloaded !== false)
|
|
4017
|
+
.sort((a, b) => MODEL_ORDER(a, state) - MODEL_ORDER(b, state) || a.id.localeCompare(b.id));
|
|
4018
|
+
return models.map((m) => {
|
|
4019
|
+
const job = jobForModel(state, m.id);
|
|
4020
|
+
const info = state.loaded.get(m.id);
|
|
4021
|
+
const bits: string[] = [];
|
|
4022
|
+
if (job) bits.push(`${job.status === "paused" ? "download paused" : `downloading ${typeof job.percent === "number" ? job.percent.toFixed(0) : "?"}%`}${typeof job.total_download_size === "number" ? ` of ${formatBytes(job.total_download_size)}` : ""}`);
|
|
4023
|
+
if (info) {
|
|
4024
|
+
bits.push(
|
|
4025
|
+
`loaded (${info.status ?? "ready"}${info.backend_health && info.backend_health !== "ready" ? `, backend ${info.backend_health}` : ""})` +
|
|
4026
|
+
`${info.pinned ? " · pinned" : ""}${info.is_busy || info.is_streaming ? " · busy" : ""}`
|
|
4027
|
+
);
|
|
4028
|
+
}
|
|
4029
|
+
if (m.downloaded === false) bits.push("not downloaded");
|
|
4030
|
+
if (m.update_available) bits.push("⤴ update available");
|
|
4031
|
+
if ((m.labels ?? []).includes("on-demand")) bits.push("on-demand (no load — serves its endpoint directly)");
|
|
4032
|
+
const ctx = m.context_length ?? m.max_context_window;
|
|
4033
|
+
bits.push(
|
|
4034
|
+
[
|
|
4035
|
+
m.size !== undefined ? `${m.size} GB` : undefined,
|
|
4036
|
+
ctx ? `ctx ${ctx.toLocaleString("en-US")}` : undefined,
|
|
4037
|
+
m.recipe,
|
|
4038
|
+
...(m.labels ?? []).filter((l) => ![
|
|
4039
|
+
"chat", "hot", "on-demand", "custom", "image", "edit",
|
|
4040
|
+
].includes(l)).slice(0, 4),
|
|
4041
|
+
]
|
|
4042
|
+
.filter(Boolean)
|
|
4043
|
+
.join(" • ")
|
|
4044
|
+
);
|
|
4045
|
+
return {
|
|
4046
|
+
value: m.id,
|
|
4047
|
+
label: `${modelGlyph(m, state)} ${m.id}${(m.labels ?? []).includes("hot") ? " ★" : ""}${m.update_available ? " ⤴" : ""}`,
|
|
4048
|
+
description: bits.join(" — "),
|
|
4049
|
+
};
|
|
4050
|
+
});
|
|
4051
|
+
}
|
|
4052
|
+
|
|
4053
|
+
/** Full model detail sheet (textView): everything the server knows. */
|
|
4054
|
+
async function modelDetails(ctx: UiCtx, config: LemonadeConfig, m: LemonadeModel, state: BrowserState): Promise<void> {
|
|
4055
|
+
const info = state.loaded.get(m.id);
|
|
4056
|
+
const job = (state.jobs ?? []).find((j) => j.model_name === m.id);
|
|
4057
|
+
const lines: string[] = [
|
|
4058
|
+
`Model: ${m.id}`,
|
|
4059
|
+
`Recipe: ${m.recipe ?? "?"}${(m.labels ?? []).length ? ` · labels: ${m.labels!.join(", ")}` : ""}`,
|
|
4060
|
+
`Checkpoint: ${m.checkpoint ?? "?"}`,
|
|
4061
|
+
...(Object.entries(m.checkpoints ?? {} as Record<string, string>).length
|
|
4062
|
+
? ["Checkpoints:", ...Object.entries(m.checkpoints as Record<string, string>).map(([k, v]) => ` ${k}: ${v}`)]
|
|
4063
|
+
: []),
|
|
4064
|
+
];
|
|
4065
|
+
if (m.checkpoint_status?.length) {
|
|
4066
|
+
lines.push("Checkpoint files:");
|
|
4067
|
+
for (const cs of m.checkpoint_status) {
|
|
4068
|
+
lines.push(` ${cs.type ?? "?"}: ${cs.present ? "present" : "MISSING"}${cs.required === false ? " (optional)" : ""} [${cs.registry ?? "local"}]`);
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
lines.push(
|
|
4072
|
+
`Size: ${m.size !== undefined ? `${m.size} GB` : "?"} · Downloaded: ${m.downloaded === false ? "no" : "yes"}` +
|
|
4073
|
+
(m.update_available ? " · ⤴ UPDATE AVAILABLE" : ""),
|
|
4074
|
+
`Context: ${m.context_length ? `${m.context_length.toLocaleString("en-US")} (live)` : "?"}${m.max_context_window ? ` / max ${m.max_context_window.toLocaleString("en-US")}` : ""}`
|
|
4075
|
+
);
|
|
4076
|
+
if (Object.keys(m.recipe_options ?? {}).length) {
|
|
4077
|
+
lines.push("Recipe options (saved): " + JSON.stringify(m.recipe_options));
|
|
4078
|
+
}
|
|
4079
|
+
if (info) {
|
|
4080
|
+
lines.push(
|
|
4081
|
+
"",
|
|
4082
|
+
"Loaded state:",
|
|
4083
|
+
` status: ${info.status ?? "?"} · device: ${info.device ?? "?"} · backend: ${info.backend_health ?? "?"}`,
|
|
4084
|
+
` pinned: ${info.pinned ? "yes" : "no"} · busy: ${info.is_busy ? "yes" : "no"} · streaming: ${info.is_streaming ? "yes" : "no"}`,
|
|
4085
|
+
...(info.pid ? [` pid: ${info.pid}`] : []),
|
|
4086
|
+
...(info.slot_pool ? [` slot pool: ${info.slot_pool} (${info.residency_class ?? "standard"})`] : []),
|
|
4087
|
+
...(info.launch_command ? [" launch: " + info.launch_command.join(" ")] : []),
|
|
4088
|
+
...(info.watchdog_reset_reason ? [` watchdog reset: ${info.watchdog_reset_reason}`] : [])
|
|
4089
|
+
);
|
|
4090
|
+
}
|
|
4091
|
+
if (job) {
|
|
4092
|
+
lines.push(
|
|
4093
|
+
"",
|
|
4094
|
+
`Download job ${job.id}: ${job.status}${typeof job.percent === "number" ? ` — ${job.percent.toFixed(1)}%` : ""}`,
|
|
4095
|
+
...(job.error ? [` error: ${job.error}`] : [])
|
|
4096
|
+
);
|
|
4097
|
+
}
|
|
4098
|
+
await textView(ctx, `Model — ${m.id}`, lines, true);
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
/** The per-model action menu: everything you can do with a model, offered
|
|
4102
|
+
* contextually from its live state. */
|
|
4103
|
+
async function modelActions(
|
|
4104
|
+
ctx: UiCtx,
|
|
4105
|
+
pi: ExtensionAPI,
|
|
4106
|
+
config: LemonadeConfig,
|
|
4107
|
+
fullConfig: LemonadeConfig,
|
|
4108
|
+
id: string
|
|
4109
|
+
): Promise<void> {
|
|
4110
|
+
for (;;) {
|
|
4111
|
+
const state = await fetchBrowserState(config); // fresh state per action
|
|
4112
|
+
if (!state.reachable) {
|
|
4113
|
+
ctx.ui.notify(`Lemonade server at ${config.baseUrl} is unreachable right now — actions need a live box.`, "error");
|
|
4114
|
+
return;
|
|
4115
|
+
}
|
|
4116
|
+
const m = state.catalog.find((x) => x.id === id);
|
|
4117
|
+
if (!m) {
|
|
4118
|
+
ctx.ui.notify(`"${id}" is no longer in the catalog (deleted or renamed?).`, "warning");
|
|
4119
|
+
return;
|
|
4120
|
+
}
|
|
4121
|
+
const info = state.loaded.get(id);
|
|
4122
|
+
const job = jobForModel(state, id);
|
|
4123
|
+
const labels = m.labels ?? [];
|
|
4124
|
+
const onDemand = labels.includes("on-demand");
|
|
4125
|
+
|
|
4126
|
+
const items: MenuItem<string>[] = [
|
|
4127
|
+
{ value: "view", label: "View details", description: "Checkpoints, context, load state, launch command" },
|
|
4128
|
+
];
|
|
4129
|
+
if (job) {
|
|
4130
|
+
items.push({
|
|
4131
|
+
value: "canceljob",
|
|
4132
|
+
label: `Cancel download (${typeof job.percent === "number" ? `${job.percent.toFixed(0)}%` : "?"})`,
|
|
4133
|
+
description: "Partial files stay on disk; a later pull resumes from them",
|
|
4134
|
+
});
|
|
4135
|
+
}
|
|
4136
|
+
if (!info && !onDemand && m.downloaded !== false) {
|
|
4137
|
+
items.push({ value: "load", label: "Load into memory", description: "May take minutes — esc continues in background" });
|
|
4138
|
+
}
|
|
4139
|
+
if (info) {
|
|
4140
|
+
items.push({ value: "unload", label: "Unload", description: "Free its slot (evictable again)" });
|
|
4141
|
+
items.push({
|
|
4142
|
+
value: "pin",
|
|
4143
|
+
label: info.pinned ? "Unpin" : "Pin",
|
|
4144
|
+
description: info.pinned
|
|
4145
|
+
? "Pinned models are skipped by LRU eviction — unpin to make it evictable"
|
|
4146
|
+
: "Skip LRU eviction while loaded (admin key)",
|
|
4147
|
+
});
|
|
4148
|
+
}
|
|
4149
|
+
if (m.downloaded === false && !job) {
|
|
4150
|
+
items.push({ value: "pull", label: "Pull (download)", description: m.size !== undefined ? `~${m.size} GB — live progress, esc cancels` : "Live progress, esc cancels" });
|
|
4151
|
+
}
|
|
4152
|
+
if (m.update_available) {
|
|
4153
|
+
items.push({ value: "update", label: "⤴ Update to latest commit", description: "Re-pulls from the registry — live progress" });
|
|
4154
|
+
}
|
|
4155
|
+
items.push({ value: "options", label: "Recipe options", description: `ctx size, backend, args — saved per model (recipe: ${m.recipe ?? "?"})` });
|
|
4156
|
+
if (m.downloaded !== false) {
|
|
4157
|
+
items.push({ value: "delete", label: "Delete from disk", description: "Typed-phrase confirmation required" });
|
|
4158
|
+
}
|
|
4159
|
+
if (onDemand) {
|
|
4160
|
+
items.push({ value: "ondemand", label: "Why no Load?", description: "on-demand models serve their endpoint directly, with no resident server" });
|
|
4161
|
+
}
|
|
4162
|
+
items.push({ value: "back", label: "Back", description: "" });
|
|
4163
|
+
|
|
4164
|
+
const action = await menu(ctx, `${modelGlyph(m, state)} ${id}`, items);
|
|
4165
|
+
if (action === null || action === "back") return;
|
|
4166
|
+
|
|
4167
|
+
if (action === "view") {
|
|
4168
|
+
await modelDetails(ctx, config, m, state);
|
|
4169
|
+
} else if (action === "ondemand") {
|
|
4170
|
+
ctx.ui.notify(
|
|
4171
|
+
"on-demand models (e.g. built-in upscalers) have no resident model server: " +
|
|
4172
|
+
"/v1/load is not supported. They run through their task endpoint (e.g. /v1/images/upscale) " +
|
|
4173
|
+
"when a tool calls them.",
|
|
4174
|
+
"info"
|
|
4175
|
+
);
|
|
4176
|
+
} else if (action === "canceljob" && job) {
|
|
4177
|
+
const ok = await ctx.ui.confirm(`Cancel the download of ${id}?`, "Partial files stay on disk; a later pull resumes from them.");
|
|
4178
|
+
if (!ok) continue;
|
|
4179
|
+
const [succeeded, msg] = await withStatus(ctx, `Cancelling ${job.id}…`, () =>
|
|
4180
|
+
controlDownload(config, job.id, "cancel").then(() => `Download of ${id} cancelled.`)
|
|
4181
|
+
);
|
|
4182
|
+
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
4183
|
+
} else if (action === "load") {
|
|
4184
|
+
const outcome = await busyView(ctx, `Loading ${id}…`, () => loadModel(config, id), {
|
|
4185
|
+
escDetach: true,
|
|
4186
|
+
liveStatus: async () => {
|
|
4187
|
+
const h = await fetchHealth(config);
|
|
4188
|
+
const me = (h?.all_models_loaded ?? []).find((x) => x.model_name === id);
|
|
4189
|
+
if (!me) return "server: preparing load…";
|
|
4190
|
+
return `server: ${me.status ?? "loading"}${me.device ? ` on ${me.device}` : ""}${me.is_busy ? " · busy" : ""}`;
|
|
4191
|
+
},
|
|
4192
|
+
});
|
|
4193
|
+
if (outcome.detached) {
|
|
4194
|
+
ctx.ui.notify(`Load of ${id} continues in the background — the browser will show it live.`, "info");
|
|
4195
|
+
} else if (outcome.error) {
|
|
4196
|
+
ctx.ui.notify(`Load failed: ${outcome.error}`, "error");
|
|
4197
|
+
} else {
|
|
4198
|
+
ctx.ui.notify(`Loaded ${id}.`, "info");
|
|
4199
|
+
}
|
|
4200
|
+
await registerLemonadeProvider(pi, fullConfig);
|
|
4201
|
+
} else if (action === "unload") {
|
|
4202
|
+
const outcome = await busyView(ctx, `Unloading ${id}…`, () => unloadModel(config, id).then(() => "unloaded"));
|
|
4203
|
+
if (outcome.error) ctx.ui.notify(`Unload failed: ${outcome.error}`, "error");
|
|
4204
|
+
else {
|
|
4205
|
+
ctx.ui.notify(`Unloaded ${id}.`, "info");
|
|
4206
|
+
await registerLemonadeProvider(pi, fullConfig);
|
|
4207
|
+
}
|
|
4208
|
+
} else if (action === "pin" && info) {
|
|
4209
|
+
const target = !info.pinned;
|
|
4210
|
+
const outcome = await busyView(ctx, `${target ? "Pinning" : "Unpinning"} ${id}…`, () =>
|
|
4211
|
+
setPinned(config, id, target).then(() => "pinned")
|
|
4212
|
+
);
|
|
4213
|
+
if (outcome.error) ctx.ui.notify(outcome.error, "error");
|
|
4214
|
+
else ctx.ui.notify(`${id} ${target ? "pinned — LRU eviction will skip it" : "unpinned — evictable again"}.`, "info");
|
|
4215
|
+
} else if (action === "pull") {
|
|
4216
|
+
const ok = await ctx.ui.confirm(
|
|
4217
|
+
`Pull ${id}?`,
|
|
4218
|
+
m.size !== undefined ? `Downloads ~${m.size} GB to the server's disk.` : "Downloads the model to the server's disk."
|
|
4219
|
+
);
|
|
4220
|
+
if (!ok) continue;
|
|
4221
|
+
const [succeeded, msg] = await pullWithProgress(ctx, pi, config, id);
|
|
4222
|
+
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
4223
|
+
} else if (action === "update") {
|
|
4224
|
+
const ok = await ctx.ui.confirm(
|
|
4225
|
+
`Update ${id}?`,
|
|
4226
|
+
"Downloads the model's newer registry commit (the current copy stays until the pull lands)."
|
|
4227
|
+
);
|
|
4228
|
+
if (!ok) continue;
|
|
4229
|
+
const [succeeded, msg] = await pullWithProgress(ctx, pi, config, id);
|
|
4230
|
+
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
4231
|
+
} else if (action === "delete") {
|
|
4232
|
+
const ok = await ctx.ui.confirm(
|
|
4233
|
+
`Delete ${id}?`,
|
|
4234
|
+
"This permanently removes the model files from the server's disk. Load state and cache are also lost."
|
|
4235
|
+
);
|
|
4236
|
+
if (!ok) continue;
|
|
4237
|
+
const phrase = `i want to delete ${id.toLowerCase()}`;
|
|
4238
|
+
let confirmed = false;
|
|
4239
|
+
for (let attempt = 1; attempt <= 3 && !confirmed; attempt++) {
|
|
4240
|
+
const chancesLeft = 4 - attempt;
|
|
4241
|
+
const typed = await ctx.ui.input(
|
|
4242
|
+
`Type "${phrase}" (${chancesLeft} chance${chancesLeft === 1 ? "" : "s"} left):`,
|
|
4243
|
+
""
|
|
4244
|
+
);
|
|
4245
|
+
if (typed === undefined) break; // Esc = abort immediately
|
|
4246
|
+
if (typed.trim().toLowerCase().replace(/\s+/g, " ") === phrase) confirmed = true;
|
|
4247
|
+
else if (attempt < 3) ctx.ui.notify("Phrase did not match.", "warning");
|
|
4248
|
+
}
|
|
4249
|
+
if (!confirmed) {
|
|
4250
|
+
ctx.ui.notify(`Deletion of ${id} aborted.`, "warning");
|
|
4251
|
+
continue;
|
|
4252
|
+
}
|
|
4253
|
+
const outcome = await busyView(ctx, `Deleting ${id}…`, () => deleteModel(config, id));
|
|
4254
|
+
if (outcome.error) ctx.ui.notify(`Delete failed: ${outcome.error}`, "error");
|
|
4255
|
+
else {
|
|
4256
|
+
ctx.ui.notify(`Deleted ${id} from disk.`, "info");
|
|
4257
|
+
await registerLemonadeProvider(pi, fullConfig);
|
|
4258
|
+
}
|
|
4259
|
+
return; // the model is gone — leave the action loop
|
|
4260
|
+
} else if (action === "options") {
|
|
4261
|
+
await recipeOptionsEditor(ctx, pi, config, fullConfig, id);
|
|
4262
|
+
}
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4266
|
+
/** Parse a typed option value the way the API expects: ""/"auto" → null
|
|
4267
|
+
* (tombstone, fall through the priority chain), numbers as numbers, booleans
|
|
4268
|
+
* as booleans, anything else as the raw string. ctx_size keeps -1 (auto). */
|
|
4269
|
+
function parseOptionValue(raw: string, key: string): unknown {
|
|
4270
|
+
const t = raw.trim();
|
|
4271
|
+
if (t === "" || t.toLowerCase() === "auto" || t.toLowerCase() === "null") return null;
|
|
4272
|
+
if (key === "ctx_size" && t === "-1") return -1;
|
|
4273
|
+
if (t === "true" || t === "false") return t === "true";
|
|
4274
|
+
if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
|
|
4275
|
+
return t;
|
|
4276
|
+
}
|
|
4277
|
+
|
|
4278
|
+
/** Per-model recipe options editor: read the saved/effective/defaults layers,
|
|
4279
|
+
* edit one key at a time (saved immediately), reset to defaults, and reload
|
|
4280
|
+
* a loaded model to apply. Works on unloaded models too — options take
|
|
4281
|
+
* effect at the next load. */
|
|
4282
|
+
async function recipeOptionsEditor(
|
|
4283
|
+
ctx: UiCtx,
|
|
4284
|
+
pi: ExtensionAPI,
|
|
4285
|
+
config: LemonadeConfig,
|
|
4286
|
+
fullConfig: LemonadeConfig,
|
|
4287
|
+
id: string
|
|
4288
|
+
): Promise<void> {
|
|
4289
|
+
for (;;) {
|
|
4290
|
+
let layers: ModelOptionsLayers;
|
|
4291
|
+
try {
|
|
4292
|
+
layers = await fetchModelOptions(config, id);
|
|
4293
|
+
} catch (error) {
|
|
4294
|
+
ctx.ui.notify(`Could not read options for ${id}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
4295
|
+
return;
|
|
4296
|
+
}
|
|
4297
|
+
const saved = layers.saved ?? {};
|
|
4298
|
+
const effective = layers.effective ?? {};
|
|
4299
|
+
const defaults = layers.defaults ?? {};
|
|
4300
|
+
const keys = Array.from(new Set([...Object.keys(defaults), ...Object.keys(effective), ...Object.keys(saved)])).sort();
|
|
4301
|
+
|
|
4302
|
+
const items: MenuItem<string>[] = keys.map((k) => ({
|
|
4303
|
+
value: k,
|
|
4304
|
+
label: k,
|
|
4305
|
+
description:
|
|
4306
|
+
`saved: ${k in saved ? JSON.stringify(saved[k]) : "(default)"} · effective: ${JSON.stringify(effective[k] ?? defaults[k] ?? null)}`,
|
|
4307
|
+
}));
|
|
4308
|
+
items.push({ value: "__reset__", label: "Reset ALL saved options", description: "DELETE every saved key — back to recipe defaults" });
|
|
4309
|
+
items.push({ value: "__back__", label: "Back", description: "" });
|
|
4310
|
+
|
|
4311
|
+
const key = await filterMenu(ctx, {
|
|
4312
|
+
title: `Recipe Options — ${id} (recipe: ${layers.recipe ?? "?"}, ${Object.keys(saved).length} saved)`,
|
|
4313
|
+
items,
|
|
4314
|
+
banner: `ctx_size: -1 = auto · ""/"auto" clears a key · edits save immediately`,
|
|
4315
|
+
maxVisible: 12,
|
|
4316
|
+
});
|
|
4317
|
+
if (key === null || key === "__back__") return;
|
|
4318
|
+
|
|
4319
|
+
if (key === "__reset__") {
|
|
4320
|
+
const ok = await ctx.ui.confirm(
|
|
4321
|
+
`Reset every saved option for ${id}?`,
|
|
4322
|
+
"All saved recipe options are deleted; the model falls back to its defaults."
|
|
4323
|
+
);
|
|
4324
|
+
if (!ok) continue;
|
|
4325
|
+
const outcome = await busyView(ctx, `Resetting options for ${id}…`, () => resetModelOptions(config, id));
|
|
4326
|
+
if (outcome.error) ctx.ui.notify(`Reset failed: ${outcome.error}`, "error");
|
|
4327
|
+
else ctx.ui.notify(`Saved options for ${id} reset to defaults.`, "info");
|
|
4328
|
+
continue;
|
|
4329
|
+
}
|
|
4330
|
+
|
|
4331
|
+
// Value entry: backend selectors get a menu of what this system supports;
|
|
4332
|
+
// ctx_size accepts 32k/1m shorthand; everything else is free text.
|
|
4333
|
+
let value: unknown;
|
|
4334
|
+
let cancelled = false;
|
|
4335
|
+
if (/_backend$/.test(key)) {
|
|
4336
|
+
let choices: string[] = [];
|
|
4337
|
+
try {
|
|
4338
|
+
const sysinfo = await fetchJsonOrNull<SystemInfo>(config, "/v1/system-info");
|
|
4339
|
+
const recipe = layers.recipe ?? "";
|
|
4340
|
+
choices = Object.keys(sysinfo?.recipes?.[recipe]?.backends ?? {});
|
|
4341
|
+
} catch {
|
|
4342
|
+
/* fall back to free text */
|
|
4343
|
+
}
|
|
4344
|
+
if (choices.length) {
|
|
4345
|
+
const picked = await menu(ctx, `${id} → ${key}`, [
|
|
4346
|
+
...choices.map((b) => ({ value: b, label: b, description: `effective: ${JSON.stringify(effective[key] ?? defaults[key] ?? null)}` })),
|
|
4347
|
+
{ value: "__clear__", label: "(clear — use the default)", description: "" },
|
|
4348
|
+
]);
|
|
4349
|
+
if (picked === null) cancelled = true;
|
|
4350
|
+
else value = picked === "__clear__" ? null : picked;
|
|
4351
|
+
}
|
|
4352
|
+
}
|
|
4353
|
+
if (!cancelled && value === undefined) {
|
|
4354
|
+
const current = key in saved ? String(saved[key]) : "";
|
|
4355
|
+
const input = await ctx.ui.input(
|
|
4356
|
+
`${id} → ${key} (empty/auto = clear the saved value${key === "ctx_size" ? "; -1 = auto-size" : ""}):`,
|
|
4357
|
+
key === "ctx_size" ? String(effective.ctx_size ?? "") : current
|
|
4358
|
+
);
|
|
4359
|
+
if (input === undefined) cancelled = true;
|
|
4360
|
+
else if (key === "ctx_size") {
|
|
4361
|
+
const parsed = input.trim() === "" ? null : parseCtxSize(input);
|
|
4362
|
+
if (parsed === null) {
|
|
4363
|
+
ctx.ui.notify("Invalid size — use e.g. 32k, 1m, 262144, -1 (auto), or empty to clear.", "warning");
|
|
4364
|
+
continue;
|
|
4365
|
+
}
|
|
4366
|
+
value = parsed;
|
|
4367
|
+
} else {
|
|
4368
|
+
value = parseOptionValue(input, key);
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
if (cancelled) continue;
|
|
4372
|
+
|
|
4373
|
+
const outcome = await busyView(ctx, `Saving ${key} for ${id}…`, () =>
|
|
4374
|
+
saveModelOptions(config, id, { [key]: value })
|
|
4375
|
+
);
|
|
4376
|
+
if (outcome.error) {
|
|
4377
|
+
ctx.ui.notify(`Save failed: ${outcome.error}`, "error");
|
|
4378
|
+
continue;
|
|
4379
|
+
}
|
|
4380
|
+
ctx.ui.notify(
|
|
4381
|
+
`${key} saved for ${id}${value === null ? " (cleared — falls back to defaults)" : `: ${JSON.stringify(value)}`}.`,
|
|
4382
|
+
"info"
|
|
4383
|
+
);
|
|
4384
|
+
|
|
4385
|
+
// A loaded model keeps running with its old options — offer an apply.
|
|
4386
|
+
const h = await fetchHealth(config);
|
|
4387
|
+
if ((h?.all_models_loaded ?? []).some((x) => x.model_name === id)) {
|
|
4388
|
+
const apply = await ctx.ui.confirm(
|
|
4389
|
+
`${id} is loaded — reload it now to apply the new options?`,
|
|
4390
|
+
"The server reloads a model whose load-time options changed (saved options persist either way)."
|
|
4391
|
+
);
|
|
4392
|
+
if (apply) {
|
|
4393
|
+
const loadOutcome = await busyView(ctx, `Reloading ${id}…`, () => loadModel(config, id), { escDetach: true });
|
|
4394
|
+
if (loadOutcome.detached) ctx.ui.notify(`Reload of ${id} continues in the background.`, "info");
|
|
4395
|
+
else if (loadOutcome.error) ctx.ui.notify(`Reload failed: ${loadOutcome.error}`, "error");
|
|
4396
|
+
else ctx.ui.notify(`Reloaded ${id} with the new options.`, "info");
|
|
4397
|
+
await registerLemonadeProvider(pi, fullConfig);
|
|
4398
|
+
}
|
|
4399
|
+
}
|
|
4400
|
+
}
|
|
4401
|
+
}
|
|
4402
|
+
|
|
4403
|
+
/** Server-owned download jobs: live list with per-job actions. Terminal jobs
|
|
4404
|
+
* (errored/cancelled) linger on the server until a client removes them —
|
|
4405
|
+
* this is the view that removes them, and the retry path resumes the
|
|
4406
|
+
* partial files that stayed on disk. */
|
|
4407
|
+
async function downloadsManager(ctx: UiCtx, pi: ExtensionAPI, config: LemonadeConfig): Promise<void> {
|
|
4408
|
+
for (;;) {
|
|
4409
|
+
const picked = await filterMenu<string>(ctx, {
|
|
4410
|
+
title: "Downloads (server-owned jobs)",
|
|
4411
|
+
items: [], // replaced by the first refetch; also handles the empty case
|
|
4412
|
+
refetch: async () => {
|
|
4413
|
+
const jobs = (await listDownloads(config)) ?? [];
|
|
4414
|
+
const items: MenuItem<string>[] = jobs
|
|
4415
|
+
.map((j) => ({
|
|
4416
|
+
value: j.id,
|
|
4417
|
+
label: `${j.running ? "↓" : j.status === "error" ? "✗" : j.status === "paused" ? "⏸" : j.status === "cancelled" ? "∅" : "✓"} ${j.id} — ${j.status}${typeof j.percent === "number" ? ` ${j.percent.toFixed(0)}%` : ""}`,
|
|
4418
|
+
description: [
|
|
4419
|
+
j.model_name,
|
|
4420
|
+
typeof j.total_download_size === "number" ? formatBytes(j.total_download_size) : undefined,
|
|
4421
|
+
j.file ? `file ${j.file_index ?? "?"}/${j.total_files ?? "?"}: ${j.file}` : undefined,
|
|
4422
|
+
j.error ? `error: ${j.error.slice(0, 100)}` : undefined,
|
|
4423
|
+
]
|
|
4424
|
+
.filter(Boolean)
|
|
4425
|
+
.join(" • "),
|
|
4426
|
+
}))
|
|
4427
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
4428
|
+
items.push({ value: "__clear__", label: "✕ Remove all stopped jobs", description: "Clears errored/cancelled/completed entries from the server's list (files on disk are untouched)" });
|
|
4429
|
+
items.push({ value: "__back__", label: "Back", description: "" });
|
|
4430
|
+
return items;
|
|
4431
|
+
},
|
|
4432
|
+
pollMs: 1500,
|
|
4433
|
+
hint: "live",
|
|
4434
|
+
});
|
|
4435
|
+
if (picked === null || picked === "__back__") return;
|
|
4436
|
+
|
|
4437
|
+
if (picked === "__clear__") {
|
|
4438
|
+
const jobs = (await listDownloads(config)) ?? [];
|
|
4439
|
+
const stopped = jobs.filter((j) => !j.running);
|
|
4440
|
+
if (!stopped.length) {
|
|
4441
|
+
ctx.ui.notify("No stopped jobs to remove.", "info");
|
|
4442
|
+
continue;
|
|
4443
|
+
}
|
|
4444
|
+
const ok = await ctx.ui.confirm(
|
|
4445
|
+
`Remove ${stopped.length} stopped job${stopped.length === 1 ? "" : "s"}?`,
|
|
4446
|
+
"Removes them from the server's job list only — partial downloads stay on disk and resume on the next pull."
|
|
4447
|
+
);
|
|
4448
|
+
if (!ok) continue;
|
|
4449
|
+
const outcome = await busyView(ctx, `Removing ${stopped.length} stopped job(s)…`, async () => {
|
|
4450
|
+
for (const j of stopped) {
|
|
4451
|
+
try {
|
|
4452
|
+
await controlDownload(config, j.id, "remove");
|
|
4453
|
+
} catch {
|
|
4454
|
+
/* already gone — removal is idempotent */
|
|
4455
|
+
}
|
|
4456
|
+
}
|
|
4457
|
+
});
|
|
4458
|
+
if (outcome.error) ctx.ui.notify(outcome.error, "error");
|
|
4459
|
+
else ctx.ui.notify(`Removed ${stopped.length} stopped job(s) from the list.`, "info");
|
|
4460
|
+
continue;
|
|
4461
|
+
}
|
|
4462
|
+
|
|
4463
|
+
// Per-job actions
|
|
4464
|
+
const jobs = (await listDownloads(config)) ?? [];
|
|
4465
|
+
const job = jobs.find((j) => j.id === picked);
|
|
4466
|
+
if (!job) {
|
|
4467
|
+
ctx.ui.notify("That job is no longer listed.", "warning");
|
|
4468
|
+
continue;
|
|
4469
|
+
}
|
|
4470
|
+
const items: MenuItem<string>[] = [];
|
|
4471
|
+
if (job.running) {
|
|
4472
|
+
items.push({ value: "cancel", label: "Cancel", description: "Stop the download — partial files stay, a later pull resumes" });
|
|
4473
|
+
items.push({ value: "pause", label: "Pause", description: "Stop the worker, keep the job listed as paused" });
|
|
4474
|
+
} else {
|
|
4475
|
+
items.push({ value: "remove", label: "Remove from list", description: "Clear the finished/failed entry (files untouched)" });
|
|
4476
|
+
if (job.model_name && (job.status === "error" || job.status === "cancelled")) {
|
|
4477
|
+
items.push({ value: "retry", label: "Retry pull", description: "Re-pulls — resumes from the partial files on disk" });
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
items.push({ value: "back", label: "Back", description: "" });
|
|
4481
|
+
const action = await menu(ctx, `${job.id} — ${job.status}`, items);
|
|
4482
|
+
if (action === null || action === "back") continue;
|
|
4483
|
+
|
|
4484
|
+
if (action === "cancel" || action === "pause" || action === "remove") {
|
|
4485
|
+
const outcome = await busyView(ctx, `${action} ${job.id}…`, () => controlDownload(config, job.id, action));
|
|
4486
|
+
if (outcome.error) ctx.ui.notify(outcome.error, "error");
|
|
4487
|
+
else ctx.ui.notify(`${job.id}: ${action} sent.`, "info");
|
|
4488
|
+
} else if (action === "retry" && job.model_name) {
|
|
4489
|
+
const [succeeded, msg] = await pullWithProgress(ctx, pi, config, job.model_name);
|
|
4490
|
+
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
4491
|
+
}
|
|
4492
|
+
}
|
|
4493
|
+
}
|
|
4494
|
+
|
|
4495
|
+
/** Backend browser: every recipe × backend on this system with its install
|
|
4496
|
+
* state; install (with live download progress) or uninstall from here. */
|
|
4497
|
+
async function backendsView(ctx: UiCtx, pi: ExtensionAPI, config: LemonadeConfig): Promise<void> {
|
|
4498
|
+
const flatten = (sysinfo: SystemInfo | null): Array<{ recipe: string; backend: string; info: BackendInfo; isDefault: boolean }> => {
|
|
4499
|
+
const out: Array<{ recipe: string; backend: string; info: BackendInfo; isDefault: boolean }> = [];
|
|
4500
|
+
for (const [recipe, r] of Object.entries(sysinfo?.recipes ?? {})) {
|
|
4501
|
+
for (const [backend, info] of Object.entries(r.backends ?? {})) {
|
|
4502
|
+
out.push({ recipe, backend, info, isDefault: r.default_backend === backend });
|
|
4503
|
+
}
|
|
4504
|
+
}
|
|
4505
|
+
const rank = (s: string | undefined): number => (s === "installed" ? 0 : s === "installable" ? 1 : 2);
|
|
4506
|
+
return out.sort(
|
|
4507
|
+
(a, b) => rank(a.info.state) - rank(b.info.state) || `${a.recipe}:${a.backend}`.localeCompare(`${b.recipe}:${b.backend}`)
|
|
4508
|
+
);
|
|
4509
|
+
};
|
|
4510
|
+
|
|
4511
|
+
for (;;) {
|
|
4512
|
+
const picked = await filterMenu<string>(ctx, {
|
|
4513
|
+
title: "Backends (recipe × backend, live from system-info)",
|
|
4514
|
+
items: [],
|
|
4515
|
+
refetch: async () => {
|
|
4516
|
+
const sysinfo = await fetchJsonOrNull<SystemInfo>(config, "/v1/system-info");
|
|
4517
|
+
const flat = flatten(sysinfo);
|
|
4518
|
+
const items: MenuItem<string>[] = flat.map((b) => ({
|
|
4519
|
+
value: `${b.recipe}:${b.backend}`,
|
|
4520
|
+
label: `${b.info.state === "installed" ? "●" : b.info.state === "installable" ? "○" : "⊘"} ${b.recipe}:${b.backend}${b.isDefault ? " (default)" : ""}`,
|
|
4521
|
+
description: [
|
|
4522
|
+
b.info.state,
|
|
4523
|
+
b.info.tier ? `tier: ${b.info.tier}` : undefined,
|
|
4524
|
+
b.info.version ? `v: ${b.info.version}` : undefined,
|
|
4525
|
+
b.info.format,
|
|
4526
|
+
b.info.message || undefined,
|
|
4527
|
+
]
|
|
4528
|
+
.filter(Boolean)
|
|
4529
|
+
.join(" • "),
|
|
4530
|
+
}));
|
|
4531
|
+
items.push({ value: "__back__", label: "Back", description: "" });
|
|
4532
|
+
return items;
|
|
4533
|
+
},
|
|
4534
|
+
pollMs: 2500,
|
|
4535
|
+
hint: "live",
|
|
4536
|
+
banner: "models whose recipe has only guest-tier backends stay hidden from the catalog until one is installed",
|
|
4537
|
+
});
|
|
4538
|
+
if (picked === null || picked === "__back__") return;
|
|
4539
|
+
|
|
4540
|
+
const [recipe, backend] = picked.split(":", 2);
|
|
4541
|
+
const sysinfo = await fetchJsonOrNull<SystemInfo>(config, "/v1/system-info");
|
|
4542
|
+
const info = sysinfo?.recipes?.[recipe]?.backends?.[backend];
|
|
4543
|
+
if (!info) {
|
|
4544
|
+
ctx.ui.notify("That backend disappeared from system-info — refresh and retry.", "warning");
|
|
4545
|
+
continue;
|
|
4546
|
+
}
|
|
4547
|
+
|
|
4548
|
+
const items: MenuItem<string>[] = [];
|
|
4549
|
+
if (info.state === "installable") {
|
|
4550
|
+
items.push({ value: "install", label: `Install ${recipe}:${backend}`, description: "Downloads + installs — live progress, esc cancels the download" });
|
|
4551
|
+
}
|
|
4552
|
+
if (info.state === "installed" && info.can_uninstall !== false) {
|
|
4553
|
+
items.push({ value: "uninstall", label: `Uninstall ${recipe}:${backend}`, description: "Models needing it can no longer load — the files are removed" });
|
|
4554
|
+
}
|
|
4555
|
+
if (info.action) items.push({ value: "cli", label: "CLI equivalent", description: info.action });
|
|
4556
|
+
if (!items.length) items.push({ value: "noop", label: `Not available on this system (${info.state})`, description: info.message ?? "" });
|
|
4557
|
+
items.push({ value: "back", label: "Back", description: "" });
|
|
4558
|
+
const action = await menu(ctx, `${recipe}:${backend} — ${info.state}${info.tier ? ` (tier: ${info.tier})` : ""}`, items);
|
|
4559
|
+
if (action === null || action === "back" || action === "noop" || action === "cli") continue;
|
|
4560
|
+
|
|
4561
|
+
if (action === "install") {
|
|
4562
|
+
let preview: Record<string, unknown> | undefined;
|
|
4563
|
+
const dry = await busyView(ctx, `Resolving ${recipe}:${backend}…`, () => installBackendDryRun(config, recipe, backend));
|
|
4564
|
+
if (dry.error) {
|
|
4565
|
+
ctx.ui.notify(`Install dry-run failed: ${dry.error}`, "error");
|
|
4566
|
+
continue;
|
|
4567
|
+
}
|
|
4568
|
+
preview = dry.result;
|
|
4569
|
+
const ok = await ctx.ui.confirm(
|
|
4570
|
+
`Install ${recipe}:${backend}?`,
|
|
4571
|
+
`Downloads ${String(preview?.download_filename ?? "the backend asset")} (v ${String(preview?.version ?? "?")}) and installs it on the server.`
|
|
4572
|
+
);
|
|
4573
|
+
if (!ok) continue;
|
|
4574
|
+
let job: DownloadJob;
|
|
4575
|
+
const start = await busyView(ctx, `Starting install of ${recipe}:${backend}…`, () =>
|
|
4576
|
+
installBackendJob(config, recipe, backend)
|
|
4577
|
+
);
|
|
4578
|
+
if (start.error) {
|
|
4579
|
+
ctx.ui.notify(`Install failed to start: ${start.error}`, "error");
|
|
4580
|
+
continue;
|
|
4581
|
+
}
|
|
4582
|
+
job = start.result!;
|
|
4583
|
+
const outcome = await trackDownload(ctx, config, job.id, `${recipe}:${backend}`);
|
|
4584
|
+
ctx.ui.notify(
|
|
4585
|
+
outcome === "complete"
|
|
4586
|
+
? `Installed ${recipe}:${backend}.`
|
|
4587
|
+
: outcome === "cancelled"
|
|
4588
|
+
? `Install of ${recipe}:${backend} cancelled.`
|
|
4589
|
+
: `Install of ${recipe}:${backend} failed.`,
|
|
4590
|
+
outcome === "complete" ? "info" : "error"
|
|
4591
|
+
);
|
|
4592
|
+
} else if (action === "uninstall") {
|
|
4593
|
+
const ok = await ctx.ui.confirm(
|
|
4594
|
+
`Uninstall ${recipe}:${backend}?`,
|
|
4595
|
+
"Removes the backend from the server — models that need it can no longer load until it is installed again."
|
|
4596
|
+
);
|
|
4597
|
+
if (!ok) continue;
|
|
4598
|
+
const outcome = await busyView(ctx, `Uninstalling ${recipe}:${backend}…`, () =>
|
|
4599
|
+
uninstallBackend(config, recipe, backend)
|
|
4600
|
+
);
|
|
4601
|
+
if (outcome.error) ctx.ui.notify(`Uninstall failed: ${outcome.error}`, "error");
|
|
4602
|
+
else ctx.ui.notify(`Uninstalled ${recipe}:${backend}.`, "info");
|
|
4603
|
+
}
|
|
4604
|
+
}
|
|
4605
|
+
}
|
|
4606
|
+
|
|
4607
|
+
/** Model-update flow: check what has newer upstream commits, then bulk-sync
|
|
4608
|
+
* (admin key) or update one at a time via plain pulls. */
|
|
4609
|
+
async function updatesFlow(ctx: UiCtx, pi: ExtensionAPI, config: LemonadeConfig, fullConfig: LemonadeConfig): Promise<void> {
|
|
4610
|
+
const check = await busyView(ctx, "Checking every downloaded model for updates…", () => checkModelUpdates(config));
|
|
4611
|
+
if (check.error) {
|
|
4612
|
+
ctx.ui.notify(`Update check failed: ${check.error}`, "error");
|
|
4613
|
+
return;
|
|
4614
|
+
}
|
|
4615
|
+
const result = check.result!;
|
|
4616
|
+
const models = result.models ?? [];
|
|
4617
|
+
if (!models.length) {
|
|
4618
|
+
ctx.ui.notify("All downloaded models are up to date.", "info");
|
|
4619
|
+
return;
|
|
4620
|
+
}
|
|
4621
|
+
const failures = Object.entries(result.failed_models ?? {});
|
|
4622
|
+
const choice = await menu(ctx, `Updates Available — ${models.length} model(s)`, [
|
|
4623
|
+
...models.map((m) => ({ value: m, label: `⤴ ${m}`, description: "newer upstream commit available" })),
|
|
4624
|
+
{ value: "__all__", label: `Update all ${models.length} (bulk sync — needs the admin key)`, description: "One server job checks + downloads every update" },
|
|
4625
|
+
{ value: "__back__", label: "Back", description: "" },
|
|
4626
|
+
]);
|
|
4627
|
+
if (choice === null || choice === "__back__") return;
|
|
4628
|
+
|
|
4629
|
+
if (choice === "__all__") {
|
|
4630
|
+
const outcome = await busyView(ctx, "Syncing model updates…", () => syncModelUpdates(config, { models }), {
|
|
4631
|
+
liveStatus: async () => {
|
|
4632
|
+
const h = await fetchHealth(config);
|
|
4633
|
+
const sync = h?.model_sync;
|
|
4634
|
+
return sync ? `server: sync ${sync.sync_id ?? "?"} — ${sync.status ?? "?"}` : null;
|
|
4635
|
+
},
|
|
4636
|
+
});
|
|
4637
|
+
if (outcome.error) {
|
|
4638
|
+
ctx.ui.notify(outcome.error, "error");
|
|
4639
|
+
return;
|
|
4640
|
+
}
|
|
4641
|
+
const r = outcome.result!;
|
|
4642
|
+
const updated = r.models_updated ?? [];
|
|
4643
|
+
const failed = Object.entries(r.failed_models ?? {});
|
|
4644
|
+
ctx.ui.notify(
|
|
4645
|
+
`Sync ${r.status ?? "done"}: ${updated.length} updated${updated.length ? ` (${updated.join(", ")})` : ""}` +
|
|
4646
|
+
(failed.length ? ` · ${failed.length} failed: ${failed.map(([k, v]) => `${k} (${String(v).slice(0, 60)})`).join(", ")}` : ""),
|
|
4647
|
+
updated.length || !failed.length ? "info" : "warning"
|
|
4648
|
+
);
|
|
4649
|
+
await registerLemonadeProvider(pi, fullConfig);
|
|
4650
|
+
return;
|
|
4651
|
+
}
|
|
4652
|
+
|
|
4653
|
+
// Single model: plain pull (no admin key needed) with live progress.
|
|
4654
|
+
const ok = await ctx.ui.confirm(
|
|
4655
|
+
`Update ${choice}?`,
|
|
4656
|
+
"Downloads the model's newer registry commit — live progress, esc cancels."
|
|
4657
|
+
);
|
|
4658
|
+
if (!ok) return;
|
|
4659
|
+
const [succeeded, msg] = await pullWithProgress(ctx, pi, config, choice);
|
|
4660
|
+
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
4661
|
+
if (failures.length) {
|
|
4662
|
+
ctx.ui.notify(`Also failed to check: ${failures.map(([k]) => k).join(", ")}`, "warning");
|
|
4663
|
+
}
|
|
4664
|
+
}
|
|
4665
|
+
|
|
4666
|
+
/** The single model browser: every model on the box, live status inline,
|
|
4667
|
+
* type-to-search, ctrl+t toggles all/downloaded, actions on select. The
|
|
4668
|
+
* list refreshes underneath you every couple of seconds — loads, downloads
|
|
4669
|
+
* and state flips appear without leaving the view. */
|
|
4670
|
+
async function modelBrowser(
|
|
4671
|
+
ctx: UiCtx,
|
|
4672
|
+
pi: ExtensionAPI,
|
|
4673
|
+
config: LemonadeConfig,
|
|
4674
|
+
fullConfig: LemonadeConfig,
|
|
4675
|
+
instanceName: string
|
|
4676
|
+
): Promise<void> {
|
|
4677
|
+
let downloadedOnly = false;
|
|
4678
|
+
for (;;) {
|
|
4679
|
+
const picked = await filterMenu<string>(ctx, {
|
|
4680
|
+
title: `Model Browser — ${instanceName}`,
|
|
4681
|
+
// Placeholder until the first refetch lands; unreachable boxes keep
|
|
4682
|
+
// the last good list (refetch throws → snapshot preserved).
|
|
4683
|
+
items: [{ value: "__loading__", label: "loading catalog…", description: "" }],
|
|
4684
|
+
refetch: async () => {
|
|
4685
|
+
const state = await fetchBrowserState(config);
|
|
4686
|
+
if (!state.reachable || (!state.catalog.length && !state.loaded.size)) throw new Error("unreachable");
|
|
4687
|
+
return browserItems(state, downloadedOnly);
|
|
4688
|
+
},
|
|
4689
|
+
pollMs: 2000,
|
|
4690
|
+
hint: "ctrl+t all/downloaded · live",
|
|
4691
|
+
banner: "● loaded · ○ downloaded · ○ not on disk · ↓ downloading · ⤴ update · ctrl+t toggles all ⇄ downloaded",
|
|
4692
|
+
onKey: (data, api) => {
|
|
4693
|
+
if (matchesKey(data, Key.ctrl("t"))) {
|
|
4694
|
+
downloadedOnly = !downloadedOnly;
|
|
4695
|
+
api.setBanner(
|
|
4696
|
+
downloadedOnly
|
|
4697
|
+
? "showing: DOWNLOADED models only — ctrl+t shows every catalog entry again"
|
|
4698
|
+
: "● loaded · ○ downloaded · ○ not on disk · ↓ downloading · ⤴ update · ctrl+t toggles all ⇄ downloaded"
|
|
4699
|
+
);
|
|
4700
|
+
return true;
|
|
4701
|
+
}
|
|
4702
|
+
return false;
|
|
4703
|
+
},
|
|
4704
|
+
});
|
|
4705
|
+
if (picked === null) return; // Esc/back
|
|
4706
|
+
if (picked === "__loading__") continue;
|
|
4707
|
+
await modelActions(ctx, pi, config, fullConfig, picked);
|
|
4708
|
+
}
|
|
4709
|
+
}
|
|
4710
|
+
|
|
2940
4711
|
function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeConfig }): void {
|
|
2941
4712
|
pi.registerCommand("lemonade-setup", {
|
|
2942
4713
|
description: "Lemonade server: status, discovery, endpoints, models, tools, transcription",
|
|
@@ -2996,16 +4767,94 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
2996
4767
|
const iconfig = viewOrErr;
|
|
2997
4768
|
const choice = await menu(ctx, "🍋 Lemonade Setup", [
|
|
2998
4769
|
{ value: "status", label: "Server status", description: `Live health + host + perf from ${iconfig.baseUrl}` },
|
|
2999
|
-
{ value: "server", label: "Server settings", description: "Discover, base URL, API
|
|
3000
|
-
{ value: "models", label: "Model management", description: `
|
|
4770
|
+
{ value: "server", label: "Server settings", description: "Discover, base URL, API/admin keys, instances, connection test" },
|
|
4771
|
+
{ value: "models", label: "Model management", description: `Model browser (live status, type-to-search, actions), downloads, HF install, updates — active instance: ${activeInstance}` },
|
|
4772
|
+
{ value: "backends", label: "Backends", description: "Recipe × backend inventory — install/uninstall with live progress" },
|
|
3001
4773
|
{ value: "transcription", label: "Transcription settings", description: "Whisper model, paths, smoke test" },
|
|
3002
4774
|
{ value: "logs", label: "Live server logs", description: "Stream the server's log over websocket" },
|
|
3003
4775
|
{ value: "spec", label: "Spec & version check", description: "Docs drift + live server-version drift — status, re-check, re-sync, toggles" },
|
|
4776
|
+
{ value: "refresh", label: "Refresh connection", description: "Force a re-probe of every instance now and re-register (bypasses the outage-resync throttle)" },
|
|
4777
|
+
{ value: "cache", label: "Catalog cache", description: `Outage listings (cacheOnOutage: ${config.cacheOnOutage ? "on" : "off"}) — cached catalog snapshots: status, toggle, clear` },
|
|
3004
4778
|
{ value: "instance", label: "Switch instance", description: `Current: ${activeInstance} — pick which box status/models/logs act on` },
|
|
3005
4779
|
{ value: "exit", label: "Exit", description: "Close the setup menu" },
|
|
3006
4780
|
]);
|
|
3007
4781
|
if (choice === null || choice === "exit") break;
|
|
3008
4782
|
|
|
4783
|
+
// ── Refresh connection (manual outage re-sync) ──────────────────
|
|
4784
|
+
if (choice === "refresh") {
|
|
4785
|
+
const [ok, msg] = await withStatus(ctx, "Probing instances…", async () => {
|
|
4786
|
+
const n = await resyncFromOutage(pi, configRef, undefined, true);
|
|
4787
|
+
if (n < 0) return "A re-sync is already in flight — try again in a moment.";
|
|
4788
|
+
return cachedServing.size
|
|
4789
|
+
? `Refreshed — ${n} model(s) registered; ${cachedServing.size} instance(s) unreachable, serving cached catalogs.`
|
|
4790
|
+
: `Refreshed — ${n} model(s) registered.`;
|
|
4791
|
+
});
|
|
4792
|
+
ctx.ui.notify(msg, ok ? "info" : "error");
|
|
4793
|
+
continue;
|
|
4794
|
+
}
|
|
4795
|
+
|
|
4796
|
+
// ── Catalog cache (outage listings) ─────────────────────
|
|
4797
|
+
if (choice === "cache") {
|
|
4798
|
+
for (;;) {
|
|
4799
|
+
const config = configRef.current;
|
|
4800
|
+
const cache = loadCatalogCache();
|
|
4801
|
+
const entries = Object.entries(cache.instances).sort(([a], [b]) => a.localeCompare(b));
|
|
4802
|
+
const state = !config.cacheOnOutage
|
|
4803
|
+
? "disabled (cacheOnOutage: false) — catalogs are neither cached nor served"
|
|
4804
|
+
: entries.length
|
|
4805
|
+
? entries.map(([name, e]) => `${name}: ${e.catalog.length} model(s) cached ${formatCacheStamp(e.fetchedAt)}`).join(" · ")
|
|
4806
|
+
: "enabled — no snapshots yet (written on every successful probe)";
|
|
4807
|
+
const sub = await menu(ctx, "Catalog Cache — Outage Listings", [
|
|
4808
|
+
{ value: "status", label: "Status", description: state },
|
|
4809
|
+
{
|
|
4810
|
+
value: "toggle",
|
|
4811
|
+
label: `Toggle outage cache: ${config.cacheOnOutage ? "on → off" : "off → on"}`,
|
|
4812
|
+
description: `cacheOnOutage (currently ${config.cacheOnOutage ? "on" : "off"}) — when a probe fails, list the instance's last good catalog, provenance-marked "(cached …)"`,
|
|
4813
|
+
},
|
|
4814
|
+
{
|
|
4815
|
+
value: "clear",
|
|
4816
|
+
label: "Clear cached catalogs",
|
|
4817
|
+
description: "Drop the on-disk snapshots — unreachable instances then list nothing",
|
|
4818
|
+
},
|
|
4819
|
+
{ value: "back", label: "Back", description: "" },
|
|
4820
|
+
]);
|
|
4821
|
+
if (sub === null || sub === "back") break;
|
|
4822
|
+
|
|
4823
|
+
if (sub === "toggle") {
|
|
4824
|
+
config.cacheOnOutage = !config.cacheOnOutage;
|
|
4825
|
+
saveConfig(config);
|
|
4826
|
+
ctx.ui.notify(
|
|
4827
|
+
`Outage cache ${config.cacheOnOutage ? "on" : "off"} — takes effect at the next registration (Refresh connection applies it now).`,
|
|
4828
|
+
"info"
|
|
4829
|
+
);
|
|
4830
|
+
continue;
|
|
4831
|
+
}
|
|
4832
|
+
if (sub === "clear") {
|
|
4833
|
+
clearCatalogCache();
|
|
4834
|
+
ctx.ui.notify(
|
|
4835
|
+
"Cached catalogs cleared — unreachable instances will list nothing from the next registration on (Refresh connection applies it now).",
|
|
4836
|
+
"info"
|
|
4837
|
+
);
|
|
4838
|
+
continue;
|
|
4839
|
+
}
|
|
4840
|
+
if (sub === "status") {
|
|
4841
|
+
await textView(ctx, "Catalog Cache — Status", [
|
|
4842
|
+
`cacheOnOutage: ${config.cacheOnOutage ? "on" : "off"}`,
|
|
4843
|
+
`Snapshot path: ${catalogCachePath()}`,
|
|
4844
|
+
"",
|
|
4845
|
+
entries.length ? "Cached catalogs on disk:" : "No cached catalogs on disk.",
|
|
4846
|
+
...entries.map(([name, e]) => ` ${name} — ${e.catalog.length} model(s), fetched ${formatCacheStamp(e.fetchedAt)}${e.unreachableAtLastProbe ? " — LAST PROBE FAILED (instant-served at startup)" : ""}`),
|
|
4847
|
+
"",
|
|
4848
|
+
"Instances currently listed from cache in /model:",
|
|
4849
|
+
...(cachedServing.size
|
|
4850
|
+
? [...cachedServing].map(([name, s]) => ` ${name} — serving cache from ${formatCacheStamp(s.fetchedAt)}`)
|
|
4851
|
+
: [" (none — every instance is live or lists nothing)"]),
|
|
4852
|
+
]);
|
|
4853
|
+
}
|
|
4854
|
+
}
|
|
4855
|
+
continue;
|
|
4856
|
+
}
|
|
4857
|
+
|
|
3009
4858
|
// ── Spec & version check ───────────────────────────────────────
|
|
3010
4859
|
if (choice === "spec") {
|
|
3011
4860
|
for (;;) {
|
|
@@ -3221,6 +5070,11 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
3221
5070
|
{ value: "instances", label: "Manage instances", description: `${config.servers.length} instance(s) — servers[0] is the default` },
|
|
3222
5071
|
{ value: "baseurl", label: "Edit default instance base URL", description: `Current: ${config.servers[0].baseUrl}` },
|
|
3223
5072
|
{ value: "apikey", label: "Edit shared API key", description: `Fallback for instances without their own — ${config.apiKey ? "••••• (set)" : "(none)"}` },
|
|
5073
|
+
{
|
|
5074
|
+
value: "adminkey",
|
|
5075
|
+
label: "Edit admin API key",
|
|
5076
|
+
description: `${config.adminApiKey ? "••••• (set)" : "(not set — /internal/* uses the API key)"} — LEMONADE_ADMIN_API_KEY for pin, model sync, config`,
|
|
5077
|
+
},
|
|
3224
5078
|
{ value: "test", label: "Test connection", description: "Ping the health endpoint" },
|
|
3225
5079
|
{ value: "back", label: "Back", description: "" },
|
|
3226
5080
|
]);
|
|
@@ -3459,6 +5313,21 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
3459
5313
|
saveConfig(config);
|
|
3460
5314
|
ctx.ui.notify("API key saved.", "info");
|
|
3461
5315
|
}
|
|
5316
|
+
} else if (sub === "adminkey") {
|
|
5317
|
+
const input = await ctx.ui.input(
|
|
5318
|
+
"Admin API key (LEMONADE_ADMIN_API_KEY — pin, model sync, config; empty = use the API key):",
|
|
5319
|
+
config.adminApiKey
|
|
5320
|
+
);
|
|
5321
|
+
if (input !== undefined) {
|
|
5322
|
+
config.adminApiKey = input.trim();
|
|
5323
|
+
saveConfig(config);
|
|
5324
|
+
ctx.ui.notify(
|
|
5325
|
+
config.adminApiKey
|
|
5326
|
+
? "Admin key saved — /internal/* endpoints (pin, model sync) will use it."
|
|
5327
|
+
: "Admin key cleared — /internal/* falls back to the API key.",
|
|
5328
|
+
"info"
|
|
5329
|
+
);
|
|
5330
|
+
}
|
|
3462
5331
|
} else if (sub === "test") {
|
|
3463
5332
|
const h = await fetchHealth(iconfig);
|
|
3464
5333
|
ctx.ui.notify(
|
|
@@ -3476,13 +5345,10 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
3476
5345
|
if (choice === "models") {
|
|
3477
5346
|
for (;;) {
|
|
3478
5347
|
const sub = await menu(ctx, "Model Management", [
|
|
3479
|
-
{ value: "
|
|
3480
|
-
{ value: "
|
|
3481
|
-
{ value: "unload", label: "Unload a model", description: "Free its slot" },
|
|
3482
|
-
{ value: "pull", label: "Pull (download) a model", description: "Live progress, esc to cancel" },
|
|
5348
|
+
{ value: "browser", label: "Model browser", description: `Every model, live status inline — type to search, actions on select (${activeInstance})` },
|
|
5349
|
+
{ value: "downloads", label: "Downloads", description: "Server-owned download jobs — live list, cancel/pause/remove/retry" },
|
|
3483
5350
|
{ value: "installhf", label: "Install from Hugging Face", description: "Search HF, pick a variant, install as user.*" },
|
|
3484
|
-
{ value: "
|
|
3485
|
-
{ value: "changectx", label: "Change context size", description: "Unload → reload with new ctx (saved)" },
|
|
5351
|
+
{ value: "updates", label: "Check for model updates", description: "Report — and update — models with newer upstream commits" },
|
|
3486
5352
|
{
|
|
3487
5353
|
value: "chatonly",
|
|
3488
5354
|
label: "Toggle chat-only filter",
|
|
@@ -3493,11 +5359,22 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
3493
5359
|
]);
|
|
3494
5360
|
if (sub === null || sub === "back") break;
|
|
3495
5361
|
|
|
5362
|
+
if (sub === "browser") {
|
|
5363
|
+
await modelBrowser(ctx, pi, iconfig, config, activeInstance);
|
|
5364
|
+
continue;
|
|
5365
|
+
}
|
|
5366
|
+
if (sub === "downloads") {
|
|
5367
|
+
await downloadsManager(ctx, pi, iconfig);
|
|
5368
|
+
continue;
|
|
5369
|
+
}
|
|
3496
5370
|
if (sub === "installhf") {
|
|
3497
5371
|
await installFromHuggingFace(ctx, pi, iconfig);
|
|
3498
5372
|
continue;
|
|
3499
5373
|
}
|
|
3500
|
-
|
|
5374
|
+
if (sub === "updates") {
|
|
5375
|
+
await updatesFlow(ctx, pi, iconfig, config);
|
|
5376
|
+
continue;
|
|
5377
|
+
}
|
|
3501
5378
|
if (sub === "chatonly") {
|
|
3502
5379
|
config.chatOnly = !config.chatOnly;
|
|
3503
5380
|
saveConfig(config);
|
|
@@ -3514,183 +5391,16 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
3514
5391
|
ctx.ui.notify(msg, ok ? "info" : "error");
|
|
3515
5392
|
continue;
|
|
3516
5393
|
}
|
|
3517
|
-
|
|
3518
|
-
// Everything else needs the live catalog. show_all=true includes
|
|
3519
|
-
// not-yet-downloaded registry entries (pull candidates).
|
|
3520
|
-
let catalog: LemonadeModel[];
|
|
3521
|
-
let loadedIds = new Set<string>();
|
|
3522
|
-
let loadedCtx = new Map<string, number>();
|
|
3523
|
-
try {
|
|
3524
|
-
const health = await fetchHealth(iconfig);
|
|
3525
|
-
loadedIds = new Set((health?.all_models_loaded ?? []).map((m) => m.model_name));
|
|
3526
|
-
for (const m of health?.all_models_loaded ?? []) {
|
|
3527
|
-
if (m.max_context_window) loadedCtx.set(m.model_name, m.max_context_window);
|
|
3528
|
-
}
|
|
3529
|
-
catalog = await fetchCatalog(iconfig, true);
|
|
3530
|
-
} catch (error) {
|
|
3531
|
-
ctx.ui.notify(`Could not reach server: ${error instanceof Error ? error.message : error}`, "error");
|
|
3532
|
-
continue;
|
|
3533
|
-
}
|
|
3534
|
-
|
|
3535
|
-
if (sub === "list") {
|
|
3536
|
-
const lines: string[] = [`Catalog: ${catalog.length} model(s), ${loadedIds.size} loaded`, ""];
|
|
3537
|
-
for (const m of catalog) {
|
|
3538
|
-
const state = loadedIds.has(m.id) ? "● loaded" : "○ on-demand";
|
|
3539
|
-
lines.push(
|
|
3540
|
-
`${state} ${m.id}` +
|
|
3541
|
-
(m.labels?.length ? ` [${m.labels.join(", ")}]` : "") +
|
|
3542
|
-
(m.downloaded === false ? " (not downloaded)" : "") +
|
|
3543
|
-
(m.max_context_window ? ` ctx ${m.max_context_window}` : "") +
|
|
3544
|
-
(m.size ? ` ${m.size} GB` : "")
|
|
3545
|
-
);
|
|
3546
|
-
}
|
|
3547
|
-
await textView(ctx, "Model Catalog", lines, true);
|
|
3548
|
-
continue;
|
|
3549
|
-
}
|
|
3550
|
-
|
|
3551
|
-
if (sub === "changectx") {
|
|
3552
|
-
if (!loadedIds.size) {
|
|
3553
|
-
ctx.ui.notify("Nothing is loaded — load a model first.", "warning");
|
|
3554
|
-
continue;
|
|
3555
|
-
}
|
|
3556
|
-
const picked = await menu(ctx, "Change Context Size (pick model)", [...loadedIds].map((id) => ({
|
|
3557
|
-
value: id,
|
|
3558
|
-
label: id,
|
|
3559
|
-
})));
|
|
3560
|
-
if (!picked) continue;
|
|
3561
|
-
const currentCtx =
|
|
3562
|
-
loadedCtx.get(picked) ??
|
|
3563
|
-
catalog.find((m) => m.id === picked)?.max_context_window ??
|
|
3564
|
-
32768;
|
|
3565
|
-
const input = await ctx.ui.input(
|
|
3566
|
-
`New context size (current: ${currentCtx}; e.g. 32k, 1m, or raw tokens):`,
|
|
3567
|
-
String(currentCtx)
|
|
3568
|
-
);
|
|
3569
|
-
const ctxSize = input ? parseCtxSize(input) : null;
|
|
3570
|
-
if (!ctxSize) {
|
|
3571
|
-
ctx.ui.notify("Invalid size — use e.g. 32k, 1m, or 262144.", "warning");
|
|
3572
|
-
continue;
|
|
3573
|
-
}
|
|
3574
|
-
const ok = await ctx.ui.confirm(
|
|
3575
|
-
"Unload and reload?",
|
|
3576
|
-
`${picked} will be unloaded and reloaded with ctx ${ctxSize} (saved for future loads).`
|
|
3577
|
-
);
|
|
3578
|
-
if (!ok) continue;
|
|
3579
|
-
const [succeeded, msg] = await withStatus(ctx, `Reloading ${picked} with ctx ${ctxSize}…`, () =>
|
|
3580
|
-
changeModelContext(iconfig, picked, ctxSize)
|
|
3581
|
-
);
|
|
3582
|
-
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
3583
|
-
if (succeeded) await registerLemonadeProvider(pi, config);
|
|
3584
|
-
continue;
|
|
3585
|
-
}
|
|
3586
|
-
|
|
3587
|
-
if (sub === "pull" || sub === "delete") {
|
|
3588
|
-
const predicate = sub === "delete"
|
|
3589
|
-
? (m: LemonadeModel) => m.downloaded !== false
|
|
3590
|
-
: (m: LemonadeModel) => m.downloaded === false;
|
|
3591
|
-
const candidates = catalog.filter(predicate);
|
|
3592
|
-
|
|
3593
|
-
let picked: string | null = null;
|
|
3594
|
-
if (candidates.length) {
|
|
3595
|
-
picked = await menu(
|
|
3596
|
-
ctx,
|
|
3597
|
-
sub === "pull" ? "Pull (download)" : "Delete from disk",
|
|
3598
|
-
candidates.map((m) => ({
|
|
3599
|
-
value: m.id,
|
|
3600
|
-
label: m.id,
|
|
3601
|
-
description: [
|
|
3602
|
-
loadedIds.has(m.id) ? "loaded" : undefined,
|
|
3603
|
-
m.size ? `${m.size} GB` : undefined,
|
|
3604
|
-
m.recipe,
|
|
3605
|
-
].filter(Boolean).join(" • "),
|
|
3606
|
-
}))
|
|
3607
|
-
);
|
|
3608
|
-
} else {
|
|
3609
|
-
const input = await ctx.ui.input(
|
|
3610
|
-
sub === "pull"
|
|
3611
|
-
? "No undownloaded models in catalog. Enter a model id to pull:"
|
|
3612
|
-
: "Enter a model id to delete:",
|
|
3613
|
-
""
|
|
3614
|
-
);
|
|
3615
|
-
picked = input?.trim() || null;
|
|
3616
|
-
}
|
|
3617
|
-
if (!picked) continue;
|
|
3618
|
-
|
|
3619
|
-
if (sub === "pull") {
|
|
3620
|
-
const size = catalog.find((m) => m.id === picked)?.size;
|
|
3621
|
-
const ok = await ctx.ui.confirm(
|
|
3622
|
-
`Pull ${picked}?`,
|
|
3623
|
-
size ? `Downloads ~${size} GB to the server's disk.` : "Downloads the model to the server's disk."
|
|
3624
|
-
);
|
|
3625
|
-
if (!ok) continue;
|
|
3626
|
-
const [succeeded, msg] = await pullWithProgress(ctx, pi, iconfig, picked!);
|
|
3627
|
-
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
3628
|
-
continue;
|
|
3629
|
-
}
|
|
3630
|
-
|
|
3631
|
-
// delete: confirm + typed phrase, 3 attempts
|
|
3632
|
-
const ok = await ctx.ui.confirm(
|
|
3633
|
-
`Delete ${picked}?`,
|
|
3634
|
-
"This permanently removes the model files from the server's disk. Load state and cache are also lost."
|
|
3635
|
-
);
|
|
3636
|
-
if (!ok) continue;
|
|
3637
|
-
const phrase = `i want to delete ${picked.toLowerCase()}`;
|
|
3638
|
-
let confirmed = false;
|
|
3639
|
-
for (let attempt = 1; attempt <= 3 && !confirmed; attempt++) {
|
|
3640
|
-
const chancesLeft = 4 - attempt;
|
|
3641
|
-
const typed = await ctx.ui.input(
|
|
3642
|
-
`Type "${phrase}" (${chancesLeft} chance${chancesLeft === 1 ? "" : "s"} left):`,
|
|
3643
|
-
""
|
|
3644
|
-
);
|
|
3645
|
-
if (typed === undefined) break; // Esc = abort immediately
|
|
3646
|
-
if (typed.trim().toLowerCase().replace(/\s+/g, " ") === phrase) {
|
|
3647
|
-
confirmed = true;
|
|
3648
|
-
} else if (attempt < 3) {
|
|
3649
|
-
ctx.ui.notify("Phrase did not match.", "warning");
|
|
3650
|
-
}
|
|
3651
|
-
}
|
|
3652
|
-
if (!confirmed) {
|
|
3653
|
-
ctx.ui.notify(`Deletion of ${picked} aborted.`, "warning");
|
|
3654
|
-
continue;
|
|
3655
|
-
}
|
|
3656
|
-
const [succeeded, msg] = await withStatus(ctx, `Deleting ${picked}…`, () =>
|
|
3657
|
-
deleteModel(iconfig, picked!)
|
|
3658
|
-
);
|
|
3659
|
-
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
3660
|
-
if (succeeded) await registerLemonadeProvider(pi, config);
|
|
3661
|
-
continue;
|
|
3662
|
-
}
|
|
3663
|
-
|
|
3664
|
-
// load / unload
|
|
3665
|
-
{
|
|
3666
|
-
const candidates =
|
|
3667
|
-
sub === "unload"
|
|
3668
|
-
? [...loadedIds]
|
|
3669
|
-
: catalog.map((m) => m.id).filter((id) => !loadedIds.has(id));
|
|
3670
|
-
if (!candidates.length) {
|
|
3671
|
-
ctx.ui.notify(sub === "unload" ? "Nothing is loaded." : "Everything is already loaded.", "warning");
|
|
3672
|
-
continue;
|
|
3673
|
-
}
|
|
3674
|
-
const picked = await menu(
|
|
3675
|
-
ctx,
|
|
3676
|
-
sub === "load" ? "Load model" : "Unload model",
|
|
3677
|
-
candidates.map((id) => ({
|
|
3678
|
-
value: id,
|
|
3679
|
-
label: id,
|
|
3680
|
-
description: loadedIds.has(id) ? "loaded" : "on-demand",
|
|
3681
|
-
}))
|
|
3682
|
-
);
|
|
3683
|
-
if (!picked) continue;
|
|
3684
|
-
const [succeeded, msg] = await withStatus(ctx, `${sub === "load" ? "Loading" : "Unloading"} ${picked}…`, () =>
|
|
3685
|
-
sub === "load" ? loadModel(iconfig, picked) : unloadModel(iconfig, picked).then(() => `Unloaded ${picked}.`)
|
|
3686
|
-
);
|
|
3687
|
-
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
3688
|
-
if (succeeded) await registerLemonadeProvider(pi, config);
|
|
3689
|
-
}
|
|
3690
5394
|
}
|
|
3691
5395
|
continue;
|
|
3692
5396
|
}
|
|
3693
5397
|
|
|
5398
|
+
// ── Backends (recipe × backend inventory + install/uninstall) ──────
|
|
5399
|
+
if (choice === "backends") {
|
|
5400
|
+
await backendsView(ctx, pi, iconfig);
|
|
5401
|
+
continue;
|
|
5402
|
+
}
|
|
5403
|
+
|
|
3694
5404
|
// ── Transcription settings ────────────────────────────────────────
|
|
3695
5405
|
if (choice === "transcription") {
|
|
3696
5406
|
for (;;) {
|
|
@@ -3787,11 +5497,33 @@ export default async function piLemonadeLink(pi: ExtensionAPI): Promise<void> {
|
|
|
3787
5497
|
// merged from DEFAULT_CONFIG in memory; /lemonade-setup persists the full
|
|
3788
5498
|
// normalized object when the user actually changes something.
|
|
3789
5499
|
|
|
3790
|
-
|
|
3791
|
-
|
|
5500
|
+
// One shared parallel probe pass feeds both consumers: an unreachable or
|
|
5501
|
+
// slow box stalls startup once (not once per consumer, not once per
|
|
5502
|
+
// instance) — bounded by a single discoveryTimeoutMs window. With
|
|
5503
|
+
// cacheOnOutage, instances the probe can't reach are listed from their
|
|
5504
|
+
// last good cached catalog, provenance-marked — see withCacheFallback.
|
|
5505
|
+
//
|
|
5506
|
+
// Hybrid SWR: instances whose LAST probe failed (durable flag) are served
|
|
5507
|
+
// from cache instantly — zero probe wait — and reconciled by a background
|
|
5508
|
+
// probe pass; instances last known reachable still probe first, so a
|
|
5509
|
+
// healthy box never flashes stale data. Net: the down-box stall only ever
|
|
5510
|
+
// bites once per outage, at the transition into it.
|
|
5511
|
+
const probes = await instantOrLiveProbes(configRef.current);
|
|
5512
|
+
await registerLemonadeProvider(pi, configRef.current, probes);
|
|
5513
|
+
await registerAgentTools(pi, configRef.current, probes);
|
|
5514
|
+
lastResyncProbeAt = Date.now(); // the activation pass IS a probe pass — throttle the session_start re-sync trigger
|
|
5515
|
+
if (probes.some((p) => p.cachedAt !== undefined)) {
|
|
5516
|
+
// Background reconcile for instant-served instances: probes everything
|
|
5517
|
+
// again (cheap for instances that just answered) and re-registers with
|
|
5518
|
+
// live truth when the box is back. Diff notices are silent here —
|
|
5519
|
+
// activation has no ui ctx, and the session_start cached warning has
|
|
5520
|
+
// already told the user the state; the suffix flips are visible in /model.
|
|
5521
|
+
void resyncFromOutage(pi, configRef, undefined, true);
|
|
5522
|
+
}
|
|
3792
5523
|
registerLemonadeBar(pi, configRef);
|
|
3793
5524
|
registerSetupCommand(pi, configRef);
|
|
3794
5525
|
registerSpecDriftWarnings(pi, configRef);
|
|
5526
|
+
registerOutageResync(pi, configRef);
|
|
3795
5527
|
|
|
3796
5528
|
// Fresh install (or a hand-emptied config): nothing is registered yet —
|
|
3797
5529
|
// point the user at the wizard instead of leaving them wondering why
|