@qvac/llm-llamacpp 0.66.0 → 0.68.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)
@@ -113,7 +114,7 @@ const args = {
113
114
 
114
115
  The `args` obj contains the following properties:
115
116
 
116
- * `files.model`: Required. An array of absolute paths to the GGUF model file(s) to load. The caller is responsible for passing the complete set of files for the model, including every shard and the `.tensors.txt` companion for multi-shard models (see [Sharded models](#sharded-models) below).
117
+ * `files.model`: Exactly one absolute disk path: the single GGUF file or only the first shard. Fabric reads the remaining shards from the same directory.
117
118
  * `files.projectionModel`: Optional. Absolute path to the projection model file. This is required for multimodal support.
118
119
  * `config`: The model configuration object (see next section).
119
120
  * `logger`: This property is used to create a [`QvacLogger`](../logging) instance, which handles all logging functionality.
@@ -121,46 +122,24 @@ The `args` obj contains the following properties:
121
122
 
122
123
  #### Sharded models
123
124
 
124
- The addon no longer expands sharded models internally. If you are loading a multi-shard GGUF model, **the caller MUST pass every file** — including the `.tensors.txt` companion file that lives alongside the shards — in `files.model`. Anything missing will cause the addon to fail during weight streaming.
125
+ Models load from disk only. Pass exactly one absolute path in `files.model`:
126
+ the single GGUF file, or the first shard (`*-00001-of-000NN.gguf`). All shards
127
+ must already be in the same directory with their original names. Fabric reads
128
+ the GGUF metadata, discovers the remaining shards and reads their weights.
129
+ No JavaScript weight buffers or `.tensors.txt` file are needed.
125
130
 
126
- **Required ordering for multi-shard models:**
127
- 1. The `.tensors.txt` companion file **first**.
128
- 2. Each `*-NNNNN-of-MMMMM.gguf` shard in **numerical order** (shard `00001` before `00002`, and so on).
129
-
130
- Example — loading a 5-shard model:
131
+ Passing multiple paths or weight buffers is rejected. Migrate an old all-files
132
+ list to `[firstShardPath]`. The `loadWeights()` API has been removed.
133
+ This removes model-weight streaming, while generated output still streams as before.
131
134
 
132
135
  ```js
133
- const path = require('bare-path')
134
- const LlmLlamacpp = require('@qvac/llm-llamacpp')
135
-
136
- const dir = path.resolve('./models')
137
- const modelBase = 'my-big-model-Q4_K_M'
138
-
139
136
  const model = new LlmLlamacpp({
140
- files: {
141
- model: [
142
- path.join(dir, `${modelBase}.tensors.txt`),
143
- path.join(dir, `${modelBase}-00001-of-00005.gguf`),
144
- path.join(dir, `${modelBase}-00002-of-00005.gguf`),
145
- path.join(dir, `${modelBase}-00003-of-00005.gguf`),
146
- path.join(dir, `${modelBase}-00004-of-00005.gguf`),
147
- path.join(dir, `${modelBase}-00005-of-00005.gguf`)
148
- ]
149
- },
150
- config,
151
- logger: console,
152
- opts: { stats: true }
137
+ files: { model: ['/absolute/models/model-00001-of-00003.gguf'] },
138
+ config
153
139
  })
154
-
155
140
  await model.load()
156
141
  ```
157
142
 
158
- For single-file GGUF models, pass a one-element array:
159
-
160
- ```js
161
- files: { model: [path.join(dir, 'Llama-3.2-1B-Instruct-Q4_0.gguf')] }
162
- ```
163
-
164
143
  ### 3. Create the `config` obj
165
144
 
166
145
  The `config` obj consists of a set of hyper-parameters which can be used to tweak the behaviour of the model.
@@ -206,7 +185,8 @@ const config = {
206
185
  | 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
186
  | 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
187
  | 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)) |
188
+ | 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)) |
189
+ | 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
190
  | 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
191
  | 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
192
  | 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 +325,28 @@ const response = await model.run([
345
325
 
346
326
  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
327
 
328
+ #### Ordered text and media
329
+
330
+ 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:
331
+
332
+ ```javascript
333
+ const response = await model.run([{
334
+ role: 'user',
335
+ content: [
336
+ { type: 'text', text: 'Which image shows the same place as ' },
337
+ { type: 'media', content: '/images/place.jpg' },
338
+ { type: 'text', text: '?\nA. ' },
339
+ { type: 'media', content: imageABytes }, // raw bytes work too
340
+ { type: 'text', text: '\nB. ' },
341
+ { type: 'media', content: '/images/option-b.jpg' }
342
+ ]
343
+ }])
344
+ ```
345
+
346
+ 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`.
347
+
348
+ 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.
349
+
348
350
  #### Batch inference
349
351
 
350
352
  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 +445,7 @@ Admission is controlled by `rejectWhenBusy` (instance-level `opts.rejectWhenBusy
443
445
 
444
446
  #### Prefill (cache warming) with `parallel >= 2`
445
447
 
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).
448
+ 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
449
 
448
450
  #### Cancelling a batch
449
451
 
@@ -735,7 +737,7 @@ npm run test:cpp:models:ci # exactly what .github/workflows/cpp-tests-llm.yml
735
737
  # downloads; matches what CI exercises
736
738
  ```
737
739
 
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`).
740
+ 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
741
 
740
742
  ### Fuzzing
741
743
 
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";
@@ -54,11 +55,6 @@ export type AddonBatchRunResult = {
54
55
  id?: never;
55
56
  ids: string[];
56
57
  };
57
- export interface LoadWeightsData {
58
- filename: string;
59
- chunk: Uint8Array | null;
60
- completed: boolean;
61
- }
62
58
  export interface AddonConfigurationParams {
63
59
  path: string;
64
60
  projectionPath: string;
@@ -73,7 +69,6 @@ export interface AddonCalcTokensParams {
73
69
  }
74
70
  export interface LlamaBinding {
75
71
  createInstance(owner: LlamaInterface, configurationParams: AddonConfigurationParams, outputCallback: AddonOutputCallback, reserved: null): object;
76
- loadWeights(handle: unknown, data: LoadWeightsData): Promise<void> | void;
77
72
  activate(handle: unknown): Promise<void> | void;
78
73
  activeJobs(handle: unknown): number;
79
74
  activeSlots(handle: unknown): number;
@@ -104,7 +99,6 @@ export declare class LlamaInterface {
104
99
  private readonly _binding;
105
100
  private _handle;
106
101
  constructor(binding: unknown, configurationParams: AddonConfigurationParams, outputCb: AddonOutputCallback);
107
- loadWeights(weightsData: LoadWeightsData): Promise<void>;
108
102
  /**
109
103
  * Moves addon to the LISTENING state after all the initialization is done
110
104
  */
package/addon.js CHANGED
@@ -80,9 +80,6 @@ class LlamaInterface {
80
80
  }
81
81
  this._handle = this._binding.createInstance(this, configurationParams, outputCb, null);
82
82
  }
83
- loadWeights(weightsData) {
84
- return Promise.resolve(this._binding.loadWeights(this._handle, weightsData));
85
- }
86
83
  /**
87
84
  * Moves addon to the LISTENING state after all the initialization is done
88
85
  */
package/index.d.ts CHANGED
@@ -16,11 +16,10 @@ type LlmLlamacppArgs = LlmLlamacpp.LlmLlamacppArgs;
16
16
  type Message = LlmLlamacpp.Message;
17
17
  type RunOptions = LlmLlamacpp.RunOptions;
18
18
  /**
19
- * Returns the first shard (matching `-NNNNN-of-MMMMM.gguf`) or the sole
20
- * entry for single-file models. Matches the C++ shard-expansion contract
21
- * in `GGUFShards::expandGGUFIntoShards`.
19
+ * Validates the single disk path. For a sharded GGUF, pass only the first
20
+ * shard; Fabric discovers the remaining shards. Weight streaming is unsupported.
22
21
  */
23
- declare function pickPrimaryGgufPath(files: string[]): string;
22
+ declare function validateModelPath(files: string[]): string;
24
23
  interface LlmLlamacpp {
25
24
  /** The native addon, or `null` before `load()` and after `unload()`. Advanced/test access only. */
26
25
  addon: LlmLlamacpp.Addon | null;
@@ -93,8 +92,8 @@ interface LlmLlamacpp {
93
92
  interface LlmLlamacppConstructor {
94
93
  new (args: LlmLlamacppArgs): LlmLlamacpp;
95
94
  readonly prototype: LlmLlamacpp;
96
- /** Returns the first shard (matching `-NNNNN-of-MMMMM.gguf`) or the sole entry for single-file models. */
97
- readonly pickPrimaryGgufPath: typeof pickPrimaryGgufPath;
95
+ /** Validates and returns the single absolute disk path; split models require the first shard. */
96
+ readonly validateModelPath: typeof validateModelPath;
98
97
  readonly assessFit: typeof assessFitImpl;
99
98
  }
100
99
  /** LLM client wrapping the native LlamaInterface for inference, finetuning, and pause/resume. */
@@ -119,11 +118,6 @@ declare namespace LlmLlamacpp {
119
118
  */
120
119
  type AddonBatchRunResult = AddonModule.AddonBatchRunResult;
121
120
  interface Addon {
122
- loadWeights(data: {
123
- filename: string;
124
- chunk: Uint8Array | null;
125
- completed: boolean;
126
- }, logger?: QvacLogger): Promise<void>;
127
121
  activate(): Promise<void>;
128
122
  /** Single-request admission: resolves the accepted flag plus the native-assigned job id. */
129
123
  runJob(data: AddonRunJobMessage[]): Promise<AddonRunJobResult>;
@@ -380,6 +374,19 @@ declare namespace LlmLlamacpp {
380
374
  * maximum is 1048576 (1 TiB). Also accepted as `cache-ram-mib`.
381
375
  */
382
376
  cache_ram_mib?: NumericLike;
377
+ /**
378
+ * Whether a request on a decoder-only model may start from state another
379
+ * conversation left: the memory a request without a `cacheKey` keeps,
380
+ * another key's state in a sequence or the RAM tier, or a `cacheKey` file
381
+ * the model has used. `'true'` (default) shares it, as llama-server's
382
+ * prompt cache does. `'false'` keeps every request to its own key's state,
383
+ * for a model serving callers who must not learn through `cacheTokens` or
384
+ * timing whether another conversation shares their prompt: a request
385
+ * without a key starts cold and is reset afterwards, a new key starts cold,
386
+ * and `baseCacheKey` has no effect. Also accepted as
387
+ * `cache-share-across-keys`.
388
+ */
389
+ cache_share_across_keys?: "true" | "false" | "on" | "off" | "1" | "0";
383
390
  /**
384
391
  * Speculative decoding, as llama-server's `--spec-type`. `'draft-mtp'`
385
392
  * drafts with the model's own multi-token-prediction (MTP / NextN) head,
@@ -420,8 +427,9 @@ declare namespace LlmLlamacpp {
420
427
  [key: string]: string | number | boolean | string[] | undefined;
421
428
  }
422
429
  interface LlmLlamacppArgs {
430
+ /** One absolute disk path, or only the first shard path. Weight buffers, multiple paths and tensor-name files are unsupported. */
423
431
  files: {
424
- model: string[];
432
+ model: [string];
425
433
  projectionModel?: string;
426
434
  };
427
435
  config: LlamaConfig;
@@ -437,9 +445,30 @@ declare namespace LlmLlamacpp {
437
445
  rejectWhenBusy?: boolean;
438
446
  };
439
447
  }
448
+ /**
449
+ * One ordered piece of a message's `content`. Media parts keep their place in
450
+ * the turn: a single user message can interleave text and media, which a
451
+ * string `content` plus separate `type: "media"` messages cannot express
452
+ * without splitting the turn.
453
+ */
454
+ type UserContentPart = {
455
+ type: "text";
456
+ text: string;
457
+ } | {
458
+ type: "media";
459
+ /**
460
+ * Either an absolute path to the media file (`string`) or its raw bytes
461
+ * (`Uint8Array`), the same two forms a `type: "media"` message accepts.
462
+ */
463
+ content: string | Uint8Array;
464
+ };
440
465
  interface UserTextMessage {
441
466
  role: "system" | "assistant" | "user" | "tool" | "session" | string;
442
- content: string;
467
+ /**
468
+ * Plain text, or ordered text/media parts rendered as one turn. Media parts
469
+ * are only valid on user messages.
470
+ */
471
+ content: string | UserContentPart[];
443
472
  /**
444
473
  * An assistant turn's reasoning, separate from its answer in `content`, as
445
474
  * in OpenAI-compatible APIs. Optional: an answer sent back with its
@@ -554,10 +583,9 @@ declare namespace LlmLlamacpp {
554
583
  * Run prefill only (cache warming): the prompt is evaluated but no tokens
555
584
  * are generated. On a model loaded with `parallel >= 2` a prefill needs a
556
585
  * `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.
586
+ * request on that key) — a keyless prefill warms state no later request
587
+ * can count on and is rejected with `InvalidArgument`; run keyless
588
+ * prefills on a `parallel: 1` model. The same rule applies per batch item.
561
589
  */
562
590
  prefill?: boolean;
563
591
  generationParams?: GenerationParams;
@@ -574,11 +602,26 @@ declare namespace LlmLlamacpp {
574
602
  * otherwise be lost with turns the file lacks: when it is set aside for
575
603
  * another key, evicted from its batch slot or from the RAM tier, or the
576
604
  * 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.
605
+ * drops it instead, so its next request finds no state of its own (it
606
+ * still reuses any prefix it shares with what the model holds, except on
607
+ * encoder-decoder models). It still loads an existing file, and an
608
+ * explicit `saveCache(cacheKey)` still writes it.
579
609
  * The flag belongs to the conversation's latest request.
580
610
  */
581
611
  ephemeral?: boolean;
612
+ /**
613
+ * A `cacheKey` file to start from when nothing in memory serves more of
614
+ * the prompt, such as a system prompt and tools primed with `prefill`.
615
+ * It is read, never written: the request's own state goes to its
616
+ * `cacheKey`. A file that is missing or shares no prefix with the prompt
617
+ * is skipped. Once named, the file stays a source for later requests on
618
+ * this model, as any `cacheKey` file it used does, until files named
619
+ * or picked as a prompt's closest match more recently push it out (the
620
+ * model keeps the 256 most recent, as far as 64 MiB of memory holds
621
+ * their prompt records). No effect with `cache_share_across_keys:
622
+ * 'false'`.
623
+ */
624
+ baseCacheKey?: string;
582
625
  /**
583
626
  * Admission policy when the worker pool is full. `true` rejects before
584
627
  * 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
  }
@@ -239,13 +273,24 @@ function normalizeFinetuneParams(opts) {
239
273
  return out;
240
274
  }
241
275
  /**
242
- * Returns the first shard (matching `-NNNNN-of-MMMMM.gguf`) or the sole
243
- * entry for single-file models. Matches the C++ shard-expansion contract
244
- * in `GGUFShards::expandGGUFIntoShards`.
276
+ * Validates the single disk path. For a sharded GGUF, pass only the first
277
+ * shard; Fabric discovers the remaining shards. Weight streaming is unsupported.
245
278
  */
246
- function pickPrimaryGgufPath(files) {
247
- const SHARD_REGEX = /-\d+-of-\d+\.gguf$/;
248
- return files.find((p) => SHARD_REGEX.test(p)) || files[0];
279
+ function validateModelPath(files) {
280
+ if (!Array.isArray(files) || files.length === 0) {
281
+ throw new TypeError("files.model must contain one absolute disk path");
282
+ }
283
+ if (files.length !== 1) {
284
+ throw new TypeError("Weight streaming is no longer supported. Pass only one disk path in files.model; " +
285
+ "for a sharded model, pass only the first shard path. Do not pass a tensor-name file.");
286
+ }
287
+ if (typeof files[0] !== "string" || files[0].length === 0 || !path.isAbsolute(files[0])) {
288
+ throw new TypeError("files.model[0] must be an absolute disk path string; weight buffers are not supported");
289
+ }
290
+ if (files[0].endsWith(".tensors.txt")) {
291
+ throw new TypeError("Weight streaming is no longer supported. Pass the first GGUF shard path, not a tensor-name file.");
292
+ }
293
+ return files[0];
249
294
  }
250
295
  // Replace media Uint8Array contents with a small summary so the logger never
251
296
  // asks V8 to JSON.stringify multi-MB binary blobs.
@@ -257,12 +302,21 @@ function sanitizePromptForLog(prompt) {
257
302
  if (media && media.type === "media" && media.content instanceof Uint8Array) {
258
303
  return { ...media, content: `[Uint8Array byteLength=${media.content.byteLength}]` };
259
304
  }
305
+ const text = msg;
306
+ if (Array.isArray(text?.content)) {
307
+ return {
308
+ ...text,
309
+ content: text.content.map((part) => isByteMediaPart(part)
310
+ ? { ...part, content: `[Uint8Array byteLength=${part.content.byteLength}]` }
311
+ : part),
312
+ };
313
+ }
260
314
  return msg;
261
315
  });
262
316
  }
263
317
  /** LLM client wrapping the native LlamaInterface for inference, finetuning, and pause/resume. */
264
318
  const LlmLlamacpp = class LlmLlamacpp {
265
- static pickPrimaryGgufPath = pickPrimaryGgufPath;
319
+ static validateModelPath = validateModelPath;
266
320
  static assessFit = fit_1.assessFit;
267
321
  // Attached for tests; untyped because `typeof QvacResponse` is not nameable by consumers.
268
322
  static QvacResponse = infer_base_1.QvacResponse;
@@ -270,7 +324,7 @@ const LlmLlamacpp = class LlmLlamacpp {
270
324
  opts;
271
325
  logger;
272
326
  state;
273
- _files;
327
+ _modelPath;
274
328
  _projectionModelPath;
275
329
  _config;
276
330
  _finetuneJob;
@@ -281,17 +335,7 @@ const LlmLlamacpp = class LlmLlamacpp {
281
335
  _batchHandler;
282
336
  _checkpointSaveDir;
283
337
  constructor({ files, config, logger = null, opts = {} }) {
284
- if (!files || !Array.isArray(files.model) || files.model.length === 0) {
285
- throw new TypeError("files.model must be a non-empty array of absolute paths");
286
- }
287
- for (const [i, entry] of files.model.entries()) {
288
- if (typeof entry !== "string" || entry.length === 0) {
289
- throw new TypeError(`files.model[${i}] must be an absolute path string`);
290
- }
291
- if (!path.isAbsolute(entry)) {
292
- throw new TypeError(`files.model[${i}] must be an absolute path (got: ${entry})`);
293
- }
294
- }
338
+ this._modelPath = validateModelPath(files?.model);
295
339
  if (files.projectionModel !== undefined) {
296
340
  if (typeof files.projectionModel !== "string" || files.projectionModel.length === 0) {
297
341
  throw new TypeError("files.projectionModel must be an absolute path string");
@@ -300,7 +344,6 @@ const LlmLlamacpp = class LlmLlamacpp {
300
344
  throw new TypeError(`files.projectionModel must be an absolute path (got: ${files.projectionModel})`);
301
345
  }
302
346
  }
303
- this._files = files.model;
304
347
  this._projectionModelPath = files.projectionModel || "";
305
348
  this._config = config;
306
349
  this.logger = new QvacLogger(logger);
@@ -361,18 +404,14 @@ const LlmLlamacpp = class LlmLlamacpp {
361
404
  }
362
405
  async _load() {
363
406
  this.logger.info("Starting model load");
364
- const primaryGgufPath = pickPrimaryGgufPath(this._files);
365
407
  const configurationParams = {
366
- path: primaryGgufPath,
408
+ path: this._modelPath,
367
409
  projectionPath: this._projectionModelPath,
368
410
  config: { ...this._config },
369
411
  };
370
412
  this.logger.info("Creating addon with configuration:", configurationParams);
371
413
  try {
372
414
  this.addon = this._createAddon(configurationParams);
373
- if (this._files.length > 1) {
374
- await this._streamShards();
375
- }
376
415
  this.logger.info("Activating addon");
377
416
  await this.addon.activate();
378
417
  }
@@ -389,17 +428,6 @@ const LlmLlamacpp = class LlmLlamacpp {
389
428
  }
390
429
  this.logger.info("Model load completed successfully");
391
430
  }
392
- async _streamShards() {
393
- for (const filePath of this._files) {
394
- const filename = path.basename(filePath);
395
- const stream = fs.createReadStream(filePath);
396
- for await (const chunk of stream) {
397
- await this.addon.loadWeights({ filename, chunk, completed: false });
398
- }
399
- await this.addon.loadWeights({ filename, chunk: null, completed: true });
400
- this.logger.info(`Streamed weights for ${filename}`);
401
- }
402
- }
403
431
  run(prompt, runOptions) {
404
432
  if (BatchHandler.isBatchInput(prompt)) {
405
433
  if (runOptions !== undefined) {
@@ -863,9 +891,20 @@ const LlmLlamacpp = class LlmLlamacpp {
863
891
  if (text !== undefined)
864
892
  params.text = text;
865
893
  // Media bytes are dropped rather than serialized: the native side rejects
866
- // any media message by its type.
894
+ // any media message or ordered media part by its type.
867
895
  if (history !== undefined) {
868
- params.history = JSON.stringify(history.map((message) => message?.type === "media" ? { ...message, content: "" } : message));
896
+ params.history = JSON.stringify(history.map((message) => {
897
+ if (message?.type === "media")
898
+ return { ...message, content: "" };
899
+ const textMessage = message;
900
+ if (Array.isArray(textMessage?.content)) {
901
+ return {
902
+ ...message,
903
+ content: textMessage.content.map((part) => isByteMediaPart(part) ? { type: "media" } : part),
904
+ };
905
+ }
906
+ return message;
907
+ }));
869
908
  }
870
909
  if (tools !== undefined)
871
910
  params.tools = JSON.stringify(tools);
@@ -877,7 +916,7 @@ const LlmLlamacpp = class LlmLlamacpp {
877
916
  };
878
917
  // Runtime-redundant: ESM named imports need the top-level `module.exports.X =` form.
879
918
  /* eslint-disable @typescript-eslint/no-unsafe-member-access -- `module.exports` is untyped CommonJS surface. */
880
- module.exports.pickPrimaryGgufPath = pickPrimaryGgufPath;
919
+ module.exports.validateModelPath = validateModelPath;
881
920
  module.exports.assessFit = fit_1.assessFit;
882
921
  module.exports.QvacResponse = infer_base_1.QvacResponse;
883
922
  module.exports = LlmLlamacpp;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qvac/llm-llamacpp",
3
- "version": "0.66.0",
3
+ "version": "0.68.0",
4
4
  "description": "llama addon for qvac",
5
5
  "addon": true,
6
6
  "scripts": {
@@ -79,8 +79,8 @@
79
79
  "bugs": "https://github.com/tetherto/qvac/issues",
80
80
  "homepage": "https://qvac.tether.io",
81
81
  "devDependencies": {
82
- "@qvac/fabric-android-arm64": "^0.25.0",
83
- "@qvac/fabric-ios": "^0.25.0",
82
+ "@qvac/fabric-android-arm64": "^0.26.0",
83
+ "@qvac/fabric-ios": "^0.26.0",
84
84
  "@types/node": "^24.2.1",
85
85
  "bare-crypto": "^1.15.3",
86
86
  "bare-process": "^4.2.2",
@@ -102,22 +102,22 @@
102
102
  "typescript-eslint": "^8.63.0"
103
103
  },
104
104
  "dependencies": {
105
- "@qvac/fabric": "^0.25.0",
105
+ "@qvac/fabric": "^0.26.0",
106
106
  "@qvac/infer-base": "^0.8.0",
107
107
  "@qvac/logging": "^0.1.0",
108
108
  "bare-fs": "^4.5.1",
109
109
  "bare-path": "^3.0.0"
110
110
  },
111
111
  "optionalDependencies": {
112
- "@qvac/fabric-linux-x64": "^0.25.0",
113
- "@qvac/fabric-linux-arm64": "^0.25.0",
114
- "@qvac/fabric-darwin-arm64": "^0.25.0",
115
- "@qvac/fabric-darwin-x64": "^0.25.0",
116
- "@qvac/fabric-win32-x64": "^0.25.0"
112
+ "@qvac/fabric-linux-x64": "^0.26.0",
113
+ "@qvac/fabric-linux-arm64": "^0.26.0",
114
+ "@qvac/fabric-darwin-arm64": "^0.26.0",
115
+ "@qvac/fabric-darwin-x64": "^0.26.0",
116
+ "@qvac/fabric-win32-x64": "^0.26.0"
117
117
  },
118
118
  "peerDependencies": {
119
- "@qvac/fabric-android-arm64": "^0.25.0",
120
- "@qvac/fabric-ios": "^0.25.0"
119
+ "@qvac/fabric-android-arm64": "^0.26.0",
120
+ "@qvac/fabric-ios": "^0.26.0"
121
121
  },
122
122
  "peerDependenciesMeta": {
123
123
  "@qvac/fabric-android-arm64": {