turbollm 1.0.0 → 1.2.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 (34) hide show
  1. package/README.md +4 -0
  2. package/dist/cli.js +346 -30
  3. package/dist/webdist/assets/{ChatScreen-CEMM3c4T.js → ChatScreen-Di2U5guk.js} +1 -1
  4. package/dist/webdist/assets/{CustomizeScreen-QBH-lamm.js → CustomizeScreen-C1N3VSzu.js} +1 -1
  5. package/dist/webdist/assets/{DeveloperScreen-CUSmAT_L.js → DeveloperScreen-DWf0TZqq.js} +1 -1
  6. package/dist/webdist/assets/{EnginesScreen-CxVyQ04U.js → EnginesScreen-Dqy-jlqZ.js} +2 -2
  7. package/dist/webdist/assets/ModelDetailDialog-BRKjfp4G.js +1 -0
  8. package/dist/webdist/assets/{ModelDirs-MIQRpOiQ.js → ModelDirs-z0eidPHf.js} +1 -1
  9. package/dist/webdist/assets/ModelsScreen-DqIQ4ST-.js +1 -0
  10. package/dist/webdist/assets/SettingsScreen-DXp8YTPJ.js +1 -0
  11. package/dist/webdist/assets/check-BnpDXpw8.js +1 -0
  12. package/dist/webdist/assets/{circle-x-DoZx722k.js → circle-x-nfGRpYmv.js} +1 -1
  13. package/dist/webdist/assets/{common-BkuT8XJF.js → common-VLx-PnhW.js} +1 -1
  14. package/dist/webdist/assets/{copy-button-CvYEyWud.js → copy-button-DvxLdCBa.js} +1 -1
  15. package/dist/webdist/assets/dialog-BQ-Ai22D.js +1 -0
  16. package/dist/webdist/assets/{external-link-CLr8NpF9.js → external-link-CO5laD7p.js} +1 -1
  17. package/dist/webdist/assets/{index-BhB9CGsv.js → index-BGye3qtk.js} +6 -6
  18. package/dist/webdist/assets/index-Bx0PAdfC.css +1 -0
  19. package/dist/webdist/assets/{pencil-Co_wjceA.js → pencil-fITP-Ux2.js} +1 -1
  20. package/dist/webdist/assets/{personas-D9qR7c_5.js → personas-Cq8LxYil.js} +1 -1
  21. package/dist/webdist/assets/{plus-mxaaevXg.js → plus-nMHxYBqJ.js} +1 -1
  22. package/dist/webdist/assets/{radix-RipaNCX7.js → radix-C-SVreIl.js} +1 -1
  23. package/dist/webdist/assets/{save-d7rhw6iu.js → save-Cm1zJnW7.js} +1 -1
  24. package/dist/webdist/assets/{skeleton-D2X0KEDs.js → skeleton-CSAyV68N.js} +1 -1
  25. package/dist/webdist/assets/{sparkles-B3j8q0hr.js → sparkles-Cs6GG6EW.js} +1 -1
  26. package/dist/webdist/assets/{trash-2-Dm-i43d8.js → trash-2-BdOxLswi.js} +1 -1
  27. package/dist/webdist/index.html +3 -3
  28. package/package.json +1 -1
  29. package/dist/webdist/assets/ModelDetailDialog-CtrZ6odT.js +0 -1
  30. package/dist/webdist/assets/ModelsScreen-BuZH5fye.js +0 -1
  31. package/dist/webdist/assets/SettingsScreen-DpyvTxBO.js +0 -1
  32. package/dist/webdist/assets/alert-dialog-NJbgeueX.js +0 -1
  33. package/dist/webdist/assets/check-R4c79Xkn.js +0 -1
  34. package/dist/webdist/assets/index-B3f5udGb.css +0 -1
package/README.md CHANGED
@@ -225,6 +225,10 @@ the UI when something fails to load.
225
225
  ## Auto-tuning & performance
226
226
 
227
227
  - **Auto-benchmark on load** derives fast defaults for your exact GPU.
228
+ - **Recommended sampling from the model card** — auto-tune reads the model's Hugging Face card
229
+ (falling back to the original model behind a requant) and prefills the author's recommended
230
+ `temperature / top_k / top_p / min_p`, shown in the results table and applied on Save. No
231
+ recommendation → your sampling is left untouched.
228
232
  - **Real measured tokens/sec** in the model list — **live** while a model is generating,
229
233
  **last-session** when it's idle (never a synthetic estimate).
230
234
  - **Full load-parameter UI**, a superset of what other tools expose:
package/dist/cli.js CHANGED
@@ -2363,6 +2363,71 @@ var UpdateScheduler = class {
2363
2363
  }
2364
2364
  };
2365
2365
 
2366
+ // src/app-update.ts
2367
+ function compareAppVersions(installed, latest) {
2368
+ return comparePipVersions(installed, latest);
2369
+ }
2370
+ var APP_PACKAGE = "turbollm";
2371
+ async function fetchNpmLatest(signal) {
2372
+ const res = await fetch(`https://registry.npmjs.org/${APP_PACKAGE}/latest`, {
2373
+ headers: { Accept: "application/json", "User-Agent": "turbollm" },
2374
+ signal
2375
+ });
2376
+ if (!res.ok) throw new Error(`npm query failed: HTTP ${res.status}`);
2377
+ const data = await res.json();
2378
+ return data.version ?? "";
2379
+ }
2380
+ async function computeAppUpdateStatus(installed, fetcher = fetchNpmLatest, signal) {
2381
+ const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
2382
+ let latest;
2383
+ try {
2384
+ latest = await fetcher(signal);
2385
+ } catch {
2386
+ return { installed, latest: null, hasUpdate: false, checkedAt, error: "offline", comparable: false };
2387
+ }
2388
+ if (!latest) {
2389
+ return { installed, latest: null, hasUpdate: false, checkedAt, error: "offline", comparable: false };
2390
+ }
2391
+ const cmp = compareAppVersions(installed, latest);
2392
+ return {
2393
+ installed,
2394
+ latest,
2395
+ hasUpdate: cmp === "newer",
2396
+ checkedAt,
2397
+ comparable: cmp !== "unknown"
2398
+ };
2399
+ }
2400
+ var APP_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
2401
+ var AppUpdateChecker = class {
2402
+ constructor(installed, fetcher = fetchNpmLatest) {
2403
+ this.installed = installed;
2404
+ this.fetcher = fetcher;
2405
+ }
2406
+ installed;
2407
+ fetcher;
2408
+ cache = null;
2409
+ /** The last cached status, or null when never checked. */
2410
+ get() {
2411
+ return this.cache;
2412
+ }
2413
+ /** True when there's no cached status or it's older than the 24h TTL — i.e. a
2414
+ * re-check is warranted. `now` is injectable for tests. */
2415
+ isStale(now = Date.now()) {
2416
+ if (!this.cache) return true;
2417
+ const at = Date.parse(this.cache.checkedAt);
2418
+ return !Number.isFinite(at) || now - at >= APP_UPDATE_CHECK_INTERVAL_MS;
2419
+ }
2420
+ /** Re-check and cache the result. On an offline result we KEEP a prior successful
2421
+ * status (don't overwrite a real latest with "couldn't check"); the UI's relative
2422
+ * time naturally ages. Returns the status used. */
2423
+ async check(signal) {
2424
+ const fresh = await computeAppUpdateStatus(this.installed, this.fetcher, signal);
2425
+ if (fresh.error === "offline" && this.cache && this.cache.latest !== null) return this.cache;
2426
+ this.cache = fresh;
2427
+ return fresh;
2428
+ }
2429
+ };
2430
+
2366
2431
  // src/engines/update-apply.ts
2367
2432
  import { rmSync as rmSync5 } from "fs";
2368
2433
  import { join as join9 } from "path";
@@ -3784,6 +3849,7 @@ var ConversationStore = class {
3784
3849
  // src/hf/hf.ts
3785
3850
  var BASE = "https://huggingface.co";
3786
3851
  var CACHE_TTL_MS = 5 * 60 * 1e3;
3852
+ var CARD_EXTRACT_MAX = 12e4;
3787
3853
  var HfError = class extends Error {
3788
3854
  constructor(code, message) {
3789
3855
  super(message);
@@ -3862,22 +3928,50 @@ var HfClient = class {
3862
3928
  ...safetensors ? { safetensors } : {}
3863
3929
  };
3864
3930
  }
3931
+ /** Public model-card fetch (ADR-099): the cleaned README for `owner/repo`, or '' when
3932
+ * missing/unreachable. Used by auto-tune to read recommended sampling from the card.
3933
+ * Uses a LARGER cap than the display path: popular requanters (e.g. unsloth) put their
3934
+ * recommended-settings section in the BACK HALF of a long card, past the 12k display cap —
3935
+ * the extra window is what lets the heuristic actually find them (live-verified). */
3936
+ fetchModelCard(repo) {
3937
+ return this.getCard(repo, CARD_EXTRACT_MAX);
3938
+ }
3939
+ /** Resolve the upstream base model for a repo (ADR-099 base-model fallback): the `base_model`
3940
+ * declared in the repo's HF card metadata. Most local GGUFs are third-party requants
3941
+ * (lmstudio-community / unsloth / noctrex / …) whose card omits the author's recommended
3942
+ * sampling — but they name the ORIGINAL model, whose card has it. Returns `owner/name`, or null
3943
+ * when none is declared / unreachable. Handles the array form and the `base_model:[relation:]repo`
3944
+ * tag form. Cached via {@link getJson}. */
3945
+ async baseModelOf(repo) {
3946
+ try {
3947
+ const info = await this.getJson(`${BASE}/api/models/${repo}`);
3948
+ const bm = info.cardData?.base_model;
3949
+ const first = Array.isArray(bm) ? bm[0] : bm;
3950
+ if (typeof first === "string" && first.includes("/")) return first;
3951
+ const tag = (info.tags ?? []).find((t) => t.startsWith("base_model:"));
3952
+ const fromTag = tag ? tag.split(":").pop() ?? "" : "";
3953
+ return fromTag.includes("/") ? fromTag : null;
3954
+ } catch {
3955
+ return null;
3956
+ }
3957
+ }
3865
3958
  /** Fetch the repo README (the model card), strip its YAML frontmatter, and cap the
3866
- * length for display. Best-effort — a missing/unreachable README yields '' rather
3867
- * than failing the whole repo-detail request. Cached like other HF reads. */
3868
- async getCard(repo) {
3959
+ * length. Best-effort — a missing/unreachable README yields '' rather than failing the
3960
+ * whole repo-detail request. The full (up to {@link CARD_EXTRACT_MAX}) card is cached and
3961
+ * each caller slices to its own `maxLen` (display 12k; extraction larger), so the two
3962
+ * paths share one fetch without the cache returning a too-short slice. */
3963
+ async getCard(repo, maxLen = 12e3) {
3869
3964
  const url = `${BASE}/${repo}/raw/main/README.md`;
3870
3965
  const now = Date.now();
3871
3966
  const hit = this.cache.get(url);
3872
- if (hit && now - hit.at < CACHE_TTL_MS) return hit.value;
3967
+ if (hit && now - hit.at < CACHE_TTL_MS) return hit.value.slice(0, maxLen);
3873
3968
  try {
3874
3969
  const res = await fetch(url, { headers: this.authHeaders(), redirect: "follow" });
3875
3970
  if (!res.ok) return "";
3876
3971
  const raw = await res.text();
3877
- const body3 = raw.replace(/^?---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim();
3878
- const card = body3.slice(0, 12e3);
3879
- this.cache.set(url, { at: now, value: card });
3880
- return card;
3972
+ const full = raw.replace(/^?---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim().slice(0, CARD_EXTRACT_MAX);
3973
+ this.cache.set(url, { at: now, value: full });
3974
+ return full.slice(0, maxLen);
3881
3975
  } catch {
3882
3976
  return "";
3883
3977
  }
@@ -4345,6 +4439,110 @@ import { execFile as execFile6 } from "child_process";
4345
4439
  import { mkdirSync as mkdirSync9, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "fs";
4346
4440
  import { join as join15 } from "path";
4347
4441
  import { randomUUID as randomUUID4 } from "crypto";
4442
+
4443
+ // src/api/path-utils.ts
4444
+ function inferRepoFromPath(filePath, modelDirs) {
4445
+ const norm = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "");
4446
+ const fp = norm(filePath);
4447
+ const seg = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
4448
+ for (const dir of modelDirs) {
4449
+ const root = norm(dir);
4450
+ if (!fp.toLowerCase().startsWith(root.toLowerCase() + "/")) continue;
4451
+ const parts = fp.slice(root.length + 1).split("/");
4452
+ if (parts.length >= 2 && seg.test(parts[0]) && seg.test(parts[1])) {
4453
+ return `${parts[0]}/${parts[1]}`;
4454
+ }
4455
+ return null;
4456
+ }
4457
+ return null;
4458
+ }
4459
+
4460
+ // src/models/card-sampling.ts
4461
+ var FIELDS = [
4462
+ { key: "temp", alias: "temp(?:erature)?", min: 0, max: 2 },
4463
+ { key: "topP", alias: "top[_\\-\\s]?p", min: 0, max: 1 },
4464
+ { key: "topK", alias: "top[_\\-\\s]?k", min: 0, max: 1e3, integer: true },
4465
+ { key: "minP", alias: "min[_\\-\\s]?p", min: 0, max: 1 }
4466
+ ];
4467
+ var NUM = "(\\d*\\.?\\d+)";
4468
+ var SEP = "[\\s:=|*\"'`~]{0,6}";
4469
+ function clampCardSampling(s) {
4470
+ const out = {};
4471
+ for (const f of FIELDS) {
4472
+ const v = s[f.key];
4473
+ if (v == null || !Number.isFinite(v)) continue;
4474
+ const n = f.integer ? Math.round(v) : v;
4475
+ if (n < f.min || n > f.max) continue;
4476
+ out[f.key] = n;
4477
+ }
4478
+ return out;
4479
+ }
4480
+ function parseCardSampling(card) {
4481
+ const out = {};
4482
+ if (!card) return out;
4483
+ const prose = card.replace(/```[\s\S]*?```/g, " ").replace(/~~~[\s\S]*?~~~/g, " ");
4484
+ for (const f of FIELDS) {
4485
+ const re = new RegExp(`\\b${f.alias}\\b${SEP}${NUM}`, "i");
4486
+ const m = re.exec(prose);
4487
+ if (!m) continue;
4488
+ const v = Number(m[1]);
4489
+ if (Number.isFinite(v)) out[f.key] = v;
4490
+ }
4491
+ return clampCardSampling(out);
4492
+ }
4493
+ function hasAnySampling(s) {
4494
+ return s.temp != null || s.topP != null || s.topK != null || s.minP != null;
4495
+ }
4496
+ function relevantCardExcerpt(card, maxLen = 8e3) {
4497
+ if (card.length <= maxLen) return card;
4498
+ const strong = /\b(?:recommended\s+(?:settings|sampling|parameters)|sampling\s+(?:settings|parameters|params)|best\s+practices?|generation\s+config)\b/i.exec(card);
4499
+ const cue = strong ?? /\b(?:temp(?:erature)?|top[_\-\s]?[pk]|min[_\-\s]?p)\b/i.exec(card);
4500
+ if (!cue || cue.index < maxLen) return card.slice(0, maxLen);
4501
+ const start = Math.max(0, cue.index - Math.floor(maxLen * 0.3));
4502
+ return card.slice(start, start + maxLen);
4503
+ }
4504
+ function buildCardExtractionPrompt(card) {
4505
+ const trimmed = relevantCardExcerpt(card, 8e3);
4506
+ return [
4507
+ "Extract the model author's RECOMMENDED sampling settings from the model card below.",
4508
+ "Output ONLY a single JSON object, no prose, with exactly these keys:",
4509
+ '{"temperature": number|null, "top_k": number|null, "top_p": number|null, "min_p": number|null}',
4510
+ "Use a value ONLY if the card explicitly recommends it; otherwise use null. Do not guess.",
4511
+ "",
4512
+ "MODEL CARD:",
4513
+ trimmed
4514
+ ].join("\n");
4515
+ }
4516
+ function parseLlmSampling(text) {
4517
+ const m = /\{[\s\S]*\}/.exec(text);
4518
+ if (!m) return {};
4519
+ let obj;
4520
+ try {
4521
+ obj = JSON.parse(m[0]);
4522
+ } catch {
4523
+ return {};
4524
+ }
4525
+ if (typeof obj !== "object" || obj === null) return {};
4526
+ const num = (v) => {
4527
+ if (typeof v === "number" && Number.isFinite(v)) return v;
4528
+ if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
4529
+ return null;
4530
+ };
4531
+ const out = {};
4532
+ const map = [
4533
+ ["temperature", "temp"],
4534
+ ["top_p", "topP"],
4535
+ ["top_k", "topK"],
4536
+ ["min_p", "minP"]
4537
+ ];
4538
+ for (const [jsonKey, field] of map) {
4539
+ const v = num(obj[jsonKey]);
4540
+ if (v !== null) out[field] = v;
4541
+ }
4542
+ return clampCardSampling(out);
4543
+ }
4544
+
4545
+ // src/bench/bench.ts
4348
4546
  var READY_TIMEOUT_MS = 15e4;
4349
4547
  var PER_TEST_TIMEOUT_MS = 3 * 6e4;
4350
4548
  var PREFILL_GRACE_MS = 8e3;
@@ -4360,18 +4558,20 @@ var BenchError = class extends Error {
4360
4558
  code;
4361
4559
  };
4362
4560
  var BenchRunner = class {
4363
- constructor(manager2, store2, scanner2, registry2, version2) {
4561
+ constructor(manager2, store2, scanner2, registry2, version2, hf2) {
4364
4562
  this.manager = manager2;
4365
4563
  this.store = store2;
4366
4564
  this.scanner = scanner2;
4367
4565
  this.registry = registry2;
4368
4566
  this.version = version2;
4567
+ this.hf = hf2;
4369
4568
  }
4370
4569
  manager;
4371
4570
  store;
4372
4571
  scanner;
4373
4572
  registry;
4374
4573
  version;
4574
+ hf;
4375
4575
  state = { running: false };
4376
4576
  cancelled = false;
4377
4577
  deadline = 0;
@@ -4464,13 +4664,38 @@ var BenchRunner = class {
4464
4664
  await this.manager.stopAndWait().catch(() => {
4465
4665
  });
4466
4666
  if (best) {
4467
- this.winning = { modelKey, profile: best.profile, cand: best.cand, entry, sys, engineVersion: active?.version ?? "" };
4667
+ let recommended;
4668
+ if (!this.cancelled && Date.now() <= this.deadline) {
4669
+ recommended = await this.extractCardSampling(entry, best.profile, caps, sys).catch(() => void 0);
4670
+ await this.manager.stopAndWait().catch(() => {
4671
+ });
4672
+ }
4673
+ if (this.cancelled) {
4674
+ this.state = { running: false, modelKey, done: true, candidates: results };
4675
+ return;
4676
+ }
4677
+ const profile = recommended && hasAnySampling(recommended) ? { ...best.profile, sampling: { ...best.profile.sampling, ...recommended } } : best.profile;
4678
+ this.winning = { modelKey, profile, cand: best.cand, entry, sys, engineVersion: active?.version ?? "" };
4468
4679
  this.state = {
4469
4680
  running: false,
4470
4681
  modelKey,
4471
4682
  done: true,
4472
4683
  bestTps: best.cand.tps ?? void 0,
4473
- result: { params: best.cand.params, tps: best.cand.tps ?? 0, ttftMs: best.cand.ttftMs ?? 0, vramMb: best.cand.vramMb },
4684
+ result: {
4685
+ params: best.cand.params,
4686
+ tps: best.cand.tps ?? 0,
4687
+ ttftMs: best.cand.ttftMs ?? 0,
4688
+ vramMb: best.cand.vramMb,
4689
+ // The full sampling that Save will persist (winning profile, card values merged in) so
4690
+ // the results dialog can show the COMPLETE config — not just the card-derived delta.
4691
+ sampling: {
4692
+ temp: profile.sampling.temp,
4693
+ topK: profile.sampling.topK,
4694
+ topP: profile.sampling.topP,
4695
+ minP: profile.sampling.minP
4696
+ },
4697
+ ...recommended && hasAnySampling(recommended) ? { recommendedSampling: recommended } : {}
4698
+ },
4474
4699
  candidates: results
4475
4700
  };
4476
4701
  } else {
@@ -4837,6 +5062,101 @@ var BenchRunner = class {
4837
5062
  if (!t || typeof t.predicted_per_second !== "number") return null;
4838
5063
  return { tps: t.predicted_per_second, ttftMs: typeof t.prompt_ms === "number" ? t.prompt_ms : 0 };
4839
5064
  }
5065
+ // ---- card-derived recommended sampling (ADR-099) ------------------------
5066
+ /** Resolve the model's HF card and extract recommended sampling (temp/top_k/top_p/min_p).
5067
+ * Order (ADR-099): (1) heuristic on the LOCAL repo card; (2) heuristic on the BASE model's card
5068
+ * — most local GGUFs are third-party requants (lmstudio-community/unsloth/noctrex/…) whose card
5069
+ * omits the author's recommended sampling, but they declare the original model, whose card has
5070
+ * it (e.g. Gemma QAT → `google/gemma-…`); (3) LLM fallback (one reload) on the richer card for
5071
+ * prose-only recommendations. Returns undefined when the repo can't be resolved (hand-placed
5072
+ * file), no card is reachable, or nothing parseable is found — the caller then leaves sampling
5073
+ * at the resolved defaults. Never throws.
5074
+ *
5075
+ * NOTE: a gated base model (e.g. Gemma's `google/…`) needs a configured HF token to fetch — without
5076
+ * one its card 401s and we fall through (sampling unchanged). */
5077
+ async extractCardSampling(entry, winningProfile, caps, sys) {
5078
+ const repo = inferRepoFromPath(entry.path, this.store.snapshot().modelDirs);
5079
+ if (!repo) return void 0;
5080
+ this.state = { ...this.state, step: "Reading model-card recommendations\u2026" };
5081
+ const localCard = await this.hf.fetchModelCard(repo).catch(() => "");
5082
+ const localH = parseCardSampling(localCard);
5083
+ if (hasAnySampling(localH)) return localH;
5084
+ let baseCard = "";
5085
+ if (!this.cancelled) {
5086
+ const baseRepo = await this.hf.baseModelOf(repo).catch(() => null);
5087
+ if (baseRepo && baseRepo !== repo) {
5088
+ baseCard = await this.hf.fetchModelCard(baseRepo).catch(() => "");
5089
+ const baseH = parseCardSampling(baseCard);
5090
+ if (hasAnySampling(baseH)) return baseH;
5091
+ }
5092
+ }
5093
+ if (this.cancelled) return void 0;
5094
+ const card = baseCard.length > localCard.length ? baseCard : localCard;
5095
+ if (!card) return void 0;
5096
+ const llm = await this.llmExtractSampling(entry, winningProfile, caps, sys, card).catch(() => void 0);
5097
+ return llm && hasAnySampling(llm) ? llm : void 0;
5098
+ }
5099
+ /** LLM fallback for {@link extractCardSampling}: briefly reload the winning profile, ask the
5100
+ * model to extract recommended sampling as JSON, then stop. The recommendation is
5101
+ * model-specific (independent of the swept offload), so reusing the winning profile is exact.
5102
+ * Bounded by the readiness window + a short generation timeout; any failure → undefined, and
5103
+ * the engine is always left stopped. */
5104
+ async llmExtractSampling(entry, profile, caps, sys, card) {
5105
+ const active = this.registry.active();
5106
+ if (!active) return void 0;
5107
+ const opts = {
5108
+ engine: active,
5109
+ model: { key: entry.key, name: entry.name, quant: entry.quant, ctx: profile.ctx, vision: entry.vision },
5110
+ modelPath: entry.path,
5111
+ extraArgs: profileToArgs(profile, entry, caps, sys.cores)
5112
+ };
5113
+ try {
5114
+ await this.manager.start(opts);
5115
+ } catch {
5116
+ return void 0;
5117
+ }
5118
+ const ready = await this.awaitReady(Date.now() + READY_TIMEOUT_MS);
5119
+ const target = ready === "ok" ? this.manager.target() : null;
5120
+ if (!target) {
5121
+ await this.manager.stopAndWait().catch(() => {
5122
+ });
5123
+ return void 0;
5124
+ }
5125
+ const text = await this.chatText(target, buildCardExtractionPrompt(card), 200, 6e4).catch(() => null);
5126
+ await this.manager.stopAndWait().catch(() => {
5127
+ });
5128
+ return text ? parseLlmSampling(text) : void 0;
5129
+ }
5130
+ /** One non-streaming completion that returns the generated TEXT (vs {@link chat}, which
5131
+ * returns timings). Used by the card-sampling LLM fallback. Honors the per-call timeout and
5132
+ * the cancel kill-switch; null on a non-OK response.
5133
+ *
5134
+ * `enable_thinking: false` is REQUIRED here (live-verified): a reasoning model (Gemma 4,
5135
+ * Qwen3, …) otherwise spends the whole token budget on hidden reasoning and either emits no
5136
+ * JSON or truncates it (`finish_reason: length`) — the extraction returns nothing on exactly
5137
+ * the models people run. Card extraction is a structured task that needs no reasoning; with
5138
+ * thinking off, even a 4B model emits clean JSON in well under 200 tokens. Templates that
5139
+ * don't know the kwarg ignore it. */
5140
+ async chatText(target, content, maxTokens, timeoutMs) {
5141
+ const signals = [AbortSignal.timeout(timeoutMs)];
5142
+ if (this.abort) signals.push(this.abort.signal);
5143
+ const res = await fetch(`${target}/v1/chat/completions`, {
5144
+ method: "POST",
5145
+ headers: { "Content-Type": "application/json" },
5146
+ body: JSON.stringify({
5147
+ model: "bench",
5148
+ messages: [{ role: "user", content }],
5149
+ max_tokens: maxTokens,
5150
+ temperature: 0,
5151
+ stream: false,
5152
+ chat_template_kwargs: { enable_thinking: false }
5153
+ }),
5154
+ signal: signals.length > 1 ? AbortSignal.any(signals) : signals[0]
5155
+ });
5156
+ if (!res.ok) return null;
5157
+ const data = await res.json();
5158
+ return data.choices?.[0]?.message?.content ?? null;
5159
+ }
4840
5160
  /** Save the winning profile as the model's saved profile (tunedBy:'bench') and
4841
5161
  * persist a benchResults row. Both via the same ConfigStore the route uses. */
4842
5162
  persistBest(modelKey, profile, cand) {
@@ -7748,23 +8068,6 @@ function recommendEngines(p, engines) {
7748
8068
  return { recommended: null, fits };
7749
8069
  }
7750
8070
 
7751
- // src/api/path-utils.ts
7752
- function inferRepoFromPath(filePath, modelDirs) {
7753
- const norm = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "");
7754
- const fp = norm(filePath);
7755
- const seg = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
7756
- for (const dir of modelDirs) {
7757
- const root = norm(dir);
7758
- if (!fp.toLowerCase().startsWith(root.toLowerCase() + "/")) continue;
7759
- const parts = fp.slice(root.length + 1).split("/");
7760
- if (parts.length >= 2 && seg.test(parts[0]) && seg.test(parts[1])) {
7761
- return `${parts[0]}/${parts[1]}`;
7762
- }
7763
- return null;
7764
- }
7765
- return null;
7766
- }
7767
-
7768
8071
  // src/api/routes.ts
7769
8072
  function err2(c, status, code, message) {
7770
8073
  return c.json({ error: { code, message } }, status);
@@ -8255,6 +8558,16 @@ function registerApi(app2, d) {
8255
8558
  for (const e of engines) policies[e.id] = normalizeUpdatePolicy(e.updatePolicy);
8256
8559
  return c.json({ updates: updates2, policies });
8257
8560
  });
8561
+ app2.get("/api/v1/app/update", (c) => {
8562
+ const fallback = { installed: d.version, latest: null, hasUpdate: false, checkedAt: (/* @__PURE__ */ new Date()).toISOString(), comparable: false };
8563
+ if (!d.appUpdates) return c.json(fallback);
8564
+ const refresh = c.req.query("refresh") === "1";
8565
+ if (refresh || d.appUpdates.isStale()) {
8566
+ void d.appUpdates.check(AbortSignal.timeout(1e4)).catch(() => {
8567
+ });
8568
+ }
8569
+ return c.json(d.appUpdates.get() ?? fallback);
8570
+ });
8258
8571
  app2.put("/api/v1/engines/:id/update-policy", async (c) => {
8259
8572
  const b = await body2(c);
8260
8573
  if (b.policy !== "off" && b.policy !== "notify" && b.policy !== "auto") {
@@ -9925,7 +10238,7 @@ var hashes = new HashStore(store.dir());
9925
10238
  var db = new ConversationStore(store.dir());
9926
10239
  var hf = new HfClient(() => store.snapshot().hf.token, version);
9927
10240
  var downloads = new DownloadManager(store, () => void scanner.rescan(), () => hf.authHeaders());
9928
- var bench = new BenchRunner(manager, store, scanner, registry, version);
10241
+ var bench = new BenchRunner(manager, store, scanner, registry, version, hf);
9929
10242
  var comfy = new ComfyGuard(store, manager);
9930
10243
  var modelRouter = new ModelRouter(store, registry, manager, scanner, comfy);
9931
10244
  var toolRegistry = new ToolRegistry(store.snapshot().tools);
@@ -9934,8 +10247,11 @@ void (async () => {
9934
10247
  await toolRegistry.syncMcpServers(cfg2.mcp.servers);
9935
10248
  })();
9936
10249
  var startedAt = Date.now();
9937
- var deps = { store, registry, manager, scanner, hashes, db, provision, updates, hf, downloads, bench, modelRouter, comfy, tools: toolRegistry, version, startedAt };
10250
+ var appUpdates = new AppUpdateChecker(version);
10251
+ var deps = { store, registry, manager, scanner, hashes, db, provision, updates, appUpdates, hf, downloads, bench, modelRouter, comfy, tools: toolRegistry, version, startedAt };
9938
10252
  var app = createApp(deps);
10253
+ setTimeout(() => void appUpdates.check(AbortSignal.timeout(1e4)).catch(() => {
10254
+ }), 5e3).unref();
9939
10255
  var updateScheduler = new UpdateScheduler({
9940
10256
  store,
9941
10257
  registry,