@bendyline/gezel-inference 1.0.0 → 1.0.2

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 (116) hide show
  1. package/README.md +90 -8
  2. package/dist/cache/adapter.d.ts +7 -0
  3. package/dist/cache/controller.d.ts +12 -0
  4. package/dist/cache/controller.js +24 -0
  5. package/dist/catalog/chat-models/bt/btl4-35b-q4/manifest.json +1 -0
  6. package/dist/catalog/chat-models/gl/glm-5.2-754b-q2/manifest.json +1 -0
  7. package/dist/catalog/chat-models/index.json +1 -1
  8. package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/manifest.json +122 -0
  9. package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/versions/1.0.0/manifest.json +15 -0
  10. package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/manifest.json +185 -0
  11. package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/versions/1.0.0/manifest.json +94 -0
  12. package/dist/catalog/chat-models/mi/minicpm5-1b-q4/manifest.json +1 -0
  13. package/dist/catalog/chat-models/mi/minicpm5-2b-q4/manifest.json +1 -0
  14. package/dist/catalog/chat-models/qw/qwen3.5-0.8b-q4/manifest.json +1 -0
  15. package/dist/catalog/chat-models/raw-index.json +1 -1
  16. package/dist/catalog/provenance.json +1 -1
  17. package/dist/engines/electron-native-reuse.d.ts +9 -0
  18. package/dist/engines/electron-native-reuse.js +146 -45
  19. package/dist/engines/native-file-manifest.json +404 -404
  20. package/dist/engines/native-manifest.d.ts +11 -3
  21. package/dist/engines/native-manifest.js +48 -18
  22. package/dist/http/openai-compat/apple-model.d.ts +2 -0
  23. package/dist/http/openai-compat/apple-model.js +21 -1
  24. package/dist/http/openai-compat/chat-target.d.ts +12 -0
  25. package/dist/http/openai-compat/chat-target.js +27 -1
  26. package/dist/http/openai-compat/engine-failure.d.ts +25 -0
  27. package/dist/http/openai-compat/engine-failure.js +76 -0
  28. package/dist/http/openai-compat/responses-streaming.js +2 -1
  29. package/dist/http/openai-compat/responses-translate.d.ts +1 -1
  30. package/dist/http/openai-compat/translate.d.ts +1 -1
  31. package/dist/http/routes/engines.js +44 -3
  32. package/dist/http/routes/v1-chat.js +30 -8
  33. package/dist/http/routes/v1-models.d.ts +6 -0
  34. package/dist/http/routes/v1-models.js +137 -30
  35. package/dist/http/routes/v1-responses.js +16 -3
  36. package/dist/in-flight-requests.d.ts +19 -0
  37. package/dist/in-flight-requests.js +34 -0
  38. package/dist/inference-service.js +3 -3
  39. package/dist/knowledge/catalog-host.d.ts +9 -1
  40. package/dist/knowledge/catalog-host.js +2 -0
  41. package/dist/knowledge/manager.d.ts +14 -0
  42. package/dist/knowledge/manager.js +53 -6
  43. package/dist/knowledge/search-worker.d.ts +1 -1
  44. package/dist/knowledge/spatial-query.d.ts +2 -3
  45. package/dist/knowledge/spatial-query.js +12 -6
  46. package/dist/knowledge/worker-host.js +2 -0
  47. package/dist/memory/embed-core.d.ts +16 -0
  48. package/dist/memory/embed-core.js +31 -0
  49. package/dist/memory/embed-worker.d.ts +7 -3
  50. package/dist/memory/embed-worker.js +13 -4
  51. package/dist/memory/embeddings.d.ts +17 -2
  52. package/dist/memory/embeddings.js +36 -7
  53. package/dist/models/ensure.d.ts +2 -0
  54. package/dist/models/ensure.js +9 -0
  55. package/dist/perf/responsiveness.d.ts +14 -1
  56. package/dist/perf/responsiveness.js +77 -7
  57. package/dist/providers/llama-cpp/backend-health.d.ts +30 -0
  58. package/dist/providers/llama-cpp/backend-health.js +64 -0
  59. package/dist/providers/llama-cpp/backend-quarantine.d.ts +40 -0
  60. package/dist/providers/llama-cpp/backend-quarantine.js +100 -0
  61. package/dist/providers/llama-cpp/build-provider.js +140 -118
  62. package/dist/providers/llama-cpp/cache-adapter.d.ts +12 -0
  63. package/dist/providers/llama-cpp/cache-adapter.js +36 -1
  64. package/dist/providers/llama-cpp/devices.d.ts +11 -2
  65. package/dist/providers/llama-cpp/devices.js +10 -4
  66. package/dist/providers/llama-cpp/engine-flags.d.ts +0 -18
  67. package/dist/providers/llama-cpp/engine-flags.js +12 -18
  68. package/dist/providers/llama-cpp/gguf-metadata-async.js +7 -0
  69. package/dist/providers/llama-cpp/gguf-metadata.js +20 -3
  70. package/dist/providers/llama-cpp/gpu-startup-watch.d.ts +41 -0
  71. package/dist/providers/llama-cpp/gpu-startup-watch.js +58 -0
  72. package/dist/providers/llama-cpp/models.d.ts +6 -0
  73. package/dist/providers/llama-cpp/models.js +19 -1
  74. package/dist/providers/llama-cpp/offload-planner.d.ts +3 -18
  75. package/dist/providers/llama-cpp/offload-planner.js +3 -14
  76. package/dist/providers/llama-cpp/provider.d.ts +8 -0
  77. package/dist/providers/llama-cpp/provider.js +4 -1
  78. package/dist/providers/mlx/provider.js +14 -11
  79. package/dist/providers/mlx/python/batch_processors.py +26 -0
  80. package/dist/providers/mlx/python/gezel_mlx_server.py +88 -20
  81. package/dist/providers/mlx/python/qwen4_ple.py +9 -2
  82. package/dist/providers/mlx/python/response_format.py +374 -0
  83. package/dist/providers/mlx/python/spec_decode.py +5 -0
  84. package/dist/providers/mlx/python/think_budget.py +35 -1
  85. package/dist/providers/mlx/python/tool_grammar.py +62 -3
  86. package/dist/providers/mlx/reasoning-stream.d.ts +20 -2
  87. package/dist/providers/mlx/reasoning-stream.js +77 -16
  88. package/dist/providers/mlx/runtime-diagnostics.d.ts +16 -0
  89. package/dist/providers/mlx/runtime-diagnostics.js +29 -2
  90. package/dist/providers/native/capacity-broker.d.ts +51 -10
  91. package/dist/providers/native/capacity-broker.js +80 -9
  92. package/dist/providers/native/capacity-budget.d.ts +35 -3
  93. package/dist/providers/native/capacity-budget.js +49 -5
  94. package/dist/providers/native/device-capacity.d.ts +1 -1
  95. package/dist/providers/native/device-capacity.js +23 -5
  96. package/dist/providers/native/local-engine-runtime.js +15 -13
  97. package/dist/providers/native/measured-budget.js +2 -0
  98. package/dist/providers/native/supervisor.d.ts +25 -0
  99. package/dist/providers/native/supervisor.js +59 -7
  100. package/dist/remotes/identity.d.ts +1 -0
  101. package/dist/remotes/identity.js +6 -0
  102. package/dist/remotes/pinned-fetch.d.ts +2 -0
  103. package/dist/remotes/pinned-fetch.js +30 -1
  104. package/dist/remotes/types.d.ts +14 -0
  105. package/dist/runtime-lock.js +1 -1
  106. package/dist/system/amdgpu-adapters.d.ts +18 -0
  107. package/dist/system/amdgpu-adapters.js +55 -0
  108. package/dist/system/gpu-carve-out.d.ts +84 -0
  109. package/dist/system/gpu-carve-out.js +111 -0
  110. package/dist/system/memory.d.ts +17 -1
  111. package/dist/system/memory.js +105 -14
  112. package/dist/transformers-cache.d.ts +2 -0
  113. package/dist/transformers-cache.js +6 -3
  114. package/dist/utils/service-worker-entry.d.ts +4 -0
  115. package/dist/utils/service-worker-entry.js +4 -0
  116. package/package.json +14 -10
@@ -31,6 +31,12 @@ export interface LlamaDeviceProbe {
31
31
  probedAt: string;
32
32
  /** True when returned from the on-disk cache rather than a fresh spawn. */
33
33
  cached: boolean;
34
+ /**
35
+ * The RAM the OS reported when the probe ran. Changing an integrated GPU's
36
+ * firmware carve-out changes this figure and the GPU's own, so a mismatch
37
+ * means the cached devices describe a machine that no longer exists.
38
+ */
39
+ systemRamBytes?: number;
34
40
  }
35
41
  export interface NvidiaRuntimeDevice {
36
42
  index: number;
@@ -72,16 +78,19 @@ export declare function pickBestGpuDevice(devices: LlamaDevice[]): LlamaDevice |
72
78
  /**
73
79
  * Probe device memory by running `<binaryPath> --list-devices`, caching
74
80
  * the result. Reads the cache when present (VRAM capacity is stable per
75
- * machine); pass `force` to re-probe. Any failure (spawn error, no
81
+ * machine) and the OS still reports the RAM it was probed with; pass
82
+ * `force` to re-probe. Any failure (spawn error, no
76
83
  * cache, unparseable output) resolves to an empty device list — the
77
84
  * planner then simply does nothing and the engine's own `--fit`/`-ngl
78
- * auto` takes over. `nowIso` is injectable for deterministic tests.
85
+ * auto` takes over. `nowIso` and `systemRamBytes` are injectable for
86
+ * deterministic tests.
79
87
  */
80
88
  export declare function probeLlamaDevices(opts: {
81
89
  binaryPath: string;
82
90
  home: string;
83
91
  force?: boolean;
84
92
  nowIso?: () => string;
93
+ systemRamBytes?: number;
85
94
  }): Promise<LlamaDeviceProbe>;
86
95
  /**
87
96
  * Best-effort VRAM for a non-NVIDIA GPU (AMD Radeon, Intel Arc, any Vulkan
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { execFile } from 'node:child_process';
19
19
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { totalmem } from 'node:os';
20
21
  import { delimiter, dirname, join } from 'node:path';
21
22
  import { promisify } from 'node:util';
22
23
  import { windowsHeadlessSpawnOptions } from '@bendyline/gezel/native';
@@ -124,20 +125,25 @@ function cachePath(home) {
124
125
  /**
125
126
  * Probe device memory by running `<binaryPath> --list-devices`, caching
126
127
  * the result. Reads the cache when present (VRAM capacity is stable per
127
- * machine); pass `force` to re-probe. Any failure (spawn error, no
128
+ * machine) and the OS still reports the RAM it was probed with; pass
129
+ * `force` to re-probe. Any failure (spawn error, no
128
130
  * cache, unparseable output) resolves to an empty device list — the
129
131
  * planner then simply does nothing and the engine's own `--fit`/`-ngl
130
- * auto` takes over. `nowIso` is injectable for deterministic tests.
132
+ * auto` takes over. `nowIso` and `systemRamBytes` are injectable for
133
+ * deterministic tests.
131
134
  */
132
135
  export async function probeLlamaDevices(opts) {
133
136
  const { binaryPath, home, force } = opts;
134
137
  const now = opts.nowIso ?? (() => new Date().toISOString());
138
+ const systemRamBytes = opts.systemRamBytes ?? totalmem();
135
139
  const path = cachePath(home);
136
140
  if (!force && existsSync(path)) {
137
141
  try {
138
142
  const cached = JSON.parse(readFileSync(path, 'utf8'));
139
- if (Array.isArray(cached.devices))
143
+ // A cache without the RAM figure predates the check and re-probes once.
144
+ if (Array.isArray(cached.devices) && cached.systemRamBytes === systemRamBytes) {
140
145
  return { ...cached, cached: true };
146
+ }
141
147
  }
142
148
  catch {
143
149
  // fall through to a fresh probe
@@ -165,7 +171,7 @@ export async function probeLlamaDevices(opts) {
165
171
  // Leave devices empty — caller treats "no devices" as "no offload target".
166
172
  return { devices, probedAt: now(), cached: false };
167
173
  }
168
- const probe = { devices, probedAt: now(), cached: false };
174
+ const probe = { devices, probedAt: now(), cached: false, systemRamBytes };
169
175
  try {
170
176
  mkdirSync(dirname(path), { recursive: true });
171
177
  writeFileSync(path, JSON.stringify(probe, null, 2), 'utf8');
@@ -126,24 +126,6 @@ export interface EngineFlagInput {
126
126
  * when the client also replays `reasoning_content`.
127
127
  */
128
128
  reasoningPreserve?: boolean | undefined;
129
- /**
130
- * The model's GGUF `general.architecture`, for the Gemma-family auto
131
- * defaults (sliding-window full cache). Undefined disables the arch
132
- * heuristic; an explicit config/manifest `swaFull` still applies.
133
- */
134
- architecture?: string | undefined;
135
- /** The resolved model id, a fallback for `architecture` (see isGemmaModel). */
136
- modelId?: string | undefined;
137
- /**
138
- * Whether resident weights plus the full SWA KV cache fit the host's
139
- * fast-memory pool (VRAM on a discrete GPU). The exact estimate honors
140
- * per-layer SWA dimensions and shared-KV layers. `false` declines the
141
- * auto-default so a low-memory device keeps the model's full context on
142
- * the windowed cache instead of spilling or OOMing. `undefined` (unknown
143
- * host/metadata) and `true` retain the compatibility default. An explicit
144
- * config/manifest `swaFull` wins regardless.
145
- */
146
- swaFullAutoFits?: boolean | undefined;
147
129
  /**
148
130
  * The llama-server build's backend (`cuda`, `vulkan`, `metal`, `cpu`).
149
131
  * Only a GPU backend can receive the GPU-resident thread default.
@@ -21,7 +21,6 @@
21
21
  * dependency; the real `GezelConfig` / `LlamaCppEngineConfig` satisfy
22
22
  * these shapes by construction.
23
23
  */
24
- import { isGemmaModel } from './kv-cache-type.js';
25
24
  /** Default `--cache-reuse` chunk when neither config nor manifest set it. */
26
25
  export const DEFAULT_CACHE_REUSE = 256;
27
26
  /**
@@ -101,7 +100,7 @@ function normalizeFlagKey(key) {
101
100
  * `args` array.
102
101
  */
103
102
  export function buildLlamaCppEngineArgs(input) {
104
- const { config, perModel, planner, kvCacheType, slots, ggufHasMtp, installedDraftModelPath, reasoningFormat, reasoningPreserve, architecture, modelId, swaFullAutoFits, backend, hostCpuCount, } = input;
103
+ const { config, perModel, planner, kvCacheType, slots, ggufHasMtp, installedDraftModelPath, reasoningFormat, reasoningPreserve, backend, hostCpuCount, } = input;
105
104
  const args = [];
106
105
  // ── GPU-layer offload (`--n-gpu-layers`) ──────────────────────────
107
106
  const nGpuLayers = config.llamaCppNGpuLayers ?? perModel?.nGpuLayers ?? planner?.nGpuLayers;
@@ -176,26 +175,20 @@ export function buildLlamaCppEngineArgs(input) {
176
175
  //
177
176
  // So the engine already declines it precisely when it cannot honour it,
178
177
  // which is what the warning is for. Withholding the flag ourselves only
179
- // cost reuse on multi-slot dense engines. Note the coupling this exposes:
180
- // the memory-driven `--swa-full` decision below also silently decides
181
- // whether Gemma gets any cross-request prefix reuse at all.
178
+ // cost reuse on multi-slot dense engines. Gemma therefore gets it only
179
+ // with a pinned `--swa-full` below.
182
180
  const explicitCacheReuse = config.llamaCppCacheReuse ?? perModel?.cacheReuse;
183
181
  const cacheReuse = explicitCacheReuse ?? DEFAULT_CACHE_REUSE;
184
182
  if (cacheReuse > 0)
185
183
  args.push('--cache-reuse', String(cacheReuse));
186
184
  // ── SWA full cache (`--swa-full`) ─────────────────────────────────
187
- // Tri-state: explicit config/manifest `true`/`false` wins; `undefined`
188
- // at both layers falls through to the Gemma-family auto-default. Gemma's
189
- // sliding-window attention refuses cross-request prefix reuse under the
190
- // memory-efficient windowed cache; the full cache preserves old SWA KV
191
- // for cache operations by allocating full-context storage on every
192
- // cache-owning layer. The cost varies sharply with model geometry, so
193
- // Auto is gated on `swaFullAutoFits`: when weights + full KV do not fit
194
- // fast memory, Gemma launches with the windowed cache instead.
195
- const swaFull = config.llamaCppSwaFull ??
196
- perModel?.swaFull ??
197
- (swaFullAutoFits !== false && isGemmaModel({ architecture, modelId }));
198
- if (swaFull)
185
+ // Opt-in only: an explicit config/manifest `swaFull`. Gemma used to get
186
+ // it by default for cross-request prefix reuse, until a paired A/B on
187
+ // gemma4-31b (2026-10-15) showed the full cache decodes 17-25% slower at
188
+ // every context depth, because decode reads each sliding-window layer's
189
+ // whole cache, and costs 51 GB more at 64K. Trials ran 19% longer in
190
+ // total despite the better reuse.
191
+ if (config.llamaCppSwaFull ?? perModel?.swaFull)
199
192
  args.push('--swa-full');
200
193
  // ── Thread / batch overrides ──────────────────────────────────────
201
194
  // The GPU-resident default needs positive evidence that no model compute
@@ -248,7 +241,8 @@ export function buildLlamaCppEngineArgs(input) {
248
241
  const nMax = config.llamaCppSpecDraftNMax ?? perModel?.spec?.nMax;
249
242
  if (typeof nMax === 'number')
250
243
  args.push('--spec-draft-n-max', String(nMax));
251
- // v0.6.0 supports probabilistic selection only for MTP/simple drafts.
244
+ // v0.7.0 supports probabilistic selection only for MTP/simple drafts
245
+ // (DFlash/LiLiCorr ignore it).
252
246
  // Leave the upstream greedy default untouched unless explicitly set.
253
247
  if ((safeSpecType === 'draft-mtp' || safeSpecType === 'draft-simple') &&
254
248
  config.llamaCppSpecDraftSampling) {
@@ -122,6 +122,9 @@ async function runInWorker(path, options) {
122
122
  const id = nextId++;
123
123
  return new Promise((resolveSummary, reject) => {
124
124
  pending.set(id, { resolve: resolveSummary, reject });
125
+ // Held while busy: an unreferenced worker let a plain Node host exit
126
+ // mid-await during an engine cold start.
127
+ target.ref();
125
128
  target.postMessage({ id, path, ...(options ? { options } : {}) });
126
129
  });
127
130
  }
@@ -141,6 +144,8 @@ function ensureWorker() {
141
144
  else if (worker === next)
142
145
  worker = null;
143
146
  });
147
+ // An idle reader must never hold the event loop open; runInWorker refs it
148
+ // only while a request is in flight.
144
149
  next.unref();
145
150
  worker = next;
146
151
  return next;
@@ -158,6 +163,8 @@ function onMessage(reply) {
158
163
  if (!request)
159
164
  return;
160
165
  pending.delete(reply.id);
166
+ if (pending.size === 0)
167
+ worker?.unref();
161
168
  if (reply.error)
162
169
  request.reject(new Error(reply.error));
163
170
  else if (reply.summary)
@@ -32,8 +32,9 @@ const GgufValueType = {
32
32
  INT64: 11,
33
33
  FLOAT64: 12,
34
34
  };
35
- // `general.file_type` enum from llama.cpp/src/llama.h. Only the
36
- // common values — the enum has ~30 entries and upstream adds more.
35
+ // `general.file_type` enum (`llama_ftype`) from llama.cpp's include/llama.h.
36
+ // Copy new values from upstream rather than guessing: this table once mapped
37
+ // 30 to BF16 (upstream 30 is IQ4_XS, BF16 is 32) and had no NVFP4 at all.
37
38
  // Anything unrecognised is reported by number.
38
39
  const FILE_TYPE_NAMES = {
39
40
  0: 'ALL_F32',
@@ -54,8 +55,24 @@ const FILE_TYPE_NAMES = {
54
55
  18: 'MOSTLY_Q6_K',
55
56
  19: 'MOSTLY_IQ2_XXS',
56
57
  20: 'MOSTLY_IQ2_XS',
58
+ 21: 'MOSTLY_Q2_K_S',
59
+ 22: 'MOSTLY_IQ3_XS',
57
60
  23: 'MOSTLY_IQ3_XXS',
58
- 30: 'MOSTLY_BF16',
61
+ 24: 'MOSTLY_IQ1_S',
62
+ 25: 'MOSTLY_IQ4_NL',
63
+ 26: 'MOSTLY_IQ3_S',
64
+ 27: 'MOSTLY_IQ3_M',
65
+ 28: 'MOSTLY_IQ2_S',
66
+ 29: 'MOSTLY_IQ2_M',
67
+ 30: 'MOSTLY_IQ4_XS',
68
+ 31: 'MOSTLY_IQ1_M',
69
+ 32: 'MOSTLY_BF16',
70
+ 36: 'MOSTLY_TQ1_0',
71
+ 37: 'MOSTLY_TQ2_0',
72
+ 38: 'MOSTLY_MXFP4_MOE',
73
+ 39: 'MOSTLY_NVFP4',
74
+ 40: 'MOSTLY_Q1_0',
75
+ 41: 'MOSTLY_Q2_0',
59
76
  };
60
77
  /**
61
78
  * The quantization tag a GGUF declares about itself, in the shape the model
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Did a GPU build of llama-server actually get its GPU? Read from the
3
+ * engine's own startup output, because nothing else says.
4
+ *
5
+ * The bundled builds load their GPU backend with dlopen (`GGML_BACKEND_DL`),
6
+ * and in a release build ggml skips a backend that fails to load without a
7
+ * word. A CUDA backend that loads but cannot initialize prints one error line
8
+ * and registers no devices. Either way the engine starts, answers, and runs
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.
12
+ *
13
+ * llama.cpp names every GPU it will use while loading a model
14
+ * (`using device CUDA0 (NVIDIA GB10) …`), integrated GPUs included. A GPU
15
+ * build that reached readiness after loading a model without that line ran
16
+ * without its GPU. The explicit lines say why when there is a why to say.
17
+ */
18
+ export type GpuUnusedReason =
19
+ /** The CUDA runtime loaded and refused to start: `failed to initialize CUDA`. */
20
+ 'init-failed'
21
+ /** The backend loaded and found no device it could use. */
22
+ | 'no-devices'
23
+ /** No sign of the GPU backend at all: it failed to load, silently. */
24
+ | 'not-loaded';
25
+ export interface GpuUnused {
26
+ backend: 'cuda' | 'vulkan';
27
+ reason: GpuUnusedReason;
28
+ /** The engine's own words, when it gave any. */
29
+ detail?: string;
30
+ }
31
+ export declare class GpuStartupWatch {
32
+ private readonly backend;
33
+ private usingGpu;
34
+ private sawModelLoad;
35
+ private initFailure;
36
+ private noDevices;
37
+ constructor(backend: string | undefined);
38
+ observe(line: string): void;
39
+ /** At readiness: what kept the GPU out, or null when it is in use or this is no GPU build. */
40
+ verdict(): GpuUnused | null;
41
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Did a GPU build of llama-server actually get its GPU? Read from the
3
+ * engine's own startup output, because nothing else says.
4
+ *
5
+ * The bundled builds load their GPU backend with dlopen (`GGML_BACKEND_DL`),
6
+ * and in a release build ggml skips a backend that fails to load without a
7
+ * word. A CUDA backend that loads but cannot initialize prints one error line
8
+ * and registers no devices. Either way the engine starts, answers, and runs
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.
12
+ *
13
+ * llama.cpp names every GPU it will use while loading a model
14
+ * (`using device CUDA0 (NVIDIA GB10) …`), integrated GPUs included. A GPU
15
+ * build that reached readiness after loading a model without that line ran
16
+ * without its GPU. The explicit lines say why when there is a why to say.
17
+ */
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*$/;
20
+ const VULKAN_NO_DEVICES = /ggml_vulkan: No devices found/;
21
+ /**
22
+ * Lines llama.cpp prints at the same verbosity as `using device`, while
23
+ * loading a model. Without one the output is too quiet to conclude anything.
24
+ */
25
+ const MODEL_LOAD = /\b(?:llama_model_loader:|load_tensors:|print_info:|llama_model_load_from_file_impl:)/;
26
+ export class GpuStartupWatch {
27
+ backend;
28
+ usingGpu = false;
29
+ sawModelLoad = false;
30
+ initFailure;
31
+ noDevices = false;
32
+ constructor(backend) {
33
+ this.backend = backend;
34
+ }
35
+ observe(line) {
36
+ if (USING_GPU.test(line))
37
+ this.usingGpu = true;
38
+ if (MODEL_LOAD.test(line))
39
+ this.sawModelLoad = true;
40
+ const failure = CUDA_INIT_FAILED.exec(line);
41
+ if (failure?.[1])
42
+ this.initFailure = failure[1];
43
+ if (VULKAN_NO_DEVICES.test(line))
44
+ this.noDevices = true;
45
+ }
46
+ /** At readiness: what kept the GPU out, or null when it is in use or this is no GPU build. */
47
+ verdict() {
48
+ const backend = this.backend;
49
+ if ((backend !== 'cuda' && backend !== 'vulkan') || this.usingGpu)
50
+ return null;
51
+ if (backend === 'cuda' && this.initFailure) {
52
+ return { backend, reason: 'init-failed', detail: this.initFailure };
53
+ }
54
+ if (backend === 'vulkan' && this.noDevices)
55
+ return { backend, reason: 'no-devices' };
56
+ return this.sawModelLoad ? { backend, reason: 'not-loaded' } : null;
57
+ }
58
+ }
@@ -236,6 +236,11 @@ export interface LlamaCppModelManagerOptions {
236
236
  engine: 'llama-cpp' | 'ds4';
237
237
  id: string;
238
238
  }) => void;
239
+ /**
240
+ * The llama-server backend installs are judged against for a catalog
241
+ * source's `excludeBackends`. Test seam — defaults to the process's own.
242
+ */
243
+ resolveBackend?: () => string | undefined;
239
244
  }
240
245
  export declare class LlamaCppModelManager {
241
246
  private readonly modelsRoot;
@@ -244,6 +249,7 @@ export declare class LlamaCppModelManager {
244
249
  private readonly fetchImpl;
245
250
  private readonly engine;
246
251
  private readonly onInstalled?;
252
+ private readonly resolveBackend;
247
253
  /**
248
254
  * In-flight installs. `install()` registers an entry on first yield
249
255
  * and clears it in the iterator's `finally` block, so consumers
@@ -35,13 +35,14 @@ import { createHash } from 'node:crypto';
35
35
  import { createReadStream } from 'node:fs';
36
36
  import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
37
37
  import { basename, join, resolve } from 'node:path';
38
- import { createLogger } from '@bendyline/gezel';
38
+ import { createLogger, llamaCppBackendExclusion, llamaCppBackendLabel } 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';
42
42
  import { MODEL_HASH_READ_BUFFER_BYTES, assertModelStorePathSafe, findModelRoot, findRenamedModelId, hashModelPayloadFiles, inspectModelDirectory, listIncompleteModelDownloads, listOverlayModelIds, makeSharedModelReadable, modelExistsOnlyReadOnly, modelStorageRoots, pruneModelPayloadFiles, readOnlyModelError, removeModelDir, verifyReadOnlyModelPayload, } from '../../models/storage-roots.js';
43
43
  import { checkDiskSpace, describeDiskShortfall } from '../../utils/disk-space.js';
44
44
  import { downloadWithRetry } from '../../utils/download-with-retry.js';
45
+ import { currentLlamaCppBackend } from './backend-health.js';
45
46
  import { ggufQuantizationTag, readGgufSummary } from './gguf-metadata.js';
46
47
  import { backfillGgufQuantization } from './gguf-quantization-backfill.js';
47
48
  /**
@@ -58,6 +59,7 @@ export class LlamaCppModelManager {
58
59
  fetchImpl;
59
60
  engine;
60
61
  onInstalled;
62
+ resolveBackend;
61
63
  /**
62
64
  * In-flight installs. `install()` registers an entry on first yield
63
65
  * and clears it in the iterator's `finally` block, so consumers
@@ -78,6 +80,7 @@ export class LlamaCppModelManager {
78
80
  this.catalog = opts.catalog;
79
81
  this.fetchImpl = opts.fetchImpl ?? fetch;
80
82
  this.onInstalled = opts.onInstalled;
83
+ this.resolveBackend = opts.resolveBackend ?? currentLlamaCppBackend;
81
84
  }
82
85
  /**
83
86
  * The catalog source block this manager downloads from — `ds4` for the
@@ -435,6 +438,17 @@ export class LlamaCppModelManager {
435
438
  };
436
439
  return;
437
440
  }
441
+ if (this.engine === 'llama-cpp') {
442
+ const backend = this.resolveBackend();
443
+ const excluded = llamaCppBackendExclusion(manifest.llamaCpp, backend);
444
+ if (excluded && backend) {
445
+ yield {
446
+ type: 'error',
447
+ error: `chat-model "${catalogId}" is not available on the ${llamaCppBackendLabel(backend)} llama.cpp engine: ${excluded}`,
448
+ };
449
+ return;
450
+ }
451
+ }
438
452
  tracked.totalBytes = installSrc.approxSizeBytes;
439
453
  yield* this.runInstall(catalogId, manifest, tracked, skipSha, includeMmproj);
440
454
  }
@@ -964,6 +978,10 @@ includeMmproj = true) {
964
978
  // after install without a second download.
965
979
  if (src.draftModel) {
966
980
  out.push({
981
+ ...(src.draftModel.huggingfaceRepo
982
+ ? { huggingfaceRepo: src.draftModel.huggingfaceRepo }
983
+ : {}),
984
+ ...(src.draftModel.revision ? { revision: src.draftModel.revision } : {}),
967
985
  repoPath: src.draftModel.filename,
968
986
  destFilename: basename(src.draftModel.filename),
969
987
  sha256: src.draftModel.sha256,
@@ -286,10 +286,9 @@ export interface WindowedKvLinearization {
286
286
  * than the layer ratio — global layers carry fewer KV heads at wider head
287
287
  * dims (31b: 10 layers × 4 heads × 512+512 vs 50 layers × 16 heads ×
288
288
  * 256+256) — so full-attention math overstates the windowed cache ~14×
289
- * and using it for admission over-clamps or over-denies exactly the
290
- * launches the `--swa-full` decline is trying to keep whole (the
291
- * 2026-08-04 gemma4-26b sweep shipped 19–56K windows on a 65536 request
292
- * this way).
289
+ * and using it for admission over-clamps or over-denies launches the
290
+ * windowed cache holds easily (the 2026-08-04 gemma4-26b sweep shipped
291
+ * 19–56K windows on a 65536 request this way).
293
292
  *
294
293
  * Returns undefined when the header lacks the SWA layout (no window, no
295
294
  * pattern, length mismatches) so callers fall back to their conservative
@@ -323,17 +322,3 @@ export declare function estimateExactPerSlotKvBytesF16(input: Omit<WindowedKvInp
323
322
  */
324
323
  export declare function estimateLinearHybridKvLinearization(input: WindowedKvInput): WindowedKvLinearization | undefined;
325
324
  export declare function estimateWindowedKvLinearization(input: WindowedKvInput): WindowedKvLinearization | undefined;
326
- export interface SwaFullFastMemoryFitInput {
327
- residentWeightsBytes: number;
328
- fullKvBytes: number;
329
- /** Already safety-discounted fast-memory budget (VRAM on a discrete GPU). */
330
- fastBudgetBytes: number;
331
- /** Fast-memory reservations held by models that are already resident. */
332
- committedOtherBytes?: number | undefined;
333
- }
334
- /**
335
- * Whether Auto may keep `--swa-full` without spilling the model or KV cache
336
- * out of the fast pool. The capacity budget already reserves driver/OS
337
- * headroom, so this comparison must not discount it a second time.
338
- */
339
- export declare function fitsSwaFullInFastMemory(input: SwaFullFastMemoryFitInput): boolean;
@@ -406,10 +406,9 @@ const SWA_UBATCH_MARGIN_TOKENS = 2048;
406
406
  * than the layer ratio — global layers carry fewer KV heads at wider head
407
407
  * dims (31b: 10 layers × 4 heads × 512+512 vs 50 layers × 16 heads ×
408
408
  * 256+256) — so full-attention math overstates the windowed cache ~14×
409
- * and using it for admission over-clamps or over-denies exactly the
410
- * launches the `--swa-full` decline is trying to keep whole (the
411
- * 2026-08-04 gemma4-26b sweep shipped 19–56K windows on a 65536 request
412
- * this way).
409
+ * and using it for admission over-clamps or over-denies launches the
410
+ * windowed cache holds easily (the 2026-08-04 gemma4-26b sweep shipped
411
+ * 19–56K windows on a 65536 request this way).
413
412
  *
414
413
  * Returns undefined when the header lacks the SWA layout (no window, no
415
414
  * pattern, length mismatches) so callers fall back to their conservative
@@ -512,13 +511,3 @@ export function estimateWindowedKvLinearization(input) {
512
511
  fixedBytes: Math.round(swaElemsPerToken * bytesPerElement * (slidingWindow + SWA_UBATCH_MARGIN_TOKENS) * loops),
513
512
  };
514
513
  }
515
- /**
516
- * Whether Auto may keep `--swa-full` without spilling the model or KV cache
517
- * out of the fast pool. The capacity budget already reserves driver/OS
518
- * headroom, so this comparison must not discount it a second time.
519
- */
520
- export function fitsSwaFullInFastMemory(input) {
521
- const required = Math.max(0, input.residentWeightsBytes) + Math.max(0, input.fullKvBytes);
522
- const available = Math.max(0, input.fastBudgetBytes - Math.max(0, input.committedOtherBytes ?? 0));
523
- return Number.isFinite(required) && Number.isFinite(available) && required <= available;
524
- }
@@ -22,6 +22,7 @@
22
22
  * lazily respawn its process (see {@link shutdown}).
23
23
  */
24
24
  import { type DisableThinkingRequestShape, LlamaCppSession, type ReasoningEffortRequestShape, type ToolGrammarFallback } from '@bendyline/gezel/local-loop';
25
+ import type { StartupCrashVerdict } from '@bendyline/gezel/native';
25
26
  import type { GpuArbiter } from '../gpu-arbiter.js';
26
27
  import type { NativeEngineLifecycleSnapshot, NativeEngineSupervisor } from '../native/supervisor.js';
27
28
  import { ProviderQueue } from '../queue.js';
@@ -55,6 +56,7 @@ export declare class LlamaCppProvider implements LLMProvider {
55
56
  */
56
57
  private toolGrammarFloor;
57
58
  private readonly supervisor?;
59
+ private readonly startupCrashVerdict?;
58
60
  /** Explicit base URL when no supervisor is managing the process. */
59
61
  private readonly externalBaseUrl?;
60
62
  private readonly defaultModel;
@@ -242,6 +244,12 @@ export declare class LlamaCppProvider implements LLMProvider {
242
244
  * `baseUrl`.
243
245
  */
244
246
  supervisor?: NativeEngineSupervisor;
247
+ /**
248
+ * What a startup crash did to its build's standing (strike or
249
+ * quarantine), looked up by incident id when a start fails, so the error
250
+ * says what the next request will actually do.
251
+ */
252
+ startupCrashVerdict?: (incidentId: string) => StartupCrashVerdict | undefined;
245
253
  /**
246
254
  * Fixed base URL of an already-running llama-server. Used in
247
255
  * dev iteration and LAN-shared setups. Mutually exclusive with
@@ -57,6 +57,7 @@ export class LlamaCppProvider {
57
57
  */
58
58
  toolGrammarFloor = null;
59
59
  supervisor;
60
+ startupCrashVerdict;
60
61
  /** Explicit base URL when no supervisor is managing the process. */
61
62
  externalBaseUrl;
62
63
  defaultModel;
@@ -247,6 +248,8 @@ export class LlamaCppProvider {
247
248
  }
248
249
  if (opts.supervisor)
249
250
  this.supervisor = opts.supervisor;
251
+ if (opts.startupCrashVerdict)
252
+ this.startupCrashVerdict = opts.startupCrashVerdict;
250
253
  if (opts.baseUrl)
251
254
  this.externalBaseUrl = opts.baseUrl.replace(/\/+$/, '');
252
255
  // Only register an evictor when we own the lifecycle. External-baseUrl
@@ -848,7 +851,7 @@ export class LlamaCppProvider {
848
851
  catch (err) {
849
852
  const nativeExit = this.supervisor.lastExitSnapshot?.();
850
853
  if (nativeExit && !nativeExit.expected && nativeExit.exitedAt >= ensureStartedAt) {
851
- throw new NativeEngineCrashedError(nativeExit, err);
854
+ throw new NativeEngineCrashedError(nativeExit, err, this.startupCrashVerdict?.(nativeExit.incidentId));
852
855
  }
853
856
  throw err;
854
857
  }
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { AsyncLocalStorage } from 'node:async_hooks';
29
29
  import { randomUUID } from 'node:crypto';
30
- import { AwakeBudget, autoCompactedNotice, createAwakeTimeout, createLogger, leaksUntaggedReasoning, turnCancelledMessage, } from '@bendyline/gezel';
30
+ import { AwakeBudget, autoCompactedNotice, createAwakeTimeout, createLogger, isProjectStartingCall, leaksUntaggedReasoning, turnCancelledMessage, } from '@bendyline/gezel';
31
31
  import { leadingSystemMessages, standaloneTurnMessages, toolSurfaceSize, } from '@bendyline/gezel/local-loop';
32
32
  import { computeToolBudgetChars } from '@bendyline/gezel/local-loop';
33
33
  import { extractReasoningWithProfile, profileBehaviorConfig, profileHasBehavior, } from '../../model-profile/runtime.js';
@@ -58,9 +58,9 @@ import { MlxEngineGate } from './engine-gate.js';
58
58
  import { EngineHold, engineHoldLabelFor } from './engine-hold.js';
59
59
  import { EngineLogRouter } from './engine-log-router.js';
60
60
  import { MlxEngineStatsSampler } from './engine-stats-sampler.js';
61
- import { StreamingReasoningSplit, requestOpensReasoning, } from './reasoning-stream.js';
61
+ import { REASONING_OPEN_HEADER, StreamingReasoningSplit, engineOpensReasoning, 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, translateMlxHttpError, } from './runtime-diagnostics.js';
63
+ import { 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';
@@ -1571,7 +1571,7 @@ class MlxSession extends StreamingSessionBase {
1571
1571
  this.deps.markUsed();
1572
1572
  const txt = await res.text().catch(() => '');
1573
1573
  log.error(`turn#${seq}.${turn} HTTP-ERR status=${res.status} ${res.statusText}`);
1574
- throw new Error(translateMlxHttpError(res.status, res.statusText, txt));
1574
+ throw mlxHttpError(res.status, res.statusText, txt);
1575
1575
  }
1576
1576
  if (debugOn) {
1577
1577
  log.debug(`turn#${seq}.${turn} HTTP-OK status=${res.status} ` +
@@ -1616,10 +1616,14 @@ class MlxSession extends StreamingSessionBase {
1616
1616
  // `turn.ramble-detection`; per-model thresholds come from
1617
1617
  // the validated config. Absent → detector disabled.
1618
1618
  //
1619
- // Read from the request, not only the template: a thinking-off
1620
- // request renders the block already closed, and seeding from the
1621
- // template streamed a whole `write_file` call into the reasoning pane.
1622
- const opensInReasoning = requestOpensReasoning(this.deps.templateOpensReasoning, body);
1619
+ // The engine reports what the rendered prompt did; the template guess
1620
+ // is only a fallback. A thinking-off request renders the block already
1621
+ // closed (seeding from the template streamed a whole `write_file` call
1622
+ // into the reasoning pane), and Qwen 3.5 0.8B's template opens it only
1623
+ // when thinking is explicitly on (guessing closed streamed its thoughts
1624
+ // and a stray `</think>` as the reply).
1625
+ const opensInReasoning = engineOpensReasoning(res.headers.get(REASONING_OPEN_HEADER)) ??
1626
+ requestOpensReasoning(this.deps.templateOpensReasoning, body);
1623
1627
  const rambleConfig = profileBehaviorConfig(this.deps.profile, 'turn.ramble-detection');
1624
1628
  const ramble = outputIsConstrained(this.deps.tuning)
1625
1629
  ? inertRambleDetector()
@@ -2652,7 +2656,7 @@ class MlxSession extends StreamingSessionBase {
2652
2656
  output =
2653
2657
  "You already posted a question card to the user earlier in this turn. The second `ask_user_question` call was suppressed so the user only sees one card. END YOUR TURN NOW — the user's answer to the first question will arrive as the next user message; this turn produces no further visible content.";
2654
2658
  }
2655
- else if ((call.function.name === 'start_project' || call.function.name === 'start_job') &&
2659
+ else if (isProjectStartingCall(call.function.name, args) &&
2656
2660
  startedProjectOrJobThisTurn) {
2657
2661
  // Success-led phrasing — wild-caught on gemma4-26b/MLX,
2658
2662
  // the previous "DO NOT call..." wording got misread by
@@ -2749,8 +2753,7 @@ class MlxSession extends StreamingSessionBase {
2749
2753
  // Successful start_project / start_job → arm the
2750
2754
  // per-turn macro guard so subsequent variants get
2751
2755
  // intercepted above before reaching the bridge.
2752
- if ((call.function.name === 'start_project' || call.function.name === 'start_job') &&
2753
- !output.startsWith('ERROR:')) {
2756
+ if (isProjectStartingCall(call.function.name, args) && !output.startsWith('ERROR:')) {
2754
2757
  startedProjectOrJobThisTurn = { tool: call.function.name, firstResult: output };
2755
2758
  }
2756
2759
  }
@@ -0,0 +1,26 @@
1
+ """batch_processors — per-sequence logits-processor lists for mlx_lm's
2
+ BatchGenerator that stay aligned across waves.
3
+
4
+ mlx_lm 0.31.3's `GenerationBatch.filter()` trims `logits_processors` only
5
+ `if any(self.logits_processors)`. Once every live sequence carries `[]`, the
6
+ finished ones' entries survive the filter, and the next wave's sequences are
7
+ appended after them at the wrong index: a request's tool grammar, think
8
+ budget or response format then ran for its first token only, and in a mixed
9
+ wave one sequence's grammar could land on another's output. Caught
10
+ 2026-10-13: a JSON-schema request right after a plain one came back as free
11
+ text. Giving every sequence a non-empty list keeps `any()` true, so the
12
+ filter always trims. The pass-through costs nothing measurable (A/B on
13
+ gemma4-e4b, 2026-10-14: 64.3 vs 62.7 tok/s median, within noise).
14
+ """
15
+
16
+ from typing import Any, Callable, List, Sequence
17
+
18
+
19
+ def pass_through_logits(tokens: Any, logits: Any) -> Any:
20
+ """A no-op processor that keeps a sequence's processor list non-empty."""
21
+ return logits
22
+
23
+
24
+ def per_sequence_processors(processors: Sequence[Callable[[Any, Any], Any]]) -> List[Callable[[Any, Any], Any]]:
25
+ """The processor list to hand BatchGenerator for one sequence; never empty."""
26
+ return list(processors) or [pass_through_logits]