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,59 @@
|
|
|
1
|
+
import { dataPath } from './paths.js';
|
|
2
|
+
const MODEL = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
|
|
3
|
+
const DIMENSION = 384;
|
|
4
|
+
export const getDimension = () => {
|
|
5
|
+
return DIMENSION;
|
|
6
|
+
};
|
|
7
|
+
const loadPipeline = async () => {
|
|
8
|
+
const { env, pipeline } = await import('@huggingface/transformers');
|
|
9
|
+
// Transformers.js caches model weights inside its own node_modules folder by
|
|
10
|
+
// default. For an `npx threadshelf` install that directory is disposable, so
|
|
11
|
+
// the ~50 MB model would be re-downloaded on every run. Keep it with the
|
|
12
|
+
// user's data instead.
|
|
13
|
+
env.cacheDir = process.env.THREADSHELF_MODEL_CACHE || dataPath('modelCache');
|
|
14
|
+
return (await pipeline('feature-extraction', MODEL, {
|
|
15
|
+
dtype: 'q8',
|
|
16
|
+
progress_callback: (p) => {
|
|
17
|
+
const info = p;
|
|
18
|
+
if (info.status === 'progress')
|
|
19
|
+
console.error('[embedding]', info.file, info.progress);
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
};
|
|
23
|
+
export const createEmbedder = (load) => {
|
|
24
|
+
let pipeline;
|
|
25
|
+
return async (texts) => {
|
|
26
|
+
if (texts.length === 0)
|
|
27
|
+
return [];
|
|
28
|
+
pipeline ??= load().catch((error) => {
|
|
29
|
+
pipeline = undefined;
|
|
30
|
+
throw error;
|
|
31
|
+
});
|
|
32
|
+
const pipe = await pipeline;
|
|
33
|
+
const options = { pooling: 'mean', normalize: true };
|
|
34
|
+
try {
|
|
35
|
+
const out = await pipe(texts, options);
|
|
36
|
+
const data = Array.from(out.data);
|
|
37
|
+
const batchSize = out.dims?.[0] === texts.length ? texts.length : texts.length === 1 ? 1 : 0;
|
|
38
|
+
if (batchSize > 0 && data.length % batchSize === 0) {
|
|
39
|
+
const stride = data.length / batchSize;
|
|
40
|
+
return texts.map((_, index) => data.slice(index * stride, (index + 1) * stride));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
if (texts.length === 1)
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
const results = [];
|
|
48
|
+
for (const text of texts) {
|
|
49
|
+
const out = await pipe(text, options);
|
|
50
|
+
results.push(Array.from(out.data));
|
|
51
|
+
}
|
|
52
|
+
return results;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
export const embed = createEmbedder(loadPipeline);
|
|
56
|
+
export const embedOne = async (text) => {
|
|
57
|
+
const [v] = await embed([text]);
|
|
58
|
+
return v;
|
|
59
|
+
};
|
package/dist/src/env.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'fs/promises';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { dataPath } from '../paths.js';
|
|
5
|
+
import { dirname, join, resolve } from 'path';
|
|
6
|
+
import { randomUUID } from 'crypto';
|
|
7
|
+
import { ValidationError } from '../validation.js';
|
|
8
|
+
const KV_CACHE_PROFILES = ['default', 'quality', 'memory'];
|
|
9
|
+
const SPECULATIVE_MODES = ['off', 'auto', 'aggressive'];
|
|
10
|
+
const REASONING_EFFORTS = [
|
|
11
|
+
'default',
|
|
12
|
+
'off',
|
|
13
|
+
'low',
|
|
14
|
+
'medium',
|
|
15
|
+
'high',
|
|
16
|
+
'xhigh',
|
|
17
|
+
];
|
|
18
|
+
let sessionOpenRouterApiKey = '';
|
|
19
|
+
const warnedInvalidEnvironmentValues = new Set();
|
|
20
|
+
export const generationConfigPath = () => resolve(process.env.GENERATION_CONFIG_PATH || dataPath('generationConfig'));
|
|
21
|
+
const normalizePaths = (paths) => [
|
|
22
|
+
...new Set(paths.map((path) => resolve(path.trim())).filter(Boolean)),
|
|
23
|
+
];
|
|
24
|
+
export const defaultModelDirectories = (env = process.env, home = homedir()) => {
|
|
25
|
+
const configured = (env.LLAMA_MODEL_PATHS || '')
|
|
26
|
+
.split(process.platform === 'win32' ? ';' : ':')
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
const defaults = env.THREADSHELF_DISABLE_DEFAULT_MODEL_PATHS === '1'
|
|
29
|
+
? []
|
|
30
|
+
: [
|
|
31
|
+
join(home, '.lmstudio', 'models'),
|
|
32
|
+
join(home, '.lmstudio'),
|
|
33
|
+
join(home, '.cache', 'lm-studio', 'models'),
|
|
34
|
+
join(home, '.cache', 'llama.cpp'),
|
|
35
|
+
join(home, '.cache', 'huggingface', 'hub'),
|
|
36
|
+
];
|
|
37
|
+
return normalizePaths([...configured, ...defaults]);
|
|
38
|
+
};
|
|
39
|
+
export const defaultDownloadDirectory = (env = process.env) => resolve(env.THREADSHELF_MODELS_PATH || dataPath('models'));
|
|
40
|
+
const readStoredConfig = async () => {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(await readFile(generationConfigPath(), 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error.code === 'ENOENT')
|
|
46
|
+
return {};
|
|
47
|
+
if (error instanceof SyntaxError) {
|
|
48
|
+
throw new Error(`Invalid generation config JSON at ${generationConfigPath()}`, {
|
|
49
|
+
cause: error,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const parseLocalBaseUrl = (value, field) => {
|
|
56
|
+
if (value === undefined || value === null || value === '')
|
|
57
|
+
return undefined;
|
|
58
|
+
if (typeof value !== 'string' || value.length > 2048) {
|
|
59
|
+
throw new ValidationError(`Invalid ${field}`, { field });
|
|
60
|
+
}
|
|
61
|
+
let url;
|
|
62
|
+
try {
|
|
63
|
+
url = new URL(value);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new ValidationError(`Invalid ${field}: expected URL`, { field });
|
|
67
|
+
}
|
|
68
|
+
const host = url.hostname.toLowerCase();
|
|
69
|
+
if (!['localhost', '127.0.0.1', '::1', '[::1]'].includes(host)) {
|
|
70
|
+
throw new ValidationError(`Invalid ${field}: llama.cpp endpoint must be loopback-only`, {
|
|
71
|
+
field,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
75
|
+
throw new ValidationError(`Invalid ${field}: expected HTTP(S)`, { field });
|
|
76
|
+
}
|
|
77
|
+
return url.toString().replace(/\/$/, '');
|
|
78
|
+
};
|
|
79
|
+
const parsePath = (value, field) => {
|
|
80
|
+
if (value === undefined || value === null || value === '')
|
|
81
|
+
return undefined;
|
|
82
|
+
if (typeof value !== 'string' || value.length > 4096 || value.includes('\0')) {
|
|
83
|
+
throw new ValidationError(`Invalid ${field}`, { field });
|
|
84
|
+
}
|
|
85
|
+
const trimmed = value.trim();
|
|
86
|
+
if (!trimmed || /^[/\\]{2}/.test(trimmed)) {
|
|
87
|
+
throw new ValidationError(`Invalid ${field}: network paths are not allowed`, { field });
|
|
88
|
+
}
|
|
89
|
+
return resolve(trimmed);
|
|
90
|
+
};
|
|
91
|
+
const parseBoolean = (value, field) => {
|
|
92
|
+
if (value === undefined)
|
|
93
|
+
return undefined;
|
|
94
|
+
if (typeof value !== 'boolean')
|
|
95
|
+
throw new ValidationError(`Invalid ${field}`, { field });
|
|
96
|
+
return value;
|
|
97
|
+
};
|
|
98
|
+
const parseContextSize = (value) => {
|
|
99
|
+
if (value === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
if (!Number.isInteger(value) || value < 512 || value > 1_048_576) {
|
|
102
|
+
throw new ValidationError('Invalid contextSize: expected integer from 512 to 1048576', {
|
|
103
|
+
field: 'contextSize',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
};
|
|
108
|
+
const parseEnum = (value, values, field) => {
|
|
109
|
+
if (value === undefined)
|
|
110
|
+
return undefined;
|
|
111
|
+
if (typeof value !== 'string' || !values.includes(value)) {
|
|
112
|
+
throw new ValidationError(`Invalid ${field}`, { field });
|
|
113
|
+
}
|
|
114
|
+
return value;
|
|
115
|
+
};
|
|
116
|
+
const parseInteger = (value, field, min, max) => {
|
|
117
|
+
if (value === undefined)
|
|
118
|
+
return undefined;
|
|
119
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
120
|
+
throw new ValidationError(`Invalid ${field}: expected integer from ${min} to ${max}`, {
|
|
121
|
+
field,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
};
|
|
126
|
+
const parseTensorSplit = (value) => {
|
|
127
|
+
if (value === undefined || value === '')
|
|
128
|
+
return undefined;
|
|
129
|
+
if (typeof value !== 'string' || value.length > 256) {
|
|
130
|
+
throw new ValidationError('Invalid tensorSplit', { field: 'tensorSplit' });
|
|
131
|
+
}
|
|
132
|
+
const parts = value.split(',').map((part) => part.trim());
|
|
133
|
+
if (parts.length === 0 ||
|
|
134
|
+
parts.length > 16 ||
|
|
135
|
+
parts.some((part) => !part || !Number.isFinite(Number(part)) || Number(part) <= 0)) {
|
|
136
|
+
throw new ValidationError('Invalid tensorSplit: expected positive comma-separated weights', {
|
|
137
|
+
field: 'tensorSplit',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return parts.join(',');
|
|
141
|
+
};
|
|
142
|
+
const parseDirectories = (value) => {
|
|
143
|
+
if (value === undefined)
|
|
144
|
+
return undefined;
|
|
145
|
+
if (!Array.isArray(value) || value.length > 32) {
|
|
146
|
+
throw new ValidationError('Invalid modelDirectories: expected at most 32 paths', {
|
|
147
|
+
field: 'modelDirectories',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return normalizePaths(value.map((entry, index) => {
|
|
151
|
+
const path = parsePath(entry, `modelDirectories[${index}]`);
|
|
152
|
+
if (!path) {
|
|
153
|
+
throw new ValidationError(`Invalid modelDirectories[${index}]: empty path`, {
|
|
154
|
+
field: 'modelDirectories',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return path;
|
|
158
|
+
}));
|
|
159
|
+
};
|
|
160
|
+
const resolveApiKey = () => sessionOpenRouterApiKey || process.env.OPENROUTER_API_KEY || '';
|
|
161
|
+
export const getOpenRouterApiKey = () => resolveApiKey();
|
|
162
|
+
export const clearSessionOpenRouterApiKeyForTests = () => {
|
|
163
|
+
sessionOpenRouterApiKey = '';
|
|
164
|
+
};
|
|
165
|
+
const parseEnvironmentOverride = (name, parser) => {
|
|
166
|
+
const value = process.env[name];
|
|
167
|
+
if (value === undefined)
|
|
168
|
+
return undefined;
|
|
169
|
+
try {
|
|
170
|
+
return parser(value);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
const warningKey = `${name}\0${value}`;
|
|
174
|
+
if (!warnedInvalidEnvironmentValues.has(warningKey)) {
|
|
175
|
+
warnedInvalidEnvironmentValues.add(warningKey);
|
|
176
|
+
console.warn(`[generation] Ignoring invalid ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
export const effectiveModelDirectories = (config) => normalizePaths([
|
|
182
|
+
config.downloadDirectory,
|
|
183
|
+
...config.modelDirectories,
|
|
184
|
+
...config.defaultModelDirectories,
|
|
185
|
+
]);
|
|
186
|
+
export const llamaCppConfigChanged = (previous, next) => JSON.stringify(previous.llamaCpp) !== JSON.stringify(next.llamaCpp);
|
|
187
|
+
export const getGenerationConfig = async () => {
|
|
188
|
+
const stored = await readStoredConfig();
|
|
189
|
+
return {
|
|
190
|
+
experimentalAlpha: true,
|
|
191
|
+
llamaCpp: {
|
|
192
|
+
executablePath: process.env.LLAMA_CPP_SERVER ||
|
|
193
|
+
process.env.LLAMA_SERVER_PATH ||
|
|
194
|
+
stored.llamaCpp?.executablePath,
|
|
195
|
+
baseUrl: parseEnvironmentOverride('LLAMA_CPP_BASE_URL', (value) => parseLocalBaseUrl(value, 'LLAMA_CPP_BASE_URL')) ?? parseLocalBaseUrl(stored.llamaCpp?.baseUrl, 'baseUrl'),
|
|
196
|
+
modelDirectories: normalizePaths(stored.llamaCpp?.modelDirectories ?? []),
|
|
197
|
+
defaultModelDirectories: defaultModelDirectories(),
|
|
198
|
+
downloadDirectory: parseEnvironmentOverride('THREADSHELF_MODELS_PATH', (value) => parsePath(value, 'THREADSHELF_MODELS_PATH')) ??
|
|
199
|
+
(stored.llamaCpp?.downloadDirectory
|
|
200
|
+
? resolve(stored.llamaCpp.downloadDirectory)
|
|
201
|
+
: defaultDownloadDirectory()),
|
|
202
|
+
contextSize: parseEnvironmentOverride('LLAMA_CPP_CONTEXT_SIZE', (value) => parseContextSize(Number(value))) ??
|
|
203
|
+
stored.llamaCpp?.contextSize ??
|
|
204
|
+
8192,
|
|
205
|
+
acceleration: parseEnvironmentOverride('LLAMA_CPP_ACCELERATION', (value) => parseEnum(value, ['auto', 'cpu', 'gpu', 'hybrid', 'multi-gpu'], 'LLAMA_CPP_ACCELERATION')) ??
|
|
206
|
+
stored.llamaCpp?.acceleration ??
|
|
207
|
+
'auto',
|
|
208
|
+
gpuLayers: parseEnvironmentOverride('LLAMA_CPP_GPU_LAYERS', (value) => parseInteger(Number(value), 'LLAMA_CPP_GPU_LAYERS', 1, 999)) ??
|
|
209
|
+
stored.llamaCpp?.gpuLayers ??
|
|
210
|
+
20,
|
|
211
|
+
splitMode: parseEnvironmentOverride('LLAMA_CPP_SPLIT_MODE', (value) => parseEnum(value, ['layer', 'row'], 'LLAMA_CPP_SPLIT_MODE')) ??
|
|
212
|
+
stored.llamaCpp?.splitMode ??
|
|
213
|
+
'layer',
|
|
214
|
+
mainGpu: parseEnvironmentOverride('LLAMA_CPP_MAIN_GPU', (value) => parseInteger(Number(value), 'LLAMA_CPP_MAIN_GPU', 0, 31)) ??
|
|
215
|
+
stored.llamaCpp?.mainGpu ??
|
|
216
|
+
0,
|
|
217
|
+
tensorSplit: parseEnvironmentOverride('LLAMA_CPP_TENSOR_SPLIT', parseTensorSplit) ??
|
|
218
|
+
stored.llamaCpp?.tensorSplit,
|
|
219
|
+
threads: parseEnvironmentOverride('LLAMA_CPP_THREADS', (value) => parseInteger(Number(value), 'LLAMA_CPP_THREADS', -1, 1024)) ??
|
|
220
|
+
stored.llamaCpp?.threads ??
|
|
221
|
+
-1,
|
|
222
|
+
flashAttention: parseEnvironmentOverride('LLAMA_CPP_FLASH_ATTENTION', (value) => parseEnum(value, ['auto', 'on', 'off'], 'LLAMA_CPP_FLASH_ATTENTION')) ??
|
|
223
|
+
stored.llamaCpp?.flashAttention ??
|
|
224
|
+
'auto',
|
|
225
|
+
kvCache: parseEnvironmentOverride('LLAMA_CPP_KV_CACHE', (value) => parseEnum(value, KV_CACHE_PROFILES, 'LLAMA_CPP_KV_CACHE')) ??
|
|
226
|
+
stored.llamaCpp?.kvCache ??
|
|
227
|
+
'quality',
|
|
228
|
+
speculative: parseEnvironmentOverride('LLAMA_CPP_SPECULATIVE', (value) => parseEnum(value, SPECULATIVE_MODES, 'LLAMA_CPP_SPECULATIVE')) ??
|
|
229
|
+
stored.llamaCpp?.speculative ??
|
|
230
|
+
'auto',
|
|
231
|
+
reasoningEffort: parseEnvironmentOverride('LLAMA_CPP_REASONING_EFFORT', (value) => parseEnum(value, REASONING_EFFORTS, 'LLAMA_CPP_REASONING_EFFORT')) ??
|
|
232
|
+
stored.llamaCpp?.reasoningEffort ??
|
|
233
|
+
'medium',
|
|
234
|
+
},
|
|
235
|
+
openRouter: {
|
|
236
|
+
baseUrl: (process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1').replace(/\/$/, ''),
|
|
237
|
+
apiKeyConfigured: Boolean(resolveApiKey()),
|
|
238
|
+
enforceZdr: stored.schemaVersion === 2 ? (stored.openRouter?.enforceZdr ?? false) : false,
|
|
239
|
+
denyDataCollection: stored.schemaVersion === 2 ? (stored.openRouter?.denyDataCollection ?? false) : false,
|
|
240
|
+
},
|
|
241
|
+
diagnostics: {
|
|
242
|
+
persistErrorLogs: stored.schemaVersion === 2 ? (stored.diagnostics?.persistErrorLogs ?? true) : true,
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
export const updateGenerationConfig = async (update) => {
|
|
247
|
+
if (!update || typeof update !== 'object' || Array.isArray(update)) {
|
|
248
|
+
throw new ValidationError('Invalid generation config');
|
|
249
|
+
}
|
|
250
|
+
const current = await readStoredConfig();
|
|
251
|
+
const llamaUpdate = update.llamaCpp;
|
|
252
|
+
const openRouterUpdate = update.openRouter;
|
|
253
|
+
const diagnosticsUpdate = update.diagnostics;
|
|
254
|
+
if (llamaUpdate !== undefined &&
|
|
255
|
+
(typeof llamaUpdate !== 'object' || Array.isArray(llamaUpdate))) {
|
|
256
|
+
throw new ValidationError('Invalid llamaCpp config', { field: 'llamaCpp' });
|
|
257
|
+
}
|
|
258
|
+
if (diagnosticsUpdate !== undefined &&
|
|
259
|
+
(typeof diagnosticsUpdate !== 'object' || Array.isArray(diagnosticsUpdate))) {
|
|
260
|
+
throw new ValidationError('Invalid diagnostics config', { field: 'diagnostics' });
|
|
261
|
+
}
|
|
262
|
+
if (openRouterUpdate !== undefined &&
|
|
263
|
+
(typeof openRouterUpdate !== 'object' || Array.isArray(openRouterUpdate))) {
|
|
264
|
+
throw new ValidationError('Invalid openRouter config', { field: 'openRouter' });
|
|
265
|
+
}
|
|
266
|
+
let nextSessionOpenRouterApiKey = sessionOpenRouterApiKey;
|
|
267
|
+
if (openRouterUpdate?.apiKey !== undefined) {
|
|
268
|
+
if (typeof openRouterUpdate.apiKey !== 'string' ||
|
|
269
|
+
openRouterUpdate.apiKey.length > 4096 ||
|
|
270
|
+
openRouterUpdate.apiKey.includes('\0')) {
|
|
271
|
+
throw new ValidationError('Invalid apiKey', { field: 'apiKey' });
|
|
272
|
+
}
|
|
273
|
+
nextSessionOpenRouterApiKey = openRouterUpdate.apiKey.trim();
|
|
274
|
+
}
|
|
275
|
+
const clearApiKey = parseBoolean(openRouterUpdate?.clearApiKey, 'clearApiKey');
|
|
276
|
+
if (clearApiKey === true)
|
|
277
|
+
nextSessionOpenRouterApiKey = '';
|
|
278
|
+
const next = {
|
|
279
|
+
schemaVersion: 2,
|
|
280
|
+
llamaCpp: {
|
|
281
|
+
executablePath: llamaUpdate?.executablePath !== undefined
|
|
282
|
+
? parsePath(llamaUpdate.executablePath, 'executablePath')
|
|
283
|
+
: current.llamaCpp?.executablePath,
|
|
284
|
+
baseUrl: llamaUpdate?.baseUrl !== undefined
|
|
285
|
+
? parseLocalBaseUrl(llamaUpdate.baseUrl, 'baseUrl')
|
|
286
|
+
: current.llamaCpp?.baseUrl,
|
|
287
|
+
modelDirectories: parseDirectories(llamaUpdate?.modelDirectories) ?? current.llamaCpp?.modelDirectories ?? [],
|
|
288
|
+
downloadDirectory: (llamaUpdate?.downloadDirectory !== undefined
|
|
289
|
+
? parsePath(llamaUpdate.downloadDirectory, 'downloadDirectory')
|
|
290
|
+
: current.llamaCpp?.downloadDirectory) ?? undefined,
|
|
291
|
+
contextSize: parseContextSize(llamaUpdate?.contextSize) ?? current.llamaCpp?.contextSize ?? 8192,
|
|
292
|
+
acceleration: parseEnum(llamaUpdate?.acceleration, ['auto', 'cpu', 'gpu', 'hybrid', 'multi-gpu'], 'acceleration') ??
|
|
293
|
+
current.llamaCpp?.acceleration ??
|
|
294
|
+
'auto',
|
|
295
|
+
gpuLayers: parseInteger(llamaUpdate?.gpuLayers, 'gpuLayers', 1, 999) ??
|
|
296
|
+
current.llamaCpp?.gpuLayers ??
|
|
297
|
+
20,
|
|
298
|
+
splitMode: parseEnum(llamaUpdate?.splitMode, ['layer', 'row'], 'splitMode') ??
|
|
299
|
+
current.llamaCpp?.splitMode ??
|
|
300
|
+
'layer',
|
|
301
|
+
mainGpu: parseInteger(llamaUpdate?.mainGpu, 'mainGpu', 0, 31) ?? current.llamaCpp?.mainGpu ?? 0,
|
|
302
|
+
tensorSplit: llamaUpdate?.tensorSplit !== undefined
|
|
303
|
+
? parseTensorSplit(llamaUpdate.tensorSplit)
|
|
304
|
+
: current.llamaCpp?.tensorSplit,
|
|
305
|
+
threads: parseInteger(llamaUpdate?.threads, 'threads', -1, 1024) ?? current.llamaCpp?.threads ?? -1,
|
|
306
|
+
flashAttention: parseEnum(llamaUpdate?.flashAttention, ['auto', 'on', 'off'], 'flashAttention') ??
|
|
307
|
+
current.llamaCpp?.flashAttention ??
|
|
308
|
+
'auto',
|
|
309
|
+
kvCache: parseEnum(llamaUpdate?.kvCache, KV_CACHE_PROFILES, 'kvCache') ??
|
|
310
|
+
current.llamaCpp?.kvCache ??
|
|
311
|
+
'quality',
|
|
312
|
+
speculative: parseEnum(llamaUpdate?.speculative, SPECULATIVE_MODES, 'speculative') ??
|
|
313
|
+
current.llamaCpp?.speculative ??
|
|
314
|
+
'auto',
|
|
315
|
+
reasoningEffort: parseEnum(llamaUpdate?.reasoningEffort, REASONING_EFFORTS, 'reasoningEffort') ??
|
|
316
|
+
current.llamaCpp?.reasoningEffort ??
|
|
317
|
+
'medium',
|
|
318
|
+
},
|
|
319
|
+
openRouter: {
|
|
320
|
+
enforceZdr: parseBoolean(openRouterUpdate?.enforceZdr, 'enforceZdr') ??
|
|
321
|
+
(current.schemaVersion === 2 ? current.openRouter?.enforceZdr : undefined) ??
|
|
322
|
+
false,
|
|
323
|
+
denyDataCollection: parseBoolean(openRouterUpdate?.denyDataCollection, 'denyDataCollection') ??
|
|
324
|
+
(current.schemaVersion === 2 ? current.openRouter?.denyDataCollection : undefined) ??
|
|
325
|
+
false,
|
|
326
|
+
},
|
|
327
|
+
diagnostics: {
|
|
328
|
+
persistErrorLogs: parseBoolean(diagnosticsUpdate?.persistErrorLogs, 'persistErrorLogs') ??
|
|
329
|
+
(current.schemaVersion === 2 ? current.diagnostics?.persistErrorLogs : undefined) ??
|
|
330
|
+
true,
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
const path = generationConfigPath();
|
|
334
|
+
await mkdir(dirname(path), { recursive: true });
|
|
335
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
336
|
+
await writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, {
|
|
337
|
+
encoding: 'utf8',
|
|
338
|
+
mode: 0o600,
|
|
339
|
+
});
|
|
340
|
+
await rename(temporary, path);
|
|
341
|
+
sessionOpenRouterApiKey = nextSessionOpenRouterApiKey;
|
|
342
|
+
return getGenerationConfig();
|
|
343
|
+
};
|
|
344
|
+
export const generationPathExists = (path) => existsSync(path);
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { createReadStream, createWriteStream } from 'fs';
|
|
3
|
+
import { rename, rm, stat } from 'fs/promises';
|
|
4
|
+
import { Readable, Transform } from 'stream';
|
|
5
|
+
import { pipeline } from 'stream/promises';
|
|
6
|
+
export class DownloadHashMismatchError extends Error {
|
|
7
|
+
expected;
|
|
8
|
+
actual;
|
|
9
|
+
url;
|
|
10
|
+
constructor(expected, actual, url) {
|
|
11
|
+
super(`SHA-256 mismatch for ${url}: expected ${expected}, got ${actual}`);
|
|
12
|
+
this.expected = expected;
|
|
13
|
+
this.actual = actual;
|
|
14
|
+
this.url = url;
|
|
15
|
+
this.name = 'DownloadHashMismatchError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const DEFAULT_ATTEMPTS = 4;
|
|
19
|
+
const DEFAULT_STALL_TIMEOUT_MS = 60_000;
|
|
20
|
+
const partPath = (destination) => `${destination}.part`;
|
|
21
|
+
const fileSize = async (path) => {
|
|
22
|
+
try {
|
|
23
|
+
const info = await stat(path);
|
|
24
|
+
return info.isFile() ? info.size : 0;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
/** Re-reads an existing `.part` so a resumed transfer keeps a single-pass digest. */
|
|
31
|
+
const hashExistingBytes = async (path, hash) => {
|
|
32
|
+
await pipeline(createReadStream(path), new Transform({
|
|
33
|
+
transform(chunk, _encoding, callback) {
|
|
34
|
+
hash.update(chunk);
|
|
35
|
+
callback();
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
};
|
|
39
|
+
const isRetryableStatus = (status) => status === 408 || status === 425 || status === 429 || status >= 500;
|
|
40
|
+
const isRetryableError = (error) => {
|
|
41
|
+
if (error instanceof DownloadHashMismatchError)
|
|
42
|
+
return false;
|
|
43
|
+
if (error instanceof HttpStatusError)
|
|
44
|
+
return isRetryableStatus(error.status);
|
|
45
|
+
// Network-level faults surface as TypeError from fetch or as errno codes from
|
|
46
|
+
// the stream. Both are worth another attempt; a resumed range picks up where
|
|
47
|
+
// the broken transfer stopped.
|
|
48
|
+
return true;
|
|
49
|
+
};
|
|
50
|
+
class HttpStatusError extends Error {
|
|
51
|
+
status;
|
|
52
|
+
constructor(status, url) {
|
|
53
|
+
super(`Download failed (${status}) for ${url}`);
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.name = 'HttpStatusError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const backoffMs = (attempt) => Math.min(1000 * 2 ** (attempt - 1), 8000);
|
|
59
|
+
const delay = (ms, signal) => new Promise((resolveDelay, reject) => {
|
|
60
|
+
if (signal?.aborted) {
|
|
61
|
+
reject(signal.reason);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const timer = setTimeout(() => {
|
|
65
|
+
signal?.removeEventListener('abort', onAbort);
|
|
66
|
+
resolveDelay();
|
|
67
|
+
}, ms);
|
|
68
|
+
const onAbort = () => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
reject(signal?.reason);
|
|
71
|
+
};
|
|
72
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
73
|
+
});
|
|
74
|
+
export const assertDownloadUrl = (url) => {
|
|
75
|
+
const parsed = new URL(url);
|
|
76
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
77
|
+
throw new Error('Download URL must use HTTPS or HTTP');
|
|
78
|
+
}
|
|
79
|
+
return parsed;
|
|
80
|
+
};
|
|
81
|
+
export const downloadToFile = async (url, destination, { sha256, expectedBytes, onProgress, signal, attempts = DEFAULT_ATTEMPTS, stallTimeoutMs = DEFAULT_STALL_TIMEOUT_MS, headers = {}, fetchImpl = fetch, resume = true, } = {}) => {
|
|
82
|
+
const parsed = assertDownloadUrl(url);
|
|
83
|
+
const target = partPath(destination);
|
|
84
|
+
let lastError;
|
|
85
|
+
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt += 1) {
|
|
86
|
+
signal?.throwIfAborted();
|
|
87
|
+
const existing = resume ? await fileSize(target) : 0;
|
|
88
|
+
if (!resume && existing > 0)
|
|
89
|
+
await rm(target, { force: true });
|
|
90
|
+
const stallController = new AbortController();
|
|
91
|
+
const attemptSignal = signal
|
|
92
|
+
? AbortSignal.any([signal, stallController.signal])
|
|
93
|
+
: stallController.signal;
|
|
94
|
+
let stallTimer;
|
|
95
|
+
const armStallTimer = () => {
|
|
96
|
+
clearTimeout(stallTimer);
|
|
97
|
+
stallTimer = setTimeout(() => stallController.abort(new Error(`Download stalled for ${stallTimeoutMs}ms`)), stallTimeoutMs);
|
|
98
|
+
stallTimer.unref?.();
|
|
99
|
+
};
|
|
100
|
+
try {
|
|
101
|
+
armStallTimer();
|
|
102
|
+
const response = await fetchImpl(parsed, {
|
|
103
|
+
redirect: 'follow',
|
|
104
|
+
signal: attemptSignal,
|
|
105
|
+
headers: {
|
|
106
|
+
'User-Agent': 'ThreadShelf',
|
|
107
|
+
...headers,
|
|
108
|
+
...(existing > 0 ? { Range: `bytes=${existing}-` } : {}),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
if (!response.ok)
|
|
112
|
+
throw new HttpStatusError(response.status, url);
|
|
113
|
+
if (!response.body)
|
|
114
|
+
throw new Error(`Download returned no body for ${url}`);
|
|
115
|
+
// A server that ignores Range answers 200 with the whole file; appending
|
|
116
|
+
// to the partial file would corrupt it, so start over in that case.
|
|
117
|
+
const resumed = existing > 0 && response.status === 206;
|
|
118
|
+
if (existing > 0 && !resumed)
|
|
119
|
+
await rm(target, { force: true });
|
|
120
|
+
const resumedBytes = resumed ? existing : 0;
|
|
121
|
+
const declared = Number(response.headers.get('content-length'));
|
|
122
|
+
const totalBytes = Number.isFinite(declared)
|
|
123
|
+
? declared + resumedBytes
|
|
124
|
+
: (expectedBytes ?? undefined);
|
|
125
|
+
const hash = createHash('sha256');
|
|
126
|
+
if (resumedBytes > 0)
|
|
127
|
+
await hashExistingBytes(target, hash);
|
|
128
|
+
let downloadedBytes = resumedBytes;
|
|
129
|
+
onProgress?.({ downloadedBytes, totalBytes, resumedBytes, attempt });
|
|
130
|
+
const meter = new Transform({
|
|
131
|
+
transform(chunk, _encoding, callback) {
|
|
132
|
+
hash.update(chunk);
|
|
133
|
+
downloadedBytes += chunk.length;
|
|
134
|
+
armStallTimer();
|
|
135
|
+
onProgress?.({ downloadedBytes, totalBytes, resumedBytes, attempt });
|
|
136
|
+
callback(null, chunk);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(target, { flags: resumed ? 'a' : 'w', mode: 0o600 }), { signal: attemptSignal });
|
|
140
|
+
clearTimeout(stallTimer);
|
|
141
|
+
const digest = hash.digest('hex');
|
|
142
|
+
if (sha256 && digest !== sha256.toLowerCase()) {
|
|
143
|
+
// Either the source changed or the bytes were tampered with. Keeping the
|
|
144
|
+
// partial file would poison every later resume.
|
|
145
|
+
await rm(target, { force: true });
|
|
146
|
+
throw new DownloadHashMismatchError(sha256.toLowerCase(), digest, url);
|
|
147
|
+
}
|
|
148
|
+
if (expectedBytes !== undefined && downloadedBytes !== expectedBytes) {
|
|
149
|
+
await rm(target, { force: true });
|
|
150
|
+
throw new Error(`Size mismatch for ${url}: expected ${expectedBytes} bytes, got ${downloadedBytes}`);
|
|
151
|
+
}
|
|
152
|
+
await rename(target, destination);
|
|
153
|
+
return { bytes: downloadedBytes, sha256: digest, resumedBytes, attempts: attempt };
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
clearTimeout(stallTimer);
|
|
157
|
+
// A caller-driven abort is a cancellation, never a fault to retry.
|
|
158
|
+
if (signal?.aborted)
|
|
159
|
+
throw signal.reason ?? error;
|
|
160
|
+
lastError = error;
|
|
161
|
+
if (attempt >= attempts || !isRetryableError(error))
|
|
162
|
+
break;
|
|
163
|
+
await delay(backoffMs(attempt), signal);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
throw lastError instanceof Error ? lastError : new Error(`Download failed for ${url}`);
|
|
167
|
+
};
|
|
168
|
+
export const sha256File = async (path) => {
|
|
169
|
+
const hash = createHash('sha256');
|
|
170
|
+
await hashExistingBytes(path, hash);
|
|
171
|
+
return hash.digest('hex');
|
|
172
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, stat, writeFile } from 'fs/promises';
|
|
2
|
+
import { basename, dirname, resolve } from 'path';
|
|
3
|
+
import { dataPath } from '../paths.js';
|
|
4
|
+
import { getGenerationConfig } from './config.js';
|
|
5
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
6
|
+
const KEEP_LOG_BYTES = 4 * 1024 * 1024;
|
|
7
|
+
let pendingWrite = Promise.resolve();
|
|
8
|
+
export const generationErrorLogPath = () => resolve(process.env.GENERATION_ERROR_LOG_PATH || dataPath('generationErrorLog'));
|
|
9
|
+
const appendError = async (request, error) => {
|
|
10
|
+
if (request.persistDiagnostics === false)
|
|
11
|
+
return;
|
|
12
|
+
const config = await getGenerationConfig();
|
|
13
|
+
if (!config.diagnostics.persistErrorLogs)
|
|
14
|
+
return;
|
|
15
|
+
const path = generationErrorLogPath();
|
|
16
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
17
|
+
const entry = [
|
|
18
|
+
`[${new Date().toISOString()}] provider=${request.provider} model=${basename(request.model)}`,
|
|
19
|
+
message,
|
|
20
|
+
'',
|
|
21
|
+
].join('\n');
|
|
22
|
+
await mkdir(dirname(path), { recursive: true });
|
|
23
|
+
await appendFile(path, entry, { encoding: 'utf8', mode: 0o600 });
|
|
24
|
+
const size = await stat(path).then((value) => value.size);
|
|
25
|
+
if (size <= MAX_LOG_BYTES)
|
|
26
|
+
return;
|
|
27
|
+
const content = await readFile(path);
|
|
28
|
+
const tail = content.subarray(-KEEP_LOG_BYTES);
|
|
29
|
+
await writeFile(path, tail[0] === 0x0a ? tail.subarray(1) : tail, { mode: 0o600 });
|
|
30
|
+
};
|
|
31
|
+
export const persistGenerationError = (request, error) => {
|
|
32
|
+
pendingWrite = pendingWrite.then(() => appendError(request, error), () => appendError(request, error));
|
|
33
|
+
return pendingWrite;
|
|
34
|
+
};
|