@camstack/addon-ai 0.4.3 → 0.4.4

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/dist/addon.mjs CHANGED
@@ -2,9 +2,9 @@ import { createRequire } from "node:module";
2
2
  import * as path$1 from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { connect, createServer } from "node:net";
5
- import { promisify } from "node:util";
6
5
  import * as fs from "node:fs";
7
- import { createReadStream } from "node:fs";
6
+ import { createReadStream, existsSync, statSync } from "node:fs";
7
+ import { promisify } from "node:util";
8
8
  import { brotliCompress, gzip } from "node:zlib";
9
9
  import * as fsp from "node:fs/promises";
10
10
  import { spawn } from "node:child_process";
@@ -12500,6 +12500,18 @@ var LlmGenerateBaseInputSchema = object({
12500
12500
  * Resource ceiling = llama-server flags + idleStopMinutes ONLY (no RSS
12501
12501
  * watchdog — operator decision #3).
12502
12502
  */
12503
+ /**
12504
+ * A companion artifact that MUST land beside the main GGUF: the `mmproj`
12505
+ * projector of a vision model, or shards 2..N of a split GGUF. Carried on the
12506
+ * REF rather than looked up at install time, so what the operator approved in
12507
+ * the preview is exactly what the node downloads.
12508
+ */
12509
+ var ManagedModelExtraFileSchema = object({
12510
+ url: string(),
12511
+ filename: string(),
12512
+ sizeBytes: number$1(),
12513
+ sha256: string().optional()
12514
+ });
12503
12515
  var ManagedModelRefSchema = discriminatedUnion("kind", [
12504
12516
  object({
12505
12517
  kind: literal("catalog"),
@@ -12508,7 +12520,11 @@ var ManagedModelRefSchema = discriminatedUnion("kind", [
12508
12520
  object({
12509
12521
  kind: literal("url"),
12510
12522
  url: string(),
12511
- sha256: string().optional()
12523
+ sha256: string().optional(),
12524
+ /** Picker/status label; the file basename when absent. */
12525
+ label: string().optional(),
12526
+ sizeBytes: number$1().optional(),
12527
+ extraFiles: array(ManagedModelExtraFileSchema).optional()
12512
12528
  }),
12513
12529
  object({
12514
12530
  kind: literal("path"),
@@ -12569,11 +12585,39 @@ var ManagedRuntimeConfigSchema = object({
12569
12585
  "q4_1",
12570
12586
  "q4_0"
12571
12587
  ]).optional(),
12588
+ /**
12589
+ * Escape hatch for llama-server flags this schema does NOT model — `--jinja`
12590
+ * (which most vision chat templates need and some language-only models
12591
+ * dislike), `--cont-batching`, `--rope-scaling`, …
12592
+ *
12593
+ * It is NOT a second place to set the flags above. A token that collides
12594
+ * with a typed field is REJECTED at start, naming the field that owns it
12595
+ * (`assertNoOwnedFlags`), because two knobs writing the same argv is exactly
12596
+ * the "two switches that disagree" failure this repo has already shipped
12597
+ * twice (D62).
12598
+ */
12599
+ extraArgs: array(string()).default([]),
12572
12600
  /** Else lazy: first generate boots it. */
12573
12601
  autoStart: boolean().default(false),
12574
12602
  /** 0 = never; frees RAM after quiet periods. */
12575
12603
  idleStopMinutes: number$1().int().default(30)
12576
12604
  });
12605
+ /**
12606
+ * Where a multi-GB install currently is. A single 0..1 fraction cannot answer
12607
+ * "is it stuck?" for an install that is three files (shards + mmproj) followed
12608
+ * by a sha256 pass over 22 GB — during which the fraction sat at 1.0 and the
12609
+ * node looked hung. Phase + file + bytes is the smallest shape that does.
12610
+ */
12611
+ var LlmDownloadProgressSchema = object({
12612
+ phase: _enum(["downloading", "verifying"]),
12613
+ /** The artifact currently moving, e.g. `mmproj-F16.gguf`. */
12614
+ file: string(),
12615
+ fileIndex: number$1().int(),
12616
+ fileCount: number$1().int(),
12617
+ /** Across the WHOLE install, not the current file. */
12618
+ downloadedBytes: number$1(),
12619
+ totalBytes: number$1().optional()
12620
+ });
12577
12621
  var LlmRuntimeStatusSchema = object({
12578
12622
  /** Status is ALWAYS node-qualified. */
12579
12623
  nodeId: string(),
@@ -12590,6 +12634,8 @@ var LlmRuntimeStatusSchema = object({
12590
12634
  modelPath: string().optional(),
12591
12635
  modelId: string().optional(),
12592
12636
  downloadProgress: number$1().min(0).max(1).optional(),
12637
+ /** Detail behind `downloadProgress`; present for the same lifetime. */
12638
+ download: LlmDownloadProgressSchema.optional(),
12593
12639
  lastError: string().optional(),
12594
12640
  crashesInWindow: number$1(),
12595
12641
  /** Child RSS (sampled best-effort). */
@@ -12600,7 +12646,14 @@ var LlmNodeModelSchema = object({
12600
12646
  file: string(),
12601
12647
  sizeBytes: number$1(),
12602
12648
  catalogId: string().optional(),
12603
- installedAt: number$1().optional()
12649
+ installedAt: number$1().optional(),
12650
+ /**
12651
+ * Absolute path on the node. Present so a file that is on disk but matches
12652
+ * no catalog entry — a custom Hugging Face install, or a GGUF the operator
12653
+ * copied in by hand — is still SELECTABLE, as a `{kind:'path'}` ref. Without
12654
+ * it the picker could list such a file and do nothing with it.
12655
+ */
12656
+ path: string().optional()
12604
12657
  });
12605
12658
  var LlmRuntimeDiskUsageSchema = object({
12606
12659
  nodeId: string(),
@@ -12761,6 +12814,36 @@ var ManagedModelCatalogEntrySchema = object({
12761
12814
  /** Vision models: companion projector file. */
12762
12815
  mmprojUrl: string().optional()
12763
12816
  });
12817
+ /**
12818
+ * The outcome of turning one operator-typed Hugging Face reference into a
12819
+ * download plan. A RESULT, never a throw: "this repo has 24 quantizations and
12820
+ * I will not pick for you" is a normal answer the UI has to render, not an
12821
+ * exception.
12822
+ *
12823
+ * `candidates` is the whole reason the refusal is usable — every string in it
12824
+ * is a tag that resolves when pasted back as `<org>/<repo>:<TAG>`.
12825
+ */
12826
+ var HfModelResolutionSchema = discriminatedUnion("ok", [object({
12827
+ ok: literal(true),
12828
+ /** Ready to hand to `installModel` unchanged. */
12829
+ model: ManagedModelRefSchema,
12830
+ label: string(),
12831
+ repo: string(),
12832
+ quantization: string(),
12833
+ purpose: _enum(["text", "vision"]),
12834
+ totalBytes: number$1(),
12835
+ /** mmproj + shards, for the preview: an operator approving 23 GB should
12836
+ * see that 0.9 GB of it is a projector they did not name. */
12837
+ extraFilenames: array(string())
12838
+ }), object({
12839
+ ok: literal(false),
12840
+ code: string(),
12841
+ message: string(),
12842
+ candidates: array(string()).optional(),
12843
+ /** Set when the refusal was only the ceiling: re-calling with
12844
+ * `maxBytes: requiredBytes` is the operator's explicit override. */
12845
+ requiredBytes: number$1().optional()
12846
+ })]);
12764
12847
  var LlmRuntimeNodeSchema = object({
12765
12848
  nodeId: string(),
12766
12849
  reachable: boolean(),
@@ -12828,6 +12911,25 @@ var llmCapability = {
12828
12911
  listModelCatalog: method(object({}), array(ManagedModelCatalogEntrySchema)),
12829
12912
  listRuntimeNodes: method(object({}), array(LlmRuntimeNodeSchema)),
12830
12913
  listNodeModels: method(object({ nodeId: string() }), array(LlmNodeModelSchema)),
12914
+ /**
12915
+ * One typed Hugging Face reference → a pinned, verified `ManagedModelRef`.
12916
+ *
12917
+ * Runs on the HUB, not on the target node: resolution needs egress to
12918
+ * huggingface.co, and an agent that cannot reach it still installs fine
12919
+ * through the model-distributor relay. Nothing is downloaded here — this is
12920
+ * a tree read plus a HEAD, so the operator sees the size, the quantization
12921
+ * and the mmproj BEFORE approving a multi-GB pull.
12922
+ */
12923
+ resolveModelRef: method(object({
12924
+ /** `https://huggingface.co/<org>/<repo>/resolve/main/<f>.gguf`,
12925
+ * `<org>/<repo>/<f>.gguf`, `<org>/<repo>` or `<org>/<repo>:<QUANT>`. */
12926
+ ref: string(),
12927
+ /** Explicit ceiling override, in bytes. Absent = the built-in ceiling. */
12928
+ maxBytes: number$1().positive().optional()
12929
+ }), HfModelResolutionSchema, {
12930
+ kind: "mutation",
12931
+ auth: "admin"
12932
+ }),
12831
12933
  installModel: method(object({
12832
12934
  nodeId: string(),
12833
12935
  model: ManagedModelRefSchema
@@ -17967,6 +18069,17 @@ var maxSessionHoldMsField = {
17967
18069
  default: 12e4,
17968
18070
  step: 5e3
17969
18071
  };
18072
+ /**
18073
+ * Quiet period that closes an `audioMode: 'on-motion'` audio window. Floor of
18074
+ * 5s so a rearm can never degenerate into per-event stream churn; default 90s
18075
+ * comfortably outlives the gap between two PIR wakes on a battery camera.
18076
+ */
18077
+ var audioMotionWindowMsField = {
18078
+ min: 5e3,
18079
+ max: 6e5,
18080
+ default: 9e4,
18081
+ step: 5e3
18082
+ };
17970
18083
  var motionFpsField = {
17971
18084
  min: 1,
17972
18085
  max: 30,
@@ -18143,6 +18256,27 @@ var RunnerCameraConfigSchema = object({
18143
18256
  * resolved `CameraDetectionConfig`.
18144
18257
  */
18145
18258
  maxSessionHoldMs: number$1().min(maxSessionHoldMsField.min).max(maxSessionHoldMsField.max).optional(),
18259
+ /**
18260
+ * Orchestrator-side quiet period (ms) that closes an `audioMode:
18261
+ * 'on-motion'` audio window, measured from the LAST motion event.
18262
+ *
18263
+ * This exists because the falling edge cannot be relied on. Camera-native
18264
+ * providers emit motion as a RISING EDGE ONLY (Reolink's Baichuan push and
18265
+ * its email-push SMTP path both emit `detected: true` and never the
18266
+ * counterpart); only the frame-diff analyzer emits falls. So on an
18267
+ * onboard-only camera a window that closed only on `detected: false` never
18268
+ * closed at all, and `on-motion` silently behaved as `always-on` — on a
18269
+ * battery camera, the one failure mode the mode exists to prevent.
18270
+ *
18271
+ * Every motion event rearms this timer WITHOUT restarting the stream, so a
18272
+ * burst of re-fires costs nothing. A falling edge, when one does arrive,
18273
+ * still closes earlier via `motionCooldownMs` — whichever comes first wins.
18274
+ *
18275
+ * Not consumed by the runner: carried here so it shares the per-camera
18276
+ * device-settings surface with `motionCooldownMs`, exactly like
18277
+ * `maxSessionHoldMs`.
18278
+ */
18279
+ audioMotionWindowMs: number$1().min(audioMotionWindowMsField.min).max(audioMotionWindowMsField.max).optional(),
18146
18280
  motionFps: number$1().min(motionFpsField.min).max(motionFpsField.max).default(motionFpsField.default),
18147
18281
  detectionFps: number$1().min(detectionFpsField.min).max(detectionFpsField.max).default(detectionFpsField.default),
18148
18282
  motionStreamId: string(),
@@ -18238,7 +18372,7 @@ var RunnerCameraConfigSchema = object({
18238
18372
  */
18239
18373
  inferenceDevices: array(RunnerInferenceDeviceSchema).readonly().optional()
18240
18374
  });
18241
- motionFpsField.min, motionFpsField.max, motionFpsField.step, motionFpsField.default, detectionFpsField.min, detectionFpsField.max, detectionFpsField.step, detectionFpsField.default, motionCooldownMsField.min, motionCooldownMsField.max, motionCooldownMsField.step, motionCooldownMsField.default, maxSessionHoldMsField.min, maxSessionHoldMsField.max, maxSessionHoldMsField.step, maxSessionHoldMsField.default, occupancyRecheckSecField.min, occupancyRecheckSecField.max, occupancyRecheckSecField.step, occupancyRecheckSecField.default, occupancyRecheckFramesField.min, occupancyRecheckFramesField.max, occupancyRecheckFramesField.step, occupancyRecheckFramesField.default;
18375
+ motionFpsField.min, motionFpsField.max, motionFpsField.step, motionFpsField.default, detectionFpsField.min, detectionFpsField.max, detectionFpsField.step, detectionFpsField.default, motionCooldownMsField.min, motionCooldownMsField.max, motionCooldownMsField.step, motionCooldownMsField.default, maxSessionHoldMsField.min, maxSessionHoldMsField.max, maxSessionHoldMsField.step, maxSessionHoldMsField.default, audioMotionWindowMsField.min, audioMotionWindowMsField.max, audioMotionWindowMsField.step, audioMotionWindowMsField.default, occupancyRecheckSecField.min, occupancyRecheckSecField.max, occupancyRecheckSecField.step, occupancyRecheckSecField.default, occupancyRecheckFramesField.min, occupancyRecheckFramesField.max, occupancyRecheckFramesField.step, occupancyRecheckFramesField.default;
18242
18376
  /**
18243
18377
  * Runtime load summary returned by `getLocalLoad`. Used by the orchestrator's
18244
18378
  * load-balancing levels (L2 capacity-based, L3 hardware-aware) to decide
@@ -28085,6 +28219,12 @@ Object.freeze({
28085
28219
  addonId: null,
28086
28220
  access: "view"
28087
28221
  },
28222
+ "llm.resolveModelRef": {
28223
+ capName: "llm",
28224
+ capScope: "system",
28225
+ addonId: null,
28226
+ access: "create"
28227
+ },
28088
28228
  "llm.setDefault": {
28089
28229
  capName: "llm",
28090
28230
  capScope: "system",
@@ -45793,7 +45933,7 @@ function inferDocMediaType(uriOrName) {
45793
45933
  for (const [ext, media] of Object.entries(KNOWN_DOC_EXTENSIONS)) if (lower.endsWith(`.${ext}`)) return media;
45794
45934
  return "application/octet-stream";
45795
45935
  }
45796
- function basename$1(uriOrName) {
45936
+ function basename$2(uriOrName) {
45797
45937
  const parts = uriOrName.split("/");
45798
45938
  const last = parts[parts.length - 1];
45799
45939
  return last && last.length > 0 ? last : void 0;
@@ -45823,7 +45963,7 @@ function annotationToSource({ annotation, generateId: generateId3 }) {
45823
45963
  url: uri,
45824
45964
  ...fileCitation.file_name != null ? { title: fileCitation.file_name } : {}
45825
45965
  };
45826
- const filename = (_c = fileCitation.file_name) != null ? _c : basename$1(uri);
45966
+ const filename = (_c = fileCitation.file_name) != null ? _c : basename$2(uri);
45827
45967
  const mediaType = inferDocMediaType(uri);
45828
45968
  return {
45829
45969
  type: "source",
@@ -45912,7 +46052,7 @@ function builtinToolResultToSources({ block, generateId: generateId3 }) {
45912
46052
  });
45913
46053
  continue;
45914
46054
  }
45915
- const filename = (_h = entry.file_name) != null ? _h : basename$1(uri);
46055
+ const filename = (_h = entry.file_name) != null ? _h : basename$2(uri);
45916
46056
  const mediaType = inferDocMediaType(uri);
45917
46057
  sources.push({
45918
46058
  type: "source",
@@ -70119,6 +70259,469 @@ function resolveProfile(profiles, defaults, input) {
70119
70259
  };
70120
70260
  }
70121
70261
  //#endregion
70262
+ //#region src/runtime/hf-ref.ts
70263
+ /**
70264
+ * Hugging Face model references — parse, then resolve against the HF API.
70265
+ *
70266
+ * The operator types ONE string and gets a fully-pinned download plan. That is
70267
+ * the whole surface: this is not an HF browser, and it deliberately cannot
70268
+ * discover a model for you — it can only turn a reference you already have
70269
+ * into something the node can fetch and verify.
70270
+ *
70271
+ * ## Why resolution can REFUSE
70272
+ *
70273
+ * A GGUF repo is not one model. `unsloth/Qwen3.6-35B-A3B-GGUF` ships 25
70274
+ * quantizations between 10 GB and 50 GB, and none of them is named `Q4_K_M`
70275
+ * (they are `UD-Q4_K_M`, Unsloth's dynamic quant). Any code that "defaults to
70276
+ * Q4_K_M" would either fail or, worse, pick a neighbouring file and hand the
70277
+ * operator a model they did not ask for after a 20 GB download. So: a repo
70278
+ * with more than one candidate is an ERROR that NAMES the candidates, never a
70279
+ * guess. The only silent pick is the mmproj precision (F16 over F32) — that
70280
+ * choice costs a few hundred MB of projector, not a different model, and the
70281
+ * file it picked is reported back.
70282
+ *
70283
+ * ## The error taxonomy is read from headers, not from the status
70284
+ *
70285
+ * Probed live on 2026-08-15: huggingface.co answers **401** both for a gated
70286
+ * repo and for a repo that does not exist (it refuses to leak whether a
70287
+ * private repo is there). The two are distinguishable only by
70288
+ * `x-error-code: GatedRepo`. Reading the status alone would tell a
70289
+ * typo'd repo name that it needs a token, which is the wrong instruction.
70290
+ *
70291
+ * ## What is verified before a byte is downloaded
70292
+ *
70293
+ * host is huggingface.co · extension is `.gguf` · every file exists in the
70294
+ * tree · the split-GGUF shard set is COMPLETE · the total (main + shards +
70295
+ * mmproj) is under the ceiling · a HEAD confirms the file is reachable with
70296
+ * the credentials at hand and that its size agrees with the tree. The sha256
70297
+ * comes free: HF's LFS `oid` IS the sha256 of the file, and `x-linked-etag`
70298
+ * repeats it on the HEAD.
70299
+ */
70300
+ /** The only hosts a reference may point at. */
70301
+ var HF_HOSTS = ["huggingface.co", "www.huggingface.co"];
70302
+ var HF_API = "https://huggingface.co/api/models";
70303
+ var HF_RESOLVE = "https://huggingface.co";
70304
+ /** Where an operator puts a Hugging Face token, named in the gated error. */
70305
+ var HF_TOKEN_ENV = "HF_TOKEN";
70306
+ var SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
70307
+ function fail(code, message, candidates) {
70308
+ return {
70309
+ code,
70310
+ message,
70311
+ ...candidates !== void 0 ? { candidates } : {}
70312
+ };
70313
+ }
70314
+ function badParse(code, message, candidates) {
70315
+ return {
70316
+ ok: false,
70317
+ error: fail(code, message, candidates)
70318
+ };
70319
+ }
70320
+ var EXPECTED = "expected https://huggingface.co/<org>/<repo>/resolve/main/<file>.gguf, or <org>/<repo>/<file>.gguf, or <org>/<repo>[:<QUANT>]";
70321
+ /**
70322
+ * Reference string → a repo/file reference. Pure: no network, no environment.
70323
+ * Every rejection names the form that WAS expected, because the operator is
70324
+ * pasting from a browser and a bare "invalid" tells them nothing.
70325
+ */
70326
+ function parseHfRef(input) {
70327
+ const raw = input.trim();
70328
+ if (raw === "") return badParse("malformed", `empty model reference — ${EXPECTED}`);
70329
+ return raw.includes("://") ? parseUrlForm(raw) : parseBareForm(raw);
70330
+ }
70331
+ function parseUrlForm(raw) {
70332
+ let url;
70333
+ try {
70334
+ url = new URL(raw);
70335
+ } catch {
70336
+ return badParse("malformed", `not a URL: ${raw} — ${EXPECTED}`);
70337
+ }
70338
+ if (!HF_HOSTS.includes(url.hostname)) return badParse("not-huggingface", `only huggingface.co models can be installed this way; got host "${url.hostname}"`);
70339
+ const parts = url.pathname.split("/").filter((p) => p !== "");
70340
+ const marker = parts.findIndex((p) => p === "resolve" || p === "blob");
70341
+ if (marker !== 2 || parts.length < marker + 3) return badParse("malformed", `unrecognised Hugging Face URL: ${raw} — ${EXPECTED}`);
70342
+ return finishParse(`${String(parts[0])}/${String(parts[1])}`, String(parts[marker + 1]), parts.slice(marker + 2).join("/"), raw);
70343
+ }
70344
+ function parseBareForm(raw) {
70345
+ const [beforeTag, ...tagRest] = raw.split(":");
70346
+ const body = String(beforeTag);
70347
+ if (tagRest.length > 1) return badParse("malformed", `too many ":" in ${raw} — ${EXPECTED}`);
70348
+ const quant = tagRest[0]?.trim();
70349
+ const parts = body.split("/");
70350
+ if (parts.length < 2) return badParse("malformed", `not an <org>/<repo> reference: ${raw} — ${EXPECTED}`);
70351
+ if (parts.some((p) => p === "" || p === "." || p === "..")) return badParse("malformed", `illegal path segment in ${raw} — ${EXPECTED}`);
70352
+ const org = String(parts[0]);
70353
+ const name = String(parts[1]);
70354
+ if (!SEGMENT_RE.test(org) || !SEGMENT_RE.test(name)) return badParse("malformed", `illegal repo name in ${raw} — ${EXPECTED}`);
70355
+ const repo = `${org}/${name}`;
70356
+ if (parts.length === 2) {
70357
+ if (quant !== void 0 && quant === "") return badParse("malformed", `empty quantization tag in ${raw} — ${EXPECTED}`);
70358
+ return {
70359
+ ok: true,
70360
+ ref: {
70361
+ kind: "repo",
70362
+ repo,
70363
+ revision: "main",
70364
+ ...quant !== void 0 ? { quant } : {}
70365
+ }
70366
+ };
70367
+ }
70368
+ if (quant !== void 0) return badParse("malformed", `a quantization tag cannot follow an explicit file: ${raw}`);
70369
+ return finishParse(repo, "main", parts.slice(2).join("/"), raw);
70370
+ }
70371
+ function finishParse(repo, revision, filePath, raw) {
70372
+ if (filePath.split("/").some((p) => p === "" || p === "." || p === "..")) return badParse("malformed", `illegal path segment in ${raw} — ${EXPECTED}`);
70373
+ if (!filePath.toLowerCase().endsWith(".gguf")) return badParse("not-gguf", `the managed local runtime loads GGUF only; "${filePath}" is not a .gguf file`);
70374
+ return {
70375
+ ok: true,
70376
+ ref: {
70377
+ kind: "file",
70378
+ repo,
70379
+ revision,
70380
+ filePath
70381
+ }
70382
+ };
70383
+ }
70384
+ /** `-00001-of-00002` — llama.cpp's split-GGUF naming. */
70385
+ var SHARD_RE = /^(.*)-(\d{5})-of-(\d{5})$/;
70386
+ /**
70387
+ * One `-`-delimited segment that is a quantization, e.g. `Q4_K_M`, `IQ2_XXS`,
70388
+ * `BF16`, `fp16`. The `FP` spellings are not cosmetic: `Qwen/*-GGUF` names its
70389
+ * unquantized file `…-fp16.gguf`, and a tag list that cannot name it offers
70390
+ * the operator a suggestion that does not parse.
70391
+ */
70392
+ var QUANT_RE = /^(?:I?Q\d[A-Z0-9_]*|TQ\d_\d|BF16|FP?16|FP?32|FP8|MXFP4(?:_MOE)?)$/i;
70393
+ /** Shard coordinates of a split GGUF filename, or `null` when unsharded. */
70394
+ function shardInfoOf(filename) {
70395
+ const m = SHARD_RE.exec(stripGguf(filename));
70396
+ if (m === null) return null;
70397
+ return {
70398
+ stem: String(m[1]),
70399
+ index: Number(m[2]),
70400
+ total: Number(m[3])
70401
+ };
70402
+ }
70403
+ function stripGguf(filename) {
70404
+ return filename.replace(/\.gguf$/i, "");
70405
+ }
70406
+ /**
70407
+ * The quantization tag of a GGUF filename, uppercased, `UD-` prefix kept —
70408
+ * `''` when the name carries no recognisable tag. Shard coordinates are
70409
+ * stripped first so `X-BF16-00001-of-00002.gguf` reads as `BF16`.
70410
+ */
70411
+ function quantizationOf(filename) {
70412
+ const segments = (shardInfoOf(filename)?.stem ?? stripGguf(filename)).split("-");
70413
+ for (let i = segments.length - 1; i >= 0; i--) {
70414
+ const seg = String(segments[i]);
70415
+ if (!QUANT_RE.test(seg)) continue;
70416
+ return (i > 0 ? String(segments[i - 1]) : "").toUpperCase() === "UD" ? `UD-${seg.toUpperCase()}` : seg.toUpperCase();
70417
+ }
70418
+ return "";
70419
+ }
70420
+ function isMmproj(filePath) {
70421
+ return basename$1(filePath).toLowerCase().startsWith("mmproj");
70422
+ }
70423
+ function basename$1(filePath) {
70424
+ return filePath.slice(filePath.lastIndexOf("/") + 1);
70425
+ }
70426
+ function dirname(filePath) {
70427
+ const i = filePath.lastIndexOf("/");
70428
+ return i < 0 ? "" : filePath.slice(0, i);
70429
+ }
70430
+ function headersFor(token) {
70431
+ return {
70432
+ "User-Agent": "CamStack/1.0",
70433
+ ...token !== void 0 && token !== "" ? { Authorization: `Bearer ${token}` } : {}
70434
+ };
70435
+ }
70436
+ /** HF's 401-for-everything is only decodable through `x-error-code`. */
70437
+ function authError(response, repo) {
70438
+ const code = response.headers.get("x-error-code") ?? "";
70439
+ if (code === "GatedRepo" || code === "GatedRepoAccessDenied") return fail("gated", `${repo} is a gated Hugging Face repo: accept its licence with your HF account, then set a token in the ${HF_TOKEN_ENV} environment variable on the hub and on the runtime node (${HF_TOKEN_ENV} or HUGGING_FACE_HUB_TOKEN), and restart them.`);
70440
+ return fail("repo-not-found", `${repo} does not exist on huggingface.co, or is private (Hugging Face answers 401 for both). Check the org/repo spelling.`);
70441
+ }
70442
+ async function readTree(ref, fetchFn, token) {
70443
+ const url = `${HF_API}/${ref.repo}/tree/${ref.revision}?recursive=1`;
70444
+ let response;
70445
+ try {
70446
+ response = await fetchFn(url, {
70447
+ method: "GET",
70448
+ headers: headersFor(token)
70449
+ });
70450
+ } catch (err) {
70451
+ return {
70452
+ ok: false,
70453
+ error: fail("network", `could not reach huggingface.co: ${message(err)}`)
70454
+ };
70455
+ }
70456
+ if (response.status === 401 || response.status === 403) return {
70457
+ ok: false,
70458
+ error: authError(response, ref.repo)
70459
+ };
70460
+ if (response.status === 404) return {
70461
+ ok: false,
70462
+ error: fail("repo-not-found", `${ref.repo} has no revision "${ref.revision}"`)
70463
+ };
70464
+ if (!response.ok) return {
70465
+ ok: false,
70466
+ error: fail("network", `huggingface.co answered ${String(response.status)} for ${ref.repo}`)
70467
+ };
70468
+ let body;
70469
+ try {
70470
+ body = await response.json();
70471
+ } catch (err) {
70472
+ return {
70473
+ ok: false,
70474
+ error: fail("network", `unreadable tree for ${ref.repo}: ${message(err)}`)
70475
+ };
70476
+ }
70477
+ if (!Array.isArray(body)) return {
70478
+ ok: false,
70479
+ error: fail("network", `unexpected tree payload for ${ref.repo}`)
70480
+ };
70481
+ return {
70482
+ ok: true,
70483
+ files: body.map(toTreeEntry).filter((e) => e !== null)
70484
+ };
70485
+ }
70486
+ function toTreeEntry(raw) {
70487
+ if (typeof raw !== "object" || raw === null) return null;
70488
+ const record = { ...raw };
70489
+ if (record["type"] !== "file") return null;
70490
+ const filePath = record["path"];
70491
+ if (typeof filePath !== "string" || !filePath.toLowerCase().endsWith(".gguf")) return null;
70492
+ const lfs = typeof record["lfs"] === "object" && record["lfs"] !== null ? { ...record["lfs"] } : {};
70493
+ const lfsSize = lfs["size"];
70494
+ const oid = lfs["oid"];
70495
+ const plainSize = record["size"];
70496
+ return {
70497
+ path: filePath,
70498
+ sizeBytes: typeof lfsSize === "number" ? lfsSize : typeof plainSize === "number" ? plainSize : 0,
70499
+ ...typeof oid === "string" && oid.length === 64 ? { sha256: oid } : {}
70500
+ };
70501
+ }
70502
+ function message(err) {
70503
+ return err instanceof Error ? err.message : String(err);
70504
+ }
70505
+ /** Files that can be THE model: not a projector, not a follow-on shard. */
70506
+ function modelCandidates(files) {
70507
+ return files.filter((f) => {
70508
+ if (isMmproj(f.path)) return false;
70509
+ const shard = shardInfoOf(basename$1(f.path));
70510
+ return shard === null || shard.index === 1;
70511
+ });
70512
+ }
70513
+ function labelFor(file) {
70514
+ const quant = quantizationOf(basename$1(file.path));
70515
+ return quant === "" ? basename$1(file.path) : quant;
70516
+ }
70517
+ function selectMain(ref, files) {
70518
+ const candidates = modelCandidates(files);
70519
+ if (ref.kind === "file") {
70520
+ const wanted = ref.filePath.toLowerCase();
70521
+ const hit = files.find((f) => f.path.toLowerCase() === wanted);
70522
+ if (hit === void 0) return {
70523
+ ok: false,
70524
+ error: fail("file-not-found", `${ref.repo} has no file "${ref.filePath}" at revision ${ref.revision}`, candidates.map(labelFor))
70525
+ };
70526
+ return {
70527
+ ok: true,
70528
+ file: hit
70529
+ };
70530
+ }
70531
+ if (candidates.length === 0) return {
70532
+ ok: false,
70533
+ error: fail("not-gguf", `${ref.repo} publishes no GGUF weights (only projectors or no GGUF at all)`)
70534
+ };
70535
+ if (ref.quant !== void 0) {
70536
+ const wanted = ref.quant.toUpperCase();
70537
+ const wantedFile = stripGguf(ref.quant).toUpperCase();
70538
+ const matches = candidates.filter((f) => quantizationOf(basename$1(f.path)) === wanted || stripGguf(basename$1(f.path)).toUpperCase() === wantedFile);
70539
+ if (matches.length === 0) return {
70540
+ ok: false,
70541
+ error: fail("file-not-found", `${ref.repo} has no "${ref.quant}" quantization. Available: ${candidates.map(labelFor).join(", ")}`, dedupe(candidates.map(labelFor)))
70542
+ };
70543
+ const only = matches[0];
70544
+ if (matches.length > 1 || only === void 0) return {
70545
+ ok: false,
70546
+ error: fail("ambiguous", `"${ref.quant}" matches ${String(matches.length)} files in ${ref.repo}: ${matches.map((f) => basename$1(f.path)).join(", ")}. Name the file explicitly.`, matches.map((f) => basename$1(f.path)))
70547
+ };
70548
+ return {
70549
+ ok: true,
70550
+ file: only
70551
+ };
70552
+ }
70553
+ const solo = candidates[0];
70554
+ if (candidates.length > 1 || solo === void 0) {
70555
+ const tags = dedupe(candidates.map(labelFor));
70556
+ return {
70557
+ ok: false,
70558
+ error: fail("ambiguous", `${ref.repo} publishes ${String(candidates.length)} quantizations and picking one for you would be a guess. Re-enter it as ${ref.repo}:<TAG>, or paste the full file URL. Available: ${tags.join(", ")}`, tags)
70559
+ };
70560
+ }
70561
+ return {
70562
+ ok: true,
70563
+ file: solo
70564
+ };
70565
+ }
70566
+ function dedupe(values) {
70567
+ return [...new Set(values)];
70568
+ }
70569
+ /** Shards 2..N of `main`, or an error naming the first one that is missing. */
70570
+ function collectShards(main, files) {
70571
+ const shard = shardInfoOf(basename$1(main.path));
70572
+ if (shard === null || shard.total <= 1) return {
70573
+ ok: true,
70574
+ shards: []
70575
+ };
70576
+ const dir = dirname(main.path);
70577
+ const out = [];
70578
+ for (let i = 2; i <= shard.total; i++) {
70579
+ const wanted = `${shard.stem}-${String(i).padStart(5, "0")}-of-${String(shard.total).padStart(5, "0")}.gguf`;
70580
+ const full = dir === "" ? wanted : `${dir}/${wanted}`;
70581
+ const hit = files.find((f) => f.path === full);
70582
+ if (hit === void 0) return {
70583
+ ok: false,
70584
+ error: fail("incomplete-shards", `split GGUF is incomplete: ${wanted} is missing from the repo (llama.cpp needs all ${String(shard.total)} shards)`)
70585
+ };
70586
+ out.push(hit);
70587
+ }
70588
+ return {
70589
+ ok: true,
70590
+ shards: out
70591
+ };
70592
+ }
70593
+ /** F16 over BF16 over F32 over whatever came first — reported, never hidden. */
70594
+ var MMPROJ_PREFERENCE = [
70595
+ "F16",
70596
+ "BF16",
70597
+ "F32"
70598
+ ];
70599
+ function selectMmproj(files) {
70600
+ const projectors = files.filter((f) => isMmproj(f.path));
70601
+ if (projectors.length === 0) return null;
70602
+ for (const want of MMPROJ_PREFERENCE) {
70603
+ const hit = projectors.find((f) => quantizationOf(basename$1(f.path)) === want);
70604
+ if (hit !== void 0) return hit;
70605
+ }
70606
+ return projectors[0] ?? null;
70607
+ }
70608
+ function resolveUrl(repo, revision, filePath) {
70609
+ return `${HF_RESOLVE}/${repo}/resolve/${revision}/${filePath}`;
70610
+ }
70611
+ async function verifyHead(url, repo, declaredBytes, fetchFn, token) {
70612
+ let response;
70613
+ try {
70614
+ response = await fetchFn(url, {
70615
+ method: "HEAD",
70616
+ redirect: "manual",
70617
+ headers: headersFor(token)
70618
+ });
70619
+ } catch (err) {
70620
+ return {
70621
+ ok: false,
70622
+ error: fail("network", `HEAD ${url} failed: ${message(err)}`)
70623
+ };
70624
+ }
70625
+ if (response.status === 401 || response.status === 403) return {
70626
+ ok: false,
70627
+ error: authError(response, repo)
70628
+ };
70629
+ if (response.status === 404) return {
70630
+ ok: false,
70631
+ error: fail("file-not-found", `${url} is gone (404)`)
70632
+ };
70633
+ if (response.status >= 400) return {
70634
+ ok: false,
70635
+ error: fail("network", `HEAD ${url} answered ${String(response.status)}`)
70636
+ };
70637
+ const linked = response.headers.get("x-linked-size") ?? response.headers.get("content-length");
70638
+ const headBytes = linked === null ? void 0 : Number(linked);
70639
+ if (headBytes !== void 0 && Number.isFinite(headBytes) && headBytes !== declaredBytes) return {
70640
+ ok: false,
70641
+ error: fail("size-mismatch", `huggingface.co reports ${String(headBytes)} bytes for ${url} but its tree declared ${String(declaredBytes)} — refusing to download a file that changed under the reference`)
70642
+ };
70643
+ const etag = response.headers.get("x-linked-etag")?.replace(/"/g, "");
70644
+ return {
70645
+ ok: true,
70646
+ ...etag !== void 0 && etag.length === 64 ? { sha256: etag } : {}
70647
+ };
70648
+ }
70649
+ function toResolved(repo, revision, entry) {
70650
+ return {
70651
+ url: resolveUrl(repo, revision, entry.path),
70652
+ filename: basename$1(entry.path),
70653
+ sizeBytes: entry.sizeBytes,
70654
+ ...entry.sha256 !== void 0 ? { sha256: entry.sha256 } : {}
70655
+ };
70656
+ }
70657
+ function gb$1(bytes) {
70658
+ return `${(bytes / 1e9).toFixed(1)} GB`;
70659
+ }
70660
+ /** Reference → a pinned, size-checked, HEAD-verified download plan. */
70661
+ async function resolveHfRef(ref, deps) {
70662
+ const fetchFn = deps.fetchFn ?? fetch;
70663
+ const maxBytes = deps.maxBytes ?? 21474836480;
70664
+ const tree = await readTree(ref, fetchFn, deps.token);
70665
+ if (!tree.ok) return {
70666
+ ok: false,
70667
+ error: tree.error
70668
+ };
70669
+ const picked = selectMain(ref, tree.files);
70670
+ if (!picked.ok) return {
70671
+ ok: false,
70672
+ error: picked.error
70673
+ };
70674
+ const main = picked.file;
70675
+ const shards = collectShards(main, tree.files);
70676
+ if (!shards.ok) return {
70677
+ ok: false,
70678
+ error: shards.error
70679
+ };
70680
+ const projector = isMmproj(main.path) ? null : selectMmproj(tree.files);
70681
+ const extraEntries = [...shards.shards, ...projector === null ? [] : [projector]];
70682
+ const totalBytes = [main, ...extraEntries].reduce((sum, f) => sum + f.sizeBytes, 0);
70683
+ if (totalBytes > maxBytes) return {
70684
+ ok: false,
70685
+ error: {
70686
+ ...fail("too-large", `this install is ${String(totalBytes)} bytes (${gb$1(totalBytes)}), over the ${String(maxBytes)} byte ceiling (${gb$1(maxBytes)}). Raise the ceiling explicitly if the node really has the disk and RAM for it.`),
70687
+ requiredBytes: totalBytes
70688
+ }
70689
+ };
70690
+ const head = await verifyHead(resolveUrl(ref.repo, ref.revision, main.path), ref.repo, main.sizeBytes, fetchFn, deps.token);
70691
+ if (!head.ok) return {
70692
+ ok: false,
70693
+ error: head.error
70694
+ };
70695
+ const mainResolved = toResolved(ref.repo, ref.revision, {
70696
+ ...main,
70697
+ ...main.sha256 === void 0 && head.sha256 !== void 0 ? { sha256: head.sha256 } : {}
70698
+ });
70699
+ const quantization = quantizationOf(mainResolved.filename);
70700
+ const repoName = ref.repo.slice(ref.repo.indexOf("/") + 1);
70701
+ return {
70702
+ ok: true,
70703
+ resolution: {
70704
+ repo: ref.repo,
70705
+ revision: ref.revision,
70706
+ label: quantization === "" ? repoName : `${repoName} · ${quantization}`,
70707
+ quantization,
70708
+ purpose: projector === null ? "text" : "vision",
70709
+ main: mainResolved,
70710
+ extras: extraEntries.map((e) => toResolved(ref.repo, ref.revision, e)),
70711
+ totalBytes
70712
+ }
70713
+ };
70714
+ }
70715
+ /** `parseHfRef` then {@link resolveHfRef} — the form the cap method calls. */
70716
+ async function resolveHfReference(input, deps) {
70717
+ const parsed = parseHfRef(input);
70718
+ if (!parsed.ok) return {
70719
+ ok: false,
70720
+ error: parsed.error
70721
+ };
70722
+ return resolveHfRef(parsed.ref, deps);
70723
+ }
70724
+ //#endregion
70122
70725
  //#region src/secrets.ts
70123
70726
  /** Same marker as addon-notifiers/src/secrets.ts — UI contract. */
70124
70727
  var REDACTED_MARKER = "__redacted__";
@@ -70342,6 +70945,64 @@ function createLlmProvider(deps) {
70342
70945
  }));
70343
70946
  },
70344
70947
  listNodeModels: async ({ nodeId }) => requireRuntime(deps.runtime).listLocalModels(nodeId),
70948
+ /**
70949
+ * Hugging Face reference → a pinned `ManagedModelRef`, on the HUB.
70950
+ *
70951
+ * Never throws: a refusal ("this repo has 24 quantizations", "this is
70952
+ * gated", "23 GB is over the ceiling") is an ANSWER the operator has to
70953
+ * read and act on, and turning it into a tRPC error would reduce all of
70954
+ * them to a red toast with no candidate list and no override.
70955
+ */
70956
+ resolveModelRef: async ({ ref, maxBytes }) => {
70957
+ const token = deps.hfToken?.();
70958
+ const outcome = await resolveHfReference(ref, {
70959
+ ...maxBytes !== void 0 ? { maxBytes } : {},
70960
+ ...token !== void 0 && token !== "" ? { token } : {}
70961
+ });
70962
+ if (!outcome.ok) {
70963
+ deps.logger?.info("llm model reference refused", { meta: {
70964
+ ref,
70965
+ code: outcome.error.code
70966
+ } });
70967
+ return {
70968
+ ok: false,
70969
+ code: outcome.error.code,
70970
+ message: outcome.error.message,
70971
+ ...outcome.error.candidates !== void 0 ? { candidates: [...outcome.error.candidates] } : {},
70972
+ ...outcome.error.requiredBytes !== void 0 ? { requiredBytes: outcome.error.requiredBytes } : {}
70973
+ };
70974
+ }
70975
+ const { resolution } = outcome;
70976
+ deps.logger?.info("llm model reference resolved", { meta: {
70977
+ ref,
70978
+ repo: resolution.repo,
70979
+ quantization: resolution.quantization,
70980
+ purpose: resolution.purpose,
70981
+ totalBytes: resolution.totalBytes
70982
+ } });
70983
+ return {
70984
+ ok: true,
70985
+ model: {
70986
+ kind: "url",
70987
+ url: resolution.main.url,
70988
+ ...resolution.main.sha256 !== void 0 ? { sha256: resolution.main.sha256 } : {},
70989
+ label: resolution.label,
70990
+ sizeBytes: resolution.main.sizeBytes,
70991
+ extraFiles: resolution.extras.map((e) => ({
70992
+ url: e.url,
70993
+ filename: e.filename,
70994
+ sizeBytes: e.sizeBytes,
70995
+ ...e.sha256 !== void 0 ? { sha256: e.sha256 } : {}
70996
+ }))
70997
+ },
70998
+ label: resolution.label,
70999
+ repo: resolution.repo,
71000
+ quantization: resolution.quantization,
71001
+ purpose: resolution.purpose,
71002
+ totalBytes: resolution.totalBytes,
71003
+ extraFilenames: resolution.extras.map((e) => e.filename)
71004
+ };
71005
+ },
70345
71006
  installModel: async ({ nodeId, model }) => {
70346
71007
  const runtime = requireRuntime(deps.runtime);
70347
71008
  try {
@@ -70375,13 +71036,29 @@ function createLlmProvider(deps) {
70375
71036
  //#endregion
70376
71037
  //#region src/runtime/llm-model-catalog.ts
70377
71038
  /**
70378
- * Curated managed-model catalog (operator decision #4): ~2 small text GGUFs
70379
- * (2-4B, Q4) + 1 small vision GGUF with its companion mmproj, sized to the
70380
- * weakest runtime node (the N100 agent). Each entry carries BOTH the LLM-facing
70381
- * picker view (`meta`) and the REUSED download-plane `ModelCatalogEntry`
70382
- * (`entry`) so GGUFs ride `ensureModel` + `model-distributor` untouched — no
70383
- * bespoke fetcher (spec §4.2). Digests/sizes pinned via
70384
- * scratchpad/pin-llm-models.mjs (HF LFS `lfs.oid`).
71039
+ * Curated managed-model catalog (operator decision #4). Each entry carries BOTH
71040
+ * the LLM-facing picker view (`meta`) and the REUSED download-plane
71041
+ * `ModelCatalogEntry` (`entry`) so GGUFs ride `ensureModel` +
71042
+ * `model-distributor` untouched no bespoke fetcher (spec §4.2).
71043
+ * Digests/sizes pinned via scratchpad/pin-llm-models.mjs (HF LFS `lfs.oid`,
71044
+ * which IS the file's sha256).
71045
+ *
71046
+ * ## Two tiers, and `minRamBytes` is what separates them
71047
+ *
71048
+ * The first three entries are sized to the WEAKEST runtime node (the N100
71049
+ * agent): 1-4 GB, Q4. `QWEN36_35B` is not — it is 23 GB and only a big node
71050
+ * can hold it. The catalog does not refuse to show it; `minRamBytes` is the
71051
+ * guidance, and the picker prints the size. Keeping the tiers in one list is
71052
+ * deliberate: an operator with a 64 GB box should not have to discover the
71053
+ * free-text field to run something real.
71054
+ *
71055
+ * ## This list is no longer the boundary of what can run
71056
+ *
71057
+ * Anything on Hugging Face is installable through `llm.resolveModelRef` +
71058
+ * `installModel` without a code change ({@link ./hf-ref.ts}). An entry here
71059
+ * buys exactly two things over typing the reference: a pinned digest nobody
71060
+ * has to re-verify, and a `contextSizeDefault`/`minRamBytes` somebody checked.
71061
+ * Add one only when both are true.
70385
71062
  */
70386
71063
  var GIB = 1024 * 1024 * 1024;
70387
71064
  function mb(bytes) {
@@ -70433,30 +71110,117 @@ var LLAMA = textEntry({
70433
71110
  var SMOLVLM_MODEL_BYTES = 1112602656;
70434
71111
  var SMOLVLM_MMPROJ_BYTES = 872303680;
70435
71112
  var SMOLVLM_MMPROJ_URL = "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/mmproj-SmolVLM2-2.2B-Instruct-f16.gguf";
71113
+ var SMOLVLM = {
71114
+ meta: {
71115
+ id: "llm-smolvlm2-2.2b-instruct-q4",
71116
+ label: "SmolVLM2 2.2B Instruct (vision)",
71117
+ family: "smolvlm2",
71118
+ purpose: "vision",
71119
+ url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
71120
+ sha256: "0cf76814555b8665149075b74ab6b5c1d428ea1d3d01c1918c12012e8d7c9f58",
71121
+ sizeBytes: SMOLVLM_MODEL_BYTES,
71122
+ quantization: "Q4_K_M",
71123
+ minRamBytes: 4 * GIB,
71124
+ contextSizeDefault: 4096,
71125
+ mmprojUrl: SMOLVLM_MMPROJ_URL
71126
+ },
71127
+ entry: {
71128
+ id: "llm-smolvlm2-2.2b-instruct-q4",
71129
+ name: "SmolVLM2 2.2B Instruct (vision)",
71130
+ description: "smolvlm2 · Q4_K_M · +mmproj",
71131
+ formats: { gguf: {
71132
+ url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
71133
+ sizeMB: mb(SMOLVLM_MODEL_BYTES)
71134
+ } },
71135
+ inputSize: {
71136
+ width: 0,
71137
+ height: 0
71138
+ },
71139
+ labels: [],
71140
+ extraFiles: [{
71141
+ url: SMOLVLM_MMPROJ_URL,
71142
+ filename: "mmproj-SmolVLM2-2.2B-Instruct-f16.gguf",
71143
+ sizeMB: mb(SMOLVLM_MMPROJ_BYTES)
71144
+ }]
71145
+ }
71146
+ };
71147
+ var QWEN3VL2B_MODEL_BYTES = 1107410624;
71148
+ var QWEN3VL2B_MMPROJ_BYTES = 819395232;
71149
+ var QWEN3VL2B_BASE = "https://huggingface.co/unsloth/Qwen3-VL-2B-Instruct-GGUF/resolve/main";
71150
+ var QWEN3VL2B_MMPROJ_URL = `${QWEN3VL2B_BASE}/mmproj-F16.gguf`;
71151
+ var QWEN3VL2B_URL = `${QWEN3VL2B_BASE}/Qwen3-VL-2B-Instruct-Q4_K_M.gguf`;
71152
+ /**
71153
+ * The light vision tier the operator asked for by weight class (~2 GB all in):
71154
+ * same Qwen3-VL family as the LM Studio 8B profile already in daily use, so
71155
+ * prompts and behaviour carry over — at a tenth of the 35B's disk and a RAM
71156
+ * floor a hub-adjacent node can always afford. This is the sensible default
71157
+ * for the NC confirm gates and summary judges.
71158
+ */
71159
+ var QWEN3VL_2B = {
71160
+ meta: {
71161
+ id: "llm-qwen3-vl-2b-instruct-q4",
71162
+ label: "Qwen3-VL 2B Instruct (vision, light)",
71163
+ family: "qwen3-vl",
71164
+ purpose: "vision",
71165
+ url: QWEN3VL2B_URL,
71166
+ sha256: "858fcf2a39dc73b26dd86592cb0a5f949b59d1edb365d1dea98e46b02e955e56",
71167
+ sizeBytes: QWEN3VL2B_MODEL_BYTES,
71168
+ quantization: "Q4_K_M",
71169
+ minRamBytes: 3 * GIB,
71170
+ contextSizeDefault: 8192,
71171
+ mmprojUrl: QWEN3VL2B_MMPROJ_URL
71172
+ },
71173
+ entry: {
71174
+ id: "llm-qwen3-vl-2b-instruct-q4",
71175
+ name: "Qwen3-VL 2B Instruct (vision, light)",
71176
+ description: "qwen3-vl · Q4_K_M · +mmproj",
71177
+ formats: { gguf: {
71178
+ url: QWEN3VL2B_URL,
71179
+ sizeMB: mb(QWEN3VL2B_MODEL_BYTES)
71180
+ } },
71181
+ inputSize: {
71182
+ width: 0,
71183
+ height: 0
71184
+ },
71185
+ labels: [],
71186
+ extraFiles: [{
71187
+ url: QWEN3VL2B_MMPROJ_URL,
71188
+ filename: "mmproj-F16.gguf",
71189
+ sizeMB: mb(QWEN3VL2B_MMPROJ_BYTES)
71190
+ }]
71191
+ }
71192
+ };
71193
+ var QWEN36_MODEL_BYTES = 22134528992;
71194
+ var QWEN36_MMPROJ_BYTES = 899283680;
71195
+ var QWEN36_BASE = "https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF/resolve/main";
71196
+ var QWEN36_MMPROJ_URL = `${QWEN36_BASE}/mmproj-F16.gguf`;
71197
+ var QWEN36_URL = `${QWEN36_BASE}/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf`;
70436
71198
  var LLM_MODEL_CATALOG = [
70437
71199
  QWEN,
70438
71200
  LLAMA,
71201
+ SMOLVLM,
71202
+ QWEN3VL_2B,
70439
71203
  {
70440
71204
  meta: {
70441
- id: "llm-smolvlm2-2.2b-instruct-q4",
70442
- label: "SmolVLM2 2.2B Instruct (vision)",
70443
- family: "smolvlm2",
71205
+ id: "llm-qwen3.6-35b-a3b-ud-q4",
71206
+ label: "Qwen3.6 35B-A3B (vision)",
71207
+ family: "qwen3.6",
70444
71208
  purpose: "vision",
70445
- url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
70446
- sha256: "0cf76814555b8665149075b74ab6b5c1d428ea1d3d01c1918c12012e8d7c9f58",
70447
- sizeBytes: SMOLVLM_MODEL_BYTES,
70448
- quantization: "Q4_K_M",
70449
- minRamBytes: 4 * GIB,
70450
- contextSizeDefault: 4096,
70451
- mmprojUrl: SMOLVLM_MMPROJ_URL
71209
+ url: QWEN36_URL,
71210
+ sha256: "ac0e2c1189e055faa36eff361580e79c5bd6f8e76bffb4ce547f167d53e31a61",
71211
+ sizeBytes: QWEN36_MODEL_BYTES,
71212
+ quantization: "UD-Q4_K_M",
71213
+ minRamBytes: 26 * GIB,
71214
+ contextSizeDefault: 32768,
71215
+ mmprojUrl: QWEN36_MMPROJ_URL
70452
71216
  },
70453
71217
  entry: {
70454
- id: "llm-smolvlm2-2.2b-instruct-q4",
70455
- name: "SmolVLM2 2.2B Instruct (vision)",
70456
- description: "smolvlm2 · Q4_K_M · +mmproj",
71218
+ id: "llm-qwen3.6-35b-a3b-ud-q4",
71219
+ name: "Qwen3.6 35B-A3B (vision)",
71220
+ description: "qwen3.6 · UD-Q4_K_M · +mmproj",
70457
71221
  formats: { gguf: {
70458
- url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
70459
- sizeMB: mb(SMOLVLM_MODEL_BYTES)
71222
+ url: QWEN36_URL,
71223
+ sizeMB: mb(QWEN36_MODEL_BYTES)
70460
71224
  } },
70461
71225
  inputSize: {
70462
71226
  width: 0,
@@ -70464,9 +71228,9 @@ var LLM_MODEL_CATALOG = [
70464
71228
  },
70465
71229
  labels: [],
70466
71230
  extraFiles: [{
70467
- url: SMOLVLM_MMPROJ_URL,
70468
- filename: "mmproj-SmolVLM2-2.2B-Instruct-f16.gguf",
70469
- sizeMB: mb(SMOLVLM_MMPROJ_BYTES)
71231
+ url: QWEN36_MMPROJ_URL,
71232
+ filename: "mmproj-F16.gguf",
71233
+ sizeMB: mb(QWEN36_MMPROJ_BYTES)
70470
71234
  }]
70471
71235
  }
70472
71236
  }
@@ -70483,19 +71247,25 @@ function entryForRef(ref) {
70483
71247
  }
70484
71248
  if (ref.kind === "url") {
70485
71249
  const id = `llm-custom-${createHash("sha1").update(ref.url).digest("hex").slice(0, 12)}`;
71250
+ const extraFiles = (ref.extraFiles ?? []).map((f) => ({
71251
+ url: f.url,
71252
+ filename: f.filename,
71253
+ sizeMB: mb(f.sizeBytes)
71254
+ }));
70486
71255
  return { entry: {
70487
71256
  id,
70488
- name: id,
71257
+ name: ref.label ?? id,
70489
71258
  description: "custom GGUF",
70490
71259
  formats: { gguf: {
70491
71260
  url: ref.url,
70492
- sizeMB: 0
71261
+ sizeMB: ref.sizeBytes === void 0 ? 0 : mb(ref.sizeBytes)
70493
71262
  } },
70494
71263
  inputSize: {
70495
71264
  width: 0,
70496
71265
  height: 0
70497
71266
  },
70498
- labels: []
71267
+ labels: [],
71268
+ ...extraFiles.length > 0 ? { extraFiles } : {}
70499
71269
  } };
70500
71270
  }
70501
71271
  const id = `llm-path-${createHash("sha1").update(ref.path).digest("hex").slice(0, 12)}`;
@@ -70533,10 +71303,6 @@ function isNonEmptyFile(filePath) {
70533
71303
  function siblingFilesFor(formatEntry) {
70534
71304
  return formatEntry.isDirectory ? [] : formatEntry.files ?? [];
70535
71305
  }
70536
- /** Resolve a sibling's remote URL relative to the main file's directory. */
70537
- function siblingUrl(mainUrl, sibling) {
70538
- return mainUrl.replace(/[^/]+$/, sibling);
70539
- }
70540
71306
  /** Build fetch headers, including HF auth token for huggingface.co URLs */
70541
71307
  function buildHeaders(url) {
70542
71308
  const headers = { "User-Agent": "CamStack/1.0" };
@@ -70589,77 +71355,6 @@ async function downloadFile(url, destPath, onProgress) {
70589
71355
  throw err;
70590
71356
  }
70591
71357
  }
70592
- /**
70593
- * Download every file in a HuggingFace directory bundle (e.g.,
70594
- * `.mlpackage` / OpenVINO IR pair) atomically. `knownFiles` lists the
70595
- * relative paths inside the directory; the function fetches each from
70596
- * `${url}/${file}` and renames the staging directory only on full
70597
- * success. Mirrors `ModelDownloadService.downloadDirectory` but
70598
- * exposed as a standalone for catalog-less callers.
70599
- */
70600
- async function downloadDirectory(url, destDir, knownFiles, onProgress) {
70601
- const match = url.match(/huggingface\.co\/([^/]+\/[^/]+)\/resolve\/main\/(.+)/);
70602
- if (!match) throw new Error(`Cannot parse HuggingFace URL: ${url}`);
70603
- const [, repo, dirPath] = match;
70604
- const files = (knownFiles ?? []).map((f) => ({
70605
- relativePath: f,
70606
- fileUrl: `https://huggingface.co/${repo}/resolve/main/${dirPath}/${f}`
70607
- }));
70608
- if (files.length === 0) throw new Error(`Directory bundle requires explicit \`files\` list (got none for ${url})`);
70609
- const tmpDir = destDir + ".downloading";
70610
- fs.rmSync(tmpDir, {
70611
- recursive: true,
70612
- force: true
70613
- });
70614
- fs.mkdirSync(tmpDir, { recursive: true });
70615
- let totalDownloaded = 0;
70616
- try {
70617
- for (const file of files) {
70618
- const destPath = path$1.join(tmpDir, file.relativePath);
70619
- fs.mkdirSync(path$1.dirname(destPath), { recursive: true });
70620
- await downloadFile(file.fileUrl, destPath, (downloaded, _total) => {
70621
- onProgress?.(totalDownloaded + downloaded, void 0);
70622
- });
70623
- totalDownloaded += fs.statSync(destPath).size;
70624
- }
70625
- fs.rmSync(destDir, {
70626
- recursive: true,
70627
- force: true
70628
- });
70629
- fs.renameSync(tmpDir, destDir);
70630
- } catch (err) {
70631
- fs.rmSync(tmpDir, {
70632
- recursive: true,
70633
- force: true
70634
- });
70635
- throw err;
70636
- }
70637
- }
70638
- /**
70639
- * Resolve a `ModelCatalogEntry` against `modelsDir`: download model file
70640
- * (or directory bundle) + extra files (labels JSON, charset dict, …),
70641
- * skip if already on disk. Returns the local model path.
70642
- */
70643
- async function ensureModel(modelsDir, entry, format, onProgress) {
70644
- const formatEntry = entry.formats[format];
70645
- if (!formatEntry) throw new Error(`Model "${entry.id}" has no ${format} format. Available: ${Object.keys(entry.formats).join(", ")}`);
70646
- if (entry.extraFiles) for (const extra of entry.extraFiles) await downloadFile(extra.url, path$1.join(modelsDir, extra.filename));
70647
- const filename = formatEntry.url.split("/").pop() ?? `${entry.id}.${format}`;
70648
- const modelPath = path$1.join(modelsDir, filename);
70649
- const siblings = siblingFilesFor(formatEntry);
70650
- if (fs.existsSync(modelPath)) if (formatEntry.isDirectory && !fs.existsSync(path$1.join(modelPath, "Manifest.json"))) fs.rmSync(modelPath, {
70651
- recursive: true,
70652
- force: true
70653
- });
70654
- else if (siblings.some((f) => !isNonEmptyFile(path$1.join(modelsDir, f)))) {} else return modelPath;
70655
- fs.mkdirSync(modelsDir, { recursive: true });
70656
- if (formatEntry.isDirectory) await downloadDirectory(formatEntry.url, modelPath, formatEntry.files, onProgress);
70657
- else {
70658
- await downloadFile(formatEntry.url, modelPath, (downloaded, total) => onProgress?.(downloaded, total === 0 ? void 0 : total));
70659
- for (const sibling of siblings) await downloadFile(siblingUrl(formatEntry.url, sibling), path$1.join(modelsDir, sibling));
70660
- }
70661
- return modelPath;
70662
- }
70663
71358
  /** Compute the on-disk path for a given model + format, even when not yet downloaded. */
70664
71359
  function getModelFilePath(modelsDir, entry, format) {
70665
71360
  const formatEntry = entry.formats[format];
@@ -70703,13 +71398,79 @@ promisify(gzip);
70703
71398
  * Default `RuntimeModelOps` — the ONLY place the reused object-detection model
70704
71399
  * mechanism is imported (the documented `@camstack/system/addon-utils`
70705
71400
  * build-time-dep waiver that addon-post-analysis/addon-pipeline already use).
70706
- * GGUFs ride `ensureModel`/`isModelDownloaded`/`deleteModelFromDisk` untouched
70707
- * (spec §4.2) — no bespoke fetcher.
71401
+ * GGUFs ride the SHARED `downloadFile` (atomic `.downloading` + rename, HF
71402
+ * token headers, redirect following) — no bespoke fetcher (spec §4.2).
71403
+ *
71404
+ * ## Why this drives the file loop instead of calling `ensureModel`
71405
+ *
71406
+ * `ensureModel` downloads `extraFiles` FIRST and passes them NO progress
71407
+ * callback. That is invisible for a 40 kB labels JSON and unacceptable here: a
71408
+ * GGUF install is a 22 GB main file, up to N shards, and a 0.9 GB mmproj, and
71409
+ * under `ensureModel` every byte outside the main file moves in silence. A
71410
+ * multi-GB download that reports nothing reads as a hung node — the repo rule
71411
+ * is that a branch doing real work says so.
71412
+ *
71413
+ * So the loop is here, over the SAME `downloadFile`. What is gained: bytes
71414
+ * aggregated across the whole install, the name of the file currently moving,
71415
+ * and files already on disk excluded from the total rather than counted as
71416
+ * instantly-complete.
70708
71417
  */
70709
71418
  var GGUF = "gguf";
71419
+ var BYTES_PER_MB = 1024 * 1024;
71420
+ /**
71421
+ * Main file first, then shards/mmproj. Deliberate: a gated or mistyped URL
71422
+ * fails on the file that matters before 0.9 GB of projector is spent on it.
71423
+ */
71424
+ function planFiles(modelsDir, entry) {
71425
+ const out = [];
71426
+ const formatEntry = entry.formats[GGUF];
71427
+ if (formatEntry !== void 0) {
71428
+ const filename = formatEntry.url.split("/").pop() ?? `${entry.id}.${GGUF}`;
71429
+ out.push({
71430
+ url: formatEntry.url,
71431
+ destPath: path$1.join(modelsDir, filename),
71432
+ filename,
71433
+ expectedBytes: formatEntry.sizeMB * BYTES_PER_MB
71434
+ });
71435
+ }
71436
+ for (const extra of entry.extraFiles ?? []) out.push({
71437
+ url: extra.url,
71438
+ destPath: path$1.join(modelsDir, extra.filename),
71439
+ filename: extra.filename,
71440
+ expectedBytes: extra.sizeMB * BYTES_PER_MB
71441
+ });
71442
+ return out;
71443
+ }
70710
71444
  function createDefaultModelOps(modelsDir) {
70711
71445
  return {
70712
- ensure: (entry, onProgress) => ensureModel(modelsDir, entry, GGUF, (downloaded, total) => onProgress(total !== void 0 && total > 0 ? downloaded / total : 0)),
71446
+ ensure: async (entry, onProgress) => {
71447
+ if (entry.formats[GGUF] === void 0) throw new Error(`model ${entry.id} declares no gguf format`);
71448
+ const missing = planFiles(modelsDir, entry).filter((f) => !existsSync(f.destPath));
71449
+ const totalBytes = missing.reduce((sum, f) => sum + f.expectedBytes, 0);
71450
+ let carried = 0;
71451
+ for (const [index, file] of missing.entries()) {
71452
+ onProgress({
71453
+ file: file.filename,
71454
+ fileIndex: index + 1,
71455
+ fileCount: missing.length,
71456
+ downloadedBytes: carried,
71457
+ ...totalBytes > 0 ? { totalBytes } : {}
71458
+ });
71459
+ await downloadFile(file.url, file.destPath, (downloaded) => {
71460
+ onProgress({
71461
+ file: file.filename,
71462
+ fileIndex: index + 1,
71463
+ fileCount: missing.length,
71464
+ downloadedBytes: carried + downloaded,
71465
+ ...totalBytes > 0 ? { totalBytes } : {}
71466
+ });
71467
+ });
71468
+ carried += existsSync(file.destPath) ? statSync(file.destPath).size : file.expectedBytes;
71469
+ }
71470
+ const main = getModelFilePath(modelsDir, entry, GGUF);
71471
+ if (main === null) throw new Error(`no gguf path for model ${entry.id}`);
71472
+ return main;
71473
+ },
70713
71474
  isDownloaded: (entry) => isModelDownloaded(modelsDir, entry, GGUF),
70714
71475
  pathFor: (entry) => {
70715
71476
  const p = getModelFilePath(modelsDir, entry, GGUF);
@@ -70723,318 +71484,6 @@ function createDefaultModelOps(modelsDir) {
70723
71484
  };
70724
71485
  }
70725
71486
  //#endregion
70726
- //#region src/runtime/sha256.ts
70727
- /**
70728
- * File sha256 — a local copy of the private `computeSha256` at
70729
- * model-downloader.ts (not exported from @camstack/system), streamed so it
70730
- * never buffers a multi-GB artifact.
70731
- */
70732
- function fileSha256(filePath) {
70733
- return new Promise((resolve, reject) => {
70734
- const hash = createHash("sha256");
70735
- const stream = createReadStream(filePath);
70736
- stream.on("error", reject);
70737
- stream.on("data", (chunk) => hash.update(chunk));
70738
- stream.on("end", () => resolve(hash.digest("hex")));
70739
- });
70740
- }
70741
- //#endregion
70742
- //#region src/runtime/runtime-provider.ts
70743
- /**
70744
- * `llm-runtime` provider — the node-side managed executor. Reuses the
70745
- * object-detection model plane (ensureModel/isModelDownloaded/delete via the
70746
- * injected `RuntimeModelOps`) for GGUF artifacts, the `LlamaSupervisor` for the
70747
- * llama-server child, and the SHARED {@link LlmClient} for the local inference
70748
- * wire (only lifecycle + locality differ — spec §2). GGUFs are
70749
- * multi-GB, so a missing model is an EXPLICIT-install error, never an auto-pull.
70750
- * Usage rows are written hub-side only (single accounting point).
70751
- */
70752
- function basename(url) {
70753
- const clean = url.split("?")[0] ?? url;
70754
- return clean.slice(clean.lastIndexOf("/") + 1);
70755
- }
70756
- function catalogIdForFile(file) {
70757
- return LLM_MODEL_CATALOG.find((m) => basename(m.meta.url) === file)?.meta.id;
70758
- }
70759
- function mmprojFilename(entry) {
70760
- return entry.extraFiles?.[0]?.filename;
70761
- }
70762
- function createLlmRuntimeProvider(deps) {
70763
- let downloadProgress;
70764
- async function resolvePaths(runtime) {
70765
- const resolution = entryForRef(runtime.model);
70766
- if (resolution === null) throw new Error("unknown model reference");
70767
- const { entry, localPathOverride } = resolution;
70768
- const modelPath = localPathOverride ?? deps.modelOps.pathFor(entry);
70769
- const mmproj = mmprojFilename(entry);
70770
- return {
70771
- modelId: entry.id,
70772
- modelPath,
70773
- ...mmproj !== void 0 ? { mmprojPath: deps.modelOps.extraFilePath(entry, mmproj) } : {}
70774
- };
70775
- }
70776
- function installedGuard(runtime) {
70777
- const resolution = entryForRef(runtime.model);
70778
- if (resolution === null) return {
70779
- ok: false,
70780
- message: "unknown model reference"
70781
- };
70782
- if (resolution.localPathOverride !== void 0) return { ok: true };
70783
- if (!deps.modelOps.isDownloaded(resolution.entry)) return {
70784
- ok: false,
70785
- message: `model ${resolution.entry.id} not installed on node ${deps.nodeId}`
70786
- };
70787
- return { ok: true };
70788
- }
70789
- async function ensureStartedInternal(runtime) {
70790
- const binaryPath = await deps.ensureBinary();
70791
- const paths = await resolvePaths(runtime);
70792
- const startCfg = {
70793
- nodeId: deps.nodeId,
70794
- modelId: paths.modelId,
70795
- modelPath: paths.modelPath,
70796
- ...paths.mmprojPath !== void 0 ? { mmprojPath: paths.mmprojPath } : {},
70797
- contextSize: runtime.contextSize,
70798
- gpuLayers: runtime.gpuLayers,
70799
- ...runtime.threads !== void 0 ? { threads: runtime.threads } : {},
70800
- parallel: runtime.parallel,
70801
- ...runtime.batchSize !== void 0 ? { batchSize: runtime.batchSize } : {},
70802
- ...runtime.ubatchSize !== void 0 ? { ubatchSize: runtime.ubatchSize } : {},
70803
- flashAttention: runtime.flashAttention,
70804
- mlock: runtime.mlock,
70805
- noMmap: runtime.noMmap,
70806
- ...runtime.cacheTypeK !== void 0 ? { cacheTypeK: runtime.cacheTypeK } : {},
70807
- ...runtime.cacheTypeV !== void 0 ? { cacheTypeV: runtime.cacheTypeV } : {},
70808
- idleStopMinutes: runtime.idleStopMinutes,
70809
- binaryPath
70810
- };
70811
- return deps.supervisor.start(startCfg);
70812
- }
70813
- function status() {
70814
- return {
70815
- ...deps.supervisor.status(),
70816
- nodeId: deps.nodeId,
70817
- ...downloadProgress !== void 0 ? { downloadProgress } : {}
70818
- };
70819
- }
70820
- return {
70821
- complete: async (input) => {
70822
- const guard = installedGuard(input.runtime);
70823
- if (!guard.ok) return {
70824
- ok: false,
70825
- code: "unavailable",
70826
- message: guard.message
70827
- };
70828
- await ensureStartedInternal(input.runtime);
70829
- const port = deps.supervisor.port;
70830
- if (port === void 0) return {
70831
- ok: false,
70832
- code: "unavailable",
70833
- message: "llama-server has no port"
70834
- };
70835
- const paths = await resolvePaths(input.runtime);
70836
- const timeoutMs = input.timeoutMs ?? 12e4;
70837
- const localProfile = {
70838
- id: "managed-local",
70839
- name: "managed-local",
70840
- kind: "openai-compatible",
70841
- addonId: "ai",
70842
- enabled: true,
70843
- model: paths.modelId,
70844
- baseUrl: `http://127.0.0.1:${String(port)}/v1`,
70845
- supportsVision: paths.mmprojPath !== void 0,
70846
- timeoutMs,
70847
- connectTimeoutMs: LlmTimeoutDefaults.connectMs,
70848
- firstTokenTimeoutMs: LlmTimeoutDefaults.firstTokenMs,
70849
- idleTimeoutMs: LlmTimeoutDefaults.idleMs,
70850
- retry: {
70851
- enabled: false,
70852
- maxAttempts: 1
70853
- },
70854
- toolsEnabled: false
70855
- };
70856
- const result = await deps.client.generate({
70857
- profile: localProfile,
70858
- ...input.system !== void 0 ? { system: input.system } : {},
70859
- prompt: input.prompt,
70860
- ...input.images !== void 0 ? { images: input.images } : {},
70861
- ...input.jsonSchema !== void 0 ? { jsonSchema: input.jsonSchema } : {},
70862
- ...input.maxTokens !== void 0 ? { maxTokens: input.maxTokens } : {},
70863
- ...input.temperature !== void 0 ? { temperature: input.temperature } : {},
70864
- ...input.topP !== void 0 ? { topP: input.topP } : {},
70865
- ...input.topK !== void 0 ? { topK: input.topK } : {},
70866
- signal: new AbortController().signal
70867
- }, timeoutMs);
70868
- deps.supervisor.noteActivity();
70869
- return result;
70870
- },
70871
- ensureStarted: async ({ runtime }) => {
70872
- const guard = installedGuard(runtime);
70873
- if (!guard.ok) return {
70874
- nodeId: deps.nodeId,
70875
- state: "stopped",
70876
- lastError: guard.message,
70877
- crashesInWindow: 0
70878
- };
70879
- return ensureStartedInternal(runtime);
70880
- },
70881
- stop: async () => {
70882
- await deps.supervisor.stop();
70883
- },
70884
- status: async () => status(),
70885
- installModel: async ({ model }) => {
70886
- const resolution = entryForRef(model);
70887
- if (resolution === null) throw new Error("unknown model reference");
70888
- if (resolution.localPathOverride !== void 0) return;
70889
- downloadProgress = 0;
70890
- try {
70891
- await deps.modelOps.ensure(resolution.entry, (frac) => {
70892
- downloadProgress = frac;
70893
- });
70894
- if (model.kind === "url" && model.sha256 !== void 0) {
70895
- const filePath = deps.modelOps.pathFor(resolution.entry);
70896
- if (await (deps.fileSha256 ?? fileSha256)(filePath) !== model.sha256) {
70897
- await deps.modelOps.delete(resolution.entry);
70898
- throw new Error(`sha256 mismatch for ${model.url}`);
70899
- }
70900
- }
70901
- } finally {
70902
- downloadProgress = void 0;
70903
- }
70904
- },
70905
- deleteModel: async ({ file }) => {
70906
- const loaded = deps.supervisor.status().modelPath;
70907
- if (loaded !== void 0 && basename(loaded) === file) throw new Error(`cannot delete ${file}: loaded by the running runtime`);
70908
- await fsp.rm(path$1.join(deps.modelsDir, file), { force: true });
70909
- },
70910
- listLocalModels: async () => {
70911
- return (await listGgufFiles(deps.modelsDir)).map((f) => {
70912
- const catalogId = catalogIdForFile(f.file);
70913
- return {
70914
- file: f.file,
70915
- sizeBytes: f.sizeBytes,
70916
- ...catalogId !== void 0 ? { catalogId } : {}
70917
- };
70918
- });
70919
- },
70920
- getDiskUsage: async () => {
70921
- const modelsBytes = (await listGgufFiles(deps.modelsDir)).reduce((sum, f) => sum + f.sizeBytes, 0);
70922
- return {
70923
- nodeId: deps.nodeId,
70924
- modelsBytes
70925
- };
70926
- }
70927
- };
70928
- }
70929
- async function listGgufFiles(dir) {
70930
- let names;
70931
- try {
70932
- names = await fsp.readdir(dir);
70933
- } catch {
70934
- return [];
70935
- }
70936
- const out = [];
70937
- for (const name of names) {
70938
- if (!name.endsWith(".gguf")) continue;
70939
- try {
70940
- const stat = await fsp.stat(path$1.join(dir, name));
70941
- out.push({
70942
- file: name,
70943
- sizeBytes: stat.size
70944
- });
70945
- } catch {}
70946
- }
70947
- return out;
70948
- }
70949
- //#endregion
70950
- //#region src/runtime-client.ts
70951
- /**
70952
- * `RuntimeClient` over the cap plane — every verb pins the target node with
70953
- * `nodePin(nodeId)` (transport-level, never a hand-rolled routing field). The
70954
- * CapRouteResolver classifies the pin into hub-local-uds / remote-moleculer /
70955
- * agent-child-forward transparently (spec §4.1; the model-studio cross-node
70956
- * convert precedent). Node enumeration is the `nodes.topology` roster filtered
70957
- * to nodes advertising the `llm-runtime` cap — never a shadow registry.
70958
- */
70959
- var LLM_RUNTIME_CAP = "llm-runtime";
70960
- function createRuntimeClient(api) {
70961
- return {
70962
- complete: (nodeId, input) => api.llmRuntime.complete.mutate(input, nodePin(nodeId)),
70963
- ensureStarted: (nodeId, runtime) => api.llmRuntime.ensureStarted.mutate({ runtime }, nodePin(nodeId)),
70964
- stopRuntime: (nodeId) => api.llmRuntime.stop.mutate({}, nodePin(nodeId)),
70965
- status: (nodeId) => api.llmRuntime.status.query({}, nodePin(nodeId)),
70966
- installModel: (nodeId, model) => api.llmRuntime.installModel.mutate({ model }, nodePin(nodeId)),
70967
- deleteModel: (nodeId, file) => api.llmRuntime.deleteModel.mutate({ file }, nodePin(nodeId)),
70968
- listLocalModels: (nodeId) => api.llmRuntime.listLocalModels.query({}, nodePin(nodeId)),
70969
- getDiskUsage: (nodeId) => api.llmRuntime.getDiskUsage.query({}, nodePin(nodeId)),
70970
- listRuntimeNodeIds: async () => {
70971
- const topology = await api.nodes.topology.query();
70972
- const ids = /* @__PURE__ */ new Set();
70973
- for (const node of topology) if (node.addons.some((a) => a.capabilities.includes(LLM_RUNTIME_CAP))) ids.add(node.id);
70974
- return [...ids];
70975
- }
70976
- };
70977
- }
70978
- //#endregion
70979
- //#region src/assembly.ts
70980
- /**
70981
- * Registration assembly (the hub/agent split, spec §1). Every node running
70982
- * addon-ai registers `llm-runtime`; ONLY the hub also registers the consumer
70983
- * `llm` surface (profiles/usage need outbound internet + API keys).
70984
- * Extracted from the addon class so the split + seeding is unit-testable
70985
- * without a full AddonContext.
70986
- */
70987
- async function assembleAi(deps) {
70988
- const client = deps.client ?? createLlmClient();
70989
- const runtimeProvider = createLlmRuntimeProvider({
70990
- nodeId: deps.nodeId,
70991
- modelsDir: deps.modelsDir,
70992
- ensureBinary: deps.ensureBinary,
70993
- supervisor: deps.supervisor,
70994
- modelOps: createDefaultModelOps(deps.modelsDir),
70995
- client,
70996
- logger: deps.logger.child("llm-runtime")
70997
- });
70998
- const registrations = [{
70999
- capability: llmRuntimeCapability,
71000
- provider: runtimeProvider
71001
- }];
71002
- if (!deps.isHub) return {
71003
- registrations,
71004
- runtimeProvider
71005
- };
71006
- const { UsageStore } = await import("./usage-store-RiVXP_ma.mjs").then((n) => n.i);
71007
- const store = new ProfileStore(deps.settingsPort);
71008
- const defaults = new DefaultsStore(deps.settingsPort);
71009
- const usage = new UsageStore(deps.settingsPort, Date.now, deps.logger.child("llm-usage"));
71010
- await store.init();
71011
- await defaults.init();
71012
- await usage.init();
71013
- await store.ensureSeeded();
71014
- const llmProvider = createLlmProvider({
71015
- store,
71016
- defaults,
71017
- usage,
71018
- client,
71019
- ...deps.runtimeApi !== void 0 ? { runtime: createRuntimeClient(deps.runtimeApi) } : {},
71020
- ...deps.distributeModel !== void 0 ? { distributeModel: deps.distributeModel } : {},
71021
- catalog: LLM_MODEL_CATALOG.map((m) => m.meta),
71022
- logger: deps.logger.child("llm")
71023
- });
71024
- registrations.push({
71025
- capability: llmCapability,
71026
- provider: llmProvider
71027
- });
71028
- return {
71029
- registrations,
71030
- runtimeProvider,
71031
- llmProvider,
71032
- store,
71033
- usage,
71034
- prune: (retentionDays) => usage.prune(retentionDays)
71035
- };
71036
- }
71037
- //#endregion
71038
71487
  //#region src/runtime/crash-policy.ts
71039
71488
  var CrashPolicy = class {
71040
71489
  opts;
@@ -71084,6 +71533,63 @@ var DEFAULT_CRASH_POLICY = {
71084
71533
  * v1: at most one running child. Resource ceiling = llama-server flags +
71085
71534
  * idleStopMinutes ONLY (no RSS watchdog — operator decision #3).
71086
71535
  */
71536
+ /**
71537
+ * Every llama-server flag a TYPED field above already owns, mapped to the
71538
+ * field that owns it.
71539
+ *
71540
+ * This map is the whole reconciliation between the typed tuning surface and
71541
+ * the free-text "additional arguments" box. Both exist because neither is
71542
+ * sufficient — the typed fields give the common knobs a validated control and
71543
+ * a default, and llama.cpp has a hundred flags nobody is going to model — but
71544
+ * a flag settable from BOTH is a bug generator: whichever one loses is a
71545
+ * control the operator watched do nothing. So the box is an escape hatch for
71546
+ * what is NOT modelled, and reaching into it for something that is gets
71547
+ * rejected by name.
71548
+ */
71549
+ var OWNED_FLAGS = {
71550
+ "-m": "model",
71551
+ "--model": "model",
71552
+ "--host": "fixed to 127.0.0.1",
71553
+ "--port": "assigned by the supervisor",
71554
+ "-c": "contextSize",
71555
+ "--ctx-size": "contextSize",
71556
+ "-ngl": "gpuLayers",
71557
+ "--gpu-layers": "gpuLayers",
71558
+ "--n-gpu-layers": "gpuLayers",
71559
+ "-t": "threads",
71560
+ "--threads": "threads",
71561
+ "--parallel": "parallel",
71562
+ "-np": "parallel",
71563
+ "-b": "batchSize",
71564
+ "--batch-size": "batchSize",
71565
+ "-ub": "ubatchSize",
71566
+ "--ubatch-size": "ubatchSize",
71567
+ "-fa": "flashAttention",
71568
+ "--flash-attn": "flashAttention",
71569
+ "--mlock": "mlock",
71570
+ "--no-mmap": "noMmap",
71571
+ "-ctk": "cacheTypeK",
71572
+ "--cache-type-k": "cacheTypeK",
71573
+ "-ctv": "cacheTypeV",
71574
+ "--cache-type-v": "cacheTypeV",
71575
+ "--mmproj": "the vision model’s projector"
71576
+ };
71577
+ /**
71578
+ * Reject an `extraArgs` list that reaches for a flag a typed field owns.
71579
+ * `--flag=value` counts as `--flag`.
71580
+ */
71581
+ function checkExtraArgs(extraArgs) {
71582
+ for (const token of extraArgs) {
71583
+ if (!token.startsWith("-")) continue;
71584
+ const flag = token.split("=")[0] ?? token;
71585
+ const owner = OWNED_FLAGS[flag];
71586
+ if (owner !== void 0) return {
71587
+ ok: false,
71588
+ message: `"${flag}" is already set by the runtime field "${owner}" — set it there, not in additional arguments (a flag with two owners is a control that silently does nothing)`
71589
+ };
71590
+ }
71591
+ return { ok: true };
71592
+ }
71087
71593
  var HEALTH_GATE_INTERVAL_MS = 500;
71088
71594
  function defaultPickPort() {
71089
71595
  return new Promise((resolve, reject) => {
@@ -71134,6 +71640,7 @@ function buildLlamaArgs(cfg, port) {
71134
71640
  if (cfg.cacheTypeK !== void 0) args.push("--cache-type-k", cfg.cacheTypeK);
71135
71641
  if (cfg.cacheTypeV !== void 0) args.push("--cache-type-v", cfg.cacheTypeV);
71136
71642
  if (cfg.mmprojPath !== void 0) args.push("--mmproj", cfg.mmprojPath);
71643
+ args.push(...cfg.extraArgs ?? []);
71137
71644
  return args;
71138
71645
  }
71139
71646
  var LlamaSupervisor = class {
@@ -71358,6 +71865,463 @@ var LlamaSupervisor = class {
71358
71865
  }
71359
71866
  };
71360
71867
  //#endregion
71868
+ //#region src/runtime/sha256.ts
71869
+ /**
71870
+ * File sha256 — a local copy of the private `computeSha256` at
71871
+ * model-downloader.ts (not exported from @camstack/system), streamed so it
71872
+ * never buffers a multi-GB artifact.
71873
+ */
71874
+ function fileSha256(filePath) {
71875
+ return new Promise((resolve, reject) => {
71876
+ const hash = createHash("sha256");
71877
+ const stream = createReadStream(filePath);
71878
+ stream.on("error", reject);
71879
+ stream.on("data", (chunk) => hash.update(chunk));
71880
+ stream.on("end", () => resolve(hash.digest("hex")));
71881
+ });
71882
+ }
71883
+ //#endregion
71884
+ //#region src/runtime/runtime-provider.ts
71885
+ /**
71886
+ * `llm-runtime` provider — the node-side managed executor. Reuses the
71887
+ * object-detection model plane (ensureModel/isModelDownloaded/delete via the
71888
+ * injected `RuntimeModelOps`) for GGUF artifacts, the `LlamaSupervisor` for the
71889
+ * llama-server child, and the SHARED {@link LlmClient} for the local inference
71890
+ * wire (only lifecycle + locality differ — spec §2). GGUFs are
71891
+ * multi-GB, so a missing model is an EXPLICIT-install error, never an auto-pull.
71892
+ * Usage rows are written hub-side only (single accounting point).
71893
+ */
71894
+ function basename(url) {
71895
+ const clean = url.split("?")[0] ?? url;
71896
+ return clean.slice(clean.lastIndexOf("/") + 1);
71897
+ }
71898
+ function catalogIdForFile(file) {
71899
+ return LLM_MODEL_CATALOG.find((m) => basename(m.meta.url) === file)?.meta.id;
71900
+ }
71901
+ /**
71902
+ * The projector among the extra files — matched by NAME, not by position.
71903
+ *
71904
+ * `extraFiles[0]` was safe while the only extra a GGUF entry ever had was an
71905
+ * mmproj. A split GGUF puts shards 2..N in the same list, so index 0 is now
71906
+ * routinely a weights shard, and passing one to `--mmproj` starts llama-server
71907
+ * against a file that is not a projector.
71908
+ */
71909
+ function mmprojFilename(entry) {
71910
+ return entry.extraFiles?.find((f) => f.filename.toLowerCase().startsWith("mmproj"))?.filename;
71911
+ }
71912
+ function gb(bytes) {
71913
+ return `${(bytes / 1e9).toFixed(2)} GB`;
71914
+ }
71915
+ function createLlmRuntimeProvider(deps) {
71916
+ let downloadProgress;
71917
+ let download;
71918
+ async function resolvePaths(runtime) {
71919
+ const resolution = entryForRef(runtime.model);
71920
+ if (resolution === null) throw new Error("unknown model reference");
71921
+ const { entry, localPathOverride } = resolution;
71922
+ const modelPath = localPathOverride ?? deps.modelOps.pathFor(entry);
71923
+ const mmproj = mmprojFilename(entry);
71924
+ return {
71925
+ modelId: entry.id,
71926
+ modelPath,
71927
+ ...mmproj !== void 0 ? { mmprojPath: deps.modelOps.extraFilePath(entry, mmproj) } : {}
71928
+ };
71929
+ }
71930
+ function installedGuard(runtime) {
71931
+ const resolution = entryForRef(runtime.model);
71932
+ if (resolution === null) return {
71933
+ ok: false,
71934
+ message: "unknown model reference"
71935
+ };
71936
+ if (resolution.localPathOverride !== void 0) return { ok: true };
71937
+ if (!deps.modelOps.isDownloaded(resolution.entry)) return {
71938
+ ok: false,
71939
+ message: `model ${resolution.entry.id} not installed on node ${deps.nodeId}`
71940
+ };
71941
+ return { ok: true };
71942
+ }
71943
+ async function ensureStartedInternal(runtime) {
71944
+ const argCheck = checkExtraArgs(runtime.extraArgs);
71945
+ if (!argCheck.ok) throw new Error(argCheck.message);
71946
+ const binaryPath = await deps.ensureBinary();
71947
+ const paths = await resolvePaths(runtime);
71948
+ const startCfg = {
71949
+ nodeId: deps.nodeId,
71950
+ modelId: paths.modelId,
71951
+ modelPath: paths.modelPath,
71952
+ ...paths.mmprojPath !== void 0 ? { mmprojPath: paths.mmprojPath } : {},
71953
+ contextSize: runtime.contextSize,
71954
+ gpuLayers: runtime.gpuLayers,
71955
+ ...runtime.threads !== void 0 ? { threads: runtime.threads } : {},
71956
+ parallel: runtime.parallel,
71957
+ ...runtime.batchSize !== void 0 ? { batchSize: runtime.batchSize } : {},
71958
+ ...runtime.ubatchSize !== void 0 ? { ubatchSize: runtime.ubatchSize } : {},
71959
+ flashAttention: runtime.flashAttention,
71960
+ mlock: runtime.mlock,
71961
+ noMmap: runtime.noMmap,
71962
+ ...runtime.cacheTypeK !== void 0 ? { cacheTypeK: runtime.cacheTypeK } : {},
71963
+ ...runtime.cacheTypeV !== void 0 ? { cacheTypeV: runtime.cacheTypeV } : {},
71964
+ extraArgs: runtime.extraArgs,
71965
+ idleStopMinutes: runtime.idleStopMinutes,
71966
+ binaryPath
71967
+ };
71968
+ return deps.supervisor.start(startCfg);
71969
+ }
71970
+ /**
71971
+ * sha256 every artifact whose digest the reference pinned — the main file
71972
+ * AND the extras.
71973
+ *
71974
+ * Verifying only the main file was the gap: a truncated or swapped mmproj is
71975
+ * exactly as fatal to llama-server as a bad weights file, and a resolved HF
71976
+ * reference carries a digest for every artifact (LFS `oid`) so there is no
71977
+ * reason to check one and trust the rest.
71978
+ *
71979
+ * This pass reads tens of GB and takes minutes; it is a REPORTED phase, not
71980
+ * a silent tail, because a progress bar frozen at 100% is the shape of a
71981
+ * hang.
71982
+ */
71983
+ async function verifyDigests(entry, model, startedAt) {
71984
+ if (model.kind !== "url") return;
71985
+ const targets = [];
71986
+ if (model.sha256 !== void 0) targets.push({
71987
+ filePath: deps.modelOps.pathFor(entry),
71988
+ sha256: model.sha256,
71989
+ name: basename(model.url)
71990
+ });
71991
+ for (const extra of model.extraFiles ?? []) {
71992
+ if (extra.sha256 === void 0) continue;
71993
+ targets.push({
71994
+ filePath: deps.modelOps.extraFilePath(entry, extra.filename),
71995
+ sha256: extra.sha256,
71996
+ name: extra.filename
71997
+ });
71998
+ }
71999
+ if (targets.length === 0) return;
72000
+ const sha256 = deps.fileSha256 ?? fileSha256;
72001
+ for (const [index, target] of targets.entries()) {
72002
+ download = {
72003
+ phase: "verifying",
72004
+ file: target.name,
72005
+ fileIndex: index + 1,
72006
+ fileCount: targets.length,
72007
+ downloadedBytes: 0
72008
+ };
72009
+ deps.logger.info("llm model verifying digest", { meta: {
72010
+ nodeId: deps.nodeId,
72011
+ modelId: entry.id,
72012
+ file: target.name
72013
+ } });
72014
+ const digest = await sha256(target.filePath);
72015
+ if (digest !== target.sha256) {
72016
+ deps.logger.error("llm model digest mismatch; discarding the download", { meta: {
72017
+ nodeId: deps.nodeId,
72018
+ modelId: entry.id,
72019
+ file: target.name,
72020
+ expected: target.sha256,
72021
+ actual: digest,
72022
+ elapsedMs: Date.now() - startedAt
72023
+ } });
72024
+ await deps.modelOps.delete(entry);
72025
+ await fsp.rm(target.filePath, { force: true });
72026
+ throw new Error(`sha256 mismatch for ${target.name}: expected ${target.sha256}, got ${digest}`);
72027
+ }
72028
+ }
72029
+ }
72030
+ function status() {
72031
+ return {
72032
+ ...deps.supervisor.status(),
72033
+ nodeId: deps.nodeId,
72034
+ ...downloadProgress !== void 0 ? { downloadProgress } : {},
72035
+ ...download !== void 0 ? { download } : {}
72036
+ };
72037
+ }
72038
+ return {
72039
+ complete: async (input) => {
72040
+ const guard = installedGuard(input.runtime);
72041
+ if (!guard.ok) return {
72042
+ ok: false,
72043
+ code: "unavailable",
72044
+ message: guard.message
72045
+ };
72046
+ await ensureStartedInternal(input.runtime);
72047
+ const port = deps.supervisor.port;
72048
+ if (port === void 0) return {
72049
+ ok: false,
72050
+ code: "unavailable",
72051
+ message: "llama-server has no port"
72052
+ };
72053
+ const paths = await resolvePaths(input.runtime);
72054
+ const timeoutMs = input.timeoutMs ?? 12e4;
72055
+ const localProfile = {
72056
+ id: "managed-local",
72057
+ name: "managed-local",
72058
+ kind: "openai-compatible",
72059
+ addonId: "ai",
72060
+ enabled: true,
72061
+ model: paths.modelId,
72062
+ baseUrl: `http://127.0.0.1:${String(port)}/v1`,
72063
+ supportsVision: paths.mmprojPath !== void 0,
72064
+ timeoutMs,
72065
+ connectTimeoutMs: LlmTimeoutDefaults.connectMs,
72066
+ firstTokenTimeoutMs: LlmTimeoutDefaults.firstTokenMs,
72067
+ idleTimeoutMs: LlmTimeoutDefaults.idleMs,
72068
+ retry: {
72069
+ enabled: false,
72070
+ maxAttempts: 1
72071
+ },
72072
+ toolsEnabled: false
72073
+ };
72074
+ const result = await deps.client.generate({
72075
+ profile: localProfile,
72076
+ ...input.system !== void 0 ? { system: input.system } : {},
72077
+ prompt: input.prompt,
72078
+ ...input.images !== void 0 ? { images: input.images } : {},
72079
+ ...input.jsonSchema !== void 0 ? { jsonSchema: input.jsonSchema } : {},
72080
+ ...input.maxTokens !== void 0 ? { maxTokens: input.maxTokens } : {},
72081
+ ...input.temperature !== void 0 ? { temperature: input.temperature } : {},
72082
+ ...input.topP !== void 0 ? { topP: input.topP } : {},
72083
+ ...input.topK !== void 0 ? { topK: input.topK } : {},
72084
+ signal: new AbortController().signal
72085
+ }, timeoutMs);
72086
+ deps.supervisor.noteActivity();
72087
+ return result;
72088
+ },
72089
+ ensureStarted: async ({ runtime }) => {
72090
+ const guard = installedGuard(runtime);
72091
+ if (!guard.ok) return {
72092
+ nodeId: deps.nodeId,
72093
+ state: "stopped",
72094
+ lastError: guard.message,
72095
+ crashesInWindow: 0
72096
+ };
72097
+ return ensureStartedInternal(runtime);
72098
+ },
72099
+ stop: async () => {
72100
+ await deps.supervisor.stop();
72101
+ },
72102
+ status: async () => status(),
72103
+ /**
72104
+ * Install a model on THIS node.
72105
+ *
72106
+ * Loud on purpose. This is the longest-running operation the addon has —
72107
+ * tens of minutes for a 23 GB vision model — and until now it emitted not
72108
+ * one log line, so an install that stalled on a gated URL or a full disk
72109
+ * was indistinguishable from one that was simply slow. Every phase
72110
+ * transition is a line, and every line carries the node.
72111
+ */
72112
+ installModel: async ({ model }) => {
72113
+ const resolution = entryForRef(model);
72114
+ if (resolution === null) throw new Error("unknown model reference");
72115
+ if (resolution.localPathOverride !== void 0) {
72116
+ deps.logger.info("llm model is pre-provisioned; nothing to download", { meta: {
72117
+ nodeId: deps.nodeId,
72118
+ path: resolution.localPathOverride
72119
+ } });
72120
+ return;
72121
+ }
72122
+ const { entry } = resolution;
72123
+ const declaredBytes = model.kind === "url" ? model.sizeBytes : void 0;
72124
+ const startedAt = Date.now();
72125
+ deps.logger.info("llm model install started", { meta: {
72126
+ nodeId: deps.nodeId,
72127
+ modelId: entry.id,
72128
+ url: entry.formats.gguf?.url,
72129
+ extraFiles: (entry.extraFiles ?? []).map((f) => f.filename),
72130
+ ...declaredBytes !== void 0 ? {
72131
+ declaredBytes,
72132
+ declaredSize: gb(declaredBytes)
72133
+ } : {}
72134
+ } });
72135
+ downloadProgress = 0;
72136
+ download = {
72137
+ phase: "downloading",
72138
+ file: "",
72139
+ fileIndex: 0,
72140
+ fileCount: 0,
72141
+ downloadedBytes: 0
72142
+ };
72143
+ let lastLoggedDecile = -1;
72144
+ try {
72145
+ await deps.modelOps.ensure(entry, (progress) => {
72146
+ const fraction = progress.totalBytes !== void 0 && progress.totalBytes > 0 ? Math.min(1, progress.downloadedBytes / progress.totalBytes) : void 0;
72147
+ downloadProgress = fraction;
72148
+ download = {
72149
+ phase: "downloading",
72150
+ file: progress.file,
72151
+ fileIndex: progress.fileIndex,
72152
+ fileCount: progress.fileCount,
72153
+ downloadedBytes: progress.downloadedBytes,
72154
+ ...progress.totalBytes !== void 0 ? { totalBytes: progress.totalBytes } : {}
72155
+ };
72156
+ const decile = fraction === void 0 ? -1 : Math.floor(fraction * 10);
72157
+ if (decile > lastLoggedDecile) {
72158
+ lastLoggedDecile = decile;
72159
+ deps.logger.info("llm model download progress", { meta: {
72160
+ nodeId: deps.nodeId,
72161
+ modelId: entry.id,
72162
+ file: progress.file,
72163
+ fileIndex: progress.fileIndex,
72164
+ fileCount: progress.fileCount,
72165
+ downloadedBytes: progress.downloadedBytes,
72166
+ downloaded: gb(progress.downloadedBytes),
72167
+ ...progress.totalBytes !== void 0 ? { total: gb(progress.totalBytes) } : {}
72168
+ } });
72169
+ }
72170
+ });
72171
+ await verifyDigests(entry, model, startedAt);
72172
+ deps.logger.info("llm model install complete", { meta: {
72173
+ nodeId: deps.nodeId,
72174
+ modelId: entry.id,
72175
+ elapsedMs: Date.now() - startedAt
72176
+ } });
72177
+ } catch (err) {
72178
+ deps.logger.error("llm model install failed", { meta: {
72179
+ nodeId: deps.nodeId,
72180
+ modelId: entry.id,
72181
+ elapsedMs: Date.now() - startedAt,
72182
+ error: err instanceof Error ? err.message : String(err)
72183
+ } });
72184
+ throw err;
72185
+ } finally {
72186
+ downloadProgress = void 0;
72187
+ download = void 0;
72188
+ }
72189
+ },
72190
+ deleteModel: async ({ file }) => {
72191
+ const loaded = deps.supervisor.status().modelPath;
72192
+ if (loaded !== void 0 && basename(loaded) === file) throw new Error(`cannot delete ${file}: loaded by the running runtime`);
72193
+ await fsp.rm(path$1.join(deps.modelsDir, file), { force: true });
72194
+ },
72195
+ listLocalModels: async () => {
72196
+ return (await listGgufFiles(deps.modelsDir)).map((f) => {
72197
+ const catalogId = catalogIdForFile(f.file);
72198
+ return {
72199
+ file: f.file,
72200
+ sizeBytes: f.sizeBytes,
72201
+ path: path$1.join(deps.modelsDir, f.file),
72202
+ ...catalogId !== void 0 ? { catalogId } : {}
72203
+ };
72204
+ });
72205
+ },
72206
+ getDiskUsage: async () => {
72207
+ const modelsBytes = (await listGgufFiles(deps.modelsDir)).reduce((sum, f) => sum + f.sizeBytes, 0);
72208
+ return {
72209
+ nodeId: deps.nodeId,
72210
+ modelsBytes
72211
+ };
72212
+ }
72213
+ };
72214
+ }
72215
+ async function listGgufFiles(dir) {
72216
+ let names;
72217
+ try {
72218
+ names = await fsp.readdir(dir);
72219
+ } catch {
72220
+ return [];
72221
+ }
72222
+ const out = [];
72223
+ for (const name of names) {
72224
+ if (!name.endsWith(".gguf")) continue;
72225
+ try {
72226
+ const stat = await fsp.stat(path$1.join(dir, name));
72227
+ out.push({
72228
+ file: name,
72229
+ sizeBytes: stat.size
72230
+ });
72231
+ } catch {}
72232
+ }
72233
+ return out;
72234
+ }
72235
+ //#endregion
72236
+ //#region src/runtime-client.ts
72237
+ /**
72238
+ * `RuntimeClient` over the cap plane — every verb pins the target node with
72239
+ * `nodePin(nodeId)` (transport-level, never a hand-rolled routing field). The
72240
+ * CapRouteResolver classifies the pin into hub-local-uds / remote-moleculer /
72241
+ * agent-child-forward transparently (spec §4.1; the model-studio cross-node
72242
+ * convert precedent). Node enumeration is the `nodes.topology` roster filtered
72243
+ * to nodes advertising the `llm-runtime` cap — never a shadow registry.
72244
+ */
72245
+ var LLM_RUNTIME_CAP = "llm-runtime";
72246
+ function createRuntimeClient(api) {
72247
+ return {
72248
+ complete: (nodeId, input) => api.llmRuntime.complete.mutate(input, nodePin(nodeId)),
72249
+ ensureStarted: (nodeId, runtime) => api.llmRuntime.ensureStarted.mutate({ runtime }, nodePin(nodeId)),
72250
+ stopRuntime: (nodeId) => api.llmRuntime.stop.mutate({}, nodePin(nodeId)),
72251
+ status: (nodeId) => api.llmRuntime.status.query({}, nodePin(nodeId)),
72252
+ installModel: (nodeId, model) => api.llmRuntime.installModel.mutate({ model }, nodePin(nodeId)),
72253
+ deleteModel: (nodeId, file) => api.llmRuntime.deleteModel.mutate({ file }, nodePin(nodeId)),
72254
+ listLocalModels: (nodeId) => api.llmRuntime.listLocalModels.query({}, nodePin(nodeId)),
72255
+ getDiskUsage: (nodeId) => api.llmRuntime.getDiskUsage.query({}, nodePin(nodeId)),
72256
+ listRuntimeNodeIds: async () => {
72257
+ const topology = await api.nodes.topology.query();
72258
+ const ids = /* @__PURE__ */ new Set();
72259
+ for (const node of topology) if (node.addons.some((a) => a.capabilities.includes(LLM_RUNTIME_CAP))) ids.add(node.id);
72260
+ return [...ids];
72261
+ }
72262
+ };
72263
+ }
72264
+ //#endregion
72265
+ //#region src/assembly.ts
72266
+ /**
72267
+ * Registration assembly (the hub/agent split, spec §1). Every node running
72268
+ * addon-ai registers `llm-runtime`; ONLY the hub also registers the consumer
72269
+ * `llm` surface (profiles/usage need outbound internet + API keys).
72270
+ * Extracted from the addon class so the split + seeding is unit-testable
72271
+ * without a full AddonContext.
72272
+ */
72273
+ async function assembleAi(deps) {
72274
+ const client = deps.client ?? createLlmClient();
72275
+ const runtimeProvider = createLlmRuntimeProvider({
72276
+ nodeId: deps.nodeId,
72277
+ modelsDir: deps.modelsDir,
72278
+ ensureBinary: deps.ensureBinary,
72279
+ supervisor: deps.supervisor,
72280
+ modelOps: createDefaultModelOps(deps.modelsDir),
72281
+ client,
72282
+ logger: deps.logger.child("llm-runtime")
72283
+ });
72284
+ const registrations = [{
72285
+ capability: llmRuntimeCapability,
72286
+ provider: runtimeProvider
72287
+ }];
72288
+ if (!deps.isHub) return {
72289
+ registrations,
72290
+ runtimeProvider
72291
+ };
72292
+ const { UsageStore } = await import("./usage-store-RiVXP_ma.mjs").then((n) => n.i);
72293
+ const store = new ProfileStore(deps.settingsPort);
72294
+ const defaults = new DefaultsStore(deps.settingsPort);
72295
+ const usage = new UsageStore(deps.settingsPort, Date.now, deps.logger.child("llm-usage"));
72296
+ await store.init();
72297
+ await defaults.init();
72298
+ await usage.init();
72299
+ await store.ensureSeeded();
72300
+ const llmProvider = createLlmProvider({
72301
+ store,
72302
+ defaults,
72303
+ usage,
72304
+ client,
72305
+ ...deps.runtimeApi !== void 0 ? { runtime: createRuntimeClient(deps.runtimeApi) } : {},
72306
+ ...deps.distributeModel !== void 0 ? { distributeModel: deps.distributeModel } : {},
72307
+ catalog: LLM_MODEL_CATALOG.map((m) => m.meta),
72308
+ hfToken: () => process.env["HF_TOKEN"] ?? process.env["HUGGING_FACE_HUB_TOKEN"],
72309
+ logger: deps.logger.child("llm")
72310
+ });
72311
+ registrations.push({
72312
+ capability: llmCapability,
72313
+ provider: llmProvider
72314
+ });
72315
+ return {
72316
+ registrations,
72317
+ runtimeProvider,
72318
+ llmProvider,
72319
+ store,
72320
+ usage,
72321
+ prune: (retentionDays) => usage.prune(retentionDays)
72322
+ };
72323
+ }
72324
+ //#endregion
71361
72325
  //#region src/settings-store-port.ts
71362
72326
  function createApiSettingsStorePort(api) {
71363
72327
  return {
@@ -72175,7 +73139,7 @@ var AiAddon = class extends BaseAddon {
72175
73139
  const settingsPort = api !== void 0 ? createApiSettingsStorePort(api) : createMemorySettingsStorePort();
72176
73140
  if (api === void 0) logger.warn("addon-ai: no ctx.api — profiles are in-memory only");
72177
73141
  const binDir = path$1.join(ctx.nodeDataDir, "bin");
72178
- const { ensureLlamaServer } = await import("./ensure-llama-server-v65evjK2.mjs").then((n) => n.r);
73142
+ const { ensureLlamaServer } = await import("./ensure-llama-server-COC6iveo.mjs").then((n) => n.r);
72179
73143
  const assembly = await assembleAi({
72180
73144
  nodeId: ownNodeId,
72181
73145
  isHub,
@@ -72336,4 +73300,4 @@ var AiAddon = class extends BaseAddon {
72336
73300
  }
72337
73301
  };
72338
73302
  //#endregion
72339
- export { resolveRetryPolicy as A, AiAddon, AiAddon as default, __commonJSMin as B, createDefaultModelOps as C, AI_ADDON_ID as D, entryForRef as E, LlmProfileKindSchema as F, boolean as I, number$1 as L, require_token_util as M, require_token_error as N, createLlmProvider as O, LlmErrorCodeSchema as P, object as R, fileSha256 as S, catalogById as T, __exportAll as V, createApiSettingsStorePort as _, renderTranscript as a, createRuntimeClient as b, TEST_CHAT_CONSUMER as c, TEST_CHAT_MAX_FIRST_TOKEN_TIMEOUT_MS as d, TEST_CHAT_MIN_VISION_INPUT_TOKENS as f, encodeEvent as g, TestChatRequestSchema as h, pickTrackMedia as i, createLlmClient as j, CONSUMER_RETRY_POLICY as k, TEST_CHAT_DEFAULT_FIRST_TOKEN_TIMEOUT_MS as l, TestChatEventSchema as m, runTestChatStream as n, resolveImage as o, TEST_CHAT_PREFIX as p, TRACK_MEDIA_PREFERENCE as r, TEST_CHAT_CONNECT_TIMEOUT_MS as s, createTestChatPlaneHandler as t, TEST_CHAT_IDLE_TIMEOUT_MS as u, createMemorySettingsStorePort as v, LLM_MODEL_CATALOG as w, createLlmRuntimeProvider as x, LlamaSupervisor as y, string as z };
73303
+ export { resolveRetryPolicy as A, AiAddon, AiAddon as default, __commonJSMin as B, createDefaultModelOps as C, AI_ADDON_ID as D, entryForRef as E, LlmProfileKindSchema as F, boolean as I, number$1 as L, require_token_util as M, require_token_error as N, createLlmProvider as O, LlmErrorCodeSchema as P, object as R, LlamaSupervisor as S, catalogById as T, __exportAll as V, createApiSettingsStorePort as _, renderTranscript as a, createLlmRuntimeProvider as b, TEST_CHAT_CONSUMER as c, TEST_CHAT_MAX_FIRST_TOKEN_TIMEOUT_MS as d, TEST_CHAT_MIN_VISION_INPUT_TOKENS as f, encodeEvent as g, TestChatRequestSchema as h, pickTrackMedia as i, createLlmClient as j, CONSUMER_RETRY_POLICY as k, TEST_CHAT_DEFAULT_FIRST_TOKEN_TIMEOUT_MS as l, TestChatEventSchema as m, runTestChatStream as n, resolveImage as o, TEST_CHAT_PREFIX as p, TRACK_MEDIA_PREFERENCE as r, TEST_CHAT_CONNECT_TIMEOUT_MS as s, createTestChatPlaneHandler as t, TEST_CHAT_IDLE_TIMEOUT_MS as u, createMemorySettingsStorePort as v, LLM_MODEL_CATALOG as w, fileSha256 as x, createRuntimeClient as y, string as z };