@bendyline/gezel-inference 1.0.3 → 1.0.5

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 (104) hide show
  1. package/dist/catalog/chat-models/index.json +1 -1
  2. package/dist/catalog/chat-models/na/nanbeige4.2-3b-q4/manifest.json +1 -1
  3. package/dist/catalog/chat-models/ne/nemotron3.5-bolt-e4b-q4/manifest.json +142 -0
  4. package/dist/catalog/chat-models/ne/nemotron3.5-bolt-e4b-q4/versions/1.0.0/manifest.json +14 -0
  5. package/dist/catalog/chat-models/ne/nemotron3.5-bolt-e4b-q4/versions/1.0.1/manifest.json +14 -0
  6. package/dist/catalog/chat-models/raw-index.json +1 -1
  7. package/dist/catalog/image-models/an/animagine-xl-4/manifest.json +40 -0
  8. package/dist/catalog/image-models/an/animagine-xl-4/versions/4.0.0/manifest.json +10 -0
  9. package/dist/catalog/image-models/an/animagine-xl-4/versions/4.0.1/manifest.json +16 -0
  10. package/dist/catalog/image-models/fl/flux-2-klein-4b-q4/manifest.json +44 -0
  11. package/dist/catalog/image-models/fl/flux-2-klein-4b-q4/versions/1.0.0/manifest.json +23 -0
  12. package/dist/catalog/image-models/fl/flux-2-klein-4b-q4/versions/1.0.1/manifest.json +29 -0
  13. package/dist/catalog/image-models/index.json +1 -0
  14. package/dist/catalog/image-models/ir/iris-3b-f32/manifest.json +41 -0
  15. package/dist/catalog/image-models/ir/iris-3b-f32/versions/1.0.0/manifest.json +19 -0
  16. package/dist/catalog/image-models/ir/iris-3b-f32/versions/1.0.1/manifest.json +25 -0
  17. package/dist/catalog/image-models/ir/iris-3b-f32/versions/1.0.2/manifest.json +25 -0
  18. package/dist/catalog/image-models/kr/krea-2-turbo-q4/manifest.json +42 -0
  19. package/dist/catalog/image-models/kr/krea-2-turbo-q4/versions/1.0.0/manifest.json +23 -0
  20. package/dist/catalog/image-models/kr/krea-2-turbo-q4/versions/1.0.1/manifest.json +29 -0
  21. package/dist/catalog/image-models/kr/krea-2-turbo-q6/manifest.json +42 -0
  22. package/dist/catalog/image-models/kr/krea-2-turbo-q6/versions/1.0.0/manifest.json +23 -0
  23. package/dist/catalog/image-models/kr/krea-2-turbo-q6/versions/1.0.1/manifest.json +29 -0
  24. package/dist/catalog/image-models/kr/krea-2-turbo-q8/manifest.json +42 -0
  25. package/dist/catalog/image-models/kr/krea-2-turbo-q8/versions/1.0.0/manifest.json +23 -0
  26. package/dist/catalog/image-models/kr/krea-2-turbo-q8/versions/1.0.1/manifest.json +29 -0
  27. package/dist/catalog/image-models/mi/ming-image-0.1-design-bf16/manifest.json +41 -0
  28. package/dist/catalog/image-models/mi/ming-image-0.1-design-bf16/versions/1.0.0/manifest.json +31 -0
  29. package/dist/catalog/image-models/mi/ming-image-0.1-design-bf16/versions/1.0.1/manifest.json +50 -0
  30. package/dist/catalog/image-models/mi/ming-image-0.1-design-bf16/versions/1.0.2/manifest.json +50 -0
  31. package/dist/catalog/image-models/mo/moss-image-sft-q4/manifest.json +40 -0
  32. package/dist/catalog/image-models/mo/moss-image-sft-q4/versions/1.0.0/manifest.json +37 -0
  33. package/dist/catalog/image-models/mo/moss-image-sft-q4/versions/1.0.1/manifest.json +37 -0
  34. package/dist/catalog/image-models/raw-index.json +1 -0
  35. package/dist/catalog/image-models/sd/sd-1.5/manifest.json +40 -0
  36. package/dist/catalog/image-models/sd/sd-1.5/versions/1.0.0/manifest.json +10 -0
  37. package/dist/catalog/image-models/sd/sd-1.5/versions/1.0.1/manifest.json +16 -0
  38. package/dist/catalog/image-models/sd/sd-turbo/manifest.json +40 -0
  39. package/dist/catalog/image-models/sd/sd-turbo/versions/1.0.0/manifest.json +10 -0
  40. package/dist/catalog/image-models/sd/sd-turbo/versions/1.0.1/manifest.json +16 -0
  41. package/dist/catalog/image-models/sd/sdxl-base-1.0/manifest.json +39 -0
  42. package/dist/catalog/image-models/sd/sdxl-base-1.0/versions/1.0.0/manifest.json +10 -0
  43. package/dist/catalog/image-models/sd/sdxl-base-1.0/versions/1.0.1/manifest.json +16 -0
  44. package/dist/catalog/image-models/sd/sdxl-lightning-4step/manifest.json +40 -0
  45. package/dist/catalog/image-models/sd/sdxl-lightning-4step/versions/1.0.0/manifest.json +10 -0
  46. package/dist/catalog/image-models/sd/sdxl-lightning-4step/versions/1.0.1/manifest.json +16 -0
  47. package/dist/catalog/image-models/sd/sdxl-turbo/manifest.json +41 -0
  48. package/dist/catalog/image-models/sd/sdxl-turbo/versions/1.0.0/manifest.json +10 -0
  49. package/dist/catalog/image-models/sd/sdxl-turbo/versions/1.0.1/manifest.json +16 -0
  50. package/dist/catalog/provenance.json +2 -1
  51. package/dist/engines/native-file-manifest.json +129 -119
  52. package/dist/engines/native-manifest.d.ts +4 -4
  53. package/dist/engines/native-manifest.js +36 -36
  54. package/dist/engines/resolver.js +9 -0
  55. package/dist/http/openai-compat/engine-failure.js +7 -2
  56. package/dist/http/openai-compat/responses-streaming.d.ts +13 -0
  57. package/dist/http/openai-compat/responses-streaming.js +30 -0
  58. package/dist/http/openai-compat/streaming.d.ts +9 -0
  59. package/dist/http/openai-compat/streaming.js +49 -9
  60. package/dist/http/openai-compat/tool-call-screen.d.ts +28 -0
  61. package/dist/http/openai-compat/tool-call-screen.js +43 -0
  62. package/dist/http/routes/v1-chat.js +15 -1
  63. package/dist/http/routes/v1-images.d.ts +44 -0
  64. package/dist/http/routes/v1-images.js +370 -0
  65. package/dist/http/routes/v1-responses.js +13 -1
  66. package/dist/inference-service.d.ts +3 -0
  67. package/dist/inference-service.js +24 -2
  68. package/dist/kokoro-lexicon/manifest.json +6 -3
  69. package/dist/models/image-fit.d.ts +83 -0
  70. package/dist/models/image-fit.js +163 -0
  71. package/dist/models/weight-facts.d.ts +36 -0
  72. package/dist/models/weight-facts.js +171 -0
  73. package/dist/providers/ds4/build-provider.js +28 -21
  74. package/dist/providers/ds4/dspark.d.ts +23 -5
  75. package/dist/providers/ds4/dspark.js +36 -8
  76. package/dist/providers/image/img2img-support.d.ts +40 -0
  77. package/dist/providers/image/img2img-support.js +74 -0
  78. package/dist/providers/image/local-engine.d.ts +31 -0
  79. package/dist/providers/image/local-engine.js +47 -0
  80. package/dist/providers/image/local-factory.d.ts +95 -0
  81. package/dist/providers/image/local-factory.js +270 -0
  82. package/dist/providers/image/mock.d.ts +23 -0
  83. package/dist/providers/image/mock.js +151 -0
  84. package/dist/providers/image/pull-registry.d.ts +106 -0
  85. package/dist/providers/image/pull-registry.js +227 -0
  86. package/dist/providers/image/sd-cpp.d.ts +200 -0
  87. package/dist/providers/image/sd-cpp.js +845 -0
  88. package/dist/providers/image/types.d.ts +237 -0
  89. package/dist/providers/image/types.js +20 -0
  90. package/dist/providers/llama-cpp/backend-health.d.ts +7 -0
  91. package/dist/providers/llama-cpp/backend-health.js +41 -1
  92. package/dist/providers/mlx/provider.js +13 -3
  93. package/dist/providers/native/supervisor.d.ts +8 -0
  94. package/dist/providers/native/supervisor.js +52 -4
  95. package/dist/providers/project-macro-loop-bail.js +5 -3
  96. package/dist/providers/provider-lifecycle.d.ts +21 -0
  97. package/dist/providers/provider-lifecycle.js +83 -0
  98. package/dist/providers/retirement-gate.d.ts +20 -0
  99. package/dist/providers/retirement-gate.js +55 -0
  100. package/dist/providers/types.d.ts +7 -1
  101. package/dist/system/memory.js +6 -0
  102. package/dist/utils/image-meta.d.ts +12 -0
  103. package/dist/utils/image-meta.js +77 -0
  104. package/package.json +6 -6
@@ -0,0 +1,845 @@
1
+ /**
2
+ * StableDiffusionCppProvider — talks to a local `sd-server` binary over
3
+ * HTTP. The server speaks an OpenAI-compatible `/v1/images/generations`
4
+ * shape with a few extensions (seed, sample_steps, negative_prompt,
5
+ * cfg_scale) supported by upstream sd.cpp.
6
+ *
7
+ * Lifecycle of the server itself is not the provider's concern — an
8
+ * Electron-side supervisor (Phase 3) spawns / idle-stops the binary and
9
+ * the provider simply hits whichever URL is configured. If the server
10
+ * isn't reachable, {@link generate} surfaces a clear error so the UI
11
+ * can prompt the user to enable image generation in Settings.
12
+ *
13
+ * Model management is filesystem-only: weights live under
14
+ * `<modelsRoot>/<id>/weights.*` alongside a resolved copy of the
15
+ * catalog manifest (so local state is self-describing even if the
16
+ * catalog entry later disappears).
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import { createReadStream } from 'node:fs';
20
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
21
+ import { join } from 'node:path';
22
+ import { createAwakeTimeout, createLogger, imageModelDefaultSide } from '@bendyline/gezel';
23
+ import { resolveModelDirectory } from '../../models/model-id.js';
24
+ import { MODEL_HASH_READ_BUFFER_BYTES, assertModelStorePathSafe, findModelRoot, hashModelPayloadFiles, listOverlayModelIds, makeSharedModelReadable, modelExistsOnlyReadOnly, readOnlyModelError, verifyReadOnlyModelPayload, } from '../../models/storage-roots.js';
25
+ import { downloadWithRetry } from '../../utils/download-with-retry.js';
26
+ import { readImageMeta } from '../../utils/image-meta.js';
27
+ import { resolveImg2ImgSupport } from './img2img-support.js';
28
+ import { ImageCapacityError, ImageGenerationCancelledError, } from './types.js';
29
+ const log = createLogger('image');
30
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
31
+ const DEFAULT_SAMPLE_STEPS = 20;
32
+ /**
33
+ * Model-specific step counts, from fast distilled models to the 50 of MOSS
34
+ * and Iris. Shared with `buildSdServerArgs`, which sets
35
+ * the same server launch default for endpoints that ignore request steps.
36
+ */
37
+ export const MODEL_SAMPLE_STEP_DEFAULTS = new Map([
38
+ // FLUX.1 is still in the pinned catalog and on people's disks: keep its
39
+ // settings until no supported catalog offers it.
40
+ ['flux-1-schnell-q4', 4],
41
+ ['flux-2-klein-4b-q4', 4],
42
+ ['sd-turbo', 4],
43
+ ['sdxl-lightning-4step', 4],
44
+ ['sdxl-turbo', 4],
45
+ // Krea 2 Turbo is an 8-step distilled model. Setting the launch
46
+ // `--steps` default matters more than for the others: the pinned
47
+ // sd-server doesn't honor the per-request `sample_steps`/`steps`
48
+ // override for these models, so without this the server falls back to
49
+ // its built-in 20-step default — 5× the render time for no quality
50
+ // gain, and on CPU that overruns the generate timeout entirely.
51
+ ['krea-2-turbo-q4', 8],
52
+ ['krea-2-turbo-q6', 8],
53
+ ['krea-2-turbo-q8', 8],
54
+ ['ming-image-0.1-design-bf16', 12],
55
+ ['moss-image-sft-q4', 50],
56
+ // The publisher recommends 100; 50 renders the same at half the time
57
+ // (300 s to 148 s per 1024px image on a DGX Spark, image bench 2026-10-19).
58
+ ['iris-3b-f32', 50],
59
+ ]);
60
+ /**
61
+ * Classifier-free guidance (CFG) scale per model, applied to the
62
+ * `/sdapi/v1/txt2img` request. Distilled / Flux-flow models are trained
63
+ * to run at CFG 1 — their guidance is baked in (turbo/lightning) or
64
+ * carried by the separate Flux distilled-guidance value. Applying
65
+ * sd-server's higher default CFG to them blows out color and contrast
66
+ * (the oversaturated, "burned" look Krea 2 Turbo had before this).
67
+ * Models absent from the map (traditional SD 1.x / SDXL checkpoints)
68
+ * omit `cfg_scale` and inherit the server default, preserving their
69
+ * established behavior.
70
+ */
71
+ export const MODEL_CFG_DEFAULTS = new Map([
72
+ ['krea-2-turbo-q4', 1],
73
+ ['krea-2-turbo-q6', 1],
74
+ ['krea-2-turbo-q8', 1],
75
+ ['flux-1-schnell-q4', 1],
76
+ ['flux-1-dev-q4', 1],
77
+ ['flux-2-klein-4b-q4', 1],
78
+ ['sd-turbo', 1],
79
+ ['sdxl-turbo', 1],
80
+ ['sdxl-lightning-4step', 1],
81
+ ['ming-image-0.1-design-bf16', 1],
82
+ ['moss-image-sft-q4', 3],
83
+ ['iris-3b-f32', 3],
84
+ // The model card's recommendation; the server's 7 over-saturates it.
85
+ ['animagine-xl-4', 5],
86
+ ]);
87
+ /**
88
+ * Whether the engine runs this model with guidance that reads a negative
89
+ * prompt. At CFG 1 the unconditional pass that carries it is skipped.
90
+ * Models absent from the map run at the server's default of 7.
91
+ */
92
+ export function sdCppHonorsNegativePrompt(modelId) {
93
+ const cfg = MODEL_CFG_DEFAULTS.get(modelId);
94
+ return cfg === undefined || cfg > 1;
95
+ }
96
+ /** Pin samplers validated with the native engine for these models. */
97
+ export const MODEL_SAMPLER_DEFAULTS = new Map([
98
+ ['ming-image-0.1-design-bf16', 'Euler'],
99
+ ['moss-image-sft-q4', 'Euler'],
100
+ ['iris-3b-f32', 'Euler'],
101
+ ]);
102
+ /**
103
+ * Models whose VAE shows visible tile-boundary seams under
104
+ * stable-diffusion.cpp's `--vae-tiling` (Krea 2's Qwen-Image VAE is the
105
+ * known case — rainbow column streaks at ≤512px). `--vae-tiling` is a
106
+ * launch-time, size-agnostic flag: sd-server serves every request size
107
+ * from one process, so we can't gate it per-request on image size as
108
+ * we'd ideally like — we gate it per-model instead and skip it here.
109
+ * The trade-off is that very large renders of these models could hit a
110
+ * VAE allocation cap on memory-constrained GPUs; acceptable given these
111
+ * are high-VRAM models and seam-free output matters more.
112
+ */
113
+ export const VAE_TILING_INCOMPATIBLE_MODELS = new Set([
114
+ 'krea-2-turbo-q4',
115
+ 'krea-2-turbo-q6',
116
+ 'krea-2-turbo-q8',
117
+ ]);
118
+ /**
119
+ * Models that render the same with EasyCache step reuse. MOSS took 205 s per
120
+ * 1024px image at 50 steps on a DGX Spark and 93 s with it, with renders a
121
+ * person could not tell apart (image bench, 2026-10-19). cache-dit and spectrum
122
+ * were as fast but changed text and detail.
123
+ */
124
+ const EASYCACHE_MODELS = new Set(['moss-image-sft-q4']);
125
+ export function sdServerLoadProfile(modelId) {
126
+ const stepCache = EASYCACHE_MODELS.has(modelId) ? { stepCache: 'easycache' } : {};
127
+ if (modelId === 'iris-3b-f32') {
128
+ // Iris generates pixels directly: no VAE to tile. Upstream's Iris recipe
129
+ // enables diffusion flash attention for the 1024px sequence. The
130
+ // publisher stores F32 but runs with BF16 autocast, so only the diffusion
131
+ // weights convert at load, preserving the quantized encoder.
132
+ return {
133
+ vaeDecode: 'none',
134
+ defaultSidePx: imageModelDefaultSide(modelId),
135
+ bf16TensorPattern: '^model\\.diffusion_model\\.',
136
+ diffusionFlashAttention: true,
137
+ ...stepCache,
138
+ };
139
+ }
140
+ // Tiling avoids VAE-decode OOM on large images, but produces visible
141
+ // tile-boundary seams with some VAEs — see VAE_TILING_INCOMPATIBLE_MODELS.
142
+ return {
143
+ vaeDecode: VAE_TILING_INCOMPATIBLE_MODELS.has(modelId) ? 'untiled' : 'tiled',
144
+ defaultSidePx: imageModelDefaultSide(modelId),
145
+ diffusionFlashAttention: false,
146
+ ...stepCache,
147
+ };
148
+ }
149
+ export class StableDiffusionCppProvider {
150
+ name = 'sd-cpp';
151
+ baseUrl;
152
+ modelsRoot;
153
+ storageRoots;
154
+ timeoutMs;
155
+ fetchImpl;
156
+ supervisor;
157
+ configured;
158
+ arbiter;
159
+ launchState;
160
+ defaultModelId;
161
+ renderTail = Promise.resolve();
162
+ pendingRenders = 0;
163
+ constructor(opts) {
164
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
165
+ this.modelsRoot = opts.modelsRoot;
166
+ this.storageRoots = opts.storageRoots ?? {
167
+ writableRoot: opts.modelsRoot,
168
+ readOnlyRoots: [],
169
+ };
170
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
171
+ this.fetchImpl = opts.fetchImpl ?? fetch;
172
+ this.supervisor = opts.supervisor;
173
+ // Default true — historically the provider had no concept of
174
+ // "unconfigured", and most call sites pass an explicit URL or
175
+ // supervisor. The factory's loopback fallback flips this off.
176
+ this.configured = opts.configured ?? true;
177
+ this.launchState = opts.launchState;
178
+ this.defaultModelId = opts.defaultModelId;
179
+ // Only register an evictor when we own a supervisor. External-URL
180
+ // mode targets an sd-server the user runs themselves; we don't
181
+ // get to stop their process.
182
+ if (opts.arbiter && opts.supervisor) {
183
+ this.arbiter = opts.arbiter;
184
+ this.arbiter.registerEvictor('image', () => opts.supervisor.stop());
185
+ }
186
+ }
187
+ async generate(input) {
188
+ if (input.signal?.aborted)
189
+ throw new ImageGenerationCancelledError();
190
+ // One render at a time: sd-server holds one model and renders serially,
191
+ // and cancelling a running render stops the engine, which must never
192
+ // take a second caller's render down with it. Each caller waits for
193
+ // every earlier caller, including one that left the queue early.
194
+ const previous = this.renderTail;
195
+ let release;
196
+ const mine = new Promise((resolve) => {
197
+ release = resolve;
198
+ });
199
+ this.renderTail = previous.then(() => mine);
200
+ const waiting = this.pendingRenders++ > 0;
201
+ try {
202
+ if (waiting) {
203
+ emitProgress(input, { phase: 'queued', step: 0, totalSteps: 0 });
204
+ await untilTurn(previous, input.signal);
205
+ }
206
+ return this.supervisor
207
+ ? await this.supervisor.withRequest(() => this.generateInner(input), input.signal)
208
+ : await this.generateInner(input);
209
+ }
210
+ catch (err) {
211
+ if (input.signal?.aborted && !(err instanceof ImageGenerationCancelledError)) {
212
+ throw new ImageGenerationCancelledError();
213
+ }
214
+ throw err;
215
+ }
216
+ finally {
217
+ this.pendingRenders--;
218
+ release();
219
+ }
220
+ }
221
+ async generateInner(input) {
222
+ const { signal } = input;
223
+ if (signal?.aborted)
224
+ throw new ImageGenerationCancelledError();
225
+ const modelId = input.model ?? (await this.resolveDefaultModelId());
226
+ const defaultDimension = imageModelDefaultSide(modelId);
227
+ const width = input.width ?? defaultDimension;
228
+ const height = input.height ?? defaultDimension;
229
+ const steps = input.steps ?? (await this.defaultSampleSteps(modelId));
230
+ const seed = input.seed ?? Math.floor(Math.random() * 2_147_483_647);
231
+ const started = Date.now();
232
+ log.info(`[sd-cpp] generate model=${modelId ?? 'default'} steps=${steps} explicitSteps=${input.steps !== undefined} size=${width}x${height}`);
233
+ // Model selection: sd-server serves one model per process. When a
234
+ // request targets a different installed model than the one currently
235
+ // loaded, point resolveLaunch at it (via the shared launchState) and
236
+ // stop the running server so the next ensureRunning relaunches with
237
+ // the requested model — instead of always serving whichever model
238
+ // sorts first. A no-op on the first generate (nothing running yet).
239
+ if (this.supervisor && this.launchState && modelId && this.launchState.modelId !== modelId) {
240
+ this.launchState.modelId = modelId;
241
+ await this.supervisor.stop();
242
+ }
243
+ // A render the caller abandons keeps running inside sd-server, which
244
+ // cannot interrupt one it has started, so cancelling stops the engine.
245
+ const request = new AbortController();
246
+ const stopRender = () => {
247
+ request.abort();
248
+ if (this.supervisor)
249
+ void this.supervisor.stop().catch(() => { });
250
+ };
251
+ signal?.addEventListener('abort', stopRender, { once: true });
252
+ let baseUrl = this.baseUrl;
253
+ // Engine log lines drive progress (sd-server's `|==> | 3/20 - 18.20s/it`
254
+ // bars) and explain a failed render. Only meaningful when we own the
255
+ // supervisor (external-URL mode has no log stream).
256
+ const recentLines = [];
257
+ let unsubscribeLogs;
258
+ if (this.supervisor) {
259
+ const track = input.onProgress ? progressTracker((p) => emitProgress(input, p)) : undefined;
260
+ unsubscribeLogs = this.supervisor.subscribeLogLines((line) => {
261
+ recentLines.push(line);
262
+ if (recentLines.length > RECENT_ENGINE_LINES)
263
+ recentLines.shift();
264
+ track?.(line);
265
+ });
266
+ }
267
+ let releaseGpu;
268
+ let timeout;
269
+ try {
270
+ if (this.supervisor) {
271
+ // Acquire the GPU slot BEFORE asking the supervisor to start.
272
+ // In `swap` policy this evicts a running llama-server so its
273
+ // VRAM is freed by the time sd-server tries to load weights.
274
+ // In `coexist` policy the call returns immediately. The arbiter
275
+ // is only set on the supervised path (see constructor).
276
+ if (this.arbiter)
277
+ releaseGpu = await this.arbiter.acquireLease('image');
278
+ if (!this.supervisor.currentBaseUrl()) {
279
+ emitProgress(input, { phase: 'loading_model', step: 0, totalSteps: 0 });
280
+ }
281
+ const launch = await this.supervisor.ensureRunning();
282
+ baseUrl = launch.baseUrl.replace(/\/+$/, '');
283
+ }
284
+ if (signal?.aborted)
285
+ throw new ImageGenerationCancelledError();
286
+ // Drive sd-server's A1111-compatible endpoints (`/sdapi/v1/txt2img`
287
+ // and `/sdapi/v1/img2img`). Unlike the OpenAI `/v1/images/*` shape
288
+ // — which reads only prompt/n/size and takes steps/seed/cfg from the
289
+ // server's launch defaults — these honor per-request `steps`,
290
+ // `seed`, `cfg_scale`, and `negative_prompt`, so caller overrides
291
+ // actually take effect. `cfg_scale` is set per model (distilled /
292
+ // Flux-flow models need CFG 1; others inherit the server default).
293
+ const cfg = modelId ? MODEL_CFG_DEFAULTS.get(modelId) : undefined;
294
+ let inputImages = input.inputImages ?? [];
295
+ // Capability gate: models whose architecture doesn't honor init
296
+ // latents on the pinned sd-server fall back to txt2img instead of
297
+ // silently producing an "edit" that never saw the source. The skip
298
+ // is reported on meta so every caller (route, MCP tool, chat
299
+ // bubble) can say so honestly.
300
+ let img2imgSkippedReason;
301
+ if (inputImages.length > 0) {
302
+ const installed = await this.readInstalledManifestFields(modelId);
303
+ const verdict = resolveImg2ImgSupport({
304
+ modelId,
305
+ explicit: installed?.supportsImg2Img,
306
+ weightsKind: installed?.weightsKind,
307
+ });
308
+ if (!verdict.supported) {
309
+ img2imgSkippedReason = verdict.reason ?? 'model does not support image editing (img2img)';
310
+ log.info(`[sd-cpp] dropping ${inputImages.length} input image(s): ${img2imgSkippedReason}`);
311
+ inputImages = [];
312
+ }
313
+ }
314
+ const body = {
315
+ prompt: input.prompt,
316
+ negative_prompt: input.negativePrompt ?? '',
317
+ width,
318
+ height,
319
+ steps,
320
+ seed,
321
+ batch_size: 1,
322
+ };
323
+ if (cfg !== undefined)
324
+ body.cfg_scale = cfg;
325
+ const sampler = modelId ? MODEL_SAMPLER_DEFAULTS.get(modelId) : undefined;
326
+ if (sampler)
327
+ body.sampler_name = sampler;
328
+ let endpoint = `${baseUrl}/sdapi/v1/txt2img`;
329
+ // Img2img: a single source image drives `/sdapi/v1/img2img` via
330
+ // `init_images` (base64). stable-diffusion.cpp's img2img path takes
331
+ // one image, so we use the first and warn for any extras.
332
+ if (inputImages.length > 0) {
333
+ if (inputImages.length > 1) {
334
+ log.info(`[sd-cpp] received ${inputImages.length} input images; img2img uses the first only`);
335
+ }
336
+ const first = inputImages[0];
337
+ body.init_images = [Buffer.from(first.data).toString('base64')];
338
+ if (typeof input.strength === 'number')
339
+ body.denoising_strength = input.strength;
340
+ endpoint = `${baseUrl}/sdapi/v1/img2img`;
341
+ }
342
+ // The budget counts awake time only: a render that spans a laptop nap
343
+ // has not used the time the nap took.
344
+ timeout = createAwakeTimeout(this.timeoutMs);
345
+ const deadline = timeout.signal;
346
+ deadline.addEventListener('abort', stopRender, { once: true });
347
+ let res;
348
+ try {
349
+ res = await this.fetchImpl(endpoint, {
350
+ method: 'POST',
351
+ headers: { 'Content-Type': 'application/json' },
352
+ body: JSON.stringify(body),
353
+ signal: request.signal,
354
+ });
355
+ }
356
+ catch (err) {
357
+ if (signal?.aborted)
358
+ throw new ImageGenerationCancelledError();
359
+ if (deadline.aborted) {
360
+ throw new Error(`sd-server did not respond within ${this.timeoutMs / 1000}s`);
361
+ }
362
+ throw new Error(`sd-server unreachable at ${baseUrl}. Is the image engine enabled in Settings? (${err instanceof Error ? err.message : String(err)})`);
363
+ }
364
+ if (!res.ok) {
365
+ const text = await res.text().catch(() => '');
366
+ const shortfall = recentLines.find((line) => ENGINE_MEMORY_SHORTFALL.test(line));
367
+ if (shortfall) {
368
+ // The engine keeps the weights it loaded; stop it so the next
369
+ // launch is admitted against what is actually free.
370
+ if (this.supervisor)
371
+ await this.supervisor.stop().catch(() => { });
372
+ throw new ImageCapacityError(shortfall
373
+ .replace(/^\[[^\]]+\]\s*/, '')
374
+ .trim()
375
+ .slice(0, 200));
376
+ }
377
+ throw new Error(`sd-server returned ${res.status} ${res.statusText}: ${text.slice(0, 300)}`);
378
+ }
379
+ // A1111 txt2img/img2img return `{ images: [<base64 PNG>], … }`.
380
+ const payload = (await res.json());
381
+ const b64 = payload.images?.[0];
382
+ if (!b64)
383
+ throw new Error('sd-server response missing images[0]');
384
+ const png = Buffer.from(b64, 'base64');
385
+ // The engine rounds a size its network cannot take (SDXL to 64), so the
386
+ // picture's own header is the size the person got.
387
+ const rendered = readImageMeta(png);
388
+ return {
389
+ png,
390
+ meta: {
391
+ model: modelId ?? 'unknown',
392
+ seed,
393
+ steps,
394
+ widthPx: rendered?.width || width,
395
+ heightPx: rendered?.height || height,
396
+ durationMs: Date.now() - started,
397
+ ...(img2imgSkippedReason ? { img2imgSkippedReason } : {}),
398
+ },
399
+ };
400
+ }
401
+ catch (err) {
402
+ if (signal?.aborted && !(err instanceof ImageGenerationCancelledError)) {
403
+ throw new ImageGenerationCancelledError();
404
+ }
405
+ throw err;
406
+ }
407
+ finally {
408
+ timeout?.dispose();
409
+ signal?.removeEventListener('abort', stopRender);
410
+ this.supervisor?.markUsed();
411
+ unsubscribeLogs?.();
412
+ releaseGpu?.();
413
+ }
414
+ }
415
+ async listInstalledModels() {
416
+ const entries = await listOverlayModelIds(this.storageRoots);
417
+ const out = [];
418
+ for (const id of entries) {
419
+ const root = await findModelRoot(this.storageRoots, id);
420
+ if (!root)
421
+ continue;
422
+ const metaPath = join(root, id, 'manifest.json');
423
+ try {
424
+ const raw = await readFile(metaPath, 'utf8');
425
+ const parsed = JSON.parse(raw);
426
+ if (!parsed.id || !parsed.name || !parsed.approxSizeBytes || !parsed.installedAt)
427
+ continue;
428
+ if (!(await verifyReadOnlyModelPayload(this.storageRoots, root, id, parsed.fileSha256))) {
429
+ continue;
430
+ }
431
+ out.push({
432
+ id: parsed.id,
433
+ name: parsed.name,
434
+ approxSizeBytes: parsed.approxSizeBytes,
435
+ installedAt: parsed.installedAt,
436
+ ...(parsed.weightsKind ? { weightsKind: parsed.weightsKind } : {}),
437
+ ...(typeof parsed.supportsImg2Img === 'boolean'
438
+ ? { supportsImg2Img: parsed.supportsImg2Img }
439
+ : {}),
440
+ });
441
+ }
442
+ catch {
443
+ /* skip malformed entries */
444
+ }
445
+ }
446
+ out.sort((a, b) => a.name.localeCompare(b.name));
447
+ return out;
448
+ }
449
+ async *pullModel(id, spec, signal) {
450
+ const itemDir = join(this.modelsRoot, id);
451
+ await mkdir(this.modelsRoot, { recursive: true });
452
+ await assertModelStorePathSafe(this.modelsRoot, itemDir);
453
+ await mkdir(itemDir, { recursive: true });
454
+ await assertModelStorePathSafe(this.modelsRoot, itemDir);
455
+ // Total bytes across the unet + every auxiliary file. Driving the UI
456
+ // progress bar from this aggregate keeps multi-file pulls visually
457
+ // identical to single-file ones.
458
+ const totalAllBytes = spec.approxSizeBytes + spec.auxiliaryFiles.reduce((n, f) => n + f.approxSizeBytes, 0);
459
+ let writtenAll = 0;
460
+ const weightsFilename = extractFilename(spec.downloadUrl);
461
+ const weightsPath = join(itemDir, weightsFilename);
462
+ const downloadResult = yield* this.downloadFile({
463
+ url: spec.downloadUrl,
464
+ destPath: weightsPath,
465
+ expectedSha256: spec.sha256,
466
+ approxSizeBytes: spec.approxSizeBytes,
467
+ writtenSoFar: writtenAll,
468
+ totalAllBytes,
469
+ ...(signal ? { signal } : {}),
470
+ });
471
+ if (downloadResult.kind === 'error') {
472
+ yield { type: 'error', error: downloadResult.error };
473
+ yield { type: 'done', id };
474
+ return;
475
+ }
476
+ writtenAll = downloadResult.writtenAll;
477
+ const weightsActualSize = downloadResult.actualSize;
478
+ // Auxiliary files (VAE / clip_l / clip_g / t5xxl). Streamed sequentially
479
+ // so we don't fight the host's bandwidth across N parallel HF connections.
480
+ const auxiliaryRecords = [];
481
+ for (const aux of spec.auxiliaryFiles) {
482
+ const auxFilename = auxFilenameFor(aux);
483
+ const auxPath = join(itemDir, auxFilename);
484
+ const auxResult = yield* this.downloadFile({
485
+ url: aux.downloadUrl,
486
+ destPath: auxPath,
487
+ expectedSha256: aux.sha256,
488
+ approxSizeBytes: aux.approxSizeBytes,
489
+ writtenSoFar: writtenAll,
490
+ totalAllBytes,
491
+ ...(signal ? { signal } : {}),
492
+ });
493
+ if (auxResult.kind === 'error') {
494
+ yield { type: 'error', error: `${aux.role}: ${auxResult.error}` };
495
+ yield { type: 'done', id };
496
+ return;
497
+ }
498
+ writtenAll = auxResult.writtenAll;
499
+ auxiliaryRecords.push({ role: aux.role, filename: auxFilename });
500
+ }
501
+ const fileSha256 = await hashModelPayloadFiles(itemDir);
502
+ await writeFile(join(itemDir, 'manifest.json'), JSON.stringify({
503
+ id,
504
+ name: spec.name,
505
+ approxSizeBytes: totalAllBytes,
506
+ weightsFilename,
507
+ weightsKind: spec.weightsKind,
508
+ ...(spec.supportsImg2Img !== undefined ? { supportsImg2Img: spec.supportsImg2Img } : {}),
509
+ weightsSizeBytes: weightsActualSize,
510
+ sha256: spec.sha256.toLowerCase(),
511
+ auxiliaryFiles: auxiliaryRecords,
512
+ fileSha256,
513
+ installedAt: new Date().toISOString(),
514
+ }, null, 2), 'utf8');
515
+ await makeSharedModelReadable(itemDir);
516
+ yield { type: 'progress', bytesWritten: writtenAll, totalBytes: totalAllBytes };
517
+ yield { type: 'done', id };
518
+ }
519
+ /**
520
+ * Stream a single URL to disk with sha256 verification, reporting cumulative
521
+ * progress against `totalAllBytes` so multi-file pulls share one bar.
522
+ *
523
+ * Delegates the network plumbing to `downloadWithRetry` — that handles
524
+ * Range-based resume, exponential backoff, chunk-stall timeouts, and
525
+ * friendly error messages. This wrapper only owns the
526
+ * provider-specific concerns: sha256 verification, the
527
+ * `writtenSoFar`-aware progress translation, and the atomic rename
528
+ * after verify.
529
+ */
530
+ async *downloadFile(opts) {
531
+ const { url, destPath, expectedSha256, approxSizeBytes, totalAllBytes, signal } = opts;
532
+ const baseWritten = opts.writtenSoFar;
533
+ const gen = downloadWithRetry({
534
+ url,
535
+ destPath,
536
+ approxSizeBytes,
537
+ fetchImpl: this.fetchImpl,
538
+ ...(signal ? { signal } : {}),
539
+ });
540
+ let writtenThisFile = 0;
541
+ while (true) {
542
+ const next = await gen.next();
543
+ if (next.done) {
544
+ if (next.value.kind === 'ok') {
545
+ writtenThisFile = next.value.bytesWritten;
546
+ break;
547
+ }
548
+ if (next.value.kind === 'aborted') {
549
+ // Leave .partial in place so a follow-up pull resumes.
550
+ return { kind: 'error', error: 'download aborted' };
551
+ }
552
+ return { kind: 'error', error: next.value.error };
553
+ }
554
+ const ev = next.value;
555
+ if (ev.type === 'progress') {
556
+ // Reproject per-file progress onto the multi-file aggregate.
557
+ writtenThisFile = ev.bytesWritten;
558
+ yield {
559
+ type: 'progress',
560
+ bytesWritten: baseWritten + ev.bytesWritten,
561
+ totalBytes: totalAllBytes,
562
+ };
563
+ }
564
+ else if (ev.type === 'retrying') {
565
+ yield {
566
+ type: 'retrying',
567
+ attempt: ev.attempt,
568
+ maxAttempts: ev.maxAttempts,
569
+ delayMs: ev.delayMs,
570
+ reason: ev.reason,
571
+ };
572
+ }
573
+ }
574
+ // sha256 check — stream the .partial through the hasher rather than
575
+ // hashing while we write. Slightly more disk I/O on success, but it
576
+ // means the hasher stays correct across resume (Range requests
577
+ // would otherwise corrupt a per-chunk hasher with mid-file resumed
578
+ // bytes).
579
+ const partialPath = `${destPath}.partial`;
580
+ const hasher = createHash('sha256');
581
+ await new Promise((resolve, reject) => {
582
+ // Big read buffer so the hash doesn't starve in the busy daemon event
583
+ // loop. See MODEL_HASH_READ_BUFFER_BYTES.
584
+ const stream = createReadStream(partialPath, {
585
+ highWaterMark: MODEL_HASH_READ_BUFFER_BYTES,
586
+ });
587
+ stream.on('data', (chunk) => hasher.update(chunk));
588
+ stream.on('end', () => resolve());
589
+ stream.on('error', reject);
590
+ });
591
+ const actual = hasher.digest('hex');
592
+ if (actual !== expectedSha256.toLowerCase()) {
593
+ await rm(partialPath, { force: true });
594
+ return {
595
+ kind: 'error',
596
+ error: `sha256 mismatch: expected ${expectedSha256}, got ${actual}`,
597
+ };
598
+ }
599
+ await rm(destPath, { force: true });
600
+ await rename(partialPath, destPath);
601
+ return { kind: 'ok', writtenAll: baseWritten + writtenThisFile, actualSize: writtenThisFile };
602
+ }
603
+ async deleteModel(id) {
604
+ const itemDir = resolveModelDirectory(this.modelsRoot, id);
605
+ if (await modelExistsOnlyReadOnly(this.storageRoots, id)) {
606
+ throw readOnlyModelError(id);
607
+ }
608
+ await rm(itemDir, { recursive: true, force: true });
609
+ }
610
+ async health() {
611
+ // The factory's default-loopback branch flips `configured: false` so
612
+ // we can return a distinct state instead of confusing the user with
613
+ // a generic "unreachable" — the engine isn't unreachable, it was
614
+ // never wired up. Without a binary or external URL, the provider
615
+ // points at 127.0.0.1:9081 by default; nothing's going to answer.
616
+ if (!this.configured) {
617
+ return {
618
+ status: 'not-configured',
619
+ baseUrl: this.baseUrl,
620
+ error: 'The image engine is not installed yet. Install it with `gezel native install` ' +
621
+ '(or from the desktop app), or set GEZEL_SD_SERVER_URL to a running sd-server.',
622
+ };
623
+ }
624
+ // When a supervisor is wired the engine is "configured but lazy" —
625
+ // the binary will spawn on the first generate. We could call
626
+ // ensureRunning() here, but probing eagerly defeats the lazy-start
627
+ // pattern and would cost a cold-start every time the user opens
628
+ // Settings. Surface ok and rely on the supervisor's internal
629
+ // health-watch.
630
+ if (this.supervisor) {
631
+ return { status: 'ok', baseUrl: this.baseUrl };
632
+ }
633
+ // Probe the configured URL with a short HEAD-style fetch. Most
634
+ // sd-server builds answer the root with a quick 404; what we care
635
+ // about is whether anything's listening at all. A network-level
636
+ // throw (ECONNREFUSED) is the unreachable signal.
637
+ const ctrl = new AbortController();
638
+ const timer = setTimeout(() => ctrl.abort(), 2000);
639
+ try {
640
+ const res = await this.fetchImpl(`${this.baseUrl}/`, {
641
+ method: 'GET',
642
+ signal: ctrl.signal,
643
+ });
644
+ // Any HTTP response — 200, 404, 405 — proves something is listening.
645
+ void res.body?.cancel?.().catch(() => { });
646
+ return { status: 'ok', baseUrl: this.baseUrl };
647
+ }
648
+ catch (err) {
649
+ const message = ctrl.signal.aborted
650
+ ? `sd-server did not respond within 2s at ${this.baseUrl}`
651
+ : err instanceof Error
652
+ ? err.message
653
+ : String(err);
654
+ return {
655
+ status: 'unreachable',
656
+ baseUrl: this.baseUrl,
657
+ error: message,
658
+ };
659
+ }
660
+ finally {
661
+ clearTimeout(timer);
662
+ }
663
+ }
664
+ async shutdown() {
665
+ await this.supervisor?.stop();
666
+ }
667
+ /**
668
+ * The configured default when it is installed, else the first installed
669
+ * model id, else undefined. Stable ordering matches
670
+ * {@link listInstalledModels} so the default is deterministic.
671
+ */
672
+ async resolveDefaultModelId() {
673
+ const models = await this.listInstalledModels();
674
+ if (this.defaultModelId && models.some((m) => m.id === this.defaultModelId)) {
675
+ return this.defaultModelId;
676
+ }
677
+ return models[0]?.id;
678
+ }
679
+ async defaultSampleSteps(modelId) {
680
+ if (!modelId)
681
+ return DEFAULT_SAMPLE_STEPS;
682
+ const fields = await this.readInstalledManifestFields(modelId);
683
+ if (typeof fields?.recommendedSteps === 'number' &&
684
+ Number.isInteger(fields.recommendedSteps) &&
685
+ fields.recommendedSteps > 0) {
686
+ return fields.recommendedSteps;
687
+ }
688
+ return MODEL_SAMPLE_STEP_DEFAULTS.get(modelId) ?? DEFAULT_SAMPLE_STEPS;
689
+ }
690
+ /**
691
+ * Read the installed `manifest.json` fields that steer per-model
692
+ * behavior. Returns null for unknown ids and unreadable/legacy
693
+ * metadata — callers fall back to their per-field defaults. (Older
694
+ * installed metadata did not persist catalog generation defaults or
695
+ * the img2img capability.)
696
+ */
697
+ async readInstalledManifestFields(modelId) {
698
+ if (!modelId)
699
+ return null;
700
+ try {
701
+ const root = await findModelRoot(this.storageRoots, modelId);
702
+ if (!root)
703
+ return null;
704
+ const raw = await readFile(join(root, modelId, 'manifest.json'), 'utf8');
705
+ const parsed = JSON.parse(raw);
706
+ return {
707
+ ...(typeof parsed.recommendedSteps === 'number'
708
+ ? { recommendedSteps: parsed.recommendedSteps }
709
+ : {}),
710
+ ...(parsed.weightsKind === 'checkpoint' || parsed.weightsKind === 'diffusion-model'
711
+ ? { weightsKind: parsed.weightsKind }
712
+ : {}),
713
+ ...(typeof parsed.supportsImg2Img === 'boolean'
714
+ ? { supportsImg2Img: parsed.supportsImg2Img }
715
+ : {}),
716
+ };
717
+ }
718
+ catch {
719
+ return null;
720
+ }
721
+ }
722
+ }
723
+ /**
724
+ * Parse a single line of sd-server's sampling progress output. The
725
+ * upstream stable-diffusion.cpp server prints lines shaped like:
726
+ *
727
+ * |==> | 1/20 - 18.20s/it
728
+ * |====> | 2/25 - 7.18it/s
729
+ *
730
+ * sometimes with a leading log prefix from our supervisor ("[sd-server] ").
731
+ * The rate flips to `it/s` once a step takes under a second, which on a
732
+ * fast GPU is every step of a turbo or SD 1.5 render. Returns the extracted
733
+ * step / total / per-step seconds when the line matches; otherwise null.
734
+ * Anchored on the fraction-with-suffix shape (`N/M - X.Ys/it`) so unrelated
735
+ * log noise, including the `MB/s` weight-load bar, can never trip a false
736
+ * match.
737
+ */
738
+ /** Engine lines kept while a render runs, to explain a failure. */
739
+ const RECENT_ENGINE_LINES = 40;
740
+ /**
741
+ * sd-server's report that the render did not fit in graphics memory. It
742
+ * answers the request with a bare `generate_image returned no results`.
743
+ */
744
+ const ENGINE_MEMORY_SHORTFALL = /cannot make enough memory available|out of (?:device )?memory|ErrorOutOfDeviceMemory|failed to allocate/i;
745
+ function emitProgress(input, progress) {
746
+ try {
747
+ input.onProgress?.(progress);
748
+ }
749
+ catch {
750
+ /* listener errors are not load-bearing — drop silently */
751
+ }
752
+ }
753
+ /** Resolve when `previous` settles, or reject when `signal` aborts first. */
754
+ function untilTurn(previous, signal) {
755
+ if (!signal)
756
+ return previous;
757
+ return new Promise((resolve, reject) => {
758
+ const onAbort = () => reject(new ImageGenerationCancelledError());
759
+ if (signal.aborted)
760
+ return onAbort();
761
+ signal.addEventListener('abort', onAbort, { once: true });
762
+ void previous.then(() => {
763
+ signal.removeEventListener('abort', onAbort);
764
+ resolve();
765
+ });
766
+ });
767
+ }
768
+ /**
769
+ * Parse one line of sd-server's weight-load bar, which it prints inside the
770
+ * first request after a launch because it loads weights lazily:
771
+ *
772
+ * |######### | 49/298 - 435.57MB/s
773
+ */
774
+ export function parseWeightLoadProgress(line) {
775
+ const m = line.match(/(\d+)\s*\/\s*(\d+)\s*-\s*[\d.]+\s*(?:[KMG]?B)\/s/);
776
+ if (!m)
777
+ return null;
778
+ const step = Number.parseInt(m[1], 10);
779
+ const totalSteps = Number.parseInt(m[2], 10);
780
+ if (!Number.isFinite(step) || !Number.isFinite(totalSteps) || totalSteps <= 0)
781
+ return null;
782
+ return { phase: 'loading_model', step, totalSteps };
783
+ }
784
+ /**
785
+ * Classify sd-server's progress bars into phases. Weight loads print a
786
+ * bytes-per-second bar. The first iteration bar is sampling; once it
787
+ * completes, the next is the tiled VAE decode, which reports tiles rather
788
+ * than steps. The step count the engine prints is used rather than the one
789
+ * requested, because some launch defaults override a request's steps.
790
+ */
791
+ export function progressTracker(emit) {
792
+ let sampling = false;
793
+ let sampled = false;
794
+ return (line) => {
795
+ const load = parseWeightLoadProgress(line);
796
+ // The VAE's weights load lazily just before decoding; reporting that
797
+ // as loading the model would send the progress back a phase.
798
+ if (load)
799
+ return sampling ? undefined : emit(load);
800
+ const bar = parseSamplingProgress(line);
801
+ if (!bar)
802
+ return;
803
+ sampling = true;
804
+ emit({ ...bar, phase: sampled ? 'decoding' : 'sampling' });
805
+ if (!sampled && bar.step >= bar.totalSteps)
806
+ sampled = true;
807
+ };
808
+ }
809
+ export function parseSamplingProgress(line) {
810
+ const m = line.match(/(\d+)\s*\/\s*(\d+)\s*-\s*([\d.]+)(s\/it|it\/s)/);
811
+ if (!m)
812
+ return null;
813
+ const step = Number.parseInt(m[1], 10);
814
+ const totalSteps = Number.parseInt(m[2], 10);
815
+ const rate = Number.parseFloat(m[3]);
816
+ if (!Number.isFinite(step) || !Number.isFinite(totalSteps) || totalSteps <= 0)
817
+ return null;
818
+ const out = { step, totalSteps };
819
+ const secondsPerStep = m[4] === 'it/s' ? 1 / rate : rate;
820
+ if (Number.isFinite(secondsPerStep) && secondsPerStep > 0)
821
+ out.secondsPerStep = secondsPerStep;
822
+ return out;
823
+ }
824
+ function extractFilename(url) {
825
+ try {
826
+ const u = new URL(url);
827
+ const last = u.pathname.split('/').filter(Boolean).pop();
828
+ return last && last.length > 0 ? last : 'weights.bin';
829
+ }
830
+ catch {
831
+ return 'weights.bin';
832
+ }
833
+ }
834
+ /**
835
+ * Filename used on disk for an auxiliary file. We want a deterministic
836
+ * `<role>.<ext>` shape (e.g. `vae.safetensors`) so the supervisor can build
837
+ * launch args without re-hitting the HF URL — but we still respect the
838
+ * upstream file extension so the engine sniffs the right format.
839
+ */
840
+ function auxFilenameFor(aux) {
841
+ const upstream = extractFilename(aux.downloadUrl);
842
+ const dot = upstream.lastIndexOf('.');
843
+ const ext = dot > 0 ? upstream.slice(dot) : '';
844
+ return `${aux.role}${ext}`;
845
+ }