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.
- package/README.md +4 -0
- package/dist/cli.js +346 -30
- package/dist/webdist/assets/{ChatScreen-CEMM3c4T.js → ChatScreen-Di2U5guk.js} +1 -1
- package/dist/webdist/assets/{CustomizeScreen-QBH-lamm.js → CustomizeScreen-C1N3VSzu.js} +1 -1
- package/dist/webdist/assets/{DeveloperScreen-CUSmAT_L.js → DeveloperScreen-DWf0TZqq.js} +1 -1
- package/dist/webdist/assets/{EnginesScreen-CxVyQ04U.js → EnginesScreen-Dqy-jlqZ.js} +2 -2
- package/dist/webdist/assets/ModelDetailDialog-BRKjfp4G.js +1 -0
- package/dist/webdist/assets/{ModelDirs-MIQRpOiQ.js → ModelDirs-z0eidPHf.js} +1 -1
- package/dist/webdist/assets/ModelsScreen-DqIQ4ST-.js +1 -0
- package/dist/webdist/assets/SettingsScreen-DXp8YTPJ.js +1 -0
- package/dist/webdist/assets/check-BnpDXpw8.js +1 -0
- package/dist/webdist/assets/{circle-x-DoZx722k.js → circle-x-nfGRpYmv.js} +1 -1
- package/dist/webdist/assets/{common-BkuT8XJF.js → common-VLx-PnhW.js} +1 -1
- package/dist/webdist/assets/{copy-button-CvYEyWud.js → copy-button-DvxLdCBa.js} +1 -1
- package/dist/webdist/assets/dialog-BQ-Ai22D.js +1 -0
- package/dist/webdist/assets/{external-link-CLr8NpF9.js → external-link-CO5laD7p.js} +1 -1
- package/dist/webdist/assets/{index-BhB9CGsv.js → index-BGye3qtk.js} +6 -6
- package/dist/webdist/assets/index-Bx0PAdfC.css +1 -0
- package/dist/webdist/assets/{pencil-Co_wjceA.js → pencil-fITP-Ux2.js} +1 -1
- package/dist/webdist/assets/{personas-D9qR7c_5.js → personas-Cq8LxYil.js} +1 -1
- package/dist/webdist/assets/{plus-mxaaevXg.js → plus-nMHxYBqJ.js} +1 -1
- package/dist/webdist/assets/{radix-RipaNCX7.js → radix-C-SVreIl.js} +1 -1
- package/dist/webdist/assets/{save-d7rhw6iu.js → save-Cm1zJnW7.js} +1 -1
- package/dist/webdist/assets/{skeleton-D2X0KEDs.js → skeleton-CSAyV68N.js} +1 -1
- package/dist/webdist/assets/{sparkles-B3j8q0hr.js → sparkles-Cs6GG6EW.js} +1 -1
- package/dist/webdist/assets/{trash-2-Dm-i43d8.js → trash-2-BdOxLswi.js} +1 -1
- package/dist/webdist/index.html +3 -3
- package/package.json +1 -1
- package/dist/webdist/assets/ModelDetailDialog-CtrZ6odT.js +0 -1
- package/dist/webdist/assets/ModelsScreen-BuZH5fye.js +0 -1
- package/dist/webdist/assets/SettingsScreen-DpyvTxBO.js +0 -1
- package/dist/webdist/assets/alert-dialog-NJbgeueX.js +0 -1
- package/dist/webdist/assets/check-R4c79Xkn.js +0 -1
- 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
|
|
3867
|
-
*
|
|
3868
|
-
|
|
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
|
|
3878
|
-
|
|
3879
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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,
|