pi-lemonade-link 1.1.0 → 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 +54 -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 +1543 -227
- package/lemonade.example.json +16 -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;
|
|
@@ -131,6 +155,7 @@ export interface ServerEntry {
|
|
|
131
155
|
name: string; // lowercase alnum + hyphens; "default" and duplicate names are reserved
|
|
132
156
|
baseUrl: string;
|
|
133
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
|
|
134
159
|
description?: string; // free-form context for the user's own reminder's sake
|
|
135
160
|
}
|
|
136
161
|
|
|
@@ -159,6 +184,14 @@ const DEFAULT_CONFIG: LemonadeConfig = {
|
|
|
159
184
|
audioGenerationPath: "/v1/audio/generations",
|
|
160
185
|
mesh3dPath: "/v1/3d/generations",
|
|
161
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",
|
|
162
195
|
beaconPort: 13305,
|
|
163
196
|
chatOnly: true,
|
|
164
197
|
cacheOnOutage: false,
|
|
@@ -315,7 +348,12 @@ function listInstances(config: LemonadeConfig): InstanceView[] {
|
|
|
315
348
|
name: s.name,
|
|
316
349
|
description: s.description ?? "",
|
|
317
350
|
isDefault: i === 0,
|
|
318
|
-
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
|
+
},
|
|
319
357
|
}));
|
|
320
358
|
}
|
|
321
359
|
|
|
@@ -329,7 +367,12 @@ function instanceView(config: LemonadeConfig, name?: string): LemonadeConfig | s
|
|
|
329
367
|
if (!target) {
|
|
330
368
|
return `Unknown lemonade instance "${name}". Available instances: ${config.servers.map((s) => s.name).join(", ")}.`;
|
|
331
369
|
}
|
|
332
|
-
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
|
+
};
|
|
333
376
|
}
|
|
334
377
|
|
|
335
378
|
/** pi provider id for an instance — uniformly `lemonade-<name>` for EVERY
|
|
@@ -349,6 +392,22 @@ interface LemonadeModel {
|
|
|
349
392
|
size?: number;
|
|
350
393
|
downloaded?: boolean;
|
|
351
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 }>;
|
|
352
411
|
}
|
|
353
412
|
|
|
354
413
|
interface LoadedModelInfo {
|
|
@@ -369,6 +428,8 @@ interface LoadedModelInfo {
|
|
|
369
428
|
slot_pool?: string;
|
|
370
429
|
residency_class?: string;
|
|
371
430
|
watchdog_reset?: boolean;
|
|
431
|
+
watchdog_reset_reason?: string;
|
|
432
|
+
launch_command?: Array<string | number>;
|
|
372
433
|
}
|
|
373
434
|
|
|
374
435
|
interface LemonadeHealth {
|
|
@@ -381,6 +442,14 @@ interface LemonadeHealth {
|
|
|
381
442
|
pinned_models?: Record<string, number>;
|
|
382
443
|
pinned_helper_models?: Record<string, number>;
|
|
383
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;
|
|
384
453
|
}
|
|
385
454
|
|
|
386
455
|
interface SystemStats {
|
|
@@ -389,6 +458,18 @@ interface SystemStats {
|
|
|
389
458
|
gpu_percent?: number | null;
|
|
390
459
|
vram_gb?: number | null;
|
|
391
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;
|
|
392
473
|
}
|
|
393
474
|
|
|
394
475
|
interface PerfStats {
|
|
@@ -397,6 +478,10 @@ interface PerfStats {
|
|
|
397
478
|
input_tokens?: number;
|
|
398
479
|
output_tokens?: number;
|
|
399
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 };
|
|
400
485
|
cache_tokens?: number | null;
|
|
401
486
|
request_count_total?: number;
|
|
402
487
|
input_tokens_total?: number;
|
|
@@ -423,13 +508,19 @@ interface SystemInfo {
|
|
|
423
508
|
Processor?: string;
|
|
424
509
|
"Physical Memory"?: string;
|
|
425
510
|
"Windows Power Setting"?: string;
|
|
426
|
-
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 };
|
|
427
512
|
devices?: {
|
|
428
513
|
cpu?: { name?: string; cores?: number; threads?: number; available?: boolean };
|
|
429
514
|
amd_gpu?: GpuInfo[];
|
|
430
515
|
nvidia_gpu?: GpuInfo[];
|
|
431
516
|
amd_npu?: { name?: string; family?: string; power_mode?: string; tops_max_int?: number; utilization?: number; available?: boolean };
|
|
432
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>> };
|
|
433
524
|
}
|
|
434
525
|
|
|
435
526
|
interface DiscoveredServer {
|
|
@@ -443,6 +534,7 @@ interface DownloadJob {
|
|
|
443
534
|
model_name?: string;
|
|
444
535
|
status: string; // downloading | paused | cancelled | completed | error
|
|
445
536
|
running?: boolean;
|
|
537
|
+
type?: string; // currently "model" for server-owned jobs
|
|
446
538
|
file?: string;
|
|
447
539
|
file_index?: number;
|
|
448
540
|
total_files?: number;
|
|
@@ -450,7 +542,10 @@ interface DownloadJob {
|
|
|
450
542
|
bytes_total?: number;
|
|
451
543
|
percent?: number;
|
|
452
544
|
cumulative_bytes_downloaded?: number;
|
|
545
|
+
overall_bytes_downloaded?: number;
|
|
453
546
|
total_download_size?: number;
|
|
547
|
+
bytes_previously_downloaded?: number;
|
|
548
|
+
completed_files_bytes?: number;
|
|
454
549
|
complete?: boolean;
|
|
455
550
|
error?: string;
|
|
456
551
|
}
|
|
@@ -462,6 +557,10 @@ interface RegistryResult {
|
|
|
462
557
|
downloads?: number;
|
|
463
558
|
likes?: number;
|
|
464
559
|
tags?: string[];
|
|
560
|
+
has_gguf?: boolean;
|
|
561
|
+
task?: string;
|
|
562
|
+
source?: string;
|
|
563
|
+
repository_type?: string;
|
|
465
564
|
}
|
|
466
565
|
|
|
467
566
|
interface PullVariants {
|
|
@@ -470,6 +569,9 @@ interface PullVariants {
|
|
|
470
569
|
suggested_name?: string;
|
|
471
570
|
suggested_labels?: string[];
|
|
472
571
|
mmproj_files?: string[];
|
|
572
|
+
draft_files?: string[];
|
|
573
|
+
repo_kind?: string;
|
|
574
|
+
source?: string;
|
|
473
575
|
variants: Array<{ name: string; primary_file: string; files: string[]; sharded: boolean; size_bytes: number }>;
|
|
474
576
|
}
|
|
475
577
|
|
|
@@ -589,10 +691,200 @@ async function deleteModel(config: LemonadeConfig, id: string): Promise<string>
|
|
|
589
691
|
return `Deleted ${id} from disk.`;
|
|
590
692
|
}
|
|
591
693
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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)}`);
|
|
596
888
|
}
|
|
597
889
|
|
|
598
890
|
/** Parse "32k", "1m", "262144" → number of tokens. */
|
|
@@ -711,7 +1003,17 @@ async function discoverViaHttp(config: LemonadeConfig): Promise<DiscoveredServer
|
|
|
711
1003
|
* network failure — a hygiene check must never cry wolf on offline
|
|
712
1004
|
* startups: unknown ≠ out of date. */
|
|
713
1005
|
|
|
714
|
-
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
|
+
];
|
|
715
1017
|
const DOCS_UPSTREAM_BASE = "https://github.com/lemonade-sdk/lemonade/blob/main/docs/api/";
|
|
716
1018
|
const DOCS_GH_CONTENTS = (file: string) =>
|
|
717
1019
|
`https://api.github.com/repos/lemonade-sdk/lemonade/contents/docs/api/${file}`;
|
|
@@ -927,7 +1229,7 @@ function saveDocsSnapshot(snap: DocsSpecSnapshot): void {
|
|
|
927
1229
|
* config: it is a property of the build — updated with each extension
|
|
928
1230
|
* release after re-verification — never a user preference to move. The
|
|
929
1231
|
* server's own version is probed live from /v1/health; this is the peg. */
|
|
930
|
-
const EXTENSION_CONFIRMED_SERVER_VERSION = "
|
|
1232
|
+
const EXTENSION_CONFIRMED_SERVER_VERSION = "2026.46.2";
|
|
931
1233
|
|
|
932
1234
|
/** This extension's own version, from package.json (kept beside the
|
|
933
1235
|
* provider.apiVerifiedAgainst record — see the README versioning
|
|
@@ -944,11 +1246,20 @@ const PKG_VERSION = (() => {
|
|
|
944
1246
|
}
|
|
945
1247
|
})();
|
|
946
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
|
+
|
|
947
1257
|
/** Compare dotted numeric versions: >0 if a is newer than b, <0 if older,
|
|
948
|
-
* 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. */
|
|
949
1260
|
function versionCompare(a: string, b: string): number | null {
|
|
950
|
-
const pa = a.split(".").map(Number);
|
|
951
|
-
const pb = b.split(".").map(Number);
|
|
1261
|
+
const pa = canonicalVersion(a).split(".").map(Number);
|
|
1262
|
+
const pb = canonicalVersion(b).split(".").map(Number);
|
|
952
1263
|
if (pa.some(Number.isNaN) || pb.some(Number.isNaN)) return null;
|
|
953
1264
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
954
1265
|
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
@@ -987,7 +1298,11 @@ const serverVersionsSeen = new Map<string, string>();
|
|
|
987
1298
|
function recordServerVersion(config: LemonadeConfig, instance: string, version: string | undefined): void {
|
|
988
1299
|
const i = serverVersionMismatches.findIndex((m) => m.instance === instance);
|
|
989
1300
|
const cmp = version ? versionCompare(version, EXTENSION_CONFIRMED_SERVER_VERSION) : null;
|
|
990
|
-
|
|
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;
|
|
991
1306
|
if (version) serverVersionsSeen.set(instance, version);
|
|
992
1307
|
else serverVersionsSeen.delete(instance);
|
|
993
1308
|
if (mismatched && version) {
|
|
@@ -1332,14 +1647,22 @@ function isChatModel(m: LemonadeModel): boolean {
|
|
|
1332
1647
|
|
|
1333
1648
|
function toProviderModel(m: LemonadeModel, loaded: Set<string>, providerId = "lemonade", cachedStamp?: string) {
|
|
1334
1649
|
const labels = m.labels ?? [];
|
|
1335
|
-
|
|
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(" ")}` : "";
|
|
1336
1659
|
return {
|
|
1337
1660
|
id: m.id,
|
|
1338
1661
|
name: cachedStamp
|
|
1339
|
-
? `${m.id} (${providerId}, cached ${cachedStamp})`
|
|
1662
|
+
? `${m.id}${badgeText} (${providerId}, cached ${cachedStamp})`
|
|
1340
1663
|
: loaded.has(m.id)
|
|
1341
|
-
? `${m.id} (${providerId}, loaded)`
|
|
1342
|
-
: `${m.id} (${providerId}, on-demand)`,
|
|
1664
|
+
? `${m.id}${badgeText} (${providerId}, loaded)`
|
|
1665
|
+
: `${m.id}${badgeText} (${providerId}, on-demand)`,
|
|
1343
1666
|
reasoning: labels.includes("reasoning"),
|
|
1344
1667
|
input: labels.includes("vision") ? (["text", "image"] as ("text" | "image")[]) : (["text"] as ("text" | "image")[]),
|
|
1345
1668
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
@@ -1771,7 +2094,9 @@ async function registerAgentTools(
|
|
|
1771
2094
|
if (!probe.catalog) {
|
|
1772
2095
|
continue; // instance unreachable at registration time — hint stays generic for it
|
|
1773
2096
|
}
|
|
1774
|
-
for (const m of probe.catalog.filter(
|
|
2097
|
+
for (const m of probe.catalog.filter(
|
|
2098
|
+
(m) => m.recipe === "onnxruntime" || (m.labels ?? []).includes("classification")
|
|
2099
|
+
)) {
|
|
1775
2100
|
ids.add(m.id);
|
|
1776
2101
|
if (m.downloaded !== false) pulled.add(m.id);
|
|
1777
2102
|
}
|
|
@@ -1983,13 +2308,39 @@ async function registerAgentTools(
|
|
|
1983
2308
|
label: "Text to Speech",
|
|
1984
2309
|
description:
|
|
1985
2310
|
"Convert text to spoken audio using a TTS model on the lemonade server " +
|
|
1986
|
-
"(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.",
|
|
1987
2314
|
promptSnippet: "[lemonade] Convert text to spoken audio (TTS), saves mp3/wav to disk",
|
|
1988
2315
|
parameters: Type.Object({
|
|
1989
2316
|
server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
|
|
1990
2317
|
text: Type.String({ description: "The text to speak." }),
|
|
1991
2318
|
model: Type.Optional(Type.String({ description: "TTS model id. Omit to auto-pick from catalog." })),
|
|
1992
|
-
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
|
+
),
|
|
1993
2344
|
speed: Type.Optional(Type.Number({ description: "Speaking speed (default 1.0)." })),
|
|
1994
2345
|
response_format: Type.Optional(Type.String({ description: "Output format: mp3 (default), wav, opus, pcm." })),
|
|
1995
2346
|
}),
|
|
@@ -2005,6 +2356,22 @@ async function registerAgentTools(
|
|
|
2005
2356
|
const body: Record<string, unknown> = { model, input: params.text, response_format: fmt };
|
|
2006
2357
|
if (params.voice) body.voice = params.voice;
|
|
2007
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
|
+
}
|
|
2008
2375
|
return postJsonSaveBinary(config, config.speechPath, body, "speech", fmt === "pcm" ? "pcm" : fmt, signal, `model: ${model}`);
|
|
2009
2376
|
},
|
|
2010
2377
|
});
|
|
@@ -2023,6 +2390,8 @@ async function registerAgentTools(
|
|
|
2023
2390
|
prompt: Type.String({ description: "Style description: genre, mood, tempo, instruments, voice character." }),
|
|
2024
2391
|
model: Type.Optional(Type.String({ description: "Audio model id (e.g. ThinkSound-SFX, ACE-Step-Music). Omit to auto-pick." })),
|
|
2025
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)." })),
|
|
2026
2395
|
lyrics: Type.Optional(Type.String({ description: "Lyrics to sing (music models only). Omit for instrumental." })),
|
|
2027
2396
|
vocal_language: Type.Optional(Type.String({ description: "BCP-47 language of lyrics, e.g. 'en' (music models only)." })),
|
|
2028
2397
|
seed: Type.Optional(Type.Number({ description: "Random seed for reproducibility." })),
|
|
@@ -2031,12 +2400,14 @@ async function registerAgentTools(
|
|
|
2031
2400
|
const instCfg = instanceView(gconfig, params.server);
|
|
2032
2401
|
if (typeof instCfg === "string") return toolError(instCfg);
|
|
2033
2402
|
const config = instCfg; // instance view — every reference below targets the selected box
|
|
2034
|
-
const auto = await pickModelByLabels(config, ["music", "sfx", "audio", "tts-audio"]);
|
|
2403
|
+
const auto = await pickModelByLabels(config, ["audio-generation", "music", "sfx", "audio", "tts-audio"]);
|
|
2035
2404
|
if (auto === null) return toolError(unreachableMessage(config));
|
|
2036
2405
|
const model = params.model || auto;
|
|
2037
2406
|
if (!model) return toolError("No audio-generation model found on the lemonade server. Pull one first (e.g. ThinkSound-SFX).");
|
|
2038
2407
|
const body: Record<string, unknown> = { model, prompt: params.prompt, response_format: "wav" };
|
|
2039
2408
|
if (params.duration) body.duration = params.duration;
|
|
2409
|
+
if (params.steps) body.steps = params.steps;
|
|
2410
|
+
if (params.cfg) body.cfg = params.cfg;
|
|
2040
2411
|
if (params.lyrics) body.lyrics = params.lyrics;
|
|
2041
2412
|
if (params.vocal_language) body.vocal_language = params.vocal_language;
|
|
2042
2413
|
if (params.seed !== undefined) body.seed = params.seed;
|
|
@@ -2086,12 +2457,15 @@ async function registerAgentTools(
|
|
|
2086
2457
|
name: "classify_text",
|
|
2087
2458
|
label: "Classify Text",
|
|
2088
2459
|
description:
|
|
2089
|
-
"Classify input TEXT with
|
|
2090
|
-
"
|
|
2091
|
-
"
|
|
2092
|
-
"
|
|
2093
|
-
"
|
|
2094
|
-
"
|
|
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). " +
|
|
2095
2469
|
(classifierHint ||
|
|
2096
2470
|
"Common classifier types: phishing/PII/prompt-injection detection. Pull via /lemonade-setup.") +
|
|
2097
2471
|
" Returns all labels with confidence scores, ranked highest to lowest.",
|
|
@@ -2101,7 +2475,19 @@ async function registerAgentTools(
|
|
|
2101
2475
|
server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
|
|
2102
2476
|
input: Type.String({ description: "The text to classify." }),
|
|
2103
2477
|
model: Type.Optional(
|
|
2104
|
-
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
|
+
})
|
|
2105
2491
|
),
|
|
2106
2492
|
top_k: Type.Optional(
|
|
2107
2493
|
Type.Number({ description: "Only return the highest-scoring k labels." })
|
|
@@ -2111,16 +2497,23 @@ async function registerAgentTools(
|
|
|
2111
2497
|
const instCfg = instanceView(gconfig, params.server);
|
|
2112
2498
|
if (typeof instCfg === "string") return toolError(instCfg);
|
|
2113
2499
|
const config = instCfg; // instance view — every reference below targets the selected box
|
|
2114
|
-
|
|
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");
|
|
2115
2506
|
if (auto === null) return toolError(unreachableMessage(config));
|
|
2116
2507
|
const model = params.model || config.defaultClassifierModel || auto;
|
|
2117
2508
|
if (!model) {
|
|
2118
2509
|
return toolError(
|
|
2119
|
-
"No classifier model
|
|
2120
|
-
"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."
|
|
2121
2513
|
);
|
|
2122
2514
|
}
|
|
2123
2515
|
const body: Record<string, unknown> = { model, input: params.input };
|
|
2516
|
+
if (params.labels?.length) body.labels = params.labels;
|
|
2124
2517
|
if (params.top_k) body.top_k = params.top_k;
|
|
2125
2518
|
const res = await toolFetch(config, config.classifyPath, {
|
|
2126
2519
|
method: "POST",
|
|
@@ -2177,6 +2570,11 @@ interface BarMetrics {
|
|
|
2177
2570
|
npuPct: number | null;
|
|
2178
2571
|
npuSupported: boolean | null; // null = unknown yet; false = box has no NPU (segment dropped)
|
|
2179
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;
|
|
2180
2578
|
}
|
|
2181
2579
|
|
|
2182
2580
|
/**
|
|
@@ -2190,7 +2588,7 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
2190
2588
|
const KEY = "lemonade-status-bar";
|
|
2191
2589
|
type ExtCtx = import("@earendil-works/pi-coding-agent").ExtensionContext;
|
|
2192
2590
|
|
|
2193
|
-
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 };
|
|
2194
2592
|
let activeInstance: string | null = null;
|
|
2195
2593
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
2196
2594
|
let barCtx: ExtCtx | undefined; // ctx of the installed row — for re-sync notices
|
|
@@ -2213,6 +2611,8 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
2213
2611
|
metrics.npuPct = null;
|
|
2214
2612
|
metrics.npuSupported = null;
|
|
2215
2613
|
metrics.vramGb = null;
|
|
2614
|
+
metrics.activeReqs = null;
|
|
2615
|
+
metrics.serverToksPerSec = null;
|
|
2216
2616
|
msgStartAt = null; // a stream started under another/removed row must not leak into the next
|
|
2217
2617
|
};
|
|
2218
2618
|
|
|
@@ -2228,10 +2628,11 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
2228
2628
|
tuiRef?.requestRender();
|
|
2229
2629
|
return;
|
|
2230
2630
|
}
|
|
2231
|
-
const [health, sys, metricsBody] = await Promise.all([
|
|
2631
|
+
const [health, sys, metricsBody, perf] = await Promise.all([
|
|
2232
2632
|
fetchHealth(view),
|
|
2233
2633
|
fetchJsonOrNull<SystemStats>(view, "/v1/system-stats"),
|
|
2234
2634
|
fetchTextOrNull(view, "/metrics"),
|
|
2635
|
+
fetchJsonOrNull<PerfStats>(view, "/v1/stats"),
|
|
2235
2636
|
]);
|
|
2236
2637
|
if (activeInstance !== instance) return; // row switched while fetching: discard
|
|
2237
2638
|
if (!health) {
|
|
@@ -2255,6 +2656,19 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
2255
2656
|
metrics.npuSupported = false; // box reported system-stats with no NPU
|
|
2256
2657
|
}
|
|
2257
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
|
+
}
|
|
2258
2672
|
// Free liveness detector: this poll already proves the box answers —
|
|
2259
2673
|
// while its /model listing is served from the outage cache, trigger
|
|
2260
2674
|
// the throttled re-sync (throttle/in-flight guards live inside
|
|
@@ -2306,6 +2720,15 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
2306
2720
|
if (metrics.busy === null) parts.push(dim("…"));
|
|
2307
2721
|
else if (metrics.busy === "idle") parts.push(dim("idle"));
|
|
2308
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
|
+
}
|
|
2309
2732
|
parts.push(dim(`CPU ${metrics.cpuPct !== null ? `${metrics.cpuPct.toFixed(0)}%` : "…"}`));
|
|
2310
2733
|
parts.push(dim(`GPU ${metrics.gpuPct !== null ? `${metrics.gpuPct.toFixed(0)}%` : "…"}`));
|
|
2311
2734
|
// Boxes without an NPU report npu_percent: null — the segment is
|
|
@@ -2399,6 +2822,8 @@ interface MenuItem<T extends string> {
|
|
|
2399
2822
|
|
|
2400
2823
|
/** Show a SelectList menu framed with DynamicBorder. Returns chosen value or null on Esc. */
|
|
2401
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 });
|
|
2402
2827
|
return ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
|
|
2403
2828
|
const container = new Container();
|
|
2404
2829
|
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
@@ -2423,6 +2848,273 @@ function menu<T extends string>(ctx: UiCtx, title: string, items: MenuItem<T>[])
|
|
|
2423
2848
|
});
|
|
2424
2849
|
}
|
|
2425
2850
|
|
|
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. */
|
|
2859
|
+
pollMs?: number;
|
|
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;
|
|
2869
|
+
}
|
|
2870
|
+
|
|
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
|
+
|
|
2426
3118
|
/** Live-refresh wiring for the scrollable textView (status panel). */
|
|
2427
3119
|
interface TextViewLive {
|
|
2428
3120
|
/** Poll interval in ms; failed/empty refetches keep the last good snapshot. */
|
|
@@ -2673,6 +3365,15 @@ async function trackDownload(
|
|
|
2673
3365
|
const finish = (v: "complete" | "cancelled" | "error") => {
|
|
2674
3366
|
if (finished) return;
|
|
2675
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
|
+
}
|
|
2676
3377
|
done(v);
|
|
2677
3378
|
};
|
|
2678
3379
|
|
|
@@ -2968,6 +3669,14 @@ function formatStatus(
|
|
|
2968
3669
|
if (typeof perf.output_tokens_total === "number") lifetime.push(`out ${fmtTokens(perf.output_tokens_total)}`);
|
|
2969
3670
|
if (typeof perf.prompt_tokens_total === "number") lifetime.push(`prompt ${fmtTokens(perf.prompt_tokens_total)}`);
|
|
2970
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
|
+
}
|
|
2971
3680
|
if (lifetime.length) lines.push(`Lifetime: ${lifetime.join(" · ")}`);
|
|
2972
3681
|
if (typeof perf.routing_decisions_total === "number" || typeof perf.routing_switches_total === "number") {
|
|
2973
3682
|
lines.push(
|
|
@@ -3254,6 +3963,751 @@ async function addInstanceFlow(
|
|
|
3254
3963
|
return nameInput;
|
|
3255
3964
|
}
|
|
3256
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
|
+
|
|
3257
4711
|
function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeConfig }): void {
|
|
3258
4712
|
pi.registerCommand("lemonade-setup", {
|
|
3259
4713
|
description: "Lemonade server: status, discovery, endpoints, models, tools, transcription",
|
|
@@ -3313,8 +4767,9 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
3313
4767
|
const iconfig = viewOrErr;
|
|
3314
4768
|
const choice = await menu(ctx, "🍋 Lemonade Setup", [
|
|
3315
4769
|
{ value: "status", label: "Server status", description: `Live health + host + perf from ${iconfig.baseUrl}` },
|
|
3316
|
-
{ value: "server", label: "Server settings", description: "Discover, base URL, API
|
|
3317
|
-
{ 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" },
|
|
3318
4773
|
{ value: "transcription", label: "Transcription settings", description: "Whisper model, paths, smoke test" },
|
|
3319
4774
|
{ value: "logs", label: "Live server logs", description: "Stream the server's log over websocket" },
|
|
3320
4775
|
{ value: "spec", label: "Spec & version check", description: "Docs drift + live server-version drift — status, re-check, re-sync, toggles" },
|
|
@@ -3615,6 +5070,11 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
3615
5070
|
{ value: "instances", label: "Manage instances", description: `${config.servers.length} instance(s) — servers[0] is the default` },
|
|
3616
5071
|
{ value: "baseurl", label: "Edit default instance base URL", description: `Current: ${config.servers[0].baseUrl}` },
|
|
3617
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
|
+
},
|
|
3618
5078
|
{ value: "test", label: "Test connection", description: "Ping the health endpoint" },
|
|
3619
5079
|
{ value: "back", label: "Back", description: "" },
|
|
3620
5080
|
]);
|
|
@@ -3853,6 +5313,21 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
3853
5313
|
saveConfig(config);
|
|
3854
5314
|
ctx.ui.notify("API key saved.", "info");
|
|
3855
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
|
+
}
|
|
3856
5331
|
} else if (sub === "test") {
|
|
3857
5332
|
const h = await fetchHealth(iconfig);
|
|
3858
5333
|
ctx.ui.notify(
|
|
@@ -3870,13 +5345,10 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
3870
5345
|
if (choice === "models") {
|
|
3871
5346
|
for (;;) {
|
|
3872
5347
|
const sub = await menu(ctx, "Model Management", [
|
|
3873
|
-
{ value: "
|
|
3874
|
-
{ value: "
|
|
3875
|
-
{ value: "unload", label: "Unload a model", description: "Free its slot" },
|
|
3876
|
-
{ 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" },
|
|
3877
5350
|
{ value: "installhf", label: "Install from Hugging Face", description: "Search HF, pick a variant, install as user.*" },
|
|
3878
|
-
{ value: "
|
|
3879
|
-
{ 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" },
|
|
3880
5352
|
{
|
|
3881
5353
|
value: "chatonly",
|
|
3882
5354
|
label: "Toggle chat-only filter",
|
|
@@ -3887,11 +5359,22 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
3887
5359
|
]);
|
|
3888
5360
|
if (sub === null || sub === "back") break;
|
|
3889
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
|
+
}
|
|
3890
5370
|
if (sub === "installhf") {
|
|
3891
5371
|
await installFromHuggingFace(ctx, pi, iconfig);
|
|
3892
5372
|
continue;
|
|
3893
5373
|
}
|
|
3894
|
-
|
|
5374
|
+
if (sub === "updates") {
|
|
5375
|
+
await updatesFlow(ctx, pi, iconfig, config);
|
|
5376
|
+
continue;
|
|
5377
|
+
}
|
|
3895
5378
|
if (sub === "chatonly") {
|
|
3896
5379
|
config.chatOnly = !config.chatOnly;
|
|
3897
5380
|
saveConfig(config);
|
|
@@ -3908,183 +5391,16 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
3908
5391
|
ctx.ui.notify(msg, ok ? "info" : "error");
|
|
3909
5392
|
continue;
|
|
3910
5393
|
}
|
|
3911
|
-
|
|
3912
|
-
// Everything else needs the live catalog. show_all=true includes
|
|
3913
|
-
// not-yet-downloaded registry entries (pull candidates).
|
|
3914
|
-
let catalog: LemonadeModel[];
|
|
3915
|
-
let loadedIds = new Set<string>();
|
|
3916
|
-
let loadedCtx = new Map<string, number>();
|
|
3917
|
-
try {
|
|
3918
|
-
const health = await fetchHealth(iconfig);
|
|
3919
|
-
loadedIds = new Set((health?.all_models_loaded ?? []).map((m) => m.model_name));
|
|
3920
|
-
for (const m of health?.all_models_loaded ?? []) {
|
|
3921
|
-
if (m.max_context_window) loadedCtx.set(m.model_name, m.max_context_window);
|
|
3922
|
-
}
|
|
3923
|
-
catalog = await fetchCatalog(iconfig, true);
|
|
3924
|
-
} catch (error) {
|
|
3925
|
-
ctx.ui.notify(`Could not reach server: ${error instanceof Error ? error.message : error}`, "error");
|
|
3926
|
-
continue;
|
|
3927
|
-
}
|
|
3928
|
-
|
|
3929
|
-
if (sub === "list") {
|
|
3930
|
-
const lines: string[] = [`Catalog: ${catalog.length} model(s), ${loadedIds.size} loaded`, ""];
|
|
3931
|
-
for (const m of catalog) {
|
|
3932
|
-
const state = loadedIds.has(m.id) ? "● loaded" : "○ on-demand";
|
|
3933
|
-
lines.push(
|
|
3934
|
-
`${state} ${m.id}` +
|
|
3935
|
-
(m.labels?.length ? ` [${m.labels.join(", ")}]` : "") +
|
|
3936
|
-
(m.downloaded === false ? " (not downloaded)" : "") +
|
|
3937
|
-
(m.max_context_window ? ` ctx ${m.max_context_window}` : "") +
|
|
3938
|
-
(m.size ? ` ${m.size} GB` : "")
|
|
3939
|
-
);
|
|
3940
|
-
}
|
|
3941
|
-
await textView(ctx, "Model Catalog", lines, true);
|
|
3942
|
-
continue;
|
|
3943
|
-
}
|
|
3944
|
-
|
|
3945
|
-
if (sub === "changectx") {
|
|
3946
|
-
if (!loadedIds.size) {
|
|
3947
|
-
ctx.ui.notify("Nothing is loaded — load a model first.", "warning");
|
|
3948
|
-
continue;
|
|
3949
|
-
}
|
|
3950
|
-
const picked = await menu(ctx, "Change Context Size (pick model)", [...loadedIds].map((id) => ({
|
|
3951
|
-
value: id,
|
|
3952
|
-
label: id,
|
|
3953
|
-
})));
|
|
3954
|
-
if (!picked) continue;
|
|
3955
|
-
const currentCtx =
|
|
3956
|
-
loadedCtx.get(picked) ??
|
|
3957
|
-
catalog.find((m) => m.id === picked)?.max_context_window ??
|
|
3958
|
-
32768;
|
|
3959
|
-
const input = await ctx.ui.input(
|
|
3960
|
-
`New context size (current: ${currentCtx}; e.g. 32k, 1m, or raw tokens):`,
|
|
3961
|
-
String(currentCtx)
|
|
3962
|
-
);
|
|
3963
|
-
const ctxSize = input ? parseCtxSize(input) : null;
|
|
3964
|
-
if (!ctxSize) {
|
|
3965
|
-
ctx.ui.notify("Invalid size — use e.g. 32k, 1m, or 262144.", "warning");
|
|
3966
|
-
continue;
|
|
3967
|
-
}
|
|
3968
|
-
const ok = await ctx.ui.confirm(
|
|
3969
|
-
"Unload and reload?",
|
|
3970
|
-
`${picked} will be unloaded and reloaded with ctx ${ctxSize} (saved for future loads).`
|
|
3971
|
-
);
|
|
3972
|
-
if (!ok) continue;
|
|
3973
|
-
const [succeeded, msg] = await withStatus(ctx, `Reloading ${picked} with ctx ${ctxSize}…`, () =>
|
|
3974
|
-
changeModelContext(iconfig, picked, ctxSize)
|
|
3975
|
-
);
|
|
3976
|
-
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
3977
|
-
if (succeeded) await registerLemonadeProvider(pi, config);
|
|
3978
|
-
continue;
|
|
3979
|
-
}
|
|
3980
|
-
|
|
3981
|
-
if (sub === "pull" || sub === "delete") {
|
|
3982
|
-
const predicate = sub === "delete"
|
|
3983
|
-
? (m: LemonadeModel) => m.downloaded !== false
|
|
3984
|
-
: (m: LemonadeModel) => m.downloaded === false;
|
|
3985
|
-
const candidates = catalog.filter(predicate);
|
|
3986
|
-
|
|
3987
|
-
let picked: string | null = null;
|
|
3988
|
-
if (candidates.length) {
|
|
3989
|
-
picked = await menu(
|
|
3990
|
-
ctx,
|
|
3991
|
-
sub === "pull" ? "Pull (download)" : "Delete from disk",
|
|
3992
|
-
candidates.map((m) => ({
|
|
3993
|
-
value: m.id,
|
|
3994
|
-
label: m.id,
|
|
3995
|
-
description: [
|
|
3996
|
-
loadedIds.has(m.id) ? "loaded" : undefined,
|
|
3997
|
-
m.size ? `${m.size} GB` : undefined,
|
|
3998
|
-
m.recipe,
|
|
3999
|
-
].filter(Boolean).join(" • "),
|
|
4000
|
-
}))
|
|
4001
|
-
);
|
|
4002
|
-
} else {
|
|
4003
|
-
const input = await ctx.ui.input(
|
|
4004
|
-
sub === "pull"
|
|
4005
|
-
? "No undownloaded models in catalog. Enter a model id to pull:"
|
|
4006
|
-
: "Enter a model id to delete:",
|
|
4007
|
-
""
|
|
4008
|
-
);
|
|
4009
|
-
picked = input?.trim() || null;
|
|
4010
|
-
}
|
|
4011
|
-
if (!picked) continue;
|
|
4012
|
-
|
|
4013
|
-
if (sub === "pull") {
|
|
4014
|
-
const size = catalog.find((m) => m.id === picked)?.size;
|
|
4015
|
-
const ok = await ctx.ui.confirm(
|
|
4016
|
-
`Pull ${picked}?`,
|
|
4017
|
-
size ? `Downloads ~${size} GB to the server's disk.` : "Downloads the model to the server's disk."
|
|
4018
|
-
);
|
|
4019
|
-
if (!ok) continue;
|
|
4020
|
-
const [succeeded, msg] = await pullWithProgress(ctx, pi, iconfig, picked!);
|
|
4021
|
-
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
4022
|
-
continue;
|
|
4023
|
-
}
|
|
4024
|
-
|
|
4025
|
-
// delete: confirm + typed phrase, 3 attempts
|
|
4026
|
-
const ok = await ctx.ui.confirm(
|
|
4027
|
-
`Delete ${picked}?`,
|
|
4028
|
-
"This permanently removes the model files from the server's disk. Load state and cache are also lost."
|
|
4029
|
-
);
|
|
4030
|
-
if (!ok) continue;
|
|
4031
|
-
const phrase = `i want to delete ${picked.toLowerCase()}`;
|
|
4032
|
-
let confirmed = false;
|
|
4033
|
-
for (let attempt = 1; attempt <= 3 && !confirmed; attempt++) {
|
|
4034
|
-
const chancesLeft = 4 - attempt;
|
|
4035
|
-
const typed = await ctx.ui.input(
|
|
4036
|
-
`Type "${phrase}" (${chancesLeft} chance${chancesLeft === 1 ? "" : "s"} left):`,
|
|
4037
|
-
""
|
|
4038
|
-
);
|
|
4039
|
-
if (typed === undefined) break; // Esc = abort immediately
|
|
4040
|
-
if (typed.trim().toLowerCase().replace(/\s+/g, " ") === phrase) {
|
|
4041
|
-
confirmed = true;
|
|
4042
|
-
} else if (attempt < 3) {
|
|
4043
|
-
ctx.ui.notify("Phrase did not match.", "warning");
|
|
4044
|
-
}
|
|
4045
|
-
}
|
|
4046
|
-
if (!confirmed) {
|
|
4047
|
-
ctx.ui.notify(`Deletion of ${picked} aborted.`, "warning");
|
|
4048
|
-
continue;
|
|
4049
|
-
}
|
|
4050
|
-
const [succeeded, msg] = await withStatus(ctx, `Deleting ${picked}…`, () =>
|
|
4051
|
-
deleteModel(iconfig, picked!)
|
|
4052
|
-
);
|
|
4053
|
-
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
4054
|
-
if (succeeded) await registerLemonadeProvider(pi, config);
|
|
4055
|
-
continue;
|
|
4056
|
-
}
|
|
4057
|
-
|
|
4058
|
-
// load / unload
|
|
4059
|
-
{
|
|
4060
|
-
const candidates =
|
|
4061
|
-
sub === "unload"
|
|
4062
|
-
? [...loadedIds]
|
|
4063
|
-
: catalog.map((m) => m.id).filter((id) => !loadedIds.has(id));
|
|
4064
|
-
if (!candidates.length) {
|
|
4065
|
-
ctx.ui.notify(sub === "unload" ? "Nothing is loaded." : "Everything is already loaded.", "warning");
|
|
4066
|
-
continue;
|
|
4067
|
-
}
|
|
4068
|
-
const picked = await menu(
|
|
4069
|
-
ctx,
|
|
4070
|
-
sub === "load" ? "Load model" : "Unload model",
|
|
4071
|
-
candidates.map((id) => ({
|
|
4072
|
-
value: id,
|
|
4073
|
-
label: id,
|
|
4074
|
-
description: loadedIds.has(id) ? "loaded" : "on-demand",
|
|
4075
|
-
}))
|
|
4076
|
-
);
|
|
4077
|
-
if (!picked) continue;
|
|
4078
|
-
const [succeeded, msg] = await withStatus(ctx, `${sub === "load" ? "Loading" : "Unloading"} ${picked}…`, () =>
|
|
4079
|
-
sub === "load" ? loadModel(iconfig, picked) : unloadModel(iconfig, picked).then(() => `Unloaded ${picked}.`)
|
|
4080
|
-
);
|
|
4081
|
-
ctx.ui.notify(msg, succeeded ? "info" : "error");
|
|
4082
|
-
if (succeeded) await registerLemonadeProvider(pi, config);
|
|
4083
|
-
}
|
|
4084
5394
|
}
|
|
4085
5395
|
continue;
|
|
4086
5396
|
}
|
|
4087
5397
|
|
|
5398
|
+
// ── Backends (recipe × backend inventory + install/uninstall) ──────
|
|
5399
|
+
if (choice === "backends") {
|
|
5400
|
+
await backendsView(ctx, pi, iconfig);
|
|
5401
|
+
continue;
|
|
5402
|
+
}
|
|
5403
|
+
|
|
4088
5404
|
// ── Transcription settings ────────────────────────────────────────
|
|
4089
5405
|
if (choice === "transcription") {
|
|
4090
5406
|
for (;;) {
|