@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
@@ -106,6 +106,22 @@ export declare function isRetryablePipelineLoadFailure(error: unknown): boolean;
106
106
  * will not parse is a runtime problem instead, and retrying cannot fix it.
107
107
  */
108
108
  export declare function isFreshDownloadFailure(error: unknown, graphVerified: boolean): boolean;
109
+ /** Work abandoned because the host asked, at service shutdown. Not a model fault. */
110
+ export declare class ModelWorkCancelledError extends Error {
111
+ constructor();
112
+ }
113
+ /**
114
+ * Abandon the model download in flight and stop embedding runs at their next
115
+ * sub-batch, so the worker reaches idle and can be terminated safely.
116
+ *
117
+ * Shutdown cannot simply terminate a busy worker (that aborts the process
118
+ * inside onnxruntime), and a first-run model download outlasted every drain:
119
+ * quitting during it held the app for 30 s, until the quit timeout. A download
120
+ * is plain network I/O, outside onnxruntime, so stopping it is safe; session
121
+ * creation and a single sub-batch still finish. Work that starts afterwards
122
+ * gets a fresh signal.
123
+ */
124
+ export declare function cancelInFlightModelWork(): void;
109
125
  /** Lazily create the feature-extraction pipeline; cached for the process. */
110
126
  export declare function loadPipeline(): Promise<Pipeline>;
111
127
  /**
@@ -191,6 +191,29 @@ export function isFreshDownloadFailure(error, graphVerified) {
191
191
  /** Fresh starts after a failed download, per process, before vector memory is switched off. */
192
192
  const MAX_FRESH_DOWNLOAD_RETRIES = 3;
193
193
  let freshDownloadFailures = 0;
194
+ /** Work abandoned because the host asked, at service shutdown. Not a model fault. */
195
+ export class ModelWorkCancelledError extends Error {
196
+ constructor() {
197
+ super('model work was cancelled for shutdown');
198
+ this.name = 'ModelWorkCancelledError';
199
+ }
200
+ }
201
+ let cancelController = new AbortController();
202
+ /**
203
+ * Abandon the model download in flight and stop embedding runs at their next
204
+ * sub-batch, so the worker reaches idle and can be terminated safely.
205
+ *
206
+ * Shutdown cannot simply terminate a busy worker (that aborts the process
207
+ * inside onnxruntime), and a first-run model download outlasted every drain:
208
+ * quitting during it held the app for 30 s, until the quit timeout. A download
209
+ * is plain network I/O, outside onnxruntime, so stopping it is safe; session
210
+ * creation and a single sub-batch still finish. Work that starts afterwards
211
+ * gets a fresh signal.
212
+ */
213
+ export function cancelInFlightModelWork() {
214
+ cancelController.abort();
215
+ cancelController = new AbortController();
216
+ }
194
217
  /** The daemon pin's ONNX graph, when it names one with a digest. */
195
218
  function pinnedGraph() {
196
219
  const pin = daemonEmbedderPin();
@@ -203,6 +226,7 @@ let pipelinePromise = null;
203
226
  /** Lazily create the feature-extraction pipeline; cached for the process. */
204
227
  export async function loadPipeline() {
205
228
  if (!pipelinePromise) {
229
+ const signal = cancelController.signal;
206
230
  pipelinePromise = (async () => {
207
231
  let graphVerified = false;
208
232
  try {
@@ -230,6 +254,7 @@ export async function loadPipeline() {
230
254
  const outcome = await ensureVerifiedModelFile(cacheDir, modelId, graph, {
231
255
  remoteHost: env.remoteHost,
232
256
  remotePathTemplate: env.remotePathTemplate,
257
+ signal,
233
258
  });
234
259
  graphVerified = true;
235
260
  if (outcome === 'downloaded')
@@ -241,6 +266,9 @@ export async function loadPipeline() {
241
266
  });
242
267
  }
243
268
  catch (err) {
269
+ // Before the retry bookkeeping: a cancelled download is not a failed one.
270
+ if (signal.aborted)
271
+ throw new ModelWorkCancelledError();
244
272
  const missing = isMissingModule(err, TRANSFORMERS_MODULE);
245
273
  const message = missing
246
274
  ? 'Local memory embeddings are an optional npm feature. Install @huggingface/transformers@^4.3.1 alongside @bendyline/gezel-service (see the service README).'
@@ -272,9 +300,12 @@ export async function loadPipeline() {
272
300
  export async function runEmbed(texts) {
273
301
  if (texts.length === 0)
274
302
  return [];
303
+ const signal = cancelController.signal;
275
304
  const pipe = await loadPipeline();
276
305
  const out = [];
277
306
  for (let i = 0; i < texts.length; i += MAX_BATCH) {
307
+ if (signal.aborted)
308
+ throw new ModelWorkCancelledError();
278
309
  const slice = texts.slice(i, i + MAX_BATCH).map(capText);
279
310
  const result = await pipe(slice, { pooling: 'mean', normalize: true });
280
311
  for (const v of result.tolist())
@@ -11,13 +11,17 @@
11
11
  * with that profile;
12
12
  * reportProgress: send the
13
13
  * model download's bytes
14
+ * { cancel: true } — shutdown: stop the
15
+ * download and runs in flight
14
16
  * worker → host: { id, progress } — interim, zero or more
15
17
  * { id, vectors } — success
16
18
  * { id, error, fatal, — failure (fatal ⇒ model
17
- * retryable, optionalPeerMissing } unloadable, disable for
18
- * good; optionalPeerMissing
19
+ * retryable, optionalPeerMissing, unloadable, disable for
20
+ * cancelled } good; optionalPeerMissing
19
21
  * ⇒ the peer is just not
20
22
  * installed; retryable ⇒ a
21
- * short host-side cooldown)
23
+ * short host-side cooldown;
24
+ * cancelled ⇒ stopped by a
25
+ * cancel, not a fault)
22
26
  */
23
27
  export {};
@@ -11,22 +11,30 @@
11
11
  * with that profile;
12
12
  * reportProgress: send the
13
13
  * model download's bytes
14
+ * { cancel: true } — shutdown: stop the
15
+ * download and runs in flight
14
16
  * worker → host: { id, progress } — interim, zero or more
15
17
  * { id, vectors } — success
16
18
  * { id, error, fatal, — failure (fatal ⇒ model
17
- * retryable, optionalPeerMissing } unloadable, disable for
18
- * good; optionalPeerMissing
19
+ * retryable, optionalPeerMissing, unloadable, disable for
20
+ * cancelled } good; optionalPeerMissing
19
21
  * ⇒ the peer is just not
20
22
  * installed; retryable ⇒ a
21
- * short host-side cooldown)
23
+ * short host-side cooldown;
24
+ * cancelled ⇒ stopped by a
25
+ * cancel, not a fault)
22
26
  */
23
27
  import { parentPort } from 'node:worker_threads';
24
- import { PipelineLoadError, runEmbed, runProfileQueryEmbed } from './embed-core.js';
28
+ import { ModelWorkCancelledError, PipelineLoadError, cancelInFlightModelWork, runEmbed, runProfileQueryEmbed, } from './embed-core.js';
25
29
  if (!parentPort) {
26
30
  throw new Error('embed-worker must be run as a worker thread');
27
31
  }
28
32
  const port = parentPort;
29
33
  port.on('message', (msg) => {
34
+ if ('cancel' in msg) {
35
+ cancelInFlightModelWork();
36
+ return;
37
+ }
30
38
  void (async () => {
31
39
  try {
32
40
  const vectors = msg.profile
@@ -43,6 +51,7 @@ port.on('message', (msg) => {
43
51
  fatal: err instanceof PipelineLoadError && !err.retryable,
44
52
  retryable: err instanceof PipelineLoadError && err.retryable,
45
53
  optionalPeerMissing: err instanceof PipelineLoadError && err.optionalPeerMissing,
54
+ cancelled: err instanceof ModelWorkCancelledError,
46
55
  });
47
56
  }
48
57
  })();
@@ -35,6 +35,15 @@ export declare class EmbeddingsUnavailableError extends EmbeddingsDisabledError
35
35
  readonly retryable = true;
36
36
  constructor(reason: string);
37
37
  }
38
+ /**
39
+ * The service is stopping. Callers that keep per-item retry budgets check
40
+ * `shuttingDown` so a quit never counts against a file the way a real model
41
+ * failure does.
42
+ */
43
+ export declare class EmbeddingsShuttingDownError extends EmbeddingsUnavailableError {
44
+ readonly shuttingDown = true;
45
+ constructor();
46
+ }
38
47
  export type EmbeddingPipelineStatus = 'cold' | 'warming' | 'ready' | 'disabled' | 'unavailable';
39
48
  export declare function embeddingsDisabledReason(): string | null;
40
49
  export declare function embeddingPipelineStatus(): EmbeddingPipelineStatus;
@@ -49,8 +58,14 @@ export declare function embeddingsHealth(): {
49
58
  };
50
59
  /**
51
60
  * Close for service shutdown: refuse new work (it would start a worker, or
52
- * load the model in-process, as the process exits), then terminate the worker
53
- * once its in-flight work has finished, waiting at most `drainMs`.
61
+ * load the model in-process, as the process exits), cancel what is in flight,
62
+ * then terminate the worker once it is idle, waiting at most `drainMs`.
63
+ *
64
+ * The cancel is what makes the wait short: a first-run model download stops at
65
+ * once, and an embedding run stops at its next sub-batch. Without it the drain
66
+ * expired with the download still going, and every caller awaiting that
67
+ * embedding (index enrichment among them) held shutdown until the app's quit
68
+ * timeout.
54
69
  */
55
70
  export declare function shutdownEmbeddings(drainMs: number): Promise<void>;
56
71
  /** Reopen after shutdown — an embedded service can be started again in the same process. */
@@ -21,7 +21,7 @@ import { Worker } from 'node:worker_threads';
21
21
  import { createLogger, sameVectorSpace } from '@bendyline/gezel';
22
22
  import { retireModelWorker } from '../utils/retire-model-worker.js';
23
23
  import { findServiceWorkerEntry } from '../utils/service-worker-entry.js';
24
- import { PipelineLoadError, daemonEmbedderPin, daemonEmbedderVerified, passageInstruction, queryInstruction, runEmbed, runProfileQueryEmbed, } from './embed-core.js';
24
+ import { ModelWorkCancelledError, PipelineLoadError, cancelInFlightModelWork, daemonEmbedderPin, daemonEmbedderVerified, passageInstruction, queryInstruction, runEmbed, runProfileQueryEmbed, } from './embed-core.js';
25
25
  const log = createLogger('memory');
26
26
  /**
27
27
  * Marker class so callers can tell "the indexer is off on purpose" apart from a
@@ -46,6 +46,18 @@ export class EmbeddingsUnavailableError extends EmbeddingsDisabledError {
46
46
  this.message = `memory embeddings temporarily unavailable: ${reason}`;
47
47
  }
48
48
  }
49
+ /**
50
+ * The service is stopping. Callers that keep per-item retry budgets check
51
+ * `shuttingDown` so a quit never counts against a file the way a real model
52
+ * failure does.
53
+ */
54
+ export class EmbeddingsShuttingDownError extends EmbeddingsUnavailableError {
55
+ shuttingDown = true;
56
+ constructor() {
57
+ super('the service is shutting down');
58
+ this.name = 'EmbeddingsShuttingDownError';
59
+ }
60
+ }
49
61
  // Once the model fails to load, we permanently flip into a disabled state.
50
62
  // Every subsequent call throws the short marker error instead of re-attempting
51
63
  // the import and re-printing the full model-load error on every chat turn.
@@ -142,6 +154,11 @@ function onMessage(msg) {
142
154
  pending.delete(msg.id);
143
155
  if (pending.size === 0)
144
156
  worker?.unref();
157
+ if (msg.cancelled) {
158
+ // Our own shutdown cancel: no cooldown, no profile outage, nothing disabled.
159
+ p.reject(new EmbeddingsShuttingDownError());
160
+ return;
161
+ }
145
162
  if (msg.error) {
146
163
  if (p.profileId) {
147
164
  if (!p.localFilesOnly && (msg.fatal || msg.retryable))
@@ -214,6 +231,8 @@ async function embedInProcess(texts) {
214
231
  return await runEmbed(texts);
215
232
  }
216
233
  catch (err) {
234
+ if (err instanceof ModelWorkCancelledError)
235
+ throw new EmbeddingsShuttingDownError();
217
236
  if (err instanceof PipelineLoadError) {
218
237
  if (err.retryable) {
219
238
  markTemporarilyUnavailable(err.message);
@@ -256,17 +275,27 @@ function markDisabled(message, optionalPeerMissing = false) {
256
275
  log.error('[memory] failed to load embedding pipeline; vector memory is now disabled for this process.');
257
276
  log.error('[memory] underlying error:', message);
258
277
  }
259
- const SHUTTING_DOWN = 'the service is shutting down';
260
278
  /**
261
279
  * Close for service shutdown: refuse new work (it would start a worker, or
262
- * load the model in-process, as the process exits), then terminate the worker
263
- * once its in-flight work has finished, waiting at most `drainMs`.
280
+ * load the model in-process, as the process exits), cancel what is in flight,
281
+ * then terminate the worker once it is idle, waiting at most `drainMs`.
282
+ *
283
+ * The cancel is what makes the wait short: a first-run model download stops at
284
+ * once, and an embedding run stops at its next sub-batch. Without it the drain
285
+ * expired with the download still going, and every caller awaiting that
286
+ * embedding (index enrichment among them) held shutdown until the app's quit
287
+ * timeout.
264
288
  */
265
289
  export async function shutdownEmbeddings(drainMs) {
266
290
  closed = true;
267
291
  const w = worker;
268
- if (!w)
292
+ if (!w) {
293
+ // In-process fallback: any run in flight is on this thread.
294
+ cancelInFlightModelWork();
269
295
  return;
296
+ }
297
+ if (pending.size > 0)
298
+ w.postMessage({ cancel: true });
270
299
  if (await retireModelWorker(w, () => pending.size === 0, drainMs)) {
271
300
  if (worker === w)
272
301
  worker = null;
@@ -281,7 +310,7 @@ export function openEmbeddings() {
281
310
  /** Embed `texts`, preferring the worker and degrading to in-process. */
282
311
  async function embedMany(texts) {
283
312
  if (closed)
284
- throw new EmbeddingsUnavailableError(SHUTTING_DOWN);
313
+ throw new EmbeddingsShuttingDownError();
285
314
  if (disabledByEnv())
286
315
  throw new EmbeddingsDisabledError(ENV_DISABLED_REASON);
287
316
  if (disabledReason)
@@ -368,7 +397,7 @@ export function sharesDaemonEmbedder(profile) {
368
397
  export async function embedKnowledgeQuery(text, profile, opts = {}) {
369
398
  const { onDownloadProgress, localFilesOnly = false } = opts;
370
399
  if (closed)
371
- throw new EmbeddingsUnavailableError(SHUTTING_DOWN);
400
+ throw new EmbeddingsShuttingDownError();
372
401
  // The default daemon pipeline may fetch weights on a cold load.
373
402
  if (!localFilesOnly && sharesDaemonEmbedder(profile)) {
374
403
  const vector = await embedQuery(text);
@@ -111,6 +111,8 @@ export interface CreateEnsureModelOptions {
111
111
  backend: EnsureBackend;
112
112
  catalogId: string;
113
113
  }) => void;
114
+ /** Test seam for catalog backend exclusions — defaults to the process's own. */
115
+ resolveLlamaBackend?: () => string | undefined;
114
116
  }
115
117
  /**
116
118
  * Parse a qualified id (`<backend>:<catalogId>`) into its parts. Bare
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { llamaCppBackendExclusion, llamaCppBackendLabel } from '@bendyline/gezel';
3
+ import { currentLlamaCppBackend } from '../providers/llama-cpp/backend-health.js';
2
4
  /**
3
5
  * Error class with a structured `code` so the route can pick the
4
6
  * right HTTP status. Avoids regex-matching error message strings.
@@ -160,6 +162,13 @@ export async function createEnsureModelOrchestrator(opts) {
160
162
  ].filter((id) => Boolean(id));
161
163
  throw new KnownEnsureError('no_source_for_backend', `chat-model "${catalogId}" has no ${backend} source${alternatives.length > 0 ? ` — try ${alternatives.map((id) => `"${id}"`).join(' or ')}` : ''}`);
162
164
  }
165
+ if (backend === 'llama-cpp') {
166
+ const engineBackend = (opts.resolveLlamaBackend ?? currentLlamaCppBackend)();
167
+ const excluded = llamaCppBackendExclusion(detail.manifest.llamaCpp, engineBackend);
168
+ if (excluded && engineBackend) {
169
+ throw new KnownEnsureError('no_source_for_backend', `chat-model "${catalogId}" is not available on the ${llamaCppBackendLabel(engineBackend)} llama.cpp engine: ${excluded}${detail.manifest.mlx ? ` Try "mlx:${catalogId}".` : ''}`);
170
+ }
171
+ }
163
172
  if (await isInstalled(backend, catalogId)) {
164
173
  return { status: 'ready', modelId: qualifiedModelId };
165
174
  }
@@ -2,7 +2,7 @@ import { type ClientPerfReport, type PerfSnapshot, type PerfWork } from '@bendyl
2
2
  /**
3
3
  * A gap is measured to within one beat, so this has to stay well inside
4
4
  * `STALL_RECORD_MS`. At the same period, a pause long enough to log leaves the
5
- * watchdog late by more than half the gap, which is what `isMainThreadBlock`
5
+ * watchdog late by more than half the heartbeat's lateness, which is what `isMainThreadBlock`
6
6
  * needs to rule the main thread out.
7
7
  */
8
8
  export declare const BEAT_MS = 250;
@@ -18,8 +18,21 @@ interface WatchdogReport {
18
18
  * A stale heartbeat is a main-thread block only when the watchdog itself kept
19
19
  * time. When the watchdog was late by a comparable amount, the process or the
20
20
  * host was paused, and blaming whatever was in flight would be a false lead.
21
+ *
22
+ * Lateness against lateness: both threads sleep a beat between checks, so a
23
+ * pause delays each by about its length. Comparing the watchdog's lateness
24
+ * with the whole gap, beat included, filed short pauses as blocks.
21
25
  */
22
26
  export declare function isMainThreadBlock(report: WatchdogReport): boolean;
27
+ /** What a gap cost: the main thread's CPU, and the process's major page faults. */
28
+ export interface StallCost {
29
+ /** Null where the runtime has no per-thread CPU accounting. */
30
+ cpuMs: number | null;
31
+ pageFaults: number;
32
+ }
33
+ /** Running code when it used at least half the stall; otherwise paging or waiting on something. */
34
+ export declare function stallVerdict(durationMs: number, cost: StallCost): 'running code' | 'paging' | 'blocked waiting' | undefined;
35
+ export declare function describeStallCost(durationMs: number, cost: StallCost): string;
23
36
  interface ActiveWork {
24
37
  label: string;
25
38
  startedAt: number;
@@ -33,7 +33,7 @@ const log = createLogger('perf');
33
33
  /**
34
34
  * A gap is measured to within one beat, so this has to stay well inside
35
35
  * `STALL_RECORD_MS`. At the same period, a pause long enough to log leaves the
36
- * watchdog late by more than half the gap, which is what `isMainThreadBlock`
36
+ * watchdog late by more than half the heartbeat's lateness, which is what `isMainThreadBlock`
37
37
  * needs to rule the main thread out.
38
38
  */
39
39
  export const BEAT_MS = 250;
@@ -55,10 +55,17 @@ const PROFILE_WINDOW_MS = 60_000;
55
55
  const PROFILE_SAMPLING_US = 5_000;
56
56
  const PROFILE_KEEP = 20;
57
57
  const LABEL_MAX = 200;
58
+ /**
59
+ * A watchdog tick later than this is kept. Half the smallest recordable
60
+ * lateness: a shorter one cannot rule the main thread out. The floor was a
61
+ * whole period, which threw away the witness's own evidence of a sub-second
62
+ * pause of the whole process, so such pauses were filed as main-thread blocks
63
+ * (idle processes on a swapping machine collected dozens a minute).
64
+ */
65
+ const WATCHDOG_KEEP_LATE_MS = (STALL_RECORD_MS - BEAT_MS) / 2;
58
66
  /**
59
67
  * The witness. It keeps its own late ticks and, asked about a gap, answers
60
- * with the worst one since the gap began. A tick late by less than a period is
61
- * not kept: it cannot reach half of a recordable gap.
68
+ * with the worst one since the gap began.
62
69
  *
63
70
  * It sleeps on a futex instead of running an event loop: a worker woken by its
64
71
  * own timer cost about 1.6 context switches per tick on Windows, against 1.0
@@ -69,6 +76,7 @@ const LABEL_MAX = 200;
69
76
  const WATCHDOG_SOURCE = `
70
77
  const { parentPort, receiveMessageOnPort, workerData } = require('node:worker_threads');
71
78
  const tickMs = workerData.tickMs;
79
+ const keepLateMs = workerData.keepLateMs;
72
80
  const asked = new Int32Array(workerData.asked);
73
81
  const lateTicks = [];
74
82
  let lastTick = Date.now();
@@ -78,7 +86,7 @@ for (;;) {
78
86
  if (now - lastTick >= tickMs) {
79
87
  const lateMs = now - lastTick - tickMs;
80
88
  lastTick = now;
81
- if (lateMs >= tickMs) {
89
+ if (lateMs >= keepLateMs) {
82
90
  lateTicks.push({ at: now, lateMs });
83
91
  if (lateTicks.length > 32) lateTicks.shift();
84
92
  }
@@ -98,9 +106,52 @@ for (;;) {
98
106
  * A stale heartbeat is a main-thread block only when the watchdog itself kept
99
107
  * time. When the watchdog was late by a comparable amount, the process or the
100
108
  * host was paused, and blaming whatever was in flight would be a false lead.
109
+ *
110
+ * Lateness against lateness: both threads sleep a beat between checks, so a
111
+ * pause delays each by about its length. Comparing the watchdog's lateness
112
+ * with the whole gap, beat included, filed short pauses as blocks.
101
113
  */
102
114
  export function isMainThreadBlock(report) {
103
- return report.watchdogLateMs < report.durationMs / 2;
115
+ return report.watchdogLateMs < (report.durationMs - BEAT_MS) / 2;
116
+ }
117
+ /** Fewer page faults than this in a stall is ordinary; more, with the thread idle, is swap. */
118
+ const PAGING_MIN_FAULTS = 10;
119
+ /**
120
+ * Read at every beat. Both are single syscalls and add no wakeups. A stall's
121
+ * duration alone cannot say whether code ran: on a memory-starved machine most
122
+ * logged stalls were the thread waiting for its own pages to come back from
123
+ * swap, and their CPU profiles held no samples at all.
124
+ */
125
+ function readStallCounters() {
126
+ const threadCpuUsage = process.threadCpuUsage;
127
+ const cpu = typeof threadCpuUsage === 'function' ? threadCpuUsage() : null;
128
+ return {
129
+ cpuMs: cpu ? (cpu.user + cpu.system) / 1_000 : null,
130
+ pageFaults: process.resourceUsage().majorPageFault,
131
+ };
132
+ }
133
+ function stallCostBetween(before, after) {
134
+ return {
135
+ cpuMs: before.cpuMs !== null && after.cpuMs !== null
136
+ ? Math.max(0, Math.round(after.cpuMs - before.cpuMs))
137
+ : null,
138
+ pageFaults: Math.max(0, after.pageFaults - before.pageFaults),
139
+ };
140
+ }
141
+ /** Running code when it used at least half the stall; otherwise paging or waiting on something. */
142
+ export function stallVerdict(durationMs, cost) {
143
+ if (cost.cpuMs === null)
144
+ return undefined;
145
+ if (cost.cpuMs >= durationMs / 2)
146
+ return 'running code';
147
+ return cost.pageFaults >= PAGING_MIN_FAULTS ? 'paging' : 'blocked waiting';
148
+ }
149
+ export function describeStallCost(durationMs, cost) {
150
+ const faults = `${cost.pageFaults} page fault${cost.pageFaults === 1 ? '' : 's'}`;
151
+ const verdict = stallVerdict(durationMs, cost);
152
+ if (cost.cpuMs === null)
153
+ return faults;
154
+ return `cpu ${formatPerfMs(cost.cpuMs)}, ${faults}${verdict ? `: ${verdict}` : ''}`;
104
155
  }
105
156
  /**
106
157
  * Work that overlapped `[start, end]`, most suspicious first. Running work
@@ -295,6 +346,10 @@ class ResponsivenessMonitor {
295
346
  */
296
347
  awaitingVerdicts = 0;
297
348
  rollDeferred = false;
349
+ /** Counters at the last beat, so a gap's cost is the difference at the next one. */
350
+ lastBeatCost = readStallCounters();
351
+ /** What each gap awaiting a verdict cost, keyed by its start. */
352
+ gapCosts = new Map();
298
353
  constructor(opts) {
299
354
  this.beatTimer = setInterval(() => this.beat(), BEAT_MS);
300
355
  this.beatTimer.unref?.();
@@ -396,11 +451,15 @@ class ResponsivenessMonitor {
396
451
  this.lastBeatAt = now;
397
452
  const gapMs = now - startedAt;
398
453
  this.delay.record(Math.max(1, Math.round((gapMs - BEAT_MS) * 1e6)));
454
+ const counters = readStallCounters();
455
+ const before = this.lastBeatCost;
456
+ this.lastBeatCost = counters;
399
457
  if (gapMs < STALL_RECORD_MS || !this.watchdog) {
400
458
  if (this.rollDeferred && this.awaitingVerdicts === 0)
401
459
  this.rollProfile();
402
460
  return;
403
461
  }
462
+ this.gapCosts.set(startedAt, stallCostBetween(before, counters));
404
463
  this.awaitingVerdicts += 1;
405
464
  this.watchdog.postMessage({ startedAt, durationMs: gapMs });
406
465
  Atomics.store(this.watchdogAsked, 0, 1);
@@ -420,7 +479,11 @@ class ResponsivenessMonitor {
420
479
  try {
421
480
  const worker = new Worker(WATCHDOG_SOURCE, {
422
481
  eval: true,
423
- workerData: { tickMs: WATCHDOG_TICK_MS, asked: this.watchdogAsked.buffer },
482
+ workerData: {
483
+ tickMs: WATCHDOG_TICK_MS,
484
+ keepLateMs: WATCHDOG_KEEP_LATE_MS,
485
+ asked: this.watchdogAsked.buffer,
486
+ },
424
487
  });
425
488
  worker.on('message', (report) => this.onWatchdogReport(report));
426
489
  worker.on('error', (err) => {
@@ -440,13 +503,19 @@ class ResponsivenessMonitor {
440
503
  this.rollProfile();
441
504
  }
442
505
  handleReport(report) {
506
+ const cost = this.gapCosts.get(report.startedAt);
507
+ this.gapCosts.delete(report.startedAt);
443
508
  if (!isMainThreadBlock(report))
444
509
  return;
445
510
  const now = Date.now();
511
+ const verdict = cost ? stallVerdict(report.durationMs, cost) : undefined;
446
512
  const stall = {
447
513
  at: new Date(report.startedAt).toISOString(),
448
514
  durationMs: report.durationMs,
449
515
  during: workDuring(report.startedAt, report.startedAt + report.durationMs, this.active.values(), this.finished, now),
516
+ ...(cost?.cpuMs != null ? { cpuMs: cost.cpuMs } : {}),
517
+ ...(cost ? { pageFaults: cost.pageFaults } : {}),
518
+ ...(verdict ? { verdict } : {}),
450
519
  };
451
520
  pushRing(this.stalls, stall);
452
521
  if (report.durationMs < STALL_LOG_MS)
@@ -454,7 +523,8 @@ class ResponsivenessMonitor {
454
523
  const running = stall.during.length > 0
455
524
  ? stall.during.map((w) => `${w.label} (${formatPerfMs(w.durationMs)})`).join(', ')
456
525
  : 'nothing tracked';
457
- const line = `main thread blocked for ${formatPerfMs(report.durationMs)} — running: ${running}`;
526
+ const costText = cost ? ` (${describeStallCost(report.durationMs, cost)})` : '';
527
+ const line = `main thread blocked for ${formatPerfMs(report.durationMs)}${costText} — running: ${running}`;
458
528
  if (report.durationMs >= STALL_WARN_MS)
459
529
  log.warn(line);
460
530
  else
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Whether llama-server is getting the hardware this machine has, as the
3
+ * engine process sees it: the CUDA build discovery passed over for the
4
+ * driver (`GEZEL_LLAMA_CUDA_BLOCKER`), a GPU build that started without its
5
+ * GPU (gpu-startup-watch.ts), and Linux's out-of-memory killer stopping the
6
+ * engine. Each is invisible otherwise — the engine keeps answering, slower,
7
+ * or a chat fails with a crash that reads like any other.
8
+ *
9
+ * Process-wide like the backend env it sits beside: every llama provider in
10
+ * this process launches the same build.
11
+ */
12
+ import type { LlamaCppBackendHealthResponse } from '@bendyline/gezel';
13
+ import type { GpuUnused } from './gpu-startup-watch.js';
14
+ /** Record what the latest start of a build found; null clears it. */
15
+ export declare function noteGpuStartup(verdict: GpuUnused | null, now?: Date): void;
16
+ export declare function noteHostOutOfMemory(input: {
17
+ model?: string | undefined;
18
+ whileStarting: boolean;
19
+ }, now?: Date): void;
20
+ declare const BACKENDS: readonly ["cuda", "vulkan", "metal", "cpu"];
21
+ /**
22
+ * The backend this process's on-device llama-server runs, for catalog
23
+ * backend exclusions: the build discovery resolved, else what the probe
24
+ * found, else the only build macOS ships. Undefined when an external server
25
+ * is configured through the environment — its backend is not ours to know.
26
+ */
27
+ export declare function currentLlamaCppBackend(): (typeof BACKENDS)[number] | undefined;
28
+ export declare function llamaBackendHealthView(): LlamaCppBackendHealthResponse;
29
+ export declare function __resetLlamaBackendHealthForTest(): void;
30
+ export {};
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Whether llama-server is getting the hardware this machine has, as the
3
+ * engine process sees it: the CUDA build discovery passed over for the
4
+ * driver (`GEZEL_LLAMA_CUDA_BLOCKER`), a GPU build that started without its
5
+ * GPU (gpu-startup-watch.ts), and Linux's out-of-memory killer stopping the
6
+ * engine. Each is invisible otherwise — the engine keeps answering, slower,
7
+ * or a chat fails with a crash that reads like any other.
8
+ *
9
+ * Process-wide like the backend env it sits beside: every llama provider in
10
+ * this process launches the same build.
11
+ */
12
+ import { readLlamaCudaBlocker } from '@bendyline/gezel/native';
13
+ let gpuUnused = null;
14
+ let outOfMemory = null;
15
+ /** Record what the latest start of a build found; null clears it. */
16
+ export function noteGpuStartup(verdict, now = new Date()) {
17
+ gpuUnused = verdict ? { ...verdict, at: now.toISOString() } : null;
18
+ }
19
+ export function noteHostOutOfMemory(input, now = new Date()) {
20
+ outOfMemory = {
21
+ count: (outOfMemory?.count ?? 0) + 1,
22
+ at: now.toISOString(),
23
+ ...(input.model ? { model: input.model } : {}),
24
+ whileStarting: input.whileStarting,
25
+ };
26
+ }
27
+ const BACKENDS = ['cuda', 'vulkan', 'metal', 'cpu'];
28
+ /**
29
+ * The backend this process's on-device llama-server runs, for catalog
30
+ * backend exclusions: the build discovery resolved, else what the probe
31
+ * found, else the only build macOS ships. Undefined when an external server
32
+ * is configured through the environment — its backend is not ours to know.
33
+ */
34
+ export function currentLlamaCppBackend() {
35
+ if (process.env.GEZEL_LLAMA_SERVER_URL)
36
+ return undefined;
37
+ for (const raw of [
38
+ process.env.GEZEL_LLAMA_SERVER_BACKEND,
39
+ process.env.GEZEL_LLAMA_DETECTED_BACKEND,
40
+ ]) {
41
+ const backend = BACKENDS.find((b) => b === raw);
42
+ if (backend)
43
+ return backend;
44
+ }
45
+ return process.platform === 'darwin' ? 'metal' : undefined;
46
+ }
47
+ export function llamaBackendHealthView() {
48
+ const runningRaw = process.env.GEZEL_LLAMA_SERVER_BACKEND;
49
+ const running = BACKENDS.find((backend) => backend === runningRaw);
50
+ const cudaBlocker = readLlamaCudaBlocker();
51
+ // A verdict about a build the process has since moved off (a quarantine
52
+ // fallback, a lifted quarantine) no longer describes what runs.
53
+ const unused = gpuUnused && gpuUnused.backend === running ? gpuUnused : null;
54
+ return {
55
+ ...(running ? { running } : {}),
56
+ ...(cudaBlocker ? { cudaBlocker } : {}),
57
+ ...(unused ? { gpuUnused: unused } : {}),
58
+ ...(outOfMemory ? { outOfMemory } : {}),
59
+ };
60
+ }
61
+ export function __resetLlamaBackendHealthForTest() {
62
+ gpuUnused = null;
63
+ outOfMemory = null;
64
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The engine process's side of the llama-server quarantine
3
+ * (`llama-quarantine.ts` in `@bendyline/gezel/native`): which build this
4
+ * process launches, kept in the env discovery stamps at startup
5
+ * (`GEZEL_LLAMA_SERVER_BIN`, `GEZEL_LLAMA_SERVER_BACKEND`,
6
+ * `GEZEL_LLAMA_QUARANTINED`), and the view and undo behind the engine's
7
+ * Settings tab.
8
+ *
9
+ * Discovery reads the quarantine only at startup. These move the env inside
10
+ * a running process, so a quarantine and its undo both take effect on the
11
+ * next engine launch instead of the next app start.
12
+ */
13
+ import type { LlamaCppQuarantineResponse } from '@bendyline/gezel';
14
+ import { type LlamaBackend, type ResolvedLlamaBinary } from '@bendyline/gezel/native';
15
+ export declare function isLlamaBackend(value: string): value is LlamaBackend;
16
+ /**
17
+ * Point this process at the build that replaces `backend`'s, just
18
+ * quarantined, so the pool's rebuild of the failed engine launches it. Null,
19
+ * env untouched, when nothing usable is bundled beside `binary`.
20
+ */
21
+ export declare function adoptLlamaFallback(home: string, binary: string, backend: LlamaBackend): ResolvedLlamaBinary | null;
22
+ /**
23
+ * The quarantined builds that still apply. An entry pins its binary's bytes,
24
+ * so one whose build an update replaced is left out — discovery ignores it
25
+ * too. Without a resolved build to measure from, entries are shown as written.
26
+ */
27
+ export declare function llamaQuarantineView(home: string): LlamaCppQuarantineResponse;
28
+ /**
29
+ * Lift `backend`'s quarantine at a person's request. Unless they pinned a
30
+ * backend, also point this process back at the best build the hardware
31
+ * supports, so the next engine launch tries it; `restored` is that build when
32
+ * the env moved. A pinned backend is launched as pinned and was never routed
33
+ * around, so there is nothing to move back.
34
+ */
35
+ export declare function liftLlamaQuarantine(home: string, backend: LlamaBackend, opts: {
36
+ pinned: boolean;
37
+ }): {
38
+ cleared: boolean;
39
+ restored: ResolvedLlamaBinary | null;
40
+ };