@bendyline/gezel-inference 0.1.0 → 1.0.1
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/README.md +90 -8
- package/dist/cache/adapter.d.ts +7 -0
- package/dist/cache/controller.d.ts +12 -0
- package/dist/cache/controller.js +24 -0
- package/dist/catalog/chat-models/bt/btl4-35b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/gl/glm-5.2-754b-q2/manifest.json +1 -0
- package/dist/catalog/chat-models/index.json +1 -1
- package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/manifest.json +122 -0
- package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/versions/1.0.0/manifest.json +15 -0
- package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/manifest.json +185 -0
- package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/versions/1.0.0/manifest.json +94 -0
- package/dist/catalog/chat-models/mi/minicpm5-1b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/mi/minicpm5-2b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/qw/qwen3.5-0.8b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/raw-index.json +1 -1
- package/dist/catalog/provenance.json +1 -1
- package/dist/engines/electron-native-reuse.d.ts +9 -0
- package/dist/engines/electron-native-reuse.js +146 -45
- package/dist/engines/native-manifest.d.ts +8 -0
- package/dist/engines/native-manifest.js +30 -0
- package/dist/http/openai-compat/apple-model.d.ts +2 -0
- package/dist/http/openai-compat/apple-model.js +21 -1
- package/dist/http/openai-compat/chat-target.d.ts +12 -0
- package/dist/http/openai-compat/chat-target.js +27 -1
- package/dist/http/routes/engines.js +9 -3
- package/dist/http/routes/v1-chat.js +21 -8
- package/dist/http/routes/v1-models.d.ts +6 -0
- package/dist/http/routes/v1-models.js +133 -28
- package/dist/http/routes/v1-responses.js +9 -2
- package/dist/in-flight-requests.d.ts +19 -0
- package/dist/in-flight-requests.js +34 -0
- package/dist/inference-service.js +3 -3
- package/dist/providers/llama-cpp/build-provider.js +33 -8
- package/dist/providers/llama-cpp/cache-adapter.d.ts +12 -0
- package/dist/providers/llama-cpp/cache-adapter.js +34 -0
- package/dist/providers/llama-cpp/gguf-metadata-async.js +7 -0
- package/dist/providers/mlx/provider.js +11 -7
- package/dist/providers/mlx/python/batch_processors.py +26 -0
- package/dist/providers/mlx/python/gezel_mlx_server.py +88 -20
- package/dist/providers/mlx/python/response_format.py +374 -0
- package/dist/providers/mlx/python/spec_decode.py +5 -0
- package/dist/providers/mlx/python/think_budget.py +35 -1
- package/dist/providers/mlx/python/tool_grammar.py +62 -3
- package/dist/providers/mlx/reasoning-stream.d.ts +20 -2
- package/dist/providers/mlx/reasoning-stream.js +77 -16
- package/dist/providers/mlx/runtime-diagnostics.d.ts +16 -0
- package/dist/providers/mlx/runtime-diagnostics.js +29 -2
- package/dist/providers/native/capacity-broker.d.ts +35 -0
- package/dist/providers/native/capacity-broker.js +69 -4
- package/dist/providers/native/local-engine-runtime.js +7 -2
- package/dist/remotes/identity.d.ts +1 -0
- package/dist/remotes/identity.js +6 -0
- package/dist/remotes/pinned-fetch.d.ts +2 -0
- package/dist/remotes/pinned-fetch.js +30 -1
- package/dist/remotes/types.d.ts +14 -0
- package/dist/runtime-lock.js +1 -1
- package/dist/system/memory.js +11 -1
- package/dist/utils/service-worker-entry.d.ts +4 -0
- package/dist/utils/service-worker-entry.js +4 -0
- package/package.json +15 -11
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { externalGezelModelId, isRecommendedModel, resolveDistributionProfile, } from '@bendyline/gezel';
|
|
1
|
+
import { estimateManifestKvBytes, externalGezelModelId, isRecommendedModel, localContextFloorTokens, rateModelsForDevice, resolveDistributionProfile, } from '@bendyline/gezel';
|
|
2
2
|
import { resolveOnDeviceProvider } from '@bendyline/gezel/native';
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
|
+
import { CapacityBroker } from '../../providers/native/capacity-broker.js';
|
|
5
|
+
import { detectMemoryProfileCached } from '../../system/memory.js';
|
|
4
6
|
import { appleSystemModel } from '../openai-compat/apple-model.js';
|
|
5
7
|
import { resolveFallbackGezelId } from '../openai-compat/chat-target.js';
|
|
6
8
|
/**
|
|
@@ -30,12 +32,35 @@ const PROVIDERS_TO_ENUMERATE = [
|
|
|
30
32
|
'ds4',
|
|
31
33
|
];
|
|
32
34
|
const LOCAL_PROVIDERS = ['llama-cpp', 'mlx', 'ds4'];
|
|
35
|
+
/**
|
|
36
|
+
* Engines that enforce `response_format` with a grammar: llama-server's
|
|
37
|
+
* `json_schema`, and the MLX sidecar's llguidance JSON grammar
|
|
38
|
+
* (`providers/mlx/python/response_format.py`). ds4 shares llama.cpp's request
|
|
39
|
+
* map, but nothing here shows its server enforces a schema, so it is not
|
|
40
|
+
* advertised.
|
|
41
|
+
*/
|
|
42
|
+
function enforcesResponseFormat(provider) {
|
|
43
|
+
return provider === 'llama-cpp' || provider === 'mlx';
|
|
44
|
+
}
|
|
45
|
+
function catalogCapabilities(manifest, provider) {
|
|
46
|
+
return {
|
|
47
|
+
text: true,
|
|
48
|
+
tools: manifest.supportsTools,
|
|
49
|
+
structuredOutput: enforcesResponseFormat(provider),
|
|
50
|
+
images: false,
|
|
51
|
+
foregroundOnly: false,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
33
54
|
function catalogBackend(manifest) {
|
|
55
|
+
// ds4 has no portable fallback: `discoverNativeBinaries` stamps its binary
|
|
56
|
+
// only where a build for this host is on disk, which is the gate the
|
|
57
|
+
// Settings availability check reads too. Windows has none.
|
|
58
|
+
const ds4 = manifest.ds4 && process.env.GEZEL_DS4_SERVER_BIN ? 'ds4' : null;
|
|
34
59
|
// Store hosts have no provisioned Python runtime. Only advertise models
|
|
35
60
|
// backed by bundled native engines until a frozen MLX runtime ships.
|
|
36
61
|
const store = resolveDistributionProfile().pythonProvisioning === 'frozen-only';
|
|
37
62
|
if (store)
|
|
38
|
-
return manifest.llamaCpp ? 'llama-cpp' :
|
|
63
|
+
return manifest.llamaCpp ? 'llama-cpp' : ds4;
|
|
39
64
|
const preferred = resolveOnDeviceProvider(process.platform, process.arch);
|
|
40
65
|
if (preferred === 'mlx' && manifest.mlx && !manifest.mlx.disabledReason)
|
|
41
66
|
return 'mlx';
|
|
@@ -45,19 +70,71 @@ function catalogBackend(manifest) {
|
|
|
45
70
|
return 'llama-cpp';
|
|
46
71
|
if (manifest.mlx && !manifest.mlx.disabledReason && process.platform === 'darwin')
|
|
47
72
|
return 'mlx';
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
73
|
+
return ds4;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* What loading a catalog model would hold, priced as the capacity broker
|
|
77
|
+
* prices it: the catalog's measured pin when it has one, else the engine's
|
|
78
|
+
* estimate, plus KV at the context floor this machine launches with. ds4's
|
|
79
|
+
* figure already bounds its streamed working set, so it takes no extra KV.
|
|
80
|
+
*/
|
|
81
|
+
function footprintBytes({ manifest, provider, source }, memory) {
|
|
82
|
+
const mmprojBytes = provider === 'llama-cpp' ? manifest.llamaCpp?.mmproj?.sizeBytes : undefined;
|
|
83
|
+
const weights = source.residentBytes ??
|
|
84
|
+
CapacityBroker.estimateResidentBytes(provider, source.approxSizeBytes, mmprojBytes ? { mmprojBytes } : undefined);
|
|
85
|
+
if (provider === 'ds4')
|
|
86
|
+
return weights;
|
|
87
|
+
const ctxTokens = localContextFloorTokens({
|
|
88
|
+
totalRamBytes: memory.totalRamBytes,
|
|
89
|
+
gpuVramBytes: fastGpuBytes(memory),
|
|
90
|
+
});
|
|
91
|
+
// MLX keeps its cache in f16; llama.cpp's default q8_0 is the helper's default.
|
|
92
|
+
return (weights +
|
|
93
|
+
estimateManifestKvBytes(manifest, {
|
|
94
|
+
ctxTokens,
|
|
95
|
+
...(provider === 'mlx' ? { cacheScale: 1 } : {}),
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* An integrated adapter's advertised allocation is shared system RAM, not a
|
|
100
|
+
* fast pool beside it, so fit treats that host as RAM-paced (as Settings does).
|
|
101
|
+
*/
|
|
102
|
+
function fastGpuBytes(memory) {
|
|
103
|
+
return memory.gpuMemoryKind === 'integrated' ? null : memory.gpuVramBytes;
|
|
104
|
+
}
|
|
105
|
+
function recoDevice(memory) {
|
|
106
|
+
return {
|
|
107
|
+
platform: memory.platform,
|
|
108
|
+
gpuVramBytes: fastGpuBytes(memory),
|
|
109
|
+
gpuMemoryKind: memory.gpuMemoryKind,
|
|
110
|
+
totalRamBytes: memory.totalRamBytes,
|
|
111
|
+
usableBytes: memory.usableBytes,
|
|
112
|
+
budgetBytes: memory.budgetBytes,
|
|
113
|
+
};
|
|
51
114
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
115
|
+
/** Curated order, for when this machine's memory could not be read. */
|
|
116
|
+
function byCuratedScore(a, b) {
|
|
117
|
+
const aRecommended = isRecommendedModel(a.manifest);
|
|
118
|
+
const bRecommended = isRecommendedModel(b.manifest);
|
|
119
|
+
if (aRecommended !== bRecommended)
|
|
120
|
+
return aRecommended ? -1 : 1;
|
|
121
|
+
const score = (b.manifest.recoScore ?? 0) - (a.manifest.recoScore ?? 0);
|
|
122
|
+
return score || (a.entry.name ?? a.entry.id).localeCompare(b.entry.name ?? b.entry.id);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Downloadable models, each rated against this machine (`fit`,
|
|
126
|
+
* `recommendation`) and listed best first. A model too big to run here stays
|
|
127
|
+
* listed with `fit.tier: 'too-big'` and a 0 score, so a client decides
|
|
128
|
+
* whether to hide it.
|
|
129
|
+
*/
|
|
130
|
+
async function downloadableCatalogEntries(items, created, existingIds, memoryProfile) {
|
|
131
|
+
const candidates = items.flatMap((item) => {
|
|
56
132
|
const manifest = item.manifest;
|
|
57
133
|
if (manifest.kind !== 'chat-model')
|
|
58
134
|
return [];
|
|
59
135
|
const provider = catalogBackend(manifest);
|
|
60
|
-
|
|
136
|
+
const source = provider === 'mlx' ? manifest.mlx : provider === 'ds4' ? manifest.ds4 : manifest.llamaCpp;
|
|
137
|
+
if (!provider || !source)
|
|
61
138
|
return [];
|
|
62
139
|
const id = `${provider}:${manifest.id}`;
|
|
63
140
|
if (existingIds.has(id))
|
|
@@ -65,6 +142,8 @@ async function downloadableCatalogEntries(ctx, created, existingIds) {
|
|
|
65
142
|
return [
|
|
66
143
|
{
|
|
67
144
|
manifest,
|
|
145
|
+
provider,
|
|
146
|
+
source,
|
|
68
147
|
entry: {
|
|
69
148
|
id,
|
|
70
149
|
object: 'model',
|
|
@@ -78,26 +157,41 @@ async function downloadableCatalogEntries(ctx, created, existingIds) {
|
|
|
78
157
|
locality: 'on-device',
|
|
79
158
|
download_bytes: manifest.approxSizeBytes,
|
|
80
159
|
...(manifest.contextWindow ? { context_window: manifest.contextWindow } : {}),
|
|
81
|
-
capabilities:
|
|
82
|
-
text: true,
|
|
83
|
-
tools: manifest.supportsTools,
|
|
84
|
-
structuredOutput: false,
|
|
85
|
-
images: false,
|
|
86
|
-
foregroundOnly: false,
|
|
87
|
-
},
|
|
160
|
+
capabilities: catalogCapabilities(manifest, provider),
|
|
88
161
|
},
|
|
89
162
|
},
|
|
90
163
|
];
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
164
|
+
});
|
|
165
|
+
if (candidates.length === 0)
|
|
166
|
+
return [];
|
|
167
|
+
const memory = await memoryProfile().catch(() => null);
|
|
168
|
+
if (!memory)
|
|
169
|
+
return candidates.sort(byCuratedScore).map(({ entry }) => entry);
|
|
170
|
+
const rated = candidates.map((candidate) => {
|
|
171
|
+
const { entry, manifest, source } = candidate;
|
|
172
|
+
return {
|
|
173
|
+
id: entry.id,
|
|
174
|
+
residentBytes: footprintBytes(candidate, memory),
|
|
175
|
+
tags: manifest.tags,
|
|
176
|
+
supportsTools: manifest.supportsTools,
|
|
177
|
+
...(manifest.recoScore !== undefined ? { recoScore: manifest.recoScore } : {}),
|
|
178
|
+
...(manifest.licenseClass ? { licenseClass: manifest.licenseClass } : {}),
|
|
179
|
+
...(source.quantization ? { quantization: source.quantization } : {}),
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
const entries = new Map(candidates.map(({ entry }) => [entry.id, entry]));
|
|
183
|
+
return rateModelsForDevice(rated, recoDevice(memory)).flatMap((rating) => {
|
|
184
|
+
const entry = entries.get(rating.id);
|
|
185
|
+
if (!entry)
|
|
186
|
+
return [];
|
|
187
|
+
return [
|
|
188
|
+
{
|
|
189
|
+
...entry,
|
|
190
|
+
fit: { tier: rating.fit.tier, label: rating.fit.label, detail: rating.fit.detail },
|
|
191
|
+
recommendation: { score: rating.score, recommended: rating.recommended },
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
});
|
|
101
195
|
}
|
|
102
196
|
async function buildModelEntries(ctx, created, options) {
|
|
103
197
|
// Advertise every gezel as a selectable OpenAI "model". Keep the
|
|
@@ -128,6 +222,14 @@ async function buildModelEntries(ctx, created, options) {
|
|
|
128
222
|
...(gezel.id === fallbackGezelId ? { is_fallback: true } : {}),
|
|
129
223
|
}));
|
|
130
224
|
}
|
|
225
|
+
const catalogItems = await ctx.catalog.list('chat-model').catch(() => []);
|
|
226
|
+
// An installed model carries the capabilities its catalog entry
|
|
227
|
+
// advertised before download; a model with no entry stays unflagged
|
|
228
|
+
// rather than guessing its tools or images support.
|
|
229
|
+
const manifests = new Map();
|
|
230
|
+
for (const item of catalogItems)
|
|
231
|
+
if (item.manifest.kind === 'chat-model')
|
|
232
|
+
manifests.set(item.manifest.id, item.manifest);
|
|
131
233
|
const providers = (options.localOnly ? LOCAL_PROVIDERS : PROVIDERS_TO_ENUMERATE).filter((provider) => provider !== 'mlx' || resolveDistributionProfile().pythonProvisioning !== 'frozen-only');
|
|
132
234
|
const buckets = await Promise.all(providers.map(async (provider) => {
|
|
133
235
|
try {
|
|
@@ -146,6 +248,9 @@ async function buildModelEntries(ctx, created, options) {
|
|
|
146
248
|
: {}),
|
|
147
249
|
...(m.contextWindow ? { context_window: m.contextWindow } : {}),
|
|
148
250
|
...(m.supportsReasoning ? { supports_reasoning: true } : {}),
|
|
251
|
+
...(enforcesResponseFormat(provider) && manifests.has(m.id)
|
|
252
|
+
? { capabilities: catalogCapabilities(manifests.get(m.id), provider) }
|
|
253
|
+
: {}),
|
|
149
254
|
}));
|
|
150
255
|
}
|
|
151
256
|
catch {
|
|
@@ -156,7 +261,7 @@ async function buildModelEntries(ctx, created, options) {
|
|
|
156
261
|
}));
|
|
157
262
|
const availableEntries = buckets.flat();
|
|
158
263
|
const existingIds = new Set([...gezelEntries, ...availableEntries].map((entry) => entry.id));
|
|
159
|
-
const downloadable = await downloadableCatalogEntries(
|
|
264
|
+
const downloadable = await downloadableCatalogEntries(catalogItems, created, existingIds, options.memoryProfile ?? (() => detectMemoryProfileCached()));
|
|
160
265
|
const apple = await appleSystemModel(created);
|
|
161
266
|
return [...gezelEntries, ...apple, ...availableEntries, ...downloadable];
|
|
162
267
|
}
|
|
@@ -3,10 +3,11 @@ import { Hono } from 'hono';
|
|
|
3
3
|
import { bodyLimit } from 'hono/body-limit';
|
|
4
4
|
import { streamSSE } from 'hono/streaming';
|
|
5
5
|
import { ZodError } from 'zod';
|
|
6
|
+
import { UnsupportedResponseFormatError } from '../../providers/mlx/runtime-diagnostics.js';
|
|
6
7
|
import { ExternalToolsUnsupportedError, ModelNotInstalledError, } from '../../providers/types.js';
|
|
7
8
|
import { appendCallerOwnedActionLedger } from '../openai-compat/action-ledger.js';
|
|
8
9
|
import { profileForCallerOwnedInference } from '../openai-compat/caller-owned-profile.js';
|
|
9
|
-
import { resolveChatTarget } from '../openai-compat/chat-target.js';
|
|
10
|
+
import { resolveChatTarget, resolvePinnedModelTarget, } from '../openai-compat/chat-target.js';
|
|
10
11
|
import { runResponsesNonStreaming, runResponsesStreaming, } from '../openai-compat/responses-streaming.js';
|
|
11
12
|
import { ResponsesRequestSchema, translateResponsesRequest, } from '../openai-compat/responses-translate.js';
|
|
12
13
|
import { flattenTranscriptIntoPrompt, parseGezelModelRef, resolveModelTarget, } from '../openai-compat/translate.js';
|
|
@@ -259,6 +260,9 @@ export function v1ResponsesRoutes(ctx, opts = {}) {
|
|
|
259
260
|
if (err instanceof ExternalToolsUnsupportedError) {
|
|
260
261
|
return invalidRequest(c, err.message, 'tools_not_supported_for_provider');
|
|
261
262
|
}
|
|
263
|
+
if (err instanceof UnsupportedResponseFormatError) {
|
|
264
|
+
return invalidRequest(c, err.message, err.code);
|
|
265
|
+
}
|
|
262
266
|
const message = err instanceof Error ? err.message : String(err);
|
|
263
267
|
if (message.includes('messages must') ||
|
|
264
268
|
message.includes('empty') ||
|
|
@@ -284,7 +288,10 @@ async function resolveResponsesTarget(model, ctx) {
|
|
|
284
288
|
systemPrefix: '',
|
|
285
289
|
};
|
|
286
290
|
}
|
|
287
|
-
|
|
291
|
+
const pinned = await resolvePinnedModelTarget(ctx, model);
|
|
292
|
+
if (pinned)
|
|
293
|
+
return pinned;
|
|
294
|
+
throw new Error(`Unknown model "${model}". Use an explicit <provider>:<model> id, a model pinned as a default, or an advertised gezel:<role>-<name> alias.`);
|
|
288
295
|
}
|
|
289
296
|
function prependSystemPrefix(input, prefix) {
|
|
290
297
|
if (!prefix)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-process transport's open requests, and the one handle that keeps Node
|
|
3
|
+
* running while any of them is open.
|
|
4
|
+
*
|
|
5
|
+
* The direct transport has no socket, so nothing a request awaits is
|
|
6
|
+
* guaranteed to hold the event loop: engine supervisors and workers unref their
|
|
7
|
+
* timers and threads so that an idle host never blocks exit. A plain Node
|
|
8
|
+
* script exited with code 13 ("unsettled top-level await") inside `chat()`
|
|
9
|
+
* while llama-server cold-started; Electron's own handles had hidden it.
|
|
10
|
+
*/
|
|
11
|
+
export declare class InFlightRequests {
|
|
12
|
+
private readonly open;
|
|
13
|
+
private keepAlive;
|
|
14
|
+
get size(): number;
|
|
15
|
+
add(controller: AbortController): void;
|
|
16
|
+
delete(controller: AbortController): void;
|
|
17
|
+
/** Abort and forget every open request, including a body nobody will read. */
|
|
18
|
+
abortAll(): void;
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-process transport's open requests, and the one handle that keeps Node
|
|
3
|
+
* running while any of them is open.
|
|
4
|
+
*
|
|
5
|
+
* The direct transport has no socket, so nothing a request awaits is
|
|
6
|
+
* guaranteed to hold the event loop: engine supervisors and workers unref their
|
|
7
|
+
* timers and threads so that an idle host never blocks exit. A plain Node
|
|
8
|
+
* script exited with code 13 ("unsettled top-level await") inside `chat()`
|
|
9
|
+
* while llama-server cold-started; Electron's own handles had hidden it.
|
|
10
|
+
*/
|
|
11
|
+
export class InFlightRequests {
|
|
12
|
+
open = new Set();
|
|
13
|
+
keepAlive;
|
|
14
|
+
get size() {
|
|
15
|
+
return this.open.size;
|
|
16
|
+
}
|
|
17
|
+
add(controller) {
|
|
18
|
+
this.open.add(controller);
|
|
19
|
+
this.keepAlive ??= setInterval(() => { }, 2 ** 30);
|
|
20
|
+
}
|
|
21
|
+
delete(controller) {
|
|
22
|
+
if (!this.open.delete(controller) || this.open.size > 0)
|
|
23
|
+
return;
|
|
24
|
+
clearInterval(this.keepAlive);
|
|
25
|
+
this.keepAlive = undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Abort and forget every open request, including a body nobody will read. */
|
|
28
|
+
abortAll() {
|
|
29
|
+
for (const controller of [...this.open]) {
|
|
30
|
+
controller.abort();
|
|
31
|
+
this.delete(controller);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -18,6 +18,7 @@ import { v1KnowledgeRoutes } from './http/routes/v1-knowledge.js';
|
|
|
18
18
|
import { v1ModelsEnsureRoutes } from './http/routes/v1-models-ensure.js';
|
|
19
19
|
import { v1ModelsRoutes } from './http/routes/v1-models.js';
|
|
20
20
|
import { v1ResponsesRoutes } from './http/routes/v1-responses.js';
|
|
21
|
+
import { InFlightRequests } from './in-flight-requests.js';
|
|
21
22
|
import { KnowledgeManager } from './knowledge/manager.js';
|
|
22
23
|
import { createWorkerCatalogHost } from './knowledge/worker-host.js';
|
|
23
24
|
import { embeddingsHealth, openEmbeddings, shutdownEmbeddings } from './memory/embeddings.js';
|
|
@@ -44,14 +45,13 @@ export async function startService(options = {}) {
|
|
|
44
45
|
const runtimeDir = join(home, 'runtime');
|
|
45
46
|
const lock = await acquireSingleInstanceLock({ runtimeDir, lockPath: join(runtimeDir, 'lock') });
|
|
46
47
|
const cleanup = [() => lock.release()];
|
|
47
|
-
const requests = new
|
|
48
|
+
const requests = new InFlightRequests();
|
|
48
49
|
let stopping;
|
|
49
50
|
let closed = false;
|
|
50
51
|
const stop = () => {
|
|
51
52
|
stopping ??= (async () => {
|
|
52
53
|
closed = true;
|
|
53
|
-
|
|
54
|
-
controller.abort();
|
|
54
|
+
requests.abortAll();
|
|
55
55
|
const errors = [];
|
|
56
56
|
for (const close of cleanup.reverse())
|
|
57
57
|
try {
|
|
@@ -8,7 +8,7 @@ import { effectiveEngineRelease, isEnginePinned } from '../../engines/native-man
|
|
|
8
8
|
import { engineInstallPhase } from '../../engines/registry.js';
|
|
9
9
|
import { localDeviceSettingsLabel, noModelYetMessage, notReadyError, } from '../active-install-message.js';
|
|
10
10
|
import { resolveCatalogLlamaCppEngineConfig, resolveCatalogReasoningBudget, } from '../catalog-model-config.js';
|
|
11
|
-
import { CapacityDeniedError, NOT_ENOUGH_MEMORY_MESSAGE, availableSystemRamBytes, formatContextCapacityDenial, minViableLocalContextTokens, planAdaptiveContextGrowth, pressureIdleGraceMs, resolveLlamaCppContextRequirement, } from '../native/capacity-broker.js';
|
|
11
|
+
import { CapacityDeniedError, NOT_ENOUGH_MEMORY_MESSAGE, autoKvCacheMaxBytes, availableSystemRamBytes, formatContextCapacityDenial, minViableLocalContextTokens, planAdaptiveContextGrowth, pressureIdleGraceMs, resolveLlamaCppContextRequirement, } from '../native/capacity-broker.js';
|
|
12
12
|
import { measureAvailableGpuBytes } from '../native/device-capacity.js';
|
|
13
13
|
import { engineApiKey, withEngineApiKey } from '../native/engine-api-key.js';
|
|
14
14
|
import { makeEngineKey } from '../native/engine-key.js';
|
|
@@ -803,7 +803,13 @@ export async function buildLlamaCppProvider(opts) {
|
|
|
803
803
|
architecture: modelCatalogInfo?.architecture,
|
|
804
804
|
modelId: defaultModelId ?? undefined,
|
|
805
805
|
});
|
|
806
|
+
// A full-size cache past the automatic KV ceiling is declined even
|
|
807
|
+
// where it fits: gemma4-31b's is 55 GB at 64K, 11x its windowed
|
|
808
|
+
// cache, for a cross-request reuse gain nobody had measured.
|
|
809
|
+
const kvCeilingBytes = autoKvCacheMaxBytes();
|
|
810
|
+
const fullCacheOverCeiling = fullKvBytesAtRequested !== undefined && fullKvBytesAtRequested > kvCeilingBytes;
|
|
806
811
|
const fullKvOverBudget = fullCacheFitsFastMemory === false ||
|
|
812
|
+
fullCacheOverCeiling ||
|
|
807
813
|
!admission.minimumSatisfied ||
|
|
808
814
|
admission.slots < slots ||
|
|
809
815
|
admission.clamped;
|
|
@@ -867,13 +873,32 @@ export async function buildLlamaCppProvider(opts) {
|
|
|
867
873
|
const fastFitDetail = fullCacheFitsFastMemory === false && fullKvBytesAtRequested !== undefined
|
|
868
874
|
? `projected weights + full KV ${((residentBytes + fullKvBytesAtRequested) / 1024 ** 3).toFixed(1)} GiB exceeds available fast memory ${(Math.max(0, fastBudgetBytes - committedOtherBytes) / 1024 ** 3).toFixed(1)} GiB`
|
|
869
875
|
: undefined;
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
876
|
+
const overCeilingOnly = fullCacheOverCeiling &&
|
|
877
|
+
fullCacheFitsFastMemory !== false &&
|
|
878
|
+
admission.minimumSatisfied &&
|
|
879
|
+
admission.slots >= slots &&
|
|
880
|
+
!admission.clamped;
|
|
881
|
+
if (overCeilingOnly && fullKvBytesAtRequested !== undefined) {
|
|
882
|
+
// A policy choice on a machine that could hold the cache, not
|
|
883
|
+
// a shortage: info, not warn.
|
|
884
|
+
log.info([
|
|
885
|
+
`[llama-cpp] ${modelCatalogInfo?.id ?? 'model'}: the full SWA cache at the requested `,
|
|
886
|
+
`${effectiveNumCtx * slots}-token total context would take `,
|
|
887
|
+
`${(fullKvBytesAtRequested / 1024 ** 3).toFixed(1)} GiB, past the `,
|
|
888
|
+
`${(kvCeilingBytes / 1024 ** 3).toFixed(1)} GiB automatic KV ceiling — keeping the full `,
|
|
889
|
+
'context and declining the --swa-full auto-default instead; the windowed KV cache ',
|
|
890
|
+
'holds it (cross-request prefix reuse unavailable).',
|
|
891
|
+
].join(''));
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
log.warn([
|
|
895
|
+
`[llama-cpp] ${modelCatalogInfo?.id ?? 'model'}: the full SWA working set at the requested `,
|
|
896
|
+
`${effectiveNumCtx * slots}-token total context does not fit fast memory — keeping the full `,
|
|
897
|
+
'context and declining the --swa-full auto-default instead; the windowed KV cache ',
|
|
898
|
+
'is what actually fits (cross-request prefix reuse unavailable). ',
|
|
899
|
+
`Fit detail: ${fastFitDetail ?? admission.reason ?? 'over budget at the requested slot count'}`,
|
|
900
|
+
].join(''));
|
|
901
|
+
}
|
|
877
902
|
}
|
|
878
903
|
if (windowed) {
|
|
879
904
|
ladderPlan = planCtxTokensForMemory({
|
|
@@ -149,6 +149,11 @@ export declare class LlamaCppCacheAdapter implements EngineCacheAdapter {
|
|
|
149
149
|
* may have been pruned in between — re-seeding keeps it warm).
|
|
150
150
|
*/
|
|
151
151
|
private readonly restoredSessions;
|
|
152
|
+
/**
|
|
153
|
+
* Sessions that will never be resumed (see `markEphemeral`): a save seeds
|
|
154
|
+
* their gezel prefix file when it is missing and writes nothing else.
|
|
155
|
+
*/
|
|
156
|
+
private readonly ephemeralSessions;
|
|
152
157
|
/**
|
|
153
158
|
* Latched off when this llama-server refuses slot save/restore (501
|
|
154
159
|
* "This feature is not supported by multimodal" — fires for mmproj-
|
|
@@ -190,6 +195,7 @@ export declare class LlamaCppCacheAdapter implements EngineCacheAdapter {
|
|
|
190
195
|
prepareForSend(sessionId: string, systemPrompt?: string, layers?: SystemPromptLayers): Promise<void>;
|
|
191
196
|
buildRequestExtras(sessionId: string): Record<string, unknown>;
|
|
192
197
|
evict(sessionIds: readonly string[]): Promise<void>;
|
|
198
|
+
markEphemeral(sessionId: string): void;
|
|
193
199
|
reportUsage(): Promise<readonly EngineCacheUsage[]>;
|
|
194
200
|
warm(_sessionId: string, _messages: readonly CacheWarmMessage[]): Promise<void>;
|
|
195
201
|
/**
|
|
@@ -227,6 +233,12 @@ export declare class LlamaCppCacheAdapter implements EngineCacheAdapter {
|
|
|
227
233
|
private pruneDiskCache;
|
|
228
234
|
private saveSlotForSession;
|
|
229
235
|
private saveSlotForSessionUnlocked;
|
|
236
|
+
/**
|
|
237
|
+
* A one-shot session's only lasting value is the prompt prefix it shares
|
|
238
|
+
* with its gezel's next session. Save the slot straight to that prefix file
|
|
239
|
+
* when none exists yet; otherwise write nothing. Returns whether it wrote.
|
|
240
|
+
*/
|
|
241
|
+
private seedPrefixFromEphemeralSlot;
|
|
230
242
|
/** Try restore-by-session, then restore-by-prefix. Returns true if
|
|
231
243
|
* any restore succeeded. */
|
|
232
244
|
private tryRestoreForSession;
|
|
@@ -149,6 +149,11 @@ export class LlamaCppCacheAdapter {
|
|
|
149
149
|
* may have been pruned in between — re-seeding keeps it warm).
|
|
150
150
|
*/
|
|
151
151
|
restoredSessions = new Set();
|
|
152
|
+
/**
|
|
153
|
+
* Sessions that will never be resumed (see `markEphemeral`): a save seeds
|
|
154
|
+
* their gezel prefix file when it is missing and writes nothing else.
|
|
155
|
+
*/
|
|
156
|
+
ephemeralSessions = new Set();
|
|
152
157
|
/**
|
|
153
158
|
* Latched off when this llama-server refuses slot save/restore (501
|
|
154
159
|
* "This feature is not supported by multimodal" — fires for mmproj-
|
|
@@ -263,8 +268,12 @@ export class LlamaCppCacheAdapter {
|
|
|
263
268
|
this.sessionPrefix.delete(id);
|
|
264
269
|
this.sessionLayerPrefixes.delete(id);
|
|
265
270
|
this.restoredSessions.delete(id);
|
|
271
|
+
this.ephemeralSessions.delete(id);
|
|
266
272
|
}
|
|
267
273
|
}
|
|
274
|
+
markEphemeral(sessionId) {
|
|
275
|
+
this.ephemeralSessions.add(sessionId);
|
|
276
|
+
}
|
|
268
277
|
async reportUsage() {
|
|
269
278
|
const generation = this.engineGeneration;
|
|
270
279
|
const baseUrl = await this.resolveBaseUrl().catch(() => null);
|
|
@@ -410,6 +419,7 @@ export class LlamaCppCacheAdapter {
|
|
|
410
419
|
log.debug(`slot ${chosen} recycled: evicting session ${victimSession.slice(0, 8)} for ${sessionId.slice(0, 8)} (disk save: ${saved ? 'ok' : 'MISSED'})`);
|
|
411
420
|
this.sessionToSlot.delete(victimSession);
|
|
412
421
|
this.slotToSession.delete(chosen);
|
|
422
|
+
this.ephemeralSessions.delete(victimSession);
|
|
413
423
|
}
|
|
414
424
|
}
|
|
415
425
|
// We now have a slot id `chosen` that's free of any current binding.
|
|
@@ -534,6 +544,9 @@ export class LlamaCppCacheAdapter {
|
|
|
534
544
|
const baseUrl = await this.resolveBaseUrl().catch(() => null);
|
|
535
545
|
if (!baseUrl || generation !== this.engineGeneration)
|
|
536
546
|
return false;
|
|
547
|
+
if (this.ephemeralSessions.has(sessionId)) {
|
|
548
|
+
return this.seedPrefixFromEphemeralSlot(baseUrl, slot, sessionId, generation);
|
|
549
|
+
}
|
|
537
550
|
const filename = sessFilename(sessionId);
|
|
538
551
|
const ok = await this.invokeSlotAction(baseUrl, slot, 'save', filename, generation);
|
|
539
552
|
if (!ok)
|
|
@@ -571,6 +584,27 @@ export class LlamaCppCacheAdapter {
|
|
|
571
584
|
}
|
|
572
585
|
return true;
|
|
573
586
|
}
|
|
587
|
+
/**
|
|
588
|
+
* A one-shot session's only lasting value is the prompt prefix it shares
|
|
589
|
+
* with its gezel's next session. Save the slot straight to that prefix file
|
|
590
|
+
* when none exists yet; otherwise write nothing. Returns whether it wrote.
|
|
591
|
+
*/
|
|
592
|
+
async seedPrefixFromEphemeralSlot(baseUrl, slot, sessionId, generation) {
|
|
593
|
+
if (!this.slotSavePath)
|
|
594
|
+
return false;
|
|
595
|
+
const prefixId = this.sessionPrefix.get(sessionId);
|
|
596
|
+
if (!prefixId || this.seededPrefixes.has(prefixId))
|
|
597
|
+
return false;
|
|
598
|
+
const name = prefixFilename(prefixId);
|
|
599
|
+
if (await stat(join(this.slotSavePath, name)).catch(() => null)) {
|
|
600
|
+
this.seededPrefixes.add(prefixId);
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
const ok = await this.invokeSlotAction(baseUrl, slot, 'save', name, generation);
|
|
604
|
+
if (ok)
|
|
605
|
+
this.seededPrefixes.add(prefixId);
|
|
606
|
+
return ok;
|
|
607
|
+
}
|
|
574
608
|
/** Try restore-by-session, then restore-by-prefix. Returns true if
|
|
575
609
|
* any restore succeeded. */
|
|
576
610
|
async tryRestoreForSession(slot, sessionId) {
|
|
@@ -122,6 +122,9 @@ async function runInWorker(path, options) {
|
|
|
122
122
|
const id = nextId++;
|
|
123
123
|
return new Promise((resolveSummary, reject) => {
|
|
124
124
|
pending.set(id, { resolve: resolveSummary, reject });
|
|
125
|
+
// Held while busy: an unreferenced worker let a plain Node host exit
|
|
126
|
+
// mid-await during an engine cold start.
|
|
127
|
+
target.ref();
|
|
125
128
|
target.postMessage({ id, path, ...(options ? { options } : {}) });
|
|
126
129
|
});
|
|
127
130
|
}
|
|
@@ -141,6 +144,8 @@ function ensureWorker() {
|
|
|
141
144
|
else if (worker === next)
|
|
142
145
|
worker = null;
|
|
143
146
|
});
|
|
147
|
+
// An idle reader must never hold the event loop open; runInWorker refs it
|
|
148
|
+
// only while a request is in flight.
|
|
144
149
|
next.unref();
|
|
145
150
|
worker = next;
|
|
146
151
|
return next;
|
|
@@ -158,6 +163,8 @@ function onMessage(reply) {
|
|
|
158
163
|
if (!request)
|
|
159
164
|
return;
|
|
160
165
|
pending.delete(reply.id);
|
|
166
|
+
if (pending.size === 0)
|
|
167
|
+
worker?.unref();
|
|
161
168
|
if (reply.error)
|
|
162
169
|
request.reject(new Error(reply.error));
|
|
163
170
|
else if (reply.summary)
|
|
@@ -58,9 +58,9 @@ import { MlxEngineGate } from './engine-gate.js';
|
|
|
58
58
|
import { EngineHold, engineHoldLabelFor } from './engine-hold.js';
|
|
59
59
|
import { EngineLogRouter } from './engine-log-router.js';
|
|
60
60
|
import { MlxEngineStatsSampler } from './engine-stats-sampler.js';
|
|
61
|
-
import { StreamingReasoningSplit, requestOpensReasoning, } from './reasoning-stream.js';
|
|
61
|
+
import { REASONING_OPEN_HEADER, StreamingReasoningSplit, engineOpensReasoning, requestOpensReasoning, } from './reasoning-stream.js';
|
|
62
62
|
import { applyMlxRequestShape } from './request-shape.js';
|
|
63
|
-
import { PRE_FIRST_BYTE_BASE_MS, buildMidStreamDropMessage, buildPreFirstByteAbortMessage, computePreFirstByteBudgetMs, estimatePromptTokens, formatTps, isMidStreamConnectionDrop,
|
|
63
|
+
import { PRE_FIRST_BYTE_BASE_MS, buildMidStreamDropMessage, buildPreFirstByteAbortMessage, computePreFirstByteBudgetMs, estimatePromptTokens, formatTps, isMidStreamConnectionDrop, mlxHttpError, } from './runtime-diagnostics.js';
|
|
64
64
|
import { classifyMlxFatalErrorLine, classifyMlxStartupLine, } from './stdout-parser.js';
|
|
65
65
|
import { salvageTextualToolCalls } from './textual-tool-call-salvage.js';
|
|
66
66
|
import { APPEND_TO_FILE_CONTINUATION_TOOL, MlxToolCallAccumulator, applyCallableToolGrammar, callableRequestToolNames, chatCompletionToolName, externalToolsAsChatCompletions, hermesRequiredArgGrammarRequested, missingTopLevelRequiredToolArgs, toChatCompletionsTools, validatorReportedMissingRequiredArgs, } from './tool-call-protocol.js';
|
|
@@ -1571,7 +1571,7 @@ class MlxSession extends StreamingSessionBase {
|
|
|
1571
1571
|
this.deps.markUsed();
|
|
1572
1572
|
const txt = await res.text().catch(() => '');
|
|
1573
1573
|
log.error(`turn#${seq}.${turn} HTTP-ERR status=${res.status} ${res.statusText}`);
|
|
1574
|
-
throw
|
|
1574
|
+
throw mlxHttpError(res.status, res.statusText, txt);
|
|
1575
1575
|
}
|
|
1576
1576
|
if (debugOn) {
|
|
1577
1577
|
log.debug(`turn#${seq}.${turn} HTTP-OK status=${res.status} ` +
|
|
@@ -1616,10 +1616,14 @@ class MlxSession extends StreamingSessionBase {
|
|
|
1616
1616
|
// `turn.ramble-detection`; per-model thresholds come from
|
|
1617
1617
|
// the validated config. Absent → detector disabled.
|
|
1618
1618
|
//
|
|
1619
|
-
//
|
|
1620
|
-
// request renders the block already
|
|
1621
|
-
// template streamed a whole `write_file` call
|
|
1622
|
-
|
|
1619
|
+
// The engine reports what the rendered prompt did; the template guess
|
|
1620
|
+
// is only a fallback. A thinking-off request renders the block already
|
|
1621
|
+
// closed (seeding from the template streamed a whole `write_file` call
|
|
1622
|
+
// into the reasoning pane), and Qwen 3.5 0.8B's template opens it only
|
|
1623
|
+
// when thinking is explicitly on (guessing closed streamed its thoughts
|
|
1624
|
+
// and a stray `</think>` as the reply).
|
|
1625
|
+
const opensInReasoning = engineOpensReasoning(res.headers.get(REASONING_OPEN_HEADER)) ??
|
|
1626
|
+
requestOpensReasoning(this.deps.templateOpensReasoning, body);
|
|
1623
1627
|
const rambleConfig = profileBehaviorConfig(this.deps.profile, 'turn.ramble-detection');
|
|
1624
1628
|
const ramble = outputIsConstrained(this.deps.tuning)
|
|
1625
1629
|
? inertRambleDetector()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""batch_processors — per-sequence logits-processor lists for mlx_lm's
|
|
2
|
+
BatchGenerator that stay aligned across waves.
|
|
3
|
+
|
|
4
|
+
mlx_lm 0.31.3's `GenerationBatch.filter()` trims `logits_processors` only
|
|
5
|
+
`if any(self.logits_processors)`. Once every live sequence carries `[]`, the
|
|
6
|
+
finished ones' entries survive the filter, and the next wave's sequences are
|
|
7
|
+
appended after them at the wrong index: a request's tool grammar, think
|
|
8
|
+
budget or response format then ran for its first token only, and in a mixed
|
|
9
|
+
wave one sequence's grammar could land on another's output. Caught
|
|
10
|
+
2026-10-13: a JSON-schema request right after a plain one came back as free
|
|
11
|
+
text. Giving every sequence a non-empty list keeps `any()` true, so the
|
|
12
|
+
filter always trims. The pass-through costs nothing measurable (A/B on
|
|
13
|
+
gemma4-e4b, 2026-10-14: 64.3 vs 62.7 tok/s median, within noise).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from typing import Any, Callable, List, Sequence
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pass_through_logits(tokens: Any, logits: Any) -> Any:
|
|
20
|
+
"""A no-op processor that keeps a sequence's processor list non-empty."""
|
|
21
|
+
return logits
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def per_sequence_processors(processors: Sequence[Callable[[Any, Any], Any]]) -> List[Callable[[Any, Any], Any]]:
|
|
25
|
+
"""The processor list to hand BatchGenerator for one sequence; never empty."""
|
|
26
|
+
return list(processors) or [pass_through_logits]
|