AI Providers
Try-on generation is abstracted behind a provider interface so KIE.AI and fal.ai are interchangeable.
Interface
lib/server/try-on-provider.ts defines TryOnProviderClient:
type TryOnProviderClient = {
generateTryOn(input): Promise<TryOnResult>; // synchronous (polls)
createTryOnTask(input & { callBackUrl? }): Promise<TryOnTaskCreation>; // async
getTaskSnapshot(taskId): Promise<TryOnTaskSnapshot>; // poll provider directly
};createTryOnProviderClient({ provider, modelOverride }) returns the KIE or fal client
(lib/server/try-on-provider-client.ts). Default provider is kie.ai.
Boundary (#73, enforced by lib/server/tryon-prompt-builder.ts + tryon-provider-adapter.test.ts):
that factory is the ONLY module allowed to import a concrete provider client, and the clients never
import each other. Everything provider-NEUTRAL — every category prompt builder,
buildCombinedTryOnPrompt, resolveTryOnCategoryKeyForProduct / resolveTryOnKnobFamilyForProduct
and the mock preview image — lives in lib/server/tryon-prompt-builder.ts, so routes and the fal/mock
clients never reach into kie-ai-client.ts. coerceGenerationProvider (DB string → provider union,
unknown ⇒ kie.ai) and mapProviderSnapshotToJobStatus live in lib/server/try-on-provider.ts.
Known asymmetries, on purpose: KIE_ALLOW_MOCK_FALLBACK also gates fal’s mock fallback (legacy env
name), and the two clients keep different retry policies.
KIE.AI (lib/server/kie-ai-client.ts)
- Uses
KIE_API_KEY,KIE_API_BASE_URL,KIE_UPLOAD_BASE_URL,KIE_MODEL. createTryOnTaskresolves both images viaresolveInputImage— data URLs are uploaded to the provider, http URLs are passed through. So the person image can be a data URL and the garment a public URL.getTaskSnapshotpolls the KIE task;generateTryOnwraps create+poll with retries.KIE_ALLOW_MOCK_FALLBACKreturns a mock result when the key is missing or on provider error (useful for local/dev).
fal.ai (lib/server/fal-ai-client.ts)
- Alternative provider; selected per store via
stores.generation_provider='fal.ai'with modelnano-banana-2.
Per-store model selection
store.generation_provider + store.generation_model choose the client/model. Operators
can change a store’s model from the admin dashboard.
Category → model: two different things (#366)
A category can dictate the generation model in two ways, and confusing them is how lingerie stops
generating altogether. resolveGenerationModel (lib/server/category-model-routing.ts) keeps them
apart.
| HARD pin — content block | SOFT rule — quality | |
|---|---|---|
| example | intimates → seedream-5-pro (#59) | ring → nano-banana-2 (#361) |
| why | the nano-banana family refuses lingerie/swimwear at the model layer (IMAGE_SAFETY). Override it and the generation does not degrade — it fails | measured quality: prod thumbs-down tracked the tier nano-banana-lite 75% / nano-banana-2-lite 50% / nano-banana-2 25% |
| lives in | CONTENT_BLOCK_PINS in code | the category_model_rules table |
| editable in the admin | no — the API answers 422 | yes, /admin?tab=tuning |
| beaten by a per-product override | no | yes |
| smart routing | left alone (categoryPinned) | free to switch provider within the same tier (kie and fal both serve nano-banana-2), so cost and failover keep working and a job can never drop to lite |
Precedence:
content block > per-product override (#87) > store rule > global rule > store.generation_modelEvery provider job records modelSource (content_block / product_override / store_rule /
global_rule / store_default) plus categoryModelRuleId, so a wrong model is diagnosable from
prod data instead of re-derived by hand.
The rules table
category_model_rules — store_id IS NULL means global. Two partial unique indexes enforce one
rule per scope; a plain UNIQUE would not constrain the global rows at all, because NULL never equals
NULL. RLS on, service-role only: the table decides how merchant money is spent per generation, so it
is never reachable from a browser session. The generation path reads it through
lib/server/category-model-rules.ts (~30s cache + single-flight, so a burst shares one query), and
a read failure degrades to the store default rather than failing the try-on.
Why the ring rule exists: 79% of ring generations ran on a lite tier, and resolution is the
mechanism — nano-banana-2-lite returns 576×1024 vs nano-banana-2’s 1536×2730, and a ring is ~5%
of the frame, so on lite the band is ~40px across and its engraving is unrecoverable. A measured
replay of the ten down-rated prod generations fixed 9/10 (#361).
Adding a rule
/admin?tab=tuning → Category model rules → scope (a store, or “Global — all stores”) +
category + model + why. The confirm dialog states the affected stores and products and the estimated
monthly cost change, computed from the last 30 days of real spend — a global rule multiplies
cost across the whole fleet and must never be one unlabelled click away.
Use the per-product tuning below it when one product is special; use a rule when the whole
category is. And a new rule wants a measured replay behind it rather than an intuition —
proof-361-ring-quality.mjs accepts any set of prod sessions.
Prompts
The garment/person inputs plus product metadata feed buildTryOnPrompt (category, product
name/type) to steer the generation. Two rules that are easy to break:
- The scale guardrail must name a body part that is in frame. There are two:
SCALE_GUARDRAILanchors on the face and neck (necklace, pendant, eyewear, generic jewelry) andHAND_SCALE_GUARDRAILanchors on the finger, wrist and knuckle (ring, bracelet, watch). Until #361 every category shared the face/neck one, so all 85 recorded prod ring generations were told to judge a finger-worn product against a face that was usually out of frame — and oversizing was their most common defect. - Reference images outrank prompt text.
selectTryOnReferenceImages(lib/products/tryon-reference-images.ts) withholds product images that are themselves AI generations (*_worn_nano-banana-2.jpgand similar markers) because merchants upload previous try-on outputs as product photos. On prod, 72% of ring generations carried such a reference, and the one for the „Medusa” ring showed the ring oversized and straddling two fingers — the exact placement the ring prompt forbids in words. The outputs copied the picture, not the paragraph. For hand-worn categories the multi-reference clause additionally states that only the product may be taken from a worn reference — never its hand, finger choice, pose or framing. - Rings send the packshot ALONE (
allowsReferenceImages). Filename filtering is not enough: Icedout’s third „Medusa” image is a poolside lifestyle photo of a different, tattooed man wearing six other rings, and the multi-reference clause tells the model it shows “the SAME single product”. Replayed, the model took his hand and his tattoos, and with references present it also rendered a phantom second ring. Packshot-only was equal-or-better in 9 of 9 comparable replayed cases. The rule is scoped toringon purpose — do not extend it to bracelets or watches without running the same measurement (proof-361-ring-quality.mjsaccepts any set of prod sessions). - Merchant listing text is sanitised by
sanitizeListingDescription(lib/products/listing-description.ts) before it reaches the prompt: CTAs, prices, delivery promises and sentences quoting a different product’s name are dropped. Two live products told the model to render a ring other than the one being tried on.
PROMPT_TEMPLATE_VERSION (lib/server/prompt-knobs.ts) must be bumped on any prompt-builder
change; it is stamped on every provider job so quality can be sliced old-vs-new.