threadshelf 1.2.0
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/CHANGELOG.md +185 -0
- package/LICENSE +21 -0
- package/README.md +763 -0
- package/SECURITY.md +75 -0
- package/bin/threadshelf-mcp.js +12 -0
- package/bin/threadshelf.js +87 -0
- package/dist/mcp/server.js +388 -0
- package/dist/src/chunking.js +72 -0
- package/dist/src/cli.js +24 -0
- package/dist/src/embedding.js +59 -0
- package/dist/src/env.js +2 -0
- package/dist/src/generation/config.js +344 -0
- package/dist/src/generation/downloader.js +172 -0
- package/dist/src/generation/error-log.js +34 -0
- package/dist/src/generation/filesystem-browser.js +83 -0
- package/dist/src/generation/gguf-metadata.js +179 -0
- package/dist/src/generation/hardware.js +87 -0
- package/dist/src/generation/llama-install.js +563 -0
- package/dist/src/generation/llama-process.js +576 -0
- package/dist/src/generation/llama-profile.js +136 -0
- package/dist/src/generation/master-prompts.js +155 -0
- package/dist/src/generation/model-catalog.js +276 -0
- package/dist/src/generation/model-discovery.js +60 -0
- package/dist/src/generation/model-download.js +151 -0
- package/dist/src/generation/openai-compatible.js +231 -0
- package/dist/src/generation/providers/llama-cpp.js +97 -0
- package/dist/src/generation/providers/openrouter.js +106 -0
- package/dist/src/generation/quick-setup.js +215 -0
- package/dist/src/generation/registry.js +23 -0
- package/dist/src/generation/service.js +100 -0
- package/dist/src/generation/threads.js +311 -0
- package/dist/src/generation/types.js +1 -0
- package/dist/src/ingest-cli.js +95 -0
- package/dist/src/ingest.js +257 -0
- package/dist/src/load-env.js +17 -0
- package/dist/src/model-label.js +15 -0
- package/dist/src/parser.js +811 -0
- package/dist/src/paths.js +79 -0
- package/dist/src/routes/collections.js +97 -0
- package/dist/src/routes/files.js +136 -0
- package/dist/src/routes/generation.js +536 -0
- package/dist/src/routes/health.js +6 -0
- package/dist/src/routes/index.js +21 -0
- package/dist/src/routes/ingest.js +300 -0
- package/dist/src/routes/insights.js +24 -0
- package/dist/src/routes/loopback.js +15 -0
- package/dist/src/routes/model-catalog.js +178 -0
- package/dist/src/routes/search.js +57 -0
- package/dist/src/routes/stream-abort.js +23 -0
- package/dist/src/routes/thread.js +43 -0
- package/dist/src/search-cli.js +93 -0
- package/dist/src/server.js +78 -0
- package/dist/src/services/collections.js +58 -0
- package/dist/src/services/insights.js +111 -0
- package/dist/src/services/search.js +68 -0
- package/dist/src/services/stats.js +35 -0
- package/dist/src/services/thread.js +140 -0
- package/dist/src/store.js +1138 -0
- package/dist/src/validation.js +250 -0
- package/dist/src/watch.js +83 -0
- package/package.json +103 -0
- package/public/assets/index-CIm_Idqi.js +38 -0
- package/public/assets/index-Dv09K2vS.css +1 -0
- package/public/favicon.svg +6 -0
- package/public/index.html +28 -0
- package/scripts/openrouter-export-all.js +228 -0
- package/scripts/openrouter-export-browser.js +153 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Only symmetric pairs: stock CUDA builds compile Flash Attention kernels for
|
|
2
|
+
// q8_0/q8_0 and q4_0/q4_0, while other combinations can fall back to slow paths.
|
|
3
|
+
const KV_CACHE_TYPE = { quality: 'q8_0', memory: 'q4_0' };
|
|
4
|
+
const MTP_DRAFT_TOKENS = { auto: 2, aggressive: 3 };
|
|
5
|
+
/** Above this, current CUDA builds have reported severe decode slowdowns. */
|
|
6
|
+
export const LONG_CONTEXT_WARNING_TOKENS = 65_536;
|
|
7
|
+
const formatTokens = (value) => value % 1024 === 0 ? `${value / 1024}K` : value.toLocaleString('en-US');
|
|
8
|
+
/**
|
|
9
|
+
* Turns the saved performance settings into llama-server flags. Every option is
|
|
10
|
+
* gated on what this executable advertises in `--help` and on the GGUF header, so
|
|
11
|
+
* an unsupported choice is reported as skipped instead of breaking server startup.
|
|
12
|
+
* Sampling (temperature/top-p/top-k) is deliberately never touched here.
|
|
13
|
+
*/
|
|
14
|
+
export const resolveLlamaTuning = (config, capabilities, model) => {
|
|
15
|
+
const args = [];
|
|
16
|
+
const entries = [];
|
|
17
|
+
const skipped = (setting, value, note) => ({
|
|
18
|
+
setting,
|
|
19
|
+
value,
|
|
20
|
+
source: 'runtime',
|
|
21
|
+
applied: false,
|
|
22
|
+
note,
|
|
23
|
+
});
|
|
24
|
+
const contextNotes = [
|
|
25
|
+
model?.contextLength && config.contextSize > model.contextLength
|
|
26
|
+
? `exceeds the model's native ${formatTokens(model.contextLength)}`
|
|
27
|
+
: '',
|
|
28
|
+
config.contextSize > LONG_CONTEXT_WARNING_TOKENS
|
|
29
|
+
? 'experimental: very long contexts can decode much slower'
|
|
30
|
+
: '',
|
|
31
|
+
].filter(Boolean);
|
|
32
|
+
entries.push({
|
|
33
|
+
setting: 'ctx',
|
|
34
|
+
value: formatTokens(config.contextSize),
|
|
35
|
+
source: 'settings',
|
|
36
|
+
applied: true,
|
|
37
|
+
...(contextNotes.length ? { note: contextNotes.join('; ') } : {}),
|
|
38
|
+
});
|
|
39
|
+
let flashAttention = config.flashAttention;
|
|
40
|
+
let kvEntry;
|
|
41
|
+
const kvCache = config.kvCache ?? 'default';
|
|
42
|
+
if (kvCache !== 'default') {
|
|
43
|
+
const type = KV_CACHE_TYPE[kvCache];
|
|
44
|
+
const value = `${type}×${type}`;
|
|
45
|
+
if (!capabilities.kvCacheTypes) {
|
|
46
|
+
kvEntry = skipped('KV', value, 'this llama-server has no --cache-type-k');
|
|
47
|
+
}
|
|
48
|
+
else if (config.flashAttention === 'off') {
|
|
49
|
+
kvEntry = skipped('KV', value, 'a quantized KV cache needs Flash Attention, which is off');
|
|
50
|
+
}
|
|
51
|
+
else if (!capabilities.flashAttentionValues) {
|
|
52
|
+
kvEntry = skipped('KV', value, 'this llama-server cannot force Flash Attention on');
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
args.push('--cache-type-k', type, '--cache-type-v', type);
|
|
56
|
+
kvEntry = { setting: 'KV', value, source: 'settings', applied: true };
|
|
57
|
+
flashAttention = 'on';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
entries.push({
|
|
61
|
+
setting: 'FA',
|
|
62
|
+
value: flashAttention,
|
|
63
|
+
applied: true,
|
|
64
|
+
...(flashAttention === config.flashAttention
|
|
65
|
+
? { source: 'settings' }
|
|
66
|
+
: { source: 'threadshelf', note: 'required by the quantized KV cache' }),
|
|
67
|
+
});
|
|
68
|
+
if (kvEntry)
|
|
69
|
+
entries.push(kvEntry);
|
|
70
|
+
const speculative = config.speculative ?? 'off';
|
|
71
|
+
if (speculative === 'off') {
|
|
72
|
+
entries.push({ setting: 'MTP', value: 'off', source: 'settings', applied: true });
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const draft = String(MTP_DRAFT_TOKENS[speculative]);
|
|
76
|
+
if (!capabilities.speculativeTypes.includes('draft-mtp')) {
|
|
77
|
+
entries.push(skipped('MTP', draft, 'this llama-server has no draft-mtp decoding'));
|
|
78
|
+
}
|
|
79
|
+
else if (!model) {
|
|
80
|
+
entries.push(skipped('MTP', draft, 'GGUF metadata could not be read'));
|
|
81
|
+
}
|
|
82
|
+
else if (model.nextnPredictLayers < 1) {
|
|
83
|
+
entries.push({
|
|
84
|
+
setting: 'MTP',
|
|
85
|
+
value: 'off',
|
|
86
|
+
source: 'model',
|
|
87
|
+
applied: true,
|
|
88
|
+
note: 'the model has no MTP/NextN head',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
args.push('--spec-type', 'draft-mtp', '--spec-draft-n-max', draft);
|
|
93
|
+
entries.push({
|
|
94
|
+
setting: 'MTP',
|
|
95
|
+
value: draft,
|
|
96
|
+
source: 'settings',
|
|
97
|
+
applied: true,
|
|
98
|
+
note: `${model.nextnPredictLayers} NextN layer(s) in the GGUF`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const reasoning = config.reasoningEffort ?? 'default';
|
|
103
|
+
if (reasoning === 'off') {
|
|
104
|
+
if (capabilities.reasoningToggle) {
|
|
105
|
+
args.push('--reasoning', 'off');
|
|
106
|
+
entries.push({ setting: 'reasoning', value: 'off', source: 'settings', applied: true });
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
entries.push(skipped('reasoning', 'off', 'this llama-server has no --reasoning switch'));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if (reasoning !== 'default') {
|
|
113
|
+
if (capabilities.reasoningEffort) {
|
|
114
|
+
args.push('--reasoning-effort', reasoning);
|
|
115
|
+
entries.push({ setting: 'reasoning', value: reasoning, source: 'settings', applied: true });
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
entries.push(skipped('reasoning', reasoning, 'this llama-server has no --reasoning-effort'));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (capabilities.parallelSlots) {
|
|
122
|
+
args.push('--parallel', '1');
|
|
123
|
+
entries.push({
|
|
124
|
+
setting: 'slots',
|
|
125
|
+
value: '1',
|
|
126
|
+
source: 'threadshelf',
|
|
127
|
+
applied: true,
|
|
128
|
+
note: 'single local user; concurrent chats queue',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return { flashAttention, args, entries };
|
|
132
|
+
};
|
|
133
|
+
/** One human-readable line: value, where it came from, and why anything was skipped. */
|
|
134
|
+
export const formatLlamaTuning = (entries) => entries
|
|
135
|
+
.map((entry) => `${entry.setting} ${entry.value} (${entry.applied ? entry.source : 'skipped'}${entry.note ? `: ${entry.note}` : ''})`)
|
|
136
|
+
.join(' · ');
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'path';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import { dataPath } from '../paths.js';
|
|
5
|
+
import { ValidationError } from '../validation.js';
|
|
6
|
+
const MAX_PROMPTS = 50;
|
|
7
|
+
const MAX_NAME_CHARS = 60;
|
|
8
|
+
const MAX_TEXT_CHARS = 20_000;
|
|
9
|
+
export const masterPromptsPath = () => resolve(process.env.MASTER_PROMPTS_PATH || dataPath('masterPrompts'));
|
|
10
|
+
const isPrompt = (value) => Boolean(value) &&
|
|
11
|
+
typeof value === 'object' &&
|
|
12
|
+
typeof value.id === 'string' &&
|
|
13
|
+
typeof value.name === 'string' &&
|
|
14
|
+
typeof value.text === 'string';
|
|
15
|
+
const read = async () => {
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = await readFile(masterPromptsPath(), 'utf8');
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code === 'ENOENT')
|
|
22
|
+
return { prompts: [], activeId: '' };
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
throw new Error(`Invalid master prompt JSON at ${masterPromptsPath()}`, { cause: error });
|
|
31
|
+
}
|
|
32
|
+
const stored = parsed;
|
|
33
|
+
const prompts = (Array.isArray(stored.prompts) ? stored.prompts : [])
|
|
34
|
+
.filter(isPrompt)
|
|
35
|
+
.slice(0, MAX_PROMPTS)
|
|
36
|
+
.map((prompt) => ({
|
|
37
|
+
id: prompt.id,
|
|
38
|
+
name: prompt.name,
|
|
39
|
+
text: prompt.text,
|
|
40
|
+
updatedAt: typeof prompt.updatedAt === 'string' ? prompt.updatedAt : '',
|
|
41
|
+
}));
|
|
42
|
+
const activeId = typeof stored.activeId === 'string' ? stored.activeId : '';
|
|
43
|
+
return {
|
|
44
|
+
prompts,
|
|
45
|
+
activeId: prompts.some((prompt) => prompt.id === activeId) ? activeId : '',
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
const write = async (next) => {
|
|
49
|
+
const path = masterPromptsPath();
|
|
50
|
+
await mkdir(dirname(path), { recursive: true });
|
|
51
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
52
|
+
await writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, {
|
|
53
|
+
encoding: 'utf8',
|
|
54
|
+
mode: 0o600,
|
|
55
|
+
});
|
|
56
|
+
await rename(temporary, path);
|
|
57
|
+
return next;
|
|
58
|
+
};
|
|
59
|
+
// Writes are small but arrive from a chatty UI; serializing them keeps the
|
|
60
|
+
// read-modify-write cycle from dropping a concurrent edit.
|
|
61
|
+
let queue = Promise.resolve();
|
|
62
|
+
const serialize = (operation) => {
|
|
63
|
+
const result = queue.then(operation, operation);
|
|
64
|
+
queue = result.catch(() => undefined);
|
|
65
|
+
return result;
|
|
66
|
+
};
|
|
67
|
+
const parseText = (value) => {
|
|
68
|
+
if (typeof value !== 'string') {
|
|
69
|
+
throw new ValidationError('Invalid text: must be a string', { field: 'text' });
|
|
70
|
+
}
|
|
71
|
+
const trimmed = value.trim();
|
|
72
|
+
if (!trimmed)
|
|
73
|
+
throw new ValidationError('A master prompt cannot be empty', { field: 'text' });
|
|
74
|
+
if (trimmed.length > MAX_TEXT_CHARS) {
|
|
75
|
+
throw new ValidationError(`Invalid text: max ${MAX_TEXT_CHARS} chars`, { field: 'text' });
|
|
76
|
+
}
|
|
77
|
+
return trimmed;
|
|
78
|
+
};
|
|
79
|
+
const parseName = (value, fallback) => {
|
|
80
|
+
if (value === undefined || value === null || value === '') {
|
|
81
|
+
return fallback.replace(/\s+/g, ' ').slice(0, 28);
|
|
82
|
+
}
|
|
83
|
+
if (typeof value !== 'string') {
|
|
84
|
+
throw new ValidationError('Invalid name: must be a string', { field: 'name' });
|
|
85
|
+
}
|
|
86
|
+
const trimmed = value.replace(/\s+/g, ' ').trim();
|
|
87
|
+
if (!trimmed)
|
|
88
|
+
return fallback.replace(/\s+/g, ' ').slice(0, 28);
|
|
89
|
+
if (trimmed.length > MAX_NAME_CHARS) {
|
|
90
|
+
throw new ValidationError(`Invalid name: max ${MAX_NAME_CHARS} chars`, { field: 'name' });
|
|
91
|
+
}
|
|
92
|
+
return trimmed;
|
|
93
|
+
};
|
|
94
|
+
// Route params arrive untyped from Express, so ids are validated here rather
|
|
95
|
+
// than trusted at the call site.
|
|
96
|
+
const parseId = (value) => {
|
|
97
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
98
|
+
throw new ValidationError('Invalid id', { field: 'id' });
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
};
|
|
102
|
+
export const listMasterPrompts = () => read();
|
|
103
|
+
export const createMasterPrompt = (input) => serialize(async () => {
|
|
104
|
+
const current = await read();
|
|
105
|
+
if (current.prompts.length >= MAX_PROMPTS) {
|
|
106
|
+
throw new ValidationError(`At most ${MAX_PROMPTS} master prompts can be stored`);
|
|
107
|
+
}
|
|
108
|
+
const text = parseText(input?.text);
|
|
109
|
+
const prompt = {
|
|
110
|
+
id: randomUUID(),
|
|
111
|
+
name: parseName(input?.name, text),
|
|
112
|
+
text,
|
|
113
|
+
updatedAt: new Date().toISOString(),
|
|
114
|
+
};
|
|
115
|
+
// A freshly written prompt is the one the user means to use.
|
|
116
|
+
return write({ prompts: [...current.prompts, prompt], activeId: prompt.id });
|
|
117
|
+
});
|
|
118
|
+
export const updateMasterPrompt = (value, input) => serialize(async () => {
|
|
119
|
+
const id = parseId(value);
|
|
120
|
+
const current = await read();
|
|
121
|
+
const existing = current.prompts.find((prompt) => prompt.id === id);
|
|
122
|
+
if (!existing)
|
|
123
|
+
throw new ValidationError('Master prompt not found', { field: 'id' });
|
|
124
|
+
const text = input?.text === undefined ? existing.text : parseText(input.text);
|
|
125
|
+
const updated = {
|
|
126
|
+
...existing,
|
|
127
|
+
name: input?.name === undefined ? existing.name : parseName(input.name, text),
|
|
128
|
+
text,
|
|
129
|
+
updatedAt: new Date().toISOString(),
|
|
130
|
+
};
|
|
131
|
+
return write({
|
|
132
|
+
prompts: current.prompts.map((prompt) => (prompt.id === id ? updated : prompt)),
|
|
133
|
+
activeId: input?.active === false ? '' : id,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
export const deleteMasterPrompt = (value) => serialize(async () => {
|
|
137
|
+
const id = parseId(value);
|
|
138
|
+
const current = await read();
|
|
139
|
+
const prompts = current.prompts.filter((prompt) => prompt.id !== id);
|
|
140
|
+
if (prompts.length === current.prompts.length) {
|
|
141
|
+
throw new ValidationError('Master prompt not found', { field: 'id' });
|
|
142
|
+
}
|
|
143
|
+
return write({ prompts, activeId: current.activeId === id ? '' : current.activeId });
|
|
144
|
+
});
|
|
145
|
+
/** `id` of '' turns the master prompt off without deleting anything. */
|
|
146
|
+
export const setActiveMasterPrompt = (id) => serialize(async () => {
|
|
147
|
+
if (typeof id !== 'string') {
|
|
148
|
+
throw new ValidationError('Invalid id: must be a string', { field: 'id' });
|
|
149
|
+
}
|
|
150
|
+
const current = await read();
|
|
151
|
+
if (id && !current.prompts.some((prompt) => prompt.id === id)) {
|
|
152
|
+
throw new ValidationError('Master prompt not found', { field: 'id' });
|
|
153
|
+
}
|
|
154
|
+
return write({ prompts: current.prompts, activeId: id });
|
|
155
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only browser for GGUF models published on the Hugging Face Hub.
|
|
3
|
+
*
|
|
4
|
+
* Everything here uses the public, unauthenticated API: search, download counts,
|
|
5
|
+
* per-file sizes and the LFS `oid` that doubles as the file's SHA-256. A token is
|
|
6
|
+
* only ever needed for *gated* repositories, which are detected up front so the
|
|
7
|
+
* UI can say so instead of failing with a 401 halfway through a download.
|
|
8
|
+
*
|
|
9
|
+
* Only catalog metadata leaves the machine. No chat content is ever sent here.
|
|
10
|
+
*/
|
|
11
|
+
export const HUGGINGFACE_ORIGIN = 'https://huggingface.co';
|
|
12
|
+
/**
|
|
13
|
+
* Publishers whose GGUF repositories are consistently ungated and well-formed.
|
|
14
|
+
* They are surfaced first and used as the pool for one-click setup, which is how
|
|
15
|
+
* the common path avoids ever needing a Hugging Face login.
|
|
16
|
+
*/
|
|
17
|
+
export const TRUSTED_PUBLISHERS = [
|
|
18
|
+
'unsloth',
|
|
19
|
+
'lmstudio-community',
|
|
20
|
+
'bartowski',
|
|
21
|
+
'ggml-org',
|
|
22
|
+
'Qwen',
|
|
23
|
+
'google',
|
|
24
|
+
'mistralai',
|
|
25
|
+
];
|
|
26
|
+
const REPO_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\/[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/;
|
|
27
|
+
const CACHE_TTL_MS = 5 * 60_000;
|
|
28
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
29
|
+
const MAX_SEARCH_LIMIT = 50;
|
|
30
|
+
/**
|
|
31
|
+
* Matches the quantisation suffix at the end of a GGUF file name: `Q4_K_M`,
|
|
32
|
+
* `IQ3_XXS`, `UD-Q4_K_XL`, `BF16`, `MXFP4_MOE`, `TQ1_0`.
|
|
33
|
+
*/
|
|
34
|
+
const QUANT_SUFFIX = /(?:^|[-_.])((?:UD-)?(?:I?Q\d+[A-Z0-9_]*|TQ\d+[A-Z0-9_]*|MXFP4[A-Z0-9_]*|BF16|FP16|F16|FP32|F32))$/i;
|
|
35
|
+
const SHARD_SUFFIX = /-(\d{5})-of-(\d{5})$/;
|
|
36
|
+
const PREFERRED_QUANTS = ['UD-Q4_K_XL', 'Q4_K_M', 'Q4_K_S', 'IQ4_XS', 'Q5_K_M', 'Q8_0'];
|
|
37
|
+
/**
|
|
38
|
+
* Quality tiers for automatic selection. Size alone is a bad proxy: a legacy
|
|
39
|
+
* `Q4_1` and a modern `Q4_K_M` occupy the same space while the K-quant is
|
|
40
|
+
* clearly better, and full-precision weights waste memory that the KV cache
|
|
41
|
+
* needs. Higher is better; 0 means "never pick this automatically".
|
|
42
|
+
*/
|
|
43
|
+
export const quantQualityTier = (label) => {
|
|
44
|
+
const value = label.toUpperCase();
|
|
45
|
+
if (/^(UD-)?(BF16|FP16|F16|FP32|F32)$/.test(value))
|
|
46
|
+
return 0;
|
|
47
|
+
// Legacy non-K quantisations, kept usable but never preferred. Only the 4- and
|
|
48
|
+
// 5-bit families are legacy; `Q8_0` shares the shape but is the best common
|
|
49
|
+
// quantisation there is.
|
|
50
|
+
if (/^(UD-)?Q[45]_[01]$/.test(value))
|
|
51
|
+
return 1;
|
|
52
|
+
const bits = Number(value.match(/Q(\d+)/)?.[1] ?? 0);
|
|
53
|
+
if (bits >= 8)
|
|
54
|
+
return 6;
|
|
55
|
+
if (bits >= 6)
|
|
56
|
+
return 5;
|
|
57
|
+
if (bits >= 5)
|
|
58
|
+
return 4;
|
|
59
|
+
if (bits >= 4)
|
|
60
|
+
return 3;
|
|
61
|
+
if (bits >= 3)
|
|
62
|
+
return 2;
|
|
63
|
+
return 1;
|
|
64
|
+
};
|
|
65
|
+
export class CatalogError extends Error {
|
|
66
|
+
status;
|
|
67
|
+
constructor(message, status) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.status = status;
|
|
70
|
+
this.name = 'CatalogError';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export const assertRepoId = (value) => {
|
|
74
|
+
if (typeof value !== 'string' || !REPO_ID_PATTERN.test(value) || value.includes('..')) {
|
|
75
|
+
throw new CatalogError('Invalid model id: expected "owner/name"', 400);
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
};
|
|
79
|
+
export const huggingFaceToken = (env = process.env) => (env.HF_TOKEN || env.HUGGING_FACE_HUB_TOKEN || '').trim();
|
|
80
|
+
const catalogHeaders = () => {
|
|
81
|
+
const token = huggingFaceToken();
|
|
82
|
+
return {
|
|
83
|
+
Accept: 'application/json',
|
|
84
|
+
'User-Agent': 'ThreadShelf-model-catalog',
|
|
85
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
const cache = new Map();
|
|
89
|
+
const cached = async (key, load) => {
|
|
90
|
+
const hit = cache.get(key);
|
|
91
|
+
if (hit && Date.now() - hit.at < CACHE_TTL_MS)
|
|
92
|
+
return hit.value;
|
|
93
|
+
const value = await load();
|
|
94
|
+
cache.set(key, { at: Date.now(), value });
|
|
95
|
+
// The catalog is browsed interactively; a small bound keeps memory flat without
|
|
96
|
+
// any eviction bookkeeping.
|
|
97
|
+
if (cache.size > 200)
|
|
98
|
+
cache.delete(cache.keys().next().value);
|
|
99
|
+
return value;
|
|
100
|
+
};
|
|
101
|
+
export const clearCatalogCacheForTests = () => cache.clear();
|
|
102
|
+
const catalogGet = async (path, fetchImpl) => {
|
|
103
|
+
const response = await fetchImpl(`${HUGGINGFACE_ORIGIN}${path}`, {
|
|
104
|
+
headers: catalogHeaders(),
|
|
105
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
106
|
+
});
|
|
107
|
+
if (response.status === 401 || response.status === 403) {
|
|
108
|
+
throw new CatalogError('Hugging Face refused the request. Gated repositories need HF_TOKEN in the server .env.', response.status);
|
|
109
|
+
}
|
|
110
|
+
if (response.status === 404)
|
|
111
|
+
throw new CatalogError('Model not found on Hugging Face', 404);
|
|
112
|
+
if (response.status === 429) {
|
|
113
|
+
throw new CatalogError('Hugging Face rate limit reached. Try again shortly.', 429);
|
|
114
|
+
}
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
throw new CatalogError(`Hugging Face request failed (${response.status})`, response.status);
|
|
117
|
+
}
|
|
118
|
+
return response.json();
|
|
119
|
+
};
|
|
120
|
+
const parseGating = (value) => value === 'auto' || value === 'manual' ? value : false;
|
|
121
|
+
const HF_SORT_FIELD = {
|
|
122
|
+
downloads: 'downloads',
|
|
123
|
+
likes: 'likes',
|
|
124
|
+
trending: 'trendingScore',
|
|
125
|
+
recent: 'lastModified',
|
|
126
|
+
};
|
|
127
|
+
const toSummary = (raw) => {
|
|
128
|
+
const id = raw.id || raw.modelId;
|
|
129
|
+
if (!id || !id.includes('/'))
|
|
130
|
+
return null;
|
|
131
|
+
const [author = '', ...rest] = id.split('/');
|
|
132
|
+
return {
|
|
133
|
+
id,
|
|
134
|
+
author,
|
|
135
|
+
name: rest.join('/'),
|
|
136
|
+
downloads: Number(raw.downloads) || 0,
|
|
137
|
+
likes: Number(raw.likes) || 0,
|
|
138
|
+
trendingScore: typeof raw.trendingScore === 'number' ? raw.trendingScore : undefined,
|
|
139
|
+
gated: parseGating(raw.gated),
|
|
140
|
+
updatedAt: raw.lastModified,
|
|
141
|
+
architecture: raw.gguf?.architecture,
|
|
142
|
+
contextLength: raw.gguf?.context_length,
|
|
143
|
+
parameterCount: raw.gguf?.total,
|
|
144
|
+
trustedPublisher: TRUSTED_PUBLISHERS.some((publisher) => publisher.toLowerCase() === author.toLowerCase()),
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
export const searchCatalogModels = async ({ query = '', sort = 'downloads', limit = 24, author, fetchImpl = fetch, } = {}) => {
|
|
148
|
+
const safeLimit = Math.min(Math.max(1, Math.trunc(limit) || 24), MAX_SEARCH_LIMIT);
|
|
149
|
+
const params = new URLSearchParams({
|
|
150
|
+
filter: 'gguf',
|
|
151
|
+
sort: HF_SORT_FIELD[sort] ?? 'downloads',
|
|
152
|
+
direction: '-1',
|
|
153
|
+
limit: String(safeLimit),
|
|
154
|
+
});
|
|
155
|
+
// The list endpoint omits `gated` unless it is explicitly expanded, so without
|
|
156
|
+
// this the browser could never warn that a repository needs a Hugging Face
|
|
157
|
+
// login. Expanding replaces the default field set, so every field the UI reads
|
|
158
|
+
// has to be named here.
|
|
159
|
+
for (const field of ['gated', 'downloads', 'likes', 'trendingScore', 'lastModified']) {
|
|
160
|
+
params.append('expand[]', field);
|
|
161
|
+
}
|
|
162
|
+
const trimmed = query.trim().slice(0, 128);
|
|
163
|
+
if (trimmed)
|
|
164
|
+
params.set('search', trimmed);
|
|
165
|
+
if (author)
|
|
166
|
+
params.set('author', author);
|
|
167
|
+
const path = `/api/models?${params.toString()}`;
|
|
168
|
+
const payload = await cached(path, () => catalogGet(path, fetchImpl));
|
|
169
|
+
const models = (Array.isArray(payload) ? payload : [])
|
|
170
|
+
.map((entry) => toSummary(entry))
|
|
171
|
+
.filter((model) => model !== null);
|
|
172
|
+
return {
|
|
173
|
+
models,
|
|
174
|
+
source: HUGGINGFACE_ORIGIN,
|
|
175
|
+
tokenConfigured: Boolean(huggingFaceToken()),
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
/** Splits `Model-Q4_K_M-00001-of-00002.gguf` into its quant label and shard index. */
|
|
179
|
+
export const parseGgufFileName = (path) => {
|
|
180
|
+
const segments = path.split('/');
|
|
181
|
+
const fileName = segments[segments.length - 1] ?? path;
|
|
182
|
+
let base = fileName.replace(/\.gguf$/i, '');
|
|
183
|
+
let shardIndex;
|
|
184
|
+
let shardTotal;
|
|
185
|
+
const shard = base.match(SHARD_SUFFIX);
|
|
186
|
+
if (shard) {
|
|
187
|
+
shardIndex = Number(shard[1]);
|
|
188
|
+
shardTotal = Number(shard[2]);
|
|
189
|
+
base = base.slice(0, -shard[0].length);
|
|
190
|
+
}
|
|
191
|
+
const quant = base.match(QUANT_SUFFIX);
|
|
192
|
+
if (quant?.[1])
|
|
193
|
+
return { label: quant[1].toUpperCase(), shardIndex, shardTotal };
|
|
194
|
+
// Some repositories keep each quant in its own folder instead of encoding it in
|
|
195
|
+
// the file name (`Q4_K_M/model-00001-of-00002.gguf`).
|
|
196
|
+
if (segments.length > 1 && segments[0]) {
|
|
197
|
+
return { label: segments[0].toUpperCase(), shardIndex, shardTotal };
|
|
198
|
+
}
|
|
199
|
+
return { label: null, shardIndex, shardTotal };
|
|
200
|
+
};
|
|
201
|
+
export const groupCatalogQuants = (entries) => {
|
|
202
|
+
const projectors = [];
|
|
203
|
+
const groups = new Map();
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
if (entry.type !== 'file' || !entry.path)
|
|
206
|
+
continue;
|
|
207
|
+
if (!/\.gguf$/i.test(entry.path))
|
|
208
|
+
continue;
|
|
209
|
+
const file = {
|
|
210
|
+
path: entry.path,
|
|
211
|
+
sizeBytes: Number(entry.lfs?.size ?? entry.size) || 0,
|
|
212
|
+
// The LFS object id for a Hub file is its SHA-256, which lets a download be
|
|
213
|
+
// verified with exactly the same guarantee as a llama.cpp release archive.
|
|
214
|
+
sha256: /^[a-f0-9]{64}$/i.test(entry.lfs?.oid ?? '')
|
|
215
|
+
? entry.lfs.oid.toLowerCase()
|
|
216
|
+
: undefined,
|
|
217
|
+
};
|
|
218
|
+
const fileName = entry.path.split('/').pop() ?? entry.path;
|
|
219
|
+
if (/^mmproj/i.test(fileName)) {
|
|
220
|
+
projectors.push(file);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const { label } = parseGgufFileName(entry.path);
|
|
224
|
+
const key = label ?? fileName.replace(/\.gguf$/i, '').toUpperCase();
|
|
225
|
+
const bucket = groups.get(key);
|
|
226
|
+
if (bucket)
|
|
227
|
+
bucket.push(file);
|
|
228
|
+
else
|
|
229
|
+
groups.set(key, [file]);
|
|
230
|
+
}
|
|
231
|
+
const quants = [...groups.entries()]
|
|
232
|
+
.map(([label, files]) => {
|
|
233
|
+
const ordered = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
234
|
+
return {
|
|
235
|
+
label,
|
|
236
|
+
files: ordered,
|
|
237
|
+
totalBytes: ordered.reduce((sum, file) => sum + file.sizeBytes, 0),
|
|
238
|
+
shards: ordered.length,
|
|
239
|
+
recommended: false,
|
|
240
|
+
};
|
|
241
|
+
})
|
|
242
|
+
.sort((a, b) => a.totalBytes - b.totalBytes);
|
|
243
|
+
const preferred = PREFERRED_QUANTS.map((label) => quants.find((quant) => quant.label === label)).find(Boolean) ??
|
|
244
|
+
quants[Math.floor(quants.length / 2)];
|
|
245
|
+
return {
|
|
246
|
+
quants: quants.map((quant) => ({ ...quant, recommended: quant === preferred })),
|
|
247
|
+
projectors: projectors.sort((a, b) => a.path.localeCompare(b.path)),
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
export const getCatalogModel = async (repoId, fetchImpl = fetch) => {
|
|
251
|
+
const id = assertRepoId(repoId);
|
|
252
|
+
const encoded = id.split('/').map(encodeURIComponent).join('/');
|
|
253
|
+
const [info, tree] = await Promise.all([
|
|
254
|
+
cached(`/api/models/${encoded}`, () => catalogGet(`/api/models/${encoded}`, fetchImpl)),
|
|
255
|
+
cached(`/tree/${encoded}`, () => catalogGet(`/api/models/${encoded}/tree/main?recursive=true`, fetchImpl)),
|
|
256
|
+
]);
|
|
257
|
+
const summary = toSummary({ ...info, id });
|
|
258
|
+
if (!summary)
|
|
259
|
+
throw new CatalogError('Hugging Face returned an unusable model payload', 502);
|
|
260
|
+
const { quants, projectors } = groupCatalogQuants(Array.isArray(tree) ? tree : []);
|
|
261
|
+
if (quants.length === 0) {
|
|
262
|
+
throw new CatalogError(`${id} publishes no GGUF files`, 404);
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
...summary,
|
|
266
|
+
quants,
|
|
267
|
+
projectors,
|
|
268
|
+
license: info.cardData?.license,
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
export const catalogFileUrl = (repoId, filePath) => {
|
|
272
|
+
const id = assertRepoId(repoId);
|
|
273
|
+
const encodedRepo = id.split('/').map(encodeURIComponent).join('/');
|
|
274
|
+
const encodedPath = filePath.split('/').map(encodeURIComponent).join('/');
|
|
275
|
+
return `${HUGGINGFACE_ORIGIN}/${encodedRepo}/resolve/main/${encodedPath}?download=true`;
|
|
276
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readdir, stat } from 'fs/promises';
|
|
2
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'path';
|
|
3
|
+
const MAX_MODELS = 10_000;
|
|
4
|
+
const MAX_DEPTH = 8;
|
|
5
|
+
export const localGgufModelName = (modelPath) => basename(modelPath, extname(modelPath));
|
|
6
|
+
const isInside = (path, root) => {
|
|
7
|
+
const rel = relative(resolve(root), resolve(path));
|
|
8
|
+
// Across Windows drives `relative` yields an absolute path, which carries no
|
|
9
|
+
// `..` prefix and would otherwise read as "inside" — silently dropping every
|
|
10
|
+
// model root that lives on a different drive than the first one.
|
|
11
|
+
if (isAbsolute(rel))
|
|
12
|
+
return false;
|
|
13
|
+
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !rel.includes('\0'));
|
|
14
|
+
};
|
|
15
|
+
export const discoverGgufModels = async (directories, { maxModels = MAX_MODELS, maxDepth = MAX_DEPTH } = {}) => {
|
|
16
|
+
const models = [];
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
const roots = [...new Set(directories.map((directory) => resolve(directory)))]
|
|
19
|
+
.sort((a, b) => a.length - b.length)
|
|
20
|
+
.filter((root, index, all) => !all.slice(0, index).some((parent) => isInside(root, parent)));
|
|
21
|
+
const walk = async (root, directory, depth) => {
|
|
22
|
+
if (depth > maxDepth || models.length >= maxModels || !isInside(directory, root))
|
|
23
|
+
return;
|
|
24
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (models.length >= maxModels)
|
|
27
|
+
break;
|
|
28
|
+
const path = join(directory, entry.name);
|
|
29
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
30
|
+
await walk(root, path, depth + 1);
|
|
31
|
+
}
|
|
32
|
+
else if (entry.isFile() && extname(entry.name).toLowerCase() === '.gguf') {
|
|
33
|
+
const lowerName = entry.name.toLowerCase();
|
|
34
|
+
if (lowerName.startsWith('mmproj'))
|
|
35
|
+
continue;
|
|
36
|
+
const shard = lowerName.match(/-(\d{5})-of-\d{5}\.gguf$/);
|
|
37
|
+
if (shard && shard[1] !== '00001')
|
|
38
|
+
continue;
|
|
39
|
+
const absolute = resolve(path);
|
|
40
|
+
if (seen.has(absolute))
|
|
41
|
+
continue;
|
|
42
|
+
seen.add(absolute);
|
|
43
|
+
const fileStat = await stat(absolute).catch(() => null);
|
|
44
|
+
models.push({
|
|
45
|
+
id: absolute,
|
|
46
|
+
name: localGgufModelName(absolute),
|
|
47
|
+
provider: 'llama-cpp',
|
|
48
|
+
path: absolute,
|
|
49
|
+
sizeBytes: fileStat?.size,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
for (const root of roots) {
|
|
55
|
+
await walk(root, root, 0);
|
|
56
|
+
if (models.length >= maxModels)
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
return models.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
|
60
|
+
};
|