AI Providers

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.
  • createTryOnTask resolves both images via resolveInputImage — 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.
  • getTaskSnapshot polls the KIE task; generateTryOn wraps create+poll with retries.
  • KIE_ALLOW_MOCK_FALLBACK returns 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 model nano-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 blockSOFT rule — quality
exampleintimates → seedream-5-pro (#59)ring → nano-banana-2 (#361)
whythe nano-banana family refuses lingerie/swimwear at the model layer (IMAGE_SAFETY). Override it and the generation does not degrade — it failsmeasured quality: prod thumbs-down tracked the tier nano-banana-lite 75% / nano-banana-2-lite 50% / nano-banana-2 25%
lives inCONTENT_BLOCK_PINS in codethe category_model_rules table
editable in the adminno — the API answers 422yes, /admin?tab=tuning
beaten by a per-product overridenoyes
smart routingleft 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_model

Every 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_GUARDRAIL anchors on the face and neck (necklace, pendant, eyewear, generic jewelry) and HAND_SCALE_GUARDRAIL anchors 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.jpg and 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 to ring on purpose — do not extend it to bracelets or watches without running the same measurement (proof-361-ring-quality.mjs accepts 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.