@bendyline/gezel-inference 1.0.8 → 1.0.9

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 (59) hide show
  1. package/dist/catalog/chat-models/ge/gemma4-12b-q4/manifest.json +46 -3
  2. package/dist/catalog/chat-models/ge/gemma4-12b-q4/versions/1.2.1/manifest.json +141 -0
  3. package/dist/catalog/chat-models/ge/gemma4-e4b-q4/manifest.json +5 -0
  4. package/dist/catalog/chat-models/index.json +1 -1
  5. package/dist/catalog/chat-models/or/ornith1.5-9b-q4/manifest.json +5 -0
  6. package/dist/catalog/chat-models/or/ornith1.5-9b-q8/manifest.json +5 -0
  7. package/dist/catalog/chat-models/qw/qwen3.5-9b-q4/manifest.json +5 -0
  8. package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/manifest.json +5 -0
  9. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-q2/manifest.json +24 -7
  10. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-q2/versions/1.0.3/manifest.json +47 -0
  11. package/dist/catalog/chat-models/raw-index.json +1 -1
  12. package/dist/catalog/provenance.json +1 -1
  13. package/dist/engines/electron-native-reuse.d.ts +5 -4
  14. package/dist/engines/electron-native-reuse.js +1 -1
  15. package/dist/engines/native-file-manifest.json +12344 -698
  16. package/dist/engines/native-manifest.d.ts +4 -4
  17. package/dist/engines/native-manifest.js +40 -36
  18. package/dist/engines/resolver.d.ts +18 -17
  19. package/dist/engines/resolver.js +36 -1
  20. package/dist/http/model-inventory-cache.d.ts +1 -1
  21. package/dist/http/routes/engines.js +3 -3
  22. package/dist/models/catalog-drift.d.ts +2 -0
  23. package/dist/models/catalog-drift.js +1 -1
  24. package/dist/models/catalog-payload-identity.d.ts +2 -2
  25. package/dist/models/catalog-payload-identity.js +10 -5
  26. package/dist/providers/active-install-message.js +1 -1
  27. package/dist/providers/catalog-model-config.d.ts +28 -0
  28. package/dist/providers/ds4/build-provider.js +35 -13
  29. package/dist/providers/ds4/residency.d.ts +77 -0
  30. package/dist/providers/ds4/residency.js +87 -6
  31. package/dist/providers/image/sd-cpp.js +3 -2
  32. package/dist/providers/llama-cpp/backend-health.d.ts +3 -2
  33. package/dist/providers/llama-cpp/backend-health.js +6 -3
  34. package/dist/providers/llama-cpp/backend-quarantine.js +1 -1
  35. package/dist/providers/llama-cpp/build-provider.js +168 -29
  36. package/dist/providers/llama-cpp/devices.js +4 -1
  37. package/dist/providers/llama-cpp/engine-flags.d.ts +8 -1
  38. package/dist/providers/llama-cpp/engine-flags.js +13 -8
  39. package/dist/providers/llama-cpp/gpu-startup-watch.d.ts +4 -4
  40. package/dist/providers/llama-cpp/gpu-startup-watch.js +7 -5
  41. package/dist/providers/llama-cpp/models.d.ts +10 -0
  42. package/dist/providers/llama-cpp/models.js +64 -10
  43. package/dist/providers/llama-cpp/offload-planner.d.ts +6 -0
  44. package/dist/providers/llama-cpp/offload-planner.js +1 -1
  45. package/dist/providers/llama-cpp/spec-draft.d.ts +45 -23
  46. package/dist/providers/llama-cpp/spec-draft.js +113 -24
  47. package/dist/providers/mlx/python/spec_decode.py +23 -2
  48. package/dist/providers/mlx/python/tool_args_json.py +59 -21
  49. package/dist/providers/mlx/venv.d.ts +19 -1
  50. package/dist/providers/mlx/venv.js +21 -2
  51. package/dist/providers/native/device-capacity.d.ts +4 -0
  52. package/dist/providers/native/device-capacity.js +5 -0
  53. package/dist/providers/native/local-engine-runtime.d.ts +16 -1
  54. package/dist/providers/native/local-engine-runtime.js +142 -39
  55. package/dist/providers/native/supervisor.d.ts +8 -1
  56. package/dist/providers/native/supervisor.js +16 -7
  57. package/dist/providers/types.d.ts +4 -0
  58. package/dist/remotes/types.d.ts +1 -1
  59. package/package.json +4 -4
@@ -22,13 +22,17 @@ chunk by chunk. Everything else (ints, floats, bools, objects, arrays) is
22
22
  small and needs the whole value for `_convert_param_value`-style coercion, so
23
23
  those are buffered per-parameter and emitted when their `</parameter>` lands.
24
24
 
25
- Semantics are copied from mlx_vlm 0.6.6 `tool_parsers/qwen3_coder.py` and must
26
- stay identical; `test_tool_args_json.py` asserts that by differential-testing
27
- every case against the reference parser. In particular:
25
+ Semantics are copied from mlx_vlm 0.7.12 `tools/parsers/qwen3_coder.py` and
26
+ must stay identical; `tool_args_json_test.py` asserts that by differential-
27
+ testing every case against the reference parser. In particular:
28
28
 
29
29
  * exactly ONE leading and ONE trailing "\\n" is stripped from a value
30
- * a literal "null" (any case) becomes JSON null
30
+ * a literal "null" (any case) becomes JSON null, except in a declared
31
+ string parameter, which keeps the text unless the schema says
32
+ `"nullable": true`
31
33
  * a parameter absent from the schema is treated as a string
34
+ * a declared parameter without a type decodes JSON objects and arrays and
35
+ keeps anything else as text
32
36
  * unknown/odd types fall through to `ast.literal_eval`, as upstream does
33
37
 
34
38
  Chunk boundaries are the hazard throughout: `</parameter>`, `</function>` and
@@ -68,29 +72,59 @@ def arguments_config(func_name: str, tools: Optional[Any]) -> Dict[str, Any]:
68
72
  return {}
69
73
 
70
74
 
75
+ def _declared_string(param: Any) -> bool:
76
+ return (
77
+ isinstance(param, dict)
78
+ and isinstance(param.get("type"), str)
79
+ and param["type"].strip().lower() in _STRING_TYPES
80
+ )
81
+
82
+
71
83
  def is_string_param(param_name: str, param_config: Dict[str, Any]) -> bool:
72
84
  """Can this parameter stream, or must its value be buffered whole?
73
85
 
74
86
  Unknown parameters count as strings — upstream returns the raw value when
75
- the schema has no entry, and raw-string is the streamable case.
87
+ the schema has no entry, and raw-string is the streamable case. A declared
88
+ parameter without a type is buffered: its value may decode to an object.
76
89
  """
77
- param = param_config.get(param_name)
78
- if not param:
79
- return True
80
- declared = param.get("type")
81
- if declared is None:
90
+ if param_name not in param_config:
82
91
  return True
83
- return str(declared).strip().lower() in _STRING_TYPES
92
+ param = param_config[param_name]
93
+ if not isinstance(param, dict) or "type" not in param:
94
+ return False
95
+ return str(param["type"]).strip().lower() in _STRING_TYPES
96
+
97
+
98
+ def null_text_is_none(param_name: str, param_config: Dict[str, Any]) -> bool:
99
+ """Whether a complete value of "null" is JSON null for this parameter.
100
+
101
+ A declared string keeps the text unless the schema marks it nullable;
102
+ every other parameter, unknown ones included, reads it as null.
103
+ """
104
+ param = param_config.get(param_name)
105
+ if _declared_string(param):
106
+ return param.get("nullable") is True
107
+ return True
84
108
 
85
109
 
86
110
  def convert_param_value(value: str, param_name: str, param_config: Dict[str, Any]) -> Any:
87
111
  """Coerce a complete value, mirroring `_convert_param_value`."""
112
+ param = param_config.get(param_name)
113
+ if _declared_string(param):
114
+ if value.lower() == "null" and param.get("nullable") is True:
115
+ return None
116
+ return value
88
117
  if value.lower() == "null":
89
118
  return None
90
- param = param_config.get(param_name)
91
- if not param:
119
+ if param_name not in param_config:
92
120
  return value
93
- param_type = str(param["type"]).strip().lower() if "type" in param else "string"
121
+ if not isinstance(param, dict) or "type" not in param:
122
+ try:
123
+ decoded = json.loads(value)
124
+ except json.JSONDecodeError:
125
+ return value
126
+ return decoded if isinstance(decoded, (dict, list)) else value
127
+ param_type = str(param["type"]).strip().lower()
94
128
  if param_type in _STRING_TYPES:
95
129
  return value
96
130
  if param_type.startswith(("int", "uint", "long", "short", "unsigned")):
@@ -194,10 +228,10 @@ class ToolArgsJsonEncoder:
194
228
  self._leading_stripped = False
195
229
  self._opened_quote = False
196
230
  # NOTE: the opening quote is deliberately NOT emitted yet.
197
- # `_convert_param_value` maps a whole value of "null" to JSON
198
- # null BEFORE consulting the schema, so even a string-typed
199
- # parameter can turn out to be null — and by then a quote
200
- # would already be on the wire. Hold the first few bytes.
231
+ # A whole value of "null" is JSON null for an unknown or
232
+ # nullable string parameter (`null_text_is_none`) — and by
233
+ # then a quote would already be on the wire. Hold the first
234
+ # few bytes.
201
235
  continue
202
236
 
203
237
  end = self._buf.find(_PARAM_CLOSE)
@@ -228,8 +262,8 @@ class ToolArgsJsonEncoder:
228
262
  # * strip exactly one leading newline
229
263
  # * strip exactly one trailing newline (so the final newline is
230
264
  # held until we know whether anything follows it)
231
- # * a complete value of "null" is JSON null, not a string, so
232
- # the opening quote waits until the value is longer than
265
+ # * a complete value of "null" can be JSON null, not a string,
266
+ # so the opening quote waits until the value is longer than
233
267
  # "null" or the parameter closes
234
268
  if not self._leading_stripped:
235
269
  if chunk.startswith("\n"):
@@ -241,7 +275,11 @@ class ToolArgsJsonEncoder:
241
275
  self._value_buf += chunk
242
276
  if end < 0 and len(self._value_buf) <= 4:
243
277
  break
244
- if end >= 0 and self._value_buf.lower() == "null":
278
+ if (
279
+ end >= 0
280
+ and self._value_buf.lower() == "null"
281
+ and null_text_is_none(self._value_name, self.param_config)
282
+ ):
245
283
  out.append(f"{json.dumps(self._value_name)}:null")
246
284
  self._in_value = False
247
285
  continue
@@ -78,9 +78,27 @@
78
78
  * instead of consuming the Metal working set. Do not treat the library pin
79
79
  * alone as proof that every community conversion is correct or fits.
80
80
  *
81
+ * 0.7.12 (2026-11-05): the project moved to Neywa-Labs/mlx-vlm; same PyPI
82
+ * name. It resolves mlx 0.32.4 (mlx-audio pins it exactly) and transformers
83
+ * 5.22. Every private `speculative.mtp` helper the assisted round loop calls
84
+ * is unchanged for Qwen 3.5; Gemma 4 targets now rebuild the drafter's shared
85
+ * KV from committed caches, which `shared_kv_after_round` follows. Its
86
+ * qwen3_coder parser keeps "null" as text in a string parameter, which
87
+ * `tool_args_json.py` now mirrors. Verified on an M5 Max against 0.7.1
88
+ * through the real sidecar: gemma4-e4b and qwen3.8-27b + MTP drafter render
89
+ * identical prompt token counts and identical tool-call, JSON and vision
90
+ * output; 0.7.1-written disk caches load and extend (Gemma 4 rotating,
91
+ * Qwen 3.5 hybrid). Greedy MTP matches greedy no-spec on code at 0-30k
92
+ * tokens; on prose both versions flip bf16 one-step ties (top-2 gap 0.125
93
+ * nats), which two non-speculative decode paths also disagree on, so prose
94
+ * is not an exactness probe. Clean A/B/B/A decode on qwen3.8-27b-q4: MTP on
95
+ * the vlm tower at parity to 10k and +19% at 50k; vlm tower without MTP 2.0x
96
+ * at 50k (7.3 -> 14.8 tok/s), now level with the mlx_lm tower; mlx_lm tower
97
+ * 0.94x at 0-10k, within run-to-run noise.
98
+ *
81
99
  * Keep upgrades explicit and re-sweep the MLX catalog whenever this pin
82
100
  * moves. */
83
- export declare const MLX_DEFAULT_PACKAGE_SPEC = "mlx-vlm==0.7.1";
101
+ export declare const MLX_DEFAULT_PACKAGE_SPEC = "mlx-vlm==0.7.12";
84
102
  /** Venv name passed to `UvRuntime.ensureVenv` (and its dir basename). */
85
103
  export declare const MLX_VENV_NAME = "mlx";
86
104
  /**
@@ -78,9 +78,27 @@
78
78
  * instead of consuming the Metal working set. Do not treat the library pin
79
79
  * alone as proof that every community conversion is correct or fits.
80
80
  *
81
+ * 0.7.12 (2026-11-05): the project moved to Neywa-Labs/mlx-vlm; same PyPI
82
+ * name. It resolves mlx 0.32.4 (mlx-audio pins it exactly) and transformers
83
+ * 5.22. Every private `speculative.mtp` helper the assisted round loop calls
84
+ * is unchanged for Qwen 3.5; Gemma 4 targets now rebuild the drafter's shared
85
+ * KV from committed caches, which `shared_kv_after_round` follows. Its
86
+ * qwen3_coder parser keeps "null" as text in a string parameter, which
87
+ * `tool_args_json.py` now mirrors. Verified on an M5 Max against 0.7.1
88
+ * through the real sidecar: gemma4-e4b and qwen3.8-27b + MTP drafter render
89
+ * identical prompt token counts and identical tool-call, JSON and vision
90
+ * output; 0.7.1-written disk caches load and extend (Gemma 4 rotating,
91
+ * Qwen 3.5 hybrid). Greedy MTP matches greedy no-spec on code at 0-30k
92
+ * tokens; on prose both versions flip bf16 one-step ties (top-2 gap 0.125
93
+ * nats), which two non-speculative decode paths also disagree on, so prose
94
+ * is not an exactness probe. Clean A/B/B/A decode on qwen3.8-27b-q4: MTP on
95
+ * the vlm tower at parity to 10k and +19% at 50k; vlm tower without MTP 2.0x
96
+ * at 50k (7.3 -> 14.8 tok/s), now level with the mlx_lm tower; mlx_lm tower
97
+ * 0.94x at 0-10k, within run-to-run noise.
98
+ *
81
99
  * Keep upgrades explicit and re-sweep the MLX catalog whenever this pin
82
100
  * moves. */
83
- export const MLX_DEFAULT_PACKAGE_SPEC = 'mlx-vlm==0.7.1';
101
+ export const MLX_DEFAULT_PACKAGE_SPEC = 'mlx-vlm==0.7.12';
84
102
  /** Venv name passed to `UvRuntime.ensureVenv` (and its dir basename). */
85
103
  export const MLX_VENV_NAME = 'mlx';
86
104
  /**
@@ -100,7 +118,8 @@ export function mlxVenvPackages(packageSpec) {
100
118
  // a dependency (0.6.6 did, which is the only reason the old list
101
119
  // worked). Dropping this pin kills the batch engine at import time.
102
120
  // 0.31.3 is also the combination used for the 0.7.1 Qwen4-Exp batch
103
- // compatibility check above; keep it independent of mlx-vlm's deps.
121
+ // compatibility check and the 0.7.12 verification above; keep it
122
+ // independent of mlx-vlm's deps.
104
123
  return [
105
124
  packageSpec ?? MLX_DEFAULT_PACKAGE_SPEC,
106
125
  'mlx-lm==0.31.3',
@@ -77,6 +77,10 @@ export declare function staticCapacityCeilings(budget: Pick<CapacityBudget, 'bud
77
77
  budgetBytes: number;
78
78
  gpuBudgetBytes: number;
79
79
  };
80
+ /** The RAM ceiling the device ledger admits one launch under, for sizing a launch to fit it. */
81
+ export declare function deviceModelBudgetBytes(config: {
82
+ localEngineMemoryGb?: number | null | undefined;
83
+ }): Promise<number>;
80
84
  export declare function sampleDeviceCapacity(home?: string): Promise<DeviceCapacitySample>;
81
85
  export declare function localDeviceCapacity(home: string): DeviceCapacityLedger;
82
86
  /** Conservative fallback for media engines that do not expose a launch planner. */
@@ -155,6 +155,11 @@ export function staticCapacityCeilings(budget, ramBytes, configuredBytes, platfo
155
155
  : budget.fastBytes),
156
156
  };
157
157
  }
158
+ /** The RAM ceiling the device ledger admits one launch under, for sizing a launch to fit it. */
159
+ export async function deviceModelBudgetBytes(config) {
160
+ const ram = totalMemoryBytes();
161
+ return staticCapacityCeilings(await measuredCapacityBudget(), ram, configuredEngineMemoryBytes(config)).budgetBytes;
162
+ }
158
163
  export async function sampleDeviceCapacity(home) {
159
164
  const budget = await measuredCapacityBudget();
160
165
  const ram = totalMemoryBytes();
@@ -37,7 +37,7 @@ export declare class LocalEngineRuntime {
37
37
  /** Product chat may prefill its own sessions; the engine runtime never resolves them. */
38
38
  protected prefillCacheSession(_sessionId: string): Promise<boolean>;
39
39
  /** Cached provider instances keyed by provider name. */
40
- protected readonly providers: Map<"copilot" | "openai" | "anthropic" | "anthropic-cli" | "codex-cli" | "ollama" | "llama-cpp" | "mlx" | "ds4" | "apple-foundation-models" | "android-mlkit" | "remote", LLMProvider>;
40
+ protected readonly providers: Map<"copilot" | "openai" | "anthropic" | "anthropic-cli" | "codex-cli" | "ollama" | "llama-cpp" | "mlx" | "ds4" | "remote" | "apple-foundation-models" | "android-mlkit", LLMProvider>;
41
41
  protected readonly catalog: CatalogService;
42
42
  protected readonly llamaCppModels?: import('../llama-cpp/index.js').LlamaCppModelManager;
43
43
  protected readonly ds4Models?: import('../llama-cpp/index.js').LlamaCppModelManager;
@@ -384,6 +384,21 @@ export declare class LocalEngineRuntime {
384
384
  * the only guard against a window the engine cannot actually serve.
385
385
  */
386
386
  contextCeilingTokens?: number;
387
+ /** ds4 only: whether the launch would stream routed experts from SSD. */
388
+ ssdStreaming?: boolean;
389
+ /** ds4 only: `plannedResidentBytes` comes from the entry's measurement for this mode. */
390
+ memoryMeasured?: boolean;
391
+ /**
392
+ * ds4 only, streamed launches with a measured footprint: the expert cache
393
+ * the launcher would choose, so the model list's fit verdict comes from the
394
+ * same plan instead of a copy of its headroom rule.
395
+ */
396
+ expertCache?: {
397
+ cacheBytes: number;
398
+ requestedBytes: number;
399
+ clamped: boolean;
400
+ safe: boolean;
401
+ };
387
402
  }>;
388
403
  /**
389
404
  * Cancel a pending provider-queue entry by (provider, id). Resolves
@@ -1,15 +1,17 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { join } from 'node:path';
3
- import { createLogger } from '@bendyline/gezel';
4
- import { estimateLlamaCppResidentBytes } from '@bendyline/gezel';
3
+ import { createLogger, ds4SsdStreamingSupported } from '@bendyline/gezel';
4
+ import { estimateLlamaCppResidentBytes, llamaCppDraftResidentBytes, } from '@bendyline/gezel';
5
5
  import { resolveCatalogLlamaCppEngineConfig } from '../catalog-model-config.js';
6
- import { buildDs4Provider, resolveDs4LaunchCtx, resolveDs4VisionLaunch, } from '../ds4/build-provider.js';
6
+ import { buildDs4Provider, ds4RamTieredContext, resolveDs4LaunchCtx, resolveDs4VisionLaunch, } from '../ds4/build-provider.js';
7
7
  import { ds4VisionResidentBytes } from '../ds4/residency.js';
8
+ import { currentLlamaCppBackend } from '../llama-cpp/backend-health.js';
8
9
  import { buildLlamaCppProvider, ensureLlamaEngineStatus } from '../llama-cpp/build-provider.js';
9
10
  import { readGgufSummaryAsync } from '../llama-cpp/gguf-metadata-async.js';
10
11
  import { effectiveLazyTensorBytes } from '../llama-cpp/gguf-metadata.js';
11
12
  import { isGemmaModel, planLlamaCppKv, resolveLlamaCppKvCacheType, } from '../llama-cpp/kv-cache-type.js';
12
13
  import { estimateExactPerSlotKvBytesF16, estimateKvReserveBytes, estimateWindowedKvLinearization, } from '../llama-cpp/offload-planner.js';
14
+ import { drafterCostsContext, llamaCppSidecarLoads, resolveSpecDraft, } from '../llama-cpp/spec-draft.js';
13
15
  import { buildMlxProvider, resolveMlxEffectiveNumCtx } from '../mlx/build-provider.js';
14
16
  import { readMlxModelGeometry } from '../mlx/model-geometry.js';
15
17
  import { ModelNotInstalledError } from '../types.js';
@@ -594,7 +596,8 @@ export class LocalEngineRuntime {
594
596
  const config = await this.store.readConfig();
595
597
  const externalBaseUrl = process.env.GEZEL_DS4_SERVER_URL ?? config.ds4BaseUrl;
596
598
  if (!externalBaseUrl) {
597
- const { ds4ProjectedResidentBytes, ds4ResidentBytesForMode, ds4ResidentLine, shouldUseDs4SsdStreaming, } = await import('../ds4/residency.js');
599
+ const { ds4ProjectedResidentBytes, ds4ResidentBytesForMode, ds4ResidentLine, planDs4MeasuredLaunch, shouldUseDs4SsdStreaming, } = await import('../ds4/residency.js');
600
+ const { totalMemoryBytes } = await import('../../system/host-memory.js');
598
601
  // Bill the window this model will actually launch with. The
599
602
  // authored footprint is a measurement at `residentCtxTokens`, so
600
603
  // without this a raised context reserves the old number and the
@@ -603,29 +606,51 @@ export class LocalEngineRuntime {
603
606
  const ds4Block = cm.ds4;
604
607
  const installed = await this.ds4Models?.resolveModel(modelId).catch(() => null);
605
608
  const visionResidentBytes = await this.resolveDs4VisionResidentBytes(config, modelId, installed, ds4Block);
606
- const line = ds4ResidentLine({
607
- residentBytes: bytes,
608
- kvBytesPerToken: ds4Block?.kvBytesPerToken,
609
- residentCtxTokens: ds4Block?.residentCtxTokens,
609
+ const totalRamBytes = totalMemoryBytes();
610
+ const ds4Override = config.modelContextOverrides?.[`ds4:${modelId}`];
611
+ const contextFloor = minViableLocalContextTokens();
612
+ const numCtx = resolveDs4LaunchCtx({
613
+ configured: ds4Override ?? config.ds4NumCtx,
614
+ ramTieredCtx: ds4RamTieredContext(totalRamBytes),
615
+ catalogMaxCtx: ds4Block?.maxLaunchCtx,
616
+ minViableContextTokens: ds4Override !== undefined ? Math.min(contextFloor, ds4Override) : contextFloor,
610
617
  });
611
- if (line) {
612
- const { totalMemoryBytes } = await import('../../system/host-memory.js');
613
- bytes = ds4ProjectedResidentBytes(line, resolveDs4LaunchCtx({
614
- configured: config.modelContextOverrides?.[`ds4:${modelId}`] ?? config.ds4NumCtx,
615
- ramTieredCtx: totalMemoryBytes() / 1024 ** 3 >= 192 ? 262_144 : 131_072,
616
- catalogMaxCtx: ds4Block?.maxLaunchCtx,
617
- minViableContextTokens: minViableLocalContextTokens(),
618
- }));
619
- }
620
- bytes = ds4ResidentBytesForMode(bytes, shouldUseDs4SsdStreaming({
618
+ const streamingSupported = ds4SsdStreamingSupported(ds4Block, process.platform);
619
+ const ssdStreaming = shouldUseDs4SsdStreaming({
621
620
  configured: config.ds4SsdStreaming,
622
- ...(ds4Block?.ssdStreamingSupported !== undefined
623
- ? { ssdStreamingSupported: ds4Block.ssdStreamingSupported }
621
+ ...(streamingSupported !== undefined
622
+ ? { ssdStreamingSupported: streamingSupported }
624
623
  : {}),
625
624
  modelSizeBytes: ds4Block?.residentWeightBytes ?? block.approxSizeBytes,
625
+ totalRamBytes,
626
626
  ...(visionResidentBytes > 0 ? { companionBytes: visionResidentBytes } : {}),
627
- }), ds4Block?.ssdStreamingSupported);
628
- bytes += visionResidentBytes;
627
+ });
628
+ const measured = planDs4MeasuredLaunch({
629
+ source: ds4Block,
630
+ numCtx,
631
+ ssdStreaming,
632
+ totalRamBytes,
633
+ budgetBytes: ssdStreaming && ds4Block?.streamingMemory
634
+ ? await (await import('./device-capacity.js')).deviceModelBudgetBytes(config)
635
+ : undefined,
636
+ sidecarBytes: visionResidentBytes,
637
+ configuredCacheGb: config.ds4CacheExpertsGb,
638
+ });
639
+ if (measured) {
640
+ bytes = measured.workingSetBytes;
641
+ }
642
+ else {
643
+ const line = ds4ResidentLine({
644
+ residentBytes: bytes,
645
+ kvBytesPerToken: ds4Block?.kvBytesPerToken,
646
+ residentCtxTokens: ds4Block?.residentCtxTokens,
647
+ });
648
+ if (line)
649
+ bytes = ds4ProjectedResidentBytes(line, numCtx);
650
+ bytes =
651
+ ds4ResidentBytesForMode(bytes, ssdStreaming, streamingSupported) +
652
+ visionResidentBytes;
653
+ }
629
654
  }
630
655
  }
631
656
  }
@@ -1047,7 +1072,9 @@ export class LocalEngineRuntime {
1047
1072
  throw new ModelNotInstalledError(name, modelId);
1048
1073
  }
1049
1074
  const { totalMemoryBytes } = await import('../../system/host-memory.js');
1050
- const ramTieredCtx = totalMemoryBytes() / 1024 ** 3 >= 192 ? 262_144 : 131_072;
1075
+ // The launcher's own tier: 64K below 96 GiB, so a smaller Mac is quoted
1076
+ // the window and footprint it will actually launch with.
1077
+ const ramTieredCtx = ds4RamTieredContext(totalMemoryBytes());
1051
1078
  const ds4Override = config.modelContextOverrides?.[`ds4:${modelId}`];
1052
1079
  const ds4Floor = ds4Override !== undefined ? Math.min(contextFloor, ds4Override) : contextFloor;
1053
1080
  const effective = resolveDs4LaunchCtx({
@@ -1086,36 +1113,69 @@ export class LocalEngineRuntime {
1086
1113
  // `fixed + slope × ctx` line llama.cpp/MLX rows carry, so the slider
1087
1114
  // prices a drag instead of claiming the footprint never moves. Entries
1088
1115
  // without a measured slope keep the flat number and say so.
1089
- const { ds4ProjectedResidentBytes, ds4ResidentBytesForMode, ds4ResidentLine, shouldUseDs4SsdStreaming, } = await import('../ds4/residency.js');
1116
+ const { ds4ProjectedResidentBytes, ds4ResidentBytesForMode, ds4ResidentLine, planDs4MeasuredLaunch, shouldUseDs4SsdStreaming, } = await import('../ds4/residency.js');
1090
1117
  const ds4Line = ds4ResidentLine({
1091
1118
  residentBytes: ds4Source?.residentBytes,
1092
1119
  kvBytesPerToken: ds4Source?.kvBytesPerToken,
1093
1120
  residentCtxTokens: ds4Source?.residentCtxTokens,
1094
1121
  });
1095
1122
  const visionResidentBytes = await this.resolveDs4VisionResidentBytes(config, modelId, installed, ds4Source);
1123
+ const streamingSupported = ds4SsdStreamingSupported(ds4Source, process.platform);
1096
1124
  const ssdStreaming = shouldUseDs4SsdStreaming({
1097
1125
  configured: config.ds4SsdStreaming,
1098
- ...(ds4Source?.ssdStreamingSupported !== undefined
1099
- ? { ssdStreamingSupported: ds4Source.ssdStreamingSupported }
1100
- : {}),
1126
+ ...(streamingSupported !== undefined ? { ssdStreamingSupported: streamingSupported } : {}),
1101
1127
  modelSizeBytes: ds4Source?.residentWeightBytes ??
1102
1128
  ds4Source?.approxSizeBytes ??
1103
1129
  installed?.approxSizeBytes,
1104
1130
  ...(visionResidentBytes > 0 ? { companionBytes: visionResidentBytes } : {}),
1105
1131
  });
1106
- const plannedResidentBytes = ds4Line
1107
- ? ds4ResidentBytesForMode(ds4ProjectedResidentBytes(ds4Line, effective), ssdStreaming, ds4Source?.ssdStreamingSupported) + visionResidentBytes
1108
- : await this.resolveResidentBytes('ds4', modelId);
1132
+ // A measurement for this launch's mode prices it exactly — the window
1133
+ // moves only that mode's slope, and a streamed launch carries the cache
1134
+ // the launcher would choose. Unmeasured entries keep the authored line.
1135
+ const measured = planDs4MeasuredLaunch({
1136
+ source: ds4Source,
1137
+ numCtx: effective,
1138
+ ssdStreaming,
1139
+ totalRamBytes: totalMemoryBytes(),
1140
+ budgetBytes: ssdStreaming && ds4Source?.streamingMemory
1141
+ ? await (await import('./device-capacity.js')).deviceModelBudgetBytes(config)
1142
+ : undefined,
1143
+ sidecarBytes: visionResidentBytes,
1144
+ configuredCacheGb: config.ds4CacheExpertsGb,
1145
+ });
1146
+ const plannedResidentBytes = measured
1147
+ ? measured.workingSetBytes
1148
+ : ds4Line
1149
+ ? ds4ResidentBytesForMode(ds4ProjectedResidentBytes(ds4Line, effective), ssdStreaming, streamingSupported) + visionResidentBytes
1150
+ : await this.resolveResidentBytes('ds4', modelId);
1109
1151
  return {
1110
1152
  contextWindow: useResidentOr(effective, Math.min(effective, ds4Floor)),
1111
1153
  plannedResidentBytes,
1112
- ...(ds4Line
1154
+ ssdStreaming,
1155
+ ...(measured
1113
1156
  ? {
1114
- weightsResidentBytes: ds4ResidentBytesForMode(ds4Line.contextFreeBytes, ssdStreaming, ds4Source?.ssdStreamingSupported) + visionResidentBytes,
1157
+ memoryMeasured: true,
1158
+ weightsResidentBytes: measured.contextFreeBytes,
1115
1159
  kvFixedBytesPerSlot: 0,
1116
- kvBytesPerTokenPerSlot: ds4Line.kvBytesPerToken,
1160
+ kvBytesPerTokenPerSlot: measured.bytesPerToken,
1161
+ ...(measured.cache
1162
+ ? {
1163
+ expertCache: {
1164
+ cacheBytes: measured.cache.cacheGb * 1024 ** 3,
1165
+ requestedBytes: measured.cache.requestedGb * 1024 ** 3,
1166
+ clamped: measured.cache.clamped,
1167
+ safe: measured.cache.safe,
1168
+ },
1169
+ }
1170
+ : {}),
1117
1171
  }
1118
- : {}),
1172
+ : ds4Line
1173
+ ? {
1174
+ weightsResidentBytes: ds4ResidentBytesForMode(ds4Line.contextFreeBytes, ssdStreaming, streamingSupported) + visionResidentBytes,
1175
+ kvFixedBytesPerSlot: 0,
1176
+ kvBytesPerTokenPerSlot: ds4Line.kvBytesPerToken,
1177
+ }
1178
+ : {}),
1119
1179
  ...(nativeWindow ? { nativeContextWindow: nativeWindow } : {}),
1120
1180
  ...(ds4Override !== undefined ? { overrideContextTokens: ds4Override } : {}),
1121
1181
  ...(ds4AutoContextWindow !== undefined ? { autoContextWindow: ds4AutoContextWindow } : {}),
@@ -1190,9 +1250,32 @@ export class LocalEngineRuntime {
1190
1250
  ? effectiveLazyTensorBytes(summary, config.llamaCppLazyMode ?? manifestEngineConfig?.lazyMode ?? 'auto')
1191
1251
  : 0;
1192
1252
  const residentWeightFileBytes = Math.max(0, installed.approxSizeBytes - lazyTensorBytes);
1193
- const residentWeightsBytes = estimateLlamaCppResidentBytes(residentWeightFileBytes, {
1253
+ const { perModel: previewEngineConfig } = await resolveSpecDraft({
1254
+ perModel: manifestEngineConfig,
1255
+ backend: currentLlamaCppBackend(),
1256
+ configSpecType: config.llamaCppSpecType,
1257
+ configDraftModelPath: config.llamaCppDraftModelPath,
1258
+ installedDraftModelPath: installed.draftModelPath,
1259
+ resolveDraftPath: async (id) => (await this.llamaCppModels?.resolveModelPath(id)) ?? null,
1260
+ });
1261
+ const previewSpecType = config.llamaCppSpecType ?? previewEngineConfig?.spec?.type;
1262
+ const draftBudgetBytes = llamaCppSidecarLoads({
1263
+ specType: previewSpecType,
1264
+ targetHasMtp: (summary?.nextnPredictLayers ?? 0) > 0,
1265
+ perBackend: !!manifestEngineConfig?.spec?.byBackend,
1266
+ }) && installed.draftModelPath
1267
+ ? (installed.draftModelSizeBytes ?? 0)
1268
+ : 0;
1269
+ const catalogDetail = draftBudgetBytes > 0 ? await this.catalog.get('chat-model', modelId).catch(() => null) : null;
1270
+ const draftResidentBytes = llamaCppDraftResidentBytes(catalogDetail?.manifest.kind === 'chat-model'
1271
+ ? catalogDetail.manifest.llamaCpp?.draftModel
1272
+ : undefined, installed.draftModelPath);
1273
+ const residentBytesWith = (draftBytes) => estimateLlamaCppResidentBytes(residentWeightFileBytes, {
1194
1274
  mmprojBytes: visionBudgetBytes,
1275
+ draftBytes,
1276
+ draftResidentBytes,
1195
1277
  });
1278
+ let residentWeightsBytes = residentBytesWith(draftBudgetBytes);
1196
1279
  const exactPerSlotKvF16 = summary
1197
1280
  ? estimateExactPerSlotKvBytesF16({
1198
1281
  blockCount: summary.blockCount,
@@ -1452,6 +1535,7 @@ export class LocalEngineRuntime {
1452
1535
  maxSlots: defaultLocalEngineSlots(fastBudgetBytes),
1453
1536
  });
1454
1537
  kv = kvPlan.kvCacheType;
1538
+ const draftFits = planFitsAt(kv, kvPlan.ctxCapTokens ?? grantedCtx, 1);
1455
1539
  // Mirror the launch: the f16-by-context-cap trade shrinks the granted
1456
1540
  // window here too, so the settings preview shows what will really run.
1457
1541
  if (kvPlan.ctxCapTokens !== undefined && kvPlan.ctxCapTokens < grantedCtx) {
@@ -1462,7 +1546,7 @@ export class LocalEngineRuntime {
1462
1546
  ceiling: passCeiling(kv, grantedCtx),
1463
1547
  tierDefault: defaultLocalEngineSlots(fastBudgetBytes),
1464
1548
  });
1465
- if ((config.llamaCppSpecType ?? manifestEngineConfig?.spec?.type) === 'draft-mtp')
1549
+ if (previewSpecType === 'draft-mtp')
1466
1550
  slots = 1;
1467
1551
  try {
1468
1552
  if (!summary)
@@ -1544,7 +1628,14 @@ export class LocalEngineRuntime {
1544
1628
  // SWA model without a readable layout, over budget on full
1545
1629
  // math: the launch path leaves such a launch untouched — preview
1546
1630
  // the requested window.
1547
- return { grantedCtx, slots, kvCacheType: kv, plannedFieldsOk: false };
1631
+ return {
1632
+ grantedCtx,
1633
+ slots,
1634
+ kvCacheType: kv,
1635
+ plannedFieldsOk: false,
1636
+ draftFits,
1637
+ kvPlan,
1638
+ };
1548
1639
  }
1549
1640
  }
1550
1641
  if (!admission.minimumSatisfied) {
@@ -1588,9 +1679,21 @@ export class LocalEngineRuntime {
1588
1679
  throw error;
1589
1680
  log.warn(`[llama-cpp] could not inspect ${modelId} while previewing admission: ${error instanceof Error ? error.message : String(error)}`);
1590
1681
  }
1591
- return { grantedCtx, slots, kvCacheType: kv, plannedFieldsOk: true };
1682
+ return { grantedCtx, slots, kvCacheType: kv, plannedFieldsOk: true, draftFits, kvPlan };
1592
1683
  };
1593
- const plan = llamaPlanPass(explicitContextWindow, contextFloor);
1684
+ let plan = llamaPlanPass(explicitContextWindow, contextFloor);
1685
+ // The launch leaves out a catalog drafter that would cap the target window
1686
+ // or cost its KV precision; the preview has to land on the same plan.
1687
+ if (draftBudgetBytes > 0 && !config.llamaCppSpecType && !config.llamaCppDraftModelPath) {
1688
+ residentWeightsBytes = residentBytesWith(0);
1689
+ const withoutDrafter = llamaPlanPass(explicitContextWindow, contextFloor);
1690
+ if (drafterCostsContext(plan.kvPlan, withoutDrafter.kvPlan, plan.draftFits)) {
1691
+ plan = withoutDrafter;
1692
+ }
1693
+ else {
1694
+ residentWeightsBytes = residentBytesWith(draftBudgetBytes);
1695
+ }
1696
+ }
1594
1697
  let autoContextWindow;
1595
1698
  if (overrideActive) {
1596
1699
  try {
@@ -516,7 +516,14 @@ export declare class NativeEngineSupervisor {
516
516
  private clearFreezeTimer;
517
517
  private clearHealthTimer;
518
518
  }
519
- /** High-precision fatal signatures; attribution happens only if the child exits. */
519
+ /**
520
+ * High-precision fatal signatures; attribution happens only if the child exits.
521
+ *
522
+ * The `cuda-*` kinds also cover ROCm builds: ggml's ROCm backend is its CUDA
523
+ * code compiled with HIP, and it prints the same errors under the name `ROCm`
524
+ * (`ROCm error: out of memory`). The kinds are wire values, so they keep their
525
+ * names rather than growing a ROCm twin each.
526
+ */
520
527
  export declare function classifyNativeEnginePanic(line: string): {
521
528
  kind: NativeEnginePanicKind;
522
529
  line: string;
@@ -1415,13 +1415,20 @@ export class NativeEngineSupervisor {
1415
1415
  }
1416
1416
  }
1417
1417
  }
1418
- /** High-precision fatal signatures; attribution happens only if the child exits. */
1418
+ /**
1419
+ * High-precision fatal signatures; attribution happens only if the child exits.
1420
+ *
1421
+ * The `cuda-*` kinds also cover ROCm builds: ggml's ROCm backend is its CUDA
1422
+ * code compiled with HIP, and it prints the same errors under the name `ROCm`
1423
+ * (`ROCm error: out of memory`). The kinds are wire values, so they keep their
1424
+ * names rather than growing a ROCm twin each.
1425
+ */
1419
1426
  export function classifyNativeEnginePanic(line) {
1420
1427
  const clipped = line.trim().slice(0, 1_000);
1421
- if (/CUDA error:\s*invalid (?:configuration )?argument/i.test(line)) {
1428
+ if (/(?:CUDA|ROCm) error:\s*invalid (?:configuration )?argument/i.test(line)) {
1422
1429
  return { kind: 'cuda-invalid-argument', line: clipped };
1423
1430
  }
1424
- if (/CUDA error:\s*(?:out of memory|memory allocation)/i.test(line)) {
1431
+ if (/(?:CUDA|ROCm) error:\s*(?:out of memory|memory allocation)/i.test(line)) {
1425
1432
  return { kind: 'cuda-out-of-memory', line: clipped };
1426
1433
  }
1427
1434
  // Vulkan's spellings of the same condition — the backend a non-CUDA
@@ -1429,16 +1436,18 @@ export function classifyNativeEnginePanic(line) {
1429
1436
  if (/ErrorOutOfDeviceMemory|OutOfDeviceMemoryError|ggml_vulkan:.*(?:allocation|memory).*failed/i.test(line)) {
1430
1437
  return { kind: 'vulkan-out-of-memory', line: clipped };
1431
1438
  }
1432
- if (/CUDA error:\s*(?:an )?illegal memory access/i.test(line)) {
1439
+ if (/(?:CUDA|ROCm) error:\s*(?:an )?illegal memory access/i.test(line)) {
1433
1440
  return { kind: 'cuda-illegal-memory-access', line: clipped };
1434
1441
  }
1435
- if (/CUDA error:.*device-side assert/i.test(line)) {
1442
+ if (/(?:CUDA|ROCm) error:.*device-side assert/i.test(line)) {
1436
1443
  return { kind: 'cuda-device-assert', line: clipped };
1437
1444
  }
1438
- if (/CUDA error:\s*(?:no kernel image is available|the provided PTX was compiled with an unsupported toolchain)/i.test(line)) {
1445
+ if (/(?:CUDA|ROCm) error:\s*(?:no kernel image is available|invalid device function|the provided PTX was compiled with an unsupported toolchain)/i.test(line) ||
1446
+ // rocBLAS names the architecture it has no kernel library for, then aborts.
1447
+ /rocBLAS error: Cannot read .*TensileLibrary.*for GPU arch/i.test(line)) {
1439
1448
  return { kind: 'cuda-unsupported-device', line: clipped };
1440
1449
  }
1441
- if (/CUDA error:/i.test(line))
1450
+ if (/(?:CUDA|ROCm) error:/i.test(line))
1442
1451
  return { kind: 'cuda-error', line: clipped };
1443
1452
  if (/assert(?:ion)? failed|GGML_ASSERT/i.test(line)) {
1444
1453
  return { kind: 'assertion-failed', line: clipped };
@@ -600,6 +600,10 @@ export interface EngineLaunchSnapshot {
600
600
  export interface ModelInfo {
601
601
  id: string;
602
602
  name: string;
603
+ /** One short line a picker shows beside the name. */
604
+ description?: string;
605
+ /** Heading a picker lists this model under; see core's `ModelInfoSchema`. */
606
+ group?: string;
603
607
  supportsReasoning?: boolean;
604
608
  reasoningEfforts?: string[];
605
609
  defaultReasoningEffort?: string;
@@ -11,7 +11,7 @@ export interface PairedRemote {
11
11
  remoteId: string;
12
12
  /** Base URL A reaches the server at, e.g. `https://192.168.1.50:6229`. */
13
13
  baseUrl: string;
14
- /** Human label shown in A's model picker / Remote Servers panel. */
14
+ /** Human label shown in A's model picker / Remote servers panel. */
15
15
  displayName: string;
16
16
  /** Per-device bearer token the server issued A (scope `remote-inference`). */
17
17
  token: string;