@qvac/llm-llamacpp 0.66.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,7 @@ This native C++ addon, built using the `Bare` Runtime, simplifies running Large
15
15
  - [5. Load Model](#5-load-model)
16
16
  - [6. Run Inference](#6-run-inference)
17
17
  - [Tool calling](#tool-calling)
18
+ - [Ordered text and media](#ordered-text-and-media)
18
19
  - [Batch inference](#batch-inference)
19
20
  - [7. Release Resources](#7-release-resources)
20
21
  - [API behavior by state](#api-behavior-by-state)
@@ -206,7 +207,8 @@ const config = {
206
207
  | cache_checkpoints_max_bytes | integer (bytes) | 0 (unlimited) | Byte budget for those checkpoints, enforced before the count. The load fails with `InvalidArgument` when it cannot hold `cache_checkpoints` checkpoints of the largest size the context allows (on a sliding-window model, a full window). Also accepted as `cache-checkpoints-max-bytes` |
207
208
  | cache_checkpoint_storage | `"memory"` or `"disk"` | `"memory"` | Where checkpoints and the per-request rollback snapshot live. `"memory"` keeps them in host RAM so a cached chat never touches the disk; `"disk"` writes them to a private directory inside `cache_checkpoint_dir`, which it requires. Also accepted as `cache-checkpoint-storage` |
208
209
  | cache_checkpoint_dir | directory path | none | **Required with `cache_checkpoint_storage: "disk"`**, and refused without it: where checkpoint files go, inside a private directory the addon creates in it (mode 0700; on Windows, the directory itself). On mobile, pass the app's cache directory. The load fails with `InvalidArgument` when it is missing, empty, or unusable. Also accepted as `cache-checkpoint-dir` |
209
- | cache_ram_mib | 0 – 1048576 (MiB) | 0 (off) | Host-RAM budget for conversations that are not running. A `cacheKey` switch or a batch slot eviction moves the conversation there instead of writing its file, and the next request on that key restores it. A write-back cache: unsaved turns reach the file on `saveCache()`, when the budget pushes the entry out, or at unload; ephemeral entries are dropped instead. Also accepted as `cache-ram-mib` ([details](./docs/cache-api.md#keep-switched-away-conversations-in-ram)) |
210
+ | cache_ram_mib | 0 – 1048576 (MiB) | 0 (off) | Host-RAM budget for conversations that are not running. A `cacheKey` switch or a batch slot eviction moves the conversation there instead of writing its file, and the next request on that key restores it; unless `cache_share_across_keys` is `"false"`, a request on another key that shares its prompt prefix starts from a copy of it. A write-back cache: unsaved turns reach the file on `saveCache()`, when the budget pushes the entry out, or at unload; ephemeral entries are dropped instead. Also accepted as `cache-ram-mib` ([details](./docs/cache-api.md#keep-switched-away-conversations-in-ram)) |
211
+ | cache_share_across_keys | `"true"`/`"on"`/`"1"`, `"false"`/`"off"`/`"0"` | `"true"` | Whether a request on a decoder-only model may start from state another conversation left (keyless memory, another key's state, a `cacheKey` file the model used). `"false"` keeps every request to its own key's state, for callers who must not learn through `cacheTokens` or timing whether another conversation shares their prompt. Also accepted as `cache-share-across-keys` ([details](./docs/cache-api.md#keep-conversations-apart)) |
210
212
  | flash-attn | `"on"`/`"enabled"`/`"true"`/`"1"`, `"off"`/`"disabled"`/`"false"`/`"0"`, or `"auto"` | `"on"`, except when finetuning or on a BitNet model, where it is forced off | Flash attention. The four truthy and four falsey spellings are equivalent; `"auto"` is a third state that defers to qvac-fabric's runtime capability probe. Lower-case only — matching is case-sensitive and any other value is rejected. Affects the KV-cache auto-default — see below. Also accepted as `flash_attn`; supplying both spellings is an error |
211
213
  | cache-type-k | `f16`, `f32`, `bf16`, `q8_0`, `q4_0`, … | auto (see below) | KV-cache **key** quantization type. Unset = auto-default (see KV-cache type below) |
212
214
  | cache-type-v | `f16`, `f32`, `bf16`, `q8_0`, `q4_0`, … | auto (see below) | KV-cache **value** quantization type. Quantizing V requires `flash-attn` on |
@@ -345,6 +347,28 @@ const response = await model.run([
345
347
 
346
348
  The tool grammar holds the model to the order of `parameters`. Inside an object it accepts the required properties first, in declared order, then the optional ones, also in declared order. When the model writes a property out of that order, the call isn't rejected with an error: the model drops a field, writes a broken value or never finishes the call. Declare `required` properties before optional ones at every level, and declare each group in the order the model is likely to write it. This applies to nested objects, and to every argument in formats that write the arguments as one JSON object (Qwen3). Qwen3.5's top-level `<parameter=...>` arguments are less strict: up to six required arguments in any order, then the optional ones in any order. An optional argument before a required one is still rejected there.
347
349
 
350
+ #### Ordered text and media
351
+
352
+ A user message's `content` is either a string or an ordered array of parts. Use the array when the position of an image inside the text matters — multiple-choice options that are pictures, illustrated forms, steps — so the model sees the text and every image in one turn, in order:
353
+
354
+ ```javascript
355
+ const response = await model.run([{
356
+ role: 'user',
357
+ content: [
358
+ { type: 'text', text: 'Which image shows the same place as ' },
359
+ { type: 'media', content: '/images/place.jpg' },
360
+ { type: 'text', text: '?\nA. ' },
361
+ { type: 'media', content: imageABytes }, // raw bytes work too
362
+ { type: 'text', text: '\nB. ' },
363
+ { type: 'media', content: '/images/option-b.jpg' }
364
+ ]
365
+ }])
366
+ ```
367
+
368
+ Each media part becomes an `<__media__>` marker at its own position in the rendered turn — the same prompt llama-server builds from an OpenAI content-parts request. A part's `content` is an absolute path or a `Uint8Array`; media parts are only valid on user messages, and an unknown part type is rejected with `InvalidInputFormat`.
369
+
370
+ The alternative — one `{ role: 'user', type: 'media' }` message per part — keeps the order but renders each text part as its own user turn, so text that refers to "this image" ends up in a different turn from the image.
371
+
348
372
  #### Batch inference
349
373
 
350
374
  Load the model with `parallel >= 2`, then pass an array of prompts. Each chunk arrives tagged with the id of the sequence that produced it; `await()` returns results in input order.
@@ -443,7 +467,7 @@ Admission is controlled by `rejectWhenBusy` (instance-level `opts.rejectWhenBusy
443
467
 
444
468
  #### Prefill (cache warming) with `parallel >= 2`
445
469
 
446
- A prefill-only run (`runOptions.prefill: true`) is admitted on a parallel model only when its product survives the slot teardown, i.e. it has a `cacheKey`: the warmed conversation stays in its slot for the next request on that key. A keyless prefill warms context state that no concurrent job could ever reach, so it is rejected with `InvalidArgument`; run it on a `parallel: 1` model instead. The same rule applies per item in batch runs. See [cache-api.md](./docs/cache-api.md).
470
+ A prefill-only run (`runOptions.prefill: true`) is admitted on a parallel model only when its product survives the slot teardown, i.e. it has a `cacheKey`: the warmed conversation stays in its slot for the next request on that key. A keyless prefill warms state no later request can count on (at most parked anonymously, first to be evicted), so it is rejected with `InvalidArgument`; run it on a `parallel: 1` model instead. The same rule applies per item in batch runs. See [cache-api.md](./docs/cache-api.md).
447
471
 
448
472
  #### Cancelling a batch
449
473
 
@@ -735,7 +759,7 @@ npm run test:cpp:models:ci # exactly what .github/workflows/cpp-tests-llm.yml
735
759
  # downloads; matches what CI exercises
736
760
  ```
737
761
 
738
- First-run downloads pull several GB from Hugging Face. Every fixture is SHA256-verified against a digest pinned in `scripts/download-unit-test-models.js`; mismatched or partial files are re-downloaded automatically. Set `HF_TOKEN` if a repo requires authentication. Override paths with env vars such as `SHARDED_MODEL_FIRST_SHARD_PATH` (see `test/unit/test_common.hpp`).
762
+ First-run downloads pull several GB, mostly from Hugging Face. Every fixture is SHA256-verified against a digest pinned in `scripts/download-unit-test-models.js`; mismatched or partial files are re-downloaded automatically. Set `HF_TOKEN` if a repo requires authentication. Fixtures pinned by `s3Path` (currently `bitnet_b1_58-large-TQ2_0.gguf`) are copied from the QVAC model registry bucket and need `MODEL_S3_BUCKET` plus AWS credentials; if either is missing or the copy fails, a local run skips the fixture with a warning and its tests skip, while `--ci` fails. Override paths with env vars such as `SHARDED_MODEL_FIRST_SHARD_PATH` or `BITNET_MODEL_PATH` (see `test/unit/test_common.hpp`).
739
763
 
740
764
  ### Fuzzing
741
765
 
package/addon.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface AddonMessage {
14
14
  generationParams?: GenerationParams;
15
15
  cacheKey?: string;
16
16
  ephemeral?: boolean;
17
+ baseCacheKey?: string;
17
18
  }
18
19
  export interface AddonMediaMessage {
19
20
  type: "media";
package/index.d.ts CHANGED
@@ -380,6 +380,19 @@ declare namespace LlmLlamacpp {
380
380
  * maximum is 1048576 (1 TiB). Also accepted as `cache-ram-mib`.
381
381
  */
382
382
  cache_ram_mib?: NumericLike;
383
+ /**
384
+ * Whether a request on a decoder-only model may start from state another
385
+ * conversation left: the memory a request without a `cacheKey` keeps,
386
+ * another key's state in a sequence or the RAM tier, or a `cacheKey` file
387
+ * the model has used. `'true'` (default) shares it, as llama-server's
388
+ * prompt cache does. `'false'` keeps every request to its own key's state,
389
+ * for a model serving callers who must not learn through `cacheTokens` or
390
+ * timing whether another conversation shares their prompt: a request
391
+ * without a key starts cold and is reset afterwards, a new key starts cold,
392
+ * and `baseCacheKey` has no effect. Also accepted as
393
+ * `cache-share-across-keys`.
394
+ */
395
+ cache_share_across_keys?: "true" | "false" | "on" | "off" | "1" | "0";
383
396
  /**
384
397
  * Speculative decoding, as llama-server's `--spec-type`. `'draft-mtp'`
385
398
  * drafts with the model's own multi-token-prediction (MTP / NextN) head,
@@ -437,9 +450,30 @@ declare namespace LlmLlamacpp {
437
450
  rejectWhenBusy?: boolean;
438
451
  };
439
452
  }
453
+ /**
454
+ * One ordered piece of a message's `content`. Media parts keep their place in
455
+ * the turn: a single user message can interleave text and media, which a
456
+ * string `content` plus separate `type: "media"` messages cannot express
457
+ * without splitting the turn.
458
+ */
459
+ type UserContentPart = {
460
+ type: "text";
461
+ text: string;
462
+ } | {
463
+ type: "media";
464
+ /**
465
+ * Either an absolute path to the media file (`string`) or its raw bytes
466
+ * (`Uint8Array`), the same two forms a `type: "media"` message accepts.
467
+ */
468
+ content: string | Uint8Array;
469
+ };
440
470
  interface UserTextMessage {
441
471
  role: "system" | "assistant" | "user" | "tool" | "session" | string;
442
- content: string;
472
+ /**
473
+ * Plain text, or ordered text/media parts rendered as one turn. Media parts
474
+ * are only valid on user messages.
475
+ */
476
+ content: string | UserContentPart[];
443
477
  /**
444
478
  * An assistant turn's reasoning, separate from its answer in `content`, as
445
479
  * in OpenAI-compatible APIs. Optional: an answer sent back with its
@@ -554,10 +588,9 @@ declare namespace LlmLlamacpp {
554
588
  * Run prefill only (cache warming): the prompt is evaluated but no tokens
555
589
  * are generated. On a model loaded with `parallel >= 2` a prefill needs a
556
590
  * `cacheKey` (the warmed conversation stays in its slot for the next
557
- * request on that key) — a keyless prefill warms context state that no
558
- * concurrent job could reach and is rejected with `InvalidArgument`; run
559
- * keyless prefills on a `parallel: 1` model. The same rule applies per
560
- * batch item.
591
+ * request on that key) — a keyless prefill warms state no later request
592
+ * can count on and is rejected with `InvalidArgument`; run keyless
593
+ * prefills on a `parallel: 1` model. The same rule applies per batch item.
561
594
  */
562
595
  prefill?: boolean;
563
596
  generationParams?: GenerationParams;
@@ -574,11 +607,26 @@ declare namespace LlmLlamacpp {
574
607
  * otherwise be lost with turns the file lacks: when it is set aside for
575
608
  * another key, evicted from its batch slot or from the RAM tier, or the
576
609
  * model is reloaded or unloaded. With `ephemeral: true` each of those
577
- * drops it instead, so its next request starts cold. It still loads an
578
- * existing file, and an explicit `saveCache(cacheKey)` still writes it.
610
+ * drops it instead, so its next request finds no state of its own (it
611
+ * still reuses any prefix it shares with what the model holds, except on
612
+ * encoder-decoder models). It still loads an existing file, and an
613
+ * explicit `saveCache(cacheKey)` still writes it.
579
614
  * The flag belongs to the conversation's latest request.
580
615
  */
581
616
  ephemeral?: boolean;
617
+ /**
618
+ * A `cacheKey` file to start from when nothing in memory serves more of
619
+ * the prompt, such as a system prompt and tools primed with `prefill`.
620
+ * It is read, never written: the request's own state goes to its
621
+ * `cacheKey`. A file that is missing or shares no prefix with the prompt
622
+ * is skipped. Once named, the file stays a source for later requests on
623
+ * this model, as any `cacheKey` file it used does, until files named
624
+ * or picked as a prompt's closest match more recently push it out (the
625
+ * model keeps the 256 most recent, as far as 64 MiB of memory holds
626
+ * their prompt records). No effect with `cache_share_across_keys:
627
+ * 'false'`.
628
+ */
629
+ baseCacheKey?: string;
582
630
  /**
583
631
  * Admission policy when the worker pool is full. `true` rejects before
584
632
  * submitting with an `Error` carrying `code === 'RUN_BUSY'` — branch on the
package/index.js CHANGED
@@ -16,6 +16,7 @@ function normalizeRunOptions(runOptions) {
16
16
  generationParams: undefined,
17
17
  cacheKey: undefined,
18
18
  ephemeral: false,
19
+ baseCacheKey: undefined,
19
20
  rejectWhenBusy: undefined,
20
21
  };
21
22
  }
@@ -43,6 +44,9 @@ function normalizeRunOptions(runOptions) {
43
44
  if (options.ephemeral !== undefined && typeof options.ephemeral !== "boolean") {
44
45
  throw new TypeError("ephemeral must be a boolean when provided");
45
46
  }
47
+ if (options.baseCacheKey !== undefined && typeof options.baseCacheKey !== "string") {
48
+ throw new TypeError("baseCacheKey must be a string when provided");
49
+ }
46
50
  if (options.rejectWhenBusy !== undefined && typeof options.rejectWhenBusy !== "boolean") {
47
51
  throw new TypeError("rejectWhenBusy must be a boolean when provided");
48
52
  }
@@ -51,15 +55,28 @@ function normalizeRunOptions(runOptions) {
51
55
  generationParams: normalizeGenerationParams(options.generationParams),
52
56
  cacheKey: options.cacheKey,
53
57
  ephemeral: options.ephemeral === true,
58
+ baseCacheKey: options.baseCacheKey,
54
59
  // Left undefined when unset so admission falls back to the instance default.
55
60
  rejectWhenBusy: options.rejectWhenBusy,
56
61
  };
57
62
  }
63
+ // Malformed parts (null, non-objects) are not byte media: they pass through
64
+ // untouched so the native validator rejects them with `InvalidInputFormat`.
65
+ function isByteMediaPart(part) {
66
+ const candidate = part;
67
+ return (candidate !== null &&
68
+ typeof candidate === "object" &&
69
+ candidate.type === "media" &&
70
+ candidate.content instanceof Uint8Array);
71
+ }
58
72
  function promptToAddonMessages(prompt, runOptions) {
59
73
  if (!Array.isArray(prompt)) {
60
74
  throw new TypeError("Prompt input must be Message[]");
61
75
  }
62
- const { prefill, generationParams, cacheKey, ephemeral } = normalizeRunOptions(runOptions);
76
+ const { prefill, generationParams, cacheKey, ephemeral, baseCacheKey } = normalizeRunOptions(runOptions);
77
+ // Only ever JSON-serialized into the addon's text message: a byte-buffer
78
+ // media part reaches the native side path-less, bound to the hoisted buffer
79
+ // by position, so the wire shape is wider than the public `Message` type.
63
80
  const textMessages = [];
64
81
  const mediaItems = [];
65
82
  for (const message of prompt) {
@@ -67,10 +84,26 @@ function promptToAddonMessages(prompt, runOptions) {
67
84
  if (media.role === "user" && media.type === "media" && media.content instanceof Uint8Array) {
68
85
  mediaItems.push(media.content);
69
86
  textMessages.push({ ...media, content: "" });
87
+ continue;
70
88
  }
71
- else {
72
- textMessages.push(message);
89
+ // Ordered parts: hoist each media part's bytes next to the other media
90
+ // messages and leave a path-less part behind. The native side records the
91
+ // plan in marker order, so the bytes still bind to the right markers.
92
+ const text = message;
93
+ if (Array.isArray(text.content)) {
94
+ textMessages.push({
95
+ ...text,
96
+ content: text.content.map((part) => {
97
+ if (isByteMediaPart(part)) {
98
+ mediaItems.push(part.content);
99
+ return { type: "media" };
100
+ }
101
+ return part;
102
+ }),
103
+ });
104
+ continue;
73
105
  }
106
+ textMessages.push(message);
74
107
  }
75
108
  const promptMessages = [];
76
109
  for (const mediaData of mediaItems) {
@@ -83,6 +116,7 @@ function promptToAddonMessages(prompt, runOptions) {
83
116
  generationParams,
84
117
  cacheKey,
85
118
  ephemeral,
119
+ baseCacheKey,
86
120
  });
87
121
  return promptMessages;
88
122
  }
@@ -257,6 +291,15 @@ function sanitizePromptForLog(prompt) {
257
291
  if (media && media.type === "media" && media.content instanceof Uint8Array) {
258
292
  return { ...media, content: `[Uint8Array byteLength=${media.content.byteLength}]` };
259
293
  }
294
+ const text = msg;
295
+ if (Array.isArray(text?.content)) {
296
+ return {
297
+ ...text,
298
+ content: text.content.map((part) => isByteMediaPart(part)
299
+ ? { ...part, content: `[Uint8Array byteLength=${part.content.byteLength}]` }
300
+ : part),
301
+ };
302
+ }
260
303
  return msg;
261
304
  });
262
305
  }
@@ -863,9 +906,20 @@ const LlmLlamacpp = class LlmLlamacpp {
863
906
  if (text !== undefined)
864
907
  params.text = text;
865
908
  // Media bytes are dropped rather than serialized: the native side rejects
866
- // any media message by its type.
909
+ // any media message or ordered media part by its type.
867
910
  if (history !== undefined) {
868
- params.history = JSON.stringify(history.map((message) => message?.type === "media" ? { ...message, content: "" } : message));
911
+ params.history = JSON.stringify(history.map((message) => {
912
+ if (message?.type === "media")
913
+ return { ...message, content: "" };
914
+ const textMessage = message;
915
+ if (Array.isArray(textMessage?.content)) {
916
+ return {
917
+ ...message,
918
+ content: textMessage.content.map((part) => isByteMediaPart(part) ? { type: "media" } : part),
919
+ };
920
+ }
921
+ return message;
922
+ }));
869
923
  }
870
924
  if (tools !== undefined)
871
925
  params.tools = JSON.stringify(tools);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qvac/llm-llamacpp",
3
- "version": "0.66.0",
3
+ "version": "0.67.0",
4
4
  "description": "llama addon for qvac",
5
5
  "addon": true,
6
6
  "scripts": {