@bendyline/gezel-inference 1.0.3 → 1.0.4

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 (95) 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/routes/v1-images.d.ts +44 -0
  57. package/dist/http/routes/v1-images.js +370 -0
  58. package/dist/inference-service.d.ts +3 -0
  59. package/dist/inference-service.js +24 -2
  60. package/dist/kokoro-lexicon/manifest.json +6 -3
  61. package/dist/models/image-fit.d.ts +83 -0
  62. package/dist/models/image-fit.js +163 -0
  63. package/dist/models/weight-facts.d.ts +36 -0
  64. package/dist/models/weight-facts.js +171 -0
  65. package/dist/providers/ds4/build-provider.js +28 -21
  66. package/dist/providers/ds4/dspark.d.ts +23 -5
  67. package/dist/providers/ds4/dspark.js +36 -8
  68. package/dist/providers/image/img2img-support.d.ts +40 -0
  69. package/dist/providers/image/img2img-support.js +74 -0
  70. package/dist/providers/image/local-engine.d.ts +31 -0
  71. package/dist/providers/image/local-engine.js +47 -0
  72. package/dist/providers/image/local-factory.d.ts +95 -0
  73. package/dist/providers/image/local-factory.js +270 -0
  74. package/dist/providers/image/mock.d.ts +23 -0
  75. package/dist/providers/image/mock.js +151 -0
  76. package/dist/providers/image/pull-registry.d.ts +106 -0
  77. package/dist/providers/image/pull-registry.js +227 -0
  78. package/dist/providers/image/sd-cpp.d.ts +200 -0
  79. package/dist/providers/image/sd-cpp.js +845 -0
  80. package/dist/providers/image/types.d.ts +235 -0
  81. package/dist/providers/image/types.js +20 -0
  82. package/dist/providers/llama-cpp/backend-health.d.ts +7 -0
  83. package/dist/providers/llama-cpp/backend-health.js +41 -1
  84. package/dist/providers/mlx/provider.js +13 -3
  85. package/dist/providers/native/supervisor.d.ts +8 -0
  86. package/dist/providers/native/supervisor.js +52 -4
  87. package/dist/providers/project-macro-loop-bail.js +5 -3
  88. package/dist/providers/provider-lifecycle.d.ts +21 -0
  89. package/dist/providers/provider-lifecycle.js +83 -0
  90. package/dist/providers/retirement-gate.d.ts +20 -0
  91. package/dist/providers/retirement-gate.js +55 -0
  92. package/dist/system/memory.js +6 -0
  93. package/dist/utils/image-meta.d.ts +12 -0
  94. package/dist/utils/image-meta.js +77 -0
  95. package/package.json +6 -6
@@ -0,0 +1,83 @@
1
+ /**
2
+ * "Can this machine run that image model?" for the image catalog.
3
+ *
4
+ * Prices each catalog entry from the weight facts its gilde entry pins (core
5
+ * `media-memory.ts`) — never the network — and checks it against the same
6
+ * static ceilings the device ledger refuses at, so `wont-run` here means the
7
+ * launch path would refuse the model on this machine whatever else is
8
+ * running. The download pages hide those models and the pull routes refuse
9
+ * them. Contract: docs/media-model-memory.md.
10
+ *
11
+ * The video half lives with the video engine in the service, which shares
12
+ * the helpers here.
13
+ */
14
+ import { type GezelConfig, type ImageModelManifest, type ImageQuality, type MediaCapacity, type MediaFitAssessment, type MediaMemoryPool, type MediaMemoryRequirement, type MediaModelFit, type MediaModelFitResponse, type MediaWeightFile } from '@bendyline/gezel';
15
+ import type { CatalogService } from '@bendyline/gezel-catalog';
16
+ import type { CapacityBudgetKind } from '../providers/native/capacity-budget.js';
17
+ export interface MediaFitContext {
18
+ catalog: CatalogService;
19
+ readConfig: () => Promise<Pick<GezelConfig, 'localEngineMemoryGb'>>;
20
+ }
21
+ /**
22
+ * The 409 body a pull route answers with when the model cannot run here, or
23
+ * null to let the pull start. A failure to assess never blocks a download:
24
+ * the launch path still applies the same check.
25
+ */
26
+ export declare function wontRunRefusal(assess: () => Promise<MediaFitAssessment | null>): Promise<{
27
+ error: string;
28
+ code: string;
29
+ } | null>;
30
+ /**
31
+ * Where sd-server keeps weights: Metal on Apple Silicon, CPU on Intel Macs,
32
+ * CUDA or Vulkan elsewhere, falling back to the processor when no GPU
33
+ * answers. Windows on Arm runs on the processor unless discovery picked its
34
+ * CUDA build, which only an RTX Spark gets.
35
+ */
36
+ export declare function imageMediaPool(kind: CapacityBudgetKind, host?: {
37
+ platform: NodeJS.Platform;
38
+ arch: string;
39
+ sdServerBin?: string | undefined;
40
+ }): MediaMemoryPool;
41
+ /** The ceilings the device ledger applies on this machine, automatic and under the person's limit. */
42
+ export declare function mediaCapacity(pool: MediaMemoryPool, readConfig: MediaFitContext['readConfig']): Promise<MediaCapacity>;
43
+ /** The memory sd-server needs for one model, from its weight files and its launch profile. */
44
+ export declare function sdServerRequirement(input: {
45
+ modelId: string;
46
+ weightsKind: 'checkpoint' | 'diffusion-model';
47
+ files: readonly MediaWeightFile[];
48
+ pool: MediaMemoryPool;
49
+ /** The render size to price; the model's default size when absent. */
50
+ megapixels?: number;
51
+ }): MediaMemoryRequirement;
52
+ /**
53
+ * What a launched sd-server claims on the device ledger.
54
+ *
55
+ * On a discrete card the fit figures charge graphics memory for weights only,
56
+ * because sd-server can stream components from RAM, and the download page
57
+ * must not call a model that offloads "won't run". A running render holds its
58
+ * compute buffers on the card too, though: FLUX.2 Klein at 1024px held
59
+ * 9.4 GiB of a 12 GiB card's dedicated memory against 4.9 GiB of weights
60
+ * (RTX 5070 Ti Laptop, Vulkan, M0 spike 2026-10-25). Claiming weights only
61
+ * let a resident chat model keep its memory, and the render then failed at
62
+ * its first step. The claim is the estimated peak, capped at the card so a
63
+ * model that must offload is admitted once the card is otherwise free.
64
+ */
65
+ export declare function sdServerLedgerClaim(requirement: MediaMemoryRequirement, cardBytes: number): {
66
+ bytes: number;
67
+ gpuBytes: number;
68
+ };
69
+ /** Megapixels a model renders at a quality tier, inside its range. */
70
+ export declare function imageRenderMegapixels(manifest: Pick<ImageModelManifest, 'id' | 'widthRange' | 'heightRange'>, quality: ImageQuality): number;
71
+ export interface FitPlan {
72
+ pool: MediaMemoryPool;
73
+ capacity: MediaCapacity;
74
+ }
75
+ export declare function mediaFitResponse(plan: FitPlan, models: MediaModelFit[]): MediaModelFitResponse;
76
+ /**
77
+ * Every catalog image model's fit. With `quality`, each is priced at the size
78
+ * it renders at that tier rather than its default, which is how the direct
79
+ * image gezel's settings know whether High will fit.
80
+ */
81
+ export declare function imageCatalogFits(ctx: MediaFitContext, quality?: ImageQuality): Promise<MediaModelFitResponse>;
82
+ /** One catalog image model's fit, or null when the id is not in the catalog. */
83
+ export declare function imageModelFit(ctx: MediaFitContext, id: string): Promise<MediaFitAssessment | null>;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * "Can this machine run that image model?" for the image catalog.
3
+ *
4
+ * Prices each catalog entry from the weight facts its gilde entry pins (core
5
+ * `media-memory.ts`) — never the network — and checks it against the same
6
+ * static ceilings the device ledger refuses at, so `wont-run` here means the
7
+ * launch path would refuse the model on this machine whatever else is
8
+ * running. The download pages hide those models and the pull routes refuse
9
+ * them. Contract: docs/media-model-memory.md.
10
+ *
11
+ * The video half lives with the video engine in the service, which shares
12
+ * the helpers here.
13
+ */
14
+ import { totalmem } from 'node:os';
15
+ import { MEDIA_MODEL_WONT_RUN_CODE, assessMediaFit, createLogger, imageMemoryRequirement, imageModelDefaultSide, resolveImageSize, } from '@bendyline/gezel';
16
+ import { isCudaArchivePath } from '@bendyline/gezel/native';
17
+ import { sdServerLoadProfile } from '../providers/image/sd-cpp.js';
18
+ import { configuredEngineMemoryBytes, staticCapacityCeilings, } from '../providers/native/device-capacity.js';
19
+ import { measuredCapacityBudget } from '../providers/native/measured-budget.js';
20
+ import { detectMemoryProfileCached } from '../system/memory.js';
21
+ import { imageCatalogWeightFiles, sdCppMainComponent } from './weight-facts.js';
22
+ const log = createLogger('media-fit');
23
+ /**
24
+ * The 409 body a pull route answers with when the model cannot run here, or
25
+ * null to let the pull start. A failure to assess never blocks a download:
26
+ * the launch path still applies the same check.
27
+ */
28
+ export async function wontRunRefusal(assess) {
29
+ try {
30
+ const fit = await assess();
31
+ if (fit?.verdict !== 'wont-run')
32
+ return null;
33
+ return {
34
+ error: `This model is too large for this machine. ${fit.reason}`,
35
+ code: MEDIA_MODEL_WONT_RUN_CODE,
36
+ };
37
+ }
38
+ catch (error) {
39
+ log.warn(`[media-fit] could not assess a pull, letting it start: ${error instanceof Error ? error.message : String(error)}`);
40
+ return null;
41
+ }
42
+ }
43
+ /**
44
+ * Where sd-server keeps weights: Metal on Apple Silicon, CPU on Intel Macs,
45
+ * CUDA or Vulkan elsewhere, falling back to the processor when no GPU
46
+ * answers. Windows on Arm runs on the processor unless discovery picked its
47
+ * CUDA build, which only an RTX Spark gets.
48
+ */
49
+ export function imageMediaPool(kind, host = {
50
+ platform: process.platform,
51
+ arch: process.arch,
52
+ sdServerBin: process.env.GEZEL_SD_SERVER_BIN,
53
+ }) {
54
+ if (host.platform === 'darwin')
55
+ return host.arch === 'arm64' ? 'unified' : 'cpu';
56
+ if (host.platform === 'win32' &&
57
+ host.arch === 'arm64' &&
58
+ !(host.sdServerBin && isCudaArchivePath(host.sdServerBin))) {
59
+ return 'cpu';
60
+ }
61
+ return kind === 'discrete-gpu' ? 'discrete' : kind === 'unified' ? 'unified' : 'cpu';
62
+ }
63
+ /** The ceilings the device ledger applies on this machine, automatic and under the person's limit. */
64
+ export async function mediaCapacity(pool, readConfig) {
65
+ const budget = await measuredCapacityBudget();
66
+ const ram = totalmem();
67
+ const auto = staticCapacityCeilings(budget, ram);
68
+ const configured = configuredEngineMemoryBytes(await readConfig().catch(() => ({})));
69
+ const limited = configured === undefined ? undefined : staticCapacityCeilings(budget, ram, configured);
70
+ const profile = pool === 'discrete' ? await detectMemoryProfileCached().catch(() => null) : null;
71
+ return {
72
+ pool,
73
+ ...auto,
74
+ ...(limited && limited.budgetBytes < auto.budgetBytes ? { limit: limited } : {}),
75
+ totalRamBytes: ram,
76
+ ...(profile?.gpuLargestDeviceBytes ? { gpuDeviceBytes: profile.gpuLargestDeviceBytes } : {}),
77
+ };
78
+ }
79
+ /** The memory sd-server needs for one model, from its weight files and its launch profile. */
80
+ export function sdServerRequirement(input) {
81
+ const profile = sdServerLoadProfile(input.modelId);
82
+ return imageMemoryRequirement({
83
+ files: input.files,
84
+ pool: input.pool,
85
+ vaeDecode: profile.vaeDecode,
86
+ // The rule names the diffusion weights, which are the main file.
87
+ bf16Components: profile.bf16TensorPattern ? [sdCppMainComponent(input.weightsKind)] : [],
88
+ megapixels: input.megapixels ?? profile.defaultSidePx ** 2 / 1024 ** 2,
89
+ });
90
+ }
91
+ /**
92
+ * What a launched sd-server claims on the device ledger.
93
+ *
94
+ * On a discrete card the fit figures charge graphics memory for weights only,
95
+ * because sd-server can stream components from RAM, and the download page
96
+ * must not call a model that offloads "won't run". A running render holds its
97
+ * compute buffers on the card too, though: FLUX.2 Klein at 1024px held
98
+ * 9.4 GiB of a 12 GiB card's dedicated memory against 4.9 GiB of weights
99
+ * (RTX 5070 Ti Laptop, Vulkan, M0 spike 2026-10-25). Claiming weights only
100
+ * let a resident chat model keep its memory, and the render then failed at
101
+ * its first step. The claim is the estimated peak, capped at the card so a
102
+ * model that must offload is admitted once the card is otherwise free.
103
+ */
104
+ export function sdServerLedgerClaim(requirement, cardBytes) {
105
+ if (requirement.pool !== 'discrete') {
106
+ return { bytes: requirement.bytes, gpuBytes: requirement.gpuBytes };
107
+ }
108
+ const gpuBytes = Math.min(Math.max(requirement.gpuPeakBytes, requirement.gpuBytes), cardBytes);
109
+ return { bytes: Math.max(requirement.bytes, gpuBytes), gpuBytes };
110
+ }
111
+ /** Megapixels a model renders at a quality tier, inside its range. */
112
+ export function imageRenderMegapixels(manifest, quality) {
113
+ const { width, height } = resolveImageSize({ quality }, {
114
+ defaultSidePx: imageModelDefaultSide(manifest.id),
115
+ widthRange: manifest.widthRange,
116
+ heightRange: manifest.heightRange,
117
+ });
118
+ return (width * height) / 1024 ** 2;
119
+ }
120
+ async function imagePlan(ctx) {
121
+ const budget = await measuredCapacityBudget();
122
+ const pool = imageMediaPool(budget.kind);
123
+ return { pool, capacity: await mediaCapacity(pool, ctx.readConfig) };
124
+ }
125
+ function assessImageManifest(plan, manifest, quality) {
126
+ return assessMediaFit(sdServerRequirement({
127
+ modelId: manifest.id,
128
+ weightsKind: manifest.weightsKind,
129
+ files: imageCatalogWeightFiles(manifest),
130
+ pool: plan.pool,
131
+ ...(quality ? { megapixels: imageRenderMegapixels(manifest, quality) } : {}),
132
+ }), plan.capacity);
133
+ }
134
+ export function mediaFitResponse(plan, models) {
135
+ return {
136
+ pool: plan.pool,
137
+ budgetBytes: plan.capacity.budgetBytes,
138
+ gpuBudgetBytes: plan.capacity.gpuBudgetBytes,
139
+ totalRamBytes: plan.capacity.totalRamBytes,
140
+ models,
141
+ };
142
+ }
143
+ /**
144
+ * Every catalog image model's fit. With `quality`, each is priced at the size
145
+ * it renders at that tier rather than its default, which is how the direct
146
+ * image gezel's settings know whether High will fit.
147
+ */
148
+ export async function imageCatalogFits(ctx, quality) {
149
+ const plan = await imagePlan(ctx);
150
+ const items = await ctx.catalog.list('image-model');
151
+ const models = items
152
+ .map((item) => item.manifest)
153
+ .filter((m) => m.kind === 'image-model')
154
+ .map((m) => ({ id: m.id, ...assessImageManifest(plan, m, quality) }));
155
+ return mediaFitResponse(plan, models);
156
+ }
157
+ /** One catalog image model's fit, or null when the id is not in the catalog. */
158
+ export async function imageModelFit(ctx, id) {
159
+ const item = await ctx.catalog.get('image-model', id);
160
+ if (!item || item.manifest.kind !== 'image-model')
161
+ return null;
162
+ return assessImageManifest(await imagePlan(ctx), item.manifest);
163
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Weight facts for media-model memory pricing: each weight file's size and
3
+ * how many elements it stores at every precision. Core `media-memory.ts`
4
+ * turns them into a requirement.
5
+ *
6
+ * Two sources that must agree. A catalog entry carries the facts as
7
+ * `tensors` beside each file's sha256, written by gilde's `pin-weight-tensors`
8
+ * tool when the model was authored; the launch path reads the same facts
9
+ * from the header of the file on disk. Files are pinned by sha256, so both
10
+ * describe the same bytes. Nothing here touches the network: an entry
11
+ * without the facts is priced by its plausible range instead.
12
+ */
13
+ import { type ImageModelManifest, type MediaWeightFile, type VideoModelManifest, type WeightTensors } from '@bendyline/gezel';
14
+ /** Length of the JSON header from a safetensors file's first 8 bytes, or null when implausible. */
15
+ export declare function safetensorsHeaderLength(prefix: Uint8Array, fileBytes: number): number | null;
16
+ /** The `tensors` facts of a safetensors file on disk; undefined when it cannot be read as one. */
17
+ export declare function readLocalWeightTensors(path: string): Promise<WeightTensors | undefined>;
18
+ export declare function videoCatalogWeightFiles(manifest: VideoModelManifest): MediaWeightFile[];
19
+ /**
20
+ * The weight files of an installed diffusers tree, headers read from disk.
21
+ * diffusers loads only the shards a directory's index names, so an unsharded
22
+ * duplicate beside them is never resident and is left out.
23
+ */
24
+ export declare function localDiffusersWeightFiles(modelDir: string): Promise<MediaWeightFile[]>;
25
+ /** The sd-cpp component the main weights load as: the flag they ride on. */
26
+ export declare function sdCppMainComponent(weightsKind: 'checkpoint' | 'diffusion-model'): string;
27
+ export declare function imageCatalogWeightFiles(manifest: ImageModelManifest): MediaWeightFile[];
28
+ /** The weight files of an installed sd-cpp model, headers read from disk. */
29
+ export declare function localSdCppWeightFiles(model: {
30
+ weightsPath: string;
31
+ weightsKind: 'checkpoint' | 'diffusion-model';
32
+ auxiliaryFiles: ReadonlyArray<{
33
+ role: string;
34
+ path: string;
35
+ }>;
36
+ }): Promise<MediaWeightFile[]>;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Weight facts for media-model memory pricing: each weight file's size and
3
+ * how many elements it stores at every precision. Core `media-memory.ts`
4
+ * turns them into a requirement.
5
+ *
6
+ * Two sources that must agree. A catalog entry carries the facts as
7
+ * `tensors` beside each file's sha256, written by gilde's `pin-weight-tensors`
8
+ * tool when the model was authored; the launch path reads the same facts
9
+ * from the header of the file on disk. Files are pinned by sha256, so both
10
+ * describe the same bytes. Nothing here touches the network: an entry
11
+ * without the facts is priced by its plausible range instead.
12
+ */
13
+ import { open, readFile, readdir, stat } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { tallySafetensorsHeader, weightFileFormat, } from '@bendyline/gezel';
16
+ /** A header larger than this is not a safetensors header. */
17
+ const MAX_HEADER_BYTES = 100 * 1024 * 1024;
18
+ /** Length of the JSON header from a safetensors file's first 8 bytes, or null when implausible. */
19
+ export function safetensorsHeaderLength(prefix, fileBytes) {
20
+ if (prefix.byteLength < 8)
21
+ return null;
22
+ const view = new DataView(prefix.buffer, prefix.byteOffset, 8);
23
+ const length = Number(view.getBigUint64(0, true));
24
+ if (!Number.isSafeInteger(length) || length <= 0 || length > MAX_HEADER_BYTES)
25
+ return null;
26
+ if (length + 8 > fileBytes)
27
+ return null;
28
+ return length;
29
+ }
30
+ /** The `tensors` facts of a safetensors file on disk; undefined when it cannot be read as one. */
31
+ export async function readLocalWeightTensors(path) {
32
+ let handle;
33
+ try {
34
+ handle = await open(path, 'r');
35
+ const size = (await handle.stat()).size;
36
+ const prefix = Buffer.alloc(8);
37
+ const { bytesRead } = await handle.read(prefix, 0, 8, 0);
38
+ const length = safetensorsHeaderLength(prefix.subarray(0, bytesRead), size);
39
+ if (length === null)
40
+ return undefined;
41
+ const header = Buffer.alloc(length);
42
+ await handle.read(header, 0, length, 8);
43
+ return tallySafetensorsHeader(JSON.parse(header.toString('utf8')));
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ finally {
49
+ await handle?.close();
50
+ }
51
+ }
52
+ function catalogFile(component, fileName, sizeBytes, tensors) {
53
+ const format = weightFileFormat(fileName);
54
+ if (!format)
55
+ return null;
56
+ return { component, sizeBytes, format, ...(tensors ? { tensors } : {}) };
57
+ }
58
+ // ─ Video: diffusers trees ───────────────────────────────────────────
59
+ /** The diffusers component a repo-relative file belongs to: its top-level folder. */
60
+ function diffusersComponent(name) {
61
+ const slash = name.indexOf('/');
62
+ return slash < 0 ? '' : name.slice(0, slash);
63
+ }
64
+ export function videoCatalogWeightFiles(manifest) {
65
+ return manifest.source.files.flatMap((file) => {
66
+ const priced = catalogFile(diffusersComponent(file.name), file.name, file.sizeBytes, file.tensors);
67
+ return priced ? [priced] : [];
68
+ });
69
+ }
70
+ /**
71
+ * The weight files of an installed diffusers tree, headers read from disk.
72
+ * diffusers loads only the shards a directory's index names, so an unsharded
73
+ * duplicate beside them is never resident and is left out.
74
+ */
75
+ export async function localDiffusersWeightFiles(modelDir) {
76
+ const out = [];
77
+ const walk = async (dir, component, depth) => {
78
+ if (depth > 6)
79
+ return;
80
+ const entries = await readdir(dir, { withFileTypes: true });
81
+ const indexed = await indexedShards(dir, entries.map((e) => e.name));
82
+ for (const entry of entries) {
83
+ const path = join(dir, entry.name);
84
+ if (entry.isDirectory()) {
85
+ await walk(path, component || entry.name, depth + 1);
86
+ continue;
87
+ }
88
+ if (!entry.isFile())
89
+ continue;
90
+ const format = weightFileFormat(entry.name);
91
+ if (!format)
92
+ continue;
93
+ if (format === 'safetensors' && indexed && !indexed.has(entry.name))
94
+ continue;
95
+ const sizeBytes = (await stat(path)).size;
96
+ const tensors = format === 'safetensors' ? await readLocalWeightTensors(path) : undefined;
97
+ out.push({ component, sizeBytes, format, ...(tensors ? { tensors } : {}) });
98
+ }
99
+ };
100
+ await walk(modelDir, '', 0);
101
+ return out;
102
+ }
103
+ async function indexedShards(dir, names) {
104
+ const index = names.find((n) => n.endsWith('.safetensors.index.json'));
105
+ if (!index)
106
+ return null;
107
+ try {
108
+ const parsed = JSON.parse(await readFile(join(dir, index), 'utf8'));
109
+ const shards = new Set(Object.values(parsed.weight_map ?? {}));
110
+ return shards.size > 0 ? shards : null;
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ // ─ Image: stable-diffusion.cpp files ────────────────────────────────
117
+ /** The sd-cpp component the main weights load as: the flag they ride on. */
118
+ export function sdCppMainComponent(weightsKind) {
119
+ return weightsKind === 'diffusion-model' ? 'diffusion_model' : 'model';
120
+ }
121
+ function urlFileName(url) {
122
+ try {
123
+ return decodeURIComponent(new URL(url).pathname.split('/').pop() ?? '');
124
+ }
125
+ catch {
126
+ return '';
127
+ }
128
+ }
129
+ export function imageCatalogWeightFiles(manifest) {
130
+ const entries = [
131
+ {
132
+ component: sdCppMainComponent(manifest.weightsKind),
133
+ url: manifest.downloadUrl,
134
+ sizeBytes: manifest.approxSizeBytes,
135
+ tensors: manifest.tensors,
136
+ },
137
+ ...manifest.auxiliaryFiles.map((aux) => ({
138
+ component: aux.role,
139
+ url: aux.downloadUrl,
140
+ sizeBytes: aux.approxSizeBytes,
141
+ tensors: aux.tensors,
142
+ })),
143
+ ];
144
+ return entries.flatMap((e) => {
145
+ const priced = catalogFile(e.component, urlFileName(e.url), e.sizeBytes, e.tensors);
146
+ return priced ? [priced] : [];
147
+ });
148
+ }
149
+ /** The weight files of an installed sd-cpp model, headers read from disk. */
150
+ export async function localSdCppWeightFiles(model) {
151
+ const entries = [
152
+ { component: sdCppMainComponent(model.weightsKind), path: model.weightsPath },
153
+ ...model.auxiliaryFiles.map((a) => ({ component: a.role, path: a.path })),
154
+ ];
155
+ const out = [];
156
+ for (const entry of entries) {
157
+ const format = weightFileFormat(entry.path);
158
+ if (!format)
159
+ continue;
160
+ let sizeBytes;
161
+ try {
162
+ sizeBytes = (await stat(entry.path)).size;
163
+ }
164
+ catch {
165
+ continue;
166
+ }
167
+ const tensors = format === 'safetensors' ? await readLocalWeightTensors(entry.path) : undefined;
168
+ out.push({ component: entry.component, sizeBytes, format, ...(tensors ? { tensors } : {}) });
169
+ }
170
+ return out;
171
+ }
@@ -7,7 +7,7 @@ import { pickFreePort } from '../native/port.js';
7
7
  import { NativeEngineSupervisor } from '../native/supervisor.js';
8
8
  import { patientFetch } from '../patient-fetch.js';
9
9
  import { nativeVisionEnabledFor } from '../vision-capability.js';
10
- import { ds4DsparkArgs, resolveDs4Dspark } from './dspark.js';
10
+ import { DS4_V41_DRAFTER_RUNTIME_BYTES, ds4DsparkArgs, ds4DsparkStreamsWithSsd, resolveDs4Dspark, } from './dspark.js';
11
11
  import { ds4MtpArgs, resolveDs4Mtp } from './mtp.js';
12
12
  import { Ds4Provider } from './provider.js';
13
13
  const log = createLogger('chat');
@@ -307,36 +307,50 @@ export async function buildDs4Provider(opts) {
307
307
  if (config.ds4SsdStreaming === true && ds4Source?.ssdStreamingSupported === false) {
308
308
  log.warn(`[ds4] ignored SSD-streaming override for ${effectiveModelId ?? modelPath}; this model keeps its lookup tables on disk but does not implement routed-expert streaming`);
309
309
  }
310
+ const cachePlanWith = (extraResidentBytes) => planDs4ExpertCache({
311
+ configuredGb: config.ds4CacheExpertsGb,
312
+ catalogCacheBytes: ds4Source?.cacheExpertsBytes,
313
+ catalogResidentBytes: (ds4Source?.residentBytes ?? 0) + visionResidentBytes + extraResidentBytes,
314
+ totalRamBytes: totalRamGb * 1024 ** 3,
315
+ });
310
316
  // DSpark speculative decoding. Resolved here, beside the residency decision
311
- // it depends on: ds4 aborts at startup if an external `--mtp-model` is combined with
312
- // `--ssd-streaming`, so this can only ever draft on a fully resident launch.
317
+ // it depends on: ds4 aborts at startup if an external `--mtp-model` is
318
+ // combined with `--ssd-streaming` for any family but DeepSeek V4.1.
319
+ const targetArchitecture = installedModel?.architecture;
320
+ const streamedDrafting = ssdStreaming && ds4DsparkStreamsWithSsd(targetArchitecture);
313
321
  const dsparkSupportPath = config.ds4DsparkModelPath ?? installedModel?.draftModelPath;
314
322
  // Residency is decided on the weights alone, then the companion is priced
315
323
  // against what is left. That precedence is deliberate and not symmetric:
316
324
  // residency is worth ~9.5x and drafting ~1.05x, so a companion that would
317
325
  // push the model out of memory loses its own flag rather than costing the
318
- // launch its residency.
326
+ // launch its residency. A streamed V4.1 launch keeps its drafter resident
327
+ // too, so it must still leave room for a minimum expert cache.
319
328
  let companionFitsMemory = true;
320
329
  let dsparkCompanionBytes = 0;
321
- if (dsparkSupportPath && !ssdStreaming) {
330
+ if (dsparkSupportPath && (!ssdStreaming || streamedDrafting)) {
322
331
  const { canUseDs4FullResidency } = await import('./residency.js');
323
332
  const { stat: statCompanion } = await import('node:fs/promises');
324
333
  const companionBytes = await statCompanion(dsparkSupportPath)
325
334
  .then((st) => st.size)
326
335
  .catch(() => undefined);
327
- dsparkCompanionBytes = companionBytes ?? 0;
328
- companionFitsMemory = canUseDs4FullResidency({
329
- modelSizeBytes: residentWeightBytes,
330
- totalRamBytes,
331
- ...(companionBytes !== undefined || visionResidentBytes > 0
332
- ? { companionBytes: (companionBytes ?? 0) + visionResidentBytes }
333
- : {}),
334
- });
336
+ dsparkCompanionBytes =
337
+ (companionBytes ?? 0) +
338
+ (ds4DsparkStreamsWithSsd(targetArchitecture) ? DS4_V41_DRAFTER_RUNTIME_BYTES : 0);
339
+ companionFitsMemory = ssdStreaming
340
+ ? cachePlanWith(dsparkCompanionBytes).safe
341
+ : canUseDs4FullResidency({
342
+ modelSizeBytes: residentWeightBytes,
343
+ totalRamBytes,
344
+ ...(companionBytes !== undefined || visionResidentBytes > 0
345
+ ? { companionBytes: dsparkCompanionBytes + visionResidentBytes }
346
+ : {}),
347
+ });
335
348
  }
336
349
  const dspark = resolveDs4Dspark({
337
350
  mode: config.ds4Dspark,
338
351
  backend: backendFlag.slice(2),
339
352
  ssdStreaming,
353
+ ...(targetArchitecture ? { targetArchitecture } : {}),
340
354
  companionFitsMemory,
341
355
  ...(dsparkSupportPath ? { supportModelPath: dsparkSupportPath } : {}),
342
356
  });
@@ -365,14 +379,7 @@ export async function buildDs4Provider(opts) {
365
379
  });
366
380
  if (mtp.unmetRequest)
367
381
  log.warn(`[ds4] ${mtp.unmetRequest}`);
368
- const cachePlan = planDs4ExpertCache({
369
- configuredGb: config.ds4CacheExpertsGb,
370
- catalogCacheBytes: ds4Source?.cacheExpertsBytes,
371
- catalogResidentBytes: ds4Source?.residentBytes !== undefined || visionResidentBytes > 0
372
- ? (ds4Source?.residentBytes ?? 0) + visionResidentBytes
373
- : undefined,
374
- totalRamBytes: totalRamGb * 1024 ** 3,
375
- });
382
+ const cachePlan = cachePlanWith(dspark.enabled && ssdStreaming ? dsparkCompanionBytes : 0);
376
383
  if (ssdStreaming && !cachePlan.safe) {
377
384
  const err = new Error(`DwarfStar (ds4) engine: ${effectiveModelId ?? 'the selected model'} cannot keep a safe minimum expert cache while preserving memory for the operating system. Choose a lighter model in Settings → DwarfStar (ds4).`);
378
385
  err.isActionable = true;
@@ -6,9 +6,12 @@
6
6
  * one measurement shape the policy here:
7
7
  *
8
8
  * - **The engine refuses an external MTP model alongside `--ssd-streaming`**,
9
- * aborting at startup. Streaming
10
- * is gezel's safe default and the only way the 153 GiB Q4 GGUF runs at all
11
- * on a 128 GiB machine, so drafting is unreachable for most installs.
9
+ * aborting at startup, for every family except DeepSeek V4.1
10
+ * (`general.architecture` `deepseek41`, upstream ee7e840). Streaming is the
11
+ * only way the 153 GiB Q4 GGUF runs at all on a 128 GiB machine, so
12
+ * drafting is unreachable there. V4.1 keeps its 8.04 GiB drafter resident
13
+ * while the target's experts stream, but upstream warns it can still be
14
+ * slower than plain decoding, so `auto` never drafts a streamed launch.
12
15
  * - **A support model is required.** Without one there is nothing to draft
13
16
  * with, and ds4 exits on `--dspark` alone ("--dspark requires --mtp-model FILE").
14
17
  * - **It does not pay on Metal.** Measured 2026-08-26, M5 Max, DeepSeek V4
@@ -25,6 +28,18 @@
25
28
  */
26
29
  /** Which acceleration backend ds4 was launched with. */
27
30
  export type Ds4Backend = 'metal' | 'cuda';
31
+ /**
32
+ * Whether ds4 accepts `--mtp-model` alongside `--ssd-streaming` for a target
33
+ * with this GGUF `general.architecture`. Mirrors the engine's own check
34
+ * (`DS4_MODEL_FAMILY != DS4_MODEL_FAMILY_DEEPSEEK41` in `ds4_engine_open`);
35
+ * an unknown architecture is treated as unsupported because the engine aborts.
36
+ */
37
+ export declare function ds4DsparkStreamsWithSsd(architecture: string | undefined): boolean;
38
+ /**
39
+ * Buffers ds4 allocates beside a V4.1 drafter's weights; its startup memory
40
+ * plan charges `mtp_model.size + 64 MiB` for them.
41
+ */
42
+ export declare const DS4_V41_DRAFTER_RUNTIME_BYTES: number;
28
43
  export type Ds4DsparkMode = 'off' | 'on' | 'auto';
29
44
  export interface Ds4DsparkOptions {
30
45
  /** `config.ds4Dspark`. Absent → `auto`. */
@@ -33,12 +48,15 @@ export interface Ds4DsparkOptions {
33
48
  backend: Ds4Backend;
34
49
  /** Whether this launch streams experts from SSD. */
35
50
  ssdStreaming: boolean;
51
+ /** The target GGUF's `general.architecture`, when known. */
52
+ targetArchitecture?: string;
36
53
  /** Absolute path to a DSpark support GGUF, if one resolved. */
37
54
  supportModelPath?: string;
38
55
  /**
39
- * Whether the companion still fits once the resident weights are accounted
56
+ * Whether the companion still fits once the resident weights (or, when
57
+ * streaming, the resident set plus a minimum expert cache) are accounted
40
58
  * for. Defaults to true so callers that cannot weigh it keep the old
41
- * behavior. When false the launch keeps its residency and drops drafting —
59
+ * behavior. When false the launch keeps its memory plan and drops drafting —
42
60
  * residency is worth ~9.5x and drafting ~1.05x, so the flag is what yields.
43
61
  */
44
62
  companionFitsMemory?: boolean;