mlx-bun 0.0.8 → 0.0.10

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 (144) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-perf-path.md +232 -0
  3. package/docs/design/batching-v2-plan.md +515 -0
  4. package/docs/design/bucketing-stage.md +196 -0
  5. package/docs/design/compat-cli-surface-design.md +11 -11
  6. package/docs/design/docs-reorg-plan.md +1 -1
  7. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  8. package/docs/design/dspark-speculative-decoding.md +106 -0
  9. package/docs/design/generic-model-support.md +381 -0
  10. package/docs/design/memory-inference-path.md +422 -0
  11. package/docs/design/memory-synthesis.md +287 -0
  12. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  13. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  14. package/docs/design/omlx-adoption-map.md +90 -0
  15. package/docs/design/orpo-dynamic-lambda.md +41 -0
  16. package/docs/design/orpo-training.md +35 -5
  17. package/docs/design/parallel-slots.md +12 -1
  18. package/docs/design/parity-tier-dag.md +13 -5
  19. package/docs/design/segmented-backward-training.md +15 -0
  20. package/docs/design/spec-decode-larger-targets.md +229 -0
  21. package/docs/design/ssd-kv-cold-tier.md +158 -0
  22. package/docs/design/tauri-desktop-app.md +2 -2
  23. package/docs/design/the-dreaming-handoff.md +141 -0
  24. package/docs/design/the-dreaming-master-plan.md +446 -0
  25. package/docs/design/write-pipeline-entity.md +468 -0
  26. package/docs/investigations/curve-bisector-routing.md +1 -1
  27. package/docs/investigations/decode-roofline-lookagain.md +348 -0
  28. package/docs/investigations/dspark-handoff.md +58 -0
  29. package/docs/investigations/kernel-perf-review-2026-07.md +171 -0
  30. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  31. package/docs/planning/curve-sampler-research-plan.md +331 -0
  32. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  33. package/docs/planning/pi-integration-review.md +178 -0
  34. package/docs/planning/release-notes-v0.0.10.md +57 -0
  35. package/docs/planning/release-notes-v0.0.9.md +122 -0
  36. package/docs/planning/web-ui-pass-plan.md +213 -0
  37. package/docs/planning/website-readme-pass-plan.md +224 -0
  38. package/docs/reference/cli.md +255 -0
  39. package/docs/reference/embedding.md +7 -2
  40. package/docs/reference/library-api.md +22 -3
  41. package/docs/reference/memory.md +5 -5
  42. package/docs/reference/models.md +137 -0
  43. package/docs/reference/orpo-quickstart.md +6 -0
  44. package/docs/reference/server-api.md +187 -9
  45. package/docs/reference/server-config.md +43 -9
  46. package/docs/reference/training.md +31 -12
  47. package/package.json +1 -1
  48. package/src/anthropic.ts +22 -0
  49. package/src/assets/curve-designer.html +1 -1
  50. package/src/chat-template.ts +25 -7
  51. package/src/cli.ts +1077 -90
  52. package/src/config.ts +4 -1
  53. package/src/download.ts +99 -8
  54. package/src/embed.ts +77 -0
  55. package/src/eval/perplexity.ts +164 -0
  56. package/src/eval/runner.ts +15 -6
  57. package/src/eval/tasks/bfcl.ts +1 -1
  58. package/src/generate.ts +149 -18
  59. package/src/harness-pi.ts +43 -17
  60. package/src/index.ts +2 -0
  61. package/src/kv-store.ts +240 -98
  62. package/src/lora.ts +2 -2
  63. package/src/memory/article.ts +558 -0
  64. package/src/memory/chunk-validation.ts +150 -0
  65. package/src/memory/chunk.ts +305 -12
  66. package/src/memory/cluster.ts +344 -17
  67. package/src/memory/coverage.ts +134 -0
  68. package/src/memory/crosslink.ts +0 -0
  69. package/src/memory/db.ts +184 -45
  70. package/src/memory/entity.ts +203 -0
  71. package/src/memory/gate.ts +187 -0
  72. package/src/memory/history.ts +152 -0
  73. package/src/memory/ingest.ts +324 -11
  74. package/src/memory/model.ts +321 -0
  75. package/src/memory/normalize.ts +671 -0
  76. package/src/memory/parse.ts +22 -0
  77. package/src/memory/pipeline.ts +242 -30
  78. package/src/memory/prompts.ts +42 -0
  79. package/src/memory/query.ts +334 -0
  80. package/src/memory/reconcile.ts +734 -0
  81. package/src/memory/redirect.ts +108 -0
  82. package/src/memory/reindex.ts +325 -0
  83. package/src/memory/resolve.ts +307 -0
  84. package/src/memory/route.ts +272 -0
  85. package/src/memory/schedule.ts +6 -4
  86. package/src/memory/stages.ts +774 -0
  87. package/src/memory/synthesize.ts +1629 -20
  88. package/src/memory/tools.ts +446 -96
  89. package/src/memory/vault.ts +462 -35
  90. package/src/memory/wikify.ts +1057 -16
  91. package/src/mlx/array.ts +21 -0
  92. package/src/mlx/ffi.ts +3 -0
  93. package/src/mlx/ops.ts +72 -0
  94. package/src/model/compiled-decode.ts +43 -8
  95. package/src/model/factory.ts +29 -8
  96. package/src/model/flash-attention.ts +4 -2
  97. package/src/model/fused-decode-kernel.ts +14 -7
  98. package/src/model/fused-mlp-kernel.ts +169 -0
  99. package/src/model/fused-swiglu-kernel.ts +240 -0
  100. package/src/model/gemma4-base.ts +96 -7
  101. package/src/model/gemma4.ts +33 -2
  102. package/src/model/minicpm5.ts +106 -8
  103. package/src/model/qwen3-delta.ts +48 -1
  104. package/src/model/qwen3.ts +276 -0
  105. package/src/model/qwen3_5.ts +11 -2
  106. package/src/model/steel-linear-kernel.ts +96 -0
  107. package/src/model/support.ts +38 -8
  108. package/src/model/universal/archs.ts +332 -0
  109. package/src/model/universal/dense.ts +576 -0
  110. package/src/model/universal/modules.ts +163 -0
  111. package/src/model/universal/rope.ts +366 -0
  112. package/src/pi-provider.ts +1 -1
  113. package/src/pi-terminal.ts +29 -8
  114. package/src/pi-web.ts +19 -2
  115. package/src/prompt-cache.ts +55 -9
  116. package/src/quantize/config-writer.ts +1 -1
  117. package/src/registry.ts +219 -8
  118. package/src/responses.ts +23 -0
  119. package/src/sampler.ts +220 -9
  120. package/src/serve/batch-scheduler.ts +307 -83
  121. package/src/serve/generation-gateway.ts +156 -29
  122. package/src/server.ts +746 -73
  123. package/src/spec/drafter.ts +81 -49
  124. package/src/spec/dspark/data-dflash.ts +156 -0
  125. package/src/spec/dspark/data.ts +201 -0
  126. package/src/spec/dspark/generate-dflash.ts +193 -0
  127. package/src/spec/dspark/generate.ts +290 -0
  128. package/src/spec/dspark/loss.ts +213 -0
  129. package/src/spec/dspark/module-dflash.ts +361 -0
  130. package/src/spec/dspark/module.ts +540 -0
  131. package/src/spec/dspark/sample.ts +112 -0
  132. package/src/spec/generate.ts +40 -19
  133. package/src/ssd-cache.ts +213 -0
  134. package/src/status-page.html +1 -1
  135. package/src/tokenizer.ts +7 -0
  136. package/src/train/flash-cce.ts +81 -23
  137. package/src/train/fuse.ts +243 -0
  138. package/src/train/index.ts +3 -0
  139. package/src/train/job.ts +8 -0
  140. package/src/train/loss.ts +341 -40
  141. package/src/train/prefix-shared.ts +62 -11
  142. package/src/train/segmented.ts +215 -55
  143. package/src/train/trainer.ts +112 -38
  144. package/src/web/skills/memory/SKILL.md +40 -17
@@ -17,6 +17,11 @@ export interface PromptCacheEntry {
17
17
  /** Namespace key — adapter spec for LoRA requests ("" = base model).
18
18
  * KV computed under one adapter must never seed another's prefill. */
19
19
  ns: string;
20
+ /** Release hook for entries restored from the SSD cold tier: unmaps the
21
+ * backing COW file mapping. MUST run only after `caches` are disposed
22
+ * (they may alias file pages); every dispose path here honors that, and
23
+ * callers who take() an entry carry the thunk with it. */
24
+ retain?: () => void;
20
25
  }
21
26
 
22
27
  function cacheBytes(caches: Cache[]): number {
@@ -26,7 +31,7 @@ function cacheBytes(caches: Cache[]): number {
26
31
  }
27
32
 
28
33
  /** Length of the longest common prefix of two token sequences. */
29
- function commonPrefixLength(a: number[], b: number[]): number {
34
+ export function commonPrefixLength(a: number[], b: number[]): number {
30
35
  const n = Math.min(a.length, b.length);
31
36
  let i = 0;
32
37
  while (i < n && a[i] === b[i]) i++;
@@ -39,9 +44,22 @@ export class PromptCache {
39
44
  #clock = 0;
40
45
  hits = 0;
41
46
  misses = 0;
47
+ /** Cold-tier spill hook: called with an entry ABOUT to be evicted (still
48
+ * alive — the hook may serialize its caches); the cache disposes it after.
49
+ * Failures are the hook's problem; eviction proceeds regardless. */
50
+ readonly #spill: ((entry: PromptCacheEntry) => void) | null;
42
51
 
43
- constructor(maxBytes: number) {
52
+ constructor(maxBytes: number, spill: ((entry: PromptCacheEntry) => void) | null = null) {
44
53
  this.maxBytes = maxBytes;
54
+ this.#spill = spill;
55
+ }
56
+
57
+ #disposeEntry(entry: PromptCacheEntry, spillFirst: boolean): void {
58
+ if (spillFirst && this.#spill) {
59
+ try { this.#spill(entry); } catch {}
60
+ }
61
+ for (const c of entry.caches) c.dispose();
62
+ entry.retain?.();
45
63
  }
46
64
 
47
65
  get totalBytes(): number {
@@ -88,26 +106,54 @@ export class PromptCache {
88
106
  return entry;
89
107
  }
90
108
 
91
- /** Insert (or reinsert) an entry; evicts LRU entries over the byte cap.
92
- * If the entry itself exceeds the cap it is disposed, not stored. */
93
- put(tokens: number[], caches: Cache[], ns = ""): void {
109
+ /** Longest usable prefix length available for `prompt` WITHOUT taking
110
+ * (the SSD tier compares this against its own best before restoring). */
111
+ peekPrefixLen(prompt: number[], ns = ""): number {
112
+ let best = 0;
113
+ for (const { entry: e } of this.#entries) {
114
+ if (e.ns !== ns) continue;
115
+ const p = Math.min(commonPrefixLength(e.tokens, prompt), prompt.length - 1);
116
+ if (p <= best) continue;
117
+ const trimNeeded = e.tokens.length - p; // same usability rule as take()
118
+ if (trimNeeded > 0 && !e.caches.every((c) => c.isTrimmable())) continue;
119
+ best = p;
120
+ }
121
+ return best;
122
+ }
123
+
124
+ /** Read-only exact-token lookup (the write-behind snapshot path: the
125
+ * entry stays owned by the cache; the caller only reads array state,
126
+ * under the gateway lock so no generation is mutating it). */
127
+ findExact(tokens: number[], ns = ""): PromptCacheEntry | null {
128
+ for (const { entry: e } of this.#entries) {
129
+ if (e.ns !== ns || e.tokens.length !== tokens.length) continue;
130
+ if (commonPrefixLength(e.tokens, tokens) === tokens.length) return e;
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /** Insert (or reinsert) an entry; evicts LRU entries over the byte cap
136
+ * (spilling each to the cold tier first, when one is attached). If the
137
+ * entry itself exceeds the cap it is spilled + disposed, not stored. */
138
+ put(tokens: number[], caches: Cache[], ns = "", retain?: () => void): void {
94
139
  const bytes = cacheBytes(caches);
140
+ const entry: PromptCacheEntry = { tokens, caches, ns, retain };
95
141
  if (bytes > this.maxBytes) {
96
- for (const c of caches) c.dispose();
142
+ this.#disposeEntry(entry, true);
97
143
  return;
98
144
  }
99
- this.#entries.push({ entry: { tokens, caches, ns }, bytes, lastUsed: ++this.#clock });
145
+ this.#entries.push({ entry, bytes, lastUsed: ++this.#clock });
100
146
  while (this.totalBytes > this.maxBytes && this.#entries.length > 1) {
101
147
  let lruIdx = 0;
102
148
  for (let i = 1; i < this.#entries.length; i++)
103
149
  if (this.#entries[i]!.lastUsed < this.#entries[lruIdx]!.lastUsed) lruIdx = i;
104
150
  const [evicted] = this.#entries.splice(lruIdx, 1);
105
- for (const c of evicted!.entry.caches) c.dispose();
151
+ this.#disposeEntry(evicted!.entry, true);
106
152
  }
107
153
  }
108
154
 
109
155
  clear(): void {
110
- for (const e of this.#entries) for (const c of e.entry.caches) c.dispose();
156
+ for (const e of this.#entries) this.#disposeEntry(e.entry, false);
111
157
  this.#entries = [];
112
158
  }
113
159
  }
@@ -109,7 +109,7 @@ export async function writeQuantizedConfig(
109
109
  }
110
110
 
111
111
  /** Copy known aux files (and any `*.model`) from src → out when present. */
112
- async function copyAuxFiles(srcDir: string, outDir: string): Promise<void> {
112
+ export async function copyAuxFiles(srcDir: string, outDir: string): Promise<void> {
113
113
  const seen = new Set<string>();
114
114
  for (const f of AUX_FILES) {
115
115
  const src = join(srcDir, f);
package/src/registry.ts CHANGED
@@ -6,7 +6,8 @@
6
6
 
7
7
  import { Database } from "bun:sqlite";
8
8
  import {
9
- closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync,
9
+ closeSync, existsSync, lstatSync, openSync, readdirSync, readFileSync,
10
+ readlinkSync, readSync, rmSync, statSync,
10
11
  } from "node:fs";
11
12
  import { join } from "node:path";
12
13
  import { isDrafterModelType } from "./model/support";
@@ -27,6 +28,12 @@ export interface ModelRecord {
27
28
  quantGroupSize: number | null;
28
29
  quantMode: string | null;
29
30
  hasVisionSidecar: boolean;
31
+ /** config.json's vision_config.model_type when it names a vision tower
32
+ * (e.g. "gemma4_vision" — SigLIP, needs the bf16 sidecar — or
33
+ * "gemma4_unified_vision" — encoder-free). null for text-only models
34
+ * AND for configs whose nested vision_config is not a vision tower
35
+ * (Qwen3.5 nests a copy of its own text config there). */
36
+ visionConfigType: string | null;
30
37
  hasKvConfig: boolean;
31
38
  hasToolTemplate: boolean;
32
39
  numLayers: number | null;
@@ -52,6 +59,7 @@ CREATE TABLE IF NOT EXISTS models (
52
59
  quant_group_size INTEGER,
53
60
  quant_mode TEXT,
54
61
  has_vision_sidecar INTEGER NOT NULL,
62
+ vision_config_type TEXT,
55
63
  has_kv_config INTEGER NOT NULL,
56
64
  has_tool_template INTEGER NOT NULL,
57
65
  num_layers INTEGER,
@@ -62,7 +70,14 @@ CREATE TABLE IF NOT EXISTS models (
62
70
  );
63
71
  `;
64
72
 
65
- export const DEFAULT_HUB = `${process.env.HOME}/.cache/huggingface/hub`;
73
+ /** HF hub cache root, honoring the standard env overrides the same way
74
+ * huggingface_hub (and our server.ts) do: HF_HUB_CACHE > HF_HOME/hub >
75
+ * ~/.cache/huggingface/hub. */
76
+ export const DEFAULT_HUB =
77
+ process.env.HF_HUB_CACHE ??
78
+ (process.env.HF_HOME
79
+ ? join(process.env.HF_HOME, "hub")
80
+ : `${process.env.HOME}/.cache/huggingface/hub`);
66
81
  export const DEFAULT_DB = `${process.env.HOME}/.cache/mlx-bun/registry.sqlite`;
67
82
 
68
83
  export class Registry {
@@ -81,7 +96,8 @@ export class Registry {
81
96
  if (
82
97
  !cols.includes("sidecar_bytes") ||
83
98
  !cols.includes("experts_bytes") ||
84
- !cols.includes("license")
99
+ !cols.includes("license") ||
100
+ !cols.includes("vision_config_type")
85
101
  ) {
86
102
  this.db.exec("DROP TABLE models");
87
103
  this.db.exec(SCHEMA);
@@ -99,7 +115,7 @@ export class Registry {
99
115
  const upsert = this.db.prepare(`
100
116
  INSERT OR REPLACE INTO models VALUES
101
117
  ($path, $repo, $type, $params, $size, $sidecar, $experts, $bits, $gs, $mode,
102
- $vision, $kv, $tools, $layers, $hidden, $vocab, $license, $at)
118
+ $vision, $vtype, $kv, $tools, $layers, $hidden, $vocab, $license, $at)
103
119
  `);
104
120
  for (const entry of readdirSync(hubDir)) {
105
121
  if (!entry.startsWith("models--")) continue;
@@ -116,6 +132,7 @@ export class Registry {
116
132
  $sidecar: rec.sidecarBytes, $experts: rec.expertsBytes,
117
133
  $bits: rec.quantBits, $gs: rec.quantGroupSize, $mode: rec.quantMode,
118
134
  $vision: rec.hasVisionSidecar ? 1 : 0,
135
+ $vtype: rec.visionConfigType,
119
136
  $kv: rec.hasKvConfig ? 1 : 0,
120
137
  $tools: rec.hasToolTemplate ? 1 : 0,
121
138
  $layers: rec.numLayers, $hidden: rec.hiddenSize, $vocab: rec.vocabSize,
@@ -131,8 +148,12 @@ export class Registry {
131
148
  const clauses: string[] = [];
132
149
  const params: Record<string, unknown> = {};
133
150
  if (filter.vision !== undefined) {
134
- clauses.push("has_vision_sidecar = $vision");
135
- params.$vision = filter.vision ? 1 : 0;
151
+ // Vision-capable = SigLIP sidecar present OR the encoder-free unified
152
+ // tower declared in config.json (12B ships no sidecar upstream anymore).
153
+ // COALESCE: NULL vision_config_type must read as "no", not SQL-NULL
154
+ // (a bare NULL LIKE poisons the NOT branch of the filter).
155
+ const cap = "(has_vision_sidecar = 1 OR COALESCE(vision_config_type,'') LIKE '%unified_vision')";
156
+ clauses.push(filter.vision ? cap : `NOT ${cap}`);
136
157
  }
137
158
  if (filter.maxBytes !== undefined) {
138
159
  clauses.push("size_bytes <= $max");
@@ -152,6 +173,24 @@ export class Registry {
152
173
  return rows.map(rowToRecord).filter((r) => existsSync(r.path));
153
174
  }
154
175
 
176
+ /** list(), collapsed to ONE record per repo: the canonical snapshot
177
+ * (refs/main, else most recently scanned). Upstream pushes create a new
178
+ * snapshots/<commit> dir per revision and the old ones are never deleted,
179
+ * so raw list() shows a row per revision — this is the user-facing view
180
+ * (`ls`, /v1/models, /library). Order follows list() (size ascending). */
181
+ listCanonical(filter: Parameters<Registry["list"]>[0] = {}): ModelRecord[] {
182
+ const byRepo = new Map<string, ModelRecord[]>();
183
+ const order: string[] = [];
184
+ for (const m of this.list(filter)) {
185
+ if (!byRepo.has(m.repoId)) {
186
+ byRepo.set(m.repoId, []);
187
+ order.push(m.repoId);
188
+ }
189
+ byRepo.get(m.repoId)!.push(m);
190
+ }
191
+ return order.map((repo) => pickCanonicalRevision(byRepo.get(repo)!));
192
+ }
193
+
155
194
  /** Resolve a fuzzy query to exactly one model (error listing candidates otherwise).
156
195
  * Speculative-decoding drafters are companion artifacts, never selectable on
157
196
  * their own, so they never count as candidates here. */
@@ -172,10 +211,17 @@ export class Registry {
172
211
  }
173
212
  }
174
213
 
214
+ /** True when the model can answer vision requests: it either ships the bf16
215
+ * SigLIP sidecar (gemma4_vision: e2b/e4b/26B/31B) or declares the
216
+ * encoder-free unified tower in config.json (gemma4_unified_vision: 12B). */
217
+ export function visionCapable(m: Pick<ModelRecord, "hasVisionSidecar" | "visionConfigType">): boolean {
218
+ return m.hasVisionSidecar || (m.visionConfigType?.endsWith("unified_vision") ?? false);
219
+ }
220
+
175
221
  /** Of several cached revisions of ONE repo, pick the canonical snapshot: the
176
222
  * revision refs/main points at, else the most recently scanned (stable
177
223
  * tie-break on path). Paths are `<hub>/models--<repo>/snapshots/<hash>`. */
178
- function pickCanonicalRevision(matches: ModelRecord[]): ModelRecord {
224
+ export function pickCanonicalRevision(matches: ModelRecord[]): ModelRecord {
179
225
  if (matches.length === 1) return matches[0]!;
180
226
  const root = matches[0]!.path.split("/snapshots/")[0]!;
181
227
  try {
@@ -203,6 +249,7 @@ function rowToRecord(r: Record<string, unknown>): ModelRecord {
203
249
  quantGroupSize: r.quant_group_size as number | null,
204
250
  quantMode: r.quant_mode as string | null,
205
251
  hasVisionSidecar: !!r.has_vision_sidecar,
252
+ visionConfigType: (r.vision_config_type as string | null) ?? null,
206
253
  hasKvConfig: !!r.has_kv_config,
207
254
  hasToolTemplate: !!r.has_tool_template,
208
255
  numLayers: r.num_layers as number | null,
@@ -258,7 +305,170 @@ function expertTensorBytes(path: string): number {
258
305
  }
259
306
  }
260
307
 
261
- async function scanSnapshot(dir: string, repoId: string): Promise<ModelRecord | null> {
308
+ // ---------------------------------------------------------------------------
309
+ // gc: reclaim superseded snapshots + dead blobs.
310
+ //
311
+ // The HF cache layout keeps one snapshots/<commit> dir per downloaded
312
+ // revision and NEVER deletes old ones — every upstream push a `get` follows
313
+ // strands the previous snapshot (and any blobs only it references). gc keeps
314
+ // the snapshots refs/* point at, deletes the rest, then deletes blobs no
315
+ // surviving snapshot symlink targets.
316
+
317
+ export interface GcSkippedSnapshot {
318
+ path: string;
319
+ /** Files this snapshot has that NO kept snapshot has (relative names).
320
+ * Deleting it would delete the only copy — needs an explicit --force. */
321
+ extraFiles: string[];
322
+ }
323
+
324
+ export interface GcRepoPlan {
325
+ repoId: string;
326
+ repoDir: string;
327
+ /** Snapshots referenced by refs/* — never deleted. */
328
+ keepSnapshots: string[];
329
+ /** Unreferenced snapshots safe to delete (file set ⊆ kept snapshots'). */
330
+ pruneSnapshots: string[];
331
+ /** Unreferenced snapshots with files the kept set lacks — skipped unless
332
+ * force (their blobs count as live). */
333
+ skippedSnapshots: GcSkippedSnapshot[];
334
+ /** blobs/ entries no surviving snapshot links to. */
335
+ deadBlobs: string[];
336
+ reclaimBytes: number;
337
+ }
338
+
339
+ /** Relative paths of all files/symlinks under a snapshot dir. */
340
+ function snapshotFiles(snapDir: string): string[] {
341
+ const out: string[] = [];
342
+ const walk = (dir: string, prefix: string) => {
343
+ for (const name of readdirSync(dir)) {
344
+ const p = join(dir, name);
345
+ const st = lstatSync(p);
346
+ if (st.isDirectory()) walk(p, `${prefix}${name}/`);
347
+ else out.push(`${prefix}${name}`);
348
+ }
349
+ };
350
+ try { walk(snapDir, ""); } catch {}
351
+ return out;
352
+ }
353
+
354
+ /** Blob basenames a snapshot's symlinks resolve to (within this repo). */
355
+ function snapshotBlobTargets(snapDir: string): Set<string> {
356
+ const targets = new Set<string>();
357
+ for (const rel of snapshotFiles(snapDir)) {
358
+ const p = join(snapDir, rel);
359
+ try {
360
+ const t = readlinkSync(p); // throws for regular files — those aren't blobs
361
+ const base = t.split("/").pop();
362
+ if (base && t.includes("blobs")) targets.add(base);
363
+ } catch {}
364
+ }
365
+ return targets;
366
+ }
367
+
368
+ /** Plan gc for one models--* repo dir. Conservative by construction:
369
+ * - no refs/ (or refs pointing at missing snapshots) → nothing is pruned;
370
+ * - an unreferenced snapshot carrying files the kept set lacks is only
371
+ * prunable under `force` (the live example: a stale snapshot whose
372
+ * optiq_vision.safetensors the canonical revision dropped);
373
+ * - blobs are dead only when NO surviving snapshot (kept or skipped)
374
+ * links to them. `.incomplete`/`.lock` resume artifacts are never touched. */
375
+ export function planRepoGc(repoDir: string, opts: { force?: boolean } = {}): GcRepoPlan {
376
+ const entry = repoDir.split("/").pop() ?? "";
377
+ const repoId = entry.slice("models--".length).replaceAll("--", "/");
378
+ const plan: GcRepoPlan = {
379
+ repoId, repoDir,
380
+ keepSnapshots: [], pruneSnapshots: [], skippedSnapshots: [],
381
+ deadBlobs: [], reclaimBytes: 0,
382
+ };
383
+ const snapsDir = join(repoDir, "snapshots");
384
+ const refsDir = join(repoDir, "refs");
385
+ if (!existsSync(snapsDir)) return plan;
386
+
387
+ const refCommits = new Set<string>();
388
+ if (existsSync(refsDir)) {
389
+ for (const r of readdirSync(refsDir)) {
390
+ try { refCommits.add(readFileSync(join(refsDir, r), "utf8").trim()); } catch {}
391
+ }
392
+ }
393
+
394
+ const candidates: string[] = [];
395
+ for (const snap of readdirSync(snapsDir)) {
396
+ const p = join(snapsDir, snap);
397
+ if (!lstatSync(p).isDirectory()) continue;
398
+ (refCommits.has(snap) ? plan.keepSnapshots : candidates).push(p);
399
+ }
400
+ // No ref resolves to an existing snapshot → we cannot tell what's live.
401
+ if (plan.keepSnapshots.length === 0) return plan;
402
+
403
+ const keptFiles = new Set<string>();
404
+ for (const k of plan.keepSnapshots) for (const f of snapshotFiles(k)) keptFiles.add(f);
405
+ for (const c of candidates) {
406
+ const extra = snapshotFiles(c).filter((f) => !keptFiles.has(f));
407
+ if (extra.length > 0 && !opts.force) plan.skippedSnapshots.push({ path: c, extraFiles: extra });
408
+ else plan.pruneSnapshots.push(c);
409
+ }
410
+
411
+ const live = new Set<string>();
412
+ for (const s of [...plan.keepSnapshots, ...plan.skippedSnapshots.map((x) => x.path)])
413
+ for (const b of snapshotBlobTargets(s)) live.add(b);
414
+ const blobsDir = join(repoDir, "blobs");
415
+ if (existsSync(blobsDir)) {
416
+ for (const b of readdirSync(blobsDir)) {
417
+ if (b.endsWith(".incomplete") || b.endsWith(".lock")) continue;
418
+ if (live.has(b)) continue;
419
+ const p = join(blobsDir, b);
420
+ plan.deadBlobs.push(p);
421
+ try { plan.reclaimBytes += lstatSync(p).size; } catch {}
422
+ }
423
+ }
424
+ return plan;
425
+ }
426
+
427
+ export function planGc(hubDir: string = DEFAULT_HUB, opts: { force?: boolean } = {}): GcRepoPlan[] {
428
+ if (!existsSync(hubDir)) return [];
429
+ const plans: GcRepoPlan[] = [];
430
+ for (const entry of readdirSync(hubDir)) {
431
+ if (!entry.startsWith("models--")) continue;
432
+ plans.push(planRepoGc(join(hubDir, entry), opts));
433
+ }
434
+ return plans;
435
+ }
436
+
437
+ /** Execute a plan: snapshot dirs first, then dead blobs. Returns totals. */
438
+ export function executeGc(plans: GcRepoPlan[]): {
439
+ snapshots: number; blobs: number; reclaimedBytes: number;
440
+ } {
441
+ let snapshots = 0, blobs = 0, reclaimedBytes = 0;
442
+ for (const p of plans) {
443
+ for (const s of p.pruneSnapshots) {
444
+ rmSync(s, { recursive: true, force: true });
445
+ snapshots++;
446
+ }
447
+ for (const b of p.deadBlobs) {
448
+ try {
449
+ const size = lstatSync(b).size;
450
+ rmSync(b, { force: true });
451
+ blobs++;
452
+ reclaimedBytes += size;
453
+ } catch {}
454
+ }
455
+ }
456
+ return { snapshots, blobs, reclaimedBytes };
457
+ }
458
+
459
+ /** vision_config.model_type, but only when it actually names a vision tower
460
+ * (`*_vision`). Some text-only configs nest a serialized copy of themselves
461
+ * under vision_config (Qwen3.5 puts model_type "qwen3_5" there) — presence
462
+ * of the key alone is NOT a vision signal. */
463
+ function visionConfigTypeOf(config: Record<string, any>): string | null {
464
+ const vt = (config.vision_config as Record<string, any> | undefined)?.model_type;
465
+ return typeof vt === "string" && vt.endsWith("_vision") ? vt : null;
466
+ }
467
+
468
+ /** Build a ModelRecord straight from a snapshot directory (also the scan()
469
+ * workhorse). Exported for explicit-path loads (`serve --model <dir>`),
470
+ * which must not depend on the registry DB having scanned that dir. */
471
+ export async function scanSnapshot(dir: string, repoId: string): Promise<ModelRecord | null> {
262
472
  const configPath = join(dir, "config.json");
263
473
  if (!existsSync(configPath)) return null;
264
474
 
@@ -325,6 +535,7 @@ async function scanSnapshot(dir: string, repoId: string): Promise<ModelRecord |
325
535
  quantGroupSize: quant?.group_size ?? null,
326
536
  quantMode: quant?.mode ?? (quant ? "affine" : null),
327
537
  hasVisionSidecar: existsSync(join(dir, "optiq_vision.safetensors")),
538
+ visionConfigType: visionConfigTypeOf(config),
328
539
  hasKvConfig: existsSync(join(dir, "kv_config.json")),
329
540
  hasToolTemplate,
330
541
  numLayers: text.num_hidden_layers ?? null,
package/src/responses.ts CHANGED
@@ -121,6 +121,19 @@ export interface ResponsesRequest {
121
121
  top_p?: number;
122
122
  top_k?: number;
123
123
  stream?: boolean;
124
+ /** mlx-lm sampler/penalty extensions + logit_bias — not part of the
125
+ * Responses protocol proper; accepted as pass-through extras with the
126
+ * same names as our /v1 chat surface. */
127
+ min_p?: number;
128
+ xtc_probability?: number;
129
+ xtc_threshold?: number;
130
+ logit_bias?: Record<string, number>;
131
+ repetition_penalty?: number;
132
+ repetition_context_size?: number;
133
+ presence_penalty?: number;
134
+ presence_context_size?: number;
135
+ frequency_penalty?: number;
136
+ frequency_context_size?: number;
124
137
  tools?: Array<Record<string, unknown>>;
125
138
  tool_choice?: unknown;
126
139
  }
@@ -235,6 +248,16 @@ export function responsesToChatBody(body: ResponsesRequest): Record<string, unkn
235
248
  if (body.top_p != null) oai.top_p = body.top_p;
236
249
  if (body.top_k != null) oai.top_k = body.top_k;
237
250
  if (body.stream) oai.stream = true;
251
+ // mlx-lm sampler/penalty extensions (+ OpenAI logit_bias): straight
252
+ // pass-through — same wire names on the chat surface.
253
+ for (const k of [
254
+ "min_p", "xtc_probability", "xtc_threshold", "logit_bias",
255
+ "repetition_penalty", "repetition_context_size",
256
+ "presence_penalty", "presence_context_size",
257
+ "frequency_penalty", "frequency_context_size",
258
+ ] as const) {
259
+ if (body[k] != null) oai[k] = body[k];
260
+ }
238
261
 
239
262
  // Flat Responses tool shape → nested chat shape; built-ins
240
263
  // (web_search, file_search, mcp, computer_use) dropped silently.