@bendyline/gezel-inference 1.0.8 → 1.0.9

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.
Files changed (59) hide show
  1. package/dist/catalog/chat-models/ge/gemma4-12b-q4/manifest.json +46 -3
  2. package/dist/catalog/chat-models/ge/gemma4-12b-q4/versions/1.2.1/manifest.json +141 -0
  3. package/dist/catalog/chat-models/ge/gemma4-e4b-q4/manifest.json +5 -0
  4. package/dist/catalog/chat-models/index.json +1 -1
  5. package/dist/catalog/chat-models/or/ornith1.5-9b-q4/manifest.json +5 -0
  6. package/dist/catalog/chat-models/or/ornith1.5-9b-q8/manifest.json +5 -0
  7. package/dist/catalog/chat-models/qw/qwen3.5-9b-q4/manifest.json +5 -0
  8. package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/manifest.json +5 -0
  9. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-q2/manifest.json +24 -7
  10. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-q2/versions/1.0.3/manifest.json +47 -0
  11. package/dist/catalog/chat-models/raw-index.json +1 -1
  12. package/dist/catalog/provenance.json +1 -1
  13. package/dist/engines/electron-native-reuse.d.ts +5 -4
  14. package/dist/engines/electron-native-reuse.js +1 -1
  15. package/dist/engines/native-file-manifest.json +12344 -698
  16. package/dist/engines/native-manifest.d.ts +4 -4
  17. package/dist/engines/native-manifest.js +40 -36
  18. package/dist/engines/resolver.d.ts +18 -17
  19. package/dist/engines/resolver.js +36 -1
  20. package/dist/http/model-inventory-cache.d.ts +1 -1
  21. package/dist/http/routes/engines.js +3 -3
  22. package/dist/models/catalog-drift.d.ts +2 -0
  23. package/dist/models/catalog-drift.js +1 -1
  24. package/dist/models/catalog-payload-identity.d.ts +2 -2
  25. package/dist/models/catalog-payload-identity.js +10 -5
  26. package/dist/providers/active-install-message.js +1 -1
  27. package/dist/providers/catalog-model-config.d.ts +28 -0
  28. package/dist/providers/ds4/build-provider.js +35 -13
  29. package/dist/providers/ds4/residency.d.ts +77 -0
  30. package/dist/providers/ds4/residency.js +87 -6
  31. package/dist/providers/image/sd-cpp.js +3 -2
  32. package/dist/providers/llama-cpp/backend-health.d.ts +3 -2
  33. package/dist/providers/llama-cpp/backend-health.js +6 -3
  34. package/dist/providers/llama-cpp/backend-quarantine.js +1 -1
  35. package/dist/providers/llama-cpp/build-provider.js +168 -29
  36. package/dist/providers/llama-cpp/devices.js +4 -1
  37. package/dist/providers/llama-cpp/engine-flags.d.ts +8 -1
  38. package/dist/providers/llama-cpp/engine-flags.js +13 -8
  39. package/dist/providers/llama-cpp/gpu-startup-watch.d.ts +4 -4
  40. package/dist/providers/llama-cpp/gpu-startup-watch.js +7 -5
  41. package/dist/providers/llama-cpp/models.d.ts +10 -0
  42. package/dist/providers/llama-cpp/models.js +64 -10
  43. package/dist/providers/llama-cpp/offload-planner.d.ts +6 -0
  44. package/dist/providers/llama-cpp/offload-planner.js +1 -1
  45. package/dist/providers/llama-cpp/spec-draft.d.ts +45 -23
  46. package/dist/providers/llama-cpp/spec-draft.js +113 -24
  47. package/dist/providers/mlx/python/spec_decode.py +23 -2
  48. package/dist/providers/mlx/python/tool_args_json.py +59 -21
  49. package/dist/providers/mlx/venv.d.ts +19 -1
  50. package/dist/providers/mlx/venv.js +21 -2
  51. package/dist/providers/native/device-capacity.d.ts +4 -0
  52. package/dist/providers/native/device-capacity.js +5 -0
  53. package/dist/providers/native/local-engine-runtime.d.ts +16 -1
  54. package/dist/providers/native/local-engine-runtime.js +142 -39
  55. package/dist/providers/native/supervisor.d.ts +8 -1
  56. package/dist/providers/native/supervisor.js +16 -7
  57. package/dist/providers/types.d.ts +4 -0
  58. package/dist/remotes/types.d.ts +1 -1
  59. package/package.json +4 -4
@@ -49,7 +49,7 @@ function gpuResidentThreads(hostCpuCount) {
49
49
  : GPU_RESIDENT_THREADS;
50
50
  return Math.max(1, Math.min(GPU_RESIDENT_THREADS, available));
51
51
  }
52
- const GPU_BACKENDS = new Set(['cuda', 'vulkan', 'metal']);
52
+ const GPU_BACKENDS = new Set(['cuda', 'rocm', 'vulkan', 'metal']);
53
53
  /**
54
54
  * `llamaCppExtraArgs` keys that either choose a thread pool or may move
55
55
  * weights to the CPU; any of them makes the GPU-resident default stand down.
@@ -223,7 +223,7 @@ export function buildLlamaCppEngineArgs(input) {
223
223
  // MTP is ON by default for a GGUF that carries the head, having cleared the
224
224
  // A/B gate this comment used to wait for (reports/llama-mtp-eval-20260829.md):
225
225
  // +12.7% / +4.1% / +15.6% / +19.3% decode at 5.5k / 21.7k / 54k / 87k
226
- // context, never negative, byte-identical greedy output, +1.35 GB resident.
226
+ // context, never negative, +1.35 GB resident.
227
227
  // The gain GROWS with context — long-context decode is bound by reading the
228
228
  // KV cache, and verifying K drafted tokens costs one pass over it instead of
229
229
  // K — which is the regime agent turns actually run in.
@@ -232,6 +232,8 @@ export function buildLlamaCppEngineArgs(input) {
232
232
  // FATALLY when draft-mtp is selected for a model with no MTP tensors, so
233
233
  // the default may only ever apply where the metadata confirms the head.
234
234
  // Explicit config and manifest `spec.type` still win, including 'none'.
235
+ // `perModel` arrives with its `spec.byBackend` already applied by
236
+ // `resolveSpecDraft`, which also decided whether a drafter is in play.
235
237
  const specType = config.llamaCppSpecType ?? perModel?.spec?.type ?? (ggufHasMtp ? 'draft-mtp' : undefined);
236
238
  const safeSpecType = specType === 'draft-mtp' && !ggufHasMtp ? undefined : specType;
237
239
  if (safeSpecType && safeSpecType !== 'none') {
@@ -244,12 +246,15 @@ export function buildLlamaCppEngineArgs(input) {
244
246
  const nMax = config.llamaCppSpecDraftNMax ?? perModel?.spec?.nMax;
245
247
  if (typeof nMax === 'number')
246
248
  args.push('--spec-draft-n-max', String(nMax));
247
- // v0.7.0 supports probabilistic selection only for MTP/simple drafts
248
- // (DFlash/LiLiCorr ignore it).
249
- // Leave the upstream greedy default untouched unless explicitly set.
250
- if ((safeSpecType === 'draft-mtp' || safeSpecType === 'draft-simple') &&
251
- config.llamaCppSpecDraftSampling) {
252
- args.push('--spec-draft-sampling', config.llamaCppSpecDraftSampling);
249
+ // MTP, simple and DFlash2 honor probabilistic selection; a DFlash 1 or
250
+ // LiLiCorr drafter takes the flag and ignores it. Leave the upstream greedy
251
+ // default untouched unless someone chose otherwise.
252
+ const draftSampling = config.llamaCppSpecDraftSampling ?? perModel?.spec?.sampling;
253
+ if ((safeSpecType === 'draft-mtp' ||
254
+ safeSpecType === 'draft-simple' ||
255
+ safeSpecType === 'draft-dflash') &&
256
+ draftSampling) {
257
+ args.push('--spec-draft-sampling', draftSampling);
253
258
  }
254
259
  }
255
260
  // ── Server-side output parsing (`--reasoning-format`) ─────────────
@@ -7,8 +7,8 @@
7
7
  * word. A CUDA backend that loads but cannot initialize prints one error line
8
8
  * and registers no devices. Either way the engine starts, answers, and runs
9
9
  * every token on the CPU — at a fraction of the speed the person's hardware
10
- * and Settings promise — while gezel reports the CUDA or Vulkan build it
11
- * launched.
10
+ * and Settings promise — while gezel reports the CUDA, ROCm or Vulkan build
11
+ * it launched.
12
12
  *
13
13
  * llama.cpp names every GPU it will use while loading a model
14
14
  * (`using device CUDA0 (NVIDIA GB10) …`), integrated GPUs included. A GPU
@@ -16,14 +16,14 @@
16
16
  * without its GPU. The explicit lines say why when there is a why to say.
17
17
  */
18
18
  export type GpuUnusedReason =
19
- /** The CUDA runtime loaded and refused to start: `failed to initialize CUDA`. */
19
+ /** The CUDA or ROCm runtime loaded and refused to start: `failed to initialize CUDA`. */
20
20
  'init-failed'
21
21
  /** The backend loaded and found no device it could use. */
22
22
  | 'no-devices'
23
23
  /** No sign of the GPU backend at all: it failed to load, silently. */
24
24
  | 'not-loaded';
25
25
  export interface GpuUnused {
26
- backend: 'cuda' | 'vulkan';
26
+ backend: 'cuda' | 'rocm' | 'vulkan';
27
27
  reason: GpuUnusedReason;
28
28
  /** The engine's own words, when it gave any. */
29
29
  detail?: string;
@@ -7,8 +7,8 @@
7
7
  * word. A CUDA backend that loads but cannot initialize prints one error line
8
8
  * and registers no devices. Either way the engine starts, answers, and runs
9
9
  * every token on the CPU — at a fraction of the speed the person's hardware
10
- * and Settings promise — while gezel reports the CUDA or Vulkan build it
11
- * launched.
10
+ * and Settings promise — while gezel reports the CUDA, ROCm or Vulkan build
11
+ * it launched.
12
12
  *
13
13
  * llama.cpp names every GPU it will use while loading a model
14
14
  * (`using device CUDA0 (NVIDIA GB10) …`), integrated GPUs included. A GPU
@@ -16,7 +16,8 @@
16
16
  * without its GPU. The explicit lines say why when there is a why to say.
17
17
  */
18
18
  const USING_GPU = /\busing device (?:CUDA|Vulkan|ROCm|SYCL|MTL)\d+\b/;
19
- const CUDA_INIT_FAILED = /ggml_cuda_init: failed to initialize CUDA:\s*(.+?)\s*$/;
19
+ /** ggml's ROCm backend is its CUDA code under HIP, and names itself `ROCm` here. */
20
+ const CUDA_INIT_FAILED = /ggml_cuda_init: failed to initialize (?:CUDA|ROCm):\s*(.+?)\s*$/;
20
21
  const VULKAN_NO_DEVICES = /ggml_vulkan: No devices found/;
21
22
  /**
22
23
  * Lines llama.cpp prints at the same verbosity as `using device`, while
@@ -46,9 +47,10 @@ export class GpuStartupWatch {
46
47
  /** At readiness: what kept the GPU out, or null when it is in use or this is no GPU build. */
47
48
  verdict() {
48
49
  const backend = this.backend;
49
- if ((backend !== 'cuda' && backend !== 'vulkan') || this.usingGpu)
50
+ if ((backend !== 'cuda' && backend !== 'rocm' && backend !== 'vulkan') || this.usingGpu) {
50
51
  return null;
51
- if (backend === 'cuda' && this.initFailure) {
52
+ }
53
+ if ((backend === 'cuda' || backend === 'rocm') && this.initFailure) {
52
54
  return { backend, reason: 'init-failed', detail: this.initFailure };
53
55
  }
54
56
  if (backend === 'vulkan' && this.noDevices)
@@ -73,6 +73,8 @@ export interface InstalledLlamaCppModel {
73
73
  * `--spec-draft-model <path>` for the selected draft algorithm.
74
74
  */
75
75
  draftModelPath?: string;
76
+ /** On-disk size of {@link draftModelPath}, for the memory plan. */
77
+ draftModelSizeBytes?: number;
76
78
  /** From the GGUF metadata — useful for the supervisor's `-c` flag (Phase 3). */
77
79
  contextWindow?: number;
78
80
  /** Quantization tag (Q4_K_M / Q8_0 / …) lifted from the catalog source block. */
@@ -389,6 +391,14 @@ export declare class LlamaCppModelManager {
389
391
  * a recorded digest plus an unchanged stat is identity.
390
392
  */
391
393
  private planReusableFiles;
394
+ /**
395
+ * A drafter is trained against a model, not a quant, so every quant of that
396
+ * model pins the same file. Link a copy another installed quant verified
397
+ * rather than downloading a gigabyte again. A hard link keeps each model's
398
+ * directory complete on its own: deleting one quant leaves the other's
399
+ * drafter in place. A volume that cannot link just downloads.
400
+ */
401
+ private adoptSiblingDraftModels;
392
402
  /** Warn once per model directory per process, then stay quiet. */
393
403
  private warnSkip;
394
404
  private loadInstalled;
@@ -33,9 +33,9 @@
33
33
  */
34
34
  import { createHash } from 'node:crypto';
35
35
  import { createReadStream } from 'node:fs';
36
- import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
36
+ import { link, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
37
37
  import { basename, join, resolve } from 'node:path';
38
- import { createLogger, llamaCppBackendExclusion, llamaCppBackendLabel } from '@bendyline/gezel';
38
+ import { createLogger, llamaCppBackendExclusion, llamaCppBackendLabel, llamaCppDraftModelApplies, } from '@bendyline/gezel';
39
39
  import { listBundleModelFiles, publishStagedModel, safeBundleModelPath, } from '../../models/bundle-storage.js';
40
40
  import { evaluateCatalogDrift } from '../../models/catalog-drift.js';
41
41
  import { catalogPayloadFingerprint, describeCatalogPayload, } from '../../models/catalog-payload-identity.js';
@@ -291,6 +291,7 @@ export class LlamaCppModelManager {
291
291
  modelDir,
292
292
  manifest: detail.manifest,
293
293
  installedVersion,
294
+ llamaCppBackend: this.resolveBackend(),
294
295
  installed: {
295
296
  ...(parsed.huggingfaceRepo ? { huggingfaceRepo: parsed.huggingfaceRepo } : {}),
296
297
  ...(parsed.payloadFingerprint ? { payloadFingerprint: parsed.payloadFingerprint } : {}),
@@ -474,7 +475,7 @@ export class LlamaCppModelManager {
474
475
  await assertModelStorePathSafe(this.modelsRoot, itemDir);
475
476
  await mkdir(itemDir, { recursive: true });
476
477
  await assertModelStorePathSafe(this.modelsRoot, itemDir);
477
- const plan = planDownloads(src, includeMmproj);
478
+ const plan = planDownloads(src, includeMmproj, this.resolveBackend());
478
479
  if (plan.length === 0) {
479
480
  yield {
480
481
  type: 'error',
@@ -495,6 +496,7 @@ export class LlamaCppModelManager {
495
496
  // the start, keep their digests, and are left untouched by the publish
496
497
  // rename below.
497
498
  const reusable = await this.planReusableFiles(itemDir, plan);
499
+ await this.adoptSiblingDraftModels(catalogId, itemDir, plan, reusable);
498
500
  let bytesCompleted = 0;
499
501
  for (const entry of plan) {
500
502
  const sha = reusable.get(entry.destFilename);
@@ -709,7 +711,7 @@ export class LlamaCppModelManager {
709
711
  // that only edits metadata can be recognized as such without re-reading a
710
712
  // byte. Skipped for a skipSha install: those bytes are knowingly not the
711
713
  // ones the catalog describes.
712
- const catalogPayload = describeCatalogPayload(manifest, this.engine);
714
+ const catalogPayload = describeCatalogPayload(manifest, this.engine, this.resolveBackend());
713
715
  const payloadFingerprint = catalogPayload && !skipSha ? catalogPayloadFingerprint(catalogPayload) : undefined;
714
716
  // Recorded next to the catalog's tag, never instead of it: the catalog
715
717
  // names the build a user chose, the header names what the bytes are.
@@ -795,6 +797,48 @@ export class LlamaCppModelManager {
795
797
  }
796
798
  return reusable;
797
799
  }
800
+ /**
801
+ * A drafter is trained against a model, not a quant, so every quant of that
802
+ * model pins the same file. Link a copy another installed quant verified
803
+ * rather than downloading a gigabyte again. A hard link keeps each model's
804
+ * directory complete on its own: deleting one quant leaves the other's
805
+ * drafter in place. A volume that cannot link just downloads.
806
+ */
807
+ async adoptSiblingDraftModels(catalogId, itemDir, plan, reusable) {
808
+ const drafters = plan.filter((entry) => entry.role === 'draft-model' && !reusable.has(entry.destFilename));
809
+ if (drafters.length === 0)
810
+ return;
811
+ const siblings = await readdir(this.modelsRoot).catch(() => []);
812
+ for (const entry of drafters) {
813
+ for (const sibling of siblings) {
814
+ if (sibling === catalogId)
815
+ continue;
816
+ const siblingDir = join(this.modelsRoot, sibling);
817
+ const recorded = await readFile(join(siblingDir, 'manifest.json'), 'utf8')
818
+ .then((raw) => JSON.parse(raw).fileSha256)
819
+ .catch(() => undefined);
820
+ if (recorded?.[entry.destFilename]?.toLowerCase() !== entry.sha256.toLowerCase())
821
+ continue;
822
+ const source = join(siblingDir, entry.destFilename);
823
+ const size = await stat(source)
824
+ .then((info) => (info.isFile() ? info.size : -1))
825
+ .catch(() => -1);
826
+ if (size !== entry.sizeBytes)
827
+ continue;
828
+ const dest = join(itemDir, entry.destFilename);
829
+ try {
830
+ await rm(dest, { force: true });
831
+ await link(source, dest);
832
+ }
833
+ catch {
834
+ continue;
835
+ }
836
+ reusable.set(entry.destFilename, entry.sha256.toLowerCase());
837
+ log.info(`[models] [${this.engine}] "${catalogId}": linked ${entry.destFilename} from "${sibling}"`);
838
+ break;
839
+ }
840
+ }
841
+ }
798
842
  /** Warn once per model directory per process, then stay quiet. */
799
843
  warnSkip(id, reason) {
800
844
  if (this.skipWarned.has(id))
@@ -854,6 +898,11 @@ export class LlamaCppModelManager {
854
898
  .then((s) => s.size)
855
899
  .catch(() => 0)
856
900
  : 0;
901
+ const draftModelSizeBytes = parsed.draftModelFilename
902
+ ? await stat(join(root, id, parsed.draftModelFilename))
903
+ .then((s) => s.size)
904
+ .catch(() => 0)
905
+ : 0;
857
906
  // Absent on every copy installed before the field existed, and on those
858
907
  // the catalog tag is the only thing the table has to show.
859
908
  const ggufQuantization = parsed.ggufQuantization ??
@@ -884,7 +933,10 @@ export class LlamaCppModelManager {
884
933
  }
885
934
  : {}),
886
935
  ...(parsed.draftModelFilename
887
- ? { draftModelPath: join(root, id, parsed.draftModelFilename) }
936
+ ? {
937
+ draftModelPath: join(root, id, parsed.draftModelFilename),
938
+ ...(draftModelSizeBytes > 0 ? { draftModelSizeBytes } : {}),
939
+ }
888
940
  : {}),
889
941
  ...(parsed.contextWindow ? { contextWindow: parsed.contextWindow } : {}),
890
942
  ...(parsed.quantization ? { quantization: parsed.quantization } : {}),
@@ -928,7 +980,9 @@ function planDownloads(src,
928
980
  * This option only governs llama.cpp's `mmproj`; a declared ds4
929
981
  * `visionEncoder` is part of that model's install and is always fetched.
930
982
  */
931
- includeMmproj = true) {
983
+ includeMmproj = true,
984
+ /** Decides a drafter limited to some llama-server backends. */
985
+ backend) {
932
986
  const out = [];
933
987
  if (src.shards && src.shards.length > 0) {
934
988
  src.shards.forEach((shard, i) => {
@@ -976,10 +1030,10 @@ includeMmproj = true) {
976
1030
  });
977
1031
  }
978
1032
  // Draft companions are part of the model's selected decoding path, not
979
- // an optional modality. Download them whenever the catalog ships one so
980
- // an explicitly selected `draft-mtp`/other draft mode can work immediately
981
- // after install without a second download.
982
- if (src.draftModel) {
1033
+ // an optional modality. Download them whenever the catalog ships one for
1034
+ // this backend, so an explicitly selected `draft-mtp`/other draft mode can
1035
+ // work immediately after install without a second download.
1036
+ if (src.draftModel && llamaCppDraftModelApplies(src.draftModel, backend)) {
983
1037
  out.push({
984
1038
  ...(src.draftModel.huggingfaceRepo
985
1039
  ? { huggingfaceRepo: src.draftModel.huggingfaceRepo }
@@ -94,6 +94,12 @@ export interface MoeOffloadInput {
94
94
  * consulted on the exact path; 0 / absent = weights-only budgeting.
95
95
  */
96
96
  kvReserveBytes?: number;
97
+ /**
98
+ * Weights outside the GGUF split that stay on the GPU, such as a
99
+ * speculative drafter. The exact path adds them to its reserves;
100
+ * `residentBytes` already counts them.
101
+ */
102
+ additionalGpuBytes?: number;
97
103
  }
98
104
  export interface MoeOffloadDecision {
99
105
  /** `--n-gpu-layers` (−1 = all). Undefined = leave to the engine. */
@@ -167,7 +167,7 @@ function planFromSplit(input, split, margin) {
167
167
  const kvReserve = Math.max(0, input.kvReserveBytes ?? 0);
168
168
  const expertTotal = split.expertBytesByLayer.reduce((sum, v) => sum + v, 0);
169
169
  const weightsTotal = split.nonExpertBytes + expertTotal;
170
- const reserves = margin + kvReserve + COMPUTE_RESERVE_BYTES;
170
+ const reserves = margin + kvReserve + COMPUTE_RESERVE_BYTES + Math.max(0, input.additionalGpuBytes ?? 0);
171
171
  if (weightsTotal + reserves <= input.vramBytes) {
172
172
  return {
173
173
  fullGpuResidency: true,
@@ -1,37 +1,59 @@
1
- /**
2
- * Speculative-decoding draft resolution, extracted from the chat/manager
3
- * launcher so it can be unit-tested in isolation.
4
- *
5
- * A model manifest names its `draft-simple` draft by CATALOG id
6
- * (`spec.draftModelId`) so it stays portable across installs — a user's GGUF
7
- * path differs from any hardcoded one. But engine-flags passes
8
- * `--spec-draft-model` verbatim and a `draft-simple` spec with no draft model
9
- * is a FATAL llama-server launch, so the id must be resolved to the installed
10
- * GGUF path (or the spec disabled) BEFORE the argv is built. This module owns
11
- * exactly that decision:
12
- *
13
- * - global `config.llamaCppDraftModelPath` set → operator's own path wins,
14
- * nothing to resolve.
15
- * - manifest draft resolves to an installed path → rewrite `draftModelId`
16
- * to that path.
17
- * - manifest-driven spec whose draft isn't installed → STRIP the spec so
18
- * the launch falls back to plain decoding instead of crashing. The
19
- * +decode win is opportunistic on the draft being present, never a hard
20
- * dependency.
21
- * - operator explicitly forced `config.llamaCppSpecType=draft-simple` but
22
- * no draft path resolves → leave it and warn; the operator owns it.
23
- */
24
1
  import type { PerModelLlamaCppEngineConfig } from './engine-flags.js';
25
2
  export interface SpecDraftResolveInput {
26
3
  /** The catalog manifest's `tuning.engine.llamaCpp` block, if any. */
27
4
  perModel: PerModelLlamaCppEngineConfig | undefined;
5
+ /** The llama-server build's backend, for the manifest's `spec.byBackend`. */
6
+ backend?: string | undefined;
28
7
  /** Global `config.llamaCppSpecType`, if the operator set one. */
29
8
  configSpecType?: string | undefined;
30
9
  /** Global `config.llamaCppDraftModelPath`, if the operator set one. */
31
10
  configDraftModelPath?: string | undefined;
11
+ /** The catalog `draftModel` sidecar on disk beside the weights, if any. */
12
+ installedDraftModelPath?: string | undefined;
32
13
  /** Resolve a catalog draft id → installed GGUF path (null if not installed). */
33
14
  resolveDraftPath: (id: string) => Promise<string | null>;
34
15
  }
16
+ /**
17
+ * Whether the sidecar beside the weights is what this launch loads. A separate
18
+ * MTP assistant loads whenever MTP runs. A DFlash drafter sits beside a target
19
+ * that may have its own head, or on a backend its catalog entry did not turn it
20
+ * on for; handing it to MTP through `--spec-draft-model` would load the wrong
21
+ * file. `perBackend` is the manifest carrying a `spec.byBackend` table.
22
+ */
23
+ export declare function llamaCppSidecarLoads(input: {
24
+ specType: string | undefined;
25
+ targetHasMtp: boolean;
26
+ perBackend: boolean;
27
+ }): boolean;
28
+ /**
29
+ * Engine and drafter pairs that took an engine down, so a later launch in this
30
+ * daemon goes without instead of failing the same way first. Keyed by both,
31
+ * because a drafter that one engine build cannot load may suit the next.
32
+ */
33
+ export declare const brokenDrafters: Set<string>;
34
+ /** The parts of a KV plan a drafter must not change. */
35
+ export interface DrafterKvPlan {
36
+ kvCacheType: string;
37
+ ctxCapTokens?: number | undefined;
38
+ }
39
+ /**
40
+ * Whether a drafter would cost the session something it runs out of. The
41
+ * target window and KV precision outlast a faster decode: a drafter that
42
+ * makes the plan cap the target, drop to a coarser KV cache, or stop fitting
43
+ * at all is left out. Adaptive growth above the target and slots pay for it,
44
+ * as they do for every resident cost.
45
+ */
46
+ export declare function drafterCostsContext(withDrafter: DrafterKvPlan, withoutDrafter: DrafterKvPlan, fitsWithDrafter: boolean): boolean;
47
+ /**
48
+ * Whether a drafter would push the launch past the graphics memory it has.
49
+ * `gpuPoolBytes` is what a discrete card leaves this launch now (free memory
50
+ * as device admission measures it, within the card's usable share), and
51
+ * undefined elsewhere. Checked against the card's total alone, a drafter
52
+ * stayed while admission refused the model it was meant to speed up: the
53
+ * 74B Bolt and the 27B on a 32 GB card beside a desktop holding 6.5 GB, both
54
+ * of which start without it (2026-11-07).
55
+ */
56
+ export declare function drafterCostsGpuResidency(withDrafterBytes: number, gpuPoolBytes: number | undefined): boolean;
35
57
  export interface SpecDraftResolution {
36
58
  /** The perModel to hand to `buildLlamaCppEngineArgs` (spec rewritten or stripped). */
37
59
  perModel: PerModelLlamaCppEngineConfig | undefined;
@@ -1,44 +1,133 @@
1
+ /**
2
+ * Speculative-decoding draft resolution, extracted from the chat/manager
3
+ * launcher so it can be unit-tested in isolation.
4
+ *
5
+ * A catalog entry can ask for a separate draft GGUF two ways: a `draftModel`
6
+ * sidecar installed beside the weights, or a `spec.draftModelId` naming another
7
+ * catalog model. engine-flags passes `--spec-draft-model` verbatim, and a draft
8
+ * algorithm with no draft model is a FATAL llama-server launch, so this has to
9
+ * be settled BEFORE the argv is built. This module owns that decision, and
10
+ * applies the catalog's per-backend overlay first so everything downstream of
11
+ * it reads one spec:
12
+ *
13
+ * - global `config.llamaCppDraftModelPath` set → operator's own path wins,
14
+ * nothing to resolve.
15
+ * - sidecar installed → use it.
16
+ * - manifest draft id resolves to an installed path → rewrite `draftModelId`
17
+ * to that path.
18
+ * - manifest-driven spec with no draft on disk → fall back so the launch
19
+ * decodes the way it would without the drafter instead of crashing. A live
20
+ * Gilde update reaches this: the new manifest takes effect at once, and the
21
+ * drafter arrives only when the person updates the model. The +decode win
22
+ * is opportunistic on the draft being present, never a hard dependency.
23
+ * - operator explicitly forced a draft `config.llamaCppSpecType` but no draft
24
+ * path resolves → leave it and warn; the operator owns it.
25
+ */
26
+ import { llamaCppSpecForBackend, llamaCppSpecNeedsDraftModel } from '@bendyline/gezel';
27
+ /**
28
+ * Whether the sidecar beside the weights is what this launch loads. A separate
29
+ * MTP assistant loads whenever MTP runs. A DFlash drafter sits beside a target
30
+ * that may have its own head, or on a backend its catalog entry did not turn it
31
+ * on for; handing it to MTP through `--spec-draft-model` would load the wrong
32
+ * file. `perBackend` is the manifest carrying a `spec.byBackend` table.
33
+ */
34
+ export function llamaCppSidecarLoads(input) {
35
+ const { specType, targetHasMtp, perBackend } = input;
36
+ if (llamaCppSpecNeedsDraftModel(specType))
37
+ return true;
38
+ if (specType && specType !== 'draft-mtp')
39
+ return false;
40
+ return !targetHasMtp && !perBackend;
41
+ }
42
+ /**
43
+ * Engine and drafter pairs that took an engine down, so a later launch in this
44
+ * daemon goes without instead of failing the same way first. Keyed by both,
45
+ * because a drafter that one engine build cannot load may suit the next.
46
+ */
47
+ export const brokenDrafters = new Set();
48
+ /**
49
+ * Whether a drafter would cost the session something it runs out of. The
50
+ * target window and KV precision outlast a faster decode: a drafter that
51
+ * makes the plan cap the target, drop to a coarser KV cache, or stop fitting
52
+ * at all is left out. Adaptive growth above the target and slots pay for it,
53
+ * as they do for every resident cost.
54
+ */
55
+ export function drafterCostsContext(withDrafter, withoutDrafter, fitsWithDrafter) {
56
+ return (!fitsWithDrafter ||
57
+ withDrafter.kvCacheType !== withoutDrafter.kvCacheType ||
58
+ withDrafter.ctxCapTokens !== withoutDrafter.ctxCapTokens);
59
+ }
60
+ /**
61
+ * Whether a drafter would push the launch past the graphics memory it has.
62
+ * `gpuPoolBytes` is what a discrete card leaves this launch now (free memory
63
+ * as device admission measures it, within the card's usable share), and
64
+ * undefined elsewhere. Checked against the card's total alone, a drafter
65
+ * stayed while admission refused the model it was meant to speed up: the
66
+ * 74B Bolt and the 27B on a 32 GB card beside a desktop holding 6.5 GB, both
67
+ * of which start without it (2026-11-07).
68
+ */
69
+ export function drafterCostsGpuResidency(withDrafterBytes, gpuPoolBytes) {
70
+ return gpuPoolBytes !== undefined && withDrafterBytes > gpuPoolBytes;
71
+ }
72
+ /** The base spec with no overlay, or `perModel` with no spec at all. */
73
+ function withoutOverlay(perModel) {
74
+ const base = llamaCppSpecForBackend(perModel.spec, undefined);
75
+ return base ? { ...perModel, spec: base } : perModel;
76
+ }
77
+ function withoutSpec(perModel) {
78
+ const { spec: _spec, ...rest } = perModel;
79
+ return rest;
80
+ }
1
81
  export async function resolveSpecDraft(input) {
2
- const { perModel, configSpecType, configDraftModelPath, resolveDraftPath } = input;
3
- const effectiveSpecType = configSpecType ?? perModel?.spec?.type;
4
- // Only the manifest-draftModelId path needs resolving. An explicit global
5
- // draft path, a non-draft-simple spec, or no draft id → nothing to do.
6
- if (effectiveSpecType !== 'draft-simple' ||
7
- !perModel?.spec?.draftModelId ||
8
- configDraftModelPath) {
82
+ const { perModel, backend, configSpecType, configDraftModelPath } = input;
83
+ const { installedDraftModelPath, resolveDraftPath } = input;
84
+ if (!perModel?.spec)
9
85
  return { perModel, log: null };
86
+ const overlay = backend ? perModel.spec.byBackend?.[backend] : undefined;
87
+ // A person who forced another algorithm has not asked for this overlay's
88
+ // tuning: MTP must not inherit a sampling mode chosen for a drafter.
89
+ const overlayStands = !configSpecType || !overlay?.type || overlay.type === configSpecType;
90
+ const spec = llamaCppSpecForBackend(perModel.spec, overlayStands ? backend : undefined);
91
+ const applied = perModel.spec.byBackend ? { ...perModel, spec } : perModel;
92
+ const effectiveSpecType = configSpecType ?? spec?.type;
93
+ if (!llamaCppSpecNeedsDraftModel(effectiveSpecType) ||
94
+ configDraftModelPath ||
95
+ installedDraftModelPath) {
96
+ return { perModel: applied, log: null };
10
97
  }
11
- const draftId = perModel.spec.draftModelId;
12
- const draftPath = await resolveDraftPath(draftId).catch(() => null);
13
- if (draftPath) {
98
+ const draftId = spec?.draftModelId;
99
+ const draftPath = draftId ? await resolveDraftPath(draftId).catch(() => null) : null;
100
+ if (draftPath && spec) {
14
101
  return {
15
- perModel: { ...perModel, spec: { ...perModel.spec, draftModelId: draftPath } },
102
+ perModel: { ...applied, spec: { ...spec, draftModelId: draftPath } },
16
103
  log: {
17
104
  level: 'info',
18
105
  message: `[llama-cpp] speculative decoding: draft "${draftId}" → ${draftPath}`,
19
106
  },
20
107
  };
21
108
  }
22
- if (!configSpecType) {
23
- // Manifest-driven spec, draft absent → strip so we don't fatal-launch a
24
- // draft-simple with no draft model.
25
- const { spec: _spec, ...rest } = perModel;
109
+ const name = draftId ? `"${draftId}"` : 'sidecar';
110
+ if (configSpecType) {
111
+ // Operator forced the spec type via global config but gave no resolvable
112
+ // draft — leave it and let llama-server surface the error rather than
113
+ // silently overriding an explicit request.
26
114
  return {
27
- perModel: rest,
115
+ perModel: applied,
28
116
  log: {
29
- level: 'info',
30
- message: `[llama-cpp] speculative decoding (manifest draft "${draftId}") skipped: draft model not installed; running without it.`,
117
+ level: 'warn',
118
+ message: `[llama-cpp] config.llamaCppSpecType=${configSpecType} but draft ${name} is not installed and no llamaCppDraftModelPath is set; the launch may fail.`,
31
119
  },
32
120
  };
33
121
  }
34
- // Operator forced the spec type via global config but gave no resolvable
35
- // draft — leave it and let llama-server surface the error rather than
36
- // silently overriding an explicit request.
122
+ // Manifest-driven spec, draft absent → the overlay's base if that decodes
123
+ // without one, else no spec at all.
124
+ const base = overlay ? withoutOverlay(perModel) : undefined;
125
+ const fallback = base && !llamaCppSpecNeedsDraftModel(base.spec?.type) ? base : withoutSpec(perModel);
37
126
  return {
38
- perModel,
127
+ perModel: fallback,
39
128
  log: {
40
- level: 'warn',
41
- message: `[llama-cpp] config.llamaCppSpecType=draft-simple but draft "${draftId}" is not installed and no llamaCppDraftModelPath is set; the launch may fail.`,
129
+ level: 'info',
130
+ message: `[llama-cpp] speculative decoding (manifest draft ${name}) skipped: draft model not installed; running without it.`,
42
131
  },
43
132
  };
44
133
  }
@@ -682,6 +682,21 @@ def abort_verify_round(verify) -> None:
682
682
  abort()
683
683
 
684
684
 
685
+ def shared_kv_after_round(verify, rejected: int, slice_after_reject):
686
+ """The drafter's shared-KV view for the next round.
687
+
688
+ mlx-vlm 0.7.12 lets a target rebuild that view from its committed caches
689
+ (Gemma 4's ``shared_kv_from_cache``): ``verify.commit`` then empties the
690
+ verify window and records ``committed_shared_kv``. Slicing the emptied
691
+ window would hand the drafter no context, which costs acceptance, never
692
+ exactness. Targets without the hook (the Qwen 3.5 family) keep the slice.
693
+ """
694
+ committed = getattr(verify, "committed_shared_kv", None)
695
+ if committed is not None:
696
+ return committed
697
+ return slice_after_reject(verify.shared_kv_states, rejected)
698
+
699
+
685
700
  def assisted_rounds(model, spec: SpecState, cache, hidden_tokens: List[int],
686
701
  last_output, first_bonus: int, max_tokens: int, *,
687
702
  sampler, processors, history):
@@ -734,9 +749,13 @@ def assisted_rounds(model, spec: SpecState, cache, hidden_tokens: List[int],
734
749
  hidden = _mtp_draft_hidden(lm, hidden)
735
750
 
736
751
  kv_offset = _mtp_cache_offset_max(cache)
752
+ rebuild_shared_kv = getattr(lm, "shared_kv_from_cache", None)
753
+ if callable(rebuild_shared_kv):
754
+ shared_kv = rebuild_shared_kv(cache)
737
755
  drafter.set_shared_kv(
738
756
  shared_kv, kv_offset, position=_mtp_draft_position(kv_offset), kv_valid_len=kv_offset
739
757
  )
758
+ release_shared_kv = getattr(drafter, "release_shared_kv", None)
740
759
 
741
760
  b = int(first_bonus)
742
761
  emitted = 1
@@ -753,6 +772,8 @@ def assisted_rounds(model, spec: SpecState, cache, hidden_tokens: List[int],
753
772
  b, hidden, None, bs, draw, token_dtype, **draft_kwargs
754
773
  )
755
774
  mx.async_eval(draft_tokens)
775
+ if callable(release_shared_kv):
776
+ release_shared_kv()
756
777
 
757
778
  verify_input = mx.concatenate(
758
779
  [mx.array([[b]], dtype=token_dtype), draft_tokens], axis=1
@@ -806,8 +827,8 @@ def assisted_rounds(model, spec: SpecState, cache, hidden_tokens: List[int],
806
827
  hidden = _mtp_draft_hidden(lm, verify.hidden[:, accepted : accepted + 1, :])
807
828
  b = new_tokens[-1] if new_tokens else b
808
829
 
809
- shared_kv = _slice_shared_kv_after_reject(
810
- verify.shared_kv_states, bs - (accepted + 1)
830
+ shared_kv = shared_kv_after_round(
831
+ verify, bs - (accepted + 1), _slice_shared_kv_after_reject
811
832
  )
812
833
  kv_offset += accepted + 1
813
834
  drafter.set_shared_kv(