@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
@@ -151,12 +151,16 @@ export class CapacityBroker {
151
151
  systemRamBytes;
152
152
  unifiedMemory;
153
153
  gpuVramBytes;
154
+ reservedGpuBytes;
155
+ gpuAddressableBytes;
154
156
  explicitAllowRamSpillover;
155
157
  reservations = new Map();
156
158
  constructor(opts = {}) {
157
159
  this.systemRamBytes = (opts.systemRamBytes ?? totalmem)();
158
160
  this.unifiedMemory = opts.unifiedMemory;
159
161
  this.gpuVramBytes = opts.gpuVramBytes;
162
+ this.reservedGpuBytes = opts.reservedGpuBytes;
163
+ this.gpuAddressableBytes = opts.gpuAddressableBytes;
160
164
  this.explicitAllowRamSpillover = opts.allowRamSpillover ?? null;
161
165
  this.explicitBudgetBytes = opts.budgetBytes ?? null;
162
166
  // Assigned by applyBudget; the definite-assignment dance TS wants for
@@ -189,6 +193,12 @@ export class CapacityBroker {
189
193
  : {
190
194
  ...(this.unifiedMemory === undefined ? {} : { unifiedMemory: this.unifiedMemory }),
191
195
  ...(this.gpuVramBytes === undefined ? {} : { gpuVramBytes: this.gpuVramBytes }),
196
+ ...(this.reservedGpuBytes === undefined
197
+ ? {}
198
+ : { reservedGpuBytes: this.reservedGpuBytes }),
199
+ ...(this.gpuAddressableBytes === undefined
200
+ ? {}
201
+ : { gpuAddressableBytes: this.gpuAddressableBytes }),
192
202
  }),
193
203
  });
194
204
  }
@@ -714,11 +724,9 @@ function envGrowthCeilingTokens() {
714
724
  return Number.isFinite(tokens) && tokens > 0 ? tokens : null;
715
725
  }
716
726
  /**
717
- * The most KV cache an automatic choice plans for one llama.cpp engine:
718
- * adaptive growth stops here, and the Gemma `--swa-full` auto-default is
719
- * declined when its full-size cache would pass it. Explicit settings (a
720
- * numeric context, a pinned `swaFull`) are the operator's call and bypass it;
721
- * it never shortens the ladder-settled base window either.
727
+ * The most KV cache adaptive growth plans for one llama.cpp engine. An
728
+ * explicit numeric context is the operator's call and skips growth
729
+ * altogether, and the ceiling never shortens the ladder-settled base window.
722
730
  *
723
731
  * Past this a bigger cache buys nothing: llama.cpp's prefill and decode cost
724
732
  * follows the tokens actually present, not the reservation, so the rest only
@@ -959,11 +967,9 @@ export const ADAPTIVE_GROWTH_TRADE_MIN_GAIN = 1.25;
959
967
  * settled slots and granted the un-grown request.
960
968
  *
961
969
  * Deliberately a second clamp pass rather than a bigger first request:
962
- * the first ladder pass sizes slot ceilings, the `--swa-full` fit verdict,
963
- * and RAM-spillover admission, all of which must stay anchored on the
964
- * practical 64K target — growing the request there would decline
965
- * `--swa-full` for models whose full cache fits at 64K and grow KV into
966
- * system-RAM spill on discrete cards.
970
+ * the first ladder pass sizes slot ceilings and RAM-spillover admission,
971
+ * both of which must stay anchored on the practical 64K target — growing
972
+ * the request there would grow KV into system-RAM spill on discrete cards.
967
973
  *
968
974
  * Slot trade: when `allowSlotTrade` is set and the target is out of reach
969
975
  * at the settled slot count, growth walks the count down — taking the
@@ -5,7 +5,13 @@
5
5
  * VRAM and RAM are common on small discrete-GPU machines, so their ratio is
6
6
  * not enough to tell a GeForce from a genuinely shared GB10/iGPU pool.
7
7
  */
8
- export declare function setDetectedGpuVramBytes(bytes: number | null, unifiedMemory?: boolean): void;
8
+ export declare function setDetectedGpuVramBytes(bytes: number | null, unifiedMemory?: boolean, reservedGpuBytes?: number | null, gpuAddressableBytes?: number | null): void;
9
+ /**
10
+ * What engines may hold of memory an integrated GPU owns or can reach — its
11
+ * carve-out, or carve-out plus GTT — after the share the desktop it also
12
+ * drives needs.
13
+ */
14
+ export declare function usableReservedGpuBytes(reservedGpuBytes: number | null | undefined): number;
9
15
  /** The published accelerator-memory figure, or null when none was measured. */
10
16
  export declare function getDetectedGpuVramBytes(): number | null;
11
17
  /**
@@ -26,13 +32,19 @@ export interface CapacityBudget {
26
32
  budgetBytes: number;
27
33
  /**
28
34
  * The FAST pool inside that budget: usable VRAM on a discrete card, the
29
- * whole budget on unified / CPU hosts. Size KV slots and prompt caches
35
+ * whole budget on unified / CPU hosts — except an integrated GPU that can
36
+ * reach less than that (Linux GTT; see
37
+ * {@link CapacityBudgetInput.gpuAddressableBytes}). Size KV slots and prompt caches
30
38
  * against this, not against {@link budgetBytes} — on a discrete card the
31
39
  * rest of the budget is system RAM that weights stream from, and putting
32
40
  * more KV there than the card holds is how a GPU OOMs.
33
41
  */
34
42
  fastBytes: number;
35
- /** Usable VRAM on a discrete card; 0 on unified / CPU-only hosts. */
43
+ /**
44
+ * Usable VRAM on a discrete card. On a unified host, the usable part of an
45
+ * integrated GPU's firmware carve-out — memory the GPU owns that free-RAM
46
+ * readings never include — and 0 when there is none. 0 on CPU-only hosts.
47
+ */
36
48
  vramBytes: number;
37
49
  /** The system-RAM share of the budget. */
38
50
  ramShareBytes: number;
@@ -59,6 +71,26 @@ export interface CapacityBudgetInput {
59
71
  */
60
72
  gpuVramBytes?: number | null;
61
73
  unifiedMemory?: boolean;
74
+ /**
75
+ * Memory firmware sets aside for an integrated GPU (an AMD "UMA frame
76
+ * buffer"): carved out before the OS counts RAM, so it is NOT inside
77
+ * `systemRamBytes`, and only the GPU can use it. Credited on a unified host
78
+ * only — an engine on the CPU build cannot reach it. Without this, raising
79
+ * the carve-out shrank the budget: the bytes left the RAM figure and were
80
+ * counted nowhere. Omit to fall back to the last published probe, under the
81
+ * same rule as `gpuVramBytes`.
82
+ */
83
+ reservedGpuBytes?: number | null;
84
+ /**
85
+ * The most memory an integrated GPU can reach: on Linux amdgpu, its
86
+ * carve-out plus GTT, the window of system RAM the kernel lets it map
87
+ * (half of RAM by default). Caps the FAST pool on a unified host — the GPU
88
+ * cannot hold more KV or weights than this, whatever the RAM share says —
89
+ * while the admission budget keeps the rest for layers the CPU runs. Omit
90
+ * to fall back to the last published probe, under the same rule as
91
+ * `gpuVramBytes`.
92
+ */
93
+ gpuAddressableBytes?: number | null;
62
94
  }
63
95
  /**
64
96
  * Resolve the memory a host can give resident local engines, per pool.
@@ -67,6 +67,13 @@ const VRAM_USABLE_FRACTION = 0.95;
67
67
  * bytes, so those hosts take the unified curve instead.
68
68
  */
69
69
  const UNIFIED_VRAM_RATIO = 0.75;
70
+ /**
71
+ * Held back from an integrated GPU's firmware carve-out (see
72
+ * {@link CapacityBudgetInput.reservedGpuBytes}) for the desktop it also
73
+ * drives. The discrete card's 5% margin is too thin here: on a 512 MiB
74
+ * carve-out it would leave the compositor 25 MiB.
75
+ */
76
+ const CARVE_OUT_DISPLAY_RESERVE_BYTES = 512 * 1024 ** 2;
70
77
  /**
71
78
  * Last accelerator-memory figure `detectMemoryProfile` measured on this host,
72
79
  * or null when nothing has probed yet / no GPU memory figure was published.
@@ -81,6 +88,8 @@ const UNIFIED_VRAM_RATIO = 0.75;
81
88
  */
82
89
  let detectedGpuVramBytes = null;
83
90
  let detectedGpuUnifiedMemory;
91
+ let detectedGpuReservedBytes = null;
92
+ let detectedGpuAddressableBytes = null;
84
93
  /**
85
94
  * Publish the measured accelerator pool. Called by `detectMemoryProfile`.
86
95
  *
@@ -88,10 +97,26 @@ let detectedGpuUnifiedMemory;
88
97
  * VRAM and RAM are common on small discrete-GPU machines, so their ratio is
89
98
  * not enough to tell a GeForce from a genuinely shared GB10/iGPU pool.
90
99
  */
91
- export function setDetectedGpuVramBytes(bytes, unifiedMemory) {
92
- detectedGpuVramBytes =
93
- typeof bytes === 'number' && Number.isFinite(bytes) && bytes > 0 ? bytes : null;
100
+ export function setDetectedGpuVramBytes(bytes, unifiedMemory, reservedGpuBytes, gpuAddressableBytes) {
101
+ detectedGpuVramBytes = positiveOrNull(bytes);
94
102
  detectedGpuUnifiedMemory = unifiedMemory;
103
+ detectedGpuReservedBytes = positiveOrNull(reservedGpuBytes);
104
+ detectedGpuAddressableBytes = positiveOrNull(gpuAddressableBytes);
105
+ }
106
+ function positiveOrNull(bytes) {
107
+ return typeof bytes === 'number' && Number.isFinite(bytes) && bytes > 0 ? bytes : null;
108
+ }
109
+ /**
110
+ * What engines may hold of memory an integrated GPU owns or can reach — its
111
+ * carve-out, or carve-out plus GTT — after the share the desktop it also
112
+ * drives needs.
113
+ */
114
+ export function usableReservedGpuBytes(reservedGpuBytes) {
115
+ const reserved = positiveOrNull(reservedGpuBytes);
116
+ if (reserved === null)
117
+ return 0;
118
+ const display = Math.max(CARVE_OUT_DISPLAY_RESERVE_BYTES, Math.ceil(reserved * (1 - VRAM_USABLE_FRACTION)));
119
+ return Math.max(0, reserved - display);
95
120
  }
96
121
  /** The published accelerator-memory figure, or null when none was measured. */
97
122
  export function getDetectedGpuVramBytes() {
@@ -144,6 +169,16 @@ export function computeCapacityBudget(input = {}) {
144
169
  const sharedPool = hasCard && measuredVram >= systemRamBytes * UNIFIED_VRAM_RATIO;
145
170
  const unified = measuredUnifiedMemory ?? (isUnifiedMemoryHost() || sharedPool);
146
171
  const discrete = hasCard && !unified;
172
+ const reservedGpuBytes = input.reservedGpuBytes !== undefined
173
+ ? input.reservedGpuBytes
174
+ : input.unifiedMemory === undefined && input.gpuVramBytes === undefined
175
+ ? detectedGpuReservedBytes
176
+ : null;
177
+ const gpuAddressableBytes = input.gpuAddressableBytes !== undefined
178
+ ? input.gpuAddressableBytes
179
+ : input.unifiedMemory === undefined && input.gpuVramBytes === undefined
180
+ ? detectedGpuAddressableBytes
181
+ : null;
147
182
  const highMemory = systemRamBytes >= HIGH_MEMORY_THRESHOLD_BYTES;
148
183
  // Whichever bound leaves the host MORE memory wins between the fraction and
149
184
  // the capped reserve, then the 4 GiB floor reserve caps them both. Written
@@ -153,7 +188,11 @@ export function computeCapacityBudget(input = {}) {
153
188
  const unifiedShare = Math.max(0, Math.min(Math.max(unifiedFractionShare, systemRamBytes - UNIFIED_OS_RESERVE_MAX_BYTES), systemRamBytes - UNIFIED_OS_RESERVE_BYTES));
154
189
  const nonUnifiedShare = Math.min(Math.floor(systemRamBytes * (highMemory ? 0.8 : 0.6)), BUDGET_CAP_BYTES);
155
190
  const ramShareBytes = unified ? unifiedShare : nonUnifiedShare;
156
- const vramBytes = discrete ? Math.floor(measuredVram * VRAM_USABLE_FRACTION) : 0;
191
+ const vramBytes = discrete
192
+ ? Math.floor(measuredVram * VRAM_USABLE_FRACTION)
193
+ : unified
194
+ ? usableReservedGpuBytes(reservedGpuBytes)
195
+ : 0;
157
196
  // The pre-cap curve, retained for slot sizing only.
158
197
  const concurrencyRamShare = unified
159
198
  ? Math.max(0, Math.min(unifiedFractionShare, systemRamBytes - UNIFIED_OS_RESERVE_BYTES, BUDGET_CAP_BYTES))
@@ -175,7 +214,12 @@ export function computeCapacityBudget(input = {}) {
175
214
  overriddenByEnv: true,
176
215
  };
177
216
  }
178
- const fastBytes = discrete ? vramBytes : auto;
217
+ const addressable = positiveOrNull(gpuAddressableBytes);
218
+ const fastBytes = discrete
219
+ ? vramBytes
220
+ : unified && addressable !== null
221
+ ? Math.min(auto, usableReservedGpuBytes(addressable))
222
+ : auto;
179
223
  return {
180
224
  budgetBytes: auto,
181
225
  fastBytes,
@@ -73,7 +73,7 @@ export declare function configuredEngineMemoryBytes(config: {
73
73
  * download pages hide is exactly one the launch path refuses. Omit
74
74
  * `configuredBytes` for the machine's automatic ceilings.
75
75
  */
76
- export declare function staticCapacityCeilings(budget: Pick<CapacityBudget, 'budgetBytes' | 'fastBytes' | 'kind'>, ramBytes: number, configuredBytes?: number): {
76
+ export declare function staticCapacityCeilings(budget: Pick<CapacityBudget, 'budgetBytes' | 'fastBytes' | 'kind' | 'vramBytes'>, ramBytes: number, configuredBytes?: number, platform?: NodeJS.Platform): {
77
77
  budgetBytes: number;
78
78
  gpuBudgetBytes: number;
79
79
  };
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { readdir, stat } from 'node:fs/promises';
3
3
  import { homedir, totalmem } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { GEZEL_VERSION, NativeCapacityCommandSchema, NativeCapacityReplySchema, awakeNow, createLogger, isUnstampedDevBuild, } from '@bendyline/gezel';
5
+ import { DS4_FULL_RESIDENCY_HEADROOM_BYTES, GEZEL_VERSION, NativeCapacityCommandSchema, NativeCapacityReplySchema, awakeNow, createLogger, isUnstampedDevBuild, } from '@bendyline/gezel';
6
6
  import { readSystemServiceRuntime, systemServiceHome } from '@bendyline/gezel-client/node';
7
7
  import { ConfigStore } from '../../fs/config-store.js';
8
8
  import { createPinnedFetch } from '../../remotes/pinned-fetch.js';
@@ -119,21 +119,39 @@ export function configuredEngineMemoryBytes(config) {
119
119
  ? config.localEngineMemoryGb * GIB
120
120
  : undefined;
121
121
  }
122
+ /**
123
+ * GPU bytes a unified host may commit to one launch. macOS keeps 0.75 × RAM,
124
+ * near where Metal's recommended GPU working set sits. Linux unified hosts
125
+ * (DGX Spark GB10) have no such limit, so a large one may commit all but the
126
+ * OS reserve ds4's residency gate keeps: on a 121.6 GiB Spark, 0.75 × RAM
127
+ * (91.2 GiB) refused a GLM 5.3 Q2 launch that runs at 105 GiB with ~9 GiB to
128
+ * spare. Smaller Linux hosts stay on the 0.75 curve, which is the larger one there.
129
+ */
130
+ function unifiedGpuCeilingBytes(ramBytes, platform) {
131
+ const curve = ramBytes * 0.75;
132
+ return platform === 'linux'
133
+ ? Math.max(curve, ramBytes - DS4_FULL_RESIDENCY_HEADROOM_BYTES)
134
+ : curve;
135
+ }
122
136
  /**
123
137
  * The ledger's static ceilings: a request above either is refused at acquire,
124
138
  * whatever else is running. Shared with the media fit check so a model the
125
139
  * download pages hide is exactly one the launch path refuses. Omit
126
140
  * `configuredBytes` for the machine's automatic ceilings.
127
141
  */
128
- export function staticCapacityCeilings(budget, ramBytes, configuredBytes) {
142
+ export function staticCapacityCeilings(budget, ramBytes, configuredBytes, platform = process.platform) {
129
143
  const budgetBytes = configuredBytes === undefined
130
144
  ? budget.budgetBytes
131
145
  : Math.min(configuredBytes, budget.budgetBytes);
132
146
  return {
133
147
  budgetBytes,
134
- // Keep non-reclaimable accelerator commitments on the conservative curve;
135
- // a larger RAM budget is not permission to wire the whole machine.
136
- gpuBudgetBytes: Math.min(budgetBytes, budget.kind === 'unified' ? Math.min(budget.fastBytes, ramBytes * 0.75) : budget.fastBytes),
148
+ // Non-reclaimable accelerator commitments get their own ceiling: a larger
149
+ // RAM budget is not permission to wire the whole machine. An integrated
150
+ // GPU's carve-out (`vramBytes` on a unified host) is already the GPU's,
151
+ // and sits outside `ramBytes`.
152
+ gpuBudgetBytes: Math.min(budgetBytes, budget.kind === 'unified'
153
+ ? Math.min(budget.fastBytes, unifiedGpuCeilingBytes(ramBytes, platform) + budget.vramBytes)
154
+ : budget.fastBytes),
137
155
  };
138
156
  }
139
157
  export async function sampleDeviceCapacity(home) {
@@ -260,6 +260,8 @@ export class LocalEngineRuntime {
260
260
  ? {
261
261
  unifiedMemory: memoryProfile.gpuMemoryKind === 'integrated' ||
262
262
  memoryProfile.gpuMemoryKind === 'unified',
263
+ reservedGpuBytes: memoryProfile.gpuReservedBytes ?? null,
264
+ gpuAddressableBytes: memoryProfile.gpuAddressableBytes ?? null,
263
265
  }
264
266
  : {}),
265
267
  allowRamSpillover: config.allowRamSpillover ?? null,
@@ -1161,7 +1163,7 @@ export class LocalEngineRuntime {
1161
1163
  // engine: the full plan must run so a stale resident window surfaces as
1162
1164
  // restart-required instead of masquerading as the applied setting.
1163
1165
  const shortCircuit = (residentContextWindow !== undefined && !overrideActive) || envNumCtx !== undefined;
1164
- const { autoKvCacheMaxBytes, defaultLocalEngineSlots, estimatePerSlotKvBytes, kvQuantScale, llamaCppSlotCeiling, planAdaptiveContextGrowth, planCtxTokensForMemory, plannedLocalEngineSlots, } = await import('./capacity-broker.js');
1166
+ const { defaultLocalEngineSlots, estimatePerSlotKvBytes, kvQuantScale, llamaCppSlotCeiling, planAdaptiveContextGrowth, planCtxTokensForMemory, plannedLocalEngineSlots, } = await import('./capacity-broker.js');
1165
1167
  const capacity = await this.previewCapacityInputs('llama-cpp', modelId, capacityOpts);
1166
1168
  const { budgetBytes: admissionBudgetBytes, fastBudgetBytes, committedOtherBytes } = capacity;
1167
1169
  const configuredSlots = config.providerConcurrency?.['llama-cpp'];
@@ -1481,27 +1483,18 @@ export class LocalEngineRuntime {
1481
1483
  vramBytes: capacity.vramBytes,
1482
1484
  });
1483
1485
  // Mirror the launch path's windowed-cache admission (see
1484
- // buildLlamaCppProvider): when the launch will decline the Gemma
1485
- // `--swa-full` auto-default (or the windowed cache is pinned), the
1486
- // full-attention plan above overstates the real allocation — re-plan
1487
- // with the windowed linearization so the previewed window matches
1488
- // what the engine will actually grant.
1486
+ // buildLlamaCppProvider): unless `swaFull` is pinned, a
1487
+ // sliding-window model runs the windowed cache, which the
1488
+ // full-attention plan above overstates — re-plan with the windowed
1489
+ // linearization so the previewed window matches what the engine will
1490
+ // actually grant.
1489
1491
  // Strict model-max deliberately does NOT gate this: for SWA models
1490
1492
  // the windowed cache is the only layout whose native-window KV can
1491
1493
  // fit real machines, and the strict minimum rides inside
1492
1494
  // `requirement.minimumPerTurnCtxTokens`, so the windowed
1493
1495
  // re-plan sheds slots or denies but never shortens the window.
1494
1496
  const explicitSwaFull = config.llamaCppSwaFull ?? manifestEngineConfig?.swaFull;
1495
- const fullCacheOverCeiling = exactKvAtReference !== undefined &&
1496
- (exactKvAtReference / referenceCtx) * grantedCtx * slots > autoKvCacheMaxBytes();
1497
- const windowedCacheWillRun = (!admission.minimumSatisfied ||
1498
- admission.clamped ||
1499
- admission.slots < slots ||
1500
- fullCacheOverCeiling) &&
1501
- (explicitSwaFull === false ||
1502
- (explicitSwaFull === undefined &&
1503
- isGemmaModel({ architecture: installed.architecture, modelId })));
1504
- if (windowedCacheWillRun) {
1497
+ if (explicitSwaFull !== true) {
1505
1498
  const windowed = estimateWindowedKvLinearization({
1506
1499
  blockCount: summary.blockCount,
1507
1500
  embeddingLength: summary.embeddingLength,
@@ -1541,9 +1534,13 @@ export class LocalEngineRuntime {
1541
1534
  fixedPerSlotBytes: windowed.fixedBytes,
1542
1535
  };
1543
1536
  }
1544
- else if ((summary.slidingWindow ?? 0) > 0 || explicitSwaFull === undefined) {
1545
- // SWA model without a readable layout: the launch path leaves
1546
- // such a launch untouched — preview the requested window.
1537
+ else if ((!admission.minimumSatisfied || admission.clamped || admission.slots < slots) &&
1538
+ (explicitSwaFull === false
1539
+ ? (summary.slidingWindow ?? 0) > 0
1540
+ : isGemmaModel({ architecture: installed.architecture, modelId }))) {
1541
+ // SWA model without a readable layout, over budget on full
1542
+ // math: the launch path leaves such a launch untouched — preview
1543
+ // the requested window.
1547
1544
  return { grantedCtx, slots, kvCacheType: kv, plannedFieldsOk: false };
1548
1545
  }
1549
1546
  }
@@ -50,5 +50,7 @@ export async function measuredCapacityBudget() {
50
50
  systemRamBytes: profile.totalRamBytes,
51
51
  gpuVramBytes: profile.gpuVramBytes,
52
52
  unifiedMemory: profile.gpuMemoryKind === 'integrated' || profile.gpuMemoryKind === 'unified',
53
+ reservedGpuBytes: profile.gpuReservedBytes ?? null,
54
+ gpuAddressableBytes: profile.gpuAddressableBytes ?? null,
53
55
  });
54
56
  }
@@ -158,6 +158,11 @@ export interface NativeEngineSupervisorOptions {
158
158
  onLog?: (line: string) => void;
159
159
  /** Called for both expected and unexpected exits after a snapshot is sealed. */
160
160
  onExit?: (snapshot: NativeEngineExitSnapshot) => void | Promise<void>;
161
+ /**
162
+ * Called each time a start reaches readiness. Errors are caught and logged;
163
+ * a failing hook never fails the start.
164
+ */
165
+ onReady?: (launch: NativeEngineLaunch) => void;
161
166
  /**
162
167
  * Consulted after a start attempt fails (child died or never became
163
168
  * ready — not spawn errors, and not crashes after readiness). Return
@@ -214,6 +219,13 @@ export interface NativeEngineSupervisorOptions {
214
219
  psRunner?: () => Promise<NativeProcessSnapshot[]>;
215
220
  /** Platform seam for Windows owner-liveness semantics in orphan tests. */
216
221
  platform?: NodeJS.Platform;
222
+ /**
223
+ * The kernel's running count of out-of-memory kills, or null when it
224
+ * cannot be read. Compared across a child's life to tell Linux's
225
+ * out-of-memory killer from any other SIGKILL. Defaults to `oom_kill` in
226
+ * `/proc/vmstat` on Linux (any account can read it) and null elsewhere.
227
+ */
228
+ readOomKillCount?: () => number | null;
217
229
  /**
218
230
  * Test seam paired with `psRunner` — invoked instead of
219
231
  * `process.kill(pid, 'SIGKILL')`. Lets unit tests assert which pids
@@ -263,6 +275,7 @@ export declare class NativeEngineSupervisor {
263
275
  private readonly fetchImpl;
264
276
  private readonly onLog;
265
277
  private readonly onExit?;
278
+ private readonly onReady?;
266
279
  private readonly recoverStartup?;
267
280
  private readonly onRawLine?;
268
281
  private readonly logPrefix;
@@ -271,6 +284,9 @@ export declare class NativeEngineSupervisor {
271
284
  private readonly psRunner;
272
285
  private readonly killProcess;
273
286
  private readonly platform;
287
+ private readonly readOomKillCount;
288
+ /** `oom_kill` when the current child spawned; null where it cannot be read. */
289
+ private currentOomKills;
274
290
  private readonly logListeners;
275
291
  private recentStarts;
276
292
  private lastUsedAt;
@@ -482,6 +498,13 @@ export declare class NativeEngineSupervisor {
482
498
  */
483
499
  private runFreeze;
484
500
  private clearIdleTimer;
501
+ /**
502
+ * The counter's movement since `before`, when it rose. Another process
503
+ * killed in the same window would also move it, but a child that died by a
504
+ * SIGKILL this supervisor did not send while the counter rose is, in
505
+ * practice, the one the killer chose.
506
+ */
507
+ private oomKillsSince;
485
508
  private clearFreezeTimer;
486
509
  private clearHealthTimer;
487
510
  }
@@ -490,6 +513,8 @@ export declare function classifyNativeEnginePanic(line: string): {
490
513
  kind: NativeEnginePanicKind;
491
514
  line: string;
492
515
  } | undefined;
516
+ /** `oom_kill` from `/proc/vmstat` (Linux 4.13+), or null when it cannot be read. */
517
+ export declare function readLinuxOomKillCount(): number | null;
493
518
  /** Parse the portable `ps -axo pid=,ppid=,command=` shape. */
494
519
  export declare function parseNativeProcessSnapshot(stdout: string): NativeProcessSnapshot[];
495
520
  /** `code` stamped on every engine-launch failure. See {@link isEngineLaunchError}. */
@@ -21,6 +21,7 @@
21
21
  * so we can drive the lifecycle without a real binary.
22
22
  */
23
23
  import { spawn as nodeSpawn } from 'node:child_process';
24
+ import { readFileSync } from 'node:fs';
24
25
  import { basename } from 'node:path';
25
26
  import { awakeNow, createLogger } from '@bendyline/gezel';
26
27
  import { listProcessSnapshots } from '@bendyline/gezel-client/node';
@@ -179,6 +180,7 @@ export class NativeEngineSupervisor {
179
180
  fetchImpl;
180
181
  onLog;
181
182
  onExit;
183
+ onReady;
182
184
  recoverStartup;
183
185
  onRawLine;
184
186
  logPrefix;
@@ -187,6 +189,9 @@ export class NativeEngineSupervisor {
187
189
  psRunner;
188
190
  killProcess;
189
191
  platform;
192
+ readOomKillCount;
193
+ /** `oom_kill` when the current child spawned; null where it cannot be read. */
194
+ currentOomKills = null;
190
195
  // Ad-hoc log listeners attached at runtime (vs. the construction-time
191
196
  // `onLog` / `onRawLine` hooks). Used by the owning provider to
192
197
  // subscribe to engine log lines for the duration of a single request
@@ -274,6 +279,8 @@ export class NativeEngineSupervisor {
274
279
  this.onLog = opts.onLog ?? ((line) => log.info(line));
275
280
  if (opts.onExit)
276
281
  this.onExit = opts.onExit;
282
+ if (opts.onReady)
283
+ this.onReady = opts.onReady;
277
284
  if (opts.recoverStartup)
278
285
  this.recoverStartup = opts.recoverStartup;
279
286
  if (opts.onRawLine)
@@ -282,6 +289,8 @@ export class NativeEngineSupervisor {
282
289
  this.readinessPath = opts.readinessPath ?? '/health';
283
290
  this.readyOnAnyResponse = opts.readyOnAnyResponse ?? false;
284
291
  this.platform = opts.platform ?? process.platform;
292
+ this.readOomKillCount =
293
+ opts.readOomKillCount ?? (this.platform === 'linux' ? readLinuxOomKillCount : () => null);
285
294
  this.psRunner = opts.psRunner ?? (() => defaultPsRunner(this.platform));
286
295
  this.killProcess = opts.killProcess ?? ((pid, sig) => process.kill(pid, sig));
287
296
  }
@@ -600,6 +609,7 @@ export class NativeEngineSupervisor {
600
609
  this.currentDiagnostics = launch.diagnostics ? { ...launch.diagnostics } : undefined;
601
610
  this.currentOutput = '';
602
611
  this.currentPanic = undefined;
612
+ this.currentOomKills = this.readOomKillCount();
603
613
  this.expectedExitReason = undefined;
604
614
  this.onLog(`${this.logPrefix} launch ${JSON.stringify({
605
615
  pid: this.currentPid ?? null,
@@ -698,6 +708,12 @@ export class NativeEngineSupervisor {
698
708
  this.state = { kind: 'running', launch, child, healthFails: 0 };
699
709
  this.resetIdleTimer();
700
710
  this.startHealthWatch();
711
+ try {
712
+ this.onReady?.(launch);
713
+ }
714
+ catch (hookErr) {
715
+ this.onLog(`${this.logPrefix} onReady hook threw: ${hookErr instanceof Error ? hookErr.message : hookErr}`);
716
+ }
701
717
  }
702
718
  catch (err) {
703
719
  // The panic classifier state is cleared by handleExit — read it now,
@@ -998,13 +1014,23 @@ export class NativeEngineSupervisor {
998
1014
  // the output didn't already explain the death more precisely: an
999
1015
  // engine that printed `CUDA error: …` and then took SIGILL on the
1000
1016
  // way down is a CUDA fault, not an unrunnable build.
1001
- const panic = this.currentPanic ??
1002
- (signal === 'SIGILL'
1003
- ? {
1004
- kind: 'illegal-instruction',
1005
- line: 'engine terminated by SIGILL (illegal instruction) with no diagnostic output',
1006
- }
1007
- : undefined);
1017
+ // An out-of-memory kill outranks anything the engine printed: a CUDA or
1018
+ // Vulkan allocation line on the way there is a symptom of the same
1019
+ // shortage, and the GPU-to-RAM ladder those lines trigger cannot help
1020
+ // when RAM is what ran out.
1021
+ const oomKilled = signal === 'SIGKILL' && !expected ? this.oomKillsSince(this.currentOomKills) : null;
1022
+ const panic = oomKilled
1023
+ ? {
1024
+ kind: 'host-out-of-memory',
1025
+ line: `engine killed by the Linux out-of-memory killer (oom_kill ${oomKilled.before} -> ${oomKilled.after})`,
1026
+ }
1027
+ : (this.currentPanic ??
1028
+ (signal === 'SIGILL'
1029
+ ? {
1030
+ kind: 'illegal-instruction',
1031
+ line: 'engine terminated by SIGILL (illegal instruction) with no diagnostic output',
1032
+ }
1033
+ : undefined));
1008
1034
  const snapshot = {
1009
1035
  incidentId: `native-${pid ?? 'unknown'}-${exitedAt}`,
1010
1036
  ...(pid !== undefined ? { pid } : {}),
@@ -1062,6 +1088,7 @@ export class NativeEngineSupervisor {
1062
1088
  this.currentDiagnostics = undefined;
1063
1089
  this.currentOutput = '';
1064
1090
  this.currentPanic = undefined;
1091
+ this.currentOomKills = null;
1065
1092
  this.expectedExitReason = undefined;
1066
1093
  if (priorState.kind === 'stopped')
1067
1094
  return;
@@ -1313,6 +1340,18 @@ export class NativeEngineSupervisor {
1313
1340
  this.idleTimer = undefined;
1314
1341
  }
1315
1342
  }
1343
+ /**
1344
+ * The counter's movement since `before`, when it rose. Another process
1345
+ * killed in the same window would also move it, but a child that died by a
1346
+ * SIGKILL this supervisor did not send while the counter rose is, in
1347
+ * practice, the one the killer chose.
1348
+ */
1349
+ oomKillsSince(before) {
1350
+ if (before === null)
1351
+ return null;
1352
+ const after = this.readOomKillCount();
1353
+ return after !== null && after > before ? { before, after } : null;
1354
+ }
1316
1355
  clearFreezeTimer() {
1317
1356
  if (this.freezeTimer) {
1318
1357
  clearTimeout(this.freezeTimer);
@@ -1346,6 +1385,9 @@ export function classifyNativeEnginePanic(line) {
1346
1385
  if (/CUDA error:.*device-side assert/i.test(line)) {
1347
1386
  return { kind: 'cuda-device-assert', line: clipped };
1348
1387
  }
1388
+ if (/CUDA error:\s*(?:no kernel image is available|the provided PTX was compiled with an unsupported toolchain)/i.test(line)) {
1389
+ return { kind: 'cuda-unsupported-device', line: clipped };
1390
+ }
1349
1391
  if (/CUDA error:/i.test(line))
1350
1392
  return { kind: 'cuda-error', line: clipped };
1351
1393
  if (/assert(?:ion)? failed|GGML_ASSERT/i.test(line)) {
@@ -1353,6 +1395,16 @@ export function classifyNativeEnginePanic(line) {
1353
1395
  }
1354
1396
  return undefined;
1355
1397
  }
1398
+ /** `oom_kill` from `/proc/vmstat` (Linux 4.13+), or null when it cannot be read. */
1399
+ export function readLinuxOomKillCount() {
1400
+ try {
1401
+ const match = /^oom_kill (\d+)$/m.exec(readFileSync('/proc/vmstat', 'utf8'));
1402
+ return match?.[1] ? Number(match[1]) : null;
1403
+ }
1404
+ catch {
1405
+ return null;
1406
+ }
1407
+ }
1356
1408
  function panicPriority(kind) {
1357
1409
  if (kind === 'cuda-error')
1358
1410
  return 1;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The amdgpu devices Linux describes in sysfs, for the carve-out decision in
3
+ * gpu-carve-out.ts — the Linux counterpart of the DXCore adapter listing the
4
+ * device-health helper reads on Windows. Everything read here is
5
+ * world-readable, so the machine service's unprivileged account sees it too.
6
+ */
7
+ import type { GpuAdapterFacts } from './gpu-carve-out.js';
8
+ export interface SysfsIo {
9
+ readFile?: (path: string) => string;
10
+ readDir?: (path: string) => string[];
11
+ }
12
+ /**
13
+ * One entry per amdgpu device: `dedicatedBytes` from `mem_info_vram_total`,
14
+ * `sharedSystemBytes` from `mem_info_gtt_total` (the system RAM the kernel
15
+ * lets it map). Connector entries (`card0-HDMI-A-1`) and render nodes are
16
+ * skipped. Empty off Linux, without amdgpu, or when sysfs cannot be read.
17
+ */
18
+ export declare function readAmdgpuAdapters(io?: SysfsIo): GpuAdapterFacts[];
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The amdgpu devices Linux describes in sysfs, for the carve-out decision in
3
+ * gpu-carve-out.ts — the Linux counterpart of the DXCore adapter listing the
4
+ * device-health helper reads on Windows. Everything read here is
5
+ * world-readable, so the machine service's unprivileged account sees it too.
6
+ */
7
+ import { readFileSync, readdirSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ const DRM_ROOT = '/sys/class/drm';
10
+ const AMD_PCI_VENDOR = 0x1002;
11
+ /**
12
+ * One entry per amdgpu device: `dedicatedBytes` from `mem_info_vram_total`,
13
+ * `sharedSystemBytes` from `mem_info_gtt_total` (the system RAM the kernel
14
+ * lets it map). Connector entries (`card0-HDMI-A-1`) and render nodes are
15
+ * skipped. Empty off Linux, without amdgpu, or when sysfs cannot be read.
16
+ */
17
+ export function readAmdgpuAdapters(io = {}) {
18
+ const readFile = io.readFile ?? ((path) => readFileSync(path, 'utf8'));
19
+ const readDir = io.readDir ?? ((path) => readdirSync(path));
20
+ const read = (path) => {
21
+ try {
22
+ return readFile(path).trim();
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ };
28
+ let cards;
29
+ try {
30
+ cards = readDir(DRM_ROOT).filter((entry) => /^card\d+$/.test(entry));
31
+ }
32
+ catch {
33
+ return [];
34
+ }
35
+ const adapters = [];
36
+ for (const card of cards) {
37
+ const device = join(DRM_ROOT, card, 'device');
38
+ if (Number.parseInt(read(join(device, 'vendor')) ?? '', 16) !== AMD_PCI_VENDOR)
39
+ continue;
40
+ const vram = byteCount(read(join(device, 'mem_info_vram_total')));
41
+ if (vram === undefined)
42
+ continue;
43
+ const gtt = byteCount(read(join(device, 'mem_info_gtt_total')));
44
+ adapters.push({
45
+ vendorId: AMD_PCI_VENDOR,
46
+ dedicatedBytes: vram,
47
+ ...(gtt !== undefined ? { sharedSystemBytes: gtt } : {}),
48
+ });
49
+ }
50
+ return adapters;
51
+ }
52
+ function byteCount(raw) {
53
+ const value = raw ? Number(raw) : Number.NaN;
54
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
55
+ }