@bendyline/gezel-inference 1.0.1 → 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 (70) hide show
  1. package/dist/engines/native-file-manifest.json +404 -404
  2. package/dist/engines/native-manifest.d.ts +4 -4
  3. package/dist/engines/native-manifest.js +34 -34
  4. package/dist/http/openai-compat/engine-failure.d.ts +25 -0
  5. package/dist/http/openai-compat/engine-failure.js +76 -0
  6. package/dist/http/openai-compat/responses-streaming.js +2 -1
  7. package/dist/http/openai-compat/responses-translate.d.ts +1 -1
  8. package/dist/http/openai-compat/translate.d.ts +1 -1
  9. package/dist/http/routes/engines.js +35 -0
  10. package/dist/http/routes/v1-chat.js +10 -1
  11. package/dist/http/routes/v1-models.js +6 -4
  12. package/dist/http/routes/v1-responses.js +7 -1
  13. package/dist/knowledge/catalog-host.d.ts +9 -1
  14. package/dist/knowledge/catalog-host.js +2 -0
  15. package/dist/knowledge/manager.d.ts +14 -0
  16. package/dist/knowledge/manager.js +53 -6
  17. package/dist/knowledge/search-worker.d.ts +1 -1
  18. package/dist/knowledge/spatial-query.d.ts +2 -3
  19. package/dist/knowledge/spatial-query.js +12 -6
  20. package/dist/knowledge/worker-host.js +2 -0
  21. package/dist/memory/embed-core.d.ts +16 -0
  22. package/dist/memory/embed-core.js +31 -0
  23. package/dist/memory/embed-worker.d.ts +7 -3
  24. package/dist/memory/embed-worker.js +13 -4
  25. package/dist/memory/embeddings.d.ts +17 -2
  26. package/dist/memory/embeddings.js +36 -7
  27. package/dist/models/ensure.d.ts +2 -0
  28. package/dist/models/ensure.js +9 -0
  29. package/dist/perf/responsiveness.d.ts +14 -1
  30. package/dist/perf/responsiveness.js +77 -7
  31. package/dist/providers/llama-cpp/backend-health.d.ts +30 -0
  32. package/dist/providers/llama-cpp/backend-health.js +64 -0
  33. package/dist/providers/llama-cpp/backend-quarantine.d.ts +40 -0
  34. package/dist/providers/llama-cpp/backend-quarantine.js +100 -0
  35. package/dist/providers/llama-cpp/build-provider.js +141 -144
  36. package/dist/providers/llama-cpp/cache-adapter.js +2 -1
  37. package/dist/providers/llama-cpp/devices.d.ts +11 -2
  38. package/dist/providers/llama-cpp/devices.js +10 -4
  39. package/dist/providers/llama-cpp/engine-flags.d.ts +0 -18
  40. package/dist/providers/llama-cpp/engine-flags.js +12 -18
  41. package/dist/providers/llama-cpp/gguf-metadata.js +20 -3
  42. package/dist/providers/llama-cpp/gpu-startup-watch.d.ts +41 -0
  43. package/dist/providers/llama-cpp/gpu-startup-watch.js +58 -0
  44. package/dist/providers/llama-cpp/models.d.ts +6 -0
  45. package/dist/providers/llama-cpp/models.js +19 -1
  46. package/dist/providers/llama-cpp/offload-planner.d.ts +3 -18
  47. package/dist/providers/llama-cpp/offload-planner.js +3 -14
  48. package/dist/providers/llama-cpp/provider.d.ts +8 -0
  49. package/dist/providers/llama-cpp/provider.js +4 -1
  50. package/dist/providers/mlx/provider.js +3 -4
  51. package/dist/providers/mlx/python/qwen4_ple.py +9 -2
  52. package/dist/providers/native/capacity-broker.d.ts +21 -15
  53. package/dist/providers/native/capacity-broker.js +16 -10
  54. package/dist/providers/native/capacity-budget.d.ts +35 -3
  55. package/dist/providers/native/capacity-budget.js +49 -5
  56. package/dist/providers/native/device-capacity.d.ts +1 -1
  57. package/dist/providers/native/device-capacity.js +23 -5
  58. package/dist/providers/native/local-engine-runtime.js +16 -19
  59. package/dist/providers/native/measured-budget.js +2 -0
  60. package/dist/providers/native/supervisor.d.ts +25 -0
  61. package/dist/providers/native/supervisor.js +59 -7
  62. package/dist/system/amdgpu-adapters.d.ts +18 -0
  63. package/dist/system/amdgpu-adapters.js +55 -0
  64. package/dist/system/gpu-carve-out.d.ts +84 -0
  65. package/dist/system/gpu-carve-out.js +111 -0
  66. package/dist/system/memory.d.ts +17 -1
  67. package/dist/system/memory.js +94 -13
  68. package/dist/transformers-cache.d.ts +2 -0
  69. package/dist/transformers-cache.js +6 -3
  70. package/package.json +5 -5
@@ -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
+ };
@@ -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,27 +1,30 @@
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';
8
8
  import { engineInstallPhase } from '../../engines/registry.js';
9
9
  import { localDeviceSettingsLabel, noModelYetMessage, notReadyError, } from '../active-install-message.js';
10
10
  import { resolveCatalogLlamaCppEngineConfig, resolveCatalogReasoningBudget, } from '../catalog-model-config.js';
11
- import { CapacityDeniedError, NOT_ENOUGH_MEMORY_MESSAGE, autoKvCacheMaxBytes, availableSystemRamBytes, formatContextCapacityDenial, minViableLocalContextTokens, planAdaptiveContextGrowth, pressureIdleGraceMs, resolveLlamaCppContextRequirement, } from '../native/capacity-broker.js';
11
+ import { CapacityDeniedError, NOT_ENOUGH_MEMORY_MESSAGE, availableSystemRamBytes, formatContextCapacityDenial, minViableLocalContextTokens, planAdaptiveContextGrowth, pressureIdleGraceMs, resolveLlamaCppContextRequirement, } from '../native/capacity-broker.js';
12
12
  import { measureAvailableGpuBytes } from '../native/device-capacity.js';
13
13
  import { engineApiKey, withEngineApiKey } from '../native/engine-api-key.js';
14
14
  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,25 +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
- // A full-size cache past the automatic KV ceiling is declined even
807
- // where it fits: gemma4-31b's is 55 GB at 64K, 11x its windowed
808
- // cache, for a cross-request reuse gain nobody had measured.
809
- const kvCeilingBytes = autoKvCacheMaxBytes();
810
- const fullCacheOverCeiling = fullKvBytesAtRequested !== undefined && fullKvBytesAtRequested > kvCeilingBytes;
811
- const fullKvOverBudget = fullCacheFitsFastMemory === false ||
812
- fullCacheOverCeiling ||
813
- !admission.minimumSatisfied ||
814
- admission.slots < slots ||
815
- admission.clamped;
806
+ const fullKvOverBudget = !admission.minimumSatisfied || admission.slots < slots || admission.clamped;
816
807
  // The plan whose ladder (deny → shed slots → clamp) is enforced
817
808
  // below. Defaults to the full-attention plan; the windowed-cache
818
- // branches replace it with windowed math or — only when the GGUF
809
+ // branch replaces it with windowed math or — only when the GGUF
819
810
  // hides its SWA layout — null (no safe estimate; launch untouched,
820
811
  // the pre-windowed-admission behavior).
821
812
  let ladderPlan = admission;
@@ -833,17 +824,17 @@ export async function buildLlamaCppProvider(opts) {
833
824
  // full-attention), and the strict minimum rides inside
834
825
  // `contextRequirement.minimumPerTurnCtxTokens`, so the windowed
835
826
  // re-plan below sheds slots or denies but never shortens the window.
836
- const windowedCacheWillRun = fullKvOverBudget && (swaFullAutoDefault || explicitSwaFull === false);
837
- if (windowedCacheWillRun) {
838
- // The denial, the slot reduction, and the clamp above are all
839
- // driven by full-attention KV math, which overstates the windowed
840
- // cache's real allocation (Gemma 4: global layers are both fewer
841
- // AND cheaper per token than SWA layers — ~14× on 31b). Re-plan
842
- // with the windowed linearization: SWA layers become a fixed,
843
- // context-independent reservation; only global layers scale.
844
- // Without it a 31b-class Gemma on a ~32 GB host would launch
845
- // with NO context admission at all — the same lazy-Metal-wiring
846
- // 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.
847
838
  const windowed = estimateWindowedKvLinearization({
848
839
  blockCount: summary.blockCount,
849
840
  embeddingLength: summary.embeddingLength,
@@ -864,42 +855,6 @@ export async function buildLlamaCppProvider(opts) {
864
855
  ssmConvKernel: summary.ssmConvKernel,
865
856
  kvCacheType,
866
857
  });
867
- if (swaFullAutoDefault) {
868
- // Declining `--swa-full` dominates every full-math remedy:
869
- // the context window is capability, the full cache is a
870
- // session-switch performance trade. Weights-level admission
871
- // stays with the capacity broker.
872
- swaFullAutoFits = false;
873
- const fastFitDetail = fullCacheFitsFastMemory === false && fullKvBytesAtRequested !== undefined
874
- ? `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`
875
- : undefined;
876
- const overCeilingOnly = fullCacheOverCeiling &&
877
- fullCacheFitsFastMemory !== false &&
878
- admission.minimumSatisfied &&
879
- admission.slots >= slots &&
880
- !admission.clamped;
881
- if (overCeilingOnly && fullKvBytesAtRequested !== undefined) {
882
- // A policy choice on a machine that could hold the cache, not
883
- // a shortage: info, not warn.
884
- log.info([
885
- `[llama-cpp] ${modelCatalogInfo?.id ?? 'model'}: the full SWA cache at the requested `,
886
- `${effectiveNumCtx * slots}-token total context would take `,
887
- `${(fullKvBytesAtRequested / 1024 ** 3).toFixed(1)} GiB, past the `,
888
- `${(kvCeilingBytes / 1024 ** 3).toFixed(1)} GiB automatic KV ceiling — keeping the full `,
889
- 'context and declining the --swa-full auto-default instead; the windowed KV cache ',
890
- 'holds it (cross-request prefix reuse unavailable).',
891
- ].join(''));
892
- }
893
- else {
894
- log.warn([
895
- `[llama-cpp] ${modelCatalogInfo?.id ?? 'model'}: the full SWA working set at the requested `,
896
- `${effectiveNumCtx * slots}-token total context does not fit fast memory — keeping the full `,
897
- 'context and declining the --swa-full auto-default instead; the windowed KV cache ',
898
- 'is what actually fits (cross-request prefix reuse unavailable). ',
899
- `Fit detail: ${fastFitDetail ?? admission.reason ?? 'over budget at the requested slot count'}`,
900
- ].join(''));
901
- }
902
- }
903
858
  if (windowed) {
904
859
  ladderPlan = planCtxTokensForMemory({
905
860
  requestedPerTurnCtxTokens: effectiveNumCtx,
@@ -917,16 +872,23 @@ export async function buildLlamaCppProvider(opts) {
917
872
  fixedPerSlotBytes: windowed.fixedBytes,
918
873
  };
919
874
  }
920
- else if ((summary.slidingWindow ?? 0) > 0 || swaFullAutoDefault) {
921
- // A sliding-window model whose GGUF hides the layer layout:
922
- // full math over-states the real cache and there is no exact
923
- // 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.
924
886
  ladderPlan = null;
925
887
  ladderKvLinearization = null;
926
888
  }
927
- // else: `swaFull: false` pinned on a model with no SWA layers at
928
- // all — the flag is a no-op there and the full-attention plan IS
929
- // 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).
930
892
  }
931
893
  if (ladderPlan !== null && envNumCtx === undefined) {
932
894
  const windowedNote = ladderPlan === admission ? '' : ' (windowed KV admission)';
@@ -989,19 +951,12 @@ export async function buildLlamaCppProvider(opts) {
989
951
  }
990
952
  }
991
953
  // Price the KV the engine will ACTUALLY allocate. Mirrors the
992
- // `--swa-full` tri-state in buildLlamaCppEngineArgs (explicit config
993
- // → manifest → Gemma auto-default gated on the fit verdict): an SWA
994
- // model on the default windowed cache carries only its global layers
995
- // per token plus a fixed window block per slot. Feeding the offload
996
- // planner full-attention math for a windowed launch over-biased
997
- // Gemma experts into system RAM for a cache that never materializes.
998
- const effectiveSwaFull = config.llamaCppSwaFull ??
999
- manifestEngineConfig?.swaFull ??
1000
- (swaFullAutoFits !== false &&
1001
- isGemmaModel({
1002
- architecture: modelCatalogInfo?.architecture,
1003
- modelId: defaultModelId ?? undefined,
1004
- }));
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;
1005
960
  const windowedKvPlan = effectiveSwaFull
1006
961
  ? undefined
1007
962
  : estimateWindowedKvLinearization({
@@ -1202,34 +1157,77 @@ export async function buildLlamaCppProvider(opts) {
1202
1157
  if (snapshot.expected)
1203
1158
  return;
1204
1159
  logFile.writeIncident(snapshot);
1205
- // A build that dies by SIGILL before it ever answers /health has
1206
- // not failed at a task — it cannot run on this CPU at all, and
1207
- // relaunching it on every request just reproduces the crash while
1208
- // a working lower-tier build sits in the same directory. Write it
1209
- // down so the next launch routes around it.
1210
- //
1211
- // Deliberately narrow: only SIGILL, and only before readiness.
1212
- // A SIGSEGV during startup can be a corrupt model file, and any
1213
- // crash after readiness is attributable to the work rather than
1214
- // the binary — quarantining a backend over either would demote
1215
- // users to slow engines for transient reasons.
1216
- 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'})`);
1217
1166
  return;
1218
- const backend = process.env.GEZEL_LLAMA_SERVER_BACKEND;
1219
- 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)
1220
1179
  return;
1221
- const entry = recordLlamaQuarantine(home, {
1180
+ const { strikes, entry } = recordLlamaStartupCrash(home, {
1222
1181
  backend,
1223
1182
  binaryPath: binary,
1224
- signal: 'SIGILL',
1225
- reason: 'crashed with SIGILL before the engine became ready — this build uses CPU instructions ' +
1226
- 'this machine does not support, or faulted inside GPU library startup',
1183
+ signal: unrunnable.signal,
1184
+ reason: unrunnable.reason,
1227
1185
  });
1228
- if (entry) {
1229
- 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}`);
1212
+ }
1213
+ },
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`);
1230
1225
  }
1231
1226
  },
1232
- onRawLine: (line) => providerHolder.current?.onStdoutLine(line),
1227
+ onRawLine: (line) => {
1228
+ gpuWatch?.observe(line);
1229
+ providerHolder.current?.onStdoutLine(line);
1230
+ },
1233
1231
  // GPU-OOM recovery ladder: when a start dies of VRAM exhaustion and the
1234
1232
  // planner's offload decision was in play, degrade it one step
1235
1233
  // (partial split → all expert/FFN weights to RAM → engine-owned fit) and let the
@@ -1275,6 +1273,7 @@ export async function buildLlamaCppProvider(opts) {
1275
1273
  return true;
1276
1274
  },
1277
1275
  resolveLaunch: async () => {
1276
+ gpuWatch = new GpuStartupWatch(binaryBackend);
1278
1277
  const port = cachedPort ?? (await pickFreePort());
1279
1278
  cachedPort = port;
1280
1279
  // Prepend the binary's own directory to PATH so the OS finds the
@@ -1301,9 +1300,6 @@ export async function buildLlamaCppProvider(opts) {
1301
1300
  // model output reaches `delta.content` for provider-side salvage.
1302
1301
  reasoningFormat: process.env.GEZEL_LLAMA_REASONING_FORMAT?.trim() || undefined,
1303
1302
  reasoningPreserve,
1304
- architecture: modelCatalogInfo?.architecture,
1305
- modelId: defaultModelId ?? undefined,
1306
- swaFullAutoFits,
1307
1303
  backend: llamaBuildMetadata?.backend ?? process.env.GEZEL_LLAMA_SERVER_BACKEND,
1308
1304
  hostCpuCount: availableParallelism(),
1309
1305
  });
@@ -1454,6 +1450,7 @@ export async function buildLlamaCppProvider(opts) {
1454
1450
  supervisor,
1455
1451
  logFile,
1456
1452
  slotSavePath,
1453
+ startupCrashVerdict: (incidentId) => lastStartupCrash?.incidentId === incidentId ? lastStartupCrash.verdict : undefined,
1457
1454
  // Lets `listModels()` enumerate every installed model (not just
1458
1455
  // the resident one) so `/v1/models` + pickers see the full set
1459
1456
  // the engine pool can serve.
@@ -306,7 +306,8 @@ export class LlamaCppCacheAdapter {
306
306
  if (!entry || typeof entry !== 'object')
307
307
  continue;
308
308
  const slot = entry.id;
309
- 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;
310
311
  if (typeof slot !== 'number' || typeof cachedTokens !== 'number' || cachedTokens <= 0) {
311
312
  continue;
312
313
  }
@@ -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