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,151 @@
|
|
|
1
|
+
import { mkdir, rm, stat } from 'fs/promises';
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from 'path';
|
|
3
|
+
import { defaultDownloadDirectory, getGenerationConfig } from './config.js';
|
|
4
|
+
import { downloadToFile, sha256File } from './downloader.js';
|
|
5
|
+
import { assertRepoId, catalogFileUrl, getCatalogModel, huggingFaceToken, CatalogError, } from './model-catalog.js';
|
|
6
|
+
/** Turns `unsloth/Qwen3.5-4B-GGUF` into a flat, filesystem-safe folder name. */
|
|
7
|
+
export const modelFolderName = (repoId) => assertRepoId(repoId).replace(/\//g, '__').replace(/[^A-Za-z0-9._-]/g, '_');
|
|
8
|
+
const assertInside = (root, candidate) => {
|
|
9
|
+
const absolute = resolve(candidate);
|
|
10
|
+
const rel = relative(resolve(root), absolute);
|
|
11
|
+
// `isAbsolute` catches the cross-drive case, where `relative` returns a full
|
|
12
|
+
// path that has no `..` prefix to detect.
|
|
13
|
+
if (isAbsolute(rel) ||
|
|
14
|
+
rel === '' ||
|
|
15
|
+
rel === '..' ||
|
|
16
|
+
rel.startsWith(`..${sep}`) ||
|
|
17
|
+
rel.includes('\0')) {
|
|
18
|
+
throw new CatalogError('Refusing to write a model file outside the download directory', 400);
|
|
19
|
+
}
|
|
20
|
+
return absolute;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* File names come from a remote API, so only a plain relative path of safe
|
|
24
|
+
* segments is accepted before it is joined onto the download directory.
|
|
25
|
+
*/
|
|
26
|
+
const safeRepoPath = (repoPath) => {
|
|
27
|
+
const segments = repoPath.split('/').filter(Boolean);
|
|
28
|
+
if (segments.length === 0 ||
|
|
29
|
+
segments.length > 4 ||
|
|
30
|
+
segments.some((segment) => segment === '.' || segment === '..' || !/^[A-Za-z0-9._-]{1,128}$/.test(segment))) {
|
|
31
|
+
throw new CatalogError(`Unsafe model file path: ${repoPath}`, 400);
|
|
32
|
+
}
|
|
33
|
+
return segments.join('/');
|
|
34
|
+
};
|
|
35
|
+
export const resolveDownloadDirectory = async () => {
|
|
36
|
+
const config = await getGenerationConfig().catch(() => null);
|
|
37
|
+
return config?.llamaCpp.downloadDirectory ?? defaultDownloadDirectory();
|
|
38
|
+
};
|
|
39
|
+
export const planModelDownload = async ({ repoId, quant, includeProjector = false, directory, fetchImpl = fetch, detail, }) => {
|
|
40
|
+
const id = assertRepoId(repoId);
|
|
41
|
+
const model = detail ?? (await getCatalogModel(id, fetchImpl));
|
|
42
|
+
const selected = quant
|
|
43
|
+
? model.quants.find((entry) => entry.label.toUpperCase() === quant.toUpperCase())
|
|
44
|
+
: (model.quants.find((entry) => entry.recommended) ?? model.quants[0]);
|
|
45
|
+
if (!selected) {
|
|
46
|
+
throw new CatalogError(`${id} does not publish a "${quant}" quantisation`, 404);
|
|
47
|
+
}
|
|
48
|
+
const root = resolve(directory ?? (await resolveDownloadDirectory()));
|
|
49
|
+
const folder = join(root, modelFolderName(id));
|
|
50
|
+
const chosen = includeProjector
|
|
51
|
+
? [...selected.files, ...model.projectors]
|
|
52
|
+
: selected.files;
|
|
53
|
+
const files = chosen.map((file) => {
|
|
54
|
+
const repoPath = safeRepoPath(file.path);
|
|
55
|
+
return {
|
|
56
|
+
url: catalogFileUrl(id, repoPath),
|
|
57
|
+
repoPath,
|
|
58
|
+
// Shards and projectors are flattened next to each other; llama.cpp finds
|
|
59
|
+
// sibling shards by name, so the repository's folder layout is not needed.
|
|
60
|
+
destination: assertInside(folder, join(folder, repoPath.split('/').pop())),
|
|
61
|
+
sizeBytes: file.sizeBytes,
|
|
62
|
+
sha256: file.sha256,
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
const primary = files.find((file) => !/^mmproj/i.test(file.repoPath.split('/').pop() ?? ''));
|
|
66
|
+
if (!primary)
|
|
67
|
+
throw new CatalogError('Selected quantisation has no model file', 404);
|
|
68
|
+
return {
|
|
69
|
+
repoId: id,
|
|
70
|
+
quant: selected.label,
|
|
71
|
+
files,
|
|
72
|
+
totalBytes: files.reduce((sum, file) => sum + file.sizeBytes, 0),
|
|
73
|
+
directory: folder,
|
|
74
|
+
primaryPath: primary.destination,
|
|
75
|
+
gated: model.gated,
|
|
76
|
+
license: model.license,
|
|
77
|
+
contextLength: model.contextLength,
|
|
78
|
+
requiresToken: model.gated !== false && !huggingFaceToken(),
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
const alreadyPresent = async (file) => {
|
|
82
|
+
const info = await stat(file.destination).catch(() => null);
|
|
83
|
+
if (!info?.isFile() || info.size !== file.sizeBytes)
|
|
84
|
+
return false;
|
|
85
|
+
if (!file.sha256)
|
|
86
|
+
return true;
|
|
87
|
+
return (await sha256File(file.destination)) === file.sha256;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Cheap "is this model already on disk?" check for planning screens: name and
|
|
91
|
+
* exact byte size only, no hashing. Re-reading 17 GB just to render a button
|
|
92
|
+
* label is not worth it — `downloadModel` still verifies the digest before it
|
|
93
|
+
* skips anything, so a size collision costs one re-download, not a bad model.
|
|
94
|
+
*/
|
|
95
|
+
export const modelFilesPresent = async (plan) => {
|
|
96
|
+
if (plan.files.length === 0)
|
|
97
|
+
return false;
|
|
98
|
+
for (const file of plan.files) {
|
|
99
|
+
const info = await stat(file.destination).catch(() => null);
|
|
100
|
+
if (!info?.isFile() || info.size !== file.sizeBytes)
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
};
|
|
105
|
+
export const downloadModel = async (plan, { onProgress, signal, } = {}) => {
|
|
106
|
+
if (plan.requiresToken) {
|
|
107
|
+
throw new CatalogError(`${plan.repoId} is a gated repository. Accept its licence on Hugging Face and set HF_TOKEN in the server .env.`, 403);
|
|
108
|
+
}
|
|
109
|
+
await mkdir(plan.directory, { recursive: true });
|
|
110
|
+
const token = huggingFaceToken();
|
|
111
|
+
let completedBytes = 0;
|
|
112
|
+
for (const [index, file] of plan.files.entries()) {
|
|
113
|
+
signal?.throwIfAborted();
|
|
114
|
+
onProgress?.({ phase: 'verifying', file: file.repoPath });
|
|
115
|
+
if (await alreadyPresent(file)) {
|
|
116
|
+
completedBytes += file.sizeBytes;
|
|
117
|
+
onProgress?.({ phase: 'skipped', file: file.repoPath });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const baseline = completedBytes;
|
|
121
|
+
try {
|
|
122
|
+
await downloadToFile(file.url, file.destination, {
|
|
123
|
+
sha256: file.sha256,
|
|
124
|
+
expectedBytes: file.sizeBytes || undefined,
|
|
125
|
+
signal,
|
|
126
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
127
|
+
onProgress: (progress) => onProgress?.({
|
|
128
|
+
phase: 'downloading',
|
|
129
|
+
file: file.repoPath,
|
|
130
|
+
fileIndex: index + 1,
|
|
131
|
+
totalFiles: plan.files.length,
|
|
132
|
+
downloadedBytes: baseline + progress.downloadedBytes,
|
|
133
|
+
totalBytes: plan.totalBytes,
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
// Cancelling is not a fault: keep the partial file so pressing Download
|
|
139
|
+
// again resumes with a Range request instead of starting over. Any other
|
|
140
|
+
// failure drops it, because a partial shard that llama.cpp would try to
|
|
141
|
+
// load is worse than no shard at all.
|
|
142
|
+
if (!(error instanceof Error && error.name === 'AbortError')) {
|
|
143
|
+
await rm(`${file.destination}.part`, { force: true }).catch(() => undefined);
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
completedBytes += file.sizeBytes;
|
|
148
|
+
}
|
|
149
|
+
onProgress?.({ phase: 'completed', primaryPath: plan.primaryPath });
|
|
150
|
+
return { primaryPath: plan.primaryPath, directory: plan.directory };
|
|
151
|
+
};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
const requestHeaders = (apiKey) => {
|
|
2
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
3
|
+
if (apiKey)
|
|
4
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
5
|
+
return headers;
|
|
6
|
+
};
|
|
7
|
+
const requestSignal = (provider, signal) => {
|
|
8
|
+
const timeout = AbortSignal.timeout(provider === 'llama-cpp' ? 30 * 60_000 : 5 * 60_000);
|
|
9
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
10
|
+
};
|
|
11
|
+
const errorChain = (error) => {
|
|
12
|
+
const messages = [];
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
let current = error;
|
|
15
|
+
while (current && !seen.has(current) && messages.length < 5) {
|
|
16
|
+
seen.add(current);
|
|
17
|
+
if (current instanceof Error) {
|
|
18
|
+
if (current.message && !messages.includes(current.message))
|
|
19
|
+
messages.push(current.message);
|
|
20
|
+
current = current.cause;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
const message = String(current);
|
|
24
|
+
if (message && !messages.includes(message))
|
|
25
|
+
messages.push(message);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return messages.join(' → ') || 'unknown transport error';
|
|
30
|
+
};
|
|
31
|
+
const providerRequestError = (provider, stage, error) => new Error(`${provider} ${stage}: ${errorChain(error)}`, { cause: error });
|
|
32
|
+
const responseErrorMessage = async (response, provider) => {
|
|
33
|
+
const raw = await response.text().catch(() => '');
|
|
34
|
+
if (raw) {
|
|
35
|
+
try {
|
|
36
|
+
const payload = JSON.parse(raw);
|
|
37
|
+
if (payload.error?.message)
|
|
38
|
+
return payload.error.message;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
const compact = raw.replace(/\s+/g, ' ').trim();
|
|
42
|
+
if (compact)
|
|
43
|
+
return compact.slice(0, 2_000);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return `${provider} request failed (${response.status} ${response.statusText || 'HTTP error'})`;
|
|
47
|
+
};
|
|
48
|
+
const requestBody = (request, stream, extraBody) => JSON.stringify({
|
|
49
|
+
model: request.model,
|
|
50
|
+
messages: request.messages,
|
|
51
|
+
temperature: request.temperature,
|
|
52
|
+
max_tokens: request.maxTokens,
|
|
53
|
+
stream,
|
|
54
|
+
...(stream ? { stream_options: { include_usage: true } } : {}),
|
|
55
|
+
...extraBody,
|
|
56
|
+
});
|
|
57
|
+
const textDelta = (value) => {
|
|
58
|
+
if (typeof value === 'string')
|
|
59
|
+
return value;
|
|
60
|
+
if (!Array.isArray(value))
|
|
61
|
+
return '';
|
|
62
|
+
return value
|
|
63
|
+
.map((part) => {
|
|
64
|
+
if (!part || typeof part !== 'object')
|
|
65
|
+
return '';
|
|
66
|
+
const text = part.text;
|
|
67
|
+
return typeof text === 'string' ? text : '';
|
|
68
|
+
})
|
|
69
|
+
.join('');
|
|
70
|
+
};
|
|
71
|
+
const responseUsage = (usage) => usage
|
|
72
|
+
? {
|
|
73
|
+
promptTokens: usage.prompt_tokens,
|
|
74
|
+
completionTokens: usage.completion_tokens,
|
|
75
|
+
totalTokens: usage.total_tokens,
|
|
76
|
+
}
|
|
77
|
+
: undefined;
|
|
78
|
+
const responsePerformance = (timings, usage, elapsedMs) => {
|
|
79
|
+
if (timings?.predicted_per_second && Number.isFinite(timings.predicted_per_second)) {
|
|
80
|
+
return {
|
|
81
|
+
completionTokensPerSecond: timings.predicted_per_second,
|
|
82
|
+
promptTokensPerSecond: timings.prompt_per_second,
|
|
83
|
+
generationMs: timings.predicted_ms,
|
|
84
|
+
source: 'provider',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (!usage?.completion_tokens || elapsedMs <= 0)
|
|
88
|
+
return undefined;
|
|
89
|
+
return {
|
|
90
|
+
completionTokensPerSecond: usage.completion_tokens / (elapsedMs / 1000),
|
|
91
|
+
generationMs: elapsedMs,
|
|
92
|
+
source: 'measured',
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
export const openAiCompatibleChat = async ({ provider, baseUrl, apiKey, request, extraBody, signal, fetchImpl = fetch, }) => {
|
|
96
|
+
const startedAt = performance.now();
|
|
97
|
+
let response;
|
|
98
|
+
try {
|
|
99
|
+
response = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: requestHeaders(apiKey),
|
|
102
|
+
body: requestBody(request, false, extraBody),
|
|
103
|
+
signal: requestSignal(provider, signal),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw providerRequestError(provider, 'connection failed', error);
|
|
108
|
+
}
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
throw new Error(`${provider} request failed: ${await responseErrorMessage(response, provider)}`);
|
|
111
|
+
}
|
|
112
|
+
const payload = (await response.json().catch(() => ({})));
|
|
113
|
+
const message = payload.choices?.[0]?.message;
|
|
114
|
+
const content = message?.content?.trim();
|
|
115
|
+
if (!content)
|
|
116
|
+
throw new Error(`${provider} returned an empty response`);
|
|
117
|
+
const reasoning = (message?.reasoning_content || message?.reasoning || '').trim() || undefined;
|
|
118
|
+
return {
|
|
119
|
+
provider,
|
|
120
|
+
model: payload.model || request.model,
|
|
121
|
+
content,
|
|
122
|
+
reasoning,
|
|
123
|
+
usage: responseUsage(payload.usage),
|
|
124
|
+
performance: responsePerformance(payload.timings, payload.usage, performance.now() - startedAt),
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
export const openAiCompatibleChatStream = async (options, onDelta) => {
|
|
128
|
+
const { provider, baseUrl, apiKey, request, extraBody, signal, fetchImpl = fetch } = options;
|
|
129
|
+
const startedAt = performance.now();
|
|
130
|
+
let response;
|
|
131
|
+
try {
|
|
132
|
+
response = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: requestHeaders(apiKey),
|
|
135
|
+
body: requestBody(request, true, extraBody),
|
|
136
|
+
signal: requestSignal(provider, signal),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
throw providerRequestError(provider, 'connection failed', error);
|
|
141
|
+
}
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
throw new Error(`${provider} request failed: ${await responseErrorMessage(response, provider)}`);
|
|
144
|
+
}
|
|
145
|
+
if (!response.body)
|
|
146
|
+
throw new Error(`${provider} returned a response without a stream`);
|
|
147
|
+
const reader = response.body.getReader();
|
|
148
|
+
const decoder = new TextDecoder();
|
|
149
|
+
let buffer = '';
|
|
150
|
+
let content = '';
|
|
151
|
+
let reasoning = '';
|
|
152
|
+
let model = request.model;
|
|
153
|
+
let usage;
|
|
154
|
+
let timings;
|
|
155
|
+
let streamDone = false;
|
|
156
|
+
const consumeLine = async (rawLine) => {
|
|
157
|
+
const line = rawLine.trim();
|
|
158
|
+
if (!line.startsWith('data:'))
|
|
159
|
+
return;
|
|
160
|
+
const data = line.slice(5).trim();
|
|
161
|
+
if (!data)
|
|
162
|
+
return;
|
|
163
|
+
if (data === '[DONE]') {
|
|
164
|
+
streamDone = true;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
let chunk;
|
|
168
|
+
try {
|
|
169
|
+
chunk = JSON.parse(data);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
throw new Error(`${provider} returned an invalid streaming event`, { cause: error });
|
|
173
|
+
}
|
|
174
|
+
if (chunk.error)
|
|
175
|
+
throw new Error(chunk.error.message || `${provider} streaming request failed`);
|
|
176
|
+
if (chunk.model)
|
|
177
|
+
model = chunk.model;
|
|
178
|
+
if (chunk.usage)
|
|
179
|
+
usage = chunk.usage;
|
|
180
|
+
if (chunk.timings)
|
|
181
|
+
timings = chunk.timings;
|
|
182
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
183
|
+
const contentPart = textDelta(delta?.content);
|
|
184
|
+
const reasoningPart = textDelta(delta?.reasoning_content ?? delta?.reasoning);
|
|
185
|
+
if (!contentPart && !reasoningPart)
|
|
186
|
+
return;
|
|
187
|
+
content += contentPart;
|
|
188
|
+
reasoning += reasoningPart;
|
|
189
|
+
await onDelta({
|
|
190
|
+
content: contentPart || undefined,
|
|
191
|
+
reasoning: reasoningPart || undefined,
|
|
192
|
+
model,
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
while (!streamDone) {
|
|
196
|
+
let result;
|
|
197
|
+
try {
|
|
198
|
+
result = await reader.read();
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
throw providerRequestError(provider, 'response stream failed', error);
|
|
202
|
+
}
|
|
203
|
+
const { done, value } = result;
|
|
204
|
+
buffer += decoder.decode(value, { stream: !done });
|
|
205
|
+
let newline = buffer.indexOf('\n');
|
|
206
|
+
while (newline !== -1) {
|
|
207
|
+
const line = buffer.slice(0, newline).replace(/\r$/, '');
|
|
208
|
+
buffer = buffer.slice(newline + 1);
|
|
209
|
+
await consumeLine(line);
|
|
210
|
+
if (streamDone)
|
|
211
|
+
break;
|
|
212
|
+
newline = buffer.indexOf('\n');
|
|
213
|
+
}
|
|
214
|
+
if (done)
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
if (!streamDone && buffer.trim())
|
|
218
|
+
await consumeLine(buffer);
|
|
219
|
+
if (streamDone)
|
|
220
|
+
await reader.cancel().catch(() => undefined);
|
|
221
|
+
if (!content.trim())
|
|
222
|
+
throw new Error(`${provider} returned an empty response`);
|
|
223
|
+
return {
|
|
224
|
+
provider,
|
|
225
|
+
model,
|
|
226
|
+
content,
|
|
227
|
+
reasoning: reasoning.trim() || undefined,
|
|
228
|
+
usage: responseUsage(usage),
|
|
229
|
+
performance: responsePerformance(timings, usage, performance.now() - startedAt),
|
|
230
|
+
};
|
|
231
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { effectiveModelDirectories, getGenerationConfig, generationPathExists } from '../config.js';
|
|
2
|
+
import { findLlamaExecutables } from '../llama-install.js';
|
|
3
|
+
import { getLlamaFailureContext, getManagedLlamaStatus, withLlamaServer, } from '../llama-process.js';
|
|
4
|
+
import { discoverGgufModels, localGgufModelName } from '../model-discovery.js';
|
|
5
|
+
import { openAiCompatibleChat, openAiCompatibleChatStream } from '../openai-compatible.js';
|
|
6
|
+
export const createLlamaCppProvider = (fetchImpl = fetch) => ({
|
|
7
|
+
id: 'llama-cpp',
|
|
8
|
+
label: 'llama.cpp',
|
|
9
|
+
local: true,
|
|
10
|
+
async status() {
|
|
11
|
+
const config = await getGenerationConfig();
|
|
12
|
+
const executable = config.llamaCpp.executablePath
|
|
13
|
+
? generationPathExists(config.llamaCpp.executablePath)
|
|
14
|
+
? config.llamaCpp.executablePath
|
|
15
|
+
: undefined
|
|
16
|
+
: (await findLlamaExecutables())[0];
|
|
17
|
+
const available = Boolean(config.llamaCpp.baseUrl || executable);
|
|
18
|
+
return {
|
|
19
|
+
id: 'llama-cpp',
|
|
20
|
+
label: 'llama.cpp',
|
|
21
|
+
available,
|
|
22
|
+
local: true,
|
|
23
|
+
detail: config.llamaCpp.baseUrl
|
|
24
|
+
? `External local server: ${config.llamaCpp.baseUrl}`
|
|
25
|
+
: executable
|
|
26
|
+
? `Executable: ${executable}`
|
|
27
|
+
: 'llama-server not found; run npm run setup:llama or set its path.',
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
async listModels() {
|
|
31
|
+
const config = await getGenerationConfig();
|
|
32
|
+
if (config.llamaCpp.baseUrl) {
|
|
33
|
+
const response = await fetchImpl(`${config.llamaCpp.baseUrl}/v1/models`, {
|
|
34
|
+
signal: AbortSignal.timeout(10_000),
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok)
|
|
37
|
+
throw new Error(`llama.cpp models request failed (${response.status})`);
|
|
38
|
+
const payload = (await response.json());
|
|
39
|
+
return (payload.data ?? [])
|
|
40
|
+
.filter((model) => Boolean(model.id))
|
|
41
|
+
.map((model) => ({
|
|
42
|
+
id: model.id,
|
|
43
|
+
name: model.id,
|
|
44
|
+
provider: 'llama-cpp',
|
|
45
|
+
loaded: true,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
const [models, runtime] = await Promise.all([
|
|
49
|
+
discoverGgufModels(effectiveModelDirectories(config.llamaCpp)),
|
|
50
|
+
Promise.resolve(getManagedLlamaStatus()),
|
|
51
|
+
]);
|
|
52
|
+
return models.map((model) => ({
|
|
53
|
+
...model,
|
|
54
|
+
loaded: runtime.state !== 'stopped' && runtime.model !== undefined && model.id === runtime.model,
|
|
55
|
+
}));
|
|
56
|
+
},
|
|
57
|
+
async chat(request, signal) {
|
|
58
|
+
const managed = !(await getGenerationConfig()).llamaCpp.baseUrl;
|
|
59
|
+
try {
|
|
60
|
+
const response = await withLlamaServer(request.model, (baseUrl) => openAiCompatibleChat({
|
|
61
|
+
provider: 'llama-cpp',
|
|
62
|
+
baseUrl,
|
|
63
|
+
request,
|
|
64
|
+
signal,
|
|
65
|
+
fetchImpl,
|
|
66
|
+
}));
|
|
67
|
+
return {
|
|
68
|
+
...response,
|
|
69
|
+
model: managed ? localGgufModelName(request.model) : response.model,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
74
|
+
const context = managed ? getLlamaFailureContext() : undefined;
|
|
75
|
+
throw new Error([`llama.cpp generation failed: ${message}`, context].filter(Boolean).join('\n\n'), { cause: error });
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
async chatStream(request, onDelta, signal) {
|
|
79
|
+
const managed = !(await getGenerationConfig()).llamaCpp.baseUrl;
|
|
80
|
+
const model = managed ? localGgufModelName(request.model) : request.model;
|
|
81
|
+
try {
|
|
82
|
+
const response = await withLlamaServer(request.model, (baseUrl) => openAiCompatibleChatStream({
|
|
83
|
+
provider: 'llama-cpp',
|
|
84
|
+
baseUrl,
|
|
85
|
+
request,
|
|
86
|
+
signal,
|
|
87
|
+
fetchImpl,
|
|
88
|
+
}, (delta) => onDelta({ ...delta, model: managed ? model : delta.model })));
|
|
89
|
+
return { ...response, model: managed ? model : response.model };
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
93
|
+
const context = managed ? getLlamaFailureContext() : undefined;
|
|
94
|
+
throw new Error([`llama.cpp generation failed: ${message}`, context].filter(Boolean).join('\n\n'), { cause: error });
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { getGenerationConfig, getOpenRouterApiKey } from '../config.js';
|
|
2
|
+
import { openAiCompatibleChat, openAiCompatibleChatStream } from '../openai-compatible.js';
|
|
3
|
+
export const isFreeOpenRouterModel = (model) => {
|
|
4
|
+
if (model.id === 'openrouter/free' || model.id.endsWith(':free'))
|
|
5
|
+
return true;
|
|
6
|
+
const prompt = Number(model.promptPrice);
|
|
7
|
+
const completion = Number(model.completionPrice);
|
|
8
|
+
return (model.promptPrice !== undefined &&
|
|
9
|
+
model.promptPrice.trim() !== '' &&
|
|
10
|
+
model.completionPrice !== undefined &&
|
|
11
|
+
model.completionPrice.trim() !== '' &&
|
|
12
|
+
Number.isFinite(prompt) &&
|
|
13
|
+
Number.isFinite(completion) &&
|
|
14
|
+
prompt === 0 &&
|
|
15
|
+
completion === 0);
|
|
16
|
+
};
|
|
17
|
+
const routingPreferences = (enforceZdr, denyDataCollection) => {
|
|
18
|
+
const provider = {};
|
|
19
|
+
if (enforceZdr)
|
|
20
|
+
provider.zdr = true;
|
|
21
|
+
if (denyDataCollection)
|
|
22
|
+
provider.data_collection = 'deny';
|
|
23
|
+
return Object.keys(provider).length > 0 ? { provider } : undefined;
|
|
24
|
+
};
|
|
25
|
+
const openRouterCreatedAt = (value) => {
|
|
26
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
27
|
+
return undefined;
|
|
28
|
+
const date = new Date(value * 1000);
|
|
29
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
30
|
+
};
|
|
31
|
+
export const createOpenRouterProvider = (fetchImpl = fetch) => ({
|
|
32
|
+
id: 'openrouter',
|
|
33
|
+
label: 'OpenRouter',
|
|
34
|
+
local: false,
|
|
35
|
+
async status() {
|
|
36
|
+
const config = await getGenerationConfig();
|
|
37
|
+
return {
|
|
38
|
+
id: 'openrouter',
|
|
39
|
+
label: 'OpenRouter',
|
|
40
|
+
available: config.openRouter.apiKeyConfigured,
|
|
41
|
+
local: false,
|
|
42
|
+
detail: config.openRouter.apiKeyConfigured
|
|
43
|
+
? 'API key configured; prompts leave this device.'
|
|
44
|
+
: 'Set OPENROUTER_API_KEY or a session key in Settings.',
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
async listModels(options = {}) {
|
|
48
|
+
const config = await getGenerationConfig();
|
|
49
|
+
const key = getOpenRouterApiKey();
|
|
50
|
+
if (!key)
|
|
51
|
+
throw new Error('OpenRouter API key is not configured');
|
|
52
|
+
const url = new URL(`${config.openRouter.baseUrl}/models`);
|
|
53
|
+
if (options.sort && options.sort !== 'default')
|
|
54
|
+
url.searchParams.set('sort', options.sort);
|
|
55
|
+
const response = await fetchImpl(url, {
|
|
56
|
+
headers: { Authorization: `Bearer ${key}`, 'User-Agent': 'ThreadShelf' },
|
|
57
|
+
signal: AbortSignal.timeout(30_000),
|
|
58
|
+
});
|
|
59
|
+
const payload = (await response.json().catch(() => ({})));
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
throw new Error(payload.error?.message || `OpenRouter models request failed (${response.status})`);
|
|
62
|
+
}
|
|
63
|
+
return (payload.data ?? [])
|
|
64
|
+
.filter((model) => Boolean(model.id))
|
|
65
|
+
.map((model) => ({
|
|
66
|
+
id: model.id,
|
|
67
|
+
name: model.name || model.id,
|
|
68
|
+
provider: 'openrouter',
|
|
69
|
+
contextLength: model.context_length,
|
|
70
|
+
createdAt: openRouterCreatedAt(model.created),
|
|
71
|
+
promptPrice: model.pricing?.prompt,
|
|
72
|
+
completionPrice: model.pricing?.completion,
|
|
73
|
+
}))
|
|
74
|
+
.filter((model) => !options.freeOnly || isFreeOpenRouterModel(model));
|
|
75
|
+
},
|
|
76
|
+
async chat(request, signal) {
|
|
77
|
+
const config = await getGenerationConfig();
|
|
78
|
+
const key = getOpenRouterApiKey();
|
|
79
|
+
if (!key)
|
|
80
|
+
throw new Error('OpenRouter API key is not configured');
|
|
81
|
+
return openAiCompatibleChat({
|
|
82
|
+
provider: 'openrouter',
|
|
83
|
+
baseUrl: config.openRouter.baseUrl,
|
|
84
|
+
apiKey: key,
|
|
85
|
+
request,
|
|
86
|
+
signal,
|
|
87
|
+
fetchImpl,
|
|
88
|
+
extraBody: routingPreferences(request.openRouterZdr ?? config.openRouter.enforceZdr, config.openRouter.denyDataCollection),
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
async chatStream(request, onDelta, signal) {
|
|
92
|
+
const config = await getGenerationConfig();
|
|
93
|
+
const key = getOpenRouterApiKey();
|
|
94
|
+
if (!key)
|
|
95
|
+
throw new Error('OpenRouter API key is not configured');
|
|
96
|
+
return openAiCompatibleChatStream({
|
|
97
|
+
provider: 'openrouter',
|
|
98
|
+
baseUrl: config.openRouter.baseUrl,
|
|
99
|
+
apiKey: key,
|
|
100
|
+
request,
|
|
101
|
+
signal,
|
|
102
|
+
fetchImpl,
|
|
103
|
+
extraBody: routingPreferences(request.openRouterZdr ?? config.openRouter.enforceZdr, config.openRouter.denyDataCollection),
|
|
104
|
+
}, onDelta);
|
|
105
|
+
},
|
|
106
|
+
});
|