@bendyline/gezel-inference 1.0.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.
Files changed (60) hide show
  1. package/README.md +90 -8
  2. package/dist/cache/adapter.d.ts +7 -0
  3. package/dist/cache/controller.d.ts +12 -0
  4. package/dist/cache/controller.js +24 -0
  5. package/dist/catalog/chat-models/bt/btl4-35b-q4/manifest.json +1 -0
  6. package/dist/catalog/chat-models/gl/glm-5.2-754b-q2/manifest.json +1 -0
  7. package/dist/catalog/chat-models/index.json +1 -1
  8. package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/manifest.json +122 -0
  9. package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/versions/1.0.0/manifest.json +15 -0
  10. package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/manifest.json +185 -0
  11. package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/versions/1.0.0/manifest.json +94 -0
  12. package/dist/catalog/chat-models/mi/minicpm5-1b-q4/manifest.json +1 -0
  13. package/dist/catalog/chat-models/mi/minicpm5-2b-q4/manifest.json +1 -0
  14. package/dist/catalog/chat-models/qw/qwen3.5-0.8b-q4/manifest.json +1 -0
  15. package/dist/catalog/chat-models/raw-index.json +1 -1
  16. package/dist/catalog/provenance.json +1 -1
  17. package/dist/engines/electron-native-reuse.d.ts +9 -0
  18. package/dist/engines/electron-native-reuse.js +146 -45
  19. package/dist/engines/native-manifest.d.ts +8 -0
  20. package/dist/engines/native-manifest.js +30 -0
  21. package/dist/http/openai-compat/apple-model.d.ts +2 -0
  22. package/dist/http/openai-compat/apple-model.js +21 -1
  23. package/dist/http/openai-compat/chat-target.d.ts +12 -0
  24. package/dist/http/openai-compat/chat-target.js +27 -1
  25. package/dist/http/routes/engines.js +9 -3
  26. package/dist/http/routes/v1-chat.js +21 -8
  27. package/dist/http/routes/v1-models.d.ts +6 -0
  28. package/dist/http/routes/v1-models.js +133 -28
  29. package/dist/http/routes/v1-responses.js +9 -2
  30. package/dist/in-flight-requests.d.ts +19 -0
  31. package/dist/in-flight-requests.js +34 -0
  32. package/dist/inference-service.js +3 -3
  33. package/dist/providers/llama-cpp/build-provider.js +33 -8
  34. package/dist/providers/llama-cpp/cache-adapter.d.ts +12 -0
  35. package/dist/providers/llama-cpp/cache-adapter.js +34 -0
  36. package/dist/providers/llama-cpp/gguf-metadata-async.js +7 -0
  37. package/dist/providers/mlx/provider.js +11 -7
  38. package/dist/providers/mlx/python/batch_processors.py +26 -0
  39. package/dist/providers/mlx/python/gezel_mlx_server.py +88 -20
  40. package/dist/providers/mlx/python/response_format.py +374 -0
  41. package/dist/providers/mlx/python/spec_decode.py +5 -0
  42. package/dist/providers/mlx/python/think_budget.py +35 -1
  43. package/dist/providers/mlx/python/tool_grammar.py +62 -3
  44. package/dist/providers/mlx/reasoning-stream.d.ts +20 -2
  45. package/dist/providers/mlx/reasoning-stream.js +77 -16
  46. package/dist/providers/mlx/runtime-diagnostics.d.ts +16 -0
  47. package/dist/providers/mlx/runtime-diagnostics.js +29 -2
  48. package/dist/providers/native/capacity-broker.d.ts +35 -0
  49. package/dist/providers/native/capacity-broker.js +69 -4
  50. package/dist/providers/native/local-engine-runtime.js +7 -2
  51. package/dist/remotes/identity.d.ts +1 -0
  52. package/dist/remotes/identity.js +6 -0
  53. package/dist/remotes/pinned-fetch.d.ts +2 -0
  54. package/dist/remotes/pinned-fetch.js +30 -1
  55. package/dist/remotes/types.d.ts +14 -0
  56. package/dist/runtime-lock.js +1 -1
  57. package/dist/system/memory.js +11 -1
  58. package/dist/utils/service-worker-entry.d.ts +4 -0
  59. package/dist/utils/service-worker-entry.js +4 -0
  60. package/package.json +14 -10
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@bendyline/gilde",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "kinds": [
5
5
  "chat-models",
6
6
  "knowledge-catalogs"
@@ -35,6 +35,15 @@ export interface ElectronNativeReuseOptions {
35
35
  * this false so their parent app is also signature/notarization checked.
36
36
  */
37
37
  allowStandaloneMacPayload?: boolean;
38
+ /**
39
+ * Remember each pinned file that hashed to its pin, with its fingerprint
40
+ * (see file-fingerprint.ts in core), so a later call re-hashes only files
41
+ * whose metadata moved: `nativePayloadVerificationPath(home)`. Everything
42
+ * else still runs on every call, including the listing that rejects a
43
+ * loadable file the release does not pin. Keep it somewhere no easier to
44
+ * write than the payload itself: whoever can edit it can vouch for any bytes.
45
+ */
46
+ cacheFile?: string;
38
47
  }
39
48
  /**
40
49
  * Adopt Electron's extracted native payload, or an explicitly allowed
@@ -1,8 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { createReadStream } from 'node:fs';
3
- import { lstat, readdir, readlink } from 'node:fs/promises';
4
- import { basename, join, posix, relative, resolve, sep } from 'node:path';
5
- import { resolvePlatformKey } from '@bendyline/gezel/native';
2
+ import { existsSync } from 'node:fs';
3
+ import { lstat, mkdir, open, readFile, readdir, readlink, writeFile } from 'node:fs/promises';
4
+ import { basename, dirname, join, posix, relative, resolve, sep } from 'node:path';
5
+ import { fileFingerprint, resolvePlatformKey, sameFileFingerprint, } from '@bendyline/gezel/native';
6
6
  import pinnedManifestJson from './native-file-manifest.json' with { type: 'json' };
7
7
  import { effectiveEngineRelease } from './native-manifest.js';
8
8
  import { BENDYLINE_APPLE_TEAM_ID, BENDYLINE_PUBLISHER, verifyCodeSignature } from './signature.js';
@@ -52,38 +52,89 @@ export async function verifyNativeBinaries(opts) {
52
52
  reason: `release ${release} has no pinned files for ${baseKey}`,
53
53
  };
54
54
  }
55
+ const cache = opts.cacheFile ? await readHashCache(opts.cacheFile) : null;
56
+ const pinnedCount = platformEntries.reduce((n, [, pin]) => n + Object.keys(pin.files).length, 0);
55
57
  const failures = [];
56
- for (const candidate of opts.candidates) {
57
- const startedAt = Date.now();
58
- try {
59
- await verifyCandidate({
60
- root: candidate,
61
- platform,
62
- entries: platformEntries,
63
- verifySignature: opts.verifySignature ?? verifyCodeSignature,
64
- allowStandaloneMacPayload: opts.allowStandaloneMacPayload === true,
65
- });
66
- return {
67
- reused: true,
68
- nativeBinDir: resolve(candidate),
69
- // The elapsed time is load-bearing diagnostics, not decoration: this
70
- // is pre-bind boot work whose cost scales with the payload, and on a
71
- // CUDA host it is the largest single read the daemon ever does at
72
- // startup. Without it in the log there is nothing to attribute a slow
73
- // launch to.
74
- reason: `verified ${opts.allowStandaloneMacPayload ? 'standalone' : 'Electron'} native release ${release} in ${((Date.now() - startedAt) / 1000).toFixed(1)}s`,
75
- };
58
+ try {
59
+ for (const candidate of opts.candidates) {
60
+ const startedAt = Date.now();
61
+ const hashedBefore = cache?.hashed ?? 0;
62
+ try {
63
+ await verifyCandidate({
64
+ root: candidate,
65
+ platform,
66
+ entries: platformEntries,
67
+ verifySignature: opts.verifySignature ?? verifyCodeSignature,
68
+ allowStandaloneMacPayload: opts.allowStandaloneMacPayload === true,
69
+ cache,
70
+ });
71
+ return {
72
+ reused: true,
73
+ nativeBinDir: resolve(candidate),
74
+ // The elapsed time is load-bearing diagnostics, not decoration: this
75
+ // is pre-bind boot work whose cost scales with the payload, and on a
76
+ // CUDA host it is the largest single read the daemon ever does at
77
+ // startup. Without it in the log there is nothing to attribute a slow
78
+ // launch to.
79
+ reason: `verified ${opts.allowStandaloneMacPayload ? 'standalone' : 'Electron'} native release ${release} in ${((Date.now() - startedAt) / 1000).toFixed(1)}s${cache ? ` (${cache.hashed - hashedBefore} of ${pinnedCount} files hashed)` : ''}`,
80
+ };
81
+ }
82
+ catch (error) {
83
+ failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`);
84
+ }
76
85
  }
77
- catch (error) {
78
- failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`);
86
+ return {
87
+ reused: false,
88
+ reason: failures.length > 0
89
+ ? failures.join('; ')
90
+ : 'no Electron native payload candidate was installed',
91
+ };
92
+ }
93
+ finally {
94
+ if (cache)
95
+ await saveHashCache(cache);
96
+ }
97
+ }
98
+ async function readHashCache(path) {
99
+ const known = new Map();
100
+ try {
101
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
102
+ if (isRecord(parsed) && parsed.schema === 1 && isRecord(parsed.files)) {
103
+ for (const [file, record] of Object.entries(parsed.files)) {
104
+ // The fingerprint's shape is checked where it is compared.
105
+ if (isRecord(record) && typeof record.sha256 === 'string') {
106
+ known.set(file, record);
107
+ }
108
+ }
79
109
  }
80
110
  }
81
- return {
82
- reused: false,
83
- reason: failures.length > 0
84
- ? failures.join('; ')
85
- : 'no Electron native payload candidate was installed',
86
- };
111
+ catch {
112
+ // Missing or unreadable: this call hashes everything and writes it anew.
113
+ }
114
+ return { path, known, examined: new Set(), confirmed: new Map(), hashed: 0 };
115
+ }
116
+ async function saveHashCache(cache) {
117
+ const dropped = [...cache.examined].some((file) => cache.known.has(file) && !cache.confirmed.has(file));
118
+ if (cache.hashed === 0 && !dropped)
119
+ return;
120
+ // Records this call never looked at belong to another candidate, which a
121
+ // dev launch verifies in a separate call against the same file. Dropping
122
+ // them would make the two calls erase each other and hash everything on
123
+ // every launch. They are kept while their files exist.
124
+ const files = {};
125
+ for (const [file, record] of cache.known) {
126
+ if (!cache.examined.has(file) && existsSync(file))
127
+ files[file] = record;
128
+ }
129
+ for (const [file, record] of cache.confirmed)
130
+ files[file] = record;
131
+ try {
132
+ await mkdir(dirname(cache.path), { recursive: true });
133
+ await writeFile(cache.path, `${JSON.stringify({ schema: 1, files }, null, 2)}\n`, 'utf8');
134
+ }
135
+ catch {
136
+ // Only a speedup: the next call hashes what it cannot find here.
137
+ }
87
138
  }
88
139
  async function verifyCandidate(opts) {
89
140
  const root = resolve(opts.root);
@@ -115,11 +166,13 @@ async function verifyCandidate(opts) {
115
166
  // Semantics are unchanged: any rejection still fails the candidate, and
116
167
  // the first one to fail wins the message.
117
168
  // The pin is the whole per-file check; see verifyNativeBinaries.
169
+ const hashBuffers = [];
118
170
  await mapWithConcurrency(Object.entries(platformPin.files), 4, async ([relativePath, pin]) => {
119
171
  if (!isSafeRelativePath(relativePath)) {
120
172
  throw new Error(`unsafe pinned native path: ${relativePath}`);
121
173
  }
122
174
  const absolute = join(platformDir, ...relativePath.split('/'));
175
+ opts.cache?.examined.add(absolute);
123
176
  const info = await lstat(absolute);
124
177
  if (!info.isFile() || info.isSymbolicLink()) {
125
178
  throw new Error(`pinned file is missing or not regular: ${platformKey}/${relativePath}`);
@@ -129,10 +182,26 @@ async function verifyCandidate(opts) {
129
182
  if (info.size !== pin.sizeBytes) {
130
183
  throw new Error(`size mismatch: ${platformKey}/${relativePath}`);
131
184
  }
132
- const actualSha = await sha256File(absolute);
133
- if (actualSha !== pin.sha256.toLowerCase()) {
185
+ const want = pin.sha256.toLowerCase();
186
+ // Fingerprinted before hashing, so a file that changes mid-hash is
187
+ // recorded as it was and hashed again next time.
188
+ const fingerprint = opts.cache ? await fileFingerprint(absolute) : null;
189
+ const known = fingerprint ? opts.cache?.known.get(absolute) : undefined;
190
+ if (fingerprint &&
191
+ known?.sha256 === want &&
192
+ sameFileFingerprint(known.fingerprint, fingerprint)) {
193
+ opts.cache?.confirmed.set(absolute, known);
194
+ return;
195
+ }
196
+ const actualSha = await sha256File(absolute, hashBuffers);
197
+ if (actualSha !== want) {
134
198
  throw new Error(`sha256 mismatch: ${platformKey}/${relativePath}`);
135
199
  }
200
+ if (opts.cache) {
201
+ opts.cache.hashed += 1;
202
+ if (fingerprint)
203
+ opts.cache.confirmed.set(absolute, { sha256: want, fingerprint });
204
+ }
136
205
  });
137
206
  for (const [relativePath, target] of Object.entries(platformPin.symlinks)) {
138
207
  if (!isSafeRelativePath(relativePath)) {
@@ -203,18 +272,30 @@ async function listNativeEntries(root, platform) {
203
272
  }
204
273
  /**
205
274
  * Run `worker` over `items` with at most `limit` in flight, rejecting with the
206
- * first failure. Workers already in flight are allowed to settle; nothing here
207
- * mutates state, so an abandoned hash is inert.
275
+ * first failure. Workers already in flight are allowed to settle, and only
276
+ * record files that really did match, so an abandoned hash is inert. No new
277
+ * item starts after a failure: without that, the other workers went on hashing
278
+ * the rest of a rejected payload (a stale 1.6 GB dev checkout) in the
279
+ * background while the next candidate was being verified.
208
280
  */
209
281
  async function mapWithConcurrency(items, limit, worker) {
210
282
  let next = 0;
283
+ let failed = false;
211
284
  const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
212
285
  for (;;) {
286
+ if (failed)
287
+ return;
213
288
  const index = next++;
214
289
  const item = items[index];
215
290
  if (index >= items.length || item === undefined)
216
291
  return;
217
- await worker(item);
292
+ try {
293
+ await worker(item);
294
+ }
295
+ catch (error) {
296
+ failed = true;
297
+ throw error;
298
+ }
218
299
  }
219
300
  });
220
301
  await Promise.all(runners);
@@ -286,15 +367,35 @@ function enclosingMacApp(path) {
286
367
  // daemon event loop (a 64 KB stream yields ~100k times and queues behind other
287
368
  // work). Engines is a lower layer than models, so this mirrors models'
288
369
  // MODEL_HASH_READ_BUFFER_BYTES rather than importing it.
370
+ //
371
+ // The buffer is reused, never one per chunk. A 16 MB `highWaterMark` stream
372
+ // allocates a fresh 16 MB buffer for every read, and in Electron's main process
373
+ // (where the dev supervisor runs this, and the embedded service after it) each
374
+ // allocation cost ~44 ms and left V8 in a ~20 s garbage-collection storm once
375
+ // the 1.7 GB Windows CUDA payload was done: the duckdb and MXC provisioning
376
+ // after it took 4.5 s and 13 s on the splash, against ~50 ms each in isolation.
377
+ // Reusing the buffer hashed the same payload in 1.1 s with no storm.
289
378
  const HASH_READ_BUFFER_BYTES = 16 * 1024 * 1024;
290
- function sha256File(path) {
291
- return new Promise((resolveHash, reject) => {
292
- const hash = createHash('sha256');
293
- const stream = createReadStream(path, { highWaterMark: HASH_READ_BUFFER_BYTES });
294
- stream.on('data', (chunk) => hash.update(chunk));
295
- stream.on('error', reject);
296
- stream.on('end', () => resolveHash(hash.digest('hex')));
297
- });
379
+ async function sha256File(path, buffers) {
380
+ const buffer = buffers.pop() ?? Buffer.allocUnsafe(HASH_READ_BUFFER_BYTES);
381
+ try {
382
+ const handle = await open(path, 'r');
383
+ try {
384
+ const hash = createHash('sha256');
385
+ for (;;) {
386
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
387
+ if (bytesRead === 0)
388
+ return hash.digest('hex');
389
+ hash.update(buffer.subarray(0, bytesRead));
390
+ }
391
+ }
392
+ finally {
393
+ await handle.close();
394
+ }
395
+ }
396
+ finally {
397
+ buffers.push(buffer);
398
+ }
298
399
  }
299
400
  /** Legacy adoption API. New embedding hosts should use the side-effect-free verifier. */
300
401
  export async function reuseVerifiedElectronNativeBinaries(opts) {
@@ -45,12 +45,20 @@ export declare const NATIVE_ENGINE_RELEASE = "0.1.49";
45
45
  export declare const SHA256SUMS_DIGEST = "51e9d1fcd18174967fed451d11a7401035076330dfedadd6a0ce396ab636cff7";
46
46
  /** Exact SHA256 values for every engine archive published by native-v0.1.49. */
47
47
  export declare const NATIVE_ENGINE_ARCHIVE_SHA256: Readonly<Record<string, string>>;
48
+ /** Download size in bytes of every engine archive published by native-v0.1.49. */
49
+ export declare const NATIVE_ENGINE_ARCHIVE_BYTES: Readonly<Record<string, number>>;
48
50
  /**
49
51
  * True only when the pinned release's standalone macOS archives were
50
52
  * Developer ID signed and accepted by Apple's notary service before
51
53
  * packaging. Electron notarization is a separate distribution contract.
52
54
  */
53
55
  export declare const NATIVE_ENGINE_MACOS_NOTARIZED = true;
56
+ /**
57
+ * The download size of the archive the resolver fetches for this platform
58
+ * and variant, when this build pins it. Undefined under a dev release
59
+ * override, whose archives this build has no sizes for.
60
+ */
61
+ export declare function pinnedEngineArchiveBytes(platformKey: string, variant: string | undefined, platform?: NodeJS.Platform): number | undefined;
54
62
  /** True when a sha256 hex string is the all-zeros placeholder. */
55
63
  export declare function isPlaceholderDigest(digest: string): boolean;
56
64
  /**
@@ -62,6 +62,24 @@ export const NATIVE_ENGINE_ARCHIVE_SHA256 = Object.freeze({
62
62
  'gezel-native-0.1.49-win32-x64-vulkan.zip': '0b7fe1cab3b881ac4a5cc24f08b0206c38edab30940ea79888a70776925a8bfb',
63
63
  'gezel-native-0.1.49-win32-x64.zip': 'e8996f82ea358903010e050d6b8b93beec683286af512b11cd8eeefdea50aa6d',
64
64
  });
65
+ /** Download size in bytes of every engine archive published by native-v0.1.49. */
66
+ export const NATIVE_ENGINE_ARCHIVE_BYTES = Object.freeze({
67
+ 'gezel-native-0.1.49-darwin-arm64-metal.tar.gz': 6895181,
68
+ 'gezel-native-0.1.49-darwin-arm64.tar.gz': 41543602,
69
+ 'gezel-native-0.1.49-linux-arm64-cpu.tar.gz': 8681742,
70
+ 'gezel-native-0.1.49-linux-arm64-cuda.tar.gz': 618839431,
71
+ 'gezel-native-0.1.49-linux-arm64.tar.gz': 41977168,
72
+ 'gezel-native-0.1.49-linux-x64-cpu.tar.gz': 13085558,
73
+ 'gezel-native-0.1.49-linux-x64-cuda.tar.gz': 987396266,
74
+ 'gezel-native-0.1.49-linux-x64-vulkan.tar.gz': 27140717,
75
+ 'gezel-native-0.1.49-linux-x64.tar.gz': 58057802,
76
+ 'gezel-native-0.1.49-win32-arm64-cpu.zip': 5459336,
77
+ 'gezel-native-0.1.49-win32-arm64.zip': 42048477,
78
+ 'gezel-native-0.1.49-win32-x64-cpu.zip': 8603082,
79
+ 'gezel-native-0.1.49-win32-x64-cuda.zip': 641853362,
80
+ 'gezel-native-0.1.49-win32-x64-vulkan.zip': 22326156,
81
+ 'gezel-native-0.1.49-win32-x64.zip': 56551783,
82
+ });
65
83
  // END PINNED NATIVE ARCHIVE HASHES
66
84
  /**
67
85
  * True only when the pinned release's standalone macOS archives were
@@ -69,6 +87,18 @@ export const NATIVE_ENGINE_ARCHIVE_SHA256 = Object.freeze({
69
87
  * packaging. Electron notarization is a separate distribution contract.
70
88
  */
71
89
  export const NATIVE_ENGINE_MACOS_NOTARIZED = true;
90
+ /**
91
+ * The download size of the archive the resolver fetches for this platform
92
+ * and variant, when this build pins it. Undefined under a dev release
93
+ * override, whose archives this build has no sizes for.
94
+ */
95
+ export function pinnedEngineArchiveBytes(platformKey, variant, platform = process.platform) {
96
+ if (effectiveEngineRelease() !== NATIVE_ENGINE_RELEASE)
97
+ return undefined;
98
+ const key = variant ? `${platformKey}-${variant}` : platformKey;
99
+ const ext = platform === 'win32' ? 'zip' : 'tar.gz';
100
+ return NATIVE_ENGINE_ARCHIVE_BYTES[`gezel-native-${NATIVE_ENGINE_RELEASE}-${key}.${ext}`];
101
+ }
72
102
  /** True when a sha256 hex string is the all-zeros placeholder. */
73
103
  export function isPlaceholderDigest(digest) {
74
104
  return /^0{64}$/.test(digest.trim().toLowerCase());
@@ -1,3 +1,5 @@
1
1
  import type { AppModel } from '@bendyline/gezel/app-models';
2
+ /** @internal Lets tests list models under a changed Apple status. */
3
+ export declare function resetAppleStatusCache(): void;
2
4
  /** Inventory owns system readiness, so embedders never speak the native helper protocol. */
3
5
  export declare function appleSystemModel(created: number): Promise<AppModel[]>;
@@ -1,7 +1,27 @@
1
1
  import { appleFoundationModelsStatus } from '../../providers/apple-foundation-models/status.js';
2
+ /**
3
+ * How long listings reuse Apple's readiness. Each check launches the
4
+ * `gezel-apple-fm` helper for one handshake (26-59 ms measured), and the SDK's
5
+ * `streamText` lists models before every call, so a fresh check per listing
6
+ * cost a process launch per chat. Short enough that Apple Intelligence
7
+ * enabled in System Settings shows up almost at once.
8
+ */
9
+ const STATUS_TTL_MS = 10_000;
10
+ let cachedStatus = null;
11
+ /** Concurrent listings share one in-flight check. */
12
+ function appleStatus() {
13
+ const now = Date.now();
14
+ if (!cachedStatus || now - cachedStatus.at > STATUS_TTL_MS)
15
+ cachedStatus = { at: now, status: appleFoundationModelsStatus() };
16
+ return cachedStatus.status;
17
+ }
18
+ /** @internal Lets tests list models under a changed Apple status. */
19
+ export function resetAppleStatusCache() {
20
+ cachedStatus = null;
21
+ }
2
22
  /** Inventory owns system readiness, so embedders never speak the native helper protocol. */
3
23
  export async function appleSystemModel(created) {
4
- const status = await appleFoundationModelsStatus();
24
+ const status = await appleStatus();
5
25
  if (!status.supported || !status.installed)
6
26
  return [];
7
27
  return [
@@ -43,6 +43,18 @@ export type ChatTargetInput = {
43
43
  provider: ProviderName;
44
44
  model: string | undefined;
45
45
  };
46
+ /**
47
+ * The on-device model a bare id names, when this install pins it as a default.
48
+ *
49
+ * `ensureModel` hands an app the bare id it asked for (`qwen3.5-0.8b-q4`) and
50
+ * pins it; an inference-only host, which has no gezel to fall back to, then
51
+ * answered that id with "Unknown model". Only a pin is trusted, so an unpinned
52
+ * bare id still reaches the serving gezel like any other unknown string. When
53
+ * several engines pin the same id, the configured provider decides. A cloud
54
+ * provider's pin never matches: an app's hardcoded `gpt-4o` must reach the
55
+ * serving gezel, not leave the machine because it equals the OpenAI default.
56
+ */
57
+ export declare function resolvePinnedModelTarget(ctx: ServiceContext, model: string): Promise<ChatTarget | null>;
46
58
  /**
47
59
  * Pick the gezel that guarantees a persona-backed fallback for public
48
60
  * inference requests.
@@ -1,4 +1,30 @@
1
- import { externalGezelModelId } from '@bendyline/gezel';
1
+ import { externalGezelModelId, isLocalProvider } from '@bendyline/gezel';
2
+ import { resolveModelTarget } from './translate.js';
3
+ /**
4
+ * The on-device model a bare id names, when this install pins it as a default.
5
+ *
6
+ * `ensureModel` hands an app the bare id it asked for (`qwen3.5-0.8b-q4`) and
7
+ * pins it; an inference-only host, which has no gezel to fall back to, then
8
+ * answered that id with "Unknown model". Only a pin is trusted, so an unpinned
9
+ * bare id still reaches the serving gezel like any other unknown string. When
10
+ * several engines pin the same id, the configured provider decides. A cloud
11
+ * provider's pin never matches: an app's hardcoded `gpt-4o` must reach the
12
+ * serving gezel, not leave the machine because it equals the OpenAI default.
13
+ */
14
+ export async function resolvePinnedModelTarget(ctx, model) {
15
+ const id = model.trim();
16
+ if (!id)
17
+ return null;
18
+ const config = await ctx.store.readConfig().catch(() => null);
19
+ const pins = Object.entries(config?.defaultModel ?? {}).filter(([provider, pinned]) => pinned === id &&
20
+ isLocalProvider(provider) &&
21
+ resolveModelTarget(`${provider}:${id}`) !== null);
22
+ const chosen = pins.find(([provider]) => provider === config?.provider) ??
23
+ (pins.length === 1 ? pins[0] : undefined);
24
+ if (!chosen)
25
+ return null;
26
+ return { provider: chosen[0], model: id, systemPrefix: '' };
27
+ }
2
28
  /**
3
29
  * Pick the gezel that guarantees a persona-backed fallback for public
4
30
  * inference requests.
@@ -4,7 +4,7 @@ import { resolvePlatformKey } from '@bendyline/gezel/native';
4
4
  import { Hono } from 'hono';
5
5
  import { streamSSE } from 'hono/streaming';
6
6
  import { z } from 'zod';
7
- import { effectiveEngineRelease, isEnginePinned } from '../../engines/native-manifest.js';
7
+ import { effectiveEngineRelease, isEnginePinned, pinnedEngineArchiveBytes, } from '../../engines/native-manifest.js';
8
8
  import { KNOWN_ENGINES, isKnownEngine } from '../../engines/registry.js';
9
9
  import { EngineBusyError } from '../../providers/native/capacity-broker.js';
10
10
  import { invalidateModelsCache } from '../model-inventory-cache.js';
@@ -39,17 +39,23 @@ export function enginesRoutes(ctx) {
39
39
  app.get('/binaries/status', (c) => {
40
40
  const backendRaw = process.env.GEZEL_LLAMA_SERVER_BACKEND ?? process.env.GEZEL_LLAMA_DETECTED_BACKEND;
41
41
  const llamaBackend = ['cuda', 'vulkan', 'metal', 'cpu'].find((value) => value === backendRaw);
42
+ const platformKey = resolvePlatformKey();
42
43
  return c.json({
43
44
  release: effectiveEngineRelease(),
44
45
  pinned: isEnginePinned(),
45
- platformKey: resolvePlatformKey(),
46
+ platformKey,
46
47
  ...(llamaBackend ? { llamaBackend } : {}),
47
48
  engines: KNOWN_ENGINES.map((name) => {
48
49
  const path = process.env[ENGINE_ENV_VAR[name]];
50
+ const installed = !!path && existsSync(path);
51
+ const downloadBytes = !installed && name === 'llama-server' && platformKey && llamaBackend
52
+ ? pinnedEngineArchiveBytes(platformKey, llamaBackend)
53
+ : undefined;
49
54
  return {
50
55
  name,
51
- installed: !!path && existsSync(path),
56
+ installed,
52
57
  ...(path ? { path } : {}),
58
+ ...(downloadBytes ? { downloadBytes } : {}),
53
59
  };
54
60
  }),
55
61
  });
@@ -3,12 +3,13 @@ import { createLogger, isLocalProvider } from '@bendyline/gezel';
3
3
  import { Hono } from 'hono';
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 { OLLAMA_TURN_TIMEOUT_MS } from '../../providers/turn-timeout.js';
7
8
  import { ExternalToolsUnsupportedError, ModelNotInstalledError, } from '../../providers/types.js';
8
9
  import { EXTERNAL_CONVERSATION_ID_HEADER, EXTERNAL_PROJECT_HEADER, EXTERNAL_WORKING_DIRECTORY_HEADER, } from '../external-conversation-headers.js';
9
10
  import { appendCallerOwnedActionLedger } from '../openai-compat/action-ledger.js';
10
11
  import { profileForCallerOwnedInference } from '../openai-compat/caller-owned-profile.js';
11
- import { resolveChatTarget, resolveFallbackGezelId } from '../openai-compat/chat-target.js';
12
+ import { resolveChatTarget, resolveFallbackGezelId, resolvePinnedModelTarget, } from '../openai-compat/chat-target.js';
12
13
  import { overlayOpenAiRequestTuning } from '../openai-compat/request-tuning.js';
13
14
  import { createStreamingDiagnostics, snapshotStreamingDiagnostics, } from '../openai-compat/streaming-telemetry.js';
14
15
  import { runNonStreaming, runStreaming } from '../openai-compat/streaming.js';
@@ -188,7 +189,11 @@ export function v1ChatRoutes(ctx, opts = {}) {
188
189
  }
189
190
  else {
190
191
  const modelTarget = resolveModelTarget(parsed.model);
191
- if (!modelTarget) {
192
+ const pinnedTarget = modelTarget ? null : await resolvePinnedModelTarget(ctx, parsed.model);
193
+ if (pinnedTarget) {
194
+ target = pinnedTarget;
195
+ }
196
+ else if (!modelTarget) {
192
197
  // Unknown model string (e.g. a client's hardcoded "gpt-4o").
193
198
  // Route through the configured fallback gezel, or the Meester
194
199
  // when no explicit override is set. Persona + tuning apply
@@ -199,7 +204,7 @@ export function v1ChatRoutes(ctx, opts = {}) {
199
204
  if (!fallbackGezelId) {
200
205
  return c.json({
201
206
  error: {
202
- message: `Unknown model "${parsed.model}", and no gezel is available to answer it.`,
207
+ message: `Unknown model "${parsed.model}", and no gezel is available to answer it. Use a <provider>:<model> id from GET /v1/models, such as "llama-cpp:${parsed.model}".`,
203
208
  type: 'invalid_request_error',
204
209
  code: 'gezel_not_found',
205
210
  },
@@ -534,11 +539,9 @@ export function v1ChatRoutes(ctx, opts = {}) {
534
539
  await stream
535
540
  .writeSSE({
536
541
  data: JSON.stringify({
537
- error: {
538
- message,
539
- type: 'server_error',
540
- code: 'provider_error',
541
- },
542
+ error: err instanceof UnsupportedResponseFormatError
543
+ ? { message, type: 'invalid_request_error', code: err.code }
544
+ : { message, type: 'server_error', code: 'provider_error' },
542
545
  }),
543
546
  })
544
547
  .catch((sseErr) => {
@@ -630,6 +633,16 @@ export function v1ChatRoutes(ctx, opts = {}) {
630
633
  },
631
634
  }, 400);
632
635
  }
636
+ if (err instanceof UnsupportedResponseFormatError) {
637
+ return c.json({
638
+ error: {
639
+ message: err.message,
640
+ type: 'invalid_request_error',
641
+ param: 'response_format',
642
+ code: err.code,
643
+ },
644
+ }, 400);
645
+ }
633
646
  const message = err instanceof Error ? err.message : String(err);
634
647
  if (message.includes('messages must') ||
635
648
  message.includes('empty') ||
@@ -1,8 +1,14 @@
1
1
  import { Hono } from 'hono';
2
+ import { type MemoryProfile } from '../../system/memory.js';
2
3
  import type { InferenceContext as ServiceContext } from '../inference-context.js';
3
4
  interface V1ModelsRouteOptions {
4
5
  /** List only on-device engines plus downloadable on-device catalog entries. */
5
6
  localOnly?: boolean;
7
+ /**
8
+ * This machine's memory, which downloadable entries are rated against.
9
+ * Defaults to the cached probe `/api/system/memory` reads.
10
+ */
11
+ memoryProfile?: () => Promise<MemoryProfile>;
6
12
  }
7
13
  export declare function v1ModelsRoutes(ctx: ServiceContext, options?: V1ModelsRouteOptions): Hono;
8
14
  export {};