docs/references/ai/image-generation-parameters.md
How the paintings page renders a per-model parameter form, collects the user's values, and turns them into a vendor-correct image-generation request — all driven by registry + catalog data, with zero per-vendor UI code and the canonical→wire rename declared exactly once.
The goal: adding a new image model (or a whole new vendor) should be a data change. A new param that several models share is a one-row catalog addition that flows to the form, the validation, the wire body, and the static types at once. Vendor wire-format quirks live in exactly one declarative place each.
One canonical param set (paramValues) is the trunk. It forks into two delivery
adapters only at the very end — the canonical param bag is the same; only how each
adapter turns it into a wire request differs (a WireProfile providerOptions body
for SDK delivery vs. a bespoke envelope the transport builds).
┌─ RENDERER ────────────────────────────────────────────────────────────────────┐
│ registry per-model `supports` ──useImageGenerationSupport──▶ form │
│ imageGenerationToFields: SupportSpec.type → control (one map) │
│ user edits → painting.params (canonical camelCase bag) │
│ canonicalGenerate: buildParamsSchema(support,mode) validate / coerce │
│ → paramValues (pure canonical bag; blanks dropped, customSize composed) │
└──────────────────────────── ipcApi.request('ai.image.generate') ───────────────┘
│ { uniqueModelId, prompt, mode, paramValues, inputImages?, mask? }
▼ (model routing is dynamic; paramValues validated + coerced by the catalog imageParamsSchema)
┌─ MAIN · AiService.generateImage ───────────────────────────────────────────────┐
│ splitParamValues(paramValues) ──via AI_SDK_NATIVE_BINDINGS──▶ │
│ structured = { n, size, seed, aspectRatio, … } (typed ParamValues & {n})│
│ vendorBag = { the non-native canonical params, camelCase } │
│ │
│ resolveImageTransport(provider, model)? │
│ ├─ NO → SDK delivery ├─ YES → transport delivery │
│ ▼ ▼ │
│ buildVendorProviderOptions getImageGenerationSupport(provider, │
│ (WireProfile + wireName) model).modes[mode].vendorTransport │
│ → providerOptions[id] = WIRE body → modelDescriptor (typed routing) │
│ native → imageParams (n/size/seed) generateImageViaJob(structured, │
│ vendorBag, modelDescriptor) │
└────────┼────────────────────────────────────────────┼──────────────────────────┘
▼ ▼ JobManager → imageGenerationJobHandler
AI SDK image model transport.submit(input)
(SiliconImageModel / @ai-sdk/openai-compatible reads canonical camelCase params
/ @ai-sdk/google / aihubmix custom) + input.{n,size,seed} + input.modelDescriptor
spreads providerOptions[id] → HTTP body builds its own envelope (input.* /
parameters.* / messages[]), POST, poll
│ │
▼ parse response ▼ poll → parse response
image data URLs ◀────────────── back through IPC ──────────▶ renderer
Two halves, one canonical vocabulary in the middle:
supports ⊗ catalog → form fields → painting.params.paramValues → splitParamValues → the WireProfile engine /
the transport → vendor wire body.The contract between them is the catalog (the canonical key set + each key's value type and wire name). The form, the validation, the partition, and the static types all project from it.
| Fact | Declared in | Consumed by |
|---|---|---|
| param value type + wire name | IMAGE_PARAM_CATALOG (ParamValues, wireName) | form, validation, wire engine, structured type |
| per-model: which params + constraints (options/range/default) | registry supports | form + buildParamsSchema |
canonical → AI-SDK-native (numImages→n, aspectRatio normalize, …) | AI_SDK_NATIVE_BINDINGS | splitParamValues |
canonical → vendor wire name (negativePrompt→negative_prompt) | wireName() (catalog wire or auto snake_case) | WireProfiles + aihubmix DEFAULT |
| per-provider delivery (dual-key / passthrough / sibling key / envelope) | WIRE_REGISTRY + each transport | the two adapters |
| per-model endpoint routing (endpoint / sync / response family) | registry vendorTransport → modelDescriptor | the transports |
Nothing in this list is repeated. A canonical param is one catalog row; a provider's wire shape is one profile / transport.
Source of truth: packages/provider-registry/src/schemas/imageParamCatalog.ts.
Each CanonicalParamKey is declared once, with everything the pipeline needs
to project from:
interface ImageParamCatalogEntry {
schema: z.ZodTypeAny // SINGLE source of the value type → form validation + ParamValues type
wire?: string // vendor wire name override; omit for the auto camelCase→snake_case form
}
const IMAGE_PARAM_CATALOG = {
negativePrompt: { schema: optString }, // → 'negative_prompt' (auto)
numInferenceSteps: { schema: optInt }, // → 'num_inference_steps' (auto)
imageResolution: { schema: optString, wire: 'size' }, // irregular → explicit
addWatermark: { schema: optBool, wire: 'watermark' },
// … exhaustive over CanonicalParamKey (a missing/extra key is a compile error)
} as const satisfies Record<CanonicalParamKey, ImageParamCatalogEntry>
type ParamValues = { [K in CanonicalParamKey]?: ParamValue<K> } // z.infer of each schema
Three projections fall out of the atom, so they can never drift:
buildParamsSchema(support, mode) (.../utils/buildParamsSchema.ts) — catalog value schemas ⊗ the per-model supports constraints (enum members / range bounds) → the zod schema that validates a model's form bag. .loose() during migration.ParamValues — z.infer of the catalog. The typed canonical bag; there is no hand-maintained param-shape type.wireName(key) — catalog.wire ?? autoSnakeCase(key). The single canonical→wire rename. Every flat-body provider derives its field name from here; no provider repeats negativePrompt → negative_prompt.Native params (
numImages/size/seed/aspectRatio) do not go throughwireName— they're routed byAI_SDK_NATIVE_BINDINGS(app layer, see below), because their target is an AI SDK call option, not a body field name.
supports + routingSource: packages/provider-registry/src/schemas/model.ts (ImageGenerationSupportSchema).
interface ImageGenerationSupport {
modes: Partial<Record<ImageGenerationMode, ModeDef>> // generate | edit | remix | upscale | merge
}
interface ModeDef {
supports: Partial<Record<CanonicalParamKey, SupportSpec>> // which params + their per-model constraints
vendorTransport?: { endpoint: string; isSync?: boolean } // async / non-OpenAI routing
requirePrompt?: boolean // default true; false for qwen-mt-image, upscalers
}
type SupportSpec =
| { type: 'switch'; default?: boolean }
| { type: 'enum'; options: string[]; default?: string; render?: 'select' | 'chips'; columns?: number }
| { type: 'range'; min: number; max: number; default?: number; step?: number }
| { type: 'size'; minSide: number; maxSide: number; pairedEnumKey?: string }
| { type: 'text'; multiline?: boolean }
supports carries only the per-model constraints (which params, their
options/range/default); the value type and wire name live in the catalog, and the
control kind derives from SupportSpec.type in the form
(imageGenerationToFields). A model's block resolves override-wins in
ProviderRegistryService.getImageGenerationSupport (src/main/data/services/ProviderRegistryService.ts):
registryOverride.imageGeneration ?? presetModel.imageGeneration ?? null // null → empty form
packages/provider-registry/data/models.json (provider-agnostic official contract).packages/provider-registry/data/provider-models.json, keyed by { providerId, modelId } (vendor-flavored params / vendor-exclusive SKUs). Id normalization tolerates dotted vs sanitized ids.Fetch — useImageGenerationSupport(providerId, modelId) (.../hooks/useImageGenerationSupport.ts) queries GET /providers/:providerId/models/:modelId*/image-generation-support (DataApi → the same ProviderRegistryService main hosts; SWR-cached).
Map — imageGenerationToFields(support, { mode }) (.../form/imageGenerationToFields.ts) iterates modes[mode].supports and dispatches each entry through specToField by spec.type — no per-vendor branches:
SupportSpec.type | widget |
|---|---|
switch | toggle |
enum (render:'chips') | chip row (size / aspectRatio / imageResolution) |
enum (default) | select dropdown |
range | slider |
size | custom width×height inputs (gated on pairedEnumKey === 'custom') |
text | input / textarea (multiline) |
Label — KEY_LABELS (same file) maps each CanonicalParamKey → i18n title/tooltip (exhaustive over the key set).
Form edits write into painting.params — a flat canonical-keyed bag. Defaults
are committed when the model is selected by computeModelFieldReset
(.../utils/computeModelFieldReset.ts).
paramValues → vendor requestcanonicalGenerate).../model/canonicalGenerate.ts validates painting.params through buildParamsSchema(support, mode) (soft-fail to raw on a bad value), drops blanks, composes customSize_* → size, and ships one canonical paramValues bag plus mode (a request property, not a param — see §5) over IPC (ai.image.generate, src/shared/ipc/schemas/ai.ts). The IPC schema types paramValues as the catalog's imageParamsSchema — the router's safeParse yields a strict, coerced ParamValues (non-catalog keys stripped). Per-model option/range constraints already ran in the renderer's buildParamsSchema; this is the value-type gate.
splitParamValues + AI_SDK_NATIVE_BINDINGS)AiService.generateImage calls splitParamValues (src/main/ai/utils/imageOptions.ts), which uses AI_SDK_NATIVE_BINDINGS (src/main/ai/utils/aiSdkNativeBindings.ts) to split the bag:
numImages→n, size, seed, aspectRatio normalized once) → structured (typed ParamValues & { n?: number }), the AI SDK call options.vendorBag (canonical camelCase).Blank / null / undefined are dropped here (the byte-identical-wire guard); the
'auto' sentinel survives and is resolved to "omit the field" at the wire layer.
For SDK-delivered providers, buildVendorProviderOptions (src/main/ai/provider/custom/wire/buildImageRequest.ts) maps the canonical bag to the wire body via wireName, then packages it per the provider's registration in WIRE_REGISTRY (src/main/ai/provider/custom/wire/wireProfile.ts):
interface WireProfile { fields: Partial<Record<CanonicalParamKey, WireRule>> } // forward / map / contribute
interface WireRegistration {
profile: WireProfile
dualOpenAI?: boolean // mirror the clean body under `openai` too (gpt-image family)
passthrough?: boolean // forward vendor-bag fields the profile doesn't name (silicon cfg, …)
also?: { key; profile }[] // a sibling provider key (dmxapi → google.imageConfig)
}
A profile declares which canonical params ride in the body; the name
comes from wireName. Delivery (which key(s), passthrough, sibling, nesting) is
the registration's job — not the profile's, and never a repeated rename. Providers
absent from WIRE_REGISTRY fall back to DEFAULT_DIFFUSION_REGISTRATION (the
OpenAI-compatible diffusion family). The result is providerOptions[id], which
the AI SDK image model spreads into the request body; structured becomes the
typed call options (imageParams).
The SDK image model is one of: a custom ImageModelV3 (e.g.
silicon/SiliconImageModel.ts, aihubmix/aihubmixImageModel.ts), @ai-sdk/openai-compatible, or @ai-sdk/google. It reads the wire body — it does not re-rename it.
When resolveImageTransport(provider, model, settings) (.../custom/imageTransportRegistry.ts) returns a transport (DashScope / PPIO / ModelScope / OVMS / DMXAPI-custom families), the request runs on the job system (generateImageViaJob → JobManager → imageGenerationJobHandler), which owns the submit/poll loop, queueing and cancellation.
The job is deliberately not restart-durable (recovery: 'abandon'): its only consumer is the in-process awaiter in generateImageViaJob, and the payload records no consumer identity, so a job resumed after a restart would have nobody to hand its result to. Non-terminal jobs are cancelled at startup instead of resumed.
Unlike the SDK path (whose bag IS the body), a transport builds its own per-model
envelope, so it receives the canonical camelCase params directly — native
n/size/seed via the typed ImageGenerationSubmitInput.{n,size,seed}, the rest
via providerParams (the vendorBag). No wire-naming, no casing probes: each
transport reads params.negativePrompt, params.promptExtend, … and places them
into its structure (input.* / parameters.* / messages[]) with the field name
its API wants. See imageGenerationModel.ts for the ImageGenerationTransport contract and each <vendor>/<vendor>Transport.ts.
modelDescriptor (derived in main)A transport routes by modelDescriptor = { id, endpoint, isSync, mode } — which
endpoint to POST, whether to poll, and which response-family parser to use.
This is backend routing data, derived where routing happens: AiService,
holding the resolved (providerId, modelId, mode), calls
providerRegistryService.getImageGenerationSupport(...) → modes[mode].vendorTransport
and builds the descriptor, threading it as a typed field on the job payload /
ImageGenerationSubmitInput.modelDescriptor — never through the param bag. The
registry is the source of vendorTransport, and main already hosts it (the
renderer's support fetch is an IPC round-trip to the same service), so the
descriptor is a pure derivation, not a param.
IMAGE_PARAM_CATALOG ({ schema, wire? }) — set wire only if the vendor name isn't the auto snake_case form. Add its KEY_LABELS row + i18n (pnpm i18n:sync).supports with the right SupportSpec (options/range/default).WireProfile field / transport line if the param needs bespoke placement (a nested block, a sibling provider key, a transport envelope).imageGeneration block in models.json.{ providerId, modelId, imageGeneration } override in provider-models.json.vendorTransport.endpoint (+ isSync) and ensure the vendor's transport recognizes the model family.DEFAULT_DIFFUSION_REGISTRATION engine + @ai-sdk/openai-compatible cover it.WireProfile with the right delivery flags (dualOpenAI / also / passthrough).ImageGenerationTransport, register it on the provider's imageModel(...), and read canonical camelCase params + input.modelDescriptor.| Concern | File |
|---|---|
| Param catalog (value + wire) | packages/provider-registry/src/schemas/imageParamCatalog.ts |
| Per-model validation schema | packages/provider-registry/src/utils/buildParamsSchema.ts |
Registry schema (supports / vendorTransport) | packages/provider-registry/src/schemas/model.ts |
| Base / override model data | packages/provider-registry/data/{models,provider-models}.json |
| Resolver (override ?? base) | src/main/data/services/ProviderRegistryService.ts |
| Support fetch hook | src/renderer/pages/paintings/hooks/useImageGenerationSupport.ts |
Registry → form fields + KEY_LABELS | src/renderer/pages/paintings/form/imageGenerationToFields.ts |
| Default population on switch | src/renderer/pages/paintings/utils/computeModelFieldReset.ts |
Validate + build the IPC paramValues bag | src/renderer/pages/paintings/model/canonicalGenerate.ts |
| Transport routing hint (→ backend, see §5) | src/renderer/pages/paintings/model/paintingPipeline.ts |
| IPC payload schema | src/shared/ipc/schemas/ai.ts |
| Main entry + native split + job dispatch | src/main/ai/AiService.ts |
splitParamValues (native vs vendorBag) | src/main/ai/utils/imageOptions.ts |
| AI-SDK native binding table | src/main/ai/utils/aiSdkNativeBindings.ts |
| WireProfile engine + delivery registry | src/main/ai/provider/custom/wire/{buildImageRequest,wireProfile}.ts |
| Custom transport contract | src/main/ai/provider/custom/imageGenerationModel.ts |
| Vendor provider + transport | src/main/ai/provider/custom/<vendor>/{<vendor>Provider,<vendor>Transport}.ts |
| Shared transport helpers | src/main/ai/provider/custom/transportUtils.ts |