@bendyline/gezel-inference 1.0.0 → 1.0.1

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 (60) 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-manifest.d.ts +8 -0
  20. package/dist/engines/native-manifest.js +30 -0
  21. package/dist/http/openai-compat/apple-model.d.ts +2 -0
  22. package/dist/http/openai-compat/apple-model.js +21 -1
  23. package/dist/http/openai-compat/chat-target.d.ts +12 -0
  24. package/dist/http/openai-compat/chat-target.js +27 -1
  25. package/dist/http/routes/engines.js +9 -3
  26. package/dist/http/routes/v1-chat.js +21 -8
  27. package/dist/http/routes/v1-models.d.ts +6 -0
  28. package/dist/http/routes/v1-models.js +133 -28
  29. package/dist/http/routes/v1-responses.js +9 -2
  30. package/dist/in-flight-requests.d.ts +19 -0
  31. package/dist/in-flight-requests.js +34 -0
  32. package/dist/inference-service.js +3 -3
  33. package/dist/providers/llama-cpp/build-provider.js +33 -8
  34. package/dist/providers/llama-cpp/cache-adapter.d.ts +12 -0
  35. package/dist/providers/llama-cpp/cache-adapter.js +34 -0
  36. package/dist/providers/llama-cpp/gguf-metadata-async.js +7 -0
  37. package/dist/providers/mlx/provider.js +11 -7
  38. package/dist/providers/mlx/python/batch_processors.py +26 -0
  39. package/dist/providers/mlx/python/gezel_mlx_server.py +88 -20
  40. package/dist/providers/mlx/python/response_format.py +374 -0
  41. package/dist/providers/mlx/python/spec_decode.py +5 -0
  42. package/dist/providers/mlx/python/think_budget.py +35 -1
  43. package/dist/providers/mlx/python/tool_grammar.py +62 -3
  44. package/dist/providers/mlx/reasoning-stream.d.ts +20 -2
  45. package/dist/providers/mlx/reasoning-stream.js +77 -16
  46. package/dist/providers/mlx/runtime-diagnostics.d.ts +16 -0
  47. package/dist/providers/mlx/runtime-diagnostics.js +29 -2
  48. package/dist/providers/native/capacity-broker.d.ts +35 -0
  49. package/dist/providers/native/capacity-broker.js +69 -4
  50. package/dist/providers/native/local-engine-runtime.js +7 -2
  51. package/dist/remotes/identity.d.ts +1 -0
  52. package/dist/remotes/identity.js +6 -0
  53. package/dist/remotes/pinned-fetch.d.ts +2 -0
  54. package/dist/remotes/pinned-fetch.js +30 -1
  55. package/dist/remotes/types.d.ts +14 -0
  56. package/dist/runtime-lock.js +1 -1
  57. package/dist/system/memory.js +11 -1
  58. package/dist/utils/service-worker-entry.d.ts +4 -0
  59. package/dist/utils/service-worker-entry.js +4 -0
  60. package/package.json +14 -10
@@ -1,6 +1,8 @@
1
- import { externalGezelModelId, isRecommendedModel, resolveDistributionProfile, } from '@bendyline/gezel';
1
+ import { estimateManifestKvBytes, externalGezelModelId, isRecommendedModel, localContextFloorTokens, rateModelsForDevice, resolveDistributionProfile, } from '@bendyline/gezel';
2
2
  import { resolveOnDeviceProvider } from '@bendyline/gezel/native';
3
3
  import { Hono } from 'hono';
4
+ import { CapacityBroker } from '../../providers/native/capacity-broker.js';
5
+ import { detectMemoryProfileCached } from '../../system/memory.js';
4
6
  import { appleSystemModel } from '../openai-compat/apple-model.js';
5
7
  import { resolveFallbackGezelId } from '../openai-compat/chat-target.js';
6
8
  /**
@@ -30,12 +32,35 @@ const PROVIDERS_TO_ENUMERATE = [
30
32
  'ds4',
31
33
  ];
32
34
  const LOCAL_PROVIDERS = ['llama-cpp', 'mlx', 'ds4'];
35
+ /**
36
+ * Engines that enforce `response_format` with a grammar: llama-server's
37
+ * `json_schema`, and the MLX sidecar's llguidance JSON grammar
38
+ * (`providers/mlx/python/response_format.py`). ds4 shares llama.cpp's request
39
+ * map, but nothing here shows its server enforces a schema, so it is not
40
+ * advertised.
41
+ */
42
+ function enforcesResponseFormat(provider) {
43
+ return provider === 'llama-cpp' || provider === 'mlx';
44
+ }
45
+ function catalogCapabilities(manifest, provider) {
46
+ return {
47
+ text: true,
48
+ tools: manifest.supportsTools,
49
+ structuredOutput: enforcesResponseFormat(provider),
50
+ images: false,
51
+ foregroundOnly: false,
52
+ };
53
+ }
33
54
  function catalogBackend(manifest) {
55
+ // ds4 has no portable fallback: `discoverNativeBinaries` stamps its binary
56
+ // only where a build for this host is on disk, which is the gate the
57
+ // Settings availability check reads too. Windows has none.
58
+ const ds4 = manifest.ds4 && process.env.GEZEL_DS4_SERVER_BIN ? 'ds4' : null;
34
59
  // Store hosts have no provisioned Python runtime. Only advertise models
35
60
  // backed by bundled native engines until a frozen MLX runtime ships.
36
61
  const store = resolveDistributionProfile().pythonProvisioning === 'frozen-only';
37
62
  if (store)
38
- return manifest.llamaCpp ? 'llama-cpp' : manifest.ds4 ? 'ds4' : null;
63
+ return manifest.llamaCpp ? 'llama-cpp' : ds4;
39
64
  const preferred = resolveOnDeviceProvider(process.platform, process.arch);
40
65
  if (preferred === 'mlx' && manifest.mlx && !manifest.mlx.disabledReason)
41
66
  return 'mlx';
@@ -45,19 +70,71 @@ function catalogBackend(manifest) {
45
70
  return 'llama-cpp';
46
71
  if (manifest.mlx && !manifest.mlx.disabledReason && process.platform === 'darwin')
47
72
  return 'mlx';
48
- if (manifest.ds4)
49
- return 'ds4';
50
- return null;
73
+ return ds4;
74
+ }
75
+ /**
76
+ * What loading a catalog model would hold, priced as the capacity broker
77
+ * prices it: the catalog's measured pin when it has one, else the engine's
78
+ * estimate, plus KV at the context floor this machine launches with. ds4's
79
+ * figure already bounds its streamed working set, so it takes no extra KV.
80
+ */
81
+ function footprintBytes({ manifest, provider, source }, memory) {
82
+ const mmprojBytes = provider === 'llama-cpp' ? manifest.llamaCpp?.mmproj?.sizeBytes : undefined;
83
+ const weights = source.residentBytes ??
84
+ CapacityBroker.estimateResidentBytes(provider, source.approxSizeBytes, mmprojBytes ? { mmprojBytes } : undefined);
85
+ if (provider === 'ds4')
86
+ return weights;
87
+ const ctxTokens = localContextFloorTokens({
88
+ totalRamBytes: memory.totalRamBytes,
89
+ gpuVramBytes: fastGpuBytes(memory),
90
+ });
91
+ // MLX keeps its cache in f16; llama.cpp's default q8_0 is the helper's default.
92
+ return (weights +
93
+ estimateManifestKvBytes(manifest, {
94
+ ctxTokens,
95
+ ...(provider === 'mlx' ? { cacheScale: 1 } : {}),
96
+ }));
97
+ }
98
+ /**
99
+ * An integrated adapter's advertised allocation is shared system RAM, not a
100
+ * fast pool beside it, so fit treats that host as RAM-paced (as Settings does).
101
+ */
102
+ function fastGpuBytes(memory) {
103
+ return memory.gpuMemoryKind === 'integrated' ? null : memory.gpuVramBytes;
104
+ }
105
+ function recoDevice(memory) {
106
+ return {
107
+ platform: memory.platform,
108
+ gpuVramBytes: fastGpuBytes(memory),
109
+ gpuMemoryKind: memory.gpuMemoryKind,
110
+ totalRamBytes: memory.totalRamBytes,
111
+ usableBytes: memory.usableBytes,
112
+ budgetBytes: memory.budgetBytes,
113
+ };
51
114
  }
52
- async function downloadableCatalogEntries(ctx, created, existingIds) {
53
- const items = await ctx.catalog.list('chat-model').catch(() => []);
54
- return items
55
- .flatMap((item) => {
115
+ /** Curated order, for when this machine's memory could not be read. */
116
+ function byCuratedScore(a, b) {
117
+ const aRecommended = isRecommendedModel(a.manifest);
118
+ const bRecommended = isRecommendedModel(b.manifest);
119
+ if (aRecommended !== bRecommended)
120
+ return aRecommended ? -1 : 1;
121
+ const score = (b.manifest.recoScore ?? 0) - (a.manifest.recoScore ?? 0);
122
+ return score || (a.entry.name ?? a.entry.id).localeCompare(b.entry.name ?? b.entry.id);
123
+ }
124
+ /**
125
+ * Downloadable models, each rated against this machine (`fit`,
126
+ * `recommendation`) and listed best first. A model too big to run here stays
127
+ * listed with `fit.tier: 'too-big'` and a 0 score, so a client decides
128
+ * whether to hide it.
129
+ */
130
+ async function downloadableCatalogEntries(items, created, existingIds, memoryProfile) {
131
+ const candidates = items.flatMap((item) => {
56
132
  const manifest = item.manifest;
57
133
  if (manifest.kind !== 'chat-model')
58
134
  return [];
59
135
  const provider = catalogBackend(manifest);
60
- if (!provider)
136
+ const source = provider === 'mlx' ? manifest.mlx : provider === 'ds4' ? manifest.ds4 : manifest.llamaCpp;
137
+ if (!provider || !source)
61
138
  return [];
62
139
  const id = `${provider}:${manifest.id}`;
63
140
  if (existingIds.has(id))
@@ -65,6 +142,8 @@ async function downloadableCatalogEntries(ctx, created, existingIds) {
65
142
  return [
66
143
  {
67
144
  manifest,
145
+ provider,
146
+ source,
68
147
  entry: {
69
148
  id,
70
149
  object: 'model',
@@ -78,26 +157,41 @@ async function downloadableCatalogEntries(ctx, created, existingIds) {
78
157
  locality: 'on-device',
79
158
  download_bytes: manifest.approxSizeBytes,
80
159
  ...(manifest.contextWindow ? { context_window: manifest.contextWindow } : {}),
81
- capabilities: {
82
- text: true,
83
- tools: manifest.supportsTools,
84
- structuredOutput: false,
85
- images: false,
86
- foregroundOnly: false,
87
- },
160
+ capabilities: catalogCapabilities(manifest, provider),
88
161
  },
89
162
  },
90
163
  ];
91
- })
92
- .sort((a, b) => {
93
- const aRecommended = isRecommendedModel(a.manifest);
94
- const bRecommended = isRecommendedModel(b.manifest);
95
- if (aRecommended !== bRecommended)
96
- return aRecommended ? -1 : 1;
97
- const score = (b.manifest.recoScore ?? 0) - (a.manifest.recoScore ?? 0);
98
- return score || (a.entry.name ?? a.entry.id).localeCompare(b.entry.name ?? b.entry.id);
99
- })
100
- .map(({ entry }) => entry);
164
+ });
165
+ if (candidates.length === 0)
166
+ return [];
167
+ const memory = await memoryProfile().catch(() => null);
168
+ if (!memory)
169
+ return candidates.sort(byCuratedScore).map(({ entry }) => entry);
170
+ const rated = candidates.map((candidate) => {
171
+ const { entry, manifest, source } = candidate;
172
+ return {
173
+ id: entry.id,
174
+ residentBytes: footprintBytes(candidate, memory),
175
+ tags: manifest.tags,
176
+ supportsTools: manifest.supportsTools,
177
+ ...(manifest.recoScore !== undefined ? { recoScore: manifest.recoScore } : {}),
178
+ ...(manifest.licenseClass ? { licenseClass: manifest.licenseClass } : {}),
179
+ ...(source.quantization ? { quantization: source.quantization } : {}),
180
+ };
181
+ });
182
+ const entries = new Map(candidates.map(({ entry }) => [entry.id, entry]));
183
+ return rateModelsForDevice(rated, recoDevice(memory)).flatMap((rating) => {
184
+ const entry = entries.get(rating.id);
185
+ if (!entry)
186
+ return [];
187
+ return [
188
+ {
189
+ ...entry,
190
+ fit: { tier: rating.fit.tier, label: rating.fit.label, detail: rating.fit.detail },
191
+ recommendation: { score: rating.score, recommended: rating.recommended },
192
+ },
193
+ ];
194
+ });
101
195
  }
102
196
  async function buildModelEntries(ctx, created, options) {
103
197
  // Advertise every gezel as a selectable OpenAI "model". Keep the
@@ -128,6 +222,14 @@ async function buildModelEntries(ctx, created, options) {
128
222
  ...(gezel.id === fallbackGezelId ? { is_fallback: true } : {}),
129
223
  }));
130
224
  }
225
+ const catalogItems = await ctx.catalog.list('chat-model').catch(() => []);
226
+ // An installed model carries the capabilities its catalog entry
227
+ // advertised before download; a model with no entry stays unflagged
228
+ // rather than guessing its tools or images support.
229
+ const manifests = new Map();
230
+ for (const item of catalogItems)
231
+ if (item.manifest.kind === 'chat-model')
232
+ manifests.set(item.manifest.id, item.manifest);
131
233
  const providers = (options.localOnly ? LOCAL_PROVIDERS : PROVIDERS_TO_ENUMERATE).filter((provider) => provider !== 'mlx' || resolveDistributionProfile().pythonProvisioning !== 'frozen-only');
132
234
  const buckets = await Promise.all(providers.map(async (provider) => {
133
235
  try {
@@ -146,6 +248,9 @@ async function buildModelEntries(ctx, created, options) {
146
248
  : {}),
147
249
  ...(m.contextWindow ? { context_window: m.contextWindow } : {}),
148
250
  ...(m.supportsReasoning ? { supports_reasoning: true } : {}),
251
+ ...(enforcesResponseFormat(provider) && manifests.has(m.id)
252
+ ? { capabilities: catalogCapabilities(manifests.get(m.id), provider) }
253
+ : {}),
149
254
  }));
150
255
  }
151
256
  catch {
@@ -156,7 +261,7 @@ async function buildModelEntries(ctx, created, options) {
156
261
  }));
157
262
  const availableEntries = buckets.flat();
158
263
  const existingIds = new Set([...gezelEntries, ...availableEntries].map((entry) => entry.id));
159
- const downloadable = await downloadableCatalogEntries(ctx, created, existingIds);
264
+ const downloadable = await downloadableCatalogEntries(catalogItems, created, existingIds, options.memoryProfile ?? (() => detectMemoryProfileCached()));
160
265
  const apple = await appleSystemModel(created);
161
266
  return [...gezelEntries, ...apple, ...availableEntries, ...downloadable];
162
267
  }
@@ -3,10 +3,11 @@ import { Hono } from 'hono';
3
3
  import { bodyLimit } from 'hono/body-limit';
4
4
  import { streamSSE } from 'hono/streaming';
5
5
  import { ZodError } from 'zod';
6
+ import { UnsupportedResponseFormatError } from '../../providers/mlx/runtime-diagnostics.js';
6
7
  import { ExternalToolsUnsupportedError, ModelNotInstalledError, } from '../../providers/types.js';
7
8
  import { appendCallerOwnedActionLedger } from '../openai-compat/action-ledger.js';
8
9
  import { profileForCallerOwnedInference } from '../openai-compat/caller-owned-profile.js';
9
- import { resolveChatTarget } from '../openai-compat/chat-target.js';
10
+ import { resolveChatTarget, resolvePinnedModelTarget, } from '../openai-compat/chat-target.js';
10
11
  import { runResponsesNonStreaming, runResponsesStreaming, } from '../openai-compat/responses-streaming.js';
11
12
  import { ResponsesRequestSchema, translateResponsesRequest, } from '../openai-compat/responses-translate.js';
12
13
  import { flattenTranscriptIntoPrompt, parseGezelModelRef, resolveModelTarget, } from '../openai-compat/translate.js';
@@ -259,6 +260,9 @@ export function v1ResponsesRoutes(ctx, opts = {}) {
259
260
  if (err instanceof ExternalToolsUnsupportedError) {
260
261
  return invalidRequest(c, err.message, 'tools_not_supported_for_provider');
261
262
  }
263
+ if (err instanceof UnsupportedResponseFormatError) {
264
+ return invalidRequest(c, err.message, err.code);
265
+ }
262
266
  const message = err instanceof Error ? err.message : String(err);
263
267
  if (message.includes('messages must') ||
264
268
  message.includes('empty') ||
@@ -284,7 +288,10 @@ async function resolveResponsesTarget(model, ctx) {
284
288
  systemPrefix: '',
285
289
  };
286
290
  }
287
- throw new Error(`Unknown model "${model}". Use an explicit <provider>:<model> id or advertised gezel:<role>-<name> alias.`);
291
+ const pinned = await resolvePinnedModelTarget(ctx, model);
292
+ if (pinned)
293
+ return pinned;
294
+ throw new Error(`Unknown model "${model}". Use an explicit <provider>:<model> id, a model pinned as a default, or an advertised gezel:<role>-<name> alias.`);
288
295
  }
289
296
  function prependSystemPrefix(input, prefix) {
290
297
  if (!prefix)
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The in-process transport's open requests, and the one handle that keeps Node
3
+ * running while any of them is open.
4
+ *
5
+ * The direct transport has no socket, so nothing a request awaits is
6
+ * guaranteed to hold the event loop: engine supervisors and workers unref their
7
+ * timers and threads so that an idle host never blocks exit. A plain Node
8
+ * script exited with code 13 ("unsettled top-level await") inside `chat()`
9
+ * while llama-server cold-started; Electron's own handles had hidden it.
10
+ */
11
+ export declare class InFlightRequests {
12
+ private readonly open;
13
+ private keepAlive;
14
+ get size(): number;
15
+ add(controller: AbortController): void;
16
+ delete(controller: AbortController): void;
17
+ /** Abort and forget every open request, including a body nobody will read. */
18
+ abortAll(): void;
19
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The in-process transport's open requests, and the one handle that keeps Node
3
+ * running while any of them is open.
4
+ *
5
+ * The direct transport has no socket, so nothing a request awaits is
6
+ * guaranteed to hold the event loop: engine supervisors and workers unref their
7
+ * timers and threads so that an idle host never blocks exit. A plain Node
8
+ * script exited with code 13 ("unsettled top-level await") inside `chat()`
9
+ * while llama-server cold-started; Electron's own handles had hidden it.
10
+ */
11
+ export class InFlightRequests {
12
+ open = new Set();
13
+ keepAlive;
14
+ get size() {
15
+ return this.open.size;
16
+ }
17
+ add(controller) {
18
+ this.open.add(controller);
19
+ this.keepAlive ??= setInterval(() => { }, 2 ** 30);
20
+ }
21
+ delete(controller) {
22
+ if (!this.open.delete(controller) || this.open.size > 0)
23
+ return;
24
+ clearInterval(this.keepAlive);
25
+ this.keepAlive = undefined;
26
+ }
27
+ /** Abort and forget every open request, including a body nobody will read. */
28
+ abortAll() {
29
+ for (const controller of [...this.open]) {
30
+ controller.abort();
31
+ this.delete(controller);
32
+ }
33
+ }
34
+ }
@@ -18,6 +18,7 @@ import { v1KnowledgeRoutes } from './http/routes/v1-knowledge.js';
18
18
  import { v1ModelsEnsureRoutes } from './http/routes/v1-models-ensure.js';
19
19
  import { v1ModelsRoutes } from './http/routes/v1-models.js';
20
20
  import { v1ResponsesRoutes } from './http/routes/v1-responses.js';
21
+ import { InFlightRequests } from './in-flight-requests.js';
21
22
  import { KnowledgeManager } from './knowledge/manager.js';
22
23
  import { createWorkerCatalogHost } from './knowledge/worker-host.js';
23
24
  import { embeddingsHealth, openEmbeddings, shutdownEmbeddings } from './memory/embeddings.js';
@@ -44,14 +45,13 @@ export async function startService(options = {}) {
44
45
  const runtimeDir = join(home, 'runtime');
45
46
  const lock = await acquireSingleInstanceLock({ runtimeDir, lockPath: join(runtimeDir, 'lock') });
46
47
  const cleanup = [() => lock.release()];
47
- const requests = new Set();
48
+ const requests = new InFlightRequests();
48
49
  let stopping;
49
50
  let closed = false;
50
51
  const stop = () => {
51
52
  stopping ??= (async () => {
52
53
  closed = true;
53
- for (const controller of requests)
54
- controller.abort();
54
+ requests.abortAll();
55
55
  const errors = [];
56
56
  for (const close of cleanup.reverse())
57
57
  try {
@@ -8,7 +8,7 @@ import { effectiveEngineRelease, isEnginePinned } from '../../engines/native-man
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, availableSystemRamBytes, formatContextCapacityDenial, minViableLocalContextTokens, planAdaptiveContextGrowth, pressureIdleGraceMs, resolveLlamaCppContextRequirement, } from '../native/capacity-broker.js';
11
+ import { CapacityDeniedError, NOT_ENOUGH_MEMORY_MESSAGE, autoKvCacheMaxBytes, 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';
@@ -803,7 +803,13 @@ export async function buildLlamaCppProvider(opts) {
803
803
  architecture: modelCatalogInfo?.architecture,
804
804
  modelId: defaultModelId ?? undefined,
805
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;
806
811
  const fullKvOverBudget = fullCacheFitsFastMemory === false ||
812
+ fullCacheOverCeiling ||
807
813
  !admission.minimumSatisfied ||
808
814
  admission.slots < slots ||
809
815
  admission.clamped;
@@ -867,13 +873,32 @@ export async function buildLlamaCppProvider(opts) {
867
873
  const fastFitDetail = fullCacheFitsFastMemory === false && fullKvBytesAtRequested !== undefined
868
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`
869
875
  : 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(''));
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
+ }
877
902
  }
878
903
  if (windowed) {
879
904
  ladderPlan = planCtxTokensForMemory({
@@ -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);
@@ -410,6 +419,7 @@ export class LlamaCppCacheAdapter {
410
419
  log.debug(`slot ${chosen} recycled: evicting session ${victimSession.slice(0, 8)} for ${sessionId.slice(0, 8)} (disk save: ${saved ? 'ok' : 'MISSED'})`);
411
420
  this.sessionToSlot.delete(victimSession);
412
421
  this.slotToSession.delete(chosen);
422
+ this.ephemeralSessions.delete(victimSession);
413
423
  }
414
424
  }
415
425
  // We now have a slot id `chosen` that's free of any current binding.
@@ -534,6 +544,9 @@ export class LlamaCppCacheAdapter {
534
544
  const baseUrl = await this.resolveBaseUrl().catch(() => null);
535
545
  if (!baseUrl || generation !== this.engineGeneration)
536
546
  return false;
547
+ if (this.ephemeralSessions.has(sessionId)) {
548
+ return this.seedPrefixFromEphemeralSlot(baseUrl, slot, sessionId, generation);
549
+ }
537
550
  const filename = sessFilename(sessionId);
538
551
  const ok = await this.invokeSlotAction(baseUrl, slot, 'save', filename, generation);
539
552
  if (!ok)
@@ -571,6 +584,27 @@ export class LlamaCppCacheAdapter {
571
584
  }
572
585
  return true;
573
586
  }
587
+ /**
588
+ * A one-shot session's only lasting value is the prompt prefix it shares
589
+ * with its gezel's next session. Save the slot straight to that prefix file
590
+ * when none exists yet; otherwise write nothing. Returns whether it wrote.
591
+ */
592
+ async seedPrefixFromEphemeralSlot(baseUrl, slot, sessionId, generation) {
593
+ if (!this.slotSavePath)
594
+ return false;
595
+ const prefixId = this.sessionPrefix.get(sessionId);
596
+ if (!prefixId || this.seededPrefixes.has(prefixId))
597
+ return false;
598
+ const name = prefixFilename(prefixId);
599
+ if (await stat(join(this.slotSavePath, name)).catch(() => null)) {
600
+ this.seededPrefixes.add(prefixId);
601
+ return false;
602
+ }
603
+ const ok = await this.invokeSlotAction(baseUrl, slot, 'save', name, generation);
604
+ if (ok)
605
+ this.seededPrefixes.add(prefixId);
606
+ return ok;
607
+ }
574
608
  /** Try restore-by-session, then restore-by-prefix. Returns true if
575
609
  * any restore succeeded. */
576
610
  async tryRestoreForSession(slot, sessionId) {
@@ -122,6 +122,9 @@ async function runInWorker(path, options) {
122
122
  const id = nextId++;
123
123
  return new Promise((resolveSummary, reject) => {
124
124
  pending.set(id, { resolve: resolveSummary, reject });
125
+ // Held while busy: an unreferenced worker let a plain Node host exit
126
+ // mid-await during an engine cold start.
127
+ target.ref();
125
128
  target.postMessage({ id, path, ...(options ? { options } : {}) });
126
129
  });
127
130
  }
@@ -141,6 +144,8 @@ function ensureWorker() {
141
144
  else if (worker === next)
142
145
  worker = null;
143
146
  });
147
+ // An idle reader must never hold the event loop open; runInWorker refs it
148
+ // only while a request is in flight.
144
149
  next.unref();
145
150
  worker = next;
146
151
  return next;
@@ -158,6 +163,8 @@ function onMessage(reply) {
158
163
  if (!request)
159
164
  return;
160
165
  pending.delete(reply.id);
166
+ if (pending.size === 0)
167
+ worker?.unref();
161
168
  if (reply.error)
162
169
  request.reject(new Error(reply.error));
163
170
  else if (reply.summary)
@@ -58,9 +58,9 @@ import { MlxEngineGate } from './engine-gate.js';
58
58
  import { EngineHold, engineHoldLabelFor } from './engine-hold.js';
59
59
  import { EngineLogRouter } from './engine-log-router.js';
60
60
  import { MlxEngineStatsSampler } from './engine-stats-sampler.js';
61
- import { StreamingReasoningSplit, requestOpensReasoning, } from './reasoning-stream.js';
61
+ import { REASONING_OPEN_HEADER, StreamingReasoningSplit, engineOpensReasoning, requestOpensReasoning, } from './reasoning-stream.js';
62
62
  import { applyMlxRequestShape } from './request-shape.js';
63
- import { PRE_FIRST_BYTE_BASE_MS, buildMidStreamDropMessage, buildPreFirstByteAbortMessage, computePreFirstByteBudgetMs, estimatePromptTokens, formatTps, isMidStreamConnectionDrop, translateMlxHttpError, } from './runtime-diagnostics.js';
63
+ import { PRE_FIRST_BYTE_BASE_MS, buildMidStreamDropMessage, buildPreFirstByteAbortMessage, computePreFirstByteBudgetMs, estimatePromptTokens, formatTps, isMidStreamConnectionDrop, mlxHttpError, } from './runtime-diagnostics.js';
64
64
  import { classifyMlxFatalErrorLine, classifyMlxStartupLine, } from './stdout-parser.js';
65
65
  import { salvageTextualToolCalls } from './textual-tool-call-salvage.js';
66
66
  import { APPEND_TO_FILE_CONTINUATION_TOOL, MlxToolCallAccumulator, applyCallableToolGrammar, callableRequestToolNames, chatCompletionToolName, externalToolsAsChatCompletions, hermesRequiredArgGrammarRequested, missingTopLevelRequiredToolArgs, toChatCompletionsTools, validatorReportedMissingRequiredArgs, } from './tool-call-protocol.js';
@@ -1571,7 +1571,7 @@ class MlxSession extends StreamingSessionBase {
1571
1571
  this.deps.markUsed();
1572
1572
  const txt = await res.text().catch(() => '');
1573
1573
  log.error(`turn#${seq}.${turn} HTTP-ERR status=${res.status} ${res.statusText}`);
1574
- throw new Error(translateMlxHttpError(res.status, res.statusText, txt));
1574
+ throw mlxHttpError(res.status, res.statusText, txt);
1575
1575
  }
1576
1576
  if (debugOn) {
1577
1577
  log.debug(`turn#${seq}.${turn} HTTP-OK status=${res.status} ` +
@@ -1616,10 +1616,14 @@ class MlxSession extends StreamingSessionBase {
1616
1616
  // `turn.ramble-detection`; per-model thresholds come from
1617
1617
  // the validated config. Absent → detector disabled.
1618
1618
  //
1619
- // Read from the request, not only the template: a thinking-off
1620
- // request renders the block already closed, and seeding from the
1621
- // template streamed a whole `write_file` call into the reasoning pane.
1622
- const opensInReasoning = requestOpensReasoning(this.deps.templateOpensReasoning, body);
1619
+ // The engine reports what the rendered prompt did; the template guess
1620
+ // is only a fallback. A thinking-off request renders the block already
1621
+ // closed (seeding from the template streamed a whole `write_file` call
1622
+ // into the reasoning pane), and Qwen 3.5 0.8B's template opens it only
1623
+ // when thinking is explicitly on (guessing closed streamed its thoughts
1624
+ // and a stray `</think>` as the reply).
1625
+ const opensInReasoning = engineOpensReasoning(res.headers.get(REASONING_OPEN_HEADER)) ??
1626
+ requestOpensReasoning(this.deps.templateOpensReasoning, body);
1623
1627
  const rambleConfig = profileBehaviorConfig(this.deps.profile, 'turn.ramble-detection');
1624
1628
  const ramble = outputIsConstrained(this.deps.tuning)
1625
1629
  ? inertRambleDetector()
@@ -0,0 +1,26 @@
1
+ """batch_processors — per-sequence logits-processor lists for mlx_lm's
2
+ BatchGenerator that stay aligned across waves.
3
+
4
+ mlx_lm 0.31.3's `GenerationBatch.filter()` trims `logits_processors` only
5
+ `if any(self.logits_processors)`. Once every live sequence carries `[]`, the
6
+ finished ones' entries survive the filter, and the next wave's sequences are
7
+ appended after them at the wrong index: a request's tool grammar, think
8
+ budget or response format then ran for its first token only, and in a mixed
9
+ wave one sequence's grammar could land on another's output. Caught
10
+ 2026-10-13: a JSON-schema request right after a plain one came back as free
11
+ text. Giving every sequence a non-empty list keeps `any()` true, so the
12
+ filter always trims. The pass-through costs nothing measurable (A/B on
13
+ gemma4-e4b, 2026-10-14: 64.3 vs 62.7 tok/s median, within noise).
14
+ """
15
+
16
+ from typing import Any, Callable, List, Sequence
17
+
18
+
19
+ def pass_through_logits(tokens: Any, logits: Any) -> Any:
20
+ """A no-op processor that keeps a sequence's processor list non-empty."""
21
+ return logits
22
+
23
+
24
+ def per_sequence_processors(processors: Sequence[Callable[[Any, Any], Any]]) -> List[Callable[[Any, Any], Any]]:
25
+ """The processor list to hand BatchGenerator for one sequence; never empty."""
26
+ return list(processors) or [pass_through_logits]