@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
@@ -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 {
@@ -502,6 +502,34 @@ export declare function liveLaunchCapBytes(input: {
502
502
  export declare const MIN_VIABLE_LOCAL_CONTEXT_TOKENS = 65536;
503
503
  /** The same minimum on a host that has to trade context for memory. */
504
504
  export declare const MIN_VIABLE_CONSTRAINED_CONTEXT_TOKENS = 32768;
505
+ /**
506
+ * The most KV cache an automatic choice plans for one llama.cpp engine:
507
+ * adaptive growth stops here, and the Gemma `--swa-full` auto-default is
508
+ * declined when its full-size cache would pass it. Explicit settings (a
509
+ * numeric context, a pinned `swaFull`) are the operator's call and bypass it;
510
+ * it never shortens the ladder-settled base window either.
511
+ *
512
+ * Past this a bigger cache buys nothing: llama.cpp's prefill and decode cost
513
+ * follows the tokens actually present, not the reservation, so the rest only
514
+ * defers compaction. Across the 2026-10-12 scorecard (DGX Spark, n=3 per
515
+ * scenario) gemma4-31b never passed 41.7K tokens of an 82,944-token window
516
+ * it held as a 69.6 GB `--swa-full` cache, and qwen3.8-27b peaked at 94.8K,
517
+ * about 6 GB of its KV. That memory is worth more as a second resident
518
+ * model, and the same week two daemons each grew into one unified pool and
519
+ * hung the machine.
520
+ */
521
+ export declare const AUTO_KV_CACHE_MAX_BYTES: number;
522
+ /** {@link AUTO_KV_CACHE_MAX_BYTES}, unless `GEZEL_LLAMA_AUTO_KV_MAX_GB` overrides it. */
523
+ export declare function autoKvCacheMaxBytes(): number;
524
+ /**
525
+ * The share of a shared pool's live free memory, left after the un-grown
526
+ * launch, that adaptive growth may spend. The rest stays free for whatever
527
+ * starts next: the desktop's own apps, a second model, another daemon. The
528
+ * base grant already holds the practical window; growth is the extra, and an
529
+ * extra that takes everything down to the OS reserve leaves the next tenant
530
+ * to page the machine.
531
+ */
532
+ export declare const ADAPTIVE_GROWTH_SHARED_FREE_SHARE = 0.5;
505
533
  /**
506
534
  * The context floor THIS host is held to. A 16 GB Mac or an 8 GB card cannot
507
535
  * back a 64K window next to the weights, and the admission ladder's only
@@ -747,6 +775,8 @@ export interface AdaptiveCtxGrowthInput {
747
775
  * growth's to spend.
748
776
  */
749
777
  allowSlotTrade?: boolean;
778
+ /** Test seam; defaults to {@link autoKvCacheMaxBytes}. */
779
+ maxKvCacheBytes?: number;
750
780
  }
751
781
  export interface AdaptiveCtxGrowthResult {
752
782
  perTurnCtxTokens: number;
@@ -787,6 +817,11 @@ export interface AdaptiveCtxGrowthResult {
787
817
  * the inputs match the base clamp. Inherits `VRAM_USABLE_FRACTION` and
788
818
  * {@link LOCAL_ENGINE_COMPUTE_HEADROOM} through the shared clamp.
789
819
  *
820
+ * Ceiling: total KV never grows past {@link autoKvCacheMaxBytes}, and on a
821
+ * shared pool with a live reading, never past the base KV plus
822
+ * {@link ADAPTIVE_GROWTH_SHARED_FREE_SHARE} of what the base launch leaves
823
+ * free. Both only stop growth; neither shrinks the base grant.
824
+ *
790
825
  * MoE models never grow on a discrete card: `planMoeOffload` places routed
791
826
  * experts around the KV reserve, and a grown reserve would silently evict
792
827
  * experts from VRAM — trading decode speed for context behind the user's
@@ -713,6 +713,38 @@ function envGrowthCeilingTokens() {
713
713
  const tokens = Number.parseInt(raw, 10);
714
714
  return Number.isFinite(tokens) && tokens > 0 ? tokens : null;
715
715
  }
716
+ /**
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.
722
+ *
723
+ * Past this a bigger cache buys nothing: llama.cpp's prefill and decode cost
724
+ * follows the tokens actually present, not the reservation, so the rest only
725
+ * defers compaction. Across the 2026-10-12 scorecard (DGX Spark, n=3 per
726
+ * scenario) gemma4-31b never passed 41.7K tokens of an 82,944-token window
727
+ * it held as a 69.6 GB `--swa-full` cache, and qwen3.8-27b peaked at 94.8K,
728
+ * about 6 GB of its KV. That memory is worth more as a second resident
729
+ * model, and the same week two daemons each grew into one unified pool and
730
+ * hung the machine.
731
+ */
732
+ export const AUTO_KV_CACHE_MAX_BYTES = 32 * GIB;
733
+ /** {@link AUTO_KV_CACHE_MAX_BYTES}, unless `GEZEL_LLAMA_AUTO_KV_MAX_GB` overrides it. */
734
+ export function autoKvCacheMaxBytes() {
735
+ const raw = process.env.GEZEL_LLAMA_AUTO_KV_MAX_GB;
736
+ const gb = raw ? Number.parseFloat(raw) : Number.NaN;
737
+ return Number.isFinite(gb) && gb > 0 ? gb * GIB : AUTO_KV_CACHE_MAX_BYTES;
738
+ }
739
+ /**
740
+ * The share of a shared pool's live free memory, left after the un-grown
741
+ * launch, that adaptive growth may spend. The rest stays free for whatever
742
+ * starts next: the desktop's own apps, a second model, another daemon. The
743
+ * base grant already holds the practical window; growth is the extra, and an
744
+ * extra that takes everything down to the OS reserve leaves the next tenant
745
+ * to page the machine.
746
+ */
747
+ export const ADAPTIVE_GROWTH_SHARED_FREE_SHARE = 0.5;
716
748
  /**
717
749
  * The context floor THIS host is held to. A 16 GB Mac or an 8 GB card cannot
718
750
  * back a 64K window next to the weights, and the admission ladder's only
@@ -948,6 +980,11 @@ export const ADAPTIVE_GROWTH_TRADE_MIN_GAIN = 1.25;
948
980
  * the inputs match the base clamp. Inherits `VRAM_USABLE_FRACTION` and
949
981
  * {@link LOCAL_ENGINE_COMPUTE_HEADROOM} through the shared clamp.
950
982
  *
983
+ * Ceiling: total KV never grows past {@link autoKvCacheMaxBytes}, and on a
984
+ * shared pool with a live reading, never past the base KV plus
985
+ * {@link ADAPTIVE_GROWTH_SHARED_FREE_SHARE} of what the base launch leaves
986
+ * free. Both only stop growth; neither shrinks the base grant.
987
+ *
951
988
  * MoE models never grow on a discrete card: `planMoeOffload` places routed
952
989
  * experts around the KV reserve, and a grown reserve would silently evict
953
990
  * experts from VRAM — trading decode speed for context behind the user's
@@ -974,6 +1011,27 @@ export function planAdaptiveContextGrowth(input) {
974
1011
  ? Math.max(0, input.measuredFreeVramBytes)
975
1012
  : undefined;
976
1013
  const cardBytes = measuredFreeVram !== undefined ? Math.min(input.vramBytes, measuredFreeVram) : input.vramBytes;
1014
+ const kvTotalAt = (slots, ctx) => slots * (kvFixedPerSlot + ctx * input.kvBytesPerToken);
1015
+ const baseKv = kvTotalAt(startSlots, base);
1016
+ const capKv = Math.max(0, input.maxKvCacheBytes ?? autoKvCacheMaxBytes());
1017
+ // Priced like the shared clamp (same OS reserve, same compute headroom) so
1018
+ // "what the base launch leaves free" means the same thing to both.
1019
+ const leftAfterBase = !discrete && input.freeSystemRamBytes !== undefined
1020
+ ? Math.max(0, (input.freeSystemRamBytes -
1021
+ liveRamOsReserveBytes() -
1022
+ CTX_PLANNING_MARGIN_BYTES -
1023
+ input.weightsResidentBytes) *
1024
+ (1 - LOCAL_ENGINE_COMPUTE_HEADROOM) -
1025
+ baseKv)
1026
+ : undefined;
1027
+ const shareKv = leftAfterBase !== undefined
1028
+ ? baseKv + leftAfterBase * ADAPTIVE_GROWTH_SHARED_FREE_SHARE
1029
+ : Number.POSITIVE_INFINITY;
1030
+ const kvCeiling = Math.min(capKv, shareKv);
1031
+ const ctxUnderCeiling = (slots) => {
1032
+ const perSlot = kvCeiling / slots - kvFixedPerSlot;
1033
+ return perSlot > 0 ? Math.floor(perSlot / input.kvBytesPerToken / 1024) * 1024 : 0;
1034
+ };
977
1035
  const safeAt = (slots) => {
978
1036
  const safe = clampCtxTokensForMemory({
979
1037
  requestedPerTurnCtxTokens: target,
@@ -995,7 +1053,7 @@ export function planAdaptiveContextGrowth(input) {
995
1053
  vramBytes: discrete ? cardBytes : 0,
996
1054
  minPerTurnCtxTokens: 1,
997
1055
  });
998
- return Math.min(target, safe.perTurnCtxTokens);
1056
+ return Math.min(target, safe.perTurnCtxTokens, ctxUnderCeiling(slots));
999
1057
  };
1000
1058
  // Walk the slot count down only while the target stays out of reach:
1001
1059
  // the first (largest) count that reaches it wins, else the floor count
@@ -1003,7 +1061,9 @@ export function planAdaptiveContextGrowth(input) {
1003
1061
  const floorSlots = input.allowSlotTrade
1004
1062
  ? Math.min(startSlots, ADAPTIVE_GROWTH_MIN_SLOTS)
1005
1063
  : startSlots;
1006
- const noTradeCtx = safeAt(startSlots);
1064
+ // Floored at the base grant: when the base already sits above the KV
1065
+ // ceiling, the ceiling must not make a lane trade look like a big gain.
1066
+ const noTradeCtx = Math.max(base, safeAt(startSlots));
1007
1067
  let chosenSlots = startSlots;
1008
1068
  let chosenCtx = noTradeCtx;
1009
1069
  for (let slots = startSlots - 1; slots >= floorSlots && chosenCtx < target; slots -= 1) {
@@ -1027,14 +1087,19 @@ export function planAdaptiveContextGrowth(input) {
1027
1087
  const measuredNote = measuredFreeVram !== undefined && measuredFreeVram < Math.min(fastLeft, input.vramBytes)
1028
1088
  ? ` (capped at the ~${gb(measuredFreeVram)} of graphics memory other applications leave free)`
1029
1089
  : '';
1090
+ const ceilingNote = grownTo < target && ctxUnderCeiling(chosenSlots) <= grownTo
1091
+ ? kvCeiling === capKv
1092
+ ? `; stopped at the ~${gb(capKv)} automatic KV ceiling`
1093
+ : `; stopped to keep ~${gb((leftAfterBase ?? 0) * (1 - ADAPTIVE_GROWTH_SHARED_FREE_SHARE))} free for other work`
1094
+ : '';
1030
1095
  return {
1031
1096
  perTurnCtxTokens: grownTo,
1032
1097
  slots: chosenSlots,
1033
1098
  grown: true,
1034
1099
  reason: `growing context ${base} → ${grownTo} tokens/turn at ${chosenSlots} slot${chosenSlots === 1 ? '' : 's'}${slotNote} ` +
1035
1100
  `(target ${target}): weights ~${gb(input.weightsResidentBytes)} + grown KV ` +
1036
- `~${gb(chosenSlots * (kvFixedPerSlot + grownTo * input.kvBytesPerToken))} fits fast memory ` +
1037
- `~${gb(fastLeft)}${measuredNote}`,
1101
+ `~${gb(kvTotalAt(chosenSlots, grownTo))} fits fast memory ` +
1102
+ `~${gb(fastLeft)}${measuredNote}${ceilingNote}`,
1038
1103
  };
1039
1104
  }
1040
1105
  /**
@@ -1161,7 +1161,7 @@ export class LocalEngineRuntime {
1161
1161
  // engine: the full plan must run so a stale resident window surfaces as
1162
1162
  // restart-required instead of masquerading as the applied setting.
1163
1163
  const shortCircuit = (residentContextWindow !== undefined && !overrideActive) || envNumCtx !== undefined;
1164
- const { defaultLocalEngineSlots, estimatePerSlotKvBytes, kvQuantScale, llamaCppSlotCeiling, planAdaptiveContextGrowth, planCtxTokensForMemory, plannedLocalEngineSlots, } = await import('./capacity-broker.js');
1164
+ const { autoKvCacheMaxBytes, defaultLocalEngineSlots, estimatePerSlotKvBytes, kvQuantScale, llamaCppSlotCeiling, planAdaptiveContextGrowth, planCtxTokensForMemory, plannedLocalEngineSlots, } = await import('./capacity-broker.js');
1165
1165
  const capacity = await this.previewCapacityInputs('llama-cpp', modelId, capacityOpts);
1166
1166
  const { budgetBytes: admissionBudgetBytes, fastBudgetBytes, committedOtherBytes } = capacity;
1167
1167
  const configuredSlots = config.providerConcurrency?.['llama-cpp'];
@@ -1492,7 +1492,12 @@ export class LocalEngineRuntime {
1492
1492
  // `requirement.minimumPerTurnCtxTokens`, so the windowed
1493
1493
  // re-plan sheds slots or denies but never shortens the window.
1494
1494
  const explicitSwaFull = config.llamaCppSwaFull ?? manifestEngineConfig?.swaFull;
1495
- const windowedCacheWillRun = (!admission.minimumSatisfied || admission.clamped || admission.slots < slots) &&
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) &&
1496
1501
  (explicitSwaFull === false ||
1497
1502
  (explicitSwaFull === undefined &&
1498
1503
  isGemmaModel({ architecture: installed.architecture, modelId })));
@@ -73,6 +73,7 @@ export declare const IdentityResponseSchema: z.ZodObject<{
73
73
  "machine-engine": "machine-engine";
74
74
  "legacy-full": "legacy-full";
75
75
  }>>;
76
+ pairingMethods: z.ZodOptional<z.ZodArray<z.ZodString>>;
76
77
  }, z.core.$strip>;
77
78
  export type IdentityResponse = z.infer<typeof IdentityResponseSchema>;
78
79
  /** Hex SHA-256 of a PEM-encoded X.509 cert's DER bytes. */
@@ -70,6 +70,12 @@ export const IdentityResponseSchema = z.object({
70
70
  gezelVersion: z.string().optional(),
71
71
  protocolVersion: z.number().optional(),
72
72
  serviceRole: z.enum(['user', 'machine-engine', 'legacy-full']).optional(),
73
+ /**
74
+ * How a client may pair: `code` (a connect code shown on this device,
75
+ * SPAKE2 over `/v1/apps/pair/*`) and/or `approval` (`/v1/apps/register`,
76
+ * approved here). Absent → a server from before connect codes: approval only.
77
+ */
78
+ pairingMethods: z.array(z.string()).max(8).optional(),
73
79
  });
74
80
  /** Hex SHA-256 of a PEM-encoded X.509 cert's DER bytes. */
75
81
  export function certFingerprintFromPem(certPem) {
@@ -17,6 +17,8 @@ import type { PairedRemote, RemotesRegistry } from './types.js';
17
17
  export type ClosableFetch = typeof fetch & {
18
18
  close(): Promise<void>;
19
19
  };
20
+ /** Connect timeout for a paired peer (see {@link createPinnedFetch}). */
21
+ export declare const PINNED_CONNECT_TIMEOUT_MS = 5000;
20
22
  /**
21
23
  * Pin a single self-signed cert as the sole CA and skip hostname/SAN
22
24
  * verification. Long-call-friendly (no header/body timeouts) for streamed
@@ -15,6 +15,29 @@
15
15
  */
16
16
  import { Agent, fetch as undiciFetch } from 'undici';
17
17
  import { IdentityResponseSchema, certFingerprintFromPem, fingerprintOfPublicKeyPem, verifyIdentitySignature, } from './identity.js';
18
+ /** Connect timeout for a paired peer (see {@link createPinnedFetch}). */
19
+ export const PINNED_CONNECT_TIMEOUT_MS = 5_000;
20
+ /** Failures before any TLS handshake: no certificate was presented, so none can be stale. */
21
+ const NO_HANDSHAKE_CODES = new Set([
22
+ 'ECONNREFUSED',
23
+ 'EHOSTUNREACH',
24
+ 'ENETUNREACH',
25
+ 'EHOSTDOWN',
26
+ 'ENETDOWN',
27
+ 'ETIMEDOUT',
28
+ 'EADDRNOTAVAIL',
29
+ 'UND_ERR_CONNECT_TIMEOUT',
30
+ ]);
31
+ function failedBeforeHandshake(err) {
32
+ let current = err;
33
+ for (let depth = 0; depth < 6 && current && typeof current === 'object'; depth++) {
34
+ const code = current.code;
35
+ if (typeof code === 'string' && NO_HANDSHAKE_CODES.has(code))
36
+ return true;
37
+ current = current.cause;
38
+ }
39
+ return false;
40
+ }
18
41
  async function closeFetch(fetchImpl) {
19
42
  const close = fetchImpl?.close;
20
43
  if (typeof close === 'function')
@@ -34,6 +57,10 @@ export function createPinnedFetch(certPem) {
34
57
  // IP; the cert identity itself is the pin, reinforced by the identity
35
58
  // signature verified at connect time.
36
59
  checkServerIdentity: () => undefined,
60
+ // A peer on the local network answers a connect within milliseconds.
61
+ // Waiting undici's default 10 s for one that left the network only
62
+ // delays the fallback; streamed replies keep their unlimited timeouts.
63
+ timeout: PINNED_CONNECT_TIMEOUT_MS,
37
64
  },
38
65
  allowH2: true,
39
66
  headersTimeout: 0,
@@ -100,7 +127,9 @@ export function createPairedRemoteFetch(remote, registry, deps = {}) {
100
127
  return await active(url, init);
101
128
  }
102
129
  catch (originalError) {
103
- if (init?.signal?.aborted)
130
+ // A refresh re-dials the same address; when that address answered
131
+ // nothing at all it doubles the wait and fails the same way.
132
+ if (init?.signal?.aborted || failedBeforeHandshake(originalError))
104
133
  throw originalError;
105
134
  refresh ??= refreshPairedCertificate(remote, registry, makeBootstrap).then((cert) => {
106
135
  const previous = active;