@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
@@ -0,0 +1,100 @@
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 { LLAMA_QUARANTINE_STRIKES, clearLlamaQuarantine, createLlamaBuildCheck, isBinaryQuarantined, llamaVariantBinaryPath, publishLlamaCudaBlocker, readLlamaQuarantine, resolveLlamaFallbackBinary, resolveLlamaVariantBinary, } from '@bendyline/gezel/native';
14
+ const BACKENDS = ['cuda', 'vulkan', 'metal', 'cpu'];
15
+ export function isLlamaBackend(value) {
16
+ return BACKENDS.some((backend) => backend === value);
17
+ }
18
+ function envBackend(name) {
19
+ const raw = process.env[name];
20
+ return raw !== undefined && isLlamaBackend(raw) ? raw : undefined;
21
+ }
22
+ function updateQuarantinedEnv(update) {
23
+ const backends = new Set(process.env.GEZEL_LLAMA_QUARANTINED?.split(',').filter(Boolean));
24
+ update(backends);
25
+ if (backends.size > 0)
26
+ process.env.GEZEL_LLAMA_QUARANTINED = [...backends].join(',');
27
+ else
28
+ delete process.env.GEZEL_LLAMA_QUARANTINED;
29
+ }
30
+ /**
31
+ * Point this process at the build that replaces `backend`'s, just
32
+ * quarantined, so the pool's rebuild of the failed engine launches it. Null,
33
+ * env untouched, when nothing usable is bundled beside `binary`.
34
+ */
35
+ export function adoptLlamaFallback(home, binary, backend) {
36
+ const quarantine = readLlamaQuarantine(home);
37
+ const fallback = resolveLlamaFallbackBinary(binary, backend, (candidate, path) => !isBinaryQuarantined(quarantine, candidate, path));
38
+ if (!fallback)
39
+ return null;
40
+ process.env.GEZEL_LLAMA_SERVER_BIN = fallback.path;
41
+ process.env.GEZEL_LLAMA_SERVER_BACKEND = fallback.backend;
42
+ updateQuarantinedEnv((backends) => backends.add(backend));
43
+ return fallback;
44
+ }
45
+ /**
46
+ * The quarantined builds that still apply. An entry pins its binary's bytes,
47
+ * so one whose build an update replaced is left out — discovery ignores it
48
+ * too. Without a resolved build to measure from, entries are shown as written.
49
+ */
50
+ export function llamaQuarantineView(home) {
51
+ const binary = process.env.GEZEL_LLAMA_SERVER_BIN;
52
+ const running = envBackend('GEZEL_LLAMA_SERVER_BACKEND');
53
+ const entries = readLlamaQuarantine(home).filter((entry) => {
54
+ if (!isLlamaBackend(entry.backend))
55
+ return false;
56
+ if (!binary || !running)
57
+ return true;
58
+ const path = llamaVariantBinaryPath(binary, running, entry.backend);
59
+ return path === null || isBinaryQuarantined([entry], entry.backend, path);
60
+ });
61
+ return {
62
+ entries: entries.map(({ backend, signal, reason, at, crashes }) => ({
63
+ backend,
64
+ signal,
65
+ reason,
66
+ at,
67
+ ...(crashes !== undefined ? { crashes } : {}),
68
+ })),
69
+ strikeThreshold: LLAMA_QUARANTINE_STRIKES,
70
+ ...(running ? { running } : {}),
71
+ };
72
+ }
73
+ /**
74
+ * Lift `backend`'s quarantine at a person's request. Unless they pinned a
75
+ * backend, also point this process back at the best build the hardware
76
+ * supports, so the next engine launch tries it; `restored` is that build when
77
+ * the env moved. A pinned backend is launched as pinned and was never routed
78
+ * around, so there is nothing to move back.
79
+ */
80
+ export function liftLlamaQuarantine(home, backend, opts) {
81
+ const cleared = clearLlamaQuarantine(home, backend);
82
+ updateQuarantinedEnv((backends) => backends.delete(backend));
83
+ const binary = process.env.GEZEL_LLAMA_SERVER_BIN;
84
+ const running = envBackend('GEZEL_LLAMA_SERVER_BACKEND');
85
+ if (opts.pinned || !binary || !running)
86
+ return { cleared, restored: null };
87
+ // Discovery's own check: a CUDA build the driver cannot run stays passed
88
+ // over, whatever else was lifted.
89
+ const check = createLlamaBuildCheck({
90
+ quarantine: readLlamaQuarantine(home),
91
+ platform: process.platform,
92
+ });
93
+ const best = resolveLlamaVariantBinary(binary, running, envBackend('GEZEL_LLAMA_DETECTED_BACKEND') ?? backend, check.isUsable);
94
+ publishLlamaCudaBlocker(check.cudaBlocker());
95
+ if (!best || best.backend === running)
96
+ return { cleared, restored: null };
97
+ process.env.GEZEL_LLAMA_SERVER_BIN = best.path;
98
+ process.env.GEZEL_LLAMA_SERVER_BACKEND = best.backend;
99
+ return { cleared, restored: best };
100
+ }
@@ -1,7 +1,7 @@
1
1
  import { availableParallelism } from 'node:os';
2
2
  import { delimiter, dirname, join } from 'node:path';
3
- import { DEFAULT_LOCAL_ENGINE_IDLE_TIMEOUT_MS, createLogger, estimateLlamaCppResidentBytes, } from '@bendyline/gezel';
4
- import { recordLlamaQuarantine } from '@bendyline/gezel/native';
3
+ import { DEFAULT_LOCAL_ENGINE_IDLE_TIMEOUT_MS, createLogger, estimateLlamaCppResidentBytes, llamaCppBackendExclusion, llamaCppBackendLabel, } from '@bendyline/gezel';
4
+ import { LLAMA_QUARANTINE_STRIKES, clearLlamaStartupStrikes, recordLlamaStartupCrash, unrunnableBuildExit, } from '@bendyline/gezel/native';
5
5
  import { gezelPaths } from '@bendyline/gezel/paths';
6
6
  import { mmprojBudgetBytes, nativeVisionEnabledFor } from '../../chat/vision-capability.js';
7
7
  import { effectiveEngineRelease, isEnginePinned } from '../../engines/native-manifest.js';
@@ -15,13 +15,16 @@ import { makeEngineKey } from '../native/engine-key.js';
15
15
  import { pickFreePort } from '../native/port.js';
16
16
  import { NativeEngineSupervisor } from '../native/supervisor.js';
17
17
  import { patientFetch } from '../patient-fetch.js';
18
+ import { currentLlamaCppBackend, noteGpuStartup, noteHostOutOfMemory } from './backend-health.js';
19
+ import { adoptLlamaFallback } from './backend-quarantine.js';
18
20
  import { lastArgValue, readLlamaCppBuildMetadata } from './build-metadata.js';
19
21
  import { matchNvidiaRuntimeDevice, maxGpuVramBytes, pickBestGpuDevice, probeLlamaDevices, probeNvidiaRuntimeDevices, } from './devices.js';
20
22
  import { buildLlamaCppEngineArgs } from './engine-flags.js';
21
23
  import { readGgufSummaryAsync } from './gguf-metadata-async.js';
22
24
  import { effectiveLazyTensorBytes } from './gguf-metadata.js';
25
+ import { GpuStartupWatch } from './gpu-startup-watch.js';
23
26
  import { isGemmaModel, planLlamaCppKv, resolveLlamaCppKvCacheType, } from './kv-cache-type.js';
24
- import { degradeDenseFfnOffloadDecision, degradeMoeOffloadDecision, estimateExactPerSlotKvBytesF16, estimateKvReserveBytes, estimateWindowedKvLinearization, fitsSwaFullInFastMemory, planDenseFfnOffload, planMoeOffload, } from './offload-planner.js';
27
+ import { degradeDenseFfnOffloadDecision, degradeMoeOffloadDecision, estimateExactPerSlotKvBytesF16, estimateKvReserveBytes, estimateWindowedKvLinearization, planDenseFfnOffload, planMoeOffload, } from './offload-planner.js';
25
28
  import { LlamaCppProvider } from './provider.js';
26
29
  import { reasoningLaunchOverridesFromEnv } from './reasoning-launch.js';
27
30
  import { resolveSpecDraft } from './spec-draft.js';
@@ -69,6 +72,15 @@ export async function buildLlamaCppProvider(opts) {
69
72
  ? undefined
70
73
  : (process.env.GEZEL_LLAMA_SERVER_URL ?? config.llamaCppBaseUrl);
71
74
  const binary = process.env.GEZEL_LLAMA_SERVER_BIN;
75
+ // Read together with the binary: after a quarantine the env points at the
76
+ // fallback build, and a crash of THIS build must be charged to its own
77
+ // backend, not to whatever the env names by then.
78
+ const binaryBackend = process.env.GEZEL_LLAMA_SERVER_BACKEND;
79
+ // What the latest startup crash did to this build's standing, for the error
80
+ // the failed start raises. `onExit` runs before `ensureRunning` rejects.
81
+ let lastStartupCrash = null;
82
+ // Fresh for each launch: whether this start of a GPU build got its GPU.
83
+ let gpuWatch = null;
72
84
  // Preservation is a two-sided contract: launch llama-server with the
73
85
  // explicit preserve/no-preserve flag AND replay captured
74
86
  // `reasoning_content` only in the preserve case. v0.4.0 changed the server
@@ -269,6 +281,18 @@ export async function buildLlamaCppProvider(opts) {
269
281
  }
270
282
  throw notReadyError(noModelYetMessage('Local model', activeLlamaInstall(opts.llamaCppModels)), 'model-not-ready');
271
283
  }
284
+ // Install refuses an excluded backend; this catches a model installed
285
+ // before the exclusion was authored, or copied in by hand.
286
+ if (modelCatalogInfo && opts.catalog) {
287
+ const engineBackend = binaryBackend ?? currentLlamaCppBackend();
288
+ const detail = await opts.catalog.get('chat-model', modelCatalogInfo.id).catch(() => null);
289
+ const excluded = llamaCppBackendExclusion(detail?.manifest.kind === 'chat-model' ? detail.manifest.llamaCpp : undefined, engineBackend);
290
+ if (excluded && engineBackend) {
291
+ const err = new Error(`Local model: ${modelCatalogInfo.name} cannot run on the ${llamaCppBackendLabel(engineBackend)} llama.cpp engine. ${excluded} Pick another local model in Settings → ${localDeviceSettingsLabel()}.`);
292
+ err.isActionable = true;
293
+ throw err;
294
+ }
295
+ }
272
296
  // Per Phase 0 measurements on a cold Mac: Metal library compile is
273
297
  // ~14s and model load adds model-size-dependent time. Default
274
298
  // startupTimeoutMs is 60s which squeaks through for a 7B on a warm
@@ -621,13 +645,6 @@ export async function buildLlamaCppProvider(opts) {
621
645
  // back to the engine's own `--fit` / `-ngl auto`.
622
646
  let offloadDecision;
623
647
  let offloadBlockCount;
624
- // Whether weights + full-attention KV at the requested context fit the
625
- // fast-memory pool — VRAM on a discrete GPU. Gates the Gemma
626
- // `--swa-full` auto-default (see `EngineFlagInput.swaFullAutoFits`). Set
627
- // false to keep the full context on the windowed cache when Full would
628
- // spill or require a slot/context reduction; undefined means it fits or
629
- // the exact decision could not be computed.
630
- let swaFullAutoFits;
631
648
  // Broker-ledger reservation for this launch: resident weights + the KV
632
649
  // the engine will actually allocate at the granted window and cache
633
650
  // mode. Undefined when the GGUF is unreadable — the pool builder then
@@ -709,21 +726,16 @@ export async function buildLlamaCppProvider(opts) {
709
726
  // under GEZEL_LLAMA_NUM_CTX — eval runs lift ceilings deliberately
710
727
  // and accept the memory consequences.
711
728
  //
712
- // The fit verdict additionally gates the Gemma `--swa-full`
713
- // auto-default, in BOTH branches: the full cache is a cache-operation
714
- // performance trade, while the context window is capability. Auto
715
- // therefore requires resident weights + exact full KV to fit the fast
716
- // pool (VRAM on a discrete GPU); otherwise it keeps the full window on
717
- // the windowed cache. Shared-KV layers and SWA-specific head dimensions
718
- // are load-bearing here — E4B owns only 24 KV caches for 42 logical
719
- // layers, and pricing all 42 at global dimensions overstated its two-slot
720
- // 64K KV allocation as 21 GiB instead of llama.cpp's actual 7 GiB.
721
- // When the windowed cache is what will run, the launch is NOT left
722
- // ungated: admission re-plans with the windowed-KV linearization
723
- // (SWA layers = fixed window-capped bytes, global layers = the only
724
- // per-token cost), so a host too small even for the windowed cache
725
- // still denies or clamps honestly instead of OOMing one machine
726
- // class below the 2026-08-05 incident.
729
+ // A sliding-window model runs llama.cpp's windowed cache unless
730
+ // `swaFull` is pinned, and admission prices what runs: it re-plans
731
+ // with the windowed-KV linearization (SWA layers = fixed window-capped
732
+ // bytes, global layers = the only per-token cost), so a host too small
733
+ // even for the windowed cache still denies or clamps honestly instead
734
+ // of OOMing one machine class below the 2026-08-05 incident.
735
+ // Shared-KV layers and SWA-specific head dimensions are load-bearing
736
+ // in that math — E4B owns only 24 KV caches for 42 logical layers, and
737
+ // pricing all 42 at global dimensions overstated its two-slot 64K KV
738
+ // allocation as 21 GiB instead of llama.cpp's actual 7 GiB.
727
739
  {
728
740
  const REFERENCE_CTX = 4096;
729
741
  const exactKvAtReference = estimateKvReserveBytes({
@@ -769,23 +781,14 @@ export async function buildLlamaCppProvider(opts) {
769
781
  ? {
770
782
  unifiedMemory: liveMemory.gpuMemoryKind === 'integrated' ||
771
783
  liveMemory.gpuMemoryKind === 'unified',
784
+ reservedGpuBytes: liveMemory.gpuReservedBytes ?? null,
785
+ gpuAddressableBytes: liveMemory.gpuAddressableBytes ?? null,
772
786
  }
773
787
  : {}),
774
788
  });
775
789
  const fastBudgetBytes = brokerSnap?.enforced
776
790
  ? (opts.broker?.fastBudgetBytes() ?? liveBudget.fastBytes)
777
791
  : liveBudget.fastBytes;
778
- const fullKvBytesAtRequested = exactKvAtReference !== undefined
779
- ? (exactKvAtReference / REFERENCE_CTX) * effectiveNumCtx * slots
780
- : undefined;
781
- const fullCacheFitsFastMemory = fullKvBytesAtRequested !== undefined
782
- ? fitsSwaFullInFastMemory({
783
- residentWeightsBytes: residentBytes,
784
- fullKvBytes: fullKvBytesAtRequested,
785
- fastBudgetBytes,
786
- committedOtherBytes,
787
- })
788
- : undefined;
789
792
  const admission = planCtxTokensForMemory({
790
793
  requestedPerTurnCtxTokens: effectiveNumCtx,
791
794
  slots,
@@ -797,19 +800,13 @@ export async function buildLlamaCppProvider(opts) {
797
800
  freeSystemRamBytes: availableSystemRamBytes(),
798
801
  vramBytes: liveBudget.vramBytes,
799
802
  });
803
+ // `--swa-full` runs only when pinned; every other launch of a
804
+ // sliding-window model gets llama.cpp's windowed cache.
800
805
  const explicitSwaFull = config.llamaCppSwaFull ?? manifestEngineConfig?.swaFull;
801
- const swaFullAutoDefault = explicitSwaFull === undefined &&
802
- isGemmaModel({
803
- architecture: modelCatalogInfo?.architecture,
804
- modelId: defaultModelId ?? undefined,
805
- });
806
- const fullKvOverBudget = fullCacheFitsFastMemory === false ||
807
- !admission.minimumSatisfied ||
808
- admission.slots < slots ||
809
- admission.clamped;
806
+ const fullKvOverBudget = !admission.minimumSatisfied || admission.slots < slots || admission.clamped;
810
807
  // The plan whose ladder (deny → shed slots → clamp) is enforced
811
808
  // below. Defaults to the full-attention plan; the windowed-cache
812
- // branches replace it with windowed math or — only when the GGUF
809
+ // branch replaces it with windowed math or — only when the GGUF
813
810
  // hides its SWA layout — null (no safe estimate; launch untouched,
814
811
  // the pre-windowed-admission behavior).
815
812
  let ladderPlan = admission;
@@ -827,17 +824,17 @@ export async function buildLlamaCppProvider(opts) {
827
824
  // full-attention), and the strict minimum rides inside
828
825
  // `contextRequirement.minimumPerTurnCtxTokens`, so the windowed
829
826
  // re-plan below sheds slots or denies but never shortens the window.
830
- const windowedCacheWillRun = fullKvOverBudget && (swaFullAutoDefault || explicitSwaFull === false);
831
- if (windowedCacheWillRun) {
832
- // The denial, the slot reduction, and the clamp above are all
833
- // driven by full-attention KV math, which overstates the windowed
834
- // cache's real allocation (Gemma 4: global layers are both fewer
835
- // AND cheaper per token than SWA layers — ~14× on 31b). Re-plan
836
- // with the windowed linearization: SWA layers become a fixed,
837
- // context-independent reservation; only global layers scale.
838
- // Without it a 31b-class Gemma on a ~32 GB host would launch
839
- // with NO context admission at all — the same lazy-Metal-wiring
840
- // OOM this gate exists to prevent, one machine class down.
827
+ if (explicitSwaFull !== true) {
828
+ // The admission above is full-attention KV math, which overstates
829
+ // the windowed cache's real allocation (Gemma 4: global layers are
830
+ // both fewer AND cheaper per token than SWA layers — ~11× on 31b
831
+ // at 64K). Re-plan with the windowed linearization: SWA layers
832
+ // become a fixed, context-independent reservation; only global
833
+ // layers scale. Without it a 31b-class Gemma on a ~32 GB host
834
+ // would launch with NO context admission at all — the same
835
+ // lazy-Metal-wiring OOM this gate exists to prevent, one machine
836
+ // class down — and growth would price every token at the full
837
+ // slope.
841
838
  const windowed = estimateWindowedKvLinearization({
842
839
  blockCount: summary.blockCount,
843
840
  embeddingLength: summary.embeddingLength,
@@ -858,23 +855,6 @@ export async function buildLlamaCppProvider(opts) {
858
855
  ssmConvKernel: summary.ssmConvKernel,
859
856
  kvCacheType,
860
857
  });
861
- if (swaFullAutoDefault) {
862
- // Declining `--swa-full` dominates every full-math remedy:
863
- // the context window is capability, the full cache is a
864
- // session-switch performance trade. Weights-level admission
865
- // stays with the capacity broker.
866
- swaFullAutoFits = false;
867
- const fastFitDetail = fullCacheFitsFastMemory === false && fullKvBytesAtRequested !== undefined
868
- ? `projected weights + full KV ${((residentBytes + fullKvBytesAtRequested) / 1024 ** 3).toFixed(1)} GiB exceeds available fast memory ${(Math.max(0, fastBudgetBytes - committedOtherBytes) / 1024 ** 3).toFixed(1)} GiB`
869
- : undefined;
870
- log.warn([
871
- `[llama-cpp] ${modelCatalogInfo?.id ?? 'model'}: the full SWA working set at the requested `,
872
- `${effectiveNumCtx * slots}-token total context does not fit fast memory — keeping the full `,
873
- 'context and declining the --swa-full auto-default instead; the windowed KV cache ',
874
- 'is what actually fits (cross-request prefix reuse unavailable). ',
875
- `Fit detail: ${fastFitDetail ?? admission.reason ?? 'over budget at the requested slot count'}`,
876
- ].join(''));
877
- }
878
858
  if (windowed) {
879
859
  ladderPlan = planCtxTokensForMemory({
880
860
  requestedPerTurnCtxTokens: effectiveNumCtx,
@@ -892,16 +872,23 @@ export async function buildLlamaCppProvider(opts) {
892
872
  fixedPerSlotBytes: windowed.fixedBytes,
893
873
  };
894
874
  }
895
- else if ((summary.slidingWindow ?? 0) > 0 || swaFullAutoDefault) {
896
- // A sliding-window model whose GGUF hides the layer layout:
897
- // full math over-states the real cache and there is no exact
898
- // substitute — leave the launch alone rather than over-clamp.
875
+ else if (fullKvOverBudget &&
876
+ (explicitSwaFull === false
877
+ ? (summary.slidingWindow ?? 0) > 0
878
+ : isGemmaModel({
879
+ architecture: modelCatalogInfo?.architecture,
880
+ modelId: defaultModelId ?? undefined,
881
+ }))) {
882
+ // A sliding-window model whose GGUF hides the layer layout,
883
+ // over budget on full math: that math over-states the real cache
884
+ // and there is no exact substitute — leave the launch alone
885
+ // rather than over-clamp.
899
886
  ladderPlan = null;
900
887
  ladderKvLinearization = null;
901
888
  }
902
- // else: `swaFull: false` pinned on a model with no SWA layers at
903
- // all — the flag is a no-op there and the full-attention plan IS
904
- // the real allocation; fall through with it intact.
889
+ // else: no SWA layers at all, or a layout-less model that fits on
890
+ // full math — the full-attention plan stands (it IS the real
891
+ // allocation, or safely above it).
905
892
  }
906
893
  if (ladderPlan !== null && envNumCtx === undefined) {
907
894
  const windowedNote = ladderPlan === admission ? '' : ' (windowed KV admission)';
@@ -964,19 +951,12 @@ export async function buildLlamaCppProvider(opts) {
964
951
  }
965
952
  }
966
953
  // Price the KV the engine will ACTUALLY allocate. Mirrors the
967
- // `--swa-full` tri-state in buildLlamaCppEngineArgs (explicit config
968
- // → manifest → Gemma auto-default gated on the fit verdict): an SWA
969
- // model on the default windowed cache carries only its global layers
970
- // per token plus a fixed window block per slot. Feeding the offload
971
- // planner full-attention math for a windowed launch over-biased
972
- // Gemma experts into system RAM for a cache that never materializes.
973
- const effectiveSwaFull = config.llamaCppSwaFull ??
974
- manifestEngineConfig?.swaFull ??
975
- (swaFullAutoFits !== false &&
976
- isGemmaModel({
977
- architecture: modelCatalogInfo?.architecture,
978
- modelId: defaultModelId ?? undefined,
979
- }));
954
+ // opt-in `--swa-full` in buildLlamaCppEngineArgs: an SWA model on the
955
+ // default windowed cache carries only its global layers per token
956
+ // plus a fixed window block per slot. Feeding the offload planner
957
+ // full-attention math for a windowed launch over-biased Gemma experts
958
+ // into system RAM for a cache that never materializes.
959
+ const effectiveSwaFull = config.llamaCppSwaFull ?? manifestEngineConfig?.swaFull ?? false;
980
960
  const windowedKvPlan = effectiveSwaFull
981
961
  ? undefined
982
962
  : estimateWindowedKvLinearization({
@@ -1177,34 +1157,77 @@ export async function buildLlamaCppProvider(opts) {
1177
1157
  if (snapshot.expected)
1178
1158
  return;
1179
1159
  logFile.writeIncident(snapshot);
1180
- // A build that dies by SIGILL before it ever answers /health has
1181
- // not failed at a task — it cannot run on this CPU at all, and
1182
- // relaunching it on every request just reproduces the crash while
1183
- // a working lower-tier build sits in the same directory. Write it
1184
- // down so the next launch routes around it.
1185
- //
1186
- // Deliberately narrow: only SIGILL, and only before readiness.
1187
- // A SIGSEGV during startup can be a corrupt model file, and any
1188
- // crash after readiness is attributable to the work rather than
1189
- // the binary — quarantining a backend over either would demote
1190
- // users to slow engines for transient reasons.
1191
- if (snapshot.signal !== 'SIGILL' || snapshot.reachedReady)
1160
+ if (snapshot.panicKind === 'host-out-of-memory') {
1161
+ noteHostOutOfMemory({
1162
+ model: modelCatalogInfo?.id ?? defaultModelId,
1163
+ whileStarting: !snapshot.reachedReady,
1164
+ });
1165
+ log.warn(`[llama-server] Linux's out-of-memory killer stopped the engine (${modelCatalogInfo?.id ?? 'model'}, incident=${snapshot.incidentId}, ${snapshot.reachedReady ? 'while serving' : 'while loading'})`);
1192
1166
  return;
1193
- const backend = process.env.GEZEL_LLAMA_SERVER_BACKEND;
1194
- if (!backend)
1167
+ }
1168
+ // A build that dies before it ever answers /health, without saying
1169
+ // why, may not be able to run here at all — and relaunching it on every
1170
+ // request just reproduces the crash while a working lower-tier build
1171
+ // sits in the next directory. Count it; a few in a row write it down so
1172
+ // the next launch routes around it. Which exits qualify (a silent
1173
+ // SIGILL; a GPU build's silent crash status on Windows or signal on
1174
+ // Linux; a CUDA build with no code for this GPU) is decided in
1175
+ // unrunnable-build.ts, how many in llama-quarantine.ts.
1176
+ const backend = binaryBackend;
1177
+ const unrunnable = unrunnableBuildExit(snapshot, backend);
1178
+ if (!backend || !unrunnable)
1195
1179
  return;
1196
- const entry = recordLlamaQuarantine(home, {
1180
+ const { strikes, entry } = recordLlamaStartupCrash(home, {
1197
1181
  backend,
1198
1182
  binaryPath: binary,
1199
- signal: 'SIGILL',
1200
- reason: 'crashed with SIGILL before the engine became ready — this build uses CPU instructions ' +
1201
- 'this machine does not support, or faulted inside GPU library startup',
1183
+ signal: unrunnable.signal,
1184
+ reason: unrunnable.reason,
1202
1185
  });
1203
- if (entry) {
1204
- log.warn(`[llama-server] quarantined the ${backend} build after SIGILL (incident=${snapshot.incidentId}); the next launch will fall back to another backend`);
1186
+ if (!entry) {
1187
+ if (strikes === 0)
1188
+ return;
1189
+ lastStartupCrash = {
1190
+ incidentId: snapshot.incidentId,
1191
+ verdict: { kind: 'strike', strikes, threshold: LLAMA_QUARANTINE_STRIKES },
1192
+ };
1193
+ log.warn(`[llama-server] the ${backend} build crashed before it was ready (${unrunnable.signal}, incident=${snapshot.incidentId}); ${strikes} of ${LLAMA_QUARANTINE_STRIKES} in a row switch it off`);
1194
+ return;
1195
+ }
1196
+ log.warn(`[llama-server] quarantined the ${backend} build after ${strikes} crashes in a row (${unrunnable.signal}, incident=${snapshot.incidentId}); the next launch will fall back to another backend`);
1197
+ // Discovery reads the quarantine only at startup. Point the env at the
1198
+ // fallback now, so the pool's rebuild of this failed engine on the next
1199
+ // request launches it instead of the build just written down. A pinned
1200
+ // backend is a decision, and discovery never routes around one either;
1201
+ // an env that has already moved on belongs to someone else's fallback.
1202
+ const pinned = config.llamaCppBackendOverride !== undefined && config.llamaCppBackendOverride !== 'auto';
1203
+ const fallback = pinned || process.env.GEZEL_LLAMA_SERVER_BIN !== binary
1204
+ ? null
1205
+ : adoptLlamaFallback(home, binary, backend);
1206
+ lastStartupCrash = {
1207
+ incidentId: snapshot.incidentId,
1208
+ verdict: { kind: 'quarantined', ...(fallback ? { switchedTo: fallback.backend } : {}) },
1209
+ };
1210
+ if (fallback) {
1211
+ log.warn(`[llama-server] the next launch uses the ${fallback.backend} build: ${fallback.path}`);
1205
1212
  }
1206
1213
  },
1207
- onRawLine: (line) => providerHolder.current?.onStdoutLine(line),
1214
+ // Any start that reaches readiness proves the build runs here, so the
1215
+ // crashes before it were not in a row. It does not prove the build got
1216
+ // its GPU: a GPU backend that cannot load or initialize leaves the engine
1217
+ // on the CPU without crashing (gpu-startup-watch.ts).
1218
+ onReady: () => {
1219
+ if (binaryBackend)
1220
+ clearLlamaStartupStrikes(home, binaryBackend);
1221
+ const unused = gpuWatch?.verdict() ?? null;
1222
+ noteGpuStartup(unused);
1223
+ if (unused) {
1224
+ log.warn(`[llama-server] the ${unused.backend} build started without its GPU (${unused.reason}${unused.detail ? `: ${unused.detail}` : ''}); every token runs on the CPU`);
1225
+ }
1226
+ },
1227
+ onRawLine: (line) => {
1228
+ gpuWatch?.observe(line);
1229
+ providerHolder.current?.onStdoutLine(line);
1230
+ },
1208
1231
  // GPU-OOM recovery ladder: when a start dies of VRAM exhaustion and the
1209
1232
  // planner's offload decision was in play, degrade it one step
1210
1233
  // (partial split → all expert/FFN weights to RAM → engine-owned fit) and let the
@@ -1250,6 +1273,7 @@ export async function buildLlamaCppProvider(opts) {
1250
1273
  return true;
1251
1274
  },
1252
1275
  resolveLaunch: async () => {
1276
+ gpuWatch = new GpuStartupWatch(binaryBackend);
1253
1277
  const port = cachedPort ?? (await pickFreePort());
1254
1278
  cachedPort = port;
1255
1279
  // Prepend the binary's own directory to PATH so the OS finds the
@@ -1276,9 +1300,6 @@ export async function buildLlamaCppProvider(opts) {
1276
1300
  // model output reaches `delta.content` for provider-side salvage.
1277
1301
  reasoningFormat: process.env.GEZEL_LLAMA_REASONING_FORMAT?.trim() || undefined,
1278
1302
  reasoningPreserve,
1279
- architecture: modelCatalogInfo?.architecture,
1280
- modelId: defaultModelId ?? undefined,
1281
- swaFullAutoFits,
1282
1303
  backend: llamaBuildMetadata?.backend ?? process.env.GEZEL_LLAMA_SERVER_BACKEND,
1283
1304
  hostCpuCount: availableParallelism(),
1284
1305
  });
@@ -1429,6 +1450,7 @@ export async function buildLlamaCppProvider(opts) {
1429
1450
  supervisor,
1430
1451
  logFile,
1431
1452
  slotSavePath,
1453
+ startupCrashVerdict: (incidentId) => lastStartupCrash?.incidentId === incidentId ? lastStartupCrash.verdict : undefined,
1432
1454
  // Lets `listModels()` enumerate every installed model (not just
1433
1455
  // the resident one) so `/v1/models` + pickers see the full set
1434
1456
  // the engine pool can serve.
@@ -149,6 +149,11 @@ export declare class LlamaCppCacheAdapter implements EngineCacheAdapter {
149
149
  * may have been pruned in between — re-seeding keeps it warm).
150
150
  */
151
151
  private readonly restoredSessions;
152
+ /**
153
+ * Sessions that will never be resumed (see `markEphemeral`): a save seeds
154
+ * their gezel prefix file when it is missing and writes nothing else.
155
+ */
156
+ private readonly ephemeralSessions;
152
157
  /**
153
158
  * Latched off when this llama-server refuses slot save/restore (501
154
159
  * "This feature is not supported by multimodal" — fires for mmproj-
@@ -190,6 +195,7 @@ export declare class LlamaCppCacheAdapter implements EngineCacheAdapter {
190
195
  prepareForSend(sessionId: string, systemPrompt?: string, layers?: SystemPromptLayers): Promise<void>;
191
196
  buildRequestExtras(sessionId: string): Record<string, unknown>;
192
197
  evict(sessionIds: readonly string[]): Promise<void>;
198
+ markEphemeral(sessionId: string): void;
193
199
  reportUsage(): Promise<readonly EngineCacheUsage[]>;
194
200
  warm(_sessionId: string, _messages: readonly CacheWarmMessage[]): Promise<void>;
195
201
  /**
@@ -227,6 +233,12 @@ export declare class LlamaCppCacheAdapter implements EngineCacheAdapter {
227
233
  private pruneDiskCache;
228
234
  private saveSlotForSession;
229
235
  private saveSlotForSessionUnlocked;
236
+ /**
237
+ * A one-shot session's only lasting value is the prompt prefix it shares
238
+ * with its gezel's next session. Save the slot straight to that prefix file
239
+ * when none exists yet; otherwise write nothing. Returns whether it wrote.
240
+ */
241
+ private seedPrefixFromEphemeralSlot;
230
242
  /** Try restore-by-session, then restore-by-prefix. Returns true if
231
243
  * any restore succeeded. */
232
244
  private tryRestoreForSession;
@@ -149,6 +149,11 @@ export class LlamaCppCacheAdapter {
149
149
  * may have been pruned in between — re-seeding keeps it warm).
150
150
  */
151
151
  restoredSessions = new Set();
152
+ /**
153
+ * Sessions that will never be resumed (see `markEphemeral`): a save seeds
154
+ * their gezel prefix file when it is missing and writes nothing else.
155
+ */
156
+ ephemeralSessions = new Set();
152
157
  /**
153
158
  * Latched off when this llama-server refuses slot save/restore (501
154
159
  * "This feature is not supported by multimodal" — fires for mmproj-
@@ -263,8 +268,12 @@ export class LlamaCppCacheAdapter {
263
268
  this.sessionPrefix.delete(id);
264
269
  this.sessionLayerPrefixes.delete(id);
265
270
  this.restoredSessions.delete(id);
271
+ this.ephemeralSessions.delete(id);
266
272
  }
267
273
  }
274
+ markEphemeral(sessionId) {
275
+ this.ephemeralSessions.add(sessionId);
276
+ }
268
277
  async reportUsage() {
269
278
  const generation = this.engineGeneration;
270
279
  const baseUrl = await this.resolveBaseUrl().catch(() => null);
@@ -297,7 +306,8 @@ export class LlamaCppCacheAdapter {
297
306
  if (!entry || typeof entry !== 'object')
298
307
  continue;
299
308
  const slot = entry.id;
300
- const cachedTokens = entry.n_cache_tokens;
309
+ // The slot's resident tokens (prompt plus generated); there is no `n_cache_tokens`.
310
+ const cachedTokens = entry.n_prompt_tokens;
301
311
  if (typeof slot !== 'number' || typeof cachedTokens !== 'number' || cachedTokens <= 0) {
302
312
  continue;
303
313
  }
@@ -410,6 +420,7 @@ export class LlamaCppCacheAdapter {
410
420
  log.debug(`slot ${chosen} recycled: evicting session ${victimSession.slice(0, 8)} for ${sessionId.slice(0, 8)} (disk save: ${saved ? 'ok' : 'MISSED'})`);
411
421
  this.sessionToSlot.delete(victimSession);
412
422
  this.slotToSession.delete(chosen);
423
+ this.ephemeralSessions.delete(victimSession);
413
424
  }
414
425
  }
415
426
  // We now have a slot id `chosen` that's free of any current binding.
@@ -534,6 +545,9 @@ export class LlamaCppCacheAdapter {
534
545
  const baseUrl = await this.resolveBaseUrl().catch(() => null);
535
546
  if (!baseUrl || generation !== this.engineGeneration)
536
547
  return false;
548
+ if (this.ephemeralSessions.has(sessionId)) {
549
+ return this.seedPrefixFromEphemeralSlot(baseUrl, slot, sessionId, generation);
550
+ }
537
551
  const filename = sessFilename(sessionId);
538
552
  const ok = await this.invokeSlotAction(baseUrl, slot, 'save', filename, generation);
539
553
  if (!ok)
@@ -571,6 +585,27 @@ export class LlamaCppCacheAdapter {
571
585
  }
572
586
  return true;
573
587
  }
588
+ /**
589
+ * A one-shot session's only lasting value is the prompt prefix it shares
590
+ * with its gezel's next session. Save the slot straight to that prefix file
591
+ * when none exists yet; otherwise write nothing. Returns whether it wrote.
592
+ */
593
+ async seedPrefixFromEphemeralSlot(baseUrl, slot, sessionId, generation) {
594
+ if (!this.slotSavePath)
595
+ return false;
596
+ const prefixId = this.sessionPrefix.get(sessionId);
597
+ if (!prefixId || this.seededPrefixes.has(prefixId))
598
+ return false;
599
+ const name = prefixFilename(prefixId);
600
+ if (await stat(join(this.slotSavePath, name)).catch(() => null)) {
601
+ this.seededPrefixes.add(prefixId);
602
+ return false;
603
+ }
604
+ const ok = await this.invokeSlotAction(baseUrl, slot, 'save', name, generation);
605
+ if (ok)
606
+ this.seededPrefixes.add(prefixId);
607
+ return ok;
608
+ }
574
609
  /** Try restore-by-session, then restore-by-prefix. Returns true if
575
610
  * any restore succeeded. */
576
611
  async tryRestoreForSession(slot, sessionId) {