@bendyline/gezel-inference 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engines/native-file-manifest.json +404 -404
- package/dist/engines/native-manifest.d.ts +4 -4
- package/dist/engines/native-manifest.js +34 -34
- package/dist/http/openai-compat/engine-failure.d.ts +25 -0
- package/dist/http/openai-compat/engine-failure.js +76 -0
- package/dist/http/openai-compat/responses-streaming.js +2 -1
- package/dist/http/openai-compat/responses-translate.d.ts +1 -1
- package/dist/http/openai-compat/translate.d.ts +1 -1
- package/dist/http/routes/engines.js +35 -0
- package/dist/http/routes/v1-chat.js +10 -1
- package/dist/http/routes/v1-models.js +6 -4
- package/dist/http/routes/v1-responses.js +7 -1
- package/dist/knowledge/catalog-host.d.ts +9 -1
- package/dist/knowledge/catalog-host.js +2 -0
- package/dist/knowledge/manager.d.ts +14 -0
- package/dist/knowledge/manager.js +53 -6
- package/dist/knowledge/search-worker.d.ts +1 -1
- package/dist/knowledge/spatial-query.d.ts +2 -3
- package/dist/knowledge/spatial-query.js +12 -6
- package/dist/knowledge/worker-host.js +2 -0
- package/dist/memory/embed-core.d.ts +16 -0
- package/dist/memory/embed-core.js +31 -0
- package/dist/memory/embed-worker.d.ts +7 -3
- package/dist/memory/embed-worker.js +13 -4
- package/dist/memory/embeddings.d.ts +17 -2
- package/dist/memory/embeddings.js +36 -7
- package/dist/models/ensure.d.ts +2 -0
- package/dist/models/ensure.js +9 -0
- package/dist/perf/responsiveness.d.ts +14 -1
- package/dist/perf/responsiveness.js +77 -7
- package/dist/providers/llama-cpp/backend-health.d.ts +30 -0
- package/dist/providers/llama-cpp/backend-health.js +64 -0
- package/dist/providers/llama-cpp/backend-quarantine.d.ts +40 -0
- package/dist/providers/llama-cpp/backend-quarantine.js +100 -0
- package/dist/providers/llama-cpp/build-provider.js +141 -144
- package/dist/providers/llama-cpp/cache-adapter.js +2 -1
- package/dist/providers/llama-cpp/devices.d.ts +11 -2
- package/dist/providers/llama-cpp/devices.js +10 -4
- package/dist/providers/llama-cpp/engine-flags.d.ts +0 -18
- package/dist/providers/llama-cpp/engine-flags.js +12 -18
- package/dist/providers/llama-cpp/gguf-metadata.js +20 -3
- package/dist/providers/llama-cpp/gpu-startup-watch.d.ts +41 -0
- package/dist/providers/llama-cpp/gpu-startup-watch.js +58 -0
- package/dist/providers/llama-cpp/models.d.ts +6 -0
- package/dist/providers/llama-cpp/models.js +19 -1
- package/dist/providers/llama-cpp/offload-planner.d.ts +3 -18
- package/dist/providers/llama-cpp/offload-planner.js +3 -14
- package/dist/providers/llama-cpp/provider.d.ts +8 -0
- package/dist/providers/llama-cpp/provider.js +4 -1
- package/dist/providers/mlx/provider.js +3 -4
- package/dist/providers/mlx/python/qwen4_ple.py +9 -2
- package/dist/providers/native/capacity-broker.d.ts +21 -15
- package/dist/providers/native/capacity-broker.js +16 -10
- package/dist/providers/native/capacity-budget.d.ts +35 -3
- package/dist/providers/native/capacity-budget.js +49 -5
- package/dist/providers/native/device-capacity.d.ts +1 -1
- package/dist/providers/native/device-capacity.js +23 -5
- package/dist/providers/native/local-engine-runtime.js +16 -19
- package/dist/providers/native/measured-budget.js +2 -0
- package/dist/providers/native/supervisor.d.ts +25 -0
- package/dist/providers/native/supervisor.js +59 -7
- package/dist/system/amdgpu-adapters.d.ts +18 -0
- package/dist/system/amdgpu-adapters.js +55 -0
- package/dist/system/gpu-carve-out.d.ts +84 -0
- package/dist/system/gpu-carve-out.js +111 -0
- package/dist/system/memory.d.ts +17 -1
- package/dist/system/memory.js +94 -13
- package/dist/transformers-cache.d.ts +2 -0
- package/dist/transformers-cache.js +6 -3
- package/package.json +5 -5
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Radius pagination binds offsets to the authorized catalog snapshot and
|
|
3
3
|
* query. Cursors confer no authority; the manager recalculates scope on
|
|
4
|
-
* every request.
|
|
5
|
-
* declared regional catalog and source-document naming convention.
|
|
4
|
+
* every request.
|
|
6
5
|
*/
|
|
7
6
|
export class KnowledgeSpatialCursorError extends Error {
|
|
8
7
|
}
|
|
@@ -24,10 +23,17 @@ export function nearbyCursorOffset(cursor, snapshot) {
|
|
|
24
23
|
throw new KnowledgeSpatialCursorError('Invalid or stale nearby cursor; restart the query.');
|
|
25
24
|
}
|
|
26
25
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Source-article ids (`wikipedia-<page-id>`) name the same article in every
|
|
28
|
+
* catalog of one publisher, and regional catalogs overlap heavily — Pacific
|
|
29
|
+
* Northwest and Canada & Alaska share 683 of the former's 1,106 articles — so
|
|
30
|
+
* those copies are one result. Any other id is unique only within its catalog.
|
|
31
|
+
* Keyed on the id, not the catalog name: an earlier catalog-name match never
|
|
32
|
+
* fired for the published `regional-*` catalogs, so every copy came through.
|
|
33
|
+
*/
|
|
34
|
+
const SOURCE_ARTICLE_ID = /^(wikipedia|wikivoyage)-[0-9]+$/;
|
|
35
|
+
export function spatialDocumentIdentity(publisherId, catalogId, documentId) {
|
|
36
|
+
return SOURCE_ARTICLE_ID.test(documentId)
|
|
31
37
|
? `${publisherId}\u0000${documentId}`
|
|
32
38
|
: `${publisherId}\u0000${catalogId}\u0000${documentId}`;
|
|
33
39
|
}
|
|
@@ -141,6 +141,8 @@ export function createWorkerCatalogHost() {
|
|
|
141
141
|
documentsPage: async (key, opts) =>
|
|
142
142
|
// biome-ignore lint/suspicious/noExplicitAny: structured-clone round trip preserves the shape
|
|
143
143
|
(await call('documentsPage', [key, opts])),
|
|
144
|
+
documentInitials: async (key, opts) => (await call('documentInitials', [key, opts])),
|
|
145
|
+
documentPosition: async (key, documentId, opts) => (await call('documentPosition', [key, documentId, opts])),
|
|
144
146
|
nearbyDocuments: async (key, radius, opts) => (await call('nearbyDocuments', [key, radius, opts])),
|
|
145
147
|
getDocument: async (key, documentId) =>
|
|
146
148
|
// biome-ignore lint/suspicious/noExplicitAny: structured-clone round trip preserves the shape
|
|
@@ -106,6 +106,22 @@ export declare function isRetryablePipelineLoadFailure(error: unknown): boolean;
|
|
|
106
106
|
* will not parse is a runtime problem instead, and retrying cannot fix it.
|
|
107
107
|
*/
|
|
108
108
|
export declare function isFreshDownloadFailure(error: unknown, graphVerified: boolean): boolean;
|
|
109
|
+
/** Work abandoned because the host asked, at service shutdown. Not a model fault. */
|
|
110
|
+
export declare class ModelWorkCancelledError extends Error {
|
|
111
|
+
constructor();
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Abandon the model download in flight and stop embedding runs at their next
|
|
115
|
+
* sub-batch, so the worker reaches idle and can be terminated safely.
|
|
116
|
+
*
|
|
117
|
+
* Shutdown cannot simply terminate a busy worker (that aborts the process
|
|
118
|
+
* inside onnxruntime), and a first-run model download outlasted every drain:
|
|
119
|
+
* quitting during it held the app for 30 s, until the quit timeout. A download
|
|
120
|
+
* is plain network I/O, outside onnxruntime, so stopping it is safe; session
|
|
121
|
+
* creation and a single sub-batch still finish. Work that starts afterwards
|
|
122
|
+
* gets a fresh signal.
|
|
123
|
+
*/
|
|
124
|
+
export declare function cancelInFlightModelWork(): void;
|
|
109
125
|
/** Lazily create the feature-extraction pipeline; cached for the process. */
|
|
110
126
|
export declare function loadPipeline(): Promise<Pipeline>;
|
|
111
127
|
/**
|
|
@@ -191,6 +191,29 @@ export function isFreshDownloadFailure(error, graphVerified) {
|
|
|
191
191
|
/** Fresh starts after a failed download, per process, before vector memory is switched off. */
|
|
192
192
|
const MAX_FRESH_DOWNLOAD_RETRIES = 3;
|
|
193
193
|
let freshDownloadFailures = 0;
|
|
194
|
+
/** Work abandoned because the host asked, at service shutdown. Not a model fault. */
|
|
195
|
+
export class ModelWorkCancelledError extends Error {
|
|
196
|
+
constructor() {
|
|
197
|
+
super('model work was cancelled for shutdown');
|
|
198
|
+
this.name = 'ModelWorkCancelledError';
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
let cancelController = new AbortController();
|
|
202
|
+
/**
|
|
203
|
+
* Abandon the model download in flight and stop embedding runs at their next
|
|
204
|
+
* sub-batch, so the worker reaches idle and can be terminated safely.
|
|
205
|
+
*
|
|
206
|
+
* Shutdown cannot simply terminate a busy worker (that aborts the process
|
|
207
|
+
* inside onnxruntime), and a first-run model download outlasted every drain:
|
|
208
|
+
* quitting during it held the app for 30 s, until the quit timeout. A download
|
|
209
|
+
* is plain network I/O, outside onnxruntime, so stopping it is safe; session
|
|
210
|
+
* creation and a single sub-batch still finish. Work that starts afterwards
|
|
211
|
+
* gets a fresh signal.
|
|
212
|
+
*/
|
|
213
|
+
export function cancelInFlightModelWork() {
|
|
214
|
+
cancelController.abort();
|
|
215
|
+
cancelController = new AbortController();
|
|
216
|
+
}
|
|
194
217
|
/** The daemon pin's ONNX graph, when it names one with a digest. */
|
|
195
218
|
function pinnedGraph() {
|
|
196
219
|
const pin = daemonEmbedderPin();
|
|
@@ -203,6 +226,7 @@ let pipelinePromise = null;
|
|
|
203
226
|
/** Lazily create the feature-extraction pipeline; cached for the process. */
|
|
204
227
|
export async function loadPipeline() {
|
|
205
228
|
if (!pipelinePromise) {
|
|
229
|
+
const signal = cancelController.signal;
|
|
206
230
|
pipelinePromise = (async () => {
|
|
207
231
|
let graphVerified = false;
|
|
208
232
|
try {
|
|
@@ -230,6 +254,7 @@ export async function loadPipeline() {
|
|
|
230
254
|
const outcome = await ensureVerifiedModelFile(cacheDir, modelId, graph, {
|
|
231
255
|
remoteHost: env.remoteHost,
|
|
232
256
|
remotePathTemplate: env.remotePathTemplate,
|
|
257
|
+
signal,
|
|
233
258
|
});
|
|
234
259
|
graphVerified = true;
|
|
235
260
|
if (outcome === 'downloaded')
|
|
@@ -241,6 +266,9 @@ export async function loadPipeline() {
|
|
|
241
266
|
});
|
|
242
267
|
}
|
|
243
268
|
catch (err) {
|
|
269
|
+
// Before the retry bookkeeping: a cancelled download is not a failed one.
|
|
270
|
+
if (signal.aborted)
|
|
271
|
+
throw new ModelWorkCancelledError();
|
|
244
272
|
const missing = isMissingModule(err, TRANSFORMERS_MODULE);
|
|
245
273
|
const message = missing
|
|
246
274
|
? 'Local memory embeddings are an optional npm feature. Install @huggingface/transformers@^4.3.1 alongside @bendyline/gezel-service (see the service README).'
|
|
@@ -272,9 +300,12 @@ export async function loadPipeline() {
|
|
|
272
300
|
export async function runEmbed(texts) {
|
|
273
301
|
if (texts.length === 0)
|
|
274
302
|
return [];
|
|
303
|
+
const signal = cancelController.signal;
|
|
275
304
|
const pipe = await loadPipeline();
|
|
276
305
|
const out = [];
|
|
277
306
|
for (let i = 0; i < texts.length; i += MAX_BATCH) {
|
|
307
|
+
if (signal.aborted)
|
|
308
|
+
throw new ModelWorkCancelledError();
|
|
278
309
|
const slice = texts.slice(i, i + MAX_BATCH).map(capText);
|
|
279
310
|
const result = await pipe(slice, { pooling: 'mean', normalize: true });
|
|
280
311
|
for (const v of result.tolist())
|
|
@@ -11,13 +11,17 @@
|
|
|
11
11
|
* with that profile;
|
|
12
12
|
* reportProgress: send the
|
|
13
13
|
* model download's bytes
|
|
14
|
+
* { cancel: true } — shutdown: stop the
|
|
15
|
+
* download and runs in flight
|
|
14
16
|
* worker → host: { id, progress } — interim, zero or more
|
|
15
17
|
* { id, vectors } — success
|
|
16
18
|
* { id, error, fatal, — failure (fatal ⇒ model
|
|
17
|
-
* retryable, optionalPeerMissing
|
|
18
|
-
*
|
|
19
|
+
* retryable, optionalPeerMissing, unloadable, disable for
|
|
20
|
+
* cancelled } good; optionalPeerMissing
|
|
19
21
|
* ⇒ the peer is just not
|
|
20
22
|
* installed; retryable ⇒ a
|
|
21
|
-
* short host-side cooldown
|
|
23
|
+
* short host-side cooldown;
|
|
24
|
+
* cancelled ⇒ stopped by a
|
|
25
|
+
* cancel, not a fault)
|
|
22
26
|
*/
|
|
23
27
|
export {};
|
|
@@ -11,22 +11,30 @@
|
|
|
11
11
|
* with that profile;
|
|
12
12
|
* reportProgress: send the
|
|
13
13
|
* model download's bytes
|
|
14
|
+
* { cancel: true } — shutdown: stop the
|
|
15
|
+
* download and runs in flight
|
|
14
16
|
* worker → host: { id, progress } — interim, zero or more
|
|
15
17
|
* { id, vectors } — success
|
|
16
18
|
* { id, error, fatal, — failure (fatal ⇒ model
|
|
17
|
-
* retryable, optionalPeerMissing
|
|
18
|
-
*
|
|
19
|
+
* retryable, optionalPeerMissing, unloadable, disable for
|
|
20
|
+
* cancelled } good; optionalPeerMissing
|
|
19
21
|
* ⇒ the peer is just not
|
|
20
22
|
* installed; retryable ⇒ a
|
|
21
|
-
* short host-side cooldown
|
|
23
|
+
* short host-side cooldown;
|
|
24
|
+
* cancelled ⇒ stopped by a
|
|
25
|
+
* cancel, not a fault)
|
|
22
26
|
*/
|
|
23
27
|
import { parentPort } from 'node:worker_threads';
|
|
24
|
-
import { PipelineLoadError, runEmbed, runProfileQueryEmbed } from './embed-core.js';
|
|
28
|
+
import { ModelWorkCancelledError, PipelineLoadError, cancelInFlightModelWork, runEmbed, runProfileQueryEmbed, } from './embed-core.js';
|
|
25
29
|
if (!parentPort) {
|
|
26
30
|
throw new Error('embed-worker must be run as a worker thread');
|
|
27
31
|
}
|
|
28
32
|
const port = parentPort;
|
|
29
33
|
port.on('message', (msg) => {
|
|
34
|
+
if ('cancel' in msg) {
|
|
35
|
+
cancelInFlightModelWork();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
30
38
|
void (async () => {
|
|
31
39
|
try {
|
|
32
40
|
const vectors = msg.profile
|
|
@@ -43,6 +51,7 @@ port.on('message', (msg) => {
|
|
|
43
51
|
fatal: err instanceof PipelineLoadError && !err.retryable,
|
|
44
52
|
retryable: err instanceof PipelineLoadError && err.retryable,
|
|
45
53
|
optionalPeerMissing: err instanceof PipelineLoadError && err.optionalPeerMissing,
|
|
54
|
+
cancelled: err instanceof ModelWorkCancelledError,
|
|
46
55
|
});
|
|
47
56
|
}
|
|
48
57
|
})();
|
|
@@ -35,6 +35,15 @@ export declare class EmbeddingsUnavailableError extends EmbeddingsDisabledError
|
|
|
35
35
|
readonly retryable = true;
|
|
36
36
|
constructor(reason: string);
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* The service is stopping. Callers that keep per-item retry budgets check
|
|
40
|
+
* `shuttingDown` so a quit never counts against a file the way a real model
|
|
41
|
+
* failure does.
|
|
42
|
+
*/
|
|
43
|
+
export declare class EmbeddingsShuttingDownError extends EmbeddingsUnavailableError {
|
|
44
|
+
readonly shuttingDown = true;
|
|
45
|
+
constructor();
|
|
46
|
+
}
|
|
38
47
|
export type EmbeddingPipelineStatus = 'cold' | 'warming' | 'ready' | 'disabled' | 'unavailable';
|
|
39
48
|
export declare function embeddingsDisabledReason(): string | null;
|
|
40
49
|
export declare function embeddingPipelineStatus(): EmbeddingPipelineStatus;
|
|
@@ -49,8 +58,14 @@ export declare function embeddingsHealth(): {
|
|
|
49
58
|
};
|
|
50
59
|
/**
|
|
51
60
|
* Close for service shutdown: refuse new work (it would start a worker, or
|
|
52
|
-
* load the model in-process, as the process exits),
|
|
53
|
-
*
|
|
61
|
+
* load the model in-process, as the process exits), cancel what is in flight,
|
|
62
|
+
* then terminate the worker once it is idle, waiting at most `drainMs`.
|
|
63
|
+
*
|
|
64
|
+
* The cancel is what makes the wait short: a first-run model download stops at
|
|
65
|
+
* once, and an embedding run stops at its next sub-batch. Without it the drain
|
|
66
|
+
* expired with the download still going, and every caller awaiting that
|
|
67
|
+
* embedding (index enrichment among them) held shutdown until the app's quit
|
|
68
|
+
* timeout.
|
|
54
69
|
*/
|
|
55
70
|
export declare function shutdownEmbeddings(drainMs: number): Promise<void>;
|
|
56
71
|
/** Reopen after shutdown — an embedded service can be started again in the same process. */
|
|
@@ -21,7 +21,7 @@ import { Worker } from 'node:worker_threads';
|
|
|
21
21
|
import { createLogger, sameVectorSpace } from '@bendyline/gezel';
|
|
22
22
|
import { retireModelWorker } from '../utils/retire-model-worker.js';
|
|
23
23
|
import { findServiceWorkerEntry } from '../utils/service-worker-entry.js';
|
|
24
|
-
import { PipelineLoadError, daemonEmbedderPin, daemonEmbedderVerified, passageInstruction, queryInstruction, runEmbed, runProfileQueryEmbed, } from './embed-core.js';
|
|
24
|
+
import { ModelWorkCancelledError, PipelineLoadError, cancelInFlightModelWork, daemonEmbedderPin, daemonEmbedderVerified, passageInstruction, queryInstruction, runEmbed, runProfileQueryEmbed, } from './embed-core.js';
|
|
25
25
|
const log = createLogger('memory');
|
|
26
26
|
/**
|
|
27
27
|
* Marker class so callers can tell "the indexer is off on purpose" apart from a
|
|
@@ -46,6 +46,18 @@ export class EmbeddingsUnavailableError extends EmbeddingsDisabledError {
|
|
|
46
46
|
this.message = `memory embeddings temporarily unavailable: ${reason}`;
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* The service is stopping. Callers that keep per-item retry budgets check
|
|
51
|
+
* `shuttingDown` so a quit never counts against a file the way a real model
|
|
52
|
+
* failure does.
|
|
53
|
+
*/
|
|
54
|
+
export class EmbeddingsShuttingDownError extends EmbeddingsUnavailableError {
|
|
55
|
+
shuttingDown = true;
|
|
56
|
+
constructor() {
|
|
57
|
+
super('the service is shutting down');
|
|
58
|
+
this.name = 'EmbeddingsShuttingDownError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
49
61
|
// Once the model fails to load, we permanently flip into a disabled state.
|
|
50
62
|
// Every subsequent call throws the short marker error instead of re-attempting
|
|
51
63
|
// the import and re-printing the full model-load error on every chat turn.
|
|
@@ -142,6 +154,11 @@ function onMessage(msg) {
|
|
|
142
154
|
pending.delete(msg.id);
|
|
143
155
|
if (pending.size === 0)
|
|
144
156
|
worker?.unref();
|
|
157
|
+
if (msg.cancelled) {
|
|
158
|
+
// Our own shutdown cancel: no cooldown, no profile outage, nothing disabled.
|
|
159
|
+
p.reject(new EmbeddingsShuttingDownError());
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
145
162
|
if (msg.error) {
|
|
146
163
|
if (p.profileId) {
|
|
147
164
|
if (!p.localFilesOnly && (msg.fatal || msg.retryable))
|
|
@@ -214,6 +231,8 @@ async function embedInProcess(texts) {
|
|
|
214
231
|
return await runEmbed(texts);
|
|
215
232
|
}
|
|
216
233
|
catch (err) {
|
|
234
|
+
if (err instanceof ModelWorkCancelledError)
|
|
235
|
+
throw new EmbeddingsShuttingDownError();
|
|
217
236
|
if (err instanceof PipelineLoadError) {
|
|
218
237
|
if (err.retryable) {
|
|
219
238
|
markTemporarilyUnavailable(err.message);
|
|
@@ -256,17 +275,27 @@ function markDisabled(message, optionalPeerMissing = false) {
|
|
|
256
275
|
log.error('[memory] failed to load embedding pipeline; vector memory is now disabled for this process.');
|
|
257
276
|
log.error('[memory] underlying error:', message);
|
|
258
277
|
}
|
|
259
|
-
const SHUTTING_DOWN = 'the service is shutting down';
|
|
260
278
|
/**
|
|
261
279
|
* Close for service shutdown: refuse new work (it would start a worker, or
|
|
262
|
-
* load the model in-process, as the process exits),
|
|
263
|
-
*
|
|
280
|
+
* load the model in-process, as the process exits), cancel what is in flight,
|
|
281
|
+
* then terminate the worker once it is idle, waiting at most `drainMs`.
|
|
282
|
+
*
|
|
283
|
+
* The cancel is what makes the wait short: a first-run model download stops at
|
|
284
|
+
* once, and an embedding run stops at its next sub-batch. Without it the drain
|
|
285
|
+
* expired with the download still going, and every caller awaiting that
|
|
286
|
+
* embedding (index enrichment among them) held shutdown until the app's quit
|
|
287
|
+
* timeout.
|
|
264
288
|
*/
|
|
265
289
|
export async function shutdownEmbeddings(drainMs) {
|
|
266
290
|
closed = true;
|
|
267
291
|
const w = worker;
|
|
268
|
-
if (!w)
|
|
292
|
+
if (!w) {
|
|
293
|
+
// In-process fallback: any run in flight is on this thread.
|
|
294
|
+
cancelInFlightModelWork();
|
|
269
295
|
return;
|
|
296
|
+
}
|
|
297
|
+
if (pending.size > 0)
|
|
298
|
+
w.postMessage({ cancel: true });
|
|
270
299
|
if (await retireModelWorker(w, () => pending.size === 0, drainMs)) {
|
|
271
300
|
if (worker === w)
|
|
272
301
|
worker = null;
|
|
@@ -281,7 +310,7 @@ export function openEmbeddings() {
|
|
|
281
310
|
/** Embed `texts`, preferring the worker and degrading to in-process. */
|
|
282
311
|
async function embedMany(texts) {
|
|
283
312
|
if (closed)
|
|
284
|
-
throw new
|
|
313
|
+
throw new EmbeddingsShuttingDownError();
|
|
285
314
|
if (disabledByEnv())
|
|
286
315
|
throw new EmbeddingsDisabledError(ENV_DISABLED_REASON);
|
|
287
316
|
if (disabledReason)
|
|
@@ -368,7 +397,7 @@ export function sharesDaemonEmbedder(profile) {
|
|
|
368
397
|
export async function embedKnowledgeQuery(text, profile, opts = {}) {
|
|
369
398
|
const { onDownloadProgress, localFilesOnly = false } = opts;
|
|
370
399
|
if (closed)
|
|
371
|
-
throw new
|
|
400
|
+
throw new EmbeddingsShuttingDownError();
|
|
372
401
|
// The default daemon pipeline may fetch weights on a cold load.
|
|
373
402
|
if (!localFilesOnly && sharesDaemonEmbedder(profile)) {
|
|
374
403
|
const vector = await embedQuery(text);
|
package/dist/models/ensure.d.ts
CHANGED
|
@@ -111,6 +111,8 @@ export interface CreateEnsureModelOptions {
|
|
|
111
111
|
backend: EnsureBackend;
|
|
112
112
|
catalogId: string;
|
|
113
113
|
}) => void;
|
|
114
|
+
/** Test seam for catalog backend exclusions — defaults to the process's own. */
|
|
115
|
+
resolveLlamaBackend?: () => string | undefined;
|
|
114
116
|
}
|
|
115
117
|
/**
|
|
116
118
|
* Parse a qualified id (`<backend>:<catalogId>`) into its parts. Bare
|
package/dist/models/ensure.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { llamaCppBackendExclusion, llamaCppBackendLabel } from '@bendyline/gezel';
|
|
3
|
+
import { currentLlamaCppBackend } from '../providers/llama-cpp/backend-health.js';
|
|
2
4
|
/**
|
|
3
5
|
* Error class with a structured `code` so the route can pick the
|
|
4
6
|
* right HTTP status. Avoids regex-matching error message strings.
|
|
@@ -160,6 +162,13 @@ export async function createEnsureModelOrchestrator(opts) {
|
|
|
160
162
|
].filter((id) => Boolean(id));
|
|
161
163
|
throw new KnownEnsureError('no_source_for_backend', `chat-model "${catalogId}" has no ${backend} source${alternatives.length > 0 ? ` — try ${alternatives.map((id) => `"${id}"`).join(' or ')}` : ''}`);
|
|
162
164
|
}
|
|
165
|
+
if (backend === 'llama-cpp') {
|
|
166
|
+
const engineBackend = (opts.resolveLlamaBackend ?? currentLlamaCppBackend)();
|
|
167
|
+
const excluded = llamaCppBackendExclusion(detail.manifest.llamaCpp, engineBackend);
|
|
168
|
+
if (excluded && engineBackend) {
|
|
169
|
+
throw new KnownEnsureError('no_source_for_backend', `chat-model "${catalogId}" is not available on the ${llamaCppBackendLabel(engineBackend)} llama.cpp engine: ${excluded}${detail.manifest.mlx ? ` Try "mlx:${catalogId}".` : ''}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
163
172
|
if (await isInstalled(backend, catalogId)) {
|
|
164
173
|
return { status: 'ready', modelId: qualifiedModelId };
|
|
165
174
|
}
|
|
@@ -2,7 +2,7 @@ import { type ClientPerfReport, type PerfSnapshot, type PerfWork } from '@bendyl
|
|
|
2
2
|
/**
|
|
3
3
|
* A gap is measured to within one beat, so this has to stay well inside
|
|
4
4
|
* `STALL_RECORD_MS`. At the same period, a pause long enough to log leaves the
|
|
5
|
-
* watchdog late by more than half the
|
|
5
|
+
* watchdog late by more than half the heartbeat's lateness, which is what `isMainThreadBlock`
|
|
6
6
|
* needs to rule the main thread out.
|
|
7
7
|
*/
|
|
8
8
|
export declare const BEAT_MS = 250;
|
|
@@ -18,8 +18,21 @@ interface WatchdogReport {
|
|
|
18
18
|
* A stale heartbeat is a main-thread block only when the watchdog itself kept
|
|
19
19
|
* time. When the watchdog was late by a comparable amount, the process or the
|
|
20
20
|
* host was paused, and blaming whatever was in flight would be a false lead.
|
|
21
|
+
*
|
|
22
|
+
* Lateness against lateness: both threads sleep a beat between checks, so a
|
|
23
|
+
* pause delays each by about its length. Comparing the watchdog's lateness
|
|
24
|
+
* with the whole gap, beat included, filed short pauses as blocks.
|
|
21
25
|
*/
|
|
22
26
|
export declare function isMainThreadBlock(report: WatchdogReport): boolean;
|
|
27
|
+
/** What a gap cost: the main thread's CPU, and the process's major page faults. */
|
|
28
|
+
export interface StallCost {
|
|
29
|
+
/** Null where the runtime has no per-thread CPU accounting. */
|
|
30
|
+
cpuMs: number | null;
|
|
31
|
+
pageFaults: number;
|
|
32
|
+
}
|
|
33
|
+
/** Running code when it used at least half the stall; otherwise paging or waiting on something. */
|
|
34
|
+
export declare function stallVerdict(durationMs: number, cost: StallCost): 'running code' | 'paging' | 'blocked waiting' | undefined;
|
|
35
|
+
export declare function describeStallCost(durationMs: number, cost: StallCost): string;
|
|
23
36
|
interface ActiveWork {
|
|
24
37
|
label: string;
|
|
25
38
|
startedAt: number;
|
|
@@ -33,7 +33,7 @@ const log = createLogger('perf');
|
|
|
33
33
|
/**
|
|
34
34
|
* A gap is measured to within one beat, so this has to stay well inside
|
|
35
35
|
* `STALL_RECORD_MS`. At the same period, a pause long enough to log leaves the
|
|
36
|
-
* watchdog late by more than half the
|
|
36
|
+
* watchdog late by more than half the heartbeat's lateness, which is what `isMainThreadBlock`
|
|
37
37
|
* needs to rule the main thread out.
|
|
38
38
|
*/
|
|
39
39
|
export const BEAT_MS = 250;
|
|
@@ -55,10 +55,17 @@ const PROFILE_WINDOW_MS = 60_000;
|
|
|
55
55
|
const PROFILE_SAMPLING_US = 5_000;
|
|
56
56
|
const PROFILE_KEEP = 20;
|
|
57
57
|
const LABEL_MAX = 200;
|
|
58
|
+
/**
|
|
59
|
+
* A watchdog tick later than this is kept. Half the smallest recordable
|
|
60
|
+
* lateness: a shorter one cannot rule the main thread out. The floor was a
|
|
61
|
+
* whole period, which threw away the witness's own evidence of a sub-second
|
|
62
|
+
* pause of the whole process, so such pauses were filed as main-thread blocks
|
|
63
|
+
* (idle processes on a swapping machine collected dozens a minute).
|
|
64
|
+
*/
|
|
65
|
+
const WATCHDOG_KEEP_LATE_MS = (STALL_RECORD_MS - BEAT_MS) / 2;
|
|
58
66
|
/**
|
|
59
67
|
* The witness. It keeps its own late ticks and, asked about a gap, answers
|
|
60
|
-
* with the worst one since the gap began.
|
|
61
|
-
* not kept: it cannot reach half of a recordable gap.
|
|
68
|
+
* with the worst one since the gap began.
|
|
62
69
|
*
|
|
63
70
|
* It sleeps on a futex instead of running an event loop: a worker woken by its
|
|
64
71
|
* own timer cost about 1.6 context switches per tick on Windows, against 1.0
|
|
@@ -69,6 +76,7 @@ const LABEL_MAX = 200;
|
|
|
69
76
|
const WATCHDOG_SOURCE = `
|
|
70
77
|
const { parentPort, receiveMessageOnPort, workerData } = require('node:worker_threads');
|
|
71
78
|
const tickMs = workerData.tickMs;
|
|
79
|
+
const keepLateMs = workerData.keepLateMs;
|
|
72
80
|
const asked = new Int32Array(workerData.asked);
|
|
73
81
|
const lateTicks = [];
|
|
74
82
|
let lastTick = Date.now();
|
|
@@ -78,7 +86,7 @@ for (;;) {
|
|
|
78
86
|
if (now - lastTick >= tickMs) {
|
|
79
87
|
const lateMs = now - lastTick - tickMs;
|
|
80
88
|
lastTick = now;
|
|
81
|
-
if (lateMs >=
|
|
89
|
+
if (lateMs >= keepLateMs) {
|
|
82
90
|
lateTicks.push({ at: now, lateMs });
|
|
83
91
|
if (lateTicks.length > 32) lateTicks.shift();
|
|
84
92
|
}
|
|
@@ -98,9 +106,52 @@ for (;;) {
|
|
|
98
106
|
* A stale heartbeat is a main-thread block only when the watchdog itself kept
|
|
99
107
|
* time. When the watchdog was late by a comparable amount, the process or the
|
|
100
108
|
* host was paused, and blaming whatever was in flight would be a false lead.
|
|
109
|
+
*
|
|
110
|
+
* Lateness against lateness: both threads sleep a beat between checks, so a
|
|
111
|
+
* pause delays each by about its length. Comparing the watchdog's lateness
|
|
112
|
+
* with the whole gap, beat included, filed short pauses as blocks.
|
|
101
113
|
*/
|
|
102
114
|
export function isMainThreadBlock(report) {
|
|
103
|
-
return report.watchdogLateMs < report.durationMs / 2;
|
|
115
|
+
return report.watchdogLateMs < (report.durationMs - BEAT_MS) / 2;
|
|
116
|
+
}
|
|
117
|
+
/** Fewer page faults than this in a stall is ordinary; more, with the thread idle, is swap. */
|
|
118
|
+
const PAGING_MIN_FAULTS = 10;
|
|
119
|
+
/**
|
|
120
|
+
* Read at every beat. Both are single syscalls and add no wakeups. A stall's
|
|
121
|
+
* duration alone cannot say whether code ran: on a memory-starved machine most
|
|
122
|
+
* logged stalls were the thread waiting for its own pages to come back from
|
|
123
|
+
* swap, and their CPU profiles held no samples at all.
|
|
124
|
+
*/
|
|
125
|
+
function readStallCounters() {
|
|
126
|
+
const threadCpuUsage = process.threadCpuUsage;
|
|
127
|
+
const cpu = typeof threadCpuUsage === 'function' ? threadCpuUsage() : null;
|
|
128
|
+
return {
|
|
129
|
+
cpuMs: cpu ? (cpu.user + cpu.system) / 1_000 : null,
|
|
130
|
+
pageFaults: process.resourceUsage().majorPageFault,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function stallCostBetween(before, after) {
|
|
134
|
+
return {
|
|
135
|
+
cpuMs: before.cpuMs !== null && after.cpuMs !== null
|
|
136
|
+
? Math.max(0, Math.round(after.cpuMs - before.cpuMs))
|
|
137
|
+
: null,
|
|
138
|
+
pageFaults: Math.max(0, after.pageFaults - before.pageFaults),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Running code when it used at least half the stall; otherwise paging or waiting on something. */
|
|
142
|
+
export function stallVerdict(durationMs, cost) {
|
|
143
|
+
if (cost.cpuMs === null)
|
|
144
|
+
return undefined;
|
|
145
|
+
if (cost.cpuMs >= durationMs / 2)
|
|
146
|
+
return 'running code';
|
|
147
|
+
return cost.pageFaults >= PAGING_MIN_FAULTS ? 'paging' : 'blocked waiting';
|
|
148
|
+
}
|
|
149
|
+
export function describeStallCost(durationMs, cost) {
|
|
150
|
+
const faults = `${cost.pageFaults} page fault${cost.pageFaults === 1 ? '' : 's'}`;
|
|
151
|
+
const verdict = stallVerdict(durationMs, cost);
|
|
152
|
+
if (cost.cpuMs === null)
|
|
153
|
+
return faults;
|
|
154
|
+
return `cpu ${formatPerfMs(cost.cpuMs)}, ${faults}${verdict ? `: ${verdict}` : ''}`;
|
|
104
155
|
}
|
|
105
156
|
/**
|
|
106
157
|
* Work that overlapped `[start, end]`, most suspicious first. Running work
|
|
@@ -295,6 +346,10 @@ class ResponsivenessMonitor {
|
|
|
295
346
|
*/
|
|
296
347
|
awaitingVerdicts = 0;
|
|
297
348
|
rollDeferred = false;
|
|
349
|
+
/** Counters at the last beat, so a gap's cost is the difference at the next one. */
|
|
350
|
+
lastBeatCost = readStallCounters();
|
|
351
|
+
/** What each gap awaiting a verdict cost, keyed by its start. */
|
|
352
|
+
gapCosts = new Map();
|
|
298
353
|
constructor(opts) {
|
|
299
354
|
this.beatTimer = setInterval(() => this.beat(), BEAT_MS);
|
|
300
355
|
this.beatTimer.unref?.();
|
|
@@ -396,11 +451,15 @@ class ResponsivenessMonitor {
|
|
|
396
451
|
this.lastBeatAt = now;
|
|
397
452
|
const gapMs = now - startedAt;
|
|
398
453
|
this.delay.record(Math.max(1, Math.round((gapMs - BEAT_MS) * 1e6)));
|
|
454
|
+
const counters = readStallCounters();
|
|
455
|
+
const before = this.lastBeatCost;
|
|
456
|
+
this.lastBeatCost = counters;
|
|
399
457
|
if (gapMs < STALL_RECORD_MS || !this.watchdog) {
|
|
400
458
|
if (this.rollDeferred && this.awaitingVerdicts === 0)
|
|
401
459
|
this.rollProfile();
|
|
402
460
|
return;
|
|
403
461
|
}
|
|
462
|
+
this.gapCosts.set(startedAt, stallCostBetween(before, counters));
|
|
404
463
|
this.awaitingVerdicts += 1;
|
|
405
464
|
this.watchdog.postMessage({ startedAt, durationMs: gapMs });
|
|
406
465
|
Atomics.store(this.watchdogAsked, 0, 1);
|
|
@@ -420,7 +479,11 @@ class ResponsivenessMonitor {
|
|
|
420
479
|
try {
|
|
421
480
|
const worker = new Worker(WATCHDOG_SOURCE, {
|
|
422
481
|
eval: true,
|
|
423
|
-
workerData: {
|
|
482
|
+
workerData: {
|
|
483
|
+
tickMs: WATCHDOG_TICK_MS,
|
|
484
|
+
keepLateMs: WATCHDOG_KEEP_LATE_MS,
|
|
485
|
+
asked: this.watchdogAsked.buffer,
|
|
486
|
+
},
|
|
424
487
|
});
|
|
425
488
|
worker.on('message', (report) => this.onWatchdogReport(report));
|
|
426
489
|
worker.on('error', (err) => {
|
|
@@ -440,13 +503,19 @@ class ResponsivenessMonitor {
|
|
|
440
503
|
this.rollProfile();
|
|
441
504
|
}
|
|
442
505
|
handleReport(report) {
|
|
506
|
+
const cost = this.gapCosts.get(report.startedAt);
|
|
507
|
+
this.gapCosts.delete(report.startedAt);
|
|
443
508
|
if (!isMainThreadBlock(report))
|
|
444
509
|
return;
|
|
445
510
|
const now = Date.now();
|
|
511
|
+
const verdict = cost ? stallVerdict(report.durationMs, cost) : undefined;
|
|
446
512
|
const stall = {
|
|
447
513
|
at: new Date(report.startedAt).toISOString(),
|
|
448
514
|
durationMs: report.durationMs,
|
|
449
515
|
during: workDuring(report.startedAt, report.startedAt + report.durationMs, this.active.values(), this.finished, now),
|
|
516
|
+
...(cost?.cpuMs != null ? { cpuMs: cost.cpuMs } : {}),
|
|
517
|
+
...(cost ? { pageFaults: cost.pageFaults } : {}),
|
|
518
|
+
...(verdict ? { verdict } : {}),
|
|
450
519
|
};
|
|
451
520
|
pushRing(this.stalls, stall);
|
|
452
521
|
if (report.durationMs < STALL_LOG_MS)
|
|
@@ -454,7 +523,8 @@ class ResponsivenessMonitor {
|
|
|
454
523
|
const running = stall.during.length > 0
|
|
455
524
|
? stall.during.map((w) => `${w.label} (${formatPerfMs(w.durationMs)})`).join(', ')
|
|
456
525
|
: 'nothing tracked';
|
|
457
|
-
const
|
|
526
|
+
const costText = cost ? ` (${describeStallCost(report.durationMs, cost)})` : '';
|
|
527
|
+
const line = `main thread blocked for ${formatPerfMs(report.durationMs)}${costText} — running: ${running}`;
|
|
458
528
|
if (report.durationMs >= STALL_WARN_MS)
|
|
459
529
|
log.warn(line);
|
|
460
530
|
else
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether llama-server is getting the hardware this machine has, as the
|
|
3
|
+
* engine process sees it: the CUDA build discovery passed over for the
|
|
4
|
+
* driver (`GEZEL_LLAMA_CUDA_BLOCKER`), a GPU build that started without its
|
|
5
|
+
* GPU (gpu-startup-watch.ts), and Linux's out-of-memory killer stopping the
|
|
6
|
+
* engine. Each is invisible otherwise — the engine keeps answering, slower,
|
|
7
|
+
* or a chat fails with a crash that reads like any other.
|
|
8
|
+
*
|
|
9
|
+
* Process-wide like the backend env it sits beside: every llama provider in
|
|
10
|
+
* this process launches the same build.
|
|
11
|
+
*/
|
|
12
|
+
import type { LlamaCppBackendHealthResponse } from '@bendyline/gezel';
|
|
13
|
+
import type { GpuUnused } from './gpu-startup-watch.js';
|
|
14
|
+
/** Record what the latest start of a build found; null clears it. */
|
|
15
|
+
export declare function noteGpuStartup(verdict: GpuUnused | null, now?: Date): void;
|
|
16
|
+
export declare function noteHostOutOfMemory(input: {
|
|
17
|
+
model?: string | undefined;
|
|
18
|
+
whileStarting: boolean;
|
|
19
|
+
}, now?: Date): void;
|
|
20
|
+
declare const BACKENDS: readonly ["cuda", "vulkan", "metal", "cpu"];
|
|
21
|
+
/**
|
|
22
|
+
* The backend this process's on-device llama-server runs, for catalog
|
|
23
|
+
* backend exclusions: the build discovery resolved, else what the probe
|
|
24
|
+
* found, else the only build macOS ships. Undefined when an external server
|
|
25
|
+
* is configured through the environment — its backend is not ours to know.
|
|
26
|
+
*/
|
|
27
|
+
export declare function currentLlamaCppBackend(): (typeof BACKENDS)[number] | undefined;
|
|
28
|
+
export declare function llamaBackendHealthView(): LlamaCppBackendHealthResponse;
|
|
29
|
+
export declare function __resetLlamaBackendHealthForTest(): void;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether llama-server is getting the hardware this machine has, as the
|
|
3
|
+
* engine process sees it: the CUDA build discovery passed over for the
|
|
4
|
+
* driver (`GEZEL_LLAMA_CUDA_BLOCKER`), a GPU build that started without its
|
|
5
|
+
* GPU (gpu-startup-watch.ts), and Linux's out-of-memory killer stopping the
|
|
6
|
+
* engine. Each is invisible otherwise — the engine keeps answering, slower,
|
|
7
|
+
* or a chat fails with a crash that reads like any other.
|
|
8
|
+
*
|
|
9
|
+
* Process-wide like the backend env it sits beside: every llama provider in
|
|
10
|
+
* this process launches the same build.
|
|
11
|
+
*/
|
|
12
|
+
import { readLlamaCudaBlocker } from '@bendyline/gezel/native';
|
|
13
|
+
let gpuUnused = null;
|
|
14
|
+
let outOfMemory = null;
|
|
15
|
+
/** Record what the latest start of a build found; null clears it. */
|
|
16
|
+
export function noteGpuStartup(verdict, now = new Date()) {
|
|
17
|
+
gpuUnused = verdict ? { ...verdict, at: now.toISOString() } : null;
|
|
18
|
+
}
|
|
19
|
+
export function noteHostOutOfMemory(input, now = new Date()) {
|
|
20
|
+
outOfMemory = {
|
|
21
|
+
count: (outOfMemory?.count ?? 0) + 1,
|
|
22
|
+
at: now.toISOString(),
|
|
23
|
+
...(input.model ? { model: input.model } : {}),
|
|
24
|
+
whileStarting: input.whileStarting,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const BACKENDS = ['cuda', 'vulkan', 'metal', 'cpu'];
|
|
28
|
+
/**
|
|
29
|
+
* The backend this process's on-device llama-server runs, for catalog
|
|
30
|
+
* backend exclusions: the build discovery resolved, else what the probe
|
|
31
|
+
* found, else the only build macOS ships. Undefined when an external server
|
|
32
|
+
* is configured through the environment — its backend is not ours to know.
|
|
33
|
+
*/
|
|
34
|
+
export function currentLlamaCppBackend() {
|
|
35
|
+
if (process.env.GEZEL_LLAMA_SERVER_URL)
|
|
36
|
+
return undefined;
|
|
37
|
+
for (const raw of [
|
|
38
|
+
process.env.GEZEL_LLAMA_SERVER_BACKEND,
|
|
39
|
+
process.env.GEZEL_LLAMA_DETECTED_BACKEND,
|
|
40
|
+
]) {
|
|
41
|
+
const backend = BACKENDS.find((b) => b === raw);
|
|
42
|
+
if (backend)
|
|
43
|
+
return backend;
|
|
44
|
+
}
|
|
45
|
+
return process.platform === 'darwin' ? 'metal' : undefined;
|
|
46
|
+
}
|
|
47
|
+
export function llamaBackendHealthView() {
|
|
48
|
+
const runningRaw = process.env.GEZEL_LLAMA_SERVER_BACKEND;
|
|
49
|
+
const running = BACKENDS.find((backend) => backend === runningRaw);
|
|
50
|
+
const cudaBlocker = readLlamaCudaBlocker();
|
|
51
|
+
// A verdict about a build the process has since moved off (a quarantine
|
|
52
|
+
// fallback, a lifted quarantine) no longer describes what runs.
|
|
53
|
+
const unused = gpuUnused && gpuUnused.backend === running ? gpuUnused : null;
|
|
54
|
+
return {
|
|
55
|
+
...(running ? { running } : {}),
|
|
56
|
+
...(cudaBlocker ? { cudaBlocker } : {}),
|
|
57
|
+
...(unused ? { gpuUnused: unused } : {}),
|
|
58
|
+
...(outOfMemory ? { outOfMemory } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function __resetLlamaBackendHealthForTest() {
|
|
62
|
+
gpuUnused = null;
|
|
63
|
+
outOfMemory = null;
|
|
64
|
+
}
|