The first AI model in a product rarely creates an architecture problem.
You build a form, call an API, and display a result.
The second model adds a dropdown and a few conditions. The third adds another input mode. Before long, the product supports text-to-image, image-to-image, text-to-video, image-to-video, reference-based generation, video editing, background removal, and upscaling.
At that point, calling the provider is no longer the hardest part.
The difficult questions are product questions:
- Which modes does each model support?
- Which controls should the form render?
- How many reference assets can a user upload?
- Does the model accept images, video, audio, or a combination?
- Which parameter combinations are valid?
- How does resolution, duration, or source media affect the credit cost?
- Can the provider rename a model without breaking public URLs or stored tasks?
While building AI Image Editor, I found that a provider adapter only solves part of this problem. The application also needs a model catalog above the adapter: one product-owned contract describing what every model means inside the product.
Provider Adapters Answer the Wrong Question
A provider adapter is still useful. It can normalize operations such as:
interface GenerationProvider {
submit(input: ProviderInput): Promise<ProviderJob>
getStatus(jobId: string): Promise<ProviderResult>
}
The rest of the application does not need to care whether one provider calls an identifier taskId and another calls it predictionId.
But an adapter answers, "How do I call this service?"
It does not answer, "What should this product allow the user to do?"
One provider can expose many models with completely different product rules. One image model may accept 16 reference images, while another accepts four. One video model may accept video input, while another only accepts a starting image. Some models charge by resolution. Others charge by output duration or by both input and output media.
Those differences belong to the product layer.
Describe Capabilities as Data
A useful catalog entry can look like this:
type ModelCatalogEntry = {
id: string
kind: 'image' | 'video'
slug: string
modes: Array<{
id: string
supportsReferenceAssets: boolean
}>
fields: Array<{
id: string
options: Array<{ label: string; value: string }>
}>
defaults: Record<string, string>
maxReferenceAssets: number
sourceAssetAccept?: string
creditCost: CreditCostTier[]
providerRoutes: ProviderRoute[]
}
This object is not a copy of a provider response. It is the application's own definition of the model.
The internal id and public slug belong to the product. A provider-specific model name belongs inside providerRoutes. If an integration changes later, the application should not have to rewrite database history, public page URLs, and frontend state.
Product identity should be stable. Provider identity should be replaceable.
Generate the Form From Capabilities
Without a catalog, model forms often become a growing collection of conditions:
if (model === 'model-a') {
showResolution()
}
if (model === 'model-b' || model === 'model-c') {
allowReferenceImages()
}
This feels direct while the catalog is small. It becomes fragile when models are renamed, upgraded, or share only some capabilities.
A capability-driven form reads the entry instead:
-
modesrenders text-to-image, image-to-image, or video-edit tabs -
fieldsrenders resolution, aspect ratio, duration, and audio controls -
defaultsinitializes the form -
maxReferenceAssetscontrols the uploader -
sourceAssetAcceptlimits media types
Adding a model becomes primarily a data change, followed by contract tests, instead of a search for every component that knows the model name.
The frontend also stays deliberately boring. It renders product capabilities without learning provider terminology.
Enforce the Same Contract on the Server
Generating the UI from a catalog is not enough. If the server does not use the same rules, the catalog is display configuration rather than a contract.
After receiving a request, the server should check:
- The model exists and matches the requested media kind.
- The requested mode belongs to the model.
- The selected mode accepts the supplied references.
- The number and kinds of assets are allowed.
- Dynamic fields contain only declared values.
- Cross-field combinations are compatible.
The server cannot trust the rendered form. A request may come from a stale browser tab, an older deployment, or a client calling the endpoint directly.
The safer flow is:
request schema
|
v
model catalog normalization
|
v
credit calculation
|
v
persist normalized task
The worker then receives normalized product input rather than raw form state.
Pricing Belongs in the Contract Too
A flat "10 credits per generation" rule rarely survives a multi-model product.
Image cost may depend on resolution. Video cost may depend on duration, quality, audio, and whether the user supplied video input. Provider pricing also changes over time.
Catalog pricing can be expressed as versioned matching rules:
type CreditCostTier = {
credits: number
effectiveAt: string
resolution?: string
duration?: string
withAudio?: boolean
withVideoInput?: boolean
}
The resolver first chooses the latest price version effective at the task creation time. It then selects the most specific tier matching the normalized input.
Two details matter here.
First, calculate and persist the cost before the task enters the generation pipeline. A price update while the task is waiting should not change what the user already confirmed.
Second, the UI estimate and server charge should use the same resolver. A price displayed on a button and a different price recorded in billing is a fast way to lose trust.
Test Catalog Semantics, Not Only Types
TypeScript can validate the shape of an entry. It cannot prove that the entry makes sense.
A correctly typed configuration can still contain:
- a default value missing from its options
- a duplicate public slug
- an uncovered resolution-duration price combination
- a reference-enabled mode with a zero reference limit
- a provider route pointing at the wrong media kind
- a new price version that unintentionally changes historical calculations
The catalog deserves focused tests:
every default is an allowed option
every public slug is unique
every supported mode normalizes successfully
every price combination resolves deterministically
every provider route matches the model kind
These tests also make reviews clearer. A model integration becomes a visible declaration of capabilities instead of a trail of special cases across unrelated files.
Do Not Turn Everything Into JSON
A catalog does not mean every difference must be forced into static configuration.
Some models have real cross-field constraints. Some video prices depend on the measured duration of uploaded source assets. Those rules are easier to express as explicit pure functions called through the catalog's public API.
A practical boundary is:
- capabilities, options, defaults, and routes are data
- normalization, cross-field validation, and dynamic cost are pure functions
- request translation and response parsing stay in provider adapters
- retries and final settlement stay in the job workflow
The goal is not to eliminate code. It is to give every difference one authoritative home.
The Payoff Is Predictability
The complexity of a multi-model AI product does not come from the number of API clients. It comes from the number of capability combinations crossing the same product flow.
Provider adapters hide integration differences. A model catalog gives the rest of the application one consistent interpretation of those differences.
Once the catalog becomes a real product contract, adding a model stops meaning "add another exception everywhere." It becomes "declare a new set of capabilities inside a boundary we already understand."
That predictability is more valuable than saving a few minutes on the next integration.
Top comments (0)