@bendyline/gezel-inference 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +90 -8
  2. package/dist/cache/adapter.d.ts +7 -0
  3. package/dist/cache/controller.d.ts +12 -0
  4. package/dist/cache/controller.js +24 -0
  5. package/dist/catalog/chat-models/bt/btl4-35b-q4/manifest.json +1 -0
  6. package/dist/catalog/chat-models/gl/glm-5.2-754b-q2/manifest.json +1 -0
  7. package/dist/catalog/chat-models/index.json +1 -1
  8. package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/manifest.json +122 -0
  9. package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/versions/1.0.0/manifest.json +15 -0
  10. package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/manifest.json +185 -0
  11. package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/versions/1.0.0/manifest.json +94 -0
  12. package/dist/catalog/chat-models/mi/minicpm5-1b-q4/manifest.json +1 -0
  13. package/dist/catalog/chat-models/mi/minicpm5-2b-q4/manifest.json +1 -0
  14. package/dist/catalog/chat-models/qw/qwen3.5-0.8b-q4/manifest.json +1 -0
  15. package/dist/catalog/chat-models/raw-index.json +1 -1
  16. package/dist/catalog/provenance.json +1 -1
  17. package/dist/engines/electron-native-reuse.d.ts +9 -0
  18. package/dist/engines/electron-native-reuse.js +146 -45
  19. package/dist/engines/native-file-manifest.json +404 -404
  20. package/dist/engines/native-manifest.d.ts +11 -3
  21. package/dist/engines/native-manifest.js +48 -18
  22. package/dist/http/openai-compat/apple-model.d.ts +2 -0
  23. package/dist/http/openai-compat/apple-model.js +21 -1
  24. package/dist/http/openai-compat/chat-target.d.ts +12 -0
  25. package/dist/http/openai-compat/chat-target.js +27 -1
  26. package/dist/http/openai-compat/engine-failure.d.ts +25 -0
  27. package/dist/http/openai-compat/engine-failure.js +76 -0
  28. package/dist/http/openai-compat/responses-streaming.js +2 -1
  29. package/dist/http/openai-compat/responses-translate.d.ts +1 -1
  30. package/dist/http/openai-compat/translate.d.ts +1 -1
  31. package/dist/http/routes/engines.js +44 -3
  32. package/dist/http/routes/v1-chat.js +30 -8
  33. package/dist/http/routes/v1-models.d.ts +6 -0
  34. package/dist/http/routes/v1-models.js +137 -30
  35. package/dist/http/routes/v1-responses.js +16 -3
  36. package/dist/in-flight-requests.d.ts +19 -0
  37. package/dist/in-flight-requests.js +34 -0
  38. package/dist/inference-service.js +3 -3
  39. package/dist/knowledge/catalog-host.d.ts +9 -1
  40. package/dist/knowledge/catalog-host.js +2 -0
  41. package/dist/knowledge/manager.d.ts +14 -0
  42. package/dist/knowledge/manager.js +53 -6
  43. package/dist/knowledge/search-worker.d.ts +1 -1
  44. package/dist/knowledge/spatial-query.d.ts +2 -3
  45. package/dist/knowledge/spatial-query.js +12 -6
  46. package/dist/knowledge/worker-host.js +2 -0
  47. package/dist/memory/embed-core.d.ts +16 -0
  48. package/dist/memory/embed-core.js +31 -0
  49. package/dist/memory/embed-worker.d.ts +7 -3
  50. package/dist/memory/embed-worker.js +13 -4
  51. package/dist/memory/embeddings.d.ts +17 -2
  52. package/dist/memory/embeddings.js +36 -7
  53. package/dist/models/ensure.d.ts +2 -0
  54. package/dist/models/ensure.js +9 -0
  55. package/dist/perf/responsiveness.d.ts +14 -1
  56. package/dist/perf/responsiveness.js +77 -7
  57. package/dist/providers/llama-cpp/backend-health.d.ts +30 -0
  58. package/dist/providers/llama-cpp/backend-health.js +64 -0
  59. package/dist/providers/llama-cpp/backend-quarantine.d.ts +40 -0
  60. package/dist/providers/llama-cpp/backend-quarantine.js +100 -0
  61. package/dist/providers/llama-cpp/build-provider.js +140 -118
  62. package/dist/providers/llama-cpp/cache-adapter.d.ts +12 -0
  63. package/dist/providers/llama-cpp/cache-adapter.js +36 -1
  64. package/dist/providers/llama-cpp/devices.d.ts +11 -2
  65. package/dist/providers/llama-cpp/devices.js +10 -4
  66. package/dist/providers/llama-cpp/engine-flags.d.ts +0 -18
  67. package/dist/providers/llama-cpp/engine-flags.js +12 -18
  68. package/dist/providers/llama-cpp/gguf-metadata-async.js +7 -0
  69. package/dist/providers/llama-cpp/gguf-metadata.js +20 -3
  70. package/dist/providers/llama-cpp/gpu-startup-watch.d.ts +41 -0
  71. package/dist/providers/llama-cpp/gpu-startup-watch.js +58 -0
  72. package/dist/providers/llama-cpp/models.d.ts +6 -0
  73. package/dist/providers/llama-cpp/models.js +19 -1
  74. package/dist/providers/llama-cpp/offload-planner.d.ts +3 -18
  75. package/dist/providers/llama-cpp/offload-planner.js +3 -14
  76. package/dist/providers/llama-cpp/provider.d.ts +8 -0
  77. package/dist/providers/llama-cpp/provider.js +4 -1
  78. package/dist/providers/mlx/provider.js +14 -11
  79. package/dist/providers/mlx/python/batch_processors.py +26 -0
  80. package/dist/providers/mlx/python/gezel_mlx_server.py +88 -20
  81. package/dist/providers/mlx/python/qwen4_ple.py +9 -2
  82. package/dist/providers/mlx/python/response_format.py +374 -0
  83. package/dist/providers/mlx/python/spec_decode.py +5 -0
  84. package/dist/providers/mlx/python/think_budget.py +35 -1
  85. package/dist/providers/mlx/python/tool_grammar.py +62 -3
  86. package/dist/providers/mlx/reasoning-stream.d.ts +20 -2
  87. package/dist/providers/mlx/reasoning-stream.js +77 -16
  88. package/dist/providers/mlx/runtime-diagnostics.d.ts +16 -0
  89. package/dist/providers/mlx/runtime-diagnostics.js +29 -2
  90. package/dist/providers/native/capacity-broker.d.ts +51 -10
  91. package/dist/providers/native/capacity-broker.js +80 -9
  92. package/dist/providers/native/capacity-budget.d.ts +35 -3
  93. package/dist/providers/native/capacity-budget.js +49 -5
  94. package/dist/providers/native/device-capacity.d.ts +1 -1
  95. package/dist/providers/native/device-capacity.js +23 -5
  96. package/dist/providers/native/local-engine-runtime.js +15 -13
  97. package/dist/providers/native/measured-budget.js +2 -0
  98. package/dist/providers/native/supervisor.d.ts +25 -0
  99. package/dist/providers/native/supervisor.js +59 -7
  100. package/dist/remotes/identity.d.ts +1 -0
  101. package/dist/remotes/identity.js +6 -0
  102. package/dist/remotes/pinned-fetch.d.ts +2 -0
  103. package/dist/remotes/pinned-fetch.js +30 -1
  104. package/dist/remotes/types.d.ts +14 -0
  105. package/dist/runtime-lock.js +1 -1
  106. package/dist/system/amdgpu-adapters.d.ts +18 -0
  107. package/dist/system/amdgpu-adapters.js +55 -0
  108. package/dist/system/gpu-carve-out.d.ts +84 -0
  109. package/dist/system/gpu-carve-out.js +111 -0
  110. package/dist/system/memory.d.ts +17 -1
  111. package/dist/system/memory.js +105 -14
  112. package/dist/transformers-cache.d.ts +2 -0
  113. package/dist/transformers-cache.js +6 -3
  114. package/dist/utils/service-worker-entry.d.ts +4 -0
  115. package/dist/utils/service-worker-entry.js +4 -0
  116. package/package.json +14 -10
@@ -96,8 +96,15 @@ class SafeToolGrammarProcessor:
96
96
  salvage layer is still there as the post-hoc safety net.
97
97
  """
98
98
 
99
- def __init__(self, inner: Any) -> None:
99
+ def __init__(
100
+ self,
101
+ inner: Any,
102
+ label: str = "tool-grammar",
103
+ fallback: str = "unconstrained + TS salvage",
104
+ ) -> None:
100
105
  self._inner = inner
106
+ self._label = label
107
+ self._fallback = fallback
101
108
  self.disabled = False
102
109
 
103
110
  def __call__(self, input_ids: Any, logits: Any) -> Any:
@@ -107,8 +114,8 @@ class SafeToolGrammarProcessor:
107
114
  return self._inner(input_ids, logits)
108
115
  except Exception as exc: # noqa: BLE001 - defensive: any matcher fault
109
116
  print(
110
- f"[tool-grammar] matcher error; disabling grammar for the rest of "
111
- f"this turn (falling back to unconstrained + TS salvage): {exc}",
117
+ f"[{self._label}] matcher error; disabling grammar for the rest of "
118
+ f"this turn (falling back to {self._fallback}): {exc}",
112
119
  flush=True,
113
120
  )
114
121
  self.disabled = True
@@ -698,6 +705,58 @@ def build_grammar_string(
698
705
  return None
699
706
 
700
707
 
708
+ # What each format's own grammar opens with, and how its reasoning block
709
+ # reads, for splicing an answer branch in front of the format's call rules.
710
+ _FORMAT_HEADS = {"hermes": _HERMES_HEAD, "gemma": _GEMMA_HEAD, "glm": _GLM_HEAD}
711
+ _THINK_BLOCKS = {
712
+ "hermes": "<think> TEXT </think>",
713
+ "glm": "<think> TEXT </think>",
714
+ "gemma": "<|channel> TEXT <channel|>",
715
+ }
716
+
717
+
718
+ def build_answer_or_calls_grammar(
719
+ tools: Optional[List[Dict[str, Any]]],
720
+ hint: Dict[str, Any],
721
+ answer_schema: Dict[str, Any],
722
+ allow_think: bool = True,
723
+ ) -> Optional[str]:
724
+ """One grammar for a request with both tools and a response format.
725
+
726
+ OpenAI's contract: the model may call tools, and when it answers, the
727
+ answer follows the schema. So the reply is optional reasoning, then
728
+ either one or more calls in the format's own syntax (with this module's
729
+ name and key constraints) or a JSON value matching `answer_schema`.
730
+ Stacking the plain JSON grammar on the tool grammar instead left only
731
+ their intersection, JSON, which no call can satisfy. Prose outside the
732
+ reasoning is ruled out, as it is for a plain response format.
733
+
734
+ None when the format has no grammar here; the caller then keeps tool
735
+ calls free and leaves the answer unconstrained, as before.
736
+
737
+ `allow_think` False drops the reasoning block for a request that turned
738
+ thinking off: with prose masked, Gemma took the open `<|channel>` as its
739
+ way out and reasoned until max_tokens (2026-10-14).
740
+ """
741
+ fmt = str((hint or {}).get("format") or "").strip()
742
+ head = _FORMAT_HEADS.get(fmt)
743
+ base = build_grammar_string(tools, hint)
744
+ if head is None or base is None or not base.startswith(head):
745
+ return None
746
+ schema = dict(answer_schema)
747
+ # Whitespace stays free inside the answer; see response_format.py.
748
+ schema["x-guidance"] = {**schema.get("x-guidance", {}), "whitespace_flexible": True}
749
+ return (
750
+ "%llguidance {}\n"
751
+ + ("start: think? WS? (calls | answer) WS?\n" if allow_think else "start: WS? (calls | answer) WS?\n")
752
+ + (f"think: {_THINK_BLOCKS[fmt]}\n" if allow_think else "")
753
+ + "calls: tool_call (WS? tool_call)*\n"
754
+ + f"answer: %json {json.dumps(schema)}\n"
755
+ + "WS: /[ \\t\\r\\n]+/\n"
756
+ + "TEXT: /(.|\\n)*/\n"
757
+ ) + base[len(head) :]
758
+
759
+
701
760
  def build_tool_grammar_processor(
702
761
  tokenizer: Any,
703
762
  tools: Optional[List[Dict[str, Any]]],
@@ -1,3 +1,13 @@
1
+ /** Response header in which the sidecar reports the rendered prompt's state. */
2
+ export declare const REASONING_OPEN_HEADER = "x-gezel-reasoning-open";
3
+ /**
4
+ * Whether the engine says this turn's prompt left the model inside a reasoning
5
+ * block. The sidecar reads the rendered prompt, so this beats anything derived
6
+ * from the template text: Qwen 3.5's small models open `<think>` only when
7
+ * `enable_thinking` is explicitly true, and Gemma 4 opens its thought channel
8
+ * only after a tool response. Undefined when the header is absent.
9
+ */
10
+ export declare function engineOpensReasoning(header: string | null | undefined): boolean | undefined;
1
11
  /**
2
12
  * How a chat template's generation prompt leaves the model inside a reasoning
3
13
  * block:
@@ -11,7 +21,9 @@ export type TemplateReasoningOpen = 'always' | 'unless-thinking-off';
11
21
  /**
12
22
  * Whether the model's chat template opens a reasoning block as the last thing
13
23
  * it emits before the model starts generating, and whether that depends on
14
- * `enable_thinking`.
24
+ * `enable_thinking`. A fallback for a sidecar that does not report
25
+ * {@link REASONING_OPEN_HEADER}: a template guess cannot see which branch the
26
+ * request takes.
15
27
  *
16
28
  * Qwen-family templates end their generation prompt with a bare `<think>`, so
17
29
  * the model's output begins *inside* reasoning and the only tag that ever
@@ -66,6 +78,11 @@ export interface ReasoningSplitChunk {
66
78
  export declare class StreamingReasoningSplit {
67
79
  private buffer;
68
80
  private inReasoning;
81
+ /** A channel opener names its channel (`thought`) before the reasoning starts. */
82
+ private channelNamePending;
83
+ /** The blank lines a model leaves after closing its reasoning are not reply. */
84
+ private afterClose;
85
+ private sawVisible;
69
86
  private readonly enabled;
70
87
  constructor(opts: {
71
88
  opensInReasoning: boolean;
@@ -79,7 +96,8 @@ export declare class StreamingReasoningSplit {
79
96
  */
80
97
  flush(): ReasoningSplitChunk;
81
98
  private drain;
82
- private firstCloseMarker;
99
+ private out;
100
+ private firstMarker;
83
101
  /** Length of the trailing fragment that could still be a partial marker. */
84
102
  private holdBackLength;
85
103
  }
@@ -3,17 +3,39 @@ import { join } from 'node:path';
3
3
  import { createLogger } from '@bendyline/gezel';
4
4
  const log = createLogger('mlx');
5
5
  /**
6
- * Closing markers that end a reasoning block the chat template opened for the
7
- * model. Kept in sync with the paired forms `extractReasoning` strips at end of
8
- * turn — this is the streaming half of the same knowledge.
6
+ * Markers that open and close a reasoning block. Kept in sync with the paired
7
+ * forms `extractReasoning` strips at end of turn — this is the streaming half
8
+ * of the same knowledge. Gemma 4 opens a named channel (`<|channel>thought\n`,
9
+ * sometimes without the leading pipe) and closes it with `<channel|>`.
9
10
  */
10
- const CLOSE_MARKERS = ['</think>', '</reasoning>', '[/THINK]', '<|end|>'];
11
- /** The longest close marker, so `push` knows how much tail to hold back. */
12
- const MAX_MARKER_LENGTH = Math.max(...CLOSE_MARKERS.map((marker) => marker.length));
11
+ const OPEN_MARKERS = ['<think>', '<reasoning>', '[THINK]', '<|channel>', '<channel>'];
12
+ const CLOSE_MARKERS = ['</think>', '</reasoning>', '[/THINK]', '<|end|>', '<channel|>'];
13
+ const OPEN_PATTERN = /<think>|<reasoning>|\[THINK\]|<\|?channel>/i;
14
+ const CHANNEL_NAME = /^[A-Za-z_][\w-]*/;
15
+ /** The longest marker, so `push` knows how much tail to hold back. */
16
+ const MAX_MARKER_LENGTH = Math.max(...[...OPEN_MARKERS, ...CLOSE_MARKERS].map((marker) => marker.length));
17
+ /** Response header in which the sidecar reports the rendered prompt's state. */
18
+ export const REASONING_OPEN_HEADER = 'x-gezel-reasoning-open';
19
+ /**
20
+ * Whether the engine says this turn's prompt left the model inside a reasoning
21
+ * block. The sidecar reads the rendered prompt, so this beats anything derived
22
+ * from the template text: Qwen 3.5's small models open `<think>` only when
23
+ * `enable_thinking` is explicitly true, and Gemma 4 opens its thought channel
24
+ * only after a tool response. Undefined when the header is absent.
25
+ */
26
+ export function engineOpensReasoning(header) {
27
+ if (header === '1')
28
+ return true;
29
+ if (header === '0')
30
+ return false;
31
+ return undefined;
32
+ }
13
33
  /**
14
34
  * Whether the model's chat template opens a reasoning block as the last thing
15
35
  * it emits before the model starts generating, and whether that depends on
16
- * `enable_thinking`.
36
+ * `enable_thinking`. A fallback for a sidecar that does not report
37
+ * {@link REASONING_OPEN_HEADER}: a template guess cannot see which branch the
38
+ * request takes.
17
39
  *
18
40
  * Qwen-family templates end their generation prompt with a bare `<think>`, so
19
41
  * the model's output begins *inside* reasoning and the only tag that ever
@@ -108,6 +130,11 @@ export function requestOpensReasoning(templateOpen, body) {
108
130
  export class StreamingReasoningSplit {
109
131
  buffer = '';
110
132
  inReasoning;
133
+ /** A channel opener names its channel (`thought`) before the reasoning starts. */
134
+ channelNamePending = false;
135
+ /** The blank lines a model leaves after closing its reasoning are not reply. */
136
+ afterClose = false;
137
+ sawVisible = false;
111
138
  enabled;
112
139
  constructor(opts) {
113
140
  this.enabled = opts.enabled ?? true;
@@ -134,20 +161,48 @@ export class StreamingReasoningSplit {
134
161
  let reasoning = '';
135
162
  while (this.buffer.length > 0) {
136
163
  if (this.inReasoning) {
137
- const close = this.firstCloseMarker();
164
+ if (this.channelNamePending) {
165
+ const name = CHANNEL_NAME.exec(this.buffer)?.[0] ?? '';
166
+ if (!final && name.length === this.buffer.length)
167
+ break;
168
+ this.buffer = this.buffer.slice(name.length).replace(/^[ \t]*\r?\n/, '');
169
+ this.channelNamePending = false;
170
+ continue;
171
+ }
172
+ const close = this.firstMarker(CLOSE_MARKERS);
138
173
  if (!close)
139
174
  break;
140
175
  reasoning += this.buffer.slice(0, close.index);
141
176
  this.buffer = this.buffer.slice(close.index + close.marker.length);
142
177
  this.inReasoning = false;
178
+ this.afterClose = true;
179
+ continue;
180
+ }
181
+ if (this.afterClose) {
182
+ if (!this.sawVisible && visible.trim() === '') {
183
+ visible = '';
184
+ this.buffer = this.buffer.replace(/^\s+/, '');
185
+ if (this.buffer.length === 0)
186
+ break;
187
+ }
188
+ this.afterClose = false;
189
+ }
190
+ const open = OPEN_PATTERN.exec(this.buffer);
191
+ // A closer with no opener is a template-opened block the engine did not
192
+ // report; the text before it has already streamed, but the tag need not.
193
+ const stray = this.firstMarker(CLOSE_MARKERS);
194
+ if (stray && (!open || stray.index < open.index)) {
195
+ visible += this.buffer.slice(0, stray.index);
196
+ this.buffer = this.buffer.slice(stray.index + stray.marker.length);
197
+ this.afterClose = true;
143
198
  continue;
144
199
  }
145
- const open = /<think>|<reasoning>|\[THINK\]/i.exec(this.buffer);
146
200
  if (!open)
147
201
  break;
148
202
  visible += this.buffer.slice(0, open.index);
149
203
  this.buffer = this.buffer.slice(open.index + open[0].length);
150
204
  this.inReasoning = true;
205
+ this.channelNamePending = /channel/i.test(open[0]);
151
206
  }
152
207
  if (final) {
153
208
  if (this.inReasoning)
@@ -155,8 +210,10 @@ export class StreamingReasoningSplit {
155
210
  else
156
211
  visible += this.buffer;
157
212
  this.buffer = '';
158
- return { visible, reasoning };
213
+ return this.out(visible, reasoning);
159
214
  }
215
+ if (this.channelNamePending)
216
+ return this.out(visible, reasoning);
160
217
  // Hold back a tail that could still grow into a marker; release the rest.
161
218
  const safeLength = this.buffer.length - this.holdBackLength();
162
219
  if (safeLength > 0) {
@@ -167,12 +224,18 @@ export class StreamingReasoningSplit {
167
224
  else
168
225
  visible += released;
169
226
  }
227
+ return this.out(visible, reasoning);
228
+ }
229
+ out(visible, reasoning) {
230
+ if (visible.trim() !== '')
231
+ this.sawVisible = true;
170
232
  return { visible, reasoning };
171
233
  }
172
- firstCloseMarker() {
234
+ firstMarker(markers) {
235
+ const haystack = this.buffer.toLowerCase();
173
236
  let best = null;
174
- for (const marker of CLOSE_MARKERS) {
175
- const index = this.buffer.toLowerCase().indexOf(marker.toLowerCase());
237
+ for (const marker of markers) {
238
+ const index = haystack.indexOf(marker.toLowerCase());
176
239
  if (index === -1)
177
240
  continue;
178
241
  if (!best || index < best.index)
@@ -182,12 +245,10 @@ export class StreamingReasoningSplit {
182
245
  }
183
246
  /** Length of the trailing fragment that could still be a partial marker. */
184
247
  holdBackLength() {
248
+ const candidates = (this.inReasoning ? CLOSE_MARKERS : [...OPEN_MARKERS, ...CLOSE_MARKERS]).map((marker) => marker.toLowerCase());
185
249
  const maxTail = Math.min(MAX_MARKER_LENGTH - 1, this.buffer.length);
186
250
  for (let length = maxTail; length > 0; length -= 1) {
187
251
  const tail = this.buffer.slice(this.buffer.length - length).toLowerCase();
188
- const candidates = this.inReasoning
189
- ? CLOSE_MARKERS.map((marker) => marker.toLowerCase())
190
- : ['<think>', '<reasoning>', '[think]'];
191
252
  if (candidates.some((marker) => marker.startsWith(tail)))
192
253
  return length;
193
254
  }
@@ -1,5 +1,21 @@
1
1
  /** Read a process's resident-set size (bytes) via `ps` for engine telemetry. */
2
2
  export declare function readProcessRssBytes(pid: number): Promise<number | null>;
3
+ /**
4
+ * The engine refused the request as the caller's to fix: a `response_format`
5
+ * schema its JSON grammar cannot enforce. Routes answer it with a 400
6
+ * `invalid_request_error`. Reported as a 500 `server_error`, it was retried
7
+ * by OpenAI SDKs although no retry could succeed.
8
+ */
9
+ export declare class UnsupportedResponseFormatError extends Error {
10
+ readonly code = "unsupported_response_format";
11
+ constructor(message: string);
12
+ }
13
+ /**
14
+ * The error a failed engine response becomes. The sidecar answers 422 with a
15
+ * `response_format` detail only for a schema it refused (response_format.py);
16
+ * its other 422s are about images, and stay engine errors.
17
+ */
18
+ export declare function mlxHttpError(status: number, statusText: string, body: string): Error;
3
19
  /** Translate known mlx_vlm response shapes into user-actionable errors. */
4
20
  export declare function translateMlxHttpError(status: number, statusText: string, body: string): string;
5
21
  /** Detect an engine-side HTTP response stream closing mid-flight. */
@@ -27,6 +27,33 @@ export async function readProcessRssBytes(pid) {
27
27
  }
28
28
  });
29
29
  }
30
+ /**
31
+ * The engine refused the request as the caller's to fix: a `response_format`
32
+ * schema its JSON grammar cannot enforce. Routes answer it with a 400
33
+ * `invalid_request_error`. Reported as a 500 `server_error`, it was retried
34
+ * by OpenAI SDKs although no retry could succeed.
35
+ */
36
+ export class UnsupportedResponseFormatError extends Error {
37
+ code = 'unsupported_response_format';
38
+ constructor(message) {
39
+ super(message);
40
+ this.name = 'UnsupportedResponseFormatError';
41
+ }
42
+ }
43
+ /**
44
+ * The error a failed engine response becomes. The sidecar answers 422 with a
45
+ * `response_format` detail only for a schema it refused (response_format.py);
46
+ * its other 422s are about images, and stay engine errors.
47
+ */
48
+ export function mlxHttpError(status, statusText, body) {
49
+ if (status === 422) {
50
+ const detail = tryParseJsonDetail(body, 1_000);
51
+ if (detail && /response[ _]format/i.test(detail)) {
52
+ return new UnsupportedResponseFormatError(detail);
53
+ }
54
+ }
55
+ return new Error(translateMlxHttpError(status, statusText, body));
56
+ }
30
57
  /** Translate known mlx_vlm response shapes into user-actionable errors. */
31
58
  export function translateMlxHttpError(status, statusText, body) {
32
59
  if (/Repository Not Found for url/i.test(body) || /401 Client Error/i.test(body)) {
@@ -148,11 +175,11 @@ export function computePreFirstByteBudgetMs(approxPromptTokens) {
148
175
  const scaled = PRE_FIRST_BYTE_BASE_MS + (over / 1000) * PRE_FIRST_BYTE_MS_PER_1K_TOKENS;
149
176
  return Math.min(PRE_FIRST_BYTE_CAP_MS, Math.round(scaled));
150
177
  }
151
- function tryParseJsonDetail(body) {
178
+ function tryParseJsonDetail(body, maxChars = 200) {
152
179
  try {
153
180
  const obj = JSON.parse(body);
154
181
  if (obj && typeof obj === 'object' && 'detail' in obj && typeof obj.detail === 'string') {
155
- return obj.detail.slice(0, 200);
182
+ return obj.detail.slice(0, maxChars);
156
183
  }
157
184
  }
158
185
  catch {
@@ -46,6 +46,14 @@ export interface CapacityBrokerOptions {
46
46
  * {@link computeCapacityBudget}. Omit to read the last published probe.
47
47
  */
48
48
  gpuVramBytes?: number | null;
49
+ /**
50
+ * An integrated GPU's firmware carve-out — see
51
+ * {@link CapacityBudgetInput.reservedGpuBytes}. Travels with
52
+ * `gpuVramBytes`/`unifiedMemory` as one description of the host.
53
+ */
54
+ reservedGpuBytes?: number | null;
55
+ /** What an integrated GPU can reach — see {@link CapacityBudgetInput.gpuAddressableBytes}. */
56
+ gpuAddressableBytes?: number | null;
49
57
  /**
50
58
  * May co-resident models spill into system RAM? `null`/omitted follows
51
59
  * {@link autoAllowRamSpillover} for this host. See
@@ -192,6 +200,8 @@ export declare class CapacityBroker {
192
200
  private readonly systemRamBytes;
193
201
  private readonly unifiedMemory;
194
202
  private readonly gpuVramBytes;
203
+ private readonly reservedGpuBytes;
204
+ private readonly gpuAddressableBytes;
195
205
  private explicitAllowRamSpillover;
196
206
  private readonly reservations;
197
207
  constructor(opts?: CapacityBrokerOptions);
@@ -502,6 +512,32 @@ export declare function liveLaunchCapBytes(input: {
502
512
  export declare const MIN_VIABLE_LOCAL_CONTEXT_TOKENS = 65536;
503
513
  /** The same minimum on a host that has to trade context for memory. */
504
514
  export declare const MIN_VIABLE_CONSTRAINED_CONTEXT_TOKENS = 32768;
515
+ /**
516
+ * The most KV cache adaptive growth plans for one llama.cpp engine. An
517
+ * explicit numeric context is the operator's call and skips growth
518
+ * altogether, and the ceiling never shortens the ladder-settled base window.
519
+ *
520
+ * Past this a bigger cache buys nothing: llama.cpp's prefill and decode cost
521
+ * follows the tokens actually present, not the reservation, so the rest only
522
+ * defers compaction. Across the 2026-10-12 scorecard (DGX Spark, n=3 per
523
+ * scenario) gemma4-31b never passed 41.7K tokens of an 82,944-token window
524
+ * it held as a 69.6 GB `--swa-full` cache, and qwen3.8-27b peaked at 94.8K,
525
+ * about 6 GB of its KV. That memory is worth more as a second resident
526
+ * model, and the same week two daemons each grew into one unified pool and
527
+ * hung the machine.
528
+ */
529
+ export declare const AUTO_KV_CACHE_MAX_BYTES: number;
530
+ /** {@link AUTO_KV_CACHE_MAX_BYTES}, unless `GEZEL_LLAMA_AUTO_KV_MAX_GB` overrides it. */
531
+ export declare function autoKvCacheMaxBytes(): number;
532
+ /**
533
+ * The share of a shared pool's live free memory, left after the un-grown
534
+ * launch, that adaptive growth may spend. The rest stays free for whatever
535
+ * starts next: the desktop's own apps, a second model, another daemon. The
536
+ * base grant already holds the practical window; growth is the extra, and an
537
+ * extra that takes everything down to the OS reserve leaves the next tenant
538
+ * to page the machine.
539
+ */
540
+ export declare const ADAPTIVE_GROWTH_SHARED_FREE_SHARE = 0.5;
505
541
  /**
506
542
  * The context floor THIS host is held to. A 16 GB Mac or an 8 GB card cannot
507
543
  * back a 64K window next to the weights, and the admission ladder's only
@@ -622,10 +658,10 @@ export interface CtxMemoryClampInput {
622
658
  */
623
659
  freeSystemRamBytes?: number;
624
660
  /**
625
- * Usable VRAM on a discrete card; MUST be 0 on unified/shared-pool
626
- * hosts, where the "VRAM" is the same physical RAM `freeSystemRamBytes`
627
- * measures and adding it would double-count. `computeCapacityBudget`
628
- * already reports `vramBytes: 0` for those hosts — pass its value.
661
+ * Usable VRAM on a discrete card. On a unified/shared-pool host it MUST be
662
+ * only an integrated GPU's firmware carve-out, which `freeSystemRamBytes`
663
+ * never sees; any "VRAM" that is the same physical RAM would double-count.
664
+ * `computeCapacityBudget` reports exactly that — pass its value.
629
665
  */
630
666
  vramBytes: number;
631
667
  minPerTurnCtxTokens?: number;
@@ -723,7 +759,7 @@ export interface AdaptiveCtxGrowthInput {
723
759
  fastBudgetBytes: number;
724
760
  committedOtherBytes?: number;
725
761
  budgetKind: CapacityBudget['kind'];
726
- /** Usable VRAM on a discrete card (`liveBudget.vramBytes`); 0 elsewhere. */
762
+ /** Usable VRAM on a discrete card (`liveBudget.vramBytes`); ignored elsewhere. */
727
763
  vramBytes: number;
728
764
  /**
729
765
  * Graphics memory free right now on a discrete card, as device admission
@@ -747,6 +783,8 @@ export interface AdaptiveCtxGrowthInput {
747
783
  * growth's to spend.
748
784
  */
749
785
  allowSlotTrade?: boolean;
786
+ /** Test seam; defaults to {@link autoKvCacheMaxBytes}. */
787
+ maxKvCacheBytes?: number;
750
788
  }
751
789
  export interface AdaptiveCtxGrowthResult {
752
790
  perTurnCtxTokens: number;
@@ -766,11 +804,9 @@ export interface AdaptiveCtxGrowthResult {
766
804
  * settled slots and granted the un-grown request.
767
805
  *
768
806
  * Deliberately a second clamp pass rather than a bigger first request:
769
- * the first ladder pass sizes slot ceilings, the `--swa-full` fit verdict,
770
- * and RAM-spillover admission, all of which must stay anchored on the
771
- * practical 64K target — growing the request there would decline
772
- * `--swa-full` for models whose full cache fits at 64K and grow KV into
773
- * system-RAM spill on discrete cards.
807
+ * the first ladder pass sizes slot ceilings and RAM-spillover admission,
808
+ * both of which must stay anchored on the practical 64K target — growing
809
+ * the request there would grow KV into system-RAM spill on discrete cards.
774
810
  *
775
811
  * Slot trade: when `allowSlotTrade` is set and the target is out of reach
776
812
  * at the settled slot count, growth walks the count down — taking the
@@ -787,6 +823,11 @@ export interface AdaptiveCtxGrowthResult {
787
823
  * the inputs match the base clamp. Inherits `VRAM_USABLE_FRACTION` and
788
824
  * {@link LOCAL_ENGINE_COMPUTE_HEADROOM} through the shared clamp.
789
825
  *
826
+ * Ceiling: total KV never grows past {@link autoKvCacheMaxBytes}, and on a
827
+ * shared pool with a live reading, never past the base KV plus
828
+ * {@link ADAPTIVE_GROWTH_SHARED_FREE_SHARE} of what the base launch leaves
829
+ * free. Both only stop growth; neither shrinks the base grant.
830
+ *
790
831
  * MoE models never grow on a discrete card: `planMoeOffload` places routed
791
832
  * experts around the KV reserve, and a grown reserve would silently evict
792
833
  * experts from VRAM — trading decode speed for context behind the user's
@@ -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
  }
@@ -713,6 +723,36 @@ function envGrowthCeilingTokens() {
713
723
  const tokens = Number.parseInt(raw, 10);
714
724
  return Number.isFinite(tokens) && tokens > 0 ? tokens : null;
715
725
  }
726
+ /**
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.
730
+ *
731
+ * Past this a bigger cache buys nothing: llama.cpp's prefill and decode cost
732
+ * follows the tokens actually present, not the reservation, so the rest only
733
+ * defers compaction. Across the 2026-10-12 scorecard (DGX Spark, n=3 per
734
+ * scenario) gemma4-31b never passed 41.7K tokens of an 82,944-token window
735
+ * it held as a 69.6 GB `--swa-full` cache, and qwen3.8-27b peaked at 94.8K,
736
+ * about 6 GB of its KV. That memory is worth more as a second resident
737
+ * model, and the same week two daemons each grew into one unified pool and
738
+ * hung the machine.
739
+ */
740
+ export const AUTO_KV_CACHE_MAX_BYTES = 32 * GIB;
741
+ /** {@link AUTO_KV_CACHE_MAX_BYTES}, unless `GEZEL_LLAMA_AUTO_KV_MAX_GB` overrides it. */
742
+ export function autoKvCacheMaxBytes() {
743
+ const raw = process.env.GEZEL_LLAMA_AUTO_KV_MAX_GB;
744
+ const gb = raw ? Number.parseFloat(raw) : Number.NaN;
745
+ return Number.isFinite(gb) && gb > 0 ? gb * GIB : AUTO_KV_CACHE_MAX_BYTES;
746
+ }
747
+ /**
748
+ * The share of a shared pool's live free memory, left after the un-grown
749
+ * launch, that adaptive growth may spend. The rest stays free for whatever
750
+ * starts next: the desktop's own apps, a second model, another daemon. The
751
+ * base grant already holds the practical window; growth is the extra, and an
752
+ * extra that takes everything down to the OS reserve leaves the next tenant
753
+ * to page the machine.
754
+ */
755
+ export const ADAPTIVE_GROWTH_SHARED_FREE_SHARE = 0.5;
716
756
  /**
717
757
  * The context floor THIS host is held to. A 16 GB Mac or an 8 GB card cannot
718
758
  * back a 64K window next to the weights, and the admission ladder's only
@@ -927,11 +967,9 @@ export const ADAPTIVE_GROWTH_TRADE_MIN_GAIN = 1.25;
927
967
  * settled slots and granted the un-grown request.
928
968
  *
929
969
  * Deliberately a second clamp pass rather than a bigger first request:
930
- * the first ladder pass sizes slot ceilings, the `--swa-full` fit verdict,
931
- * and RAM-spillover admission, all of which must stay anchored on the
932
- * practical 64K target — growing the request there would decline
933
- * `--swa-full` for models whose full cache fits at 64K and grow KV into
934
- * 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.
935
973
  *
936
974
  * Slot trade: when `allowSlotTrade` is set and the target is out of reach
937
975
  * at the settled slot count, growth walks the count down — taking the
@@ -948,6 +986,11 @@ export const ADAPTIVE_GROWTH_TRADE_MIN_GAIN = 1.25;
948
986
  * the inputs match the base clamp. Inherits `VRAM_USABLE_FRACTION` and
949
987
  * {@link LOCAL_ENGINE_COMPUTE_HEADROOM} through the shared clamp.
950
988
  *
989
+ * Ceiling: total KV never grows past {@link autoKvCacheMaxBytes}, and on a
990
+ * shared pool with a live reading, never past the base KV plus
991
+ * {@link ADAPTIVE_GROWTH_SHARED_FREE_SHARE} of what the base launch leaves
992
+ * free. Both only stop growth; neither shrinks the base grant.
993
+ *
951
994
  * MoE models never grow on a discrete card: `planMoeOffload` places routed
952
995
  * experts around the KV reserve, and a grown reserve would silently evict
953
996
  * experts from VRAM — trading decode speed for context behind the user's
@@ -974,6 +1017,27 @@ export function planAdaptiveContextGrowth(input) {
974
1017
  ? Math.max(0, input.measuredFreeVramBytes)
975
1018
  : undefined;
976
1019
  const cardBytes = measuredFreeVram !== undefined ? Math.min(input.vramBytes, measuredFreeVram) : input.vramBytes;
1020
+ const kvTotalAt = (slots, ctx) => slots * (kvFixedPerSlot + ctx * input.kvBytesPerToken);
1021
+ const baseKv = kvTotalAt(startSlots, base);
1022
+ const capKv = Math.max(0, input.maxKvCacheBytes ?? autoKvCacheMaxBytes());
1023
+ // Priced like the shared clamp (same OS reserve, same compute headroom) so
1024
+ // "what the base launch leaves free" means the same thing to both.
1025
+ const leftAfterBase = !discrete && input.freeSystemRamBytes !== undefined
1026
+ ? Math.max(0, (input.freeSystemRamBytes -
1027
+ liveRamOsReserveBytes() -
1028
+ CTX_PLANNING_MARGIN_BYTES -
1029
+ input.weightsResidentBytes) *
1030
+ (1 - LOCAL_ENGINE_COMPUTE_HEADROOM) -
1031
+ baseKv)
1032
+ : undefined;
1033
+ const shareKv = leftAfterBase !== undefined
1034
+ ? baseKv + leftAfterBase * ADAPTIVE_GROWTH_SHARED_FREE_SHARE
1035
+ : Number.POSITIVE_INFINITY;
1036
+ const kvCeiling = Math.min(capKv, shareKv);
1037
+ const ctxUnderCeiling = (slots) => {
1038
+ const perSlot = kvCeiling / slots - kvFixedPerSlot;
1039
+ return perSlot > 0 ? Math.floor(perSlot / input.kvBytesPerToken / 1024) * 1024 : 0;
1040
+ };
977
1041
  const safeAt = (slots) => {
978
1042
  const safe = clampCtxTokensForMemory({
979
1043
  requestedPerTurnCtxTokens: target,
@@ -995,7 +1059,7 @@ export function planAdaptiveContextGrowth(input) {
995
1059
  vramBytes: discrete ? cardBytes : 0,
996
1060
  minPerTurnCtxTokens: 1,
997
1061
  });
998
- return Math.min(target, safe.perTurnCtxTokens);
1062
+ return Math.min(target, safe.perTurnCtxTokens, ctxUnderCeiling(slots));
999
1063
  };
1000
1064
  // Walk the slot count down only while the target stays out of reach:
1001
1065
  // the first (largest) count that reaches it wins, else the floor count
@@ -1003,7 +1067,9 @@ export function planAdaptiveContextGrowth(input) {
1003
1067
  const floorSlots = input.allowSlotTrade
1004
1068
  ? Math.min(startSlots, ADAPTIVE_GROWTH_MIN_SLOTS)
1005
1069
  : startSlots;
1006
- const noTradeCtx = safeAt(startSlots);
1070
+ // Floored at the base grant: when the base already sits above the KV
1071
+ // ceiling, the ceiling must not make a lane trade look like a big gain.
1072
+ const noTradeCtx = Math.max(base, safeAt(startSlots));
1007
1073
  let chosenSlots = startSlots;
1008
1074
  let chosenCtx = noTradeCtx;
1009
1075
  for (let slots = startSlots - 1; slots >= floorSlots && chosenCtx < target; slots -= 1) {
@@ -1027,14 +1093,19 @@ export function planAdaptiveContextGrowth(input) {
1027
1093
  const measuredNote = measuredFreeVram !== undefined && measuredFreeVram < Math.min(fastLeft, input.vramBytes)
1028
1094
  ? ` (capped at the ~${gb(measuredFreeVram)} of graphics memory other applications leave free)`
1029
1095
  : '';
1096
+ const ceilingNote = grownTo < target && ctxUnderCeiling(chosenSlots) <= grownTo
1097
+ ? kvCeiling === capKv
1098
+ ? `; stopped at the ~${gb(capKv)} automatic KV ceiling`
1099
+ : `; stopped to keep ~${gb((leftAfterBase ?? 0) * (1 - ADAPTIVE_GROWTH_SHARED_FREE_SHARE))} free for other work`
1100
+ : '';
1030
1101
  return {
1031
1102
  perTurnCtxTokens: grownTo,
1032
1103
  slots: chosenSlots,
1033
1104
  grown: true,
1034
1105
  reason: `growing context ${base} → ${grownTo} tokens/turn at ${chosenSlots} slot${chosenSlots === 1 ? '' : 's'}${slotNote} ` +
1035
1106
  `(target ${target}): weights ~${gb(input.weightsResidentBytes)} + grown KV ` +
1036
- `~${gb(chosenSlots * (kvFixedPerSlot + grownTo * input.kvBytesPerToken))} fits fast memory ` +
1037
- `~${gb(fastLeft)}${measuredNote}`,
1107
+ `~${gb(kvTotalAt(chosenSlots, grownTo))} fits fast memory ` +
1108
+ `~${gb(fastLeft)}${measuredNote}${ceilingNote}`,
1038
1109
  };
1039
1110
  }
1040
1111
  /**