@otto-code/brain 0.8.7 → 0.8.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.
- package/dist/cli.js +2 -1
- package/dist/commands/bench.js +19 -5
- package/dist/commands/catalog.d.ts +3 -0
- package/dist/commands/catalog.js +2 -0
- package/dist/commands/pull.d.ts +1 -0
- package/dist/commands/pull.js +47 -14
- package/dist/commands/repo-download.d.ts +14 -0
- package/dist/commands/repo-download.js +29 -0
- package/dist/commands/runtime.d.ts +3 -0
- package/dist/commands/runtime.js +65 -18
- package/dist/commands/search.d.ts +3 -0
- package/dist/commands/search.js +38 -17
- package/dist/config/builtin-hosting-profiles.d.ts +8 -0
- package/dist/config/builtin-hosting-profiles.js +32 -0
- package/dist/config/hosting-profiles.d.ts +33 -0
- package/dist/config/hosting-profiles.js +71 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/paths.d.ts +2 -0
- package/dist/config/paths.js +1 -0
- package/dist/config/profile-edit.d.ts +5 -3
- package/dist/config/profile-edit.js +134 -14
- package/dist/config/profiles.js +66 -3
- package/dist/config/schema.d.ts +998 -0
- package/dist/config/schema.js +79 -0
- package/dist/config/store.js +28 -16
- package/dist/gguf.d.ts +1 -0
- package/dist/gguf.js +1 -0
- package/dist/models/download.d.ts +7 -0
- package/dist/models/download.js +165 -17
- package/dist/models/enrich.d.ts +6 -19
- package/dist/models/enrich.js +138 -4
- package/dist/models/hf.d.ts +14 -1
- package/dist/models/hf.js +239 -6
- package/dist/models/index.d.ts +2 -2
- package/dist/models/index.js +8 -5
- package/dist/models/manage.d.ts +4 -0
- package/dist/models/manage.js +34 -4
- package/dist/models/scan.js +8 -42
- package/dist/ops/calibrate.d.ts +4 -1
- package/dist/ops/calibrate.js +10 -7
- package/dist/ops/sweep.d.ts +3 -1
- package/dist/ops/sweep.js +3 -3
- package/dist/runtime/args.d.ts +2 -2
- package/dist/runtime/args.js +18 -1
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.js +11 -1
- package/dist/runtime/managed.d.ts +40 -0
- package/dist/runtime/managed.js +146 -7
- package/dist/service/host-api.d.ts +20 -3
- package/dist/service/host-api.js +313 -20
- package/dist/service/router.d.ts +18 -0
- package/dist/service/router.js +89 -4
- package/dist/service/serve.js +221 -22
- package/dist/service/supervisor.d.ts +32 -4
- package/dist/service/supervisor.js +30 -6
- package/dist/tui/app.js +1 -1
- package/dist/types.d.ts +24 -0
- package/dist/vram.d.ts +3 -0
- package/dist/vram.js +24 -6
- package/package.json +1 -1
package/dist/models/hf.js
CHANGED
|
@@ -22,12 +22,229 @@ function authHeaders(token) {
|
|
|
22
22
|
function entryBytes(entry) {
|
|
23
23
|
return entry.size ?? entry.lfs?.size ?? 0;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Model-card summaries, and the three traps they set.
|
|
27
|
+
*
|
|
28
|
+
* 1. **`base_model` chains can be circular.** A quantizer's card names its base,
|
|
29
|
+
* whose card can name the quantization straight back. Two guards, because one
|
|
30
|
+
* is not enough: the chain of repos being resolved is carried down the
|
|
31
|
+
* recursion and re-entering a repo already on it is refused, and the chain is
|
|
32
|
+
* depth-capped. A guard on direct self-reference alone misses A -> B -> A.
|
|
33
|
+
* 2. **A shared cache of *pending* promises reintroduces the deadlock the chain
|
|
34
|
+
* guard just removed.** With three rows resolving concurrently, A -> B -> C -> A
|
|
35
|
+
* can have each stack awaiting another stack's in-flight entry, a wait-for
|
|
36
|
+
* cycle no per-stack chain can observe. So the cache holds *settled values
|
|
37
|
+
* only*, and the in-flight dedupe map is consulted **only at the top level**,
|
|
38
|
+
* never inside the recursion. Every in-flight promise therefore resolves
|
|
39
|
+
* without ever awaiting another in-flight promise, which makes the wait-for
|
|
40
|
+
* graph a forest and a cycle unrepresentable.
|
|
41
|
+
* 3. **The service is long lived.** The cache is capped and LRU-evicted, and
|
|
42
|
+
* entries expire, with a much shorter life for misses so a transient failure
|
|
43
|
+
* does not stick for the process lifetime.
|
|
44
|
+
*
|
|
45
|
+
* Every fetch also carries a timeout, and the caller's abort signal, so a slow or
|
|
46
|
+
* hanging remote cannot pin an RPC open.
|
|
47
|
+
*/
|
|
48
|
+
const SUMMARY_CACHE_MAX = 256;
|
|
49
|
+
const SUMMARY_TTL_MS = 30 * 60000;
|
|
50
|
+
/** Misses expire fast: a 404 today is often a card published tomorrow. */
|
|
51
|
+
const SUMMARY_MISS_TTL_MS = 5 * 60000;
|
|
52
|
+
const SUMMARY_FETCH_TIMEOUT_MS = 4000;
|
|
53
|
+
/** Deadline for the search and tree listings, which carry the actual payload. */
|
|
54
|
+
const REQUEST_TIMEOUT_MS = 15000;
|
|
55
|
+
/** How long a whole search may spend on summaries before degrading to null. */
|
|
56
|
+
const DEFAULT_SUMMARY_BUDGET_MS = 2500;
|
|
57
|
+
/** Repos one `base_model` branch may visit before giving up on finding prose. */
|
|
58
|
+
const MAX_BASE_MODEL_DEPTH = 3;
|
|
59
|
+
const cardSummaryCache = new Map();
|
|
60
|
+
const inFlightSummaries = new Map();
|
|
61
|
+
/** A live cache entry, or null when absent or expired. Refreshes LRU recency. */
|
|
62
|
+
function readCachedSummary(key) {
|
|
63
|
+
const hit = cardSummaryCache.get(key);
|
|
64
|
+
if (!hit)
|
|
65
|
+
return null;
|
|
66
|
+
if (hit.expiresAt <= Date.now()) {
|
|
67
|
+
cardSummaryCache.delete(key);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
// Re-insert so the most recently read key is the youngest in iteration order.
|
|
71
|
+
cardSummaryCache.delete(key);
|
|
72
|
+
cardSummaryCache.set(key, hit);
|
|
73
|
+
return hit;
|
|
74
|
+
}
|
|
75
|
+
function writeCachedSummary(key, value) {
|
|
76
|
+
cardSummaryCache.delete(key);
|
|
77
|
+
cardSummaryCache.set(key, {
|
|
78
|
+
value,
|
|
79
|
+
expiresAt: Date.now() + (value === null ? SUMMARY_MISS_TTL_MS : SUMMARY_TTL_MS),
|
|
80
|
+
});
|
|
81
|
+
// Map iterates in insertion order, so the first key is the least recently used.
|
|
82
|
+
while (cardSummaryCache.size > SUMMARY_CACHE_MAX) {
|
|
83
|
+
const oldest = cardSummaryCache.keys().next();
|
|
84
|
+
if (oldest.done)
|
|
85
|
+
break;
|
|
86
|
+
cardSummaryCache.delete(oldest.value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Drop every cached summary. Exported for tests and for a token change, which
|
|
90
|
+
* can flip which repos are readable at all. */
|
|
91
|
+
export function clearCardSummaryCache() {
|
|
92
|
+
cardSummaryCache.clear();
|
|
93
|
+
inFlightSummaries.clear();
|
|
94
|
+
}
|
|
95
|
+
function cleanCardSummary(markdown) {
|
|
96
|
+
const body = markdown.replace(/^---\s*[\s\S]*?---\s*/u, "");
|
|
97
|
+
const paragraphs = body
|
|
98
|
+
.split(/\r?\n\s*\r?\n/u)
|
|
99
|
+
.map((paragraph) => paragraph
|
|
100
|
+
.replace(/<[^>]+>/gu, " ")
|
|
101
|
+
.replace(/!?(\[[^\]]*\]\([^)]*\))/gu, "$1")
|
|
102
|
+
.replace(/[>*#`_]/gu, " ")
|
|
103
|
+
.replace(/\s+/gu, " ")
|
|
104
|
+
.trim())
|
|
105
|
+
.filter((paragraph) => paragraph.length >= 80);
|
|
106
|
+
const useful = paragraphs.find((paragraph) => !/^(community model|special thanks|disclaimers|model creator|gguf quantization)/iu.test(paragraph) &&
|
|
107
|
+
!/lm studio community models highlights program|lm studio is not the creator/iu.test(paragraph));
|
|
108
|
+
if (!useful)
|
|
109
|
+
return null;
|
|
110
|
+
const sentences = useful.match(/[^.!?]+[.!?]+(?:\s|$)/gu) ?? [useful];
|
|
111
|
+
const summary = sentences.slice(0, 2).join(" ").trim();
|
|
112
|
+
return summary.length >= 80 ? summary.slice(0, 360).trim() : null;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A request the caller cannot proceed without, so a failure throws rather than
|
|
116
|
+
* reporting absence. It is still bounded: without a deadline a remote that
|
|
117
|
+
* accepts the connection and then goes quiet pins the RPC that awaits it open
|
|
118
|
+
* for as long as the socket lives, which is the whole reason the summary
|
|
119
|
+
* fetches carry one too.
|
|
120
|
+
*/
|
|
121
|
+
async function fetchHf(url, token, what) {
|
|
122
|
+
try {
|
|
123
|
+
return await fetch(url, {
|
|
124
|
+
headers: authHeaders(token),
|
|
125
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (cause) {
|
|
129
|
+
const timedOut = cause instanceof Error && cause.name === "TimeoutError";
|
|
130
|
+
throw new Error(timedOut
|
|
131
|
+
? `Hugging Face ${what} timed out after ${REQUEST_TIMEOUT_MS}ms`
|
|
132
|
+
: `Hugging Face ${what} could not reach the server`, { cause });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* One card fetch, bounded by its own timeout and by the caller's abort. Returns
|
|
137
|
+
* null on any failure: discovery reports absence rather than throwing, and a
|
|
138
|
+
* missing card is the overwhelmingly common case (most GGUF repos have none).
|
|
139
|
+
*/
|
|
140
|
+
async function fetchCard(url, token, signal) {
|
|
141
|
+
const timeout = AbortSignal.timeout(SUMMARY_FETCH_TIMEOUT_MS);
|
|
142
|
+
try {
|
|
143
|
+
return await fetch(url, {
|
|
144
|
+
headers: authHeaders(token),
|
|
145
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Resolve one repo's summary, following `base_model` when the repo has no card
|
|
154
|
+
* prose of its own. `chain` is every repo already being resolved on this branch,
|
|
155
|
+
* lowercased; it is both the cycle guard and the depth counter. Never consults
|
|
156
|
+
* the in-flight map (see the header note): the recursion only ever reads settled
|
|
157
|
+
* cache entries, so it cannot wait on another row's unfinished work.
|
|
158
|
+
*/
|
|
159
|
+
async function resolveCardSummary(repo, token, chain, signal) {
|
|
160
|
+
const key = repo.toLowerCase();
|
|
161
|
+
const cached = readCachedSummary(key);
|
|
162
|
+
if (cached)
|
|
163
|
+
return cached.value;
|
|
164
|
+
const readme = await fetchCard(`${HF_BASE}/${repo}/raw/main/README.md`, token, signal);
|
|
165
|
+
let summary = null;
|
|
166
|
+
if (readme?.ok) {
|
|
167
|
+
summary = cleanCardSummary(await readme.text().catch(() => ""));
|
|
168
|
+
}
|
|
169
|
+
// Only a depth cut makes the answer chain-specific: this repo might still
|
|
170
|
+
// have resolved from a shallower entry point, so that null must not be cached.
|
|
171
|
+
let truncated = false;
|
|
172
|
+
// Once the caller has given up, do not spend a second round trip chasing the
|
|
173
|
+
// base model: the answer can no longer be used.
|
|
174
|
+
if (summary === null && !signal?.aborted) {
|
|
175
|
+
if (chain.length >= MAX_BASE_MODEL_DEPTH) {
|
|
176
|
+
truncated = true;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// The repo names no prose of its own, so try the model it was quantized
|
|
180
|
+
// from. The `${HF_BASE}/` prefix is committed before the interpolation, so
|
|
181
|
+
// a hostile base_model cannot redirect this off huggingface.co and leak
|
|
182
|
+
// the token; keep that ordering if you touch these URLs.
|
|
183
|
+
const detail = await fetchCard(`${HF_BASE}/api/models/${repo}`, token, signal);
|
|
184
|
+
if (detail?.ok) {
|
|
185
|
+
const card = (await detail.json().catch(() => null));
|
|
186
|
+
const declared = card?.cardData?.base_model;
|
|
187
|
+
const baseModel = Array.isArray(declared) ? declared[0] : declared;
|
|
188
|
+
// Refusing a repo already on this branch is what breaks A -> B -> A.
|
|
189
|
+
// Caching the null is still correct here: a cycle carries no prose at
|
|
190
|
+
// any entry point, so every member resolves to null however it is
|
|
191
|
+
// reached.
|
|
192
|
+
const baseKey = baseModel?.toLowerCase();
|
|
193
|
+
if (baseModel && baseKey && !chain.includes(baseKey)) {
|
|
194
|
+
summary = await resolveCardSummary(baseModel, token, [...chain, baseKey], signal);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// An aborted branch resolved nothing because we stopped asking, not because
|
|
200
|
+
// the repo has no card. Caching that would let one slow search poison the next.
|
|
201
|
+
if (!truncated && !signal?.aborted)
|
|
202
|
+
writeCachedSummary(key, summary);
|
|
203
|
+
return summary;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Top-level entry: the only place the in-flight map is read, so duplicate repos
|
|
207
|
+
* in one result set share a fetch without ever forming a wait-for cycle.
|
|
208
|
+
*/
|
|
209
|
+
function modelCardSummary(repo, token, signal) {
|
|
210
|
+
const key = repo.toLowerCase();
|
|
211
|
+
const cached = readCachedSummary(key);
|
|
212
|
+
if (cached)
|
|
213
|
+
return Promise.resolve(cached.value);
|
|
214
|
+
const existing = inFlightSummaries.get(key);
|
|
215
|
+
if (existing)
|
|
216
|
+
return existing;
|
|
217
|
+
const pending = resolveCardSummary(repo, token, [key], signal)
|
|
218
|
+
.catch(() => null)
|
|
219
|
+
.finally(() => {
|
|
220
|
+
inFlightSummaries.delete(key);
|
|
221
|
+
});
|
|
222
|
+
inFlightSummaries.set(key, pending);
|
|
223
|
+
return pending;
|
|
224
|
+
}
|
|
225
|
+
async function mapWithConcurrency(values, limit, mapper) {
|
|
226
|
+
let cursor = 0;
|
|
227
|
+
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, async () => {
|
|
228
|
+
while (cursor < values.length) {
|
|
229
|
+
const index = cursor++;
|
|
230
|
+
await mapper(values[index], index);
|
|
231
|
+
}
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
25
234
|
/**
|
|
26
235
|
* Search Hugging Face for GGUF model repos, most-downloaded first. Returns a
|
|
27
236
|
* normalized shape both the TUI and the Otto app can render; drill into a result
|
|
28
237
|
* with {@link listRepoQuants} to see and download its quantizations.
|
|
238
|
+
*
|
|
239
|
+
* Card summaries are a *bounded* enrichment, never a gate on the result set. Each
|
|
240
|
+
* one costs one or two extra round trips, so the whole batch shares one budget
|
|
241
|
+
* (`summaryBudgetMs`, 0 to skip them entirely); rows that miss it come back with
|
|
242
|
+
* `summary: null`, which every consumer already treats as "not available". When
|
|
243
|
+
* the budget expires the outstanding fetches are aborted rather than left to run,
|
|
244
|
+
* so the one-shot `otto brain search --json` the daemon shells out to can exit
|
|
245
|
+
* immediately instead of lingering on open sockets.
|
|
29
246
|
*/
|
|
30
|
-
export async function searchModels(query, { limit = 25, token = null } = {}) {
|
|
247
|
+
export async function searchModels(query, { limit = 25, token = null, summaryBudgetMs = DEFAULT_SUMMARY_BUDGET_MS, } = {}) {
|
|
31
248
|
const params = new URLSearchParams({
|
|
32
249
|
search: query,
|
|
33
250
|
filter: "gguf",
|
|
@@ -35,14 +252,12 @@ export async function searchModels(query, { limit = 25, token = null } = {}) {
|
|
|
35
252
|
direction: "-1",
|
|
36
253
|
limit: String(limit),
|
|
37
254
|
});
|
|
38
|
-
const res = await
|
|
39
|
-
headers: authHeaders(token),
|
|
40
|
-
});
|
|
255
|
+
const res = await fetchHf(`${HF_BASE}/api/models?${params.toString()}`, token, "search");
|
|
41
256
|
if (!res.ok) {
|
|
42
257
|
throw new Error(`Hugging Face search failed (${res.status}) for "${query}"`);
|
|
43
258
|
}
|
|
44
259
|
const entries = (await res.json());
|
|
45
|
-
|
|
260
|
+
const rows = entries.map((entry) => ({
|
|
46
261
|
repo: entry.id,
|
|
47
262
|
author: entry.author ?? entry.id.split("/")[0] ?? "",
|
|
48
263
|
downloads: entry.downloads ?? 0,
|
|
@@ -50,6 +265,24 @@ export async function searchModels(query, { limit = 25, token = null } = {}) {
|
|
|
50
265
|
updatedAt: entry.lastModified ?? null,
|
|
51
266
|
gated: Boolean(entry.gated),
|
|
52
267
|
}));
|
|
268
|
+
const summaries = rows.map(() => null);
|
|
269
|
+
if (summaryBudgetMs > 0 && rows.length > 0) {
|
|
270
|
+
const controller = new AbortController();
|
|
271
|
+
const gather = mapWithConcurrency(rows, 4, async (row, index) => {
|
|
272
|
+
summaries[index] = await modelCardSummary(row.repo, token, controller.signal);
|
|
273
|
+
}).catch(() => undefined);
|
|
274
|
+
let expire;
|
|
275
|
+
const deadline = new Promise((resolve) => {
|
|
276
|
+
expire = setTimeout(resolve, summaryBudgetMs);
|
|
277
|
+
// Never let the budget timer itself be the reason a CLI run stays alive.
|
|
278
|
+
expire.unref?.();
|
|
279
|
+
});
|
|
280
|
+
await Promise.race([gather, deadline]);
|
|
281
|
+
if (expire)
|
|
282
|
+
clearTimeout(expire);
|
|
283
|
+
controller.abort();
|
|
284
|
+
}
|
|
285
|
+
return rows.map((row, index) => ({ ...row, summary: summaries[index] ?? null }));
|
|
53
286
|
}
|
|
54
287
|
/**
|
|
55
288
|
* Quality order for display: higher bits-per-weight is more faithful and larger.
|
|
@@ -77,7 +310,7 @@ export function quantRank(quant) {
|
|
|
77
310
|
*/
|
|
78
311
|
export async function listRepoQuants(repo, token = null) {
|
|
79
312
|
const url = `${HF_BASE}/api/models/${repo}/tree/main?recursive=true`;
|
|
80
|
-
const res = await
|
|
313
|
+
const res = await fetchHf(url, token, `listing for ${repo}`);
|
|
81
314
|
if (!res.ok) {
|
|
82
315
|
throw new Error(`Hugging Face listing failed (${res.status}) for ${repo}`);
|
|
83
316
|
}
|
package/dist/models/index.d.ts
CHANGED
|
@@ -3,9 +3,9 @@ import type { Model } from "../types.js";
|
|
|
3
3
|
export * from "./scan.js";
|
|
4
4
|
export { pickModel, pickAutoModel } from "./pick.js";
|
|
5
5
|
export { resolveModelsDirs, managedModelsDir, type ModelsDir } from "./dirs.js";
|
|
6
|
-
export { pullModel, downloadRepoFiles, type PullOptions, type PullProgress, type DownloadFilesOptions, } from "./download.js";
|
|
6
|
+
export { pullModel, bundleDownloadPlan, downloadRepoFiles, type PullOptions, type PullProgress, type DownloadFilesOptions, } from "./download.js";
|
|
7
7
|
export { matchCatalogEntry, enrichWithCatalog } from "./enrich.js";
|
|
8
|
-
export { resolveHfToken, listRepoQuants, searchModels, repoOfModel, quantRank, type QuantOption, type RepoQuants, type ModelSearchResult, } from "./hf.js";
|
|
8
|
+
export { resolveHfToken, listRepoQuants, searchModels, clearCardSummaryCache, repoOfModel, quantRank, type QuantOption, type RepoQuants, type ModelSearchResult, } from "./hf.js";
|
|
9
9
|
export { diskUsage, totalModelBytes, planDelete, deleteModelFiles, type DiskUsage, type DeletePlan, } from "./manage.js";
|
|
10
10
|
export interface ScanModelsOptions {
|
|
11
11
|
withMetadata?: boolean;
|
package/dist/models/index.js
CHANGED
|
@@ -9,14 +9,17 @@ import { scan } from "./scan.js";
|
|
|
9
9
|
export * from "./scan.js";
|
|
10
10
|
export { pickModel, pickAutoModel } from "./pick.js";
|
|
11
11
|
export { resolveModelsDirs, managedModelsDir } from "./dirs.js";
|
|
12
|
-
export { pullModel, downloadRepoFiles, } from "./download.js";
|
|
12
|
+
export { pullModel, bundleDownloadPlan, downloadRepoFiles, } from "./download.js";
|
|
13
13
|
export { matchCatalogEntry, enrichWithCatalog } from "./enrich.js";
|
|
14
|
-
export { resolveHfToken, listRepoQuants, searchModels, repoOfModel, quantRank, } from "./hf.js";
|
|
14
|
+
export { resolveHfToken, listRepoQuants, searchModels, clearCardSummaryCache, repoOfModel, quantRank, } from "./hf.js";
|
|
15
15
|
export { diskUsage, totalModelBytes, planDelete, deleteModelFiles, } from "./manage.js";
|
|
16
16
|
/** Scan every configured models directory (managed ∪ LM Studio), de-duplicated. */
|
|
17
17
|
export function scanModels(config, env = process.env, options = {}) {
|
|
18
18
|
const dirs = resolveModelsDirs(config, env);
|
|
19
|
-
|
|
19
|
+
// `Model.id` is the stable repo-relative artifact identity. The same file
|
|
20
|
+
// can exist in the managed store and LM Studio's store at different absolute
|
|
21
|
+
// paths; managed is scanned first and deliberately owns that collision.
|
|
22
|
+
const seenIds = new Set();
|
|
20
23
|
const all = [];
|
|
21
24
|
for (const { dir, origin } of dirs) {
|
|
22
25
|
for (const model of scan({
|
|
@@ -24,9 +27,9 @@ export function scanModels(config, env = process.env, options = {}) {
|
|
|
24
27
|
withMetadata: options.withMetadata ?? true,
|
|
25
28
|
origin,
|
|
26
29
|
})) {
|
|
27
|
-
if (
|
|
30
|
+
if (seenIds.has(model.id))
|
|
28
31
|
continue;
|
|
29
|
-
|
|
32
|
+
seenIds.add(model.id);
|
|
30
33
|
all.push(model);
|
|
31
34
|
}
|
|
32
35
|
}
|
package/dist/models/manage.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface DeletePlan {
|
|
|
15
15
|
files: string[];
|
|
16
16
|
bytes: number;
|
|
17
17
|
includesProjector: boolean;
|
|
18
|
+
componentIds: string[];
|
|
18
19
|
}
|
|
19
20
|
/**
|
|
20
21
|
* Work out exactly which files deleting a model removes: its GGUF (all shards),
|
|
@@ -24,4 +25,7 @@ export interface DeletePlan {
|
|
|
24
25
|
export declare function planDelete(model: Model): DeletePlan;
|
|
25
26
|
/** Delete the files a {@link planDelete} chose, plus a now-empty repo directory. */
|
|
26
27
|
export declare function deleteModelFiles(model: Model): DeletePlan;
|
|
28
|
+
/** Remove one optional bundle artifact. The primary model is never accepted,
|
|
29
|
+
* and a loaded profile may not lose a component underneath llama.cpp. */
|
|
30
|
+
export declare function deleteComponentFile(model: Model, componentId: string): DeletePlan;
|
|
27
31
|
//# sourceMappingURL=manage.d.ts.map
|
package/dist/models/manage.js
CHANGED
|
@@ -66,7 +66,7 @@ export function planDelete(model) {
|
|
|
66
66
|
// The projector is shared across the repo's quants; only remove it if this was
|
|
67
67
|
// the last model GGUF in the directory.
|
|
68
68
|
let includesProjector = false;
|
|
69
|
-
if (model.mmprojPath) {
|
|
69
|
+
if (model.mmprojPath || model.components?.length) {
|
|
70
70
|
const dir = path.dirname(model.modelPath);
|
|
71
71
|
const doomed = new Set(files.map((f) => path.resolve(f)));
|
|
72
72
|
let othersRemain = false;
|
|
@@ -86,11 +86,24 @@ export function planDelete(model) {
|
|
|
86
86
|
othersRemain = true; // be conservative: keep the projector if unsure
|
|
87
87
|
}
|
|
88
88
|
if (!othersRemain) {
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
if (model.mmprojPath) {
|
|
90
|
+
add(model.mmprojPath);
|
|
91
|
+
includesProjector = true;
|
|
92
|
+
}
|
|
93
|
+
for (const component of model.components ?? []) {
|
|
94
|
+
if (component.path && !files.includes(component.path))
|
|
95
|
+
add(component.path);
|
|
96
|
+
}
|
|
91
97
|
}
|
|
92
98
|
}
|
|
93
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
files,
|
|
101
|
+
bytes,
|
|
102
|
+
includesProjector,
|
|
103
|
+
componentIds: (model.components ?? [])
|
|
104
|
+
.filter((component) => component.path && files.includes(component.path))
|
|
105
|
+
.map((component) => component.id),
|
|
106
|
+
};
|
|
94
107
|
}
|
|
95
108
|
/** Delete the files a {@link planDelete} chose, plus a now-empty repo directory. */
|
|
96
109
|
export function deleteModelFiles(model) {
|
|
@@ -109,4 +122,21 @@ export function deleteModelFiles(model) {
|
|
|
109
122
|
}
|
|
110
123
|
return plan;
|
|
111
124
|
}
|
|
125
|
+
/** Remove one optional bundle artifact. The primary model is never accepted,
|
|
126
|
+
* and a loaded profile may not lose a component underneath llama.cpp. */
|
|
127
|
+
export function deleteComponentFile(model, componentId) {
|
|
128
|
+
const component = model.components?.find((candidate) => candidate.id === componentId);
|
|
129
|
+
if (!component || !component.path)
|
|
130
|
+
throw new Error(`component "${componentId}" is not installed`);
|
|
131
|
+
if (component.required)
|
|
132
|
+
throw new Error("required bundle components cannot be removed separately");
|
|
133
|
+
const bytes = fs.statSync(component.path).size;
|
|
134
|
+
fs.rmSync(component.path, { force: true });
|
|
135
|
+
return {
|
|
136
|
+
files: [component.path],
|
|
137
|
+
bytes,
|
|
138
|
+
includesProjector: component.role === "vision_projector",
|
|
139
|
+
componentIds: [component.id],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
112
142
|
//# sourceMappingURL=manage.js.map
|
package/dist/models/scan.js
CHANGED
|
@@ -9,42 +9,11 @@ import os from "node:os";
|
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import * as gguf from "../gguf.js";
|
|
11
11
|
export const LMSTUDIO_MODELS_DIR = path.join(os.homedir(), ".lmstudio", "models");
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"IQ2_XXS",
|
|
18
|
-
"IQ2_XS",
|
|
19
|
-
"IQ2_S",
|
|
20
|
-
"IQ2_M",
|
|
21
|
-
"IQ3_XXS",
|
|
22
|
-
"IQ3_XS",
|
|
23
|
-
"IQ3_S",
|
|
24
|
-
"IQ3_M",
|
|
25
|
-
"IQ4_XS",
|
|
26
|
-
"IQ4_NL",
|
|
27
|
-
"Q2_K_S",
|
|
28
|
-
"Q2_K",
|
|
29
|
-
"Q3_K_S",
|
|
30
|
-
"Q3_K_M",
|
|
31
|
-
"Q3_K_L",
|
|
32
|
-
"Q4_K_S",
|
|
33
|
-
"Q4_K_M",
|
|
34
|
-
"Q5_K_S",
|
|
35
|
-
"Q5_K_M",
|
|
36
|
-
"Q6_K",
|
|
37
|
-
"Q8_0",
|
|
38
|
-
"Q4_0",
|
|
39
|
-
"Q4_1",
|
|
40
|
-
"Q5_0",
|
|
41
|
-
"Q5_1",
|
|
42
|
-
"NVFP4",
|
|
43
|
-
"MXFP4",
|
|
44
|
-
"BF16",
|
|
45
|
-
"F16",
|
|
46
|
-
"F32",
|
|
47
|
-
];
|
|
12
|
+
// The quant is the terminal GGUF filename suffix, optionally preceded by a
|
|
13
|
+
// source-defined qualifier such as Muse's `UD-`. Matching a known substring
|
|
14
|
+
// truncates newer labels (for example Q2_K_XL -> Q2_K) and misses them when
|
|
15
|
+
// their exact spelling is not in a hard-coded list.
|
|
16
|
+
const QUANT_SUFFIX = /(?:^|[-_])((?:UD-)?(?:IQ[1-4]|Q[2-8])(?:_[A-Z0-9]+)*|NVFP\d+|MXFP\d+|BF16|F16|F32)(?:-(?:MTP|IMATRIX|DISTILL))?(?:-\d{5}-OF-\d{5})?\.GGUF$/i;
|
|
48
17
|
const MULTIPART = /-(\d{5})-of-(\d{5})\.gguf$/i;
|
|
49
18
|
function walk(dir, out = []) {
|
|
50
19
|
let entries;
|
|
@@ -64,12 +33,9 @@ function walk(dir, out = []) {
|
|
|
64
33
|
return out;
|
|
65
34
|
}
|
|
66
35
|
export function detectQuant(filename) {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return pattern;
|
|
71
|
-
}
|
|
72
|
-
return null;
|
|
36
|
+
const match = path.basename(filename).match(QUANT_SUFFIX);
|
|
37
|
+
// `UD-` is a source filename qualifier, not part of the user-facing quant.
|
|
38
|
+
return match?.[1]?.replace(/^UD-/i, "").toUpperCase() ?? null;
|
|
73
39
|
}
|
|
74
40
|
export function isProjectorFile(filename) {
|
|
75
41
|
return /^mmproj/i.test(path.basename(filename));
|
package/dist/ops/calibrate.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Supervisor } from "../service/supervisor.js";
|
|
1
2
|
import type { Model, Runtime } from "../types.js";
|
|
2
3
|
import type { Profile } from "../config/schema.js";
|
|
3
4
|
/**
|
|
@@ -31,6 +32,8 @@ export interface CalibrateOptions {
|
|
|
31
32
|
profile: Profile;
|
|
32
33
|
samples?: number[];
|
|
33
34
|
internalPort?: number;
|
|
35
|
+
/** Reuse the host's resident supervisor instead of creating a sidecar server. */
|
|
36
|
+
supervisor?: Supervisor;
|
|
34
37
|
onProgress?: (event: CalibrateProgress) => void;
|
|
35
38
|
}
|
|
36
39
|
/** The measured KV-cache profile calibration produces. */
|
|
@@ -45,5 +48,5 @@ export interface CalibrationMeasurement {
|
|
|
45
48
|
vision: boolean;
|
|
46
49
|
measuredAt: string;
|
|
47
50
|
}
|
|
48
|
-
export declare function calibrate({ runtime, model, profile, samples, internalPort, onProgress, }: CalibrateOptions): Promise<CalibrationMeasurement>;
|
|
51
|
+
export declare function calibrate({ runtime, model, profile, samples, internalPort, supervisor: optionsSupervisor, onProgress, }: CalibrateOptions): Promise<CalibrationMeasurement>;
|
|
49
52
|
//# sourceMappingURL=calibrate.d.ts.map
|
package/dist/ops/calibrate.js
CHANGED
|
@@ -12,21 +12,24 @@ import { DEFAULT_INTERNAL_PORT, Supervisor } from "../service/supervisor.js";
|
|
|
12
12
|
* buffers) cancels out of the difference.
|
|
13
13
|
*/
|
|
14
14
|
export const DEFAULT_SAMPLES = [8192, 65536];
|
|
15
|
-
export async function calibrate({ runtime, model, profile, samples
|
|
16
|
-
|
|
15
|
+
export async function calibrate({ runtime, model, profile, samples, internalPort = DEFAULT_INTERNAL_PORT + 1, supervisor: optionsSupervisor, onProgress = () => { }, }) {
|
|
16
|
+
const nativeContext = model.metadata?.contextLength ?? null;
|
|
17
|
+
const effectiveSamples = samples ??
|
|
18
|
+
(nativeContext ? [8192, nativeContext * profile.contextMultiplier] : DEFAULT_SAMPLES);
|
|
19
|
+
if (effectiveSamples.length < 2)
|
|
17
20
|
throw new Error("calibration needs at least two context sizes");
|
|
18
|
-
const native =
|
|
21
|
+
const native = (nativeContext || Math.max(...effectiveSamples)) * profile.contextMultiplier;
|
|
19
22
|
const points = [];
|
|
20
|
-
for (const contextSize of
|
|
23
|
+
for (const contextSize of effectiveSamples) {
|
|
21
24
|
if (contextSize > native) {
|
|
22
|
-
onProgress({ phase: "skip", contextSize, reason: `exceeds
|
|
25
|
+
onProgress({ phase: "skip", contextSize, reason: `exceeds configured context ${native}` });
|
|
23
26
|
continue;
|
|
24
27
|
}
|
|
25
|
-
const supervisor = new Supervisor({ runtime, internalPort });
|
|
28
|
+
const supervisor = optionsSupervisor ?? new Supervisor({ runtime, internalPort });
|
|
26
29
|
onProgress({ phase: "loading", contextSize });
|
|
27
30
|
const baseline = await usedBytes();
|
|
28
31
|
try {
|
|
29
|
-
await supervisor.start(model, { ...profile, contextSize });
|
|
32
|
+
await supervisor.start(model, { ...profile, contextSize }, { preserveLogs: Boolean(optionsSupervisor) });
|
|
30
33
|
const used = supervisor.vramAtReadyBytes ?? (await usedBytes());
|
|
31
34
|
const delta = Number(used) - Number(supervisor.vramBaselineBytes ?? baseline);
|
|
32
35
|
points.push({ contextSize, deltaBytes: delta, loadSeconds: supervisor.loadSeconds });
|
package/dist/ops/sweep.d.ts
CHANGED
|
@@ -70,8 +70,10 @@ export interface SweepOptions {
|
|
|
70
70
|
maxTokens?: number;
|
|
71
71
|
temperature?: number;
|
|
72
72
|
internalPort?: number;
|
|
73
|
+
/** Reuse the host's resident supervisor instead of creating a sidecar server. */
|
|
74
|
+
supervisor?: Supervisor;
|
|
73
75
|
onProgress?: (event: SweepProgress) => void;
|
|
74
76
|
}
|
|
75
|
-
export declare function sweep({ runtime, model, profile, budgets, maxTokens, temperature, internalPort, onProgress, }: SweepOptions): Promise<SweepReport>;
|
|
77
|
+
export declare function sweep({ runtime, model, profile, budgets, maxTokens, temperature, internalPort, supervisor: optionsSupervisor, onProgress, }: SweepOptions): Promise<SweepReport>;
|
|
76
78
|
export {};
|
|
77
79
|
//# sourceMappingURL=sweep.d.ts.map
|
package/dist/ops/sweep.js
CHANGED
|
@@ -81,13 +81,13 @@ export async function runTrial({ supervisor, maxTokens, temperature, }) {
|
|
|
81
81
|
contentPerSecond: elapsedSeconds > 0 ? content.length / elapsedSeconds : 0,
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
|
-
export async function sweep({ runtime, model, profile, budgets = DEFAULT_BUDGETS, maxTokens = 8192, temperature = 0.7, internalPort = DEFAULT_INTERNAL_PORT + 2, onProgress = () => { }, }) {
|
|
84
|
+
export async function sweep({ runtime, model, profile, budgets = DEFAULT_BUDGETS, maxTokens = 8192, temperature = 0.7, internalPort = DEFAULT_INTERNAL_PORT + 2, supervisor: optionsSupervisor, onProgress = () => { }, }) {
|
|
85
85
|
const results = [];
|
|
86
86
|
for (const budget of budgets) {
|
|
87
|
-
const supervisor = new Supervisor({ runtime, internalPort });
|
|
87
|
+
const supervisor = optionsSupervisor ?? new Supervisor({ runtime, internalPort });
|
|
88
88
|
onProgress({ phase: "loading", budget });
|
|
89
89
|
try {
|
|
90
|
-
await supervisor.start(model, { ...profile, reasoningBudget: budget });
|
|
90
|
+
await supervisor.start(model, { ...profile, reasoningBudget: budget }, { preserveLogs: Boolean(optionsSupervisor) });
|
|
91
91
|
onProgress({ phase: "generating", budget });
|
|
92
92
|
const trial = await runTrial({ supervisor, maxTokens, temperature });
|
|
93
93
|
results.push({ budget, ...trial, error: null });
|
package/dist/runtime/args.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* managed one, since both resolve to a `Runtime` (exe + optional vendorDir).
|
|
5
5
|
*/
|
|
6
6
|
import type { Profile } from "../config/schema.js";
|
|
7
|
-
import type { Runtime } from "../types.js";
|
|
7
|
+
import type { Model, Runtime } from "../types.js";
|
|
8
8
|
export interface ServeTarget {
|
|
9
9
|
port: number;
|
|
10
10
|
host?: string;
|
|
@@ -28,7 +28,7 @@ export declare function buildEnv(runtime: Runtime, baseEnv?: NodeJS.ProcessEnv,
|
|
|
28
28
|
* Only settings that demonstrably matter for stable local inference are emitted -
|
|
29
29
|
* no experimental sampler knobs.
|
|
30
30
|
*/
|
|
31
|
-
export declare function buildArgs(profile: Profile, { port, host }: ServeTarget): string[];
|
|
31
|
+
export declare function buildArgs(profile: Profile, { port, host }: ServeTarget, model?: Model): string[];
|
|
32
32
|
/** The same command as a copy-pasteable shell line, for the TUI to display. */
|
|
33
33
|
export declare function formatCommand(runtime: Runtime, args: string[]): string;
|
|
34
34
|
//# sourceMappingURL=args.d.ts.map
|
package/dist/runtime/args.js
CHANGED
|
@@ -29,7 +29,7 @@ export function buildEnv(runtime, baseEnv = process.env, platform = process.plat
|
|
|
29
29
|
* Only settings that demonstrably matter for stable local inference are emitted -
|
|
30
30
|
* no experimental sampler knobs.
|
|
31
31
|
*/
|
|
32
|
-
export function buildArgs(profile, { port, host = "127.0.0.1" }) {
|
|
32
|
+
export function buildArgs(profile, { port, host = "127.0.0.1" }, model) {
|
|
33
33
|
const args = [
|
|
34
34
|
"-m",
|
|
35
35
|
profile.modelPath ?? "",
|
|
@@ -52,6 +52,11 @@ export function buildArgs(profile, { port, host = "127.0.0.1" }) {
|
|
|
52
52
|
if (profile.vision && profile.mmprojPath) {
|
|
53
53
|
args.push("--mmproj", profile.mmprojPath);
|
|
54
54
|
}
|
|
55
|
+
// Component paths were resolved by the host from the catalog manifest. Only
|
|
56
|
+
// the known llama.cpp role is emitted; clients never supply process paths.
|
|
57
|
+
const drafter = profile.componentPaths?.speculative_drafter;
|
|
58
|
+
if (drafter)
|
|
59
|
+
args.push("--model-draft", drafter);
|
|
55
60
|
// The setting that was actually breaking long agentic runs.
|
|
56
61
|
if (profile.reasoningBudget !== null && profile.reasoningBudget !== undefined) {
|
|
57
62
|
args.push("--reasoning-budget", String(profile.reasoningBudget));
|
|
@@ -61,10 +66,22 @@ export function buildArgs(profile, { port, host = "127.0.0.1" }) {
|
|
|
61
66
|
}
|
|
62
67
|
if (profile.parallelSlots)
|
|
63
68
|
args.push("--parallel", String(profile.parallelSlots));
|
|
69
|
+
if (profile.contextMultiplier > 1) {
|
|
70
|
+
const nativeContext = model?.metadata?.contextLength;
|
|
71
|
+
args.push("--rope-scaling", "yarn", "--rope-scale", String(profile.contextMultiplier), ...(nativeContext ? ["--yarn-orig-ctx", String(nativeContext)] : []), ...(model?.metadata?.arch
|
|
72
|
+
? ["--override-kv", `${model.metadata.arch}.context_length=int:${profile.contextSize}`]
|
|
73
|
+
: []));
|
|
74
|
+
}
|
|
64
75
|
if (profile.batchSize)
|
|
65
76
|
args.push("-b", String(profile.batchSize));
|
|
66
77
|
if (profile.ubatchSize)
|
|
67
78
|
args.push("-ub", String(profile.ubatchSize));
|
|
79
|
+
if (profile.chatTemplateFile) {
|
|
80
|
+
args.push("--chat-template-file", profile.chatTemplateFile);
|
|
81
|
+
if (Object.keys(profile.chatTemplateKwargs ?? {}).length > 0) {
|
|
82
|
+
args.push("--chat-template-kwargs", JSON.stringify(profile.chatTemplateKwargs));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
68
85
|
if (profile.extraArgs && profile.extraArgs.length)
|
|
69
86
|
args.push(...profile.extraArgs);
|
|
70
87
|
return args;
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Runtime } from "../types.js";
|
|
|
3
3
|
import { type InstallProgress, type RuntimeTarget } from "./managed.js";
|
|
4
4
|
export { BACKENDS_DIR, LMSTUDIO_ROOT, listRuntimes as listLmStudioRuntimes } from "./lmstudio.js";
|
|
5
5
|
export { buildArgs, buildEnv, formatCommand, type ServeTarget } from "./args.js";
|
|
6
|
-
export { installManagedRuntime, listManagedRuntimes, listRuntimeDevices, verifyRuntimeExecutable, defaultRuntimeSpec, extractArchive, resolveRuntimeVariant, serverExeName, supportedVariants, DEFAULT_LLAMA_BUILD, type RuntimeSpec, type RuntimeTarget, type RuntimeVariant, type InstallProgress, } from "./managed.js";
|
|
6
|
+
export { installManagedRuntime, removeManagedRuntime, listManagedRuntimes, listRuntimeDevices, verifyRuntimeExecutable, defaultRuntimeSpec, extractArchive, resolveRuntimeVariant, serverExeName, supportedVariants, DEFAULT_LLAMA_BUILD, listRuntimeReleases, latestRuntimeBuild, resolveLatestBuildOrPin, MissingAssetError, type ResolvedBuild, type RuntimeRelease, type RuntimeSpec, type RuntimeTarget, type RuntimeVariant, type InstallProgress, } from "./managed.js";
|
|
7
7
|
/** Every runtime available on this machine, managed first then LM Studio. */
|
|
8
8
|
export declare function listAllRuntimes(env?: NodeJS.ProcessEnv): Runtime[];
|
|
9
9
|
/**
|
|
@@ -14,6 +14,13 @@ export declare function listAllRuntimes(env?: NodeJS.ProcessEnv): Runtime[];
|
|
|
14
14
|
export declare function probeNvidiaGpu(): Promise<boolean>;
|
|
15
15
|
/** The runtime to use given config, or null when none is available. */
|
|
16
16
|
export declare function resolveRuntime(config: BrainConfig, env?: NodeJS.ProcessEnv): Runtime | null;
|
|
17
|
+
/**
|
|
18
|
+
* The numeric llama.cpp build carried by a resolved runtime, when its source
|
|
19
|
+
* identifies one. LM Studio and explicit overrides need not expose their
|
|
20
|
+
* upstream build, so callers must treat null as incompatible with a component
|
|
21
|
+
* that declares a minimum build rather than guessing compatibility.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runtimeBuild(runtime: Runtime | null | undefined): number | null;
|
|
17
24
|
/** Ensure a runtime exists, downloading the default managed build if none does. */
|
|
18
25
|
export declare function ensureRuntime(config: BrainConfig, env?: NodeJS.ProcessEnv, onProgress?: (progress: InstallProgress) => void, target?: RuntimeTarget): Promise<Runtime>;
|
|
19
26
|
//# sourceMappingURL=index.d.ts.map
|