@otto-code/brain 0.8.6 → 0.8.8
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/catalog.d.ts +2 -0
- package/dist/commands/catalog.js +1 -0
- package/dist/commands/pull.d.ts +1 -0
- package/dist/commands/pull.js +55 -3
- 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 +36 -7
- package/dist/config/profile-edit.d.ts +1 -1
- package/dist/config/profile-edit.js +40 -10
- package/dist/config/profiles.js +69 -13
- package/dist/config/schema.d.ts +528 -0
- package/dist/config/schema.js +24 -0
- package/dist/config/store.js +16 -14
- package/dist/models/download.d.ts +7 -0
- package/dist/models/download.js +160 -17
- package/dist/models/enrich.d.ts +0 -19
- package/dist/models/enrich.js +95 -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/runtime/args.js +5 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/managed.d.ts +40 -0
- package/dist/runtime/managed.js +146 -7
- package/dist/service/host-api.d.ts +3 -2
- package/dist/service/host-api.js +118 -8
- package/dist/service/serve.js +39 -12
- package/dist/types.d.ts +20 -0
- package/dist/vram.d.ts +3 -0
- package/dist/vram.js +21 -3
- package/package.json +1 -1
package/dist/config/schema.js
CHANGED
|
@@ -12,6 +12,10 @@ export const ProfileSchema = z
|
|
|
12
12
|
modelId: z.string().nullable().default(null),
|
|
13
13
|
modelPath: z.string().nullable().default(null),
|
|
14
14
|
mmprojPath: z.string().nullable().default(null),
|
|
15
|
+
/** Stable bundle component ids enabled for this load. Paths are re-derived. */
|
|
16
|
+
enabledComponents: z.array(z.string()).default([]),
|
|
17
|
+
/** Derived component paths for the launcher; never accepted from clients. */
|
|
18
|
+
componentPaths: z.record(z.string()).default({}),
|
|
15
19
|
contextSize: z.number(),
|
|
16
20
|
cacheTypeK: z.string().default("q8_0"),
|
|
17
21
|
cacheTypeV: z.string().default("q8_0"),
|
|
@@ -162,6 +166,8 @@ export const DEFAULT_BRAIN_CONFIG = BrainConfigSchema.parse({});
|
|
|
162
166
|
export const CatalogModelSchema = z
|
|
163
167
|
.object({
|
|
164
168
|
id: z.string(),
|
|
169
|
+
/** Retired Otto-curated ids this canonical catalog entry replaces. */
|
|
170
|
+
replaces: z.array(z.string()).optional(),
|
|
165
171
|
name: z.string(),
|
|
166
172
|
publisher: z.string().optional(),
|
|
167
173
|
hfRepo: z.string(),
|
|
@@ -178,6 +184,24 @@ export const CatalogModelSchema = z
|
|
|
178
184
|
tier: z.string().optional(),
|
|
179
185
|
why: z.string().optional(),
|
|
180
186
|
status: z.string().optional(),
|
|
187
|
+
/** Declared only for multi-artifact model bundles. Plain models omit it. */
|
|
188
|
+
components: z
|
|
189
|
+
.array(z
|
|
190
|
+
.object({
|
|
191
|
+
id: z.string(),
|
|
192
|
+
label: z.string(),
|
|
193
|
+
description: z.string(),
|
|
194
|
+
role: z.enum(["vision_projector", "speculative_drafter"]),
|
|
195
|
+
hfRepo: z.string().optional(),
|
|
196
|
+
file: z.string(),
|
|
197
|
+
bytes: z.number().nullable().optional(),
|
|
198
|
+
required: z.boolean().default(false),
|
|
199
|
+
defaultDownload: z.boolean().default(false),
|
|
200
|
+
defaultLoad: z.boolean().default(false),
|
|
201
|
+
minRuntimeBuild: z.number().optional(),
|
|
202
|
+
})
|
|
203
|
+
.strict())
|
|
204
|
+
.optional(),
|
|
181
205
|
})
|
|
182
206
|
.passthrough();
|
|
183
207
|
export const CatalogSchema = z
|
package/dist/config/store.js
CHANGED
|
@@ -43,23 +43,25 @@ export function saveProfilesStore(store, paths = resolveBrainPaths()) {
|
|
|
43
43
|
export function loadCatalog(paths = resolveBrainPaths()) {
|
|
44
44
|
const current = readJson(paths.catalogFile, CatalogSchema);
|
|
45
45
|
if (current) {
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// catalog
|
|
46
|
+
// Curated entries are Otto-owned product copy. Replace every entry whose
|
|
47
|
+
// stable download id ships in the seed so catalog corrections reach every
|
|
48
|
+
// existing Brain home after an upgrade. Keep entries with unknown ids: they
|
|
49
|
+
// are the only user-owned catalog records and must survive product updates.
|
|
50
|
+
//
|
|
51
|
+
// Do not merge field-by-field. A partial merge leaves old names or stale
|
|
52
|
+
// descriptions behind indefinitely, which is precisely what a catalog
|
|
53
|
+
// migration is meant to prevent.
|
|
50
54
|
const legacy = readJson(path.join(packageRoot(), "config", "downloads.json"), CatalogSchema);
|
|
51
55
|
if (!legacy)
|
|
52
56
|
return current;
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return model;
|
|
62
|
-
});
|
|
57
|
+
const seedIds = new Set(legacy.models.map((model) => model.id));
|
|
58
|
+
// A source repository can change while the underlying curated model stays
|
|
59
|
+
// the same. Those retired ids are product-owned too: drop them rather than
|
|
60
|
+
// displaying an obsolete duplicate beside its canonical replacement.
|
|
61
|
+
const retiredSeedIds = new Set(legacy.models.flatMap((model) => model.replaces ?? []));
|
|
62
|
+
const userModels = current.models.filter((model) => !seedIds.has(model.id) && !retiredSeedIds.has(model.id));
|
|
63
|
+
const models = [...legacy.models, ...userModels];
|
|
64
|
+
const changed = JSON.stringify(models) !== JSON.stringify(current.models);
|
|
63
65
|
if (!changed)
|
|
64
66
|
return current;
|
|
65
67
|
const merged = { ...current, models };
|
|
@@ -11,6 +11,13 @@ export interface PullOptions {
|
|
|
11
11
|
token?: string | null;
|
|
12
12
|
onProgress?: (progress: PullProgress) => void;
|
|
13
13
|
}
|
|
14
|
+
/** Exact, manifest-driven files for a bundle selection. No quant discovery is
|
|
15
|
+
* involved, so a component pull can never select an arbitrary projector. */
|
|
16
|
+
export declare function bundleDownloadPlan(model: CatalogModel, componentIds?: string[], primaryFiles?: string[], primaryBytes?: number): {
|
|
17
|
+
repo: string;
|
|
18
|
+
files: string[];
|
|
19
|
+
totalBytes: number | null;
|
|
20
|
+
};
|
|
14
21
|
/** Download the model file; returns the local path it was written to. */
|
|
15
22
|
export declare function pullModel({ model, destRoot, file, token, onProgress, }: PullOptions): Promise<string>;
|
|
16
23
|
export interface DownloadFilesOptions {
|
package/dist/models/download.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `<managedModelsDir>/<publisher>/<repo>/<file>` to mirror the LM Studio layout
|
|
5
5
|
* the scanner already understands.
|
|
6
6
|
*/
|
|
7
|
-
import { createWriteStream, existsSync, mkdirSync, rmSync } from "node:fs";
|
|
7
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { Readable } from "node:stream";
|
|
10
10
|
import { pipeline } from "node:stream/promises";
|
|
@@ -35,41 +35,184 @@ function deriveFileFromId(id) {
|
|
|
35
35
|
function authHeaders(token) {
|
|
36
36
|
return token ? { authorization: `Bearer ${token}` } : {};
|
|
37
37
|
}
|
|
38
|
+
function partialMetadataPath(tmp) {
|
|
39
|
+
return `${tmp}.json`;
|
|
40
|
+
}
|
|
41
|
+
function clearPartial(tmp) {
|
|
42
|
+
rmSync(tmp, { force: true });
|
|
43
|
+
rmSync(partialMetadataPath(tmp), { force: true });
|
|
44
|
+
}
|
|
45
|
+
/** Read only a partial whose source identity and total length were recorded. */
|
|
46
|
+
function resumablePartial(tmp) {
|
|
47
|
+
try {
|
|
48
|
+
const bytes = statSync(tmp).size;
|
|
49
|
+
const metadata = JSON.parse(readFileSync(partialMetadataPath(tmp), "utf8"));
|
|
50
|
+
if (bytes <= 0 ||
|
|
51
|
+
!metadata.etag ||
|
|
52
|
+
!Number.isSafeInteger(metadata.totalBytes) ||
|
|
53
|
+
metadata.totalBytes <= bytes) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return { bytes, metadata };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function contentLength(response) {
|
|
63
|
+
const length = Number(response.headers.get("content-length"));
|
|
64
|
+
return Number.isSafeInteger(length) && length >= 0 ? length : null;
|
|
65
|
+
}
|
|
66
|
+
function isMatchingRangeResponse(response, partial) {
|
|
67
|
+
if (response.status !== 206)
|
|
68
|
+
return false;
|
|
69
|
+
const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get("content-range") ?? "");
|
|
70
|
+
if (!match)
|
|
71
|
+
return false;
|
|
72
|
+
const start = Number(match[1]);
|
|
73
|
+
const end = Number(match[2]);
|
|
74
|
+
const totalBytes = Number(match[3]);
|
|
75
|
+
const length = contentLength(response);
|
|
76
|
+
const etag = response.headers.get("etag");
|
|
77
|
+
return (start === partial.bytes &&
|
|
78
|
+
end >= start &&
|
|
79
|
+
totalBytes === partial.metadata.totalBytes &&
|
|
80
|
+
length === end - start + 1 &&
|
|
81
|
+
(!etag || etag === partial.metadata.etag));
|
|
82
|
+
}
|
|
83
|
+
function writePartialMetadata(tmp, metadata) {
|
|
84
|
+
writeFileSync(partialMetadataPath(tmp), JSON.stringify(metadata), {
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
mode: 0o600,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
38
89
|
/**
|
|
39
90
|
* Stream one HF file to `destPath`, reporting bytes received. Skips (returns
|
|
40
|
-
* false) if the file already exists.
|
|
41
|
-
*
|
|
91
|
+
* false) if the file already exists. Interrupted downloads keep a `.part` only
|
|
92
|
+
* when its ETag and complete length were recorded. A later attempt resumes it
|
|
93
|
+
* only after Hugging Face confirms the exact byte range belongs to that same
|
|
94
|
+
* representation; otherwise it restarts cleanly.
|
|
42
95
|
*/
|
|
43
96
|
async function streamRepoFile(url, destPath, label, token, onProgress, received) {
|
|
44
97
|
const tmp = `${destPath}.part`;
|
|
45
|
-
// Remove leftovers from an earlier interrupted attempt before starting a
|
|
46
|
-
// fresh request, including when this request fails before opening a stream.
|
|
47
|
-
rmSync(tmp, { force: true });
|
|
48
98
|
mkdirSync(path.dirname(destPath), { recursive: true });
|
|
49
|
-
if (existsSync(destPath))
|
|
99
|
+
if (existsSync(destPath)) {
|
|
100
|
+
clearPartial(tmp);
|
|
50
101
|
return false;
|
|
51
|
-
|
|
52
|
-
|
|
102
|
+
}
|
|
103
|
+
let partial = resumablePartial(tmp);
|
|
104
|
+
if (!partial && existsSync(tmp))
|
|
105
|
+
clearPartial(tmp);
|
|
106
|
+
let response;
|
|
107
|
+
if (partial) {
|
|
108
|
+
response = await fetch(url, {
|
|
109
|
+
headers: {
|
|
110
|
+
...authHeaders(token),
|
|
111
|
+
range: `bytes=${partial.bytes}-`,
|
|
112
|
+
"if-range": partial.metadata.etag,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
// A 200 is the defined If-Range response when the remote representation
|
|
116
|
+
// changed (or the server cannot range). The saved bytes are no longer safe
|
|
117
|
+
// to append, so start over with the complete response it supplied.
|
|
118
|
+
if (response.status === 200) {
|
|
119
|
+
clearPartial(tmp);
|
|
120
|
+
partial = null;
|
|
121
|
+
}
|
|
122
|
+
else if (!isMatchingRangeResponse(response, partial)) {
|
|
123
|
+
// Discard the partial before failing. A persistent non-200/non-206 answer
|
|
124
|
+
// to the ranged request (an expired signed CDN redirect returning 403, an
|
|
125
|
+
// upstream 5xx, a proxy that strips Range) would otherwise wedge this file
|
|
126
|
+
// forever: the partial stays valid, so every later attempt replays the
|
|
127
|
+
// same ranged request and gets the same answer, and only a 200 ever clears
|
|
128
|
+
// it. Dropping it here costs the resume but lets the next attempt restart
|
|
129
|
+
// cleanly from byte zero.
|
|
130
|
+
clearPartial(tmp);
|
|
131
|
+
throw new Error(`download resume failed (${response.status}) for ${url}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
response = await fetch(url, { headers: authHeaders(token) });
|
|
136
|
+
}
|
|
137
|
+
if ((response.status !== 200 && response.status !== 206) || !response.body) {
|
|
53
138
|
throw new Error(`download failed (${response.status}) for ${url}`);
|
|
54
139
|
}
|
|
55
|
-
const
|
|
140
|
+
const append = partial !== null;
|
|
141
|
+
const resumedBytes = partial?.bytes ?? 0;
|
|
142
|
+
const totalBytes = partial?.metadata.totalBytes ?? contentLength(response);
|
|
143
|
+
if (!append) {
|
|
144
|
+
const etag = response.headers.get("etag");
|
|
145
|
+
if (etag && totalBytes !== null && totalBytes > 0) {
|
|
146
|
+
writePartialMetadata(tmp, { etag, totalBytes });
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// An unidentifiable partial can never be proved safe to resume.
|
|
150
|
+
clearPartial(tmp);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
56
153
|
const body = Readable.fromWeb(response.body);
|
|
154
|
+
if (append)
|
|
155
|
+
received.bytes += resumedBytes;
|
|
57
156
|
body.on("data", (chunk) => {
|
|
58
157
|
received.bytes += chunk.length;
|
|
59
|
-
onProgress?.({
|
|
158
|
+
onProgress?.({
|
|
159
|
+
file: label,
|
|
160
|
+
receivedBytes: received.bytes,
|
|
161
|
+
totalBytes: totalBytes ?? undefined,
|
|
162
|
+
});
|
|
60
163
|
});
|
|
61
164
|
try {
|
|
62
|
-
await pipeline(body, createWriteStream(tmp));
|
|
63
|
-
|
|
165
|
+
await pipeline(body, createWriteStream(tmp, { flags: append ? "a" : "w" }));
|
|
166
|
+
// Undici rejects the body stream on a premature close (measured: a short
|
|
167
|
+
// Content-Length body, with either a destroyed socket or a clean FIN, and a
|
|
168
|
+
// truncated chunked body all reject), so a cut transfer never reaches this
|
|
169
|
+
// line. A short but *valid* 206 does: a server may answer an open-ended
|
|
170
|
+
// `bytes=N-` with any narrower range, and isMatchingRangeResponse accepts
|
|
171
|
+
// that because it requires end >= start, not end === totalBytes - 1.
|
|
172
|
+
// Renaming then would publish a truncated model as a complete one. The
|
|
173
|
+
// partial survives the throw below, so the next attempt resumes from the
|
|
174
|
+
// bytes this one did add.
|
|
175
|
+
const writtenBytes = statSync(tmp).size;
|
|
176
|
+
if (totalBytes !== null && totalBytes > 0 && writtenBytes !== totalBytes) {
|
|
177
|
+
throw new Error(`download incomplete for ${url}: wrote ${writtenBytes} of ${totalBytes} bytes`);
|
|
178
|
+
}
|
|
64
179
|
renameSync(tmp, destPath);
|
|
180
|
+
rmSync(partialMetadataPath(tmp), { force: true });
|
|
65
181
|
}
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
182
|
+
catch (error) {
|
|
183
|
+
// Keep only a partial that has a recorded immutable identity and full
|
|
184
|
+
// length. Everything else is deliberately discarded on the next attempt.
|
|
185
|
+
if (!resumablePartial(tmp))
|
|
186
|
+
clearPartial(tmp);
|
|
187
|
+
throw error;
|
|
70
188
|
}
|
|
71
189
|
return true;
|
|
72
190
|
}
|
|
191
|
+
/** Exact, manifest-driven files for a bundle selection. No quant discovery is
|
|
192
|
+
* involved, so a component pull can never select an arbitrary projector. */
|
|
193
|
+
export function bundleDownloadPlan(model, componentIds = [], primaryFiles, primaryBytes) {
|
|
194
|
+
const selected = new Set(componentIds);
|
|
195
|
+
const known = new Set((model.components ?? []).map((component) => component.id));
|
|
196
|
+
const unknown = componentIds.filter((id) => !known.has(id));
|
|
197
|
+
if (unknown.length)
|
|
198
|
+
throw new Error(`unknown bundle components: ${unknown.join(", ")}`);
|
|
199
|
+
const components = (model.components ?? []).filter((component) => component.required || selected.has(component.id));
|
|
200
|
+
const foreign = components.find((component) => (component.hfRepo ?? model.hfRepo) !== model.hfRepo);
|
|
201
|
+
if (foreign)
|
|
202
|
+
throw new Error(`component ${foreign.id} uses a separate repository and needs its own plan`);
|
|
203
|
+
return {
|
|
204
|
+
repo: model.hfRepo,
|
|
205
|
+
files: [
|
|
206
|
+
...(primaryFiles ?? [resolveFileName(model)]),
|
|
207
|
+
...components.map((component) => component.file),
|
|
208
|
+
],
|
|
209
|
+
totalBytes: (primaryBytes ?? model.approxWeightsBytes) === undefined ||
|
|
210
|
+
components.some((component) => component.bytes == null)
|
|
211
|
+
? null
|
|
212
|
+
: (primaryBytes ?? model.approxWeightsBytes) +
|
|
213
|
+
components.reduce((sum, component) => sum + (component.bytes ?? 0), 0),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
73
216
|
/** Download the model file; returns the local path it was written to. */
|
|
74
217
|
export async function pullModel({ model, destRoot, file, token, onProgress, }) {
|
|
75
218
|
const fileName = resolveFileName(model, file);
|
package/dist/models/enrich.d.ts
CHANGED
|
@@ -1,22 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reconciles scanned models back to their download-catalog entries so a model's
|
|
3
|
-
* coding metadata (useCases, tier, thinking, contextMax) survives a `pull`. The
|
|
4
|
-
* catalog carries this per entry, but once files land on disk scan.ts rebuilds a
|
|
5
|
-
* Model from filename + GGUF header alone, dropping it - this is where it is
|
|
6
|
-
* re-attached. Track B1 of the brain coding-capabilities work.
|
|
7
|
-
*
|
|
8
|
-
* The join key is the hfRepo path. download.ts writes each model to
|
|
9
|
-
* `<modelsDir>/<hfRepo>/<file>.gguf` (LM Studio mirrors the same
|
|
10
|
-
* `<publisher>/<repo>/<file>` layout), and scan.ts rebuilds `Model.id` as that
|
|
11
|
-
* same modelsDir-relative path with forward slashes. So a scanned model's id
|
|
12
|
-
* sits under its catalog entry's hfRepo directory, and that containment is the
|
|
13
|
-
* match.
|
|
14
|
-
*
|
|
15
|
-
* Total and best-effort by design: an empty catalog, a model with no match, or a
|
|
16
|
-
* repo carrying several quants all resolve without throwing. Discovery returns
|
|
17
|
-
* things unenriched on absence rather than raising - the caller decides whether
|
|
18
|
-
* absence matters.
|
|
19
|
-
*/
|
|
20
1
|
import type { Catalog, CatalogModel } from "../config/schema.js";
|
|
21
2
|
import type { Model } from "../types.js";
|
|
22
3
|
/**
|
package/dist/models/enrich.js
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciles scanned models back to their download-catalog entries so a model's
|
|
3
|
+
* coding metadata (useCases, tier, thinking, contextMax) survives a `pull`. The
|
|
4
|
+
* catalog carries this per entry, but once files land on disk scan.ts rebuilds a
|
|
5
|
+
* Model from filename + GGUF header alone, dropping it - this is where it is
|
|
6
|
+
* re-attached. Track B1 of the brain coding-capabilities work.
|
|
7
|
+
*
|
|
8
|
+
* The join key is the hfRepo path. download.ts writes each model to
|
|
9
|
+
* `<modelsDir>/<hfRepo>/<file>.gguf` (LM Studio mirrors the same
|
|
10
|
+
* `<publisher>/<repo>/<file>` layout), and scan.ts rebuilds `Model.id` as that
|
|
11
|
+
* same modelsDir-relative path with forward slashes. So a scanned model's id
|
|
12
|
+
* sits under its catalog entry's hfRepo directory, and that containment is the
|
|
13
|
+
* match.
|
|
14
|
+
*
|
|
15
|
+
* Total and best-effort by design: an empty catalog, a model with no match, or a
|
|
16
|
+
* repo carrying several quants all resolve without throwing. Discovery returns
|
|
17
|
+
* things unenriched on absence rather than raising - the caller decides whether
|
|
18
|
+
* absence matters.
|
|
19
|
+
*/
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
1
22
|
/** Normalize a repo/id path: forward slashes, lowercased, trailing slashes trimmed. */
|
|
2
23
|
function normalizePath(value) {
|
|
3
|
-
|
|
24
|
+
const normalized = value.replaceAll("\\", "/");
|
|
25
|
+
let end = normalized.length;
|
|
26
|
+
while (end > 0 && normalized.charCodeAt(end - 1) === 47) {
|
|
27
|
+
end -= 1;
|
|
28
|
+
}
|
|
29
|
+
return normalized.slice(0, end).toLowerCase();
|
|
4
30
|
}
|
|
5
31
|
/** The final path segment (file name) of a scanned model's id. */
|
|
6
32
|
function basenameOf(id) {
|
|
@@ -48,16 +74,21 @@ export function matchCatalogEntry(model, catalog) {
|
|
|
48
74
|
* through untouched. Never throws.
|
|
49
75
|
*/
|
|
50
76
|
export function enrichWithCatalog(models, catalog) {
|
|
51
|
-
if (catalog.models.length === 0)
|
|
52
|
-
return models;
|
|
53
77
|
return models.map((model) => {
|
|
54
78
|
const entry = matchCatalogEntry(model, catalog);
|
|
55
79
|
if (!entry)
|
|
56
|
-
return model;
|
|
80
|
+
return enrichDiscoveredProjector(model);
|
|
81
|
+
const components = resolveComponents(model, entry);
|
|
82
|
+
const projector = components?.find((component) => component.role === "vision_projector");
|
|
57
83
|
return {
|
|
58
84
|
...model,
|
|
59
85
|
catalogId: entry.id,
|
|
60
86
|
catalogHfRepo: entry.hfRepo,
|
|
87
|
+
components,
|
|
88
|
+
// A manifest is authoritative. Do not pair a random same-directory
|
|
89
|
+
// projector when the catalog declares the exact companion artifact.
|
|
90
|
+
mmprojPath: projector?.path ?? (components ? null : model.mmprojPath),
|
|
91
|
+
mmprojBytes: projector?.bytes ?? (components ? 0 : model.mmprojBytes),
|
|
61
92
|
useCases: entry.useCases,
|
|
62
93
|
tier: entry.tier,
|
|
63
94
|
thinking: entry.thinking,
|
|
@@ -66,4 +97,64 @@ export function enrichWithCatalog(models, catalog) {
|
|
|
66
97
|
};
|
|
67
98
|
});
|
|
68
99
|
}
|
|
100
|
+
/** Promote a scanner-paired projector in an arbitrary Hugging Face repository
|
|
101
|
+
* into the same component inventory shape used by curated bundles. */
|
|
102
|
+
function enrichDiscoveredProjector(model) {
|
|
103
|
+
if (!model.mmprojPath)
|
|
104
|
+
return model;
|
|
105
|
+
return {
|
|
106
|
+
...model,
|
|
107
|
+
components: [
|
|
108
|
+
{
|
|
109
|
+
id: "vision-projector",
|
|
110
|
+
label: "Vision projector",
|
|
111
|
+
description: "Adds image understanding",
|
|
112
|
+
role: "vision_projector",
|
|
113
|
+
path: model.mmprojPath,
|
|
114
|
+
bytes: model.mmprojBytes,
|
|
115
|
+
required: false,
|
|
116
|
+
defaultDownload: false,
|
|
117
|
+
defaultLoad: true,
|
|
118
|
+
available: true,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function resolveComponents(model, entry) {
|
|
124
|
+
if (!entry.components)
|
|
125
|
+
return undefined;
|
|
126
|
+
const modelDir = path.dirname(model.modelPath);
|
|
127
|
+
return entry.components.map((component) => {
|
|
128
|
+
const componentRepo = component.hfRepo ?? entry.hfRepo;
|
|
129
|
+
// A selected catalog primary and its declared companions share a repo in the
|
|
130
|
+
// managed layout. For a companion repository, derive its absolute path from
|
|
131
|
+
// the scanned model's models root rather than accepting a client path.
|
|
132
|
+
const repoTail = componentRepo.split("/").join(path.sep);
|
|
133
|
+
const marker = entry.hfRepo.split("/").join(path.sep);
|
|
134
|
+
const root = modelDir.endsWith(marker) ? modelDir.slice(0, -marker.length) : modelDir;
|
|
135
|
+
const candidate = path.resolve(root, repoTail, component.file);
|
|
136
|
+
let bytes = 0;
|
|
137
|
+
try {
|
|
138
|
+
bytes = fs.statSync(candidate).size;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
bytes = component.bytes ?? 0;
|
|
142
|
+
}
|
|
143
|
+
const available = fs.existsSync(candidate);
|
|
144
|
+
return {
|
|
145
|
+
id: component.id,
|
|
146
|
+
label: component.label,
|
|
147
|
+
description: component.description,
|
|
148
|
+
role: component.role,
|
|
149
|
+
path: available ? candidate : null,
|
|
150
|
+
bytes,
|
|
151
|
+
required: component.required,
|
|
152
|
+
defaultDownload: component.defaultDownload,
|
|
153
|
+
defaultLoad: component.defaultLoad,
|
|
154
|
+
available,
|
|
155
|
+
...(available ? {} : { unavailableReason: "Not downloaded" }),
|
|
156
|
+
...(component.minRuntimeBuild ? { minRuntimeBuild: component.minRuntimeBuild } : {}),
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
}
|
|
69
160
|
//# sourceMappingURL=enrich.js.map
|
package/dist/models/hf.d.ts
CHANGED
|
@@ -38,15 +38,28 @@ export interface ModelSearchResult {
|
|
|
38
38
|
likes: number;
|
|
39
39
|
updatedAt: string | null;
|
|
40
40
|
gated: boolean;
|
|
41
|
+
summary: string | null;
|
|
41
42
|
}
|
|
43
|
+
/** Drop every cached summary. Exported for tests and for a token change, which
|
|
44
|
+
* can flip which repos are readable at all. */
|
|
45
|
+
export declare function clearCardSummaryCache(): void;
|
|
42
46
|
/**
|
|
43
47
|
* Search Hugging Face for GGUF model repos, most-downloaded first. Returns a
|
|
44
48
|
* normalized shape both the TUI and the Otto app can render; drill into a result
|
|
45
49
|
* with {@link listRepoQuants} to see and download its quantizations.
|
|
50
|
+
*
|
|
51
|
+
* Card summaries are a *bounded* enrichment, never a gate on the result set. Each
|
|
52
|
+
* one costs one or two extra round trips, so the whole batch shares one budget
|
|
53
|
+
* (`summaryBudgetMs`, 0 to skip them entirely); rows that miss it come back with
|
|
54
|
+
* `summary: null`, which every consumer already treats as "not available". When
|
|
55
|
+
* the budget expires the outstanding fetches are aborted rather than left to run,
|
|
56
|
+
* so the one-shot `otto brain search --json` the daemon shells out to can exit
|
|
57
|
+
* immediately instead of lingering on open sockets.
|
|
46
58
|
*/
|
|
47
|
-
export declare function searchModels(query: string, { limit, token }?: {
|
|
59
|
+
export declare function searchModels(query: string, { limit, token, summaryBudgetMs, }?: {
|
|
48
60
|
limit?: number;
|
|
49
61
|
token?: string | null;
|
|
62
|
+
summaryBudgetMs?: number;
|
|
50
63
|
}): Promise<ModelSearchResult[]>;
|
|
51
64
|
/**
|
|
52
65
|
* Quality order for display: higher bits-per-weight is more faithful and larger.
|