@bendyline/gezel-inference 1.0.6 → 1.0.7

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 (142) hide show
  1. package/dist/cache/controller.d.ts +2 -0
  2. package/dist/cache/controller.js +38 -5
  3. package/dist/catalog/chat-models/index.json +1 -1
  4. package/dist/catalog/chat-models/lf/lfm3-1.7b-q4/manifest.json +114 -0
  5. package/dist/catalog/chat-models/lf/lfm3-1.7b-q4/versions/1.0.0/manifest.json +15 -0
  6. package/dist/catalog/chat-models/raw-index.json +1 -1
  7. package/dist/catalog/image-loras/index.json +1 -0
  8. package/dist/catalog/image-loras/kr/krea-2-ink-wash/manifest.json +31 -0
  9. package/dist/catalog/image-loras/kr/krea-2-ink-wash/versions/1.0.0/manifest.json +15 -0
  10. package/dist/catalog/image-loras/kr/krea-2-isometric-3d/manifest.json +32 -0
  11. package/dist/catalog/image-loras/kr/krea-2-isometric-3d/versions/1.0.0/manifest.json +15 -0
  12. package/dist/catalog/image-loras/kr/krea-2-kids-drawing/manifest.json +31 -0
  13. package/dist/catalog/image-loras/kr/krea-2-kids-drawing/versions/1.0.0/manifest.json +15 -0
  14. package/dist/catalog/image-loras/kr/krea-2-pixel-art/manifest.json +31 -0
  15. package/dist/catalog/image-loras/kr/krea-2-pixel-art/versions/1.0.0/manifest.json +15 -0
  16. package/dist/catalog/image-loras/kr/krea-2-soft-watercolor/manifest.json +31 -0
  17. package/dist/catalog/image-loras/kr/krea-2-soft-watercolor/versions/1.0.0/manifest.json +15 -0
  18. package/dist/catalog/image-loras/kr/krea-2-turbo-4step/manifest.json +31 -0
  19. package/dist/catalog/image-loras/kr/krea-2-turbo-4step/versions/1.0.0/manifest.json +15 -0
  20. package/dist/catalog/image-loras/mo/moss-image-gouache/manifest.json +29 -0
  21. package/dist/catalog/image-loras/mo/moss-image-gouache/versions/1.0.0/manifest.json +15 -0
  22. package/dist/catalog/image-loras/mo/moss-image-sft-4step/manifest.json +30 -0
  23. package/dist/catalog/image-loras/mo/moss-image-sft-4step/versions/1.0.0/manifest.json +15 -0
  24. package/dist/catalog/image-loras/qw/qwen-image-edit-2511-lightning/manifest.json +31 -0
  25. package/dist/catalog/image-loras/qw/qwen-image-edit-2511-lightning/versions/1.0.0/manifest.json +15 -0
  26. package/dist/catalog/image-loras/raw-index.json +1 -0
  27. package/dist/catalog/image-loras/sd/sdxl-3d-render/manifest.json +29 -0
  28. package/dist/catalog/image-loras/sd/sdxl-3d-render/versions/1.0.0/manifest.json +15 -0
  29. package/dist/catalog/image-loras/sd/sdxl-coloring-book/manifest.json +30 -0
  30. package/dist/catalog/image-loras/sd/sdxl-coloring-book/versions/1.0.0/manifest.json +15 -0
  31. package/dist/catalog/image-loras/sd/sdxl-papercut/manifest.json +29 -0
  32. package/dist/catalog/image-loras/sd/sdxl-papercut/versions/1.0.0/manifest.json +15 -0
  33. package/dist/catalog/image-loras/sd/sdxl-pixel-art/manifest.json +29 -0
  34. package/dist/catalog/image-loras/sd/sdxl-pixel-art/versions/1.0.0/manifest.json +15 -0
  35. package/dist/catalog/image-models/fl/flux-2-klein-4b-q4/manifest.json +3 -0
  36. package/dist/catalog/image-models/index.json +1 -1
  37. package/dist/catalog/image-models/qw/qwen-image-edit-2511-q4/manifest.json +44 -0
  38. package/dist/catalog/image-models/qw/qwen-image-edit-2511-q4/versions/1.0.0/manifest.json +36 -0
  39. package/dist/catalog/image-models/raw-index.json +1 -1
  40. package/dist/catalog/knowledge-catalogs/index.json +1 -1
  41. package/dist/catalog/knowledge-catalogs/raw-index.json +1 -1
  42. package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/versions/2026.10.2/manifest.json +181 -0
  43. package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/versions/2026.10.2/manifest.json +181 -0
  44. package/dist/catalog/knowledge-catalogs/wi/wikipedia-biology/versions/2026.10.2/manifest.json +217 -0
  45. package/dist/catalog/knowledge-catalogs/wi/wikipedia-business/versions/2026.10.2/manifest.json +197 -0
  46. package/dist/catalog/knowledge-catalogs/wi/wikipedia-film-tv/versions/2026.10.2/manifest.json +197 -0
  47. package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/versions/2026.10.2/manifest.json +185 -0
  48. package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/versions/2026.10.2/manifest.json +181 -0
  49. package/dist/catalog/knowledge-catalogs/wi/wikipedia-general/manifest.json +55 -0
  50. package/dist/catalog/knowledge-catalogs/wi/wikipedia-general/versions/2026.10.2/manifest.json +165 -0
  51. package/dist/catalog/knowledge-catalogs/wi/wikipedia-geography/versions/2026.10.2/manifest.json +217 -0
  52. package/dist/catalog/knowledge-catalogs/wi/wikipedia-history/versions/2026.10.2/manifest.json +217 -0
  53. package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/versions/2026.10.2/manifest.json +181 -0
  54. package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/versions/2026.10.2/manifest.json +185 -0
  55. package/dist/catalog/knowledge-catalogs/wi/wikipedia-literature/versions/2026.10.2/manifest.json +193 -0
  56. package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/versions/2026.10.2/manifest.json +189 -0
  57. package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/versions/2026.10.2/manifest.json +185 -0
  58. package/dist/catalog/knowledge-catalogs/wi/wikipedia-military/versions/2026.10.2/manifest.json +189 -0
  59. package/dist/catalog/knowledge-catalogs/wi/wikipedia-music/versions/2026.10.2/manifest.json +197 -0
  60. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-arts/versions/2026.10.2/manifest.json +209 -0
  61. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-business/versions/2026.10.2/manifest.json +189 -0
  62. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-entertainment/versions/2026.10.2/manifest.json +201 -0
  63. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-military/versions/2026.10.2/manifest.json +185 -0
  64. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-other/versions/2026.10.2/manifest.json +165 -0
  65. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-politics/versions/2026.10.2/manifest.json +197 -0
  66. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-religion/versions/2026.10.2/manifest.json +201 -0
  67. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-science/versions/2026.10.2/manifest.json +205 -0
  68. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-sports/versions/2026.10.2/manifest.json +237 -0
  69. package/dist/catalog/knowledge-catalogs/wi/wikipedia-politics/versions/2026.10.2/manifest.json +197 -0
  70. package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/versions/2026.10.2/manifest.json +189 -0
  71. package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/versions/2026.10.2/manifest.json +185 -0
  72. package/dist/catalog/knowledge-catalogs/wi/wikipedia-sports/versions/2026.10.2/manifest.json +193 -0
  73. package/dist/catalog/knowledge-catalogs/wi/wikipedia-technology/versions/2026.10.2/manifest.json +185 -0
  74. package/dist/catalog/knowledge-catalogs/wi/wikipedia-transportation/versions/2026.10.2/manifest.json +189 -0
  75. package/dist/catalog/provenance.json +2 -1
  76. package/dist/engine-components.js +2 -2
  77. package/dist/engines/electron-native-reuse.d.ts +7 -1
  78. package/dist/engines/electron-native-reuse.js +12 -4
  79. package/dist/engines/native-file-manifest.json +546 -406
  80. package/dist/engines/native-manifest.d.ts +4 -4
  81. package/dist/engines/native-manifest.js +36 -36
  82. package/dist/engines/resolver.js +1 -0
  83. package/dist/http/auth-context.d.ts +12 -0
  84. package/dist/http/routes/image-ensure-routes.d.ts +4 -0
  85. package/dist/http/routes/image-ensure-routes.js +102 -0
  86. package/dist/http/routes/v1-image-styles.d.ts +5 -0
  87. package/dist/http/routes/v1-image-styles.js +91 -0
  88. package/dist/http/routes/v1-images.d.ts +1 -0
  89. package/dist/http/routes/v1-images.js +15 -93
  90. package/dist/inference-service.js +2 -1
  91. package/dist/knowledge/worker-host.js +2 -0
  92. package/dist/machine-engine/inspect-runtime.js +12 -3
  93. package/dist/memory/embed-core.js +3 -0
  94. package/dist/memory/embeddings.js +2 -0
  95. package/dist/models/image-fit.js +2 -2
  96. package/dist/models/weight-facts.d.ts +5 -0
  97. package/dist/models/weight-facts.js +5 -2
  98. package/dist/providers/ds4/build-provider.js +1 -1
  99. package/dist/providers/ds4/residency.js +3 -3
  100. package/dist/providers/image/img2img-support.d.ts +36 -1
  101. package/dist/providers/image/img2img-support.js +30 -1
  102. package/dist/providers/image/local-engine.d.ts +1 -0
  103. package/dist/providers/image/local-engine.js +8 -2
  104. package/dist/providers/image/local-factory.d.ts +10 -1
  105. package/dist/providers/image/local-factory.js +33 -6
  106. package/dist/providers/image/mock.d.ts +8 -1
  107. package/dist/providers/image/mock.js +46 -1
  108. package/dist/providers/image/pull-registry.d.ts +25 -3
  109. package/dist/providers/image/pull-registry.js +96 -34
  110. package/dist/providers/image/sd-cpp.d.ts +63 -4
  111. package/dist/providers/image/sd-cpp.js +361 -38
  112. package/dist/providers/image/types.d.ts +76 -4
  113. package/dist/providers/image/types.js +8 -0
  114. package/dist/providers/llama-cpp/build-provider.js +3 -2
  115. package/dist/providers/llama-cpp/cache-adapter.d.ts +3 -0
  116. package/dist/providers/llama-cpp/cache-adapter.js +18 -4
  117. package/dist/providers/llama-cpp/devices.js +2 -2
  118. package/dist/providers/llama-cpp/engine-flags.d.ts +5 -0
  119. package/dist/providers/llama-cpp/engine-flags.js +5 -2
  120. package/dist/providers/mlx/provider.js +13 -4
  121. package/dist/providers/mlx/python/gezel_mlx_server.py +9 -2
  122. package/dist/providers/mlx/python/spec_decode.py +44 -5
  123. package/dist/providers/native/capacity-broker.d.ts +1 -1
  124. package/dist/providers/native/capacity-broker.js +8 -8
  125. package/dist/providers/native/capacity-budget.js +5 -4
  126. package/dist/providers/native/device-capacity-ledger.d.ts +2 -0
  127. package/dist/providers/native/device-capacity-ledger.js +36 -5
  128. package/dist/providers/native/device-capacity.js +4 -3
  129. package/dist/providers/native/local-engine-runtime.js +4 -4
  130. package/dist/providers/native/process-identity.d.ts +2 -0
  131. package/dist/providers/native/process-identity.js +70 -0
  132. package/dist/relevance/relevance-core.js +2 -0
  133. package/dist/relevance/relevance-model.js +2 -0
  134. package/dist/relevance/settings.js +2 -2
  135. package/dist/runtime-lock.d.ts +43 -5
  136. package/dist/runtime-lock.js +189 -26
  137. package/dist/system/host-memory.d.ts +46 -0
  138. package/dist/system/host-memory.js +89 -0
  139. package/dist/system/memory.js +5 -4
  140. package/dist/transformers-cache.js +2 -0
  141. package/dist/utils/download-common.d.ts +1 -1
  142. package/package.json +8 -8
@@ -148,10 +148,13 @@ export function imageCatalogWeightFiles(manifest) {
148
148
  }
149
149
  /** The weight files of an installed sd-cpp model, headers read from disk. */
150
150
  export async function localSdCppWeightFiles(model) {
151
- const entries = [
151
+ return localWeightFiles([
152
152
  { component: sdCppMainComponent(model.weightsKind), path: model.weightsPath },
153
153
  ...model.auxiliaryFiles.map((a) => ({ component: a.role, path: a.path })),
154
- ];
154
+ ]);
155
+ }
156
+ /** Weight files on disk, priced from their headers where they are safetensors. */
157
+ export async function localWeightFiles(entries) {
155
158
  const out = [];
156
159
  for (const entry of entries) {
157
160
  const format = weightFileFormat(entry.path);
@@ -123,7 +123,7 @@ export async function buildDs4Provider(opts) {
123
123
  // This tier assumes DeepSeek-V4's small resident footprint. A model whose
124
124
  // non-routed weights are much larger caps it further via the catalog's
125
125
  // `ds4.maxLaunchCtx`; see `resolveDs4LaunchCtx` below.
126
- const totalRamBytes = (await import('node:os')).totalmem();
126
+ const totalRamBytes = (await import('../../system/host-memory.js')).totalMemoryBytes();
127
127
  const totalRamGb = totalRamBytes / 1024 ** 3;
128
128
  const ramTieredCtx = ds4RamTieredContext(totalRamBytes);
129
129
  const ds4ConstrainedToolNoSignalMs = (() => {
@@ -1,5 +1,5 @@
1
- import { totalmem } from 'node:os';
2
1
  import { DS4_FULL_RESIDENCY_HEADROOM_BYTES, LLAMA_CPP_VISION_COMPUTE_BYTES, LLAMA_CPP_WEIGHTS_MULTIPLIER, ds4FitsFullResidency, } from '@bendyline/gezel';
2
+ import { totalMemoryBytes } from '../../system/host-memory.js';
3
3
  const GB = 1024 ** 3;
4
4
  export { DS4_FULL_RESIDENCY_HEADROOM_BYTES };
5
5
  /**
@@ -43,7 +43,7 @@ export function ds4BaseResidentBytes(opts) {
43
43
  export function canUseDs4FullResidency(opts = {}) {
44
44
  return ds4FitsFullResidency({
45
45
  modelSizeBytes: opts.modelSizeBytes,
46
- totalRamBytes: opts.totalRamBytes ?? totalmem(),
46
+ totalRamBytes: opts.totalRamBytes ?? totalMemoryBytes(),
47
47
  companionBytes: opts.companionBytes,
48
48
  platform: opts.platform ?? process.platform,
49
49
  arch: opts.arch ?? process.arch,
@@ -82,7 +82,7 @@ export function shouldUseDs4SsdStreaming(opts = {}) {
82
82
  * state + the cache cannot consume the runtime/OS reserve.
83
83
  */
84
84
  export function planDs4ExpertCache(opts) {
85
- const totalRamBytes = opts.totalRamBytes ?? totalmem();
85
+ const totalRamBytes = opts.totalRamBytes ?? totalMemoryBytes();
86
86
  const catalogCacheBytes = opts.catalogCacheBytes ?? 0;
87
87
  const fixedResidentBytes = Math.max(0, (opts.catalogResidentBytes ?? 0) - catalogCacheBytes);
88
88
  const fallbackGb = totalRamBytes >= 120 * GB ? 64 : totalRamBytes >= 88 * GB ? 48 : 32;
@@ -1,5 +1,7 @@
1
1
  /**
2
- * Per-model img2img capability resolution for the local sd-cpp engine.
2
+ * Per-model image-editing capability for the local sd-cpp engine: whether a
3
+ * model re-noises a source (img2img), reads sources as references beside an
4
+ * instruction, or cannot use one. The img2img half first.
3
5
  *
4
6
  * stable-diffusion.cpp's `/sdapi/v1/img2img` endpoint accepts
5
7
  * `init_images` for any loaded model, but whether the *architecture*
@@ -37,4 +39,37 @@ export interface Img2ImgSupportVerdict {
37
39
  /** Human-readable reason, set only when unsupported. */
38
40
  reason?: string;
39
41
  }
42
+ /**
43
+ * Models that edit by reading source pictures as references beside an
44
+ * instruction (sd-server's `extra_images`), rather than re-noising one. The
45
+ * same ladder as above: a catalog `referenceImages` declaration persisted at
46
+ * pull time wins, then this map, which is how installs that predate the
47
+ * field learn it.
48
+ *
49
+ * FLUX.2 Klein: verified on the pinned engine (native sd-cpp master-975) on
50
+ * an RTX Spark, 2026-10-27, recoloring, adding an object and composing two
51
+ * sources at 4 steps in 12 s.
52
+ */
53
+ export declare const MODEL_REFERENCE_EDIT_SUPPORT: Map<string, {
54
+ maxImages: number;
55
+ editOnly?: boolean;
56
+ }>;
57
+ export interface ImageEditCapabilityInput extends Img2ImgSupportInput {
58
+ /** Explicit `referenceImages` from catalog/installed metadata. */
59
+ referenceImages?: {
60
+ max: number;
61
+ } | undefined;
62
+ /** Explicit `editOnly` from catalog/installed metadata. */
63
+ editOnly?: boolean | undefined;
64
+ }
65
+ export interface ImageEditCapability {
66
+ /** Absent when the model cannot use a source picture at all. */
67
+ editMode?: 'reference' | 'img2img';
68
+ /** Sources one edit takes: the reference limit, or 1 for img2img. */
69
+ maxSourceImages?: number;
70
+ editOnly: boolean;
71
+ /** Why the model cannot edit; set only when `editMode` is absent. */
72
+ reason?: string;
73
+ }
74
+ export declare function resolveImageEditCapability(input: ImageEditCapabilityInput): ImageEditCapability;
40
75
  export declare function resolveImg2ImgSupport(input: Img2ImgSupportInput): Img2ImgSupportVerdict;
@@ -1,5 +1,7 @@
1
1
  /**
2
- * Per-model img2img capability resolution for the local sd-cpp engine.
2
+ * Per-model image-editing capability for the local sd-cpp engine: whether a
3
+ * model re-noises a source (img2img), reads sources as references beside an
4
+ * instruction, or cannot use one. The img2img half first.
3
5
  *
4
6
  * stable-diffusion.cpp's `/sdapi/v1/img2img` endpoint accepts
5
7
  * `init_images` for any loaded model, but whether the *architecture*
@@ -44,6 +46,33 @@ export const MODEL_IMG2IMG_SUPPORT = new Map([
44
46
  ['krea-2-turbo-q6', false],
45
47
  ['krea-2-turbo-q8', false],
46
48
  ]);
49
+ /**
50
+ * Models that edit by reading source pictures as references beside an
51
+ * instruction (sd-server's `extra_images`), rather than re-noising one. The
52
+ * same ladder as above: a catalog `referenceImages` declaration persisted at
53
+ * pull time wins, then this map, which is how installs that predate the
54
+ * field learn it.
55
+ *
56
+ * FLUX.2 Klein: verified on the pinned engine (native sd-cpp master-975) on
57
+ * an RTX Spark, 2026-10-27, recoloring, adding an object and composing two
58
+ * sources at 4 steps in 12 s.
59
+ */
60
+ export const MODEL_REFERENCE_EDIT_SUPPORT = new Map([
61
+ ['flux-2-klein-4b-q4', { maxImages: 3 }],
62
+ ['qwen-image-edit-2511-q4', { maxImages: 3, editOnly: true }],
63
+ ]);
64
+ export function resolveImageEditCapability(input) {
65
+ const assessed = input.modelId ? MODEL_REFERENCE_EDIT_SUPPORT.get(input.modelId) : undefined;
66
+ const editOnly = input.editOnly ?? assessed?.editOnly ?? false;
67
+ const maxImages = input.referenceImages?.max ?? assessed?.maxImages;
68
+ if (maxImages !== undefined) {
69
+ return { editMode: 'reference', maxSourceImages: maxImages, editOnly };
70
+ }
71
+ const img2img = resolveImg2ImgSupport(input);
72
+ if (img2img.supported)
73
+ return { editMode: 'img2img', maxSourceImages: 1, editOnly };
74
+ return { editOnly, ...(img2img.reason ? { reason: img2img.reason } : {}) };
75
+ }
47
76
  export function resolveImg2ImgSupport(input) {
48
77
  if (input.explicit !== undefined) {
49
78
  return input.explicit
@@ -21,6 +21,7 @@ export interface LocalImageEngineOptions {
21
21
  export declare class LocalImageEngine {
22
22
  private readonly opts;
23
23
  readonly pulls: ImageModelPullRegistry;
24
+ readonly loraPulls: ImageModelPullRegistry;
24
25
  private readonly lifecycle;
25
26
  constructor(opts: LocalImageEngineOptions);
26
27
  /** Whether this host can render at all: an engine binary, a server URL, or the mock. */
@@ -1,6 +1,6 @@
1
1
  import { ProviderLifecycle } from '../provider-lifecycle.js';
2
2
  import { createLocalImageProvider } from './local-factory.js';
3
- import { ImageModelPullRegistry } from './pull-registry.js';
3
+ import { ImageModelPullRegistry, imageLoraPullResolver } from './pull-registry.js';
4
4
  /**
5
5
  * Hosted inference's image engine: the local sd-server provider, built on
6
6
  * first use, plus the pulls that install its models. Reset drains in-flight
@@ -9,12 +9,17 @@ import { ImageModelPullRegistry } from './pull-registry.js';
9
9
  export class LocalImageEngine {
10
10
  opts;
11
11
  pulls;
12
+ loraPulls;
12
13
  // Only promise-returning operations can be tracked; a pull is an async
13
14
  // iterable and is cancelled through its own registry instead.
14
- lifecycle = new ProviderLifecycle(new Set(['generate', 'deleteModel']));
15
+ lifecycle = new ProviderLifecycle(new Set(['generate', 'deleteModel', 'deleteLora']));
15
16
  constructor(opts) {
16
17
  this.opts = opts;
17
18
  this.pulls = new ImageModelPullRegistry({ imageProvider: this, catalog: opts.catalog });
19
+ this.loraPulls = new ImageModelPullRegistry({
20
+ resolve: imageLoraPullResolver(this, opts.catalog),
21
+ kind: 'image-lora',
22
+ });
18
23
  }
19
24
  /** Whether this host can render at all: an engine binary, a server URL, or the mock. */
20
25
  available() {
@@ -42,6 +47,7 @@ export class LocalImageEngine {
42
47
  }
43
48
  async shutdown() {
44
49
  this.pulls.clear();
50
+ this.loraPulls.clear();
45
51
  await this.lifecycle.reset();
46
52
  }
47
53
  }
@@ -15,6 +15,7 @@
15
15
  * base URL (127.0.0.1:9081) and no supervisor. `health()` reports
16
16
  * `not-configured` so the UI can say the engine is not installed.
17
17
  */
18
+ import type { MediaWeightFile } from '@bendyline/gezel';
18
19
  import type { GpuArbiter } from '../gpu-arbiter.js';
19
20
  import type { NativeCapacityOptions } from '../native/device-capacity.js';
20
21
  import type { ImageProvider, InstalledImageModelInfo } from './types.js';
@@ -92,4 +93,12 @@ export interface ResolvedInstalledModel extends InstalledImageModelInfo {
92
93
  * encoder runs once per image, so GPUs lose nothing. `--diffusion-fa` means
93
94
  * `--flash-attn off,diffusion=on`, so that branch already leaves it off.
94
95
  */
95
- export declare function buildSdServerArgs(model: ResolvedInstalledModel, port: number): string[];
96
+ export declare function buildSdServerArgs(model: ResolvedInstalledModel, port: number, opts?: {
97
+ loraDir?: string;
98
+ }): string[];
99
+ /**
100
+ * The LoRA files a render of `modelId` can hold at once (its fast mode and
101
+ * its largest style), priced like weights. Read when the engine launches, so
102
+ * a LoRA installed later counts from the next launch.
103
+ */
104
+ export declare function launchedLoraWeightFiles(loraRoot: string, modelId: string): Promise<MediaWeightFile[]>;
@@ -15,18 +15,18 @@
15
15
  * base URL (127.0.0.1:9081) and no supervisor. `health()` reports
16
16
  * `not-configured` so the UI can say the engine is not installed.
17
17
  */
18
- import { readFile } from 'node:fs/promises';
18
+ import { mkdir, readFile } from 'node:fs/promises';
19
19
  import { join } from 'node:path';
20
20
  import { imageMediaPool, sdServerLedgerClaim, sdServerRequirement, } from '../../models/image-fit.js';
21
21
  import { findModelRoot, listOverlayModelIds, modelStorageRoots, verifyReadOnlyModelPayload, } from '../../models/storage-roots.js';
22
- import { localSdCppWeightFiles } from '../../models/weight-facts.js';
22
+ import { localSdCppWeightFiles, localWeightFiles } from '../../models/weight-facts.js';
23
23
  import { pressureIdleGraceMs } from '../native/capacity-broker.js';
24
24
  import { measuredCapacityBudget } from '../native/measured-budget.js';
25
25
  import { pickFreePort } from '../native/port.js';
26
26
  import { NativeEngineSupervisor } from '../native/supervisor.js';
27
27
  import { patientFetch } from '../patient-fetch.js';
28
28
  import { MockImageProvider } from './mock.js';
29
- import { MODEL_SAMPLE_STEP_DEFAULTS, StableDiffusionCppProvider, sdServerLoadProfile, } from './sd-cpp.js';
29
+ import { MODEL_SAMPLE_STEP_DEFAULTS, StableDiffusionCppProvider, readInstalledLoras, sdServerLoadProfile, } from './sd-cpp.js';
30
30
  /**
31
31
  * Nothing to generate with: the person has to download a model first. Its
32
32
  * own type so a route can answer with the fix, not a bare 500, which reads
@@ -80,6 +80,8 @@ export async function createLocalImageProvider(opts) {
80
80
  // when it changes; resolveLaunch reads it on the next start.
81
81
  const launchState = { modelId: undefined };
82
82
  let launchedModel;
83
+ const loraRoots = modelStorageRoots({ home: opts.home, engine: 'sd-cpp-loras', env });
84
+ const loraDir = loraRoots.writableRoot;
83
85
  const supervisor = new NativeEngineSupervisor({
84
86
  capacity: {
85
87
  ...sdServerCapacity(opts.home),
@@ -93,7 +95,10 @@ export async function createLocalImageProvider(opts) {
93
95
  const requirement = sdServerRequirement({
94
96
  modelId: launchedModel.id,
95
97
  weightsKind: launchedModel.weightsKind,
96
- files: await localSdCppWeightFiles(launchedModel),
98
+ files: [
99
+ ...(await localSdCppWeightFiles(launchedModel)),
100
+ ...(await launchedLoraWeightFiles(loraDir, launchedModel.id)),
101
+ ],
97
102
  pool: imageMediaPool(budget.kind),
98
103
  });
99
104
  return sdServerLedgerClaim(requirement, budget.fastBytes);
@@ -131,9 +136,11 @@ export async function createLocalImageProvider(opts) {
131
136
  if (!model)
132
137
  throw new NoLocalImageModelError();
133
138
  launchedModel = model;
139
+ // sd-server ignores a LoRA folder that does not exist yet, then lists none.
140
+ await mkdir(loraDir, { recursive: true });
134
141
  return {
135
142
  command: binary,
136
- args: buildSdServerArgs(model, port),
143
+ args: buildSdServerArgs(model, port, { loraDir }),
137
144
  baseUrl: `http://127.0.0.1:${port}`,
138
145
  };
139
146
  },
@@ -146,6 +153,7 @@ export async function createLocalImageProvider(opts) {
146
153
  storageRoots,
147
154
  supervisor,
148
155
  launchState,
156
+ loraRoots,
149
157
  fetchImpl: patientFetch(),
150
158
  ...(defaultLocalModelId ? { defaultModelId: defaultLocalModelId } : {}),
151
159
  ...(opts.arbiter ? { arbiter: opts.arbiter } : {}),
@@ -239,7 +247,7 @@ async function findFirstInstalledModel(storageRoots) {
239
247
  * encoder runs once per image, so GPUs lose nothing. `--diffusion-fa` means
240
248
  * `--flash-attn off,diffusion=on`, so that branch already leaves it off.
241
249
  */
242
- export function buildSdServerArgs(model, port) {
250
+ export function buildSdServerArgs(model, port, opts = {}) {
243
251
  const args = [];
244
252
  if (model.weightsKind === 'diffusion-model') {
245
253
  args.push('--diffusion-model', model.weightsPath);
@@ -265,6 +273,25 @@ export function buildSdServerArgs(model, port) {
265
273
  args.push('--vae-tiling');
266
274
  if (profile.stepCache)
267
275
  args.push('--cache-mode', profile.stepCache);
276
+ if (profile.modelArgs)
277
+ args.push('--model-args', profile.modelArgs);
278
+ // At runtime for every model: a LoRA then changes per request without
279
+ // touching the weights. In auto mode an unquantized checkpoint (SD, SDXL)
280
+ // would merge it into the weights instead.
281
+ if (opts.loraDir)
282
+ args.push('--lora-model-dir', opts.loraDir, '--lora-apply-mode', 'at_runtime');
268
283
  args.push('--listen-ip', '127.0.0.1', '--listen-port', String(port));
269
284
  return args;
270
285
  }
286
+ /**
287
+ * The LoRA files a render of `modelId` can hold at once (its fast mode and
288
+ * its largest style), priced like weights. Read when the engine launches, so
289
+ * a LoRA installed later counts from the next launch.
290
+ */
291
+ export async function launchedLoraWeightFiles(loraRoot, modelId) {
292
+ const fitting = (await readInstalledLoras(loraRoot)).filter((l) => l.baseModels.includes(modelId));
293
+ const largest = (purpose) => fitting
294
+ .filter((l) => l.purpose === purpose)
295
+ .sort((a, b) => b.approxSizeBytes - a.approxSizeBytes)[0];
296
+ return localWeightFiles([largest('speed'), largest('style')].flatMap((l) => l ? [{ component: 'lora', path: join(loraRoot, l.id, l.filename) }] : []));
297
+ }
@@ -4,19 +4,26 @@
4
4
  * colour is derived from a hash of the prompt so different prompts yield
5
5
  * visually different (but still trivially small) outputs.
6
6
  */
7
- import type { ImageEngineHealth, ImageGenerationInput, ImageGenerationOutput, ImageModelPullEvent, ImageModelPullSpec, ImageProvider, InstalledImageModelInfo } from './types.js';
7
+ import type { ImageEngineHealth, ImageGenerationInput, ImageGenerationOutput, ImageLoraPullSpec, ImageModelPullEvent, ImageModelPullSpec, ImageProvider, InstalledImageLoraInfo, InstalledImageModelInfo } from './types.js';
8
8
  export declare class MockImageProvider implements ImageProvider {
9
9
  readonly name = "mock";
10
10
  private readonly installed;
11
+ private readonly loras;
11
12
  /** Internal counter driving deterministic seed generation on "random" seeds. */
12
13
  private nextSeed;
13
14
  constructor(opts?: {
14
15
  preinstalled?: InstalledImageModelInfo[];
16
+ loras?: InstalledImageLoraInfo[];
15
17
  });
16
18
  /** Number of input images received on the most recent generate(). Tests use this. */
17
19
  lastInputImageCount: number;
20
+ /** The most recent generate() input. Tests use this. */
21
+ lastInput: ImageGenerationInput | undefined;
18
22
  generate(input: ImageGenerationInput): Promise<ImageGenerationOutput>;
19
23
  listInstalledModels(): Promise<InstalledImageModelInfo[]>;
24
+ listLoras(): Promise<InstalledImageLoraInfo[]>;
25
+ pullLora(id: string, spec: ImageLoraPullSpec): AsyncIterable<ImageModelPullEvent>;
26
+ deleteLora(id: string): Promise<void>;
20
27
  pullModel(id: string, spec: ImageModelPullSpec, signal?: AbortSignal): AsyncIterable<ImageModelPullEvent>;
21
28
  deleteModel(id: string): Promise<void>;
22
29
  health(): Promise<ImageEngineHealth>;
@@ -8,22 +8,33 @@ import { deflateSync } from 'node:zlib';
8
8
  export class MockImageProvider {
9
9
  name = 'mock';
10
10
  installed = new Map();
11
+ loras = new Map();
11
12
  /** Internal counter driving deterministic seed generation on "random" seeds. */
12
13
  nextSeed = 1;
13
14
  constructor(opts = {}) {
14
15
  for (const m of opts.preinstalled ?? []) {
15
16
  this.installed.set(m.id, m);
16
17
  }
18
+ for (const l of opts.loras ?? [])
19
+ this.loras.set(l.id, l);
17
20
  }
18
21
  /** Number of input images received on the most recent generate(). Tests use this. */
19
22
  lastInputImageCount = 0;
23
+ /** The most recent generate() input. Tests use this. */
24
+ lastInput;
20
25
  async generate(input) {
21
26
  this.lastInputImageCount = input.inputImages?.length ?? 0;
27
+ this.lastInput = input;
22
28
  const width = clamp(input.width ?? 16, 1, 512);
23
29
  const height = clamp(input.height ?? 16, 1, 512);
24
30
  const steps = clamp(input.steps ?? 1, 1, 100);
25
31
  const seed = input.seed ?? this.nextSeed++;
26
32
  const model = input.model ?? [...this.installed.keys()][0] ?? 'mock-model';
33
+ // Applied as the real engine applies them: installed, and fitting the model.
34
+ const loras = (input.loras ?? []).flatMap(({ id }) => {
35
+ const lora = this.loras.get(id);
36
+ return lora?.baseModels.includes(model) ? [{ id: lora.id, name: lora.name }] : [];
37
+ });
27
38
  const rgba = solidFill(width, height, colourForPrompt(input.prompt));
28
39
  const png = encodePng(rgba, width, height);
29
40
  return {
@@ -35,11 +46,45 @@ export class MockImageProvider {
35
46
  widthPx: width,
36
47
  heightPx: height,
37
48
  durationMs: 1,
49
+ ...(loras.length > 0 ? { loras } : {}),
38
50
  },
39
51
  };
40
52
  }
41
53
  async listInstalledModels() {
42
- return [...this.installed.values()];
54
+ return [...this.installed.values()].map((m) => {
55
+ const loras = [...this.loras.values()]
56
+ .filter((l) => l.baseModels.includes(m.id))
57
+ .map(({ id, name, purpose, steps }) => ({
58
+ id,
59
+ name,
60
+ purpose,
61
+ ...(steps !== undefined ? { steps } : {}),
62
+ }));
63
+ return loras.length > 0 ? { ...m, loras } : m;
64
+ });
65
+ }
66
+ async listLoras() {
67
+ return [...this.loras.values()];
68
+ }
69
+ async *pullLora(id, spec) {
70
+ yield {
71
+ type: 'progress',
72
+ bytesWritten: spec.approxSizeBytes,
73
+ totalBytes: spec.approxSizeBytes,
74
+ };
75
+ this.loras.set(id, {
76
+ id,
77
+ name: spec.name,
78
+ purpose: spec.purpose,
79
+ ...(spec.render?.steps !== undefined ? { steps: spec.render.steps } : {}),
80
+ baseModels: spec.baseModels,
81
+ approxSizeBytes: spec.approxSizeBytes,
82
+ installedAt: new Date().toISOString(),
83
+ });
84
+ yield { type: 'done', id };
85
+ }
86
+ async deleteLora(id) {
87
+ this.loras.delete(id);
43
88
  }
44
89
  async *pullModel(id, spec, signal) {
45
90
  const total = spec.approxSizeBytes;
@@ -34,6 +34,13 @@ type Listener = (event: ImageModelPullEvent) => void;
34
34
  export interface ImageProviderSource {
35
35
  current(): Promise<Pick<ImageProvider, 'pullModel'>>;
36
36
  }
37
+ /** One download the registry runs: its expected size and the event stream. */
38
+ export interface ImagePullJob {
39
+ totalBytes: number;
40
+ run(signal: AbortSignal): AsyncIterable<ImageModelPullEvent>;
41
+ }
42
+ /** Turns a catalog id into a pull, or `null` for an id the catalog lacks. */
43
+ export type ImagePullResolver = (id: string) => Promise<ImagePullJob | null>;
37
44
  /**
38
45
  * Owns the lifecycle of every in-flight image-model pull.
39
46
  *
@@ -52,11 +59,18 @@ export interface ImageProviderSource {
52
59
  export declare class ImageModelPullRegistry {
53
60
  private readonly entries;
54
61
  private readonly controllers;
55
- private readonly imageProvider;
56
- private readonly catalog;
62
+ private readonly resolve;
63
+ private readonly kind;
64
+ /**
65
+ * Image models by default; `resolve` makes it a registry for another kind
66
+ * of download (LoRAs), whose ids then never share keys with models.
67
+ */
57
68
  constructor(opts: {
58
69
  imageProvider: ImageProviderSource;
59
70
  catalog: CatalogService;
71
+ } | {
72
+ resolve: ImagePullResolver;
73
+ kind: string;
60
74
  });
61
75
  /**
62
76
  * Start a pull for `id`. Idempotent — when a pull is already in flight
@@ -101,6 +115,14 @@ export declare class ImageModelPullRegistry {
101
115
  }
102
116
  export declare class UnknownImageModelError extends Error {
103
117
  readonly id: string;
104
- constructor(id: string);
118
+ constructor(id: string, kind?: string);
105
119
  }
120
+ /**
121
+ * LoRA pulls: the catalog's `image-lora` entries, installed by whichever
122
+ * provider owns the engine. A provider without LoRA support fails the pull
123
+ * with a plain message.
124
+ */
125
+ export declare function imageLoraPullResolver(imageProvider: {
126
+ current(): Promise<Pick<ImageProvider, 'pullLora'>>;
127
+ }, catalog: CatalogService): ImagePullResolver;
106
128
  export {};
@@ -25,11 +25,21 @@ const FINISHED_TTL_MS = 8_000;
25
25
  export class ImageModelPullRegistry {
26
26
  entries = new Map();
27
27
  controllers = new Map();
28
- imageProvider;
29
- catalog;
28
+ resolve;
29
+ kind;
30
+ /**
31
+ * Image models by default; `resolve` makes it a registry for another kind
32
+ * of download (LoRAs), whose ids then never share keys with models.
33
+ */
30
34
  constructor(opts) {
31
- this.imageProvider = opts.imageProvider;
32
- this.catalog = opts.catalog;
35
+ if ('resolve' in opts) {
36
+ this.resolve = opts.resolve;
37
+ this.kind = opts.kind;
38
+ }
39
+ else {
40
+ this.resolve = imageModelPullResolver(opts.imageProvider, opts.catalog);
41
+ this.kind = 'image-model';
42
+ }
33
43
  }
34
44
  /**
35
45
  * Start a pull for `id`. Idempotent — when a pull is already in flight
@@ -47,40 +57,21 @@ export class ImageModelPullRegistry {
47
57
  }
48
58
  // Resolve the catalog entry up-front so we can fail fast with a
49
59
  // proper 404 rather than emitting a 200 + error SSE for "unknown id".
50
- const item = await this.catalog.get('image-model', id);
51
- if (!item || item.manifest.kind !== 'image-model') {
52
- throw new UnknownImageModelError(id);
53
- }
54
- const manifest = item.manifest;
60
+ const job = await this.resolve(id);
61
+ if (!job)
62
+ throw new UnknownImageModelError(id, this.kind);
55
63
  const snapshot = {
56
64
  id,
57
65
  startedAt: new Date().toISOString(),
58
66
  bytesWritten: 0,
59
- totalBytes: manifest.approxSizeBytes +
60
- manifest.auxiliaryFiles.reduce((n, f) => n + f.approxSizeBytes, 0),
67
+ totalBytes: job.totalBytes,
61
68
  finished: false,
62
69
  };
63
70
  const entry = { snapshot, listeners: new Set() };
64
71
  this.entries.set(id, entry);
65
72
  const controller = new AbortController();
66
73
  this.controllers.set(id, controller);
67
- const spec = {
68
- downloadUrl: manifest.downloadUrl,
69
- sha256: manifest.sha256,
70
- approxSizeBytes: manifest.approxSizeBytes,
71
- name: manifest.name,
72
- weightsKind: manifest.weightsKind,
73
- ...(manifest.supportsImg2Img !== undefined
74
- ? { supportsImg2Img: manifest.supportsImg2Img }
75
- : {}),
76
- auxiliaryFiles: manifest.auxiliaryFiles.map((aux) => ({
77
- role: aux.role,
78
- downloadUrl: aux.downloadUrl,
79
- sha256: aux.sha256,
80
- approxSizeBytes: aux.approxSizeBytes,
81
- })),
82
- };
83
- void this.consume(id, spec, controller.signal);
74
+ void this.consume(id, job, controller.signal);
84
75
  return { snapshot: { ...snapshot }, alreadyRunning: false };
85
76
  }
86
77
  list() {
@@ -148,13 +139,12 @@ export class ImageModelPullRegistry {
148
139
  if (snapshot.finished && !snapshot.error)
149
140
  listener({ type: 'done', id: snapshot.id });
150
141
  }
151
- async consume(id, spec, signal) {
142
+ async consume(id, job, signal) {
152
143
  const entry = this.entries.get(id);
153
144
  if (!entry)
154
145
  return;
155
146
  try {
156
- const provider = await this.imageProvider.current();
157
- for await (const event of provider.pullModel(id, spec, signal)) {
147
+ for await (const event of job.run(signal)) {
158
148
  this.applyEvent(entry, event);
159
149
  this.broadcast(entry, event);
160
150
  if (event.type === 'done' || event.type === 'error')
@@ -171,7 +161,8 @@ export class ImageModelPullRegistry {
171
161
  finally {
172
162
  entry.snapshot.finished = true;
173
163
  // Drop the controller — cancel() should be a no-op once finished.
174
- this.controllers.delete(id);
164
+ if (this.entries.get(id) === entry)
165
+ this.controllers.delete(id);
175
166
  // Keep the snapshot around briefly so a client mid-reconnect still
176
167
  // sees the terminal state on `list()` / `subscribe()`. Then garbage
177
168
  // collect.
@@ -184,6 +175,9 @@ export class ImageModelPullRegistry {
184
175
  }
185
176
  applyEvent(entry, event) {
186
177
  const s = entry.snapshot;
178
+ // Publish terminal state before generator cleanup can yield to a new subscriber.
179
+ if (event.type === 'done' || event.type === 'error')
180
+ s.finished = true;
187
181
  if (event.type === 'progress') {
188
182
  s.bytesWritten = event.bytesWritten;
189
183
  if (event.totalBytes !== undefined)
@@ -219,9 +213,77 @@ export class ImageModelPullRegistry {
219
213
  }
220
214
  export class UnknownImageModelError extends Error {
221
215
  id;
222
- constructor(id) {
223
- super(`unknown image-model: ${id}`);
216
+ constructor(id, kind = 'image-model') {
217
+ super(`unknown ${kind}: ${id}`);
224
218
  this.id = id;
225
219
  this.name = 'UnknownImageModelError';
226
220
  }
227
221
  }
222
+ function imageModelPullResolver(imageProvider, catalog) {
223
+ return async (id) => {
224
+ const item = await catalog.get('image-model', id);
225
+ if (!item || item.manifest.kind !== 'image-model')
226
+ return null;
227
+ const manifest = item.manifest;
228
+ const spec = {
229
+ downloadUrl: manifest.downloadUrl,
230
+ sha256: manifest.sha256,
231
+ approxSizeBytes: manifest.approxSizeBytes,
232
+ name: manifest.name,
233
+ weightsKind: manifest.weightsKind,
234
+ ...(manifest.supportsImg2Img !== undefined
235
+ ? { supportsImg2Img: manifest.supportsImg2Img }
236
+ : {}),
237
+ ...(manifest.referenceImages ? { referenceImages: manifest.referenceImages } : {}),
238
+ ...(manifest.editOnly !== undefined ? { editOnly: manifest.editOnly } : {}),
239
+ auxiliaryFiles: manifest.auxiliaryFiles.map((aux) => ({
240
+ role: aux.role,
241
+ downloadUrl: aux.downloadUrl,
242
+ sha256: aux.sha256,
243
+ approxSizeBytes: aux.approxSizeBytes,
244
+ })),
245
+ };
246
+ return {
247
+ totalBytes: manifest.approxSizeBytes +
248
+ manifest.auxiliaryFiles.reduce((n, f) => n + f.approxSizeBytes, 0),
249
+ async *run(signal) {
250
+ const provider = await imageProvider.current();
251
+ yield* provider.pullModel(id, spec, signal);
252
+ },
253
+ };
254
+ };
255
+ }
256
+ /**
257
+ * LoRA pulls: the catalog's `image-lora` entries, installed by whichever
258
+ * provider owns the engine. A provider without LoRA support fails the pull
259
+ * with a plain message.
260
+ */
261
+ export function imageLoraPullResolver(imageProvider, catalog) {
262
+ return async (id) => {
263
+ const item = await catalog.get('image-lora', id);
264
+ if (!item || item.manifest.kind !== 'image-lora')
265
+ return null;
266
+ const manifest = item.manifest;
267
+ const spec = {
268
+ downloadUrl: manifest.downloadUrl,
269
+ sha256: manifest.sha256,
270
+ approxSizeBytes: manifest.approxSizeBytes,
271
+ name: manifest.name,
272
+ baseModels: manifest.baseModels,
273
+ purpose: manifest.purpose,
274
+ strength: manifest.strength,
275
+ ...(manifest.prompt ? { prompt: manifest.prompt } : {}),
276
+ ...(manifest.render ? { render: manifest.render } : {}),
277
+ };
278
+ return {
279
+ totalBytes: manifest.approxSizeBytes,
280
+ async *run(signal) {
281
+ const provider = await imageProvider.current();
282
+ if (!provider.pullLora) {
283
+ throw new Error('This image engine cannot add styles or fast mode.');
284
+ }
285
+ yield* provider.pullLora(id, spec, signal);
286
+ },
287
+ };
288
+ };
289
+ }