@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.js CHANGED
@@ -8,9 +8,9 @@ let node_path$1 = require_chunk.__toESM(node_path, 1);
8
8
  node_path = require_chunk.__toESM(node_path);
9
9
  let node_crypto = require("node:crypto");
10
10
  let node_net = require("node:net");
11
- let node_util = require("node:util");
12
11
  let node_fs = require("node:fs");
13
12
  node_fs = require_chunk.__toESM(node_fs, 1);
13
+ let node_util = require("node:util");
14
14
  let node_zlib = require("node:zlib");
15
15
  let node_fs_promises = require("node:fs/promises");
16
16
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
@@ -12474,6 +12474,18 @@ var LlmGenerateBaseInputSchema = object({
12474
12474
  * Resource ceiling = llama-server flags + idleStopMinutes ONLY (no RSS
12475
12475
  * watchdog — operator decision #3).
12476
12476
  */
12477
+ /**
12478
+ * A companion artifact that MUST land beside the main GGUF: the `mmproj`
12479
+ * projector of a vision model, or shards 2..N of a split GGUF. Carried on the
12480
+ * REF rather than looked up at install time, so what the operator approved in
12481
+ * the preview is exactly what the node downloads.
12482
+ */
12483
+ var ManagedModelExtraFileSchema = object({
12484
+ url: string(),
12485
+ filename: string(),
12486
+ sizeBytes: number$1(),
12487
+ sha256: string().optional()
12488
+ });
12477
12489
  var ManagedModelRefSchema = discriminatedUnion("kind", [
12478
12490
  object({
12479
12491
  kind: literal("catalog"),
@@ -12482,7 +12494,11 @@ var ManagedModelRefSchema = discriminatedUnion("kind", [
12482
12494
  object({
12483
12495
  kind: literal("url"),
12484
12496
  url: string(),
12485
- sha256: string().optional()
12497
+ sha256: string().optional(),
12498
+ /** Picker/status label; the file basename when absent. */
12499
+ label: string().optional(),
12500
+ sizeBytes: number$1().optional(),
12501
+ extraFiles: array(ManagedModelExtraFileSchema).optional()
12486
12502
  }),
12487
12503
  object({
12488
12504
  kind: literal("path"),
@@ -12543,11 +12559,39 @@ var ManagedRuntimeConfigSchema = object({
12543
12559
  "q4_1",
12544
12560
  "q4_0"
12545
12561
  ]).optional(),
12562
+ /**
12563
+ * Escape hatch for llama-server flags this schema does NOT model — `--jinja`
12564
+ * (which most vision chat templates need and some language-only models
12565
+ * dislike), `--cont-batching`, `--rope-scaling`, …
12566
+ *
12567
+ * It is NOT a second place to set the flags above. A token that collides
12568
+ * with a typed field is REJECTED at start, naming the field that owns it
12569
+ * (`assertNoOwnedFlags`), because two knobs writing the same argv is exactly
12570
+ * the "two switches that disagree" failure this repo has already shipped
12571
+ * twice (D62).
12572
+ */
12573
+ extraArgs: array(string()).default([]),
12546
12574
  /** Else lazy: first generate boots it. */
12547
12575
  autoStart: boolean().default(false),
12548
12576
  /** 0 = never; frees RAM after quiet periods. */
12549
12577
  idleStopMinutes: number$1().int().default(30)
12550
12578
  });
12579
+ /**
12580
+ * Where a multi-GB install currently is. A single 0..1 fraction cannot answer
12581
+ * "is it stuck?" for an install that is three files (shards + mmproj) followed
12582
+ * by a sha256 pass over 22 GB — during which the fraction sat at 1.0 and the
12583
+ * node looked hung. Phase + file + bytes is the smallest shape that does.
12584
+ */
12585
+ var LlmDownloadProgressSchema = object({
12586
+ phase: _enum(["downloading", "verifying"]),
12587
+ /** The artifact currently moving, e.g. `mmproj-F16.gguf`. */
12588
+ file: string(),
12589
+ fileIndex: number$1().int(),
12590
+ fileCount: number$1().int(),
12591
+ /** Across the WHOLE install, not the current file. */
12592
+ downloadedBytes: number$1(),
12593
+ totalBytes: number$1().optional()
12594
+ });
12551
12595
  var LlmRuntimeStatusSchema = object({
12552
12596
  /** Status is ALWAYS node-qualified. */
12553
12597
  nodeId: string(),
@@ -12564,6 +12608,8 @@ var LlmRuntimeStatusSchema = object({
12564
12608
  modelPath: string().optional(),
12565
12609
  modelId: string().optional(),
12566
12610
  downloadProgress: number$1().min(0).max(1).optional(),
12611
+ /** Detail behind `downloadProgress`; present for the same lifetime. */
12612
+ download: LlmDownloadProgressSchema.optional(),
12567
12613
  lastError: string().optional(),
12568
12614
  crashesInWindow: number$1(),
12569
12615
  /** Child RSS (sampled best-effort). */
@@ -12574,7 +12620,14 @@ var LlmNodeModelSchema = object({
12574
12620
  file: string(),
12575
12621
  sizeBytes: number$1(),
12576
12622
  catalogId: string().optional(),
12577
- installedAt: number$1().optional()
12623
+ installedAt: number$1().optional(),
12624
+ /**
12625
+ * Absolute path on the node. Present so a file that is on disk but matches
12626
+ * no catalog entry — a custom Hugging Face install, or a GGUF the operator
12627
+ * copied in by hand — is still SELECTABLE, as a `{kind:'path'}` ref. Without
12628
+ * it the picker could list such a file and do nothing with it.
12629
+ */
12630
+ path: string().optional()
12578
12631
  });
12579
12632
  var LlmRuntimeDiskUsageSchema = object({
12580
12633
  nodeId: string(),
@@ -12735,6 +12788,36 @@ var ManagedModelCatalogEntrySchema = object({
12735
12788
  /** Vision models: companion projector file. */
12736
12789
  mmprojUrl: string().optional()
12737
12790
  });
12791
+ /**
12792
+ * The outcome of turning one operator-typed Hugging Face reference into a
12793
+ * download plan. A RESULT, never a throw: "this repo has 24 quantizations and
12794
+ * I will not pick for you" is a normal answer the UI has to render, not an
12795
+ * exception.
12796
+ *
12797
+ * `candidates` is the whole reason the refusal is usable — every string in it
12798
+ * is a tag that resolves when pasted back as `<org>/<repo>:<TAG>`.
12799
+ */
12800
+ var HfModelResolutionSchema = discriminatedUnion("ok", [object({
12801
+ ok: literal(true),
12802
+ /** Ready to hand to `installModel` unchanged. */
12803
+ model: ManagedModelRefSchema,
12804
+ label: string(),
12805
+ repo: string(),
12806
+ quantization: string(),
12807
+ purpose: _enum(["text", "vision"]),
12808
+ totalBytes: number$1(),
12809
+ /** mmproj + shards, for the preview: an operator approving 23 GB should
12810
+ * see that 0.9 GB of it is a projector they did not name. */
12811
+ extraFilenames: array(string())
12812
+ }), object({
12813
+ ok: literal(false),
12814
+ code: string(),
12815
+ message: string(),
12816
+ candidates: array(string()).optional(),
12817
+ /** Set when the refusal was only the ceiling: re-calling with
12818
+ * `maxBytes: requiredBytes` is the operator's explicit override. */
12819
+ requiredBytes: number$1().optional()
12820
+ })]);
12738
12821
  var LlmRuntimeNodeSchema = object({
12739
12822
  nodeId: string(),
12740
12823
  reachable: boolean(),
@@ -12802,6 +12885,25 @@ var llmCapability = {
12802
12885
  listModelCatalog: method(object({}), array(ManagedModelCatalogEntrySchema)),
12803
12886
  listRuntimeNodes: method(object({}), array(LlmRuntimeNodeSchema)),
12804
12887
  listNodeModels: method(object({ nodeId: string() }), array(LlmNodeModelSchema)),
12888
+ /**
12889
+ * One typed Hugging Face reference → a pinned, verified `ManagedModelRef`.
12890
+ *
12891
+ * Runs on the HUB, not on the target node: resolution needs egress to
12892
+ * huggingface.co, and an agent that cannot reach it still installs fine
12893
+ * through the model-distributor relay. Nothing is downloaded here — this is
12894
+ * a tree read plus a HEAD, so the operator sees the size, the quantization
12895
+ * and the mmproj BEFORE approving a multi-GB pull.
12896
+ */
12897
+ resolveModelRef: method(object({
12898
+ /** `https://huggingface.co/<org>/<repo>/resolve/main/<f>.gguf`,
12899
+ * `<org>/<repo>/<f>.gguf`, `<org>/<repo>` or `<org>/<repo>:<QUANT>`. */
12900
+ ref: string(),
12901
+ /** Explicit ceiling override, in bytes. Absent = the built-in ceiling. */
12902
+ maxBytes: number$1().positive().optional()
12903
+ }), HfModelResolutionSchema, {
12904
+ kind: "mutation",
12905
+ auth: "admin"
12906
+ }),
12805
12907
  installModel: method(object({
12806
12908
  nodeId: string(),
12807
12909
  model: ManagedModelRefSchema
@@ -17941,6 +18043,17 @@ var maxSessionHoldMsField = {
17941
18043
  default: 12e4,
17942
18044
  step: 5e3
17943
18045
  };
18046
+ /**
18047
+ * Quiet period that closes an `audioMode: 'on-motion'` audio window. Floor of
18048
+ * 5s so a rearm can never degenerate into per-event stream churn; default 90s
18049
+ * comfortably outlives the gap between two PIR wakes on a battery camera.
18050
+ */
18051
+ var audioMotionWindowMsField = {
18052
+ min: 5e3,
18053
+ max: 6e5,
18054
+ default: 9e4,
18055
+ step: 5e3
18056
+ };
17944
18057
  var motionFpsField = {
17945
18058
  min: 1,
17946
18059
  max: 30,
@@ -18117,6 +18230,27 @@ var RunnerCameraConfigSchema = object({
18117
18230
  * resolved `CameraDetectionConfig`.
18118
18231
  */
18119
18232
  maxSessionHoldMs: number$1().min(maxSessionHoldMsField.min).max(maxSessionHoldMsField.max).optional(),
18233
+ /**
18234
+ * Orchestrator-side quiet period (ms) that closes an `audioMode:
18235
+ * 'on-motion'` audio window, measured from the LAST motion event.
18236
+ *
18237
+ * This exists because the falling edge cannot be relied on. Camera-native
18238
+ * providers emit motion as a RISING EDGE ONLY (Reolink's Baichuan push and
18239
+ * its email-push SMTP path both emit `detected: true` and never the
18240
+ * counterpart); only the frame-diff analyzer emits falls. So on an
18241
+ * onboard-only camera a window that closed only on `detected: false` never
18242
+ * closed at all, and `on-motion` silently behaved as `always-on` — on a
18243
+ * battery camera, the one failure mode the mode exists to prevent.
18244
+ *
18245
+ * Every motion event rearms this timer WITHOUT restarting the stream, so a
18246
+ * burst of re-fires costs nothing. A falling edge, when one does arrive,
18247
+ * still closes earlier via `motionCooldownMs` — whichever comes first wins.
18248
+ *
18249
+ * Not consumed by the runner: carried here so it shares the per-camera
18250
+ * device-settings surface with `motionCooldownMs`, exactly like
18251
+ * `maxSessionHoldMs`.
18252
+ */
18253
+ audioMotionWindowMs: number$1().min(audioMotionWindowMsField.min).max(audioMotionWindowMsField.max).optional(),
18120
18254
  motionFps: number$1().min(motionFpsField.min).max(motionFpsField.max).default(motionFpsField.default),
18121
18255
  detectionFps: number$1().min(detectionFpsField.min).max(detectionFpsField.max).default(detectionFpsField.default),
18122
18256
  motionStreamId: string(),
@@ -18212,7 +18346,7 @@ var RunnerCameraConfigSchema = object({
18212
18346
  */
18213
18347
  inferenceDevices: array(RunnerInferenceDeviceSchema).readonly().optional()
18214
18348
  });
18215
- 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;
18349
+ 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;
18216
18350
  /**
18217
18351
  * Runtime load summary returned by `getLocalLoad`. Used by the orchestrator's
18218
18352
  * load-balancing levels (L2 capacity-based, L3 hardware-aware) to decide
@@ -28059,6 +28193,12 @@ Object.freeze({
28059
28193
  addonId: null,
28060
28194
  access: "view"
28061
28195
  },
28196
+ "llm.resolveModelRef": {
28197
+ capName: "llm",
28198
+ capScope: "system",
28199
+ addonId: null,
28200
+ access: "create"
28201
+ },
28062
28202
  "llm.setDefault": {
28063
28203
  capName: "llm",
28064
28204
  capScope: "system",
@@ -45767,7 +45907,7 @@ function inferDocMediaType(uriOrName) {
45767
45907
  for (const [ext, media] of Object.entries(KNOWN_DOC_EXTENSIONS)) if (lower.endsWith(`.${ext}`)) return media;
45768
45908
  return "application/octet-stream";
45769
45909
  }
45770
- function basename$1(uriOrName) {
45910
+ function basename$2(uriOrName) {
45771
45911
  const parts = uriOrName.split("/");
45772
45912
  const last = parts[parts.length - 1];
45773
45913
  return last && last.length > 0 ? last : void 0;
@@ -45797,7 +45937,7 @@ function annotationToSource({ annotation, generateId: generateId3 }) {
45797
45937
  url: uri,
45798
45938
  ...fileCitation.file_name != null ? { title: fileCitation.file_name } : {}
45799
45939
  };
45800
- const filename = (_c = fileCitation.file_name) != null ? _c : basename$1(uri);
45940
+ const filename = (_c = fileCitation.file_name) != null ? _c : basename$2(uri);
45801
45941
  const mediaType = inferDocMediaType(uri);
45802
45942
  return {
45803
45943
  type: "source",
@@ -45886,7 +46026,7 @@ function builtinToolResultToSources({ block, generateId: generateId3 }) {
45886
46026
  });
45887
46027
  continue;
45888
46028
  }
45889
- const filename = (_h = entry.file_name) != null ? _h : basename$1(uri);
46029
+ const filename = (_h = entry.file_name) != null ? _h : basename$2(uri);
45890
46030
  const mediaType = inferDocMediaType(uri);
45891
46031
  sources.push({
45892
46032
  type: "source",
@@ -70093,6 +70233,469 @@ function resolveProfile(profiles, defaults, input) {
70093
70233
  };
70094
70234
  }
70095
70235
  //#endregion
70236
+ //#region src/runtime/hf-ref.ts
70237
+ /**
70238
+ * Hugging Face model references — parse, then resolve against the HF API.
70239
+ *
70240
+ * The operator types ONE string and gets a fully-pinned download plan. That is
70241
+ * the whole surface: this is not an HF browser, and it deliberately cannot
70242
+ * discover a model for you — it can only turn a reference you already have
70243
+ * into something the node can fetch and verify.
70244
+ *
70245
+ * ## Why resolution can REFUSE
70246
+ *
70247
+ * A GGUF repo is not one model. `unsloth/Qwen3.6-35B-A3B-GGUF` ships 25
70248
+ * quantizations between 10 GB and 50 GB, and none of them is named `Q4_K_M`
70249
+ * (they are `UD-Q4_K_M`, Unsloth's dynamic quant). Any code that "defaults to
70250
+ * Q4_K_M" would either fail or, worse, pick a neighbouring file and hand the
70251
+ * operator a model they did not ask for after a 20 GB download. So: a repo
70252
+ * with more than one candidate is an ERROR that NAMES the candidates, never a
70253
+ * guess. The only silent pick is the mmproj precision (F16 over F32) — that
70254
+ * choice costs a few hundred MB of projector, not a different model, and the
70255
+ * file it picked is reported back.
70256
+ *
70257
+ * ## The error taxonomy is read from headers, not from the status
70258
+ *
70259
+ * Probed live on 2026-08-15: huggingface.co answers **401** both for a gated
70260
+ * repo and for a repo that does not exist (it refuses to leak whether a
70261
+ * private repo is there). The two are distinguishable only by
70262
+ * `x-error-code: GatedRepo`. Reading the status alone would tell a
70263
+ * typo'd repo name that it needs a token, which is the wrong instruction.
70264
+ *
70265
+ * ## What is verified before a byte is downloaded
70266
+ *
70267
+ * host is huggingface.co · extension is `.gguf` · every file exists in the
70268
+ * tree · the split-GGUF shard set is COMPLETE · the total (main + shards +
70269
+ * mmproj) is under the ceiling · a HEAD confirms the file is reachable with
70270
+ * the credentials at hand and that its size agrees with the tree. The sha256
70271
+ * comes free: HF's LFS `oid` IS the sha256 of the file, and `x-linked-etag`
70272
+ * repeats it on the HEAD.
70273
+ */
70274
+ /** The only hosts a reference may point at. */
70275
+ var HF_HOSTS = ["huggingface.co", "www.huggingface.co"];
70276
+ var HF_API = "https://huggingface.co/api/models";
70277
+ var HF_RESOLVE = "https://huggingface.co";
70278
+ /** Where an operator puts a Hugging Face token, named in the gated error. */
70279
+ var HF_TOKEN_ENV = "HF_TOKEN";
70280
+ var SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
70281
+ function fail(code, message, candidates) {
70282
+ return {
70283
+ code,
70284
+ message,
70285
+ ...candidates !== void 0 ? { candidates } : {}
70286
+ };
70287
+ }
70288
+ function badParse(code, message, candidates) {
70289
+ return {
70290
+ ok: false,
70291
+ error: fail(code, message, candidates)
70292
+ };
70293
+ }
70294
+ var EXPECTED = "expected https://huggingface.co/<org>/<repo>/resolve/main/<file>.gguf, or <org>/<repo>/<file>.gguf, or <org>/<repo>[:<QUANT>]";
70295
+ /**
70296
+ * Reference string → a repo/file reference. Pure: no network, no environment.
70297
+ * Every rejection names the form that WAS expected, because the operator is
70298
+ * pasting from a browser and a bare "invalid" tells them nothing.
70299
+ */
70300
+ function parseHfRef(input) {
70301
+ const raw = input.trim();
70302
+ if (raw === "") return badParse("malformed", `empty model reference — ${EXPECTED}`);
70303
+ return raw.includes("://") ? parseUrlForm(raw) : parseBareForm(raw);
70304
+ }
70305
+ function parseUrlForm(raw) {
70306
+ let url;
70307
+ try {
70308
+ url = new URL(raw);
70309
+ } catch {
70310
+ return badParse("malformed", `not a URL: ${raw} — ${EXPECTED}`);
70311
+ }
70312
+ if (!HF_HOSTS.includes(url.hostname)) return badParse("not-huggingface", `only huggingface.co models can be installed this way; got host "${url.hostname}"`);
70313
+ const parts = url.pathname.split("/").filter((p) => p !== "");
70314
+ const marker = parts.findIndex((p) => p === "resolve" || p === "blob");
70315
+ if (marker !== 2 || parts.length < marker + 3) return badParse("malformed", `unrecognised Hugging Face URL: ${raw} — ${EXPECTED}`);
70316
+ return finishParse(`${String(parts[0])}/${String(parts[1])}`, String(parts[marker + 1]), parts.slice(marker + 2).join("/"), raw);
70317
+ }
70318
+ function parseBareForm(raw) {
70319
+ const [beforeTag, ...tagRest] = raw.split(":");
70320
+ const body = String(beforeTag);
70321
+ if (tagRest.length > 1) return badParse("malformed", `too many ":" in ${raw} — ${EXPECTED}`);
70322
+ const quant = tagRest[0]?.trim();
70323
+ const parts = body.split("/");
70324
+ if (parts.length < 2) return badParse("malformed", `not an <org>/<repo> reference: ${raw} — ${EXPECTED}`);
70325
+ if (parts.some((p) => p === "" || p === "." || p === "..")) return badParse("malformed", `illegal path segment in ${raw} — ${EXPECTED}`);
70326
+ const org = String(parts[0]);
70327
+ const name = String(parts[1]);
70328
+ if (!SEGMENT_RE.test(org) || !SEGMENT_RE.test(name)) return badParse("malformed", `illegal repo name in ${raw} — ${EXPECTED}`);
70329
+ const repo = `${org}/${name}`;
70330
+ if (parts.length === 2) {
70331
+ if (quant !== void 0 && quant === "") return badParse("malformed", `empty quantization tag in ${raw} — ${EXPECTED}`);
70332
+ return {
70333
+ ok: true,
70334
+ ref: {
70335
+ kind: "repo",
70336
+ repo,
70337
+ revision: "main",
70338
+ ...quant !== void 0 ? { quant } : {}
70339
+ }
70340
+ };
70341
+ }
70342
+ if (quant !== void 0) return badParse("malformed", `a quantization tag cannot follow an explicit file: ${raw}`);
70343
+ return finishParse(repo, "main", parts.slice(2).join("/"), raw);
70344
+ }
70345
+ function finishParse(repo, revision, filePath, raw) {
70346
+ if (filePath.split("/").some((p) => p === "" || p === "." || p === "..")) return badParse("malformed", `illegal path segment in ${raw} — ${EXPECTED}`);
70347
+ if (!filePath.toLowerCase().endsWith(".gguf")) return badParse("not-gguf", `the managed local runtime loads GGUF only; "${filePath}" is not a .gguf file`);
70348
+ return {
70349
+ ok: true,
70350
+ ref: {
70351
+ kind: "file",
70352
+ repo,
70353
+ revision,
70354
+ filePath
70355
+ }
70356
+ };
70357
+ }
70358
+ /** `-00001-of-00002` — llama.cpp's split-GGUF naming. */
70359
+ var SHARD_RE = /^(.*)-(\d{5})-of-(\d{5})$/;
70360
+ /**
70361
+ * One `-`-delimited segment that is a quantization, e.g. `Q4_K_M`, `IQ2_XXS`,
70362
+ * `BF16`, `fp16`. The `FP` spellings are not cosmetic: `Qwen/*-GGUF` names its
70363
+ * unquantized file `…-fp16.gguf`, and a tag list that cannot name it offers
70364
+ * the operator a suggestion that does not parse.
70365
+ */
70366
+ var QUANT_RE = /^(?:I?Q\d[A-Z0-9_]*|TQ\d_\d|BF16|FP?16|FP?32|FP8|MXFP4(?:_MOE)?)$/i;
70367
+ /** Shard coordinates of a split GGUF filename, or `null` when unsharded. */
70368
+ function shardInfoOf(filename) {
70369
+ const m = SHARD_RE.exec(stripGguf(filename));
70370
+ if (m === null) return null;
70371
+ return {
70372
+ stem: String(m[1]),
70373
+ index: Number(m[2]),
70374
+ total: Number(m[3])
70375
+ };
70376
+ }
70377
+ function stripGguf(filename) {
70378
+ return filename.replace(/\.gguf$/i, "");
70379
+ }
70380
+ /**
70381
+ * The quantization tag of a GGUF filename, uppercased, `UD-` prefix kept —
70382
+ * `''` when the name carries no recognisable tag. Shard coordinates are
70383
+ * stripped first so `X-BF16-00001-of-00002.gguf` reads as `BF16`.
70384
+ */
70385
+ function quantizationOf(filename) {
70386
+ const segments = (shardInfoOf(filename)?.stem ?? stripGguf(filename)).split("-");
70387
+ for (let i = segments.length - 1; i >= 0; i--) {
70388
+ const seg = String(segments[i]);
70389
+ if (!QUANT_RE.test(seg)) continue;
70390
+ return (i > 0 ? String(segments[i - 1]) : "").toUpperCase() === "UD" ? `UD-${seg.toUpperCase()}` : seg.toUpperCase();
70391
+ }
70392
+ return "";
70393
+ }
70394
+ function isMmproj(filePath) {
70395
+ return basename$1(filePath).toLowerCase().startsWith("mmproj");
70396
+ }
70397
+ function basename$1(filePath) {
70398
+ return filePath.slice(filePath.lastIndexOf("/") + 1);
70399
+ }
70400
+ function dirname(filePath) {
70401
+ const i = filePath.lastIndexOf("/");
70402
+ return i < 0 ? "" : filePath.slice(0, i);
70403
+ }
70404
+ function headersFor(token) {
70405
+ return {
70406
+ "User-Agent": "CamStack/1.0",
70407
+ ...token !== void 0 && token !== "" ? { Authorization: `Bearer ${token}` } : {}
70408
+ };
70409
+ }
70410
+ /** HF's 401-for-everything is only decodable through `x-error-code`. */
70411
+ function authError(response, repo) {
70412
+ const code = response.headers.get("x-error-code") ?? "";
70413
+ 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.`);
70414
+ 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.`);
70415
+ }
70416
+ async function readTree(ref, fetchFn, token) {
70417
+ const url = `${HF_API}/${ref.repo}/tree/${ref.revision}?recursive=1`;
70418
+ let response;
70419
+ try {
70420
+ response = await fetchFn(url, {
70421
+ method: "GET",
70422
+ headers: headersFor(token)
70423
+ });
70424
+ } catch (err) {
70425
+ return {
70426
+ ok: false,
70427
+ error: fail("network", `could not reach huggingface.co: ${message(err)}`)
70428
+ };
70429
+ }
70430
+ if (response.status === 401 || response.status === 403) return {
70431
+ ok: false,
70432
+ error: authError(response, ref.repo)
70433
+ };
70434
+ if (response.status === 404) return {
70435
+ ok: false,
70436
+ error: fail("repo-not-found", `${ref.repo} has no revision "${ref.revision}"`)
70437
+ };
70438
+ if (!response.ok) return {
70439
+ ok: false,
70440
+ error: fail("network", `huggingface.co answered ${String(response.status)} for ${ref.repo}`)
70441
+ };
70442
+ let body;
70443
+ try {
70444
+ body = await response.json();
70445
+ } catch (err) {
70446
+ return {
70447
+ ok: false,
70448
+ error: fail("network", `unreadable tree for ${ref.repo}: ${message(err)}`)
70449
+ };
70450
+ }
70451
+ if (!Array.isArray(body)) return {
70452
+ ok: false,
70453
+ error: fail("network", `unexpected tree payload for ${ref.repo}`)
70454
+ };
70455
+ return {
70456
+ ok: true,
70457
+ files: body.map(toTreeEntry).filter((e) => e !== null)
70458
+ };
70459
+ }
70460
+ function toTreeEntry(raw) {
70461
+ if (typeof raw !== "object" || raw === null) return null;
70462
+ const record = { ...raw };
70463
+ if (record["type"] !== "file") return null;
70464
+ const filePath = record["path"];
70465
+ if (typeof filePath !== "string" || !filePath.toLowerCase().endsWith(".gguf")) return null;
70466
+ const lfs = typeof record["lfs"] === "object" && record["lfs"] !== null ? { ...record["lfs"] } : {};
70467
+ const lfsSize = lfs["size"];
70468
+ const oid = lfs["oid"];
70469
+ const plainSize = record["size"];
70470
+ return {
70471
+ path: filePath,
70472
+ sizeBytes: typeof lfsSize === "number" ? lfsSize : typeof plainSize === "number" ? plainSize : 0,
70473
+ ...typeof oid === "string" && oid.length === 64 ? { sha256: oid } : {}
70474
+ };
70475
+ }
70476
+ function message(err) {
70477
+ return err instanceof Error ? err.message : String(err);
70478
+ }
70479
+ /** Files that can be THE model: not a projector, not a follow-on shard. */
70480
+ function modelCandidates(files) {
70481
+ return files.filter((f) => {
70482
+ if (isMmproj(f.path)) return false;
70483
+ const shard = shardInfoOf(basename$1(f.path));
70484
+ return shard === null || shard.index === 1;
70485
+ });
70486
+ }
70487
+ function labelFor(file) {
70488
+ const quant = quantizationOf(basename$1(file.path));
70489
+ return quant === "" ? basename$1(file.path) : quant;
70490
+ }
70491
+ function selectMain(ref, files) {
70492
+ const candidates = modelCandidates(files);
70493
+ if (ref.kind === "file") {
70494
+ const wanted = ref.filePath.toLowerCase();
70495
+ const hit = files.find((f) => f.path.toLowerCase() === wanted);
70496
+ if (hit === void 0) return {
70497
+ ok: false,
70498
+ error: fail("file-not-found", `${ref.repo} has no file "${ref.filePath}" at revision ${ref.revision}`, candidates.map(labelFor))
70499
+ };
70500
+ return {
70501
+ ok: true,
70502
+ file: hit
70503
+ };
70504
+ }
70505
+ if (candidates.length === 0) return {
70506
+ ok: false,
70507
+ error: fail("not-gguf", `${ref.repo} publishes no GGUF weights (only projectors or no GGUF at all)`)
70508
+ };
70509
+ if (ref.quant !== void 0) {
70510
+ const wanted = ref.quant.toUpperCase();
70511
+ const wantedFile = stripGguf(ref.quant).toUpperCase();
70512
+ const matches = candidates.filter((f) => quantizationOf(basename$1(f.path)) === wanted || stripGguf(basename$1(f.path)).toUpperCase() === wantedFile);
70513
+ if (matches.length === 0) return {
70514
+ ok: false,
70515
+ error: fail("file-not-found", `${ref.repo} has no "${ref.quant}" quantization. Available: ${candidates.map(labelFor).join(", ")}`, dedupe(candidates.map(labelFor)))
70516
+ };
70517
+ const only = matches[0];
70518
+ if (matches.length > 1 || only === void 0) return {
70519
+ ok: false,
70520
+ 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)))
70521
+ };
70522
+ return {
70523
+ ok: true,
70524
+ file: only
70525
+ };
70526
+ }
70527
+ const solo = candidates[0];
70528
+ if (candidates.length > 1 || solo === void 0) {
70529
+ const tags = dedupe(candidates.map(labelFor));
70530
+ return {
70531
+ ok: false,
70532
+ 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)
70533
+ };
70534
+ }
70535
+ return {
70536
+ ok: true,
70537
+ file: solo
70538
+ };
70539
+ }
70540
+ function dedupe(values) {
70541
+ return [...new Set(values)];
70542
+ }
70543
+ /** Shards 2..N of `main`, or an error naming the first one that is missing. */
70544
+ function collectShards(main, files) {
70545
+ const shard = shardInfoOf(basename$1(main.path));
70546
+ if (shard === null || shard.total <= 1) return {
70547
+ ok: true,
70548
+ shards: []
70549
+ };
70550
+ const dir = dirname(main.path);
70551
+ const out = [];
70552
+ for (let i = 2; i <= shard.total; i++) {
70553
+ const wanted = `${shard.stem}-${String(i).padStart(5, "0")}-of-${String(shard.total).padStart(5, "0")}.gguf`;
70554
+ const full = dir === "" ? wanted : `${dir}/${wanted}`;
70555
+ const hit = files.find((f) => f.path === full);
70556
+ if (hit === void 0) return {
70557
+ ok: false,
70558
+ error: fail("incomplete-shards", `split GGUF is incomplete: ${wanted} is missing from the repo (llama.cpp needs all ${String(shard.total)} shards)`)
70559
+ };
70560
+ out.push(hit);
70561
+ }
70562
+ return {
70563
+ ok: true,
70564
+ shards: out
70565
+ };
70566
+ }
70567
+ /** F16 over BF16 over F32 over whatever came first — reported, never hidden. */
70568
+ var MMPROJ_PREFERENCE = [
70569
+ "F16",
70570
+ "BF16",
70571
+ "F32"
70572
+ ];
70573
+ function selectMmproj(files) {
70574
+ const projectors = files.filter((f) => isMmproj(f.path));
70575
+ if (projectors.length === 0) return null;
70576
+ for (const want of MMPROJ_PREFERENCE) {
70577
+ const hit = projectors.find((f) => quantizationOf(basename$1(f.path)) === want);
70578
+ if (hit !== void 0) return hit;
70579
+ }
70580
+ return projectors[0] ?? null;
70581
+ }
70582
+ function resolveUrl(repo, revision, filePath) {
70583
+ return `${HF_RESOLVE}/${repo}/resolve/${revision}/${filePath}`;
70584
+ }
70585
+ async function verifyHead(url, repo, declaredBytes, fetchFn, token) {
70586
+ let response;
70587
+ try {
70588
+ response = await fetchFn(url, {
70589
+ method: "HEAD",
70590
+ redirect: "manual",
70591
+ headers: headersFor(token)
70592
+ });
70593
+ } catch (err) {
70594
+ return {
70595
+ ok: false,
70596
+ error: fail("network", `HEAD ${url} failed: ${message(err)}`)
70597
+ };
70598
+ }
70599
+ if (response.status === 401 || response.status === 403) return {
70600
+ ok: false,
70601
+ error: authError(response, repo)
70602
+ };
70603
+ if (response.status === 404) return {
70604
+ ok: false,
70605
+ error: fail("file-not-found", `${url} is gone (404)`)
70606
+ };
70607
+ if (response.status >= 400) return {
70608
+ ok: false,
70609
+ error: fail("network", `HEAD ${url} answered ${String(response.status)}`)
70610
+ };
70611
+ const linked = response.headers.get("x-linked-size") ?? response.headers.get("content-length");
70612
+ const headBytes = linked === null ? void 0 : Number(linked);
70613
+ if (headBytes !== void 0 && Number.isFinite(headBytes) && headBytes !== declaredBytes) return {
70614
+ ok: false,
70615
+ 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`)
70616
+ };
70617
+ const etag = response.headers.get("x-linked-etag")?.replace(/"/g, "");
70618
+ return {
70619
+ ok: true,
70620
+ ...etag !== void 0 && etag.length === 64 ? { sha256: etag } : {}
70621
+ };
70622
+ }
70623
+ function toResolved(repo, revision, entry) {
70624
+ return {
70625
+ url: resolveUrl(repo, revision, entry.path),
70626
+ filename: basename$1(entry.path),
70627
+ sizeBytes: entry.sizeBytes,
70628
+ ...entry.sha256 !== void 0 ? { sha256: entry.sha256 } : {}
70629
+ };
70630
+ }
70631
+ function gb$1(bytes) {
70632
+ return `${(bytes / 1e9).toFixed(1)} GB`;
70633
+ }
70634
+ /** Reference → a pinned, size-checked, HEAD-verified download plan. */
70635
+ async function resolveHfRef(ref, deps) {
70636
+ const fetchFn = deps.fetchFn ?? fetch;
70637
+ const maxBytes = deps.maxBytes ?? 21474836480;
70638
+ const tree = await readTree(ref, fetchFn, deps.token);
70639
+ if (!tree.ok) return {
70640
+ ok: false,
70641
+ error: tree.error
70642
+ };
70643
+ const picked = selectMain(ref, tree.files);
70644
+ if (!picked.ok) return {
70645
+ ok: false,
70646
+ error: picked.error
70647
+ };
70648
+ const main = picked.file;
70649
+ const shards = collectShards(main, tree.files);
70650
+ if (!shards.ok) return {
70651
+ ok: false,
70652
+ error: shards.error
70653
+ };
70654
+ const projector = isMmproj(main.path) ? null : selectMmproj(tree.files);
70655
+ const extraEntries = [...shards.shards, ...projector === null ? [] : [projector]];
70656
+ const totalBytes = [main, ...extraEntries].reduce((sum, f) => sum + f.sizeBytes, 0);
70657
+ if (totalBytes > maxBytes) return {
70658
+ ok: false,
70659
+ error: {
70660
+ ...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.`),
70661
+ requiredBytes: totalBytes
70662
+ }
70663
+ };
70664
+ const head = await verifyHead(resolveUrl(ref.repo, ref.revision, main.path), ref.repo, main.sizeBytes, fetchFn, deps.token);
70665
+ if (!head.ok) return {
70666
+ ok: false,
70667
+ error: head.error
70668
+ };
70669
+ const mainResolved = toResolved(ref.repo, ref.revision, {
70670
+ ...main,
70671
+ ...main.sha256 === void 0 && head.sha256 !== void 0 ? { sha256: head.sha256 } : {}
70672
+ });
70673
+ const quantization = quantizationOf(mainResolved.filename);
70674
+ const repoName = ref.repo.slice(ref.repo.indexOf("/") + 1);
70675
+ return {
70676
+ ok: true,
70677
+ resolution: {
70678
+ repo: ref.repo,
70679
+ revision: ref.revision,
70680
+ label: quantization === "" ? repoName : `${repoName} · ${quantization}`,
70681
+ quantization,
70682
+ purpose: projector === null ? "text" : "vision",
70683
+ main: mainResolved,
70684
+ extras: extraEntries.map((e) => toResolved(ref.repo, ref.revision, e)),
70685
+ totalBytes
70686
+ }
70687
+ };
70688
+ }
70689
+ /** `parseHfRef` then {@link resolveHfRef} — the form the cap method calls. */
70690
+ async function resolveHfReference(input, deps) {
70691
+ const parsed = parseHfRef(input);
70692
+ if (!parsed.ok) return {
70693
+ ok: false,
70694
+ error: parsed.error
70695
+ };
70696
+ return resolveHfRef(parsed.ref, deps);
70697
+ }
70698
+ //#endregion
70096
70699
  //#region src/secrets.ts
70097
70700
  /** Same marker as addon-notifiers/src/secrets.ts — UI contract. */
70098
70701
  var REDACTED_MARKER = "__redacted__";
@@ -70316,6 +70919,64 @@ function createLlmProvider(deps) {
70316
70919
  }));
70317
70920
  },
70318
70921
  listNodeModels: async ({ nodeId }) => requireRuntime(deps.runtime).listLocalModels(nodeId),
70922
+ /**
70923
+ * Hugging Face reference → a pinned `ManagedModelRef`, on the HUB.
70924
+ *
70925
+ * Never throws: a refusal ("this repo has 24 quantizations", "this is
70926
+ * gated", "23 GB is over the ceiling") is an ANSWER the operator has to
70927
+ * read and act on, and turning it into a tRPC error would reduce all of
70928
+ * them to a red toast with no candidate list and no override.
70929
+ */
70930
+ resolveModelRef: async ({ ref, maxBytes }) => {
70931
+ const token = deps.hfToken?.();
70932
+ const outcome = await resolveHfReference(ref, {
70933
+ ...maxBytes !== void 0 ? { maxBytes } : {},
70934
+ ...token !== void 0 && token !== "" ? { token } : {}
70935
+ });
70936
+ if (!outcome.ok) {
70937
+ deps.logger?.info("llm model reference refused", { meta: {
70938
+ ref,
70939
+ code: outcome.error.code
70940
+ } });
70941
+ return {
70942
+ ok: false,
70943
+ code: outcome.error.code,
70944
+ message: outcome.error.message,
70945
+ ...outcome.error.candidates !== void 0 ? { candidates: [...outcome.error.candidates] } : {},
70946
+ ...outcome.error.requiredBytes !== void 0 ? { requiredBytes: outcome.error.requiredBytes } : {}
70947
+ };
70948
+ }
70949
+ const { resolution } = outcome;
70950
+ deps.logger?.info("llm model reference resolved", { meta: {
70951
+ ref,
70952
+ repo: resolution.repo,
70953
+ quantization: resolution.quantization,
70954
+ purpose: resolution.purpose,
70955
+ totalBytes: resolution.totalBytes
70956
+ } });
70957
+ return {
70958
+ ok: true,
70959
+ model: {
70960
+ kind: "url",
70961
+ url: resolution.main.url,
70962
+ ...resolution.main.sha256 !== void 0 ? { sha256: resolution.main.sha256 } : {},
70963
+ label: resolution.label,
70964
+ sizeBytes: resolution.main.sizeBytes,
70965
+ extraFiles: resolution.extras.map((e) => ({
70966
+ url: e.url,
70967
+ filename: e.filename,
70968
+ sizeBytes: e.sizeBytes,
70969
+ ...e.sha256 !== void 0 ? { sha256: e.sha256 } : {}
70970
+ }))
70971
+ },
70972
+ label: resolution.label,
70973
+ repo: resolution.repo,
70974
+ quantization: resolution.quantization,
70975
+ purpose: resolution.purpose,
70976
+ totalBytes: resolution.totalBytes,
70977
+ extraFilenames: resolution.extras.map((e) => e.filename)
70978
+ };
70979
+ },
70319
70980
  installModel: async ({ nodeId, model }) => {
70320
70981
  const runtime = requireRuntime(deps.runtime);
70321
70982
  try {
@@ -70349,13 +71010,29 @@ function createLlmProvider(deps) {
70349
71010
  //#endregion
70350
71011
  //#region src/runtime/llm-model-catalog.ts
70351
71012
  /**
70352
- * Curated managed-model catalog (operator decision #4): ~2 small text GGUFs
70353
- * (2-4B, Q4) + 1 small vision GGUF with its companion mmproj, sized to the
70354
- * weakest runtime node (the N100 agent). Each entry carries BOTH the LLM-facing
70355
- * picker view (`meta`) and the REUSED download-plane `ModelCatalogEntry`
70356
- * (`entry`) so GGUFs ride `ensureModel` + `model-distributor` untouched — no
70357
- * bespoke fetcher (spec §4.2). Digests/sizes pinned via
70358
- * scratchpad/pin-llm-models.mjs (HF LFS `lfs.oid`).
71013
+ * Curated managed-model catalog (operator decision #4). Each entry carries BOTH
71014
+ * the LLM-facing picker view (`meta`) and the REUSED download-plane
71015
+ * `ModelCatalogEntry` (`entry`) so GGUFs ride `ensureModel` +
71016
+ * `model-distributor` untouched no bespoke fetcher (spec §4.2).
71017
+ * Digests/sizes pinned via scratchpad/pin-llm-models.mjs (HF LFS `lfs.oid`,
71018
+ * which IS the file's sha256).
71019
+ *
71020
+ * ## Two tiers, and `minRamBytes` is what separates them
71021
+ *
71022
+ * The first three entries are sized to the WEAKEST runtime node (the N100
71023
+ * agent): 1-4 GB, Q4. `QWEN36_35B` is not — it is 23 GB and only a big node
71024
+ * can hold it. The catalog does not refuse to show it; `minRamBytes` is the
71025
+ * guidance, and the picker prints the size. Keeping the tiers in one list is
71026
+ * deliberate: an operator with a 64 GB box should not have to discover the
71027
+ * free-text field to run something real.
71028
+ *
71029
+ * ## This list is no longer the boundary of what can run
71030
+ *
71031
+ * Anything on Hugging Face is installable through `llm.resolveModelRef` +
71032
+ * `installModel` without a code change ({@link ./hf-ref.ts}). An entry here
71033
+ * buys exactly two things over typing the reference: a pinned digest nobody
71034
+ * has to re-verify, and a `contextSizeDefault`/`minRamBytes` somebody checked.
71035
+ * Add one only when both are true.
70359
71036
  */
70360
71037
  var GIB = 1024 * 1024 * 1024;
70361
71038
  function mb(bytes) {
@@ -70407,30 +71084,117 @@ var LLAMA = textEntry({
70407
71084
  var SMOLVLM_MODEL_BYTES = 1112602656;
70408
71085
  var SMOLVLM_MMPROJ_BYTES = 872303680;
70409
71086
  var SMOLVLM_MMPROJ_URL = "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/mmproj-SmolVLM2-2.2B-Instruct-f16.gguf";
71087
+ var SMOLVLM = {
71088
+ meta: {
71089
+ id: "llm-smolvlm2-2.2b-instruct-q4",
71090
+ label: "SmolVLM2 2.2B Instruct (vision)",
71091
+ family: "smolvlm2",
71092
+ purpose: "vision",
71093
+ url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
71094
+ sha256: "0cf76814555b8665149075b74ab6b5c1d428ea1d3d01c1918c12012e8d7c9f58",
71095
+ sizeBytes: SMOLVLM_MODEL_BYTES,
71096
+ quantization: "Q4_K_M",
71097
+ minRamBytes: 4 * GIB,
71098
+ contextSizeDefault: 4096,
71099
+ mmprojUrl: SMOLVLM_MMPROJ_URL
71100
+ },
71101
+ entry: {
71102
+ id: "llm-smolvlm2-2.2b-instruct-q4",
71103
+ name: "SmolVLM2 2.2B Instruct (vision)",
71104
+ description: "smolvlm2 · Q4_K_M · +mmproj",
71105
+ formats: { gguf: {
71106
+ url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
71107
+ sizeMB: mb(SMOLVLM_MODEL_BYTES)
71108
+ } },
71109
+ inputSize: {
71110
+ width: 0,
71111
+ height: 0
71112
+ },
71113
+ labels: [],
71114
+ extraFiles: [{
71115
+ url: SMOLVLM_MMPROJ_URL,
71116
+ filename: "mmproj-SmolVLM2-2.2B-Instruct-f16.gguf",
71117
+ sizeMB: mb(SMOLVLM_MMPROJ_BYTES)
71118
+ }]
71119
+ }
71120
+ };
71121
+ var QWEN3VL2B_MODEL_BYTES = 1107410624;
71122
+ var QWEN3VL2B_MMPROJ_BYTES = 819395232;
71123
+ var QWEN3VL2B_BASE = "https://huggingface.co/unsloth/Qwen3-VL-2B-Instruct-GGUF/resolve/main";
71124
+ var QWEN3VL2B_MMPROJ_URL = `${QWEN3VL2B_BASE}/mmproj-F16.gguf`;
71125
+ var QWEN3VL2B_URL = `${QWEN3VL2B_BASE}/Qwen3-VL-2B-Instruct-Q4_K_M.gguf`;
71126
+ /**
71127
+ * The light vision tier the operator asked for by weight class (~2 GB all in):
71128
+ * same Qwen3-VL family as the LM Studio 8B profile already in daily use, so
71129
+ * prompts and behaviour carry over — at a tenth of the 35B's disk and a RAM
71130
+ * floor a hub-adjacent node can always afford. This is the sensible default
71131
+ * for the NC confirm gates and summary judges.
71132
+ */
71133
+ var QWEN3VL_2B = {
71134
+ meta: {
71135
+ id: "llm-qwen3-vl-2b-instruct-q4",
71136
+ label: "Qwen3-VL 2B Instruct (vision, light)",
71137
+ family: "qwen3-vl",
71138
+ purpose: "vision",
71139
+ url: QWEN3VL2B_URL,
71140
+ sha256: "858fcf2a39dc73b26dd86592cb0a5f949b59d1edb365d1dea98e46b02e955e56",
71141
+ sizeBytes: QWEN3VL2B_MODEL_BYTES,
71142
+ quantization: "Q4_K_M",
71143
+ minRamBytes: 3 * GIB,
71144
+ contextSizeDefault: 8192,
71145
+ mmprojUrl: QWEN3VL2B_MMPROJ_URL
71146
+ },
71147
+ entry: {
71148
+ id: "llm-qwen3-vl-2b-instruct-q4",
71149
+ name: "Qwen3-VL 2B Instruct (vision, light)",
71150
+ description: "qwen3-vl · Q4_K_M · +mmproj",
71151
+ formats: { gguf: {
71152
+ url: QWEN3VL2B_URL,
71153
+ sizeMB: mb(QWEN3VL2B_MODEL_BYTES)
71154
+ } },
71155
+ inputSize: {
71156
+ width: 0,
71157
+ height: 0
71158
+ },
71159
+ labels: [],
71160
+ extraFiles: [{
71161
+ url: QWEN3VL2B_MMPROJ_URL,
71162
+ filename: "mmproj-F16.gguf",
71163
+ sizeMB: mb(QWEN3VL2B_MMPROJ_BYTES)
71164
+ }]
71165
+ }
71166
+ };
71167
+ var QWEN36_MODEL_BYTES = 22134528992;
71168
+ var QWEN36_MMPROJ_BYTES = 899283680;
71169
+ var QWEN36_BASE = "https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF/resolve/main";
71170
+ var QWEN36_MMPROJ_URL = `${QWEN36_BASE}/mmproj-F16.gguf`;
71171
+ var QWEN36_URL = `${QWEN36_BASE}/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf`;
70410
71172
  var LLM_MODEL_CATALOG = [
70411
71173
  QWEN,
70412
71174
  LLAMA,
71175
+ SMOLVLM,
71176
+ QWEN3VL_2B,
70413
71177
  {
70414
71178
  meta: {
70415
- id: "llm-smolvlm2-2.2b-instruct-q4",
70416
- label: "SmolVLM2 2.2B Instruct (vision)",
70417
- family: "smolvlm2",
71179
+ id: "llm-qwen3.6-35b-a3b-ud-q4",
71180
+ label: "Qwen3.6 35B-A3B (vision)",
71181
+ family: "qwen3.6",
70418
71182
  purpose: "vision",
70419
- url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
70420
- sha256: "0cf76814555b8665149075b74ab6b5c1d428ea1d3d01c1918c12012e8d7c9f58",
70421
- sizeBytes: SMOLVLM_MODEL_BYTES,
70422
- quantization: "Q4_K_M",
70423
- minRamBytes: 4 * GIB,
70424
- contextSizeDefault: 4096,
70425
- mmprojUrl: SMOLVLM_MMPROJ_URL
71183
+ url: QWEN36_URL,
71184
+ sha256: "ac0e2c1189e055faa36eff361580e79c5bd6f8e76bffb4ce547f167d53e31a61",
71185
+ sizeBytes: QWEN36_MODEL_BYTES,
71186
+ quantization: "UD-Q4_K_M",
71187
+ minRamBytes: 26 * GIB,
71188
+ contextSizeDefault: 32768,
71189
+ mmprojUrl: QWEN36_MMPROJ_URL
70426
71190
  },
70427
71191
  entry: {
70428
- id: "llm-smolvlm2-2.2b-instruct-q4",
70429
- name: "SmolVLM2 2.2B Instruct (vision)",
70430
- description: "smolvlm2 · Q4_K_M · +mmproj",
71192
+ id: "llm-qwen3.6-35b-a3b-ud-q4",
71193
+ name: "Qwen3.6 35B-A3B (vision)",
71194
+ description: "qwen3.6 · UD-Q4_K_M · +mmproj",
70431
71195
  formats: { gguf: {
70432
- url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
70433
- sizeMB: mb(SMOLVLM_MODEL_BYTES)
71196
+ url: QWEN36_URL,
71197
+ sizeMB: mb(QWEN36_MODEL_BYTES)
70434
71198
  } },
70435
71199
  inputSize: {
70436
71200
  width: 0,
@@ -70438,9 +71202,9 @@ var LLM_MODEL_CATALOG = [
70438
71202
  },
70439
71203
  labels: [],
70440
71204
  extraFiles: [{
70441
- url: SMOLVLM_MMPROJ_URL,
70442
- filename: "mmproj-SmolVLM2-2.2B-Instruct-f16.gguf",
70443
- sizeMB: mb(SMOLVLM_MMPROJ_BYTES)
71205
+ url: QWEN36_MMPROJ_URL,
71206
+ filename: "mmproj-F16.gguf",
71207
+ sizeMB: mb(QWEN36_MMPROJ_BYTES)
70444
71208
  }]
70445
71209
  }
70446
71210
  }
@@ -70457,19 +71221,25 @@ function entryForRef(ref) {
70457
71221
  }
70458
71222
  if (ref.kind === "url") {
70459
71223
  const id = `llm-custom-${(0, node_crypto.createHash)("sha1").update(ref.url).digest("hex").slice(0, 12)}`;
71224
+ const extraFiles = (ref.extraFiles ?? []).map((f) => ({
71225
+ url: f.url,
71226
+ filename: f.filename,
71227
+ sizeMB: mb(f.sizeBytes)
71228
+ }));
70460
71229
  return { entry: {
70461
71230
  id,
70462
- name: id,
71231
+ name: ref.label ?? id,
70463
71232
  description: "custom GGUF",
70464
71233
  formats: { gguf: {
70465
71234
  url: ref.url,
70466
- sizeMB: 0
71235
+ sizeMB: ref.sizeBytes === void 0 ? 0 : mb(ref.sizeBytes)
70467
71236
  } },
70468
71237
  inputSize: {
70469
71238
  width: 0,
70470
71239
  height: 0
70471
71240
  },
70472
- labels: []
71241
+ labels: [],
71242
+ ...extraFiles.length > 0 ? { extraFiles } : {}
70473
71243
  } };
70474
71244
  }
70475
71245
  const id = `llm-path-${(0, node_crypto.createHash)("sha1").update(ref.path).digest("hex").slice(0, 12)}`;
@@ -70507,10 +71277,6 @@ function isNonEmptyFile(filePath) {
70507
71277
  function siblingFilesFor(formatEntry) {
70508
71278
  return formatEntry.isDirectory ? [] : formatEntry.files ?? [];
70509
71279
  }
70510
- /** Resolve a sibling's remote URL relative to the main file's directory. */
70511
- function siblingUrl(mainUrl, sibling) {
70512
- return mainUrl.replace(/[^/]+$/, sibling);
70513
- }
70514
71280
  /** Build fetch headers, including HF auth token for huggingface.co URLs */
70515
71281
  function buildHeaders(url) {
70516
71282
  const headers = { "User-Agent": "CamStack/1.0" };
@@ -70563,77 +71329,6 @@ async function downloadFile(url, destPath, onProgress) {
70563
71329
  throw err;
70564
71330
  }
70565
71331
  }
70566
- /**
70567
- * Download every file in a HuggingFace directory bundle (e.g.,
70568
- * `.mlpackage` / OpenVINO IR pair) atomically. `knownFiles` lists the
70569
- * relative paths inside the directory; the function fetches each from
70570
- * `${url}/${file}` and renames the staging directory only on full
70571
- * success. Mirrors `ModelDownloadService.downloadDirectory` but
70572
- * exposed as a standalone for catalog-less callers.
70573
- */
70574
- async function downloadDirectory(url, destDir, knownFiles, onProgress) {
70575
- const match = url.match(/huggingface\.co\/([^/]+\/[^/]+)\/resolve\/main\/(.+)/);
70576
- if (!match) throw new Error(`Cannot parse HuggingFace URL: ${url}`);
70577
- const [, repo, dirPath] = match;
70578
- const files = (knownFiles ?? []).map((f) => ({
70579
- relativePath: f,
70580
- fileUrl: `https://huggingface.co/${repo}/resolve/main/${dirPath}/${f}`
70581
- }));
70582
- if (files.length === 0) throw new Error(`Directory bundle requires explicit \`files\` list (got none for ${url})`);
70583
- const tmpDir = destDir + ".downloading";
70584
- node_fs.rmSync(tmpDir, {
70585
- recursive: true,
70586
- force: true
70587
- });
70588
- node_fs.mkdirSync(tmpDir, { recursive: true });
70589
- let totalDownloaded = 0;
70590
- try {
70591
- for (const file of files) {
70592
- const destPath = node_path$1.join(tmpDir, file.relativePath);
70593
- node_fs.mkdirSync(node_path$1.dirname(destPath), { recursive: true });
70594
- await downloadFile(file.fileUrl, destPath, (downloaded, _total) => {
70595
- onProgress?.(totalDownloaded + downloaded, void 0);
70596
- });
70597
- totalDownloaded += node_fs.statSync(destPath).size;
70598
- }
70599
- node_fs.rmSync(destDir, {
70600
- recursive: true,
70601
- force: true
70602
- });
70603
- node_fs.renameSync(tmpDir, destDir);
70604
- } catch (err) {
70605
- node_fs.rmSync(tmpDir, {
70606
- recursive: true,
70607
- force: true
70608
- });
70609
- throw err;
70610
- }
70611
- }
70612
- /**
70613
- * Resolve a `ModelCatalogEntry` against `modelsDir`: download model file
70614
- * (or directory bundle) + extra files (labels JSON, charset dict, …),
70615
- * skip if already on disk. Returns the local model path.
70616
- */
70617
- async function ensureModel(modelsDir, entry, format, onProgress) {
70618
- const formatEntry = entry.formats[format];
70619
- if (!formatEntry) throw new Error(`Model "${entry.id}" has no ${format} format. Available: ${Object.keys(entry.formats).join(", ")}`);
70620
- if (entry.extraFiles) for (const extra of entry.extraFiles) await downloadFile(extra.url, node_path$1.join(modelsDir, extra.filename));
70621
- const filename = formatEntry.url.split("/").pop() ?? `${entry.id}.${format}`;
70622
- const modelPath = node_path$1.join(modelsDir, filename);
70623
- const siblings = siblingFilesFor(formatEntry);
70624
- if (node_fs.existsSync(modelPath)) if (formatEntry.isDirectory && !node_fs.existsSync(node_path$1.join(modelPath, "Manifest.json"))) node_fs.rmSync(modelPath, {
70625
- recursive: true,
70626
- force: true
70627
- });
70628
- else if (siblings.some((f) => !isNonEmptyFile(node_path$1.join(modelsDir, f)))) {} else return modelPath;
70629
- node_fs.mkdirSync(modelsDir, { recursive: true });
70630
- if (formatEntry.isDirectory) await downloadDirectory(formatEntry.url, modelPath, formatEntry.files, onProgress);
70631
- else {
70632
- await downloadFile(formatEntry.url, modelPath, (downloaded, total) => onProgress?.(downloaded, total === 0 ? void 0 : total));
70633
- for (const sibling of siblings) await downloadFile(siblingUrl(formatEntry.url, sibling), node_path$1.join(modelsDir, sibling));
70634
- }
70635
- return modelPath;
70636
- }
70637
71332
  /** Compute the on-disk path for a given model + format, even when not yet downloaded. */
70638
71333
  function getModelFilePath(modelsDir, entry, format) {
70639
71334
  const formatEntry = entry.formats[format];
@@ -70677,13 +71372,79 @@ function deleteModelFromDisk(modelsDir, entry, format) {
70677
71372
  * Default `RuntimeModelOps` — the ONLY place the reused object-detection model
70678
71373
  * mechanism is imported (the documented `@camstack/system/addon-utils`
70679
71374
  * build-time-dep waiver that addon-post-analysis/addon-pipeline already use).
70680
- * GGUFs ride `ensureModel`/`isModelDownloaded`/`deleteModelFromDisk` untouched
70681
- * (spec §4.2) — no bespoke fetcher.
71375
+ * GGUFs ride the SHARED `downloadFile` (atomic `.downloading` + rename, HF
71376
+ * token headers, redirect following) — no bespoke fetcher (spec §4.2).
71377
+ *
71378
+ * ## Why this drives the file loop instead of calling `ensureModel`
71379
+ *
71380
+ * `ensureModel` downloads `extraFiles` FIRST and passes them NO progress
71381
+ * callback. That is invisible for a 40 kB labels JSON and unacceptable here: a
71382
+ * GGUF install is a 22 GB main file, up to N shards, and a 0.9 GB mmproj, and
71383
+ * under `ensureModel` every byte outside the main file moves in silence. A
71384
+ * multi-GB download that reports nothing reads as a hung node — the repo rule
71385
+ * is that a branch doing real work says so.
71386
+ *
71387
+ * So the loop is here, over the SAME `downloadFile`. What is gained: bytes
71388
+ * aggregated across the whole install, the name of the file currently moving,
71389
+ * and files already on disk excluded from the total rather than counted as
71390
+ * instantly-complete.
70682
71391
  */
70683
71392
  var GGUF = "gguf";
71393
+ var BYTES_PER_MB = 1024 * 1024;
71394
+ /**
71395
+ * Main file first, then shards/mmproj. Deliberate: a gated or mistyped URL
71396
+ * fails on the file that matters before 0.9 GB of projector is spent on it.
71397
+ */
71398
+ function planFiles(modelsDir, entry) {
71399
+ const out = [];
71400
+ const formatEntry = entry.formats[GGUF];
71401
+ if (formatEntry !== void 0) {
71402
+ const filename = formatEntry.url.split("/").pop() ?? `${entry.id}.${GGUF}`;
71403
+ out.push({
71404
+ url: formatEntry.url,
71405
+ destPath: node_path.join(modelsDir, filename),
71406
+ filename,
71407
+ expectedBytes: formatEntry.sizeMB * BYTES_PER_MB
71408
+ });
71409
+ }
71410
+ for (const extra of entry.extraFiles ?? []) out.push({
71411
+ url: extra.url,
71412
+ destPath: node_path.join(modelsDir, extra.filename),
71413
+ filename: extra.filename,
71414
+ expectedBytes: extra.sizeMB * BYTES_PER_MB
71415
+ });
71416
+ return out;
71417
+ }
70684
71418
  function createDefaultModelOps(modelsDir) {
70685
71419
  return {
70686
- ensure: (entry, onProgress) => ensureModel(modelsDir, entry, GGUF, (downloaded, total) => onProgress(total !== void 0 && total > 0 ? downloaded / total : 0)),
71420
+ ensure: async (entry, onProgress) => {
71421
+ if (entry.formats[GGUF] === void 0) throw new Error(`model ${entry.id} declares no gguf format`);
71422
+ const missing = planFiles(modelsDir, entry).filter((f) => !(0, node_fs.existsSync)(f.destPath));
71423
+ const totalBytes = missing.reduce((sum, f) => sum + f.expectedBytes, 0);
71424
+ let carried = 0;
71425
+ for (const [index, file] of missing.entries()) {
71426
+ onProgress({
71427
+ file: file.filename,
71428
+ fileIndex: index + 1,
71429
+ fileCount: missing.length,
71430
+ downloadedBytes: carried,
71431
+ ...totalBytes > 0 ? { totalBytes } : {}
71432
+ });
71433
+ await downloadFile(file.url, file.destPath, (downloaded) => {
71434
+ onProgress({
71435
+ file: file.filename,
71436
+ fileIndex: index + 1,
71437
+ fileCount: missing.length,
71438
+ downloadedBytes: carried + downloaded,
71439
+ ...totalBytes > 0 ? { totalBytes } : {}
71440
+ });
71441
+ });
71442
+ carried += (0, node_fs.existsSync)(file.destPath) ? (0, node_fs.statSync)(file.destPath).size : file.expectedBytes;
71443
+ }
71444
+ const main = getModelFilePath(modelsDir, entry, GGUF);
71445
+ if (main === null) throw new Error(`no gguf path for model ${entry.id}`);
71446
+ return main;
71447
+ },
70687
71448
  isDownloaded: (entry) => isModelDownloaded(modelsDir, entry, GGUF),
70688
71449
  pathFor: (entry) => {
70689
71450
  const p = getModelFilePath(modelsDir, entry, GGUF);
@@ -70697,318 +71458,6 @@ function createDefaultModelOps(modelsDir) {
70697
71458
  };
70698
71459
  }
70699
71460
  //#endregion
70700
- //#region src/runtime/sha256.ts
70701
- /**
70702
- * File sha256 — a local copy of the private `computeSha256` at
70703
- * model-downloader.ts (not exported from @camstack/system), streamed so it
70704
- * never buffers a multi-GB artifact.
70705
- */
70706
- function fileSha256(filePath) {
70707
- return new Promise((resolve, reject) => {
70708
- const hash = (0, node_crypto.createHash)("sha256");
70709
- const stream = (0, node_fs.createReadStream)(filePath);
70710
- stream.on("error", reject);
70711
- stream.on("data", (chunk) => hash.update(chunk));
70712
- stream.on("end", () => resolve(hash.digest("hex")));
70713
- });
70714
- }
70715
- //#endregion
70716
- //#region src/runtime/runtime-provider.ts
70717
- /**
70718
- * `llm-runtime` provider — the node-side managed executor. Reuses the
70719
- * object-detection model plane (ensureModel/isModelDownloaded/delete via the
70720
- * injected `RuntimeModelOps`) for GGUF artifacts, the `LlamaSupervisor` for the
70721
- * llama-server child, and the SHARED {@link LlmClient} for the local inference
70722
- * wire (only lifecycle + locality differ — spec §2). GGUFs are
70723
- * multi-GB, so a missing model is an EXPLICIT-install error, never an auto-pull.
70724
- * Usage rows are written hub-side only (single accounting point).
70725
- */
70726
- function basename(url) {
70727
- const clean = url.split("?")[0] ?? url;
70728
- return clean.slice(clean.lastIndexOf("/") + 1);
70729
- }
70730
- function catalogIdForFile(file) {
70731
- return LLM_MODEL_CATALOG.find((m) => basename(m.meta.url) === file)?.meta.id;
70732
- }
70733
- function mmprojFilename(entry) {
70734
- return entry.extraFiles?.[0]?.filename;
70735
- }
70736
- function createLlmRuntimeProvider(deps) {
70737
- let downloadProgress;
70738
- async function resolvePaths(runtime) {
70739
- const resolution = entryForRef(runtime.model);
70740
- if (resolution === null) throw new Error("unknown model reference");
70741
- const { entry, localPathOverride } = resolution;
70742
- const modelPath = localPathOverride ?? deps.modelOps.pathFor(entry);
70743
- const mmproj = mmprojFilename(entry);
70744
- return {
70745
- modelId: entry.id,
70746
- modelPath,
70747
- ...mmproj !== void 0 ? { mmprojPath: deps.modelOps.extraFilePath(entry, mmproj) } : {}
70748
- };
70749
- }
70750
- function installedGuard(runtime) {
70751
- const resolution = entryForRef(runtime.model);
70752
- if (resolution === null) return {
70753
- ok: false,
70754
- message: "unknown model reference"
70755
- };
70756
- if (resolution.localPathOverride !== void 0) return { ok: true };
70757
- if (!deps.modelOps.isDownloaded(resolution.entry)) return {
70758
- ok: false,
70759
- message: `model ${resolution.entry.id} not installed on node ${deps.nodeId}`
70760
- };
70761
- return { ok: true };
70762
- }
70763
- async function ensureStartedInternal(runtime) {
70764
- const binaryPath = await deps.ensureBinary();
70765
- const paths = await resolvePaths(runtime);
70766
- const startCfg = {
70767
- nodeId: deps.nodeId,
70768
- modelId: paths.modelId,
70769
- modelPath: paths.modelPath,
70770
- ...paths.mmprojPath !== void 0 ? { mmprojPath: paths.mmprojPath } : {},
70771
- contextSize: runtime.contextSize,
70772
- gpuLayers: runtime.gpuLayers,
70773
- ...runtime.threads !== void 0 ? { threads: runtime.threads } : {},
70774
- parallel: runtime.parallel,
70775
- ...runtime.batchSize !== void 0 ? { batchSize: runtime.batchSize } : {},
70776
- ...runtime.ubatchSize !== void 0 ? { ubatchSize: runtime.ubatchSize } : {},
70777
- flashAttention: runtime.flashAttention,
70778
- mlock: runtime.mlock,
70779
- noMmap: runtime.noMmap,
70780
- ...runtime.cacheTypeK !== void 0 ? { cacheTypeK: runtime.cacheTypeK } : {},
70781
- ...runtime.cacheTypeV !== void 0 ? { cacheTypeV: runtime.cacheTypeV } : {},
70782
- idleStopMinutes: runtime.idleStopMinutes,
70783
- binaryPath
70784
- };
70785
- return deps.supervisor.start(startCfg);
70786
- }
70787
- function status() {
70788
- return {
70789
- ...deps.supervisor.status(),
70790
- nodeId: deps.nodeId,
70791
- ...downloadProgress !== void 0 ? { downloadProgress } : {}
70792
- };
70793
- }
70794
- return {
70795
- complete: async (input) => {
70796
- const guard = installedGuard(input.runtime);
70797
- if (!guard.ok) return {
70798
- ok: false,
70799
- code: "unavailable",
70800
- message: guard.message
70801
- };
70802
- await ensureStartedInternal(input.runtime);
70803
- const port = deps.supervisor.port;
70804
- if (port === void 0) return {
70805
- ok: false,
70806
- code: "unavailable",
70807
- message: "llama-server has no port"
70808
- };
70809
- const paths = await resolvePaths(input.runtime);
70810
- const timeoutMs = input.timeoutMs ?? 12e4;
70811
- const localProfile = {
70812
- id: "managed-local",
70813
- name: "managed-local",
70814
- kind: "openai-compatible",
70815
- addonId: "ai",
70816
- enabled: true,
70817
- model: paths.modelId,
70818
- baseUrl: `http://127.0.0.1:${String(port)}/v1`,
70819
- supportsVision: paths.mmprojPath !== void 0,
70820
- timeoutMs,
70821
- connectTimeoutMs: LlmTimeoutDefaults.connectMs,
70822
- firstTokenTimeoutMs: LlmTimeoutDefaults.firstTokenMs,
70823
- idleTimeoutMs: LlmTimeoutDefaults.idleMs,
70824
- retry: {
70825
- enabled: false,
70826
- maxAttempts: 1
70827
- },
70828
- toolsEnabled: false
70829
- };
70830
- const result = await deps.client.generate({
70831
- profile: localProfile,
70832
- ...input.system !== void 0 ? { system: input.system } : {},
70833
- prompt: input.prompt,
70834
- ...input.images !== void 0 ? { images: input.images } : {},
70835
- ...input.jsonSchema !== void 0 ? { jsonSchema: input.jsonSchema } : {},
70836
- ...input.maxTokens !== void 0 ? { maxTokens: input.maxTokens } : {},
70837
- ...input.temperature !== void 0 ? { temperature: input.temperature } : {},
70838
- ...input.topP !== void 0 ? { topP: input.topP } : {},
70839
- ...input.topK !== void 0 ? { topK: input.topK } : {},
70840
- signal: new AbortController().signal
70841
- }, timeoutMs);
70842
- deps.supervisor.noteActivity();
70843
- return result;
70844
- },
70845
- ensureStarted: async ({ runtime }) => {
70846
- const guard = installedGuard(runtime);
70847
- if (!guard.ok) return {
70848
- nodeId: deps.nodeId,
70849
- state: "stopped",
70850
- lastError: guard.message,
70851
- crashesInWindow: 0
70852
- };
70853
- return ensureStartedInternal(runtime);
70854
- },
70855
- stop: async () => {
70856
- await deps.supervisor.stop();
70857
- },
70858
- status: async () => status(),
70859
- installModel: async ({ model }) => {
70860
- const resolution = entryForRef(model);
70861
- if (resolution === null) throw new Error("unknown model reference");
70862
- if (resolution.localPathOverride !== void 0) return;
70863
- downloadProgress = 0;
70864
- try {
70865
- await deps.modelOps.ensure(resolution.entry, (frac) => {
70866
- downloadProgress = frac;
70867
- });
70868
- if (model.kind === "url" && model.sha256 !== void 0) {
70869
- const filePath = deps.modelOps.pathFor(resolution.entry);
70870
- if (await (deps.fileSha256 ?? fileSha256)(filePath) !== model.sha256) {
70871
- await deps.modelOps.delete(resolution.entry);
70872
- throw new Error(`sha256 mismatch for ${model.url}`);
70873
- }
70874
- }
70875
- } finally {
70876
- downloadProgress = void 0;
70877
- }
70878
- },
70879
- deleteModel: async ({ file }) => {
70880
- const loaded = deps.supervisor.status().modelPath;
70881
- if (loaded !== void 0 && basename(loaded) === file) throw new Error(`cannot delete ${file}: loaded by the running runtime`);
70882
- await node_fs_promises.rm(node_path.join(deps.modelsDir, file), { force: true });
70883
- },
70884
- listLocalModels: async () => {
70885
- return (await listGgufFiles(deps.modelsDir)).map((f) => {
70886
- const catalogId = catalogIdForFile(f.file);
70887
- return {
70888
- file: f.file,
70889
- sizeBytes: f.sizeBytes,
70890
- ...catalogId !== void 0 ? { catalogId } : {}
70891
- };
70892
- });
70893
- },
70894
- getDiskUsage: async () => {
70895
- const modelsBytes = (await listGgufFiles(deps.modelsDir)).reduce((sum, f) => sum + f.sizeBytes, 0);
70896
- return {
70897
- nodeId: deps.nodeId,
70898
- modelsBytes
70899
- };
70900
- }
70901
- };
70902
- }
70903
- async function listGgufFiles(dir) {
70904
- let names;
70905
- try {
70906
- names = await node_fs_promises.readdir(dir);
70907
- } catch {
70908
- return [];
70909
- }
70910
- const out = [];
70911
- for (const name of names) {
70912
- if (!name.endsWith(".gguf")) continue;
70913
- try {
70914
- const stat = await node_fs_promises.stat(node_path.join(dir, name));
70915
- out.push({
70916
- file: name,
70917
- sizeBytes: stat.size
70918
- });
70919
- } catch {}
70920
- }
70921
- return out;
70922
- }
70923
- //#endregion
70924
- //#region src/runtime-client.ts
70925
- /**
70926
- * `RuntimeClient` over the cap plane — every verb pins the target node with
70927
- * `nodePin(nodeId)` (transport-level, never a hand-rolled routing field). The
70928
- * CapRouteResolver classifies the pin into hub-local-uds / remote-moleculer /
70929
- * agent-child-forward transparently (spec §4.1; the model-studio cross-node
70930
- * convert precedent). Node enumeration is the `nodes.topology` roster filtered
70931
- * to nodes advertising the `llm-runtime` cap — never a shadow registry.
70932
- */
70933
- var LLM_RUNTIME_CAP = "llm-runtime";
70934
- function createRuntimeClient(api) {
70935
- return {
70936
- complete: (nodeId, input) => api.llmRuntime.complete.mutate(input, nodePin(nodeId)),
70937
- ensureStarted: (nodeId, runtime) => api.llmRuntime.ensureStarted.mutate({ runtime }, nodePin(nodeId)),
70938
- stopRuntime: (nodeId) => api.llmRuntime.stop.mutate({}, nodePin(nodeId)),
70939
- status: (nodeId) => api.llmRuntime.status.query({}, nodePin(nodeId)),
70940
- installModel: (nodeId, model) => api.llmRuntime.installModel.mutate({ model }, nodePin(nodeId)),
70941
- deleteModel: (nodeId, file) => api.llmRuntime.deleteModel.mutate({ file }, nodePin(nodeId)),
70942
- listLocalModels: (nodeId) => api.llmRuntime.listLocalModels.query({}, nodePin(nodeId)),
70943
- getDiskUsage: (nodeId) => api.llmRuntime.getDiskUsage.query({}, nodePin(nodeId)),
70944
- listRuntimeNodeIds: async () => {
70945
- const topology = await api.nodes.topology.query();
70946
- const ids = /* @__PURE__ */ new Set();
70947
- for (const node of topology) if (node.addons.some((a) => a.capabilities.includes(LLM_RUNTIME_CAP))) ids.add(node.id);
70948
- return [...ids];
70949
- }
70950
- };
70951
- }
70952
- //#endregion
70953
- //#region src/assembly.ts
70954
- /**
70955
- * Registration assembly (the hub/agent split, spec §1). Every node running
70956
- * addon-ai registers `llm-runtime`; ONLY the hub also registers the consumer
70957
- * `llm` surface (profiles/usage need outbound internet + API keys).
70958
- * Extracted from the addon class so the split + seeding is unit-testable
70959
- * without a full AddonContext.
70960
- */
70961
- async function assembleAi(deps) {
70962
- const client = deps.client ?? createLlmClient();
70963
- const runtimeProvider = createLlmRuntimeProvider({
70964
- nodeId: deps.nodeId,
70965
- modelsDir: deps.modelsDir,
70966
- ensureBinary: deps.ensureBinary,
70967
- supervisor: deps.supervisor,
70968
- modelOps: createDefaultModelOps(deps.modelsDir),
70969
- client,
70970
- logger: deps.logger.child("llm-runtime")
70971
- });
70972
- const registrations = [{
70973
- capability: llmRuntimeCapability,
70974
- provider: runtimeProvider
70975
- }];
70976
- if (!deps.isHub) return {
70977
- registrations,
70978
- runtimeProvider
70979
- };
70980
- const { UsageStore } = await Promise.resolve().then(() => require("./usage-store-Q34FDPSq.js")).then((n) => n.usage_store_exports);
70981
- const store = new ProfileStore(deps.settingsPort);
70982
- const defaults = new DefaultsStore(deps.settingsPort);
70983
- const usage = new UsageStore(deps.settingsPort, Date.now, deps.logger.child("llm-usage"));
70984
- await store.init();
70985
- await defaults.init();
70986
- await usage.init();
70987
- await store.ensureSeeded();
70988
- const llmProvider = createLlmProvider({
70989
- store,
70990
- defaults,
70991
- usage,
70992
- client,
70993
- ...deps.runtimeApi !== void 0 ? { runtime: createRuntimeClient(deps.runtimeApi) } : {},
70994
- ...deps.distributeModel !== void 0 ? { distributeModel: deps.distributeModel } : {},
70995
- catalog: LLM_MODEL_CATALOG.map((m) => m.meta),
70996
- logger: deps.logger.child("llm")
70997
- });
70998
- registrations.push({
70999
- capability: llmCapability,
71000
- provider: llmProvider
71001
- });
71002
- return {
71003
- registrations,
71004
- runtimeProvider,
71005
- llmProvider,
71006
- store,
71007
- usage,
71008
- prune: (retentionDays) => usage.prune(retentionDays)
71009
- };
71010
- }
71011
- //#endregion
71012
71461
  //#region src/runtime/crash-policy.ts
71013
71462
  var CrashPolicy = class {
71014
71463
  opts;
@@ -71058,6 +71507,63 @@ var DEFAULT_CRASH_POLICY = {
71058
71507
  * v1: at most one running child. Resource ceiling = llama-server flags +
71059
71508
  * idleStopMinutes ONLY (no RSS watchdog — operator decision #3).
71060
71509
  */
71510
+ /**
71511
+ * Every llama-server flag a TYPED field above already owns, mapped to the
71512
+ * field that owns it.
71513
+ *
71514
+ * This map is the whole reconciliation between the typed tuning surface and
71515
+ * the free-text "additional arguments" box. Both exist because neither is
71516
+ * sufficient — the typed fields give the common knobs a validated control and
71517
+ * a default, and llama.cpp has a hundred flags nobody is going to model — but
71518
+ * a flag settable from BOTH is a bug generator: whichever one loses is a
71519
+ * control the operator watched do nothing. So the box is an escape hatch for
71520
+ * what is NOT modelled, and reaching into it for something that is gets
71521
+ * rejected by name.
71522
+ */
71523
+ var OWNED_FLAGS = {
71524
+ "-m": "model",
71525
+ "--model": "model",
71526
+ "--host": "fixed to 127.0.0.1",
71527
+ "--port": "assigned by the supervisor",
71528
+ "-c": "contextSize",
71529
+ "--ctx-size": "contextSize",
71530
+ "-ngl": "gpuLayers",
71531
+ "--gpu-layers": "gpuLayers",
71532
+ "--n-gpu-layers": "gpuLayers",
71533
+ "-t": "threads",
71534
+ "--threads": "threads",
71535
+ "--parallel": "parallel",
71536
+ "-np": "parallel",
71537
+ "-b": "batchSize",
71538
+ "--batch-size": "batchSize",
71539
+ "-ub": "ubatchSize",
71540
+ "--ubatch-size": "ubatchSize",
71541
+ "-fa": "flashAttention",
71542
+ "--flash-attn": "flashAttention",
71543
+ "--mlock": "mlock",
71544
+ "--no-mmap": "noMmap",
71545
+ "-ctk": "cacheTypeK",
71546
+ "--cache-type-k": "cacheTypeK",
71547
+ "-ctv": "cacheTypeV",
71548
+ "--cache-type-v": "cacheTypeV",
71549
+ "--mmproj": "the vision model’s projector"
71550
+ };
71551
+ /**
71552
+ * Reject an `extraArgs` list that reaches for a flag a typed field owns.
71553
+ * `--flag=value` counts as `--flag`.
71554
+ */
71555
+ function checkExtraArgs(extraArgs) {
71556
+ for (const token of extraArgs) {
71557
+ if (!token.startsWith("-")) continue;
71558
+ const flag = token.split("=")[0] ?? token;
71559
+ const owner = OWNED_FLAGS[flag];
71560
+ if (owner !== void 0) return {
71561
+ ok: false,
71562
+ 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)`
71563
+ };
71564
+ }
71565
+ return { ok: true };
71566
+ }
71061
71567
  var HEALTH_GATE_INTERVAL_MS = 500;
71062
71568
  function defaultPickPort() {
71063
71569
  return new Promise((resolve, reject) => {
@@ -71108,6 +71614,7 @@ function buildLlamaArgs(cfg, port) {
71108
71614
  if (cfg.cacheTypeK !== void 0) args.push("--cache-type-k", cfg.cacheTypeK);
71109
71615
  if (cfg.cacheTypeV !== void 0) args.push("--cache-type-v", cfg.cacheTypeV);
71110
71616
  if (cfg.mmprojPath !== void 0) args.push("--mmproj", cfg.mmprojPath);
71617
+ args.push(...cfg.extraArgs ?? []);
71111
71618
  return args;
71112
71619
  }
71113
71620
  var LlamaSupervisor = class {
@@ -71332,6 +71839,463 @@ var LlamaSupervisor = class {
71332
71839
  }
71333
71840
  };
71334
71841
  //#endregion
71842
+ //#region src/runtime/sha256.ts
71843
+ /**
71844
+ * File sha256 — a local copy of the private `computeSha256` at
71845
+ * model-downloader.ts (not exported from @camstack/system), streamed so it
71846
+ * never buffers a multi-GB artifact.
71847
+ */
71848
+ function fileSha256(filePath) {
71849
+ return new Promise((resolve, reject) => {
71850
+ const hash = (0, node_crypto.createHash)("sha256");
71851
+ const stream = (0, node_fs.createReadStream)(filePath);
71852
+ stream.on("error", reject);
71853
+ stream.on("data", (chunk) => hash.update(chunk));
71854
+ stream.on("end", () => resolve(hash.digest("hex")));
71855
+ });
71856
+ }
71857
+ //#endregion
71858
+ //#region src/runtime/runtime-provider.ts
71859
+ /**
71860
+ * `llm-runtime` provider — the node-side managed executor. Reuses the
71861
+ * object-detection model plane (ensureModel/isModelDownloaded/delete via the
71862
+ * injected `RuntimeModelOps`) for GGUF artifacts, the `LlamaSupervisor` for the
71863
+ * llama-server child, and the SHARED {@link LlmClient} for the local inference
71864
+ * wire (only lifecycle + locality differ — spec §2). GGUFs are
71865
+ * multi-GB, so a missing model is an EXPLICIT-install error, never an auto-pull.
71866
+ * Usage rows are written hub-side only (single accounting point).
71867
+ */
71868
+ function basename(url) {
71869
+ const clean = url.split("?")[0] ?? url;
71870
+ return clean.slice(clean.lastIndexOf("/") + 1);
71871
+ }
71872
+ function catalogIdForFile(file) {
71873
+ return LLM_MODEL_CATALOG.find((m) => basename(m.meta.url) === file)?.meta.id;
71874
+ }
71875
+ /**
71876
+ * The projector among the extra files — matched by NAME, not by position.
71877
+ *
71878
+ * `extraFiles[0]` was safe while the only extra a GGUF entry ever had was an
71879
+ * mmproj. A split GGUF puts shards 2..N in the same list, so index 0 is now
71880
+ * routinely a weights shard, and passing one to `--mmproj` starts llama-server
71881
+ * against a file that is not a projector.
71882
+ */
71883
+ function mmprojFilename(entry) {
71884
+ return entry.extraFiles?.find((f) => f.filename.toLowerCase().startsWith("mmproj"))?.filename;
71885
+ }
71886
+ function gb(bytes) {
71887
+ return `${(bytes / 1e9).toFixed(2)} GB`;
71888
+ }
71889
+ function createLlmRuntimeProvider(deps) {
71890
+ let downloadProgress;
71891
+ let download;
71892
+ async function resolvePaths(runtime) {
71893
+ const resolution = entryForRef(runtime.model);
71894
+ if (resolution === null) throw new Error("unknown model reference");
71895
+ const { entry, localPathOverride } = resolution;
71896
+ const modelPath = localPathOverride ?? deps.modelOps.pathFor(entry);
71897
+ const mmproj = mmprojFilename(entry);
71898
+ return {
71899
+ modelId: entry.id,
71900
+ modelPath,
71901
+ ...mmproj !== void 0 ? { mmprojPath: deps.modelOps.extraFilePath(entry, mmproj) } : {}
71902
+ };
71903
+ }
71904
+ function installedGuard(runtime) {
71905
+ const resolution = entryForRef(runtime.model);
71906
+ if (resolution === null) return {
71907
+ ok: false,
71908
+ message: "unknown model reference"
71909
+ };
71910
+ if (resolution.localPathOverride !== void 0) return { ok: true };
71911
+ if (!deps.modelOps.isDownloaded(resolution.entry)) return {
71912
+ ok: false,
71913
+ message: `model ${resolution.entry.id} not installed on node ${deps.nodeId}`
71914
+ };
71915
+ return { ok: true };
71916
+ }
71917
+ async function ensureStartedInternal(runtime) {
71918
+ const argCheck = checkExtraArgs(runtime.extraArgs);
71919
+ if (!argCheck.ok) throw new Error(argCheck.message);
71920
+ const binaryPath = await deps.ensureBinary();
71921
+ const paths = await resolvePaths(runtime);
71922
+ const startCfg = {
71923
+ nodeId: deps.nodeId,
71924
+ modelId: paths.modelId,
71925
+ modelPath: paths.modelPath,
71926
+ ...paths.mmprojPath !== void 0 ? { mmprojPath: paths.mmprojPath } : {},
71927
+ contextSize: runtime.contextSize,
71928
+ gpuLayers: runtime.gpuLayers,
71929
+ ...runtime.threads !== void 0 ? { threads: runtime.threads } : {},
71930
+ parallel: runtime.parallel,
71931
+ ...runtime.batchSize !== void 0 ? { batchSize: runtime.batchSize } : {},
71932
+ ...runtime.ubatchSize !== void 0 ? { ubatchSize: runtime.ubatchSize } : {},
71933
+ flashAttention: runtime.flashAttention,
71934
+ mlock: runtime.mlock,
71935
+ noMmap: runtime.noMmap,
71936
+ ...runtime.cacheTypeK !== void 0 ? { cacheTypeK: runtime.cacheTypeK } : {},
71937
+ ...runtime.cacheTypeV !== void 0 ? { cacheTypeV: runtime.cacheTypeV } : {},
71938
+ extraArgs: runtime.extraArgs,
71939
+ idleStopMinutes: runtime.idleStopMinutes,
71940
+ binaryPath
71941
+ };
71942
+ return deps.supervisor.start(startCfg);
71943
+ }
71944
+ /**
71945
+ * sha256 every artifact whose digest the reference pinned — the main file
71946
+ * AND the extras.
71947
+ *
71948
+ * Verifying only the main file was the gap: a truncated or swapped mmproj is
71949
+ * exactly as fatal to llama-server as a bad weights file, and a resolved HF
71950
+ * reference carries a digest for every artifact (LFS `oid`) so there is no
71951
+ * reason to check one and trust the rest.
71952
+ *
71953
+ * This pass reads tens of GB and takes minutes; it is a REPORTED phase, not
71954
+ * a silent tail, because a progress bar frozen at 100% is the shape of a
71955
+ * hang.
71956
+ */
71957
+ async function verifyDigests(entry, model, startedAt) {
71958
+ if (model.kind !== "url") return;
71959
+ const targets = [];
71960
+ if (model.sha256 !== void 0) targets.push({
71961
+ filePath: deps.modelOps.pathFor(entry),
71962
+ sha256: model.sha256,
71963
+ name: basename(model.url)
71964
+ });
71965
+ for (const extra of model.extraFiles ?? []) {
71966
+ if (extra.sha256 === void 0) continue;
71967
+ targets.push({
71968
+ filePath: deps.modelOps.extraFilePath(entry, extra.filename),
71969
+ sha256: extra.sha256,
71970
+ name: extra.filename
71971
+ });
71972
+ }
71973
+ if (targets.length === 0) return;
71974
+ const sha256 = deps.fileSha256 ?? fileSha256;
71975
+ for (const [index, target] of targets.entries()) {
71976
+ download = {
71977
+ phase: "verifying",
71978
+ file: target.name,
71979
+ fileIndex: index + 1,
71980
+ fileCount: targets.length,
71981
+ downloadedBytes: 0
71982
+ };
71983
+ deps.logger.info("llm model verifying digest", { meta: {
71984
+ nodeId: deps.nodeId,
71985
+ modelId: entry.id,
71986
+ file: target.name
71987
+ } });
71988
+ const digest = await sha256(target.filePath);
71989
+ if (digest !== target.sha256) {
71990
+ deps.logger.error("llm model digest mismatch; discarding the download", { meta: {
71991
+ nodeId: deps.nodeId,
71992
+ modelId: entry.id,
71993
+ file: target.name,
71994
+ expected: target.sha256,
71995
+ actual: digest,
71996
+ elapsedMs: Date.now() - startedAt
71997
+ } });
71998
+ await deps.modelOps.delete(entry);
71999
+ await node_fs_promises.rm(target.filePath, { force: true });
72000
+ throw new Error(`sha256 mismatch for ${target.name}: expected ${target.sha256}, got ${digest}`);
72001
+ }
72002
+ }
72003
+ }
72004
+ function status() {
72005
+ return {
72006
+ ...deps.supervisor.status(),
72007
+ nodeId: deps.nodeId,
72008
+ ...downloadProgress !== void 0 ? { downloadProgress } : {},
72009
+ ...download !== void 0 ? { download } : {}
72010
+ };
72011
+ }
72012
+ return {
72013
+ complete: async (input) => {
72014
+ const guard = installedGuard(input.runtime);
72015
+ if (!guard.ok) return {
72016
+ ok: false,
72017
+ code: "unavailable",
72018
+ message: guard.message
72019
+ };
72020
+ await ensureStartedInternal(input.runtime);
72021
+ const port = deps.supervisor.port;
72022
+ if (port === void 0) return {
72023
+ ok: false,
72024
+ code: "unavailable",
72025
+ message: "llama-server has no port"
72026
+ };
72027
+ const paths = await resolvePaths(input.runtime);
72028
+ const timeoutMs = input.timeoutMs ?? 12e4;
72029
+ const localProfile = {
72030
+ id: "managed-local",
72031
+ name: "managed-local",
72032
+ kind: "openai-compatible",
72033
+ addonId: "ai",
72034
+ enabled: true,
72035
+ model: paths.modelId,
72036
+ baseUrl: `http://127.0.0.1:${String(port)}/v1`,
72037
+ supportsVision: paths.mmprojPath !== void 0,
72038
+ timeoutMs,
72039
+ connectTimeoutMs: LlmTimeoutDefaults.connectMs,
72040
+ firstTokenTimeoutMs: LlmTimeoutDefaults.firstTokenMs,
72041
+ idleTimeoutMs: LlmTimeoutDefaults.idleMs,
72042
+ retry: {
72043
+ enabled: false,
72044
+ maxAttempts: 1
72045
+ },
72046
+ toolsEnabled: false
72047
+ };
72048
+ const result = await deps.client.generate({
72049
+ profile: localProfile,
72050
+ ...input.system !== void 0 ? { system: input.system } : {},
72051
+ prompt: input.prompt,
72052
+ ...input.images !== void 0 ? { images: input.images } : {},
72053
+ ...input.jsonSchema !== void 0 ? { jsonSchema: input.jsonSchema } : {},
72054
+ ...input.maxTokens !== void 0 ? { maxTokens: input.maxTokens } : {},
72055
+ ...input.temperature !== void 0 ? { temperature: input.temperature } : {},
72056
+ ...input.topP !== void 0 ? { topP: input.topP } : {},
72057
+ ...input.topK !== void 0 ? { topK: input.topK } : {},
72058
+ signal: new AbortController().signal
72059
+ }, timeoutMs);
72060
+ deps.supervisor.noteActivity();
72061
+ return result;
72062
+ },
72063
+ ensureStarted: async ({ runtime }) => {
72064
+ const guard = installedGuard(runtime);
72065
+ if (!guard.ok) return {
72066
+ nodeId: deps.nodeId,
72067
+ state: "stopped",
72068
+ lastError: guard.message,
72069
+ crashesInWindow: 0
72070
+ };
72071
+ return ensureStartedInternal(runtime);
72072
+ },
72073
+ stop: async () => {
72074
+ await deps.supervisor.stop();
72075
+ },
72076
+ status: async () => status(),
72077
+ /**
72078
+ * Install a model on THIS node.
72079
+ *
72080
+ * Loud on purpose. This is the longest-running operation the addon has —
72081
+ * tens of minutes for a 23 GB vision model — and until now it emitted not
72082
+ * one log line, so an install that stalled on a gated URL or a full disk
72083
+ * was indistinguishable from one that was simply slow. Every phase
72084
+ * transition is a line, and every line carries the node.
72085
+ */
72086
+ installModel: async ({ model }) => {
72087
+ const resolution = entryForRef(model);
72088
+ if (resolution === null) throw new Error("unknown model reference");
72089
+ if (resolution.localPathOverride !== void 0) {
72090
+ deps.logger.info("llm model is pre-provisioned; nothing to download", { meta: {
72091
+ nodeId: deps.nodeId,
72092
+ path: resolution.localPathOverride
72093
+ } });
72094
+ return;
72095
+ }
72096
+ const { entry } = resolution;
72097
+ const declaredBytes = model.kind === "url" ? model.sizeBytes : void 0;
72098
+ const startedAt = Date.now();
72099
+ deps.logger.info("llm model install started", { meta: {
72100
+ nodeId: deps.nodeId,
72101
+ modelId: entry.id,
72102
+ url: entry.formats.gguf?.url,
72103
+ extraFiles: (entry.extraFiles ?? []).map((f) => f.filename),
72104
+ ...declaredBytes !== void 0 ? {
72105
+ declaredBytes,
72106
+ declaredSize: gb(declaredBytes)
72107
+ } : {}
72108
+ } });
72109
+ downloadProgress = 0;
72110
+ download = {
72111
+ phase: "downloading",
72112
+ file: "",
72113
+ fileIndex: 0,
72114
+ fileCount: 0,
72115
+ downloadedBytes: 0
72116
+ };
72117
+ let lastLoggedDecile = -1;
72118
+ try {
72119
+ await deps.modelOps.ensure(entry, (progress) => {
72120
+ const fraction = progress.totalBytes !== void 0 && progress.totalBytes > 0 ? Math.min(1, progress.downloadedBytes / progress.totalBytes) : void 0;
72121
+ downloadProgress = fraction;
72122
+ download = {
72123
+ phase: "downloading",
72124
+ file: progress.file,
72125
+ fileIndex: progress.fileIndex,
72126
+ fileCount: progress.fileCount,
72127
+ downloadedBytes: progress.downloadedBytes,
72128
+ ...progress.totalBytes !== void 0 ? { totalBytes: progress.totalBytes } : {}
72129
+ };
72130
+ const decile = fraction === void 0 ? -1 : Math.floor(fraction * 10);
72131
+ if (decile > lastLoggedDecile) {
72132
+ lastLoggedDecile = decile;
72133
+ deps.logger.info("llm model download progress", { meta: {
72134
+ nodeId: deps.nodeId,
72135
+ modelId: entry.id,
72136
+ file: progress.file,
72137
+ fileIndex: progress.fileIndex,
72138
+ fileCount: progress.fileCount,
72139
+ downloadedBytes: progress.downloadedBytes,
72140
+ downloaded: gb(progress.downloadedBytes),
72141
+ ...progress.totalBytes !== void 0 ? { total: gb(progress.totalBytes) } : {}
72142
+ } });
72143
+ }
72144
+ });
72145
+ await verifyDigests(entry, model, startedAt);
72146
+ deps.logger.info("llm model install complete", { meta: {
72147
+ nodeId: deps.nodeId,
72148
+ modelId: entry.id,
72149
+ elapsedMs: Date.now() - startedAt
72150
+ } });
72151
+ } catch (err) {
72152
+ deps.logger.error("llm model install failed", { meta: {
72153
+ nodeId: deps.nodeId,
72154
+ modelId: entry.id,
72155
+ elapsedMs: Date.now() - startedAt,
72156
+ error: err instanceof Error ? err.message : String(err)
72157
+ } });
72158
+ throw err;
72159
+ } finally {
72160
+ downloadProgress = void 0;
72161
+ download = void 0;
72162
+ }
72163
+ },
72164
+ deleteModel: async ({ file }) => {
72165
+ const loaded = deps.supervisor.status().modelPath;
72166
+ if (loaded !== void 0 && basename(loaded) === file) throw new Error(`cannot delete ${file}: loaded by the running runtime`);
72167
+ await node_fs_promises.rm(node_path.join(deps.modelsDir, file), { force: true });
72168
+ },
72169
+ listLocalModels: async () => {
72170
+ return (await listGgufFiles(deps.modelsDir)).map((f) => {
72171
+ const catalogId = catalogIdForFile(f.file);
72172
+ return {
72173
+ file: f.file,
72174
+ sizeBytes: f.sizeBytes,
72175
+ path: node_path.join(deps.modelsDir, f.file),
72176
+ ...catalogId !== void 0 ? { catalogId } : {}
72177
+ };
72178
+ });
72179
+ },
72180
+ getDiskUsage: async () => {
72181
+ const modelsBytes = (await listGgufFiles(deps.modelsDir)).reduce((sum, f) => sum + f.sizeBytes, 0);
72182
+ return {
72183
+ nodeId: deps.nodeId,
72184
+ modelsBytes
72185
+ };
72186
+ }
72187
+ };
72188
+ }
72189
+ async function listGgufFiles(dir) {
72190
+ let names;
72191
+ try {
72192
+ names = await node_fs_promises.readdir(dir);
72193
+ } catch {
72194
+ return [];
72195
+ }
72196
+ const out = [];
72197
+ for (const name of names) {
72198
+ if (!name.endsWith(".gguf")) continue;
72199
+ try {
72200
+ const stat = await node_fs_promises.stat(node_path.join(dir, name));
72201
+ out.push({
72202
+ file: name,
72203
+ sizeBytes: stat.size
72204
+ });
72205
+ } catch {}
72206
+ }
72207
+ return out;
72208
+ }
72209
+ //#endregion
72210
+ //#region src/runtime-client.ts
72211
+ /**
72212
+ * `RuntimeClient` over the cap plane — every verb pins the target node with
72213
+ * `nodePin(nodeId)` (transport-level, never a hand-rolled routing field). The
72214
+ * CapRouteResolver classifies the pin into hub-local-uds / remote-moleculer /
72215
+ * agent-child-forward transparently (spec §4.1; the model-studio cross-node
72216
+ * convert precedent). Node enumeration is the `nodes.topology` roster filtered
72217
+ * to nodes advertising the `llm-runtime` cap — never a shadow registry.
72218
+ */
72219
+ var LLM_RUNTIME_CAP = "llm-runtime";
72220
+ function createRuntimeClient(api) {
72221
+ return {
72222
+ complete: (nodeId, input) => api.llmRuntime.complete.mutate(input, nodePin(nodeId)),
72223
+ ensureStarted: (nodeId, runtime) => api.llmRuntime.ensureStarted.mutate({ runtime }, nodePin(nodeId)),
72224
+ stopRuntime: (nodeId) => api.llmRuntime.stop.mutate({}, nodePin(nodeId)),
72225
+ status: (nodeId) => api.llmRuntime.status.query({}, nodePin(nodeId)),
72226
+ installModel: (nodeId, model) => api.llmRuntime.installModel.mutate({ model }, nodePin(nodeId)),
72227
+ deleteModel: (nodeId, file) => api.llmRuntime.deleteModel.mutate({ file }, nodePin(nodeId)),
72228
+ listLocalModels: (nodeId) => api.llmRuntime.listLocalModels.query({}, nodePin(nodeId)),
72229
+ getDiskUsage: (nodeId) => api.llmRuntime.getDiskUsage.query({}, nodePin(nodeId)),
72230
+ listRuntimeNodeIds: async () => {
72231
+ const topology = await api.nodes.topology.query();
72232
+ const ids = /* @__PURE__ */ new Set();
72233
+ for (const node of topology) if (node.addons.some((a) => a.capabilities.includes(LLM_RUNTIME_CAP))) ids.add(node.id);
72234
+ return [...ids];
72235
+ }
72236
+ };
72237
+ }
72238
+ //#endregion
72239
+ //#region src/assembly.ts
72240
+ /**
72241
+ * Registration assembly (the hub/agent split, spec §1). Every node running
72242
+ * addon-ai registers `llm-runtime`; ONLY the hub also registers the consumer
72243
+ * `llm` surface (profiles/usage need outbound internet + API keys).
72244
+ * Extracted from the addon class so the split + seeding is unit-testable
72245
+ * without a full AddonContext.
72246
+ */
72247
+ async function assembleAi(deps) {
72248
+ const client = deps.client ?? createLlmClient();
72249
+ const runtimeProvider = createLlmRuntimeProvider({
72250
+ nodeId: deps.nodeId,
72251
+ modelsDir: deps.modelsDir,
72252
+ ensureBinary: deps.ensureBinary,
72253
+ supervisor: deps.supervisor,
72254
+ modelOps: createDefaultModelOps(deps.modelsDir),
72255
+ client,
72256
+ logger: deps.logger.child("llm-runtime")
72257
+ });
72258
+ const registrations = [{
72259
+ capability: llmRuntimeCapability,
72260
+ provider: runtimeProvider
72261
+ }];
72262
+ if (!deps.isHub) return {
72263
+ registrations,
72264
+ runtimeProvider
72265
+ };
72266
+ const { UsageStore } = await Promise.resolve().then(() => require("./usage-store-Q34FDPSq.js")).then((n) => n.usage_store_exports);
72267
+ const store = new ProfileStore(deps.settingsPort);
72268
+ const defaults = new DefaultsStore(deps.settingsPort);
72269
+ const usage = new UsageStore(deps.settingsPort, Date.now, deps.logger.child("llm-usage"));
72270
+ await store.init();
72271
+ await defaults.init();
72272
+ await usage.init();
72273
+ await store.ensureSeeded();
72274
+ const llmProvider = createLlmProvider({
72275
+ store,
72276
+ defaults,
72277
+ usage,
72278
+ client,
72279
+ ...deps.runtimeApi !== void 0 ? { runtime: createRuntimeClient(deps.runtimeApi) } : {},
72280
+ ...deps.distributeModel !== void 0 ? { distributeModel: deps.distributeModel } : {},
72281
+ catalog: LLM_MODEL_CATALOG.map((m) => m.meta),
72282
+ hfToken: () => process.env["HF_TOKEN"] ?? process.env["HUGGING_FACE_HUB_TOKEN"],
72283
+ logger: deps.logger.child("llm")
72284
+ });
72285
+ registrations.push({
72286
+ capability: llmCapability,
72287
+ provider: llmProvider
72288
+ });
72289
+ return {
72290
+ registrations,
72291
+ runtimeProvider,
72292
+ llmProvider,
72293
+ store,
72294
+ usage,
72295
+ prune: (retentionDays) => usage.prune(retentionDays)
72296
+ };
72297
+ }
72298
+ //#endregion
71335
72299
  //#region src/settings-store-port.ts
71336
72300
  function createApiSettingsStorePort(api) {
71337
72301
  return {