@bendyline/gezel-inference 1.0.7 → 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 (78) 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/http/routes/v1-images.d.ts +2 -1
  23. package/dist/http/routes/v1-images.js +36 -9
  24. package/dist/knowledge/manager.d.ts +3 -27
  25. package/dist/knowledge/manager.js +8 -364
  26. package/dist/knowledge/unified-search.d.ts +61 -0
  27. package/dist/knowledge/unified-search.js +373 -0
  28. package/dist/models/catalog-drift.d.ts +2 -0
  29. package/dist/models/catalog-drift.js +1 -1
  30. package/dist/models/catalog-payload-identity.d.ts +2 -2
  31. package/dist/models/catalog-payload-identity.js +10 -5
  32. package/dist/providers/active-install-message.js +1 -1
  33. package/dist/providers/catalog-model-config.d.ts +28 -0
  34. package/dist/providers/ds4/build-provider.js +35 -13
  35. package/dist/providers/ds4/residency.d.ts +77 -0
  36. package/dist/providers/ds4/residency.js +87 -6
  37. package/dist/providers/image/sd-cpp.js +19 -11
  38. package/dist/providers/llama-cpp/backend-health.d.ts +3 -2
  39. package/dist/providers/llama-cpp/backend-health.js +6 -3
  40. package/dist/providers/llama-cpp/backend-quarantine.js +1 -1
  41. package/dist/providers/llama-cpp/build-provider.js +168 -29
  42. package/dist/providers/llama-cpp/devices.js +4 -1
  43. package/dist/providers/llama-cpp/engine-flags.d.ts +8 -1
  44. package/dist/providers/llama-cpp/engine-flags.js +13 -8
  45. package/dist/providers/llama-cpp/gpu-startup-watch.d.ts +4 -4
  46. package/dist/providers/llama-cpp/gpu-startup-watch.js +7 -5
  47. package/dist/providers/llama-cpp/models.d.ts +10 -0
  48. package/dist/providers/llama-cpp/models.js +64 -10
  49. package/dist/providers/llama-cpp/offload-planner.d.ts +6 -0
  50. package/dist/providers/llama-cpp/offload-planner.js +1 -1
  51. package/dist/providers/llama-cpp/spec-draft.d.ts +45 -23
  52. package/dist/providers/llama-cpp/spec-draft.js +113 -24
  53. package/dist/providers/mlx/async-file-handoff.d.ts +9 -1
  54. package/dist/providers/mlx/async-file-handoff.js +32 -4
  55. package/dist/providers/mlx/provider.js +28 -16
  56. package/dist/providers/mlx/python/spec_decode.py +23 -2
  57. package/dist/providers/mlx/python/tool_args_json.py +59 -21
  58. package/dist/providers/mlx/runtime-diagnostics.d.ts +6 -0
  59. package/dist/providers/mlx/runtime-diagnostics.js +18 -1
  60. package/dist/providers/mlx/tool-call-protocol.d.ts +4 -3
  61. package/dist/providers/mlx/tool-call-protocol.js +4 -3
  62. package/dist/providers/mlx/venv.d.ts +19 -1
  63. package/dist/providers/mlx/venv.js +21 -2
  64. package/dist/providers/native/device-capacity-ledger.d.ts +4 -2
  65. package/dist/providers/native/device-capacity-ledger.js +35 -20
  66. package/dist/providers/native/device-capacity.d.ts +4 -0
  67. package/dist/providers/native/device-capacity.js +5 -0
  68. package/dist/providers/native/local-engine-runtime.d.ts +16 -1
  69. package/dist/providers/native/local-engine-runtime.js +142 -39
  70. package/dist/providers/native/process-identity.d.ts +38 -0
  71. package/dist/providers/native/process-identity.js +86 -0
  72. package/dist/providers/native/supervisor.d.ts +8 -1
  73. package/dist/providers/native/supervisor.js +16 -7
  74. package/dist/providers/types.d.ts +4 -0
  75. package/dist/remotes/types.d.ts +1 -1
  76. package/dist/runtime-lock.d.ts +3 -27
  77. package/dist/runtime-lock.js +7 -80
  78. package/package.json +4 -4
@@ -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
  }
@@ -7,5 +7,13 @@
7
7
  * recipients.
8
8
  */
9
9
  export declare function isSuccessfulAsyncHandoff(toolName: string, output: string): boolean;
10
- export declare function asyncHandoffClosing(count: number): string;
10
+ /** The gezel a successful handoff result names, when it names one. */
11
+ export declare function asyncHandoffRecipient(output: string): string | undefined;
12
+ /**
13
+ * The reply the person sees when the runtime ends a delegating turn, which the
14
+ * model also reads back as its own last message. The tool result already told
15
+ * the model to end its turn, so this line only says what happened. Each entry
16
+ * is one handoff's recipient, or undefined when its result named nobody.
17
+ */
18
+ export declare function asyncHandoffClosing(recipients: readonly (string | undefined)[]): string;
11
19
  export declare function immediateFileWriteClosing(paths: string[]): string;
@@ -10,10 +10,38 @@ export function isSuccessfulAsyncHandoff(toolName, output) {
10
10
  return (!output.startsWith('ERROR:') &&
11
11
  (toolName === 'message_gezel' || toolName.startsWith('delegate_')));
12
12
  }
13
- export function asyncHandoffClosing(count) {
14
- return count === 1
15
- ? 'I sent the handoff and ended my turn so the recipient can use the provider queue. Their reply will arrive asynchronously.'
16
- : `I sent ${count} handoffs and ended my turn so the recipients can use the provider queue. Their replies will arrive asynchronously.`;
13
+ /**
14
+ * The handoff results (core's `messageGezelText`, the MCP `delegate_*` tools)
15
+ * name the recipient after a fixed lead-in and again as "<name>'s turn".
16
+ * Requiring both means a reworded result, or a name holding a period, gives
17
+ * no name rather than half of one.
18
+ */
19
+ const HANDOFF_RECIPIENT = /(?:Accepted the (?:message|handoff) for|Dispatched the (?:message|handoff) to|An identical file handoff is already pending with) (.+?)(?= \(|[.;])[\s\S]*?\1's turn/;
20
+ /** The gezel a successful handoff result names, when it names one. */
21
+ export function asyncHandoffRecipient(output) {
22
+ return HANDOFF_RECIPIENT.exec(output.slice(0, 2_000))?.[1]?.trim() || undefined;
23
+ }
24
+ function joinNames(names) {
25
+ if (names.length <= 2)
26
+ return names.join(' and ');
27
+ return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`;
28
+ }
29
+ /**
30
+ * The reply the person sees when the runtime ends a delegating turn, which the
31
+ * model also reads back as its own last message. The tool result already told
32
+ * the model to end its turn, so this line only says what happened. Each entry
33
+ * is one handoff's recipient, or undefined when its result named nobody.
34
+ */
35
+ export function asyncHandoffClosing(recipients) {
36
+ const named = [...new Set(recipients.filter((name) => Boolean(name)))];
37
+ if (named.length > 0 && !recipients.includes(undefined)) {
38
+ return named.length === 1
39
+ ? `${named[0]} is on it. Their reply will appear here.`
40
+ : `${joinNames(named)} are on it. Their replies will appear here.`;
41
+ }
42
+ return recipients.length > 1
43
+ ? "I've passed this on. The replies will appear here."
44
+ : "I've passed this on. The reply will appear here.";
17
45
  }
18
46
  export function immediateFileWriteClosing(paths) {
19
47
  const unique = [...new Set(paths.filter((path) => path.trim().length > 0))];
@@ -51,7 +51,7 @@ import { coerceToolCallArgs } from '../tool-arg-schema-coercion.js';
51
51
  import { createToolExecutor } from '../tool-executor.js';
52
52
  import { ToolFailureTracker } from '../tool-failure-tracker.js';
53
53
  import { ToolRepeatTracker } from '../tool-repeat-tracker.js';
54
- import { asyncHandoffClosing, immediateFileWriteClosing, isSuccessfulAsyncHandoff, } from './async-file-handoff.js';
54
+ import { asyncHandoffClosing, asyncHandoffRecipient, immediateFileWriteClosing, isSuccessfulAsyncHandoff, } from './async-file-handoff.js';
55
55
  export { isSuccessfulAsyncHandoff };
56
56
  import { setChatTemplateKwarg, } from './chat-protocol.js';
57
57
  import { MlxEngineGate } from './engine-gate.js';
@@ -60,7 +60,7 @@ import { EngineLogRouter } from './engine-log-router.js';
60
60
  import { MlxEngineStatsSampler } from './engine-stats-sampler.js';
61
61
  import { REASONING_OPEN_HEADER, StreamingReasoningSplit, engineOpensReasoning, reopenTemplateReasoning, requestOpensReasoning, } from './reasoning-stream.js';
62
62
  import { applyMlxRequestShape } from './request-shape.js';
63
- import { PRE_FIRST_BYTE_BASE_MS, buildMidStreamDropMessage, buildPreFirstByteAbortMessage, computePreFirstByteBudgetMs, estimatePromptTokens, formatTps, isMidStreamConnectionDrop, mlxHttpError, } from './runtime-diagnostics.js';
63
+ import { MLX_IMAGE_RESERVED_CHARS, PRE_FIRST_BYTE_BASE_MS, buildMidStreamDropMessage, buildPreFirstByteAbortMessage, computePreFirstByteBudgetMs, estimatePromptTokens, formatTps, isMidStreamConnectionDrop, mlxHttpError, } from './runtime-diagnostics.js';
64
64
  import { classifyMlxFatalErrorLine, classifyMlxStartupLine, } from './stdout-parser.js';
65
65
  import { salvageTextualToolCalls } from './textual-tool-call-salvage.js';
66
66
  import { APPEND_TO_FILE_CONTINUATION_TOOL, MlxToolCallAccumulator, applyCallableToolGrammar, callableRequestToolNames, chatCompletionToolName, externalToolsAsChatCompletions, hermesRequiredArgGrammarRequested, missingTopLevelRequiredToolArgs, toChatCompletionsTools, validatorReportedMissingRequiredArgs, } from './tool-call-protocol.js';
@@ -646,7 +646,7 @@ class MlxSession extends StreamingSessionBase {
646
646
  total += m.content.length;
647
647
  // The sidecar bounds images to 1024px. Reserve patch tokens without
648
648
  // counting base64 bytes as language tokens or treating pixels as free.
649
- total += (m.images?.length ?? 0) * 8192;
649
+ total += (m.images?.length ?? 0) * MLX_IMAGE_RESERVED_CHARS;
650
650
  if (m.tool_calls) {
651
651
  for (const tc of m.tool_calls)
652
652
  total += tc.function.arguments.length + tc.function.name.length;
@@ -1427,7 +1427,8 @@ class MlxSession extends StreamingSessionBase {
1427
1427
  // - STREAMING_IDLE_MS: budget between two consecutive SSE
1428
1428
  // content events once tokens are flowing. Generous enough
1429
1429
  // to cover a heavy reasoning gap on a 26B model.
1430
- const approxPromptTokens = estimatePromptTokens(this.messages, body.tools);
1430
+ // The request as sent: an image retired from it costs no prefill.
1431
+ const approxPromptTokens = estimatePromptTokens(body.messages, body.tools);
1431
1432
  const PRE_FIRST_BYTE_IDLE_MS = computePreFirstByteBudgetMs(approxPromptTokens);
1432
1433
  const PRE_FIRST_BYTE_PER_PROGRESS_MS = PRE_FIRST_BYTE_IDLE_MS;
1433
1434
  const STREAMING_IDLE_MS = 300_000;
@@ -2168,6 +2169,11 @@ class MlxSession extends StreamingSessionBase {
2168
2169
  });
2169
2170
  turnContent = salvaged.turnContent;
2170
2171
  const { proseRepaired, xmlTagRepaired, claudeInvokeRepaired, glmRepaired, hermesRepaired, shellRepaired, envelopeRepaired, envelopeTruncated, unknownCall, truncatedSalvage, bareInvokeRepaired, } = salvaged;
2172
+ // A prose-shaped call such as lookup_record({}) was ordinary TEXT
2173
+ // to the Hermes grammar, not a constrained tool envelope. Missing
2174
+ // arguments in that salvage cannot establish a decoding violation.
2175
+ // Keep provenance by id through coercion and duplicate collapse.
2176
+ const proseCallIds = new Set(proseRepaired.map((call) => call.id));
2171
2177
  const rawTurnContentBeforeReasoning = turnContent;
2172
2178
  // Reasoning-tag extraction applies regardless of which (if any)
2173
2179
  // salvage path fired. Qwen 3.5/3.6 leak `<think>…</think>`
@@ -2602,7 +2608,7 @@ class MlxSession extends StreamingSessionBase {
2602
2608
  tool_calls: toolCalls,
2603
2609
  });
2604
2610
  let abortDueToFailureLoop = null;
2605
- let asyncHandoffCount = 0;
2611
+ const asyncHandoffRecipients = [];
2606
2612
  let terminalActionClosing = null;
2607
2613
  const toolImages = [];
2608
2614
  const immediateFileWritePaths = [];
@@ -2701,16 +2707,21 @@ class MlxSession extends StreamingSessionBase {
2701
2707
  this.deps.bridges.hasTool(call.function.name)) {
2702
2708
  const missingRequired = missingTopLevelRequiredToolArgs(toolArgSchemas.get(call.function.name), args);
2703
2709
  const grammarContractViolation = missingRequired.length > 0 &&
2710
+ !proseCallIds.has(call.id) &&
2704
2711
  hermesRequiredArgGrammarRequested(body) &&
2705
2712
  !this.forceSequentialToolGrammar;
2706
- if (grammarContractViolation) {
2707
- this.forceSequentialToolGrammar = true;
2708
- log.warn(`turn#${seq}.${turn} required-argument grammar contract violation tool=${call.function.name} missing=${missingRequired.join(',')} — latching this session to sequential decode`);
2713
+ if (missingRequired.length > 0) {
2714
+ if (grammarContractViolation) {
2715
+ this.forceSequentialToolGrammar = true;
2716
+ log.warn(`turn#${seq}.${turn} required-argument grammar contract violation tool=${call.function.name} missing=${missingRequired.join(',')} — latching this session to sequential decode`);
2717
+ }
2709
2718
  // Do not send a call that the advertised schema proves cannot
2710
- // pass validation. Keeping it out of the bridge also avoids
2711
- // poisoning the session-wide unresolved-failure ledger before
2712
- // the sequential retry gets a chance to recover.
2713
- output = `ERROR: ${call.function.name} was not executed because the constrained decoder omitted required fields: ${missingRequired.join(', ')}. The runtime switched this session to sequential decoding; retry the call once.`;
2719
+ // pass validation, regardless of how it was parsed. A rejected
2720
+ // prose salvage gets ordinary corrective feedback and retains
2721
+ // speculation; native grammar violations keep the circuit breaker.
2722
+ output = grammarContractViolation
2723
+ ? `ERROR: ${call.function.name} was not executed because the constrained decoder omitted required fields: ${missingRequired.join(', ')}. The runtime switched this session to sequential decoding; retry the call once.`
2724
+ : `ERROR: ${call.function.name} was not executed because its arguments are missing required fields: ${missingRequired.join(', ')}. Retry the call with all declared required fields.`;
2714
2725
  }
2715
2726
  else {
2716
2727
  try {
@@ -2741,6 +2752,7 @@ class MlxSession extends StreamingSessionBase {
2741
2752
  // the advertised schema. Sequential decode may still recover on
2742
2753
  // the next iteration, and the log now makes the skew explicit.
2743
2754
  if (validatorReportedMissingRequiredArgs(output) &&
2755
+ !proseCallIds.has(call.id) &&
2744
2756
  hermesRequiredArgGrammarRequested(body) &&
2745
2757
  !this.forceSequentialToolGrammar) {
2746
2758
  this.forceSequentialToolGrammar = true;
@@ -2755,7 +2767,7 @@ class MlxSession extends StreamingSessionBase {
2755
2767
  askedQuestionThisTurn = true;
2756
2768
  }
2757
2769
  if (isSuccessfulAsyncHandoff(call.function.name, output)) {
2758
- asyncHandoffCount++;
2770
+ asyncHandoffRecipients.push(asyncHandoffRecipient(output));
2759
2771
  }
2760
2772
  if ((immediateFileWriteTurn || writeContinuationActive) &&
2761
2773
  (call.function.name === 'write_file' || call.function.name === 'append_to_file') &&
@@ -2930,12 +2942,12 @@ class MlxSession extends StreamingSessionBase {
2930
2942
  `${writeContinuations > 0 ? ` continuations=${writeContinuations}` : ''}`);
2931
2943
  return fullText;
2932
2944
  }
2933
- if (asyncHandoffCount > 0) {
2934
- const closingText = asyncHandoffClosing(asyncHandoffCount);
2945
+ if (asyncHandoffRecipients.length > 0) {
2946
+ const closingText = asyncHandoffClosing(asyncHandoffRecipients);
2935
2947
  this.messages.push({ role: 'assistant', content: closingText });
2936
2948
  fullText = closingText;
2937
2949
  log.info(`turn#${seq} END async-handoff-bail afterMs=${Date.now() - start} ` +
2938
- `handoffs=${asyncHandoffCount} loopTurns=${turn + 1}`);
2950
+ `handoffs=${asyncHandoffRecipients.length} loopTurns=${turn + 1}`);
2939
2951
  return fullText;
2940
2952
  }
2941
2953
  if (forceProjectMacroBail) {
@@ -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(