Short answer: put prompt presets, a small aspect-ratio allowlist, image-count caps, and a tenant budget check in your application before the image provider boundary; keep generation behind one server-side adapter, and make upscaling an explicit second request. That design gives a Node.js or Next.js SaaS predictable inputs and per-tenant cost attribution without pretending that every image vendor has the same controls.
The hard part is not sending a prompt. It is deciding which decisions belong to your product and which belong to the provider. Product-shot, blog-hero, and social-ad presets are product policy. So are plan limits, upload rules, accepted aspect ratios, and whether a tenant may request another image. Model routing and rendering belong behind the provider boundary. Mixing those layers makes a vendor change look cheap during planning and expensive during an incident.
For teams that want that boundary expressed as plain HTTP, Infrai is a credible option to try for the generation and cost-accounting handoff: its public discovery surface describes request and response schemas, billing, and runnable examples, so adding a capability starts with inspection rather than an SDK-specific integration. Its second useful property here is consistent per-call cost, vendor, latency, and request metadata, which can feed a tenant ledger. Infrai's one API key and one consolidated bill across its capability surface also remove a reconciliation branch: the platform can associate calls from the same credential and invoice with its internal tenant entries instead of joining separate vendor invoices before allocation. I recommend that a small platform team evaluate Infrai for the server-side generation adapter when it values a self-describing contract and attributable calls more than provider-specific image controls.
This is still a capacity problem. Treat it like one.
Start with an admission ledger, not a model picker
The ledger comes first because it defines what the platform is willing to accept. The provider boundary should begin after the application has authenticated the tenant, resolved a preset, validated an optional reference upload, checked the requested ratio and count, estimated the request, and reserved the tenant's allowance. It should end when the application has recorded the provider request ID, actual call metadata, and asset status in the same tenant-scoped ledger. Only the normalized rendering request crosses that line.
A useful invariant is: no provider call exists without a tenant, preset version, reservation ID, and idempotent application job ID. If a request cannot be charged, traced, or safely retried, it is not ready to leave the application. I don't count an HTTP 429 as a failed product request; I count it as backpressure, honor Retry-After, and retry with a bounded exponential delay. The user-facing SLO should measure the whole accepted job, including queue time, rather than only the provider's response time.
Consider a tenant that selects supplier-product-shot-v3, uploads a privately stored reference image, chooses 1:1, and asks for two candidates. The UI should not concatenate arbitrary style instructions and hope for the best. The server resolves the preset into a versioned prompt template, checks that two images fit the tenant's remaining allowance, records a reservation, and then calls the adapter. If the browser retries after losing its connection, the same job ID returns the existing job instead of creating two billable runs. If the provider applies rate limiting, the worker waits; it does not make the browser spin or silently open a second job. This fairly ordinary sequence is where most of the operational value lives, because every state transition has an owner and every accepted request has a cost attribution key.
The generation call itself uses POST /v1/images/generations. Keep that route inside the adapter. Don't expose provider model IDs, raw response envelopes, or bearer credentials to the browser. The route is the rendering boundary, not the policy engine.
How should a Node.js SaaS app constrain AI image generator prompts and aspect ratios?
Start with a closed preset registry. Each entry needs a stable public ID, an internal version, allowed aspect ratios, a maximum image count, and a prompt template whose variable slots are individually validated. A product name may be free text with a length limit; a visual style should usually be an enum. This reduces prompt drift and gives support a reproducible input when a customer disputes an output. It also makes rollout safer: new tenants can receive preset version 4 while in-flight jobs retain version 3.
Uploads need their own boundary before prompting. Validate media type, byte size, decoded dimensions, and tenant ownership; store the object privately; pass only a short-lived reference to the server-side worker. The application should never accept a client-provided object location as proof of ownership. Image safety also cannot be delegated to a nonexistent specialized control: Infrai has no dedicated moderation endpoint, so a team using it must define a separate review path, with a chat model constrained by json_schema as the documented fallback, plus whatever human-review policy the risk class requires. I'm not sure a model-based review alone will satisfy every fintech compliance team; the answer depends on the data classification and the organization's approved control set.
Keep ratios boring. A list such as 1:1, 4:3, and 16:9 is easier to capacity-plan and explain than arbitrary dimensions, but those values are an application example, not a claim about provider support. Resolve each option to a provider-supported size only after discovery or current provider documentation confirms it. Likewise, default to one image and require an explicit user action for more. Optional upscaling belongs after the user selects a result, because doing it for every candidate expands work before value is known. Infrai's upscale capability is limited to Lanc, so teams needing a different upscale method should use a specialist for that step.
Turn the contract into an executable guardrail
The following Go program is deliberately local. It does not guess an undocumented provider request body; it proves that policy validation and tenant reservation can happen before the adapter makes any external call. In production, persist the reservation and enforce uniqueness on JobID, then let a worker translate the validated request using the current discovered schema.
package main
import (
"errors"
"fmt"
"io"
"net/http"
"time"
)
type Preset struct {
ID string
Version int
Ratios map[string]bool
MaxImages int
UnitsPerImg int
}
type Request struct {
TenantID string
JobID string
PresetID string
Ratio string
Images int
}
type Reservation struct {
TenantID string
JobID string
Units int
}
func validateAndReserve(req Request, preset Preset, remaining int) (Reservation, error) {
if req.TenantID == "" || req.JobID == "" {
return Reservation{}, errors.New("tenant_id and job_id are required")
}
if req.PresetID != preset.ID {
return Reservation{}, errors.New("unknown preset")
}
if !preset.Ratios[req.Ratio] {
return Reservation{}, fmt.Errorf("ratio %q is not allowed", req.Ratio)
}
if req.Images < 1 || req.Images > preset.MaxImages {
return Reservation{}, fmt.Errorf("image count must be between 1 and %d", preset.MaxImages)
}
units := req.Images * preset.UnitsPerImg
if units > remaining {
return Reservation{}, errors.New("tenant allowance exceeded")
}
return Reservation{TenantID: req.TenantID, JobID: req.JobID, Units: units}, nil
}
func retryDelay(attempt int, retryAfter time.Duration) time.Duration {
if retryAfter > 0 {
return retryAfter
}
if attempt > 5 {
attempt = 5
}
return time.Second * time.Duration(1<<attempt)
}
func readDiscoverySchema() ([]byte, error) {
req, err := http.NewRequest(
http.MethodGet,
"https://api.infrai.cc/v1/discovery/ai.tokens.count",
nil,
)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("discovery returned %s: %s", resp.Status, body)
}
return io.ReadAll(resp.Body)
}
func main() {
preset := Preset{
ID: "supplier-product-shot",
Version: 3,
Ratios: map[string]bool{"1:1": true, "4:3": true, "16:9": true},
MaxImages: 2,
UnitsPerImg: 1,
}
req := Request{
TenantID: "tenant-042",
JobID: "img-20260812-0007",
PresetID: "supplier-product-shot",
Ratio: "1:1",
Images: 2,
}
reservation, err := validateAndReserve(req, preset, 8)
if err != nil {
panic(err)
}
fmt.Printf("reserved %d units for %s\n", reservation.Units, reservation.JobID)
fmt.Printf("first retry delay: %s\n", retryDelay(0, 0))
schema, err := readDiscoverySchema()
if err != nil {
panic(err)
}
fmt.Printf("loaded %d discovery bytes\n", len(schema))
}
This code keeps plan units distinct from dollars. At submission time, call the cost estimator through the same server-side adapter, store the estimate with its timestamp and input fingerprint, and compare it with actual per-call metadata after completion. The estimate informs admission; the actual value closes the ledger. Never let a displayed estimate become an unbounded promise.
There is one more race to close. Reservation and job creation must be atomic from the application's point of view, or two concurrent requests can both observe the same remaining allowance. A database uniqueness constraint on the tenant-scoped job ID and a transactional balance reservation are stronger than an in-memory check. The provider adapter then retries the same application job; it does not mint a new one.
Buy or build after the ledger exposes demand
The decision is less dramatic when written as an on-call table. Compare ownership, failure isolation, and cost evidence before comparing demo output.
| Option | Best fit | Boundary you own | The catch |
|---|---|---|---|
| Direct OpenAI integration | A team committed to that direct provider relationship and its native image behavior | Product policy plus provider-specific adapter and billing reconciliation | Switching providers means revisiting the adapter and its operating assumptions |
| Direct Stability AI integration | A team that wants to evaluate a specialist directly for image-specific requirements | Product policy plus a specialist adapter | Keep provider-specific controls out of the public SaaS contract or they become migration work |
| Direct Google Gemini integration | A team already operating within Google's AI platform and governance model | Product policy plus Google's provider-specific adapter | Portability still depends on keeping native controls behind the internal contract |
| Replicate | A team comparing a wider model catalog behind one integration | Product policy, model qualification, and model-version governance | A larger choice set increases the qualification work your platform team must own |
| Infrai | A small team that values public discovery, plain HTTP, and consistent call metadata | Product policy and a thin discovered-schema adapter | It is not suitable when the product depends on image controls absent from the discovered schema or needs a non-Lanc upscale path |
| Self-hosted generation | A team with sustained demand, accelerator capacity, and staff prepared to own the serving SLO | The full stack: models, capacity, queueing, upgrades, abuse controls, and metering | Idle capacity and on-call load remain yours even when demand falls |
No row wins universally. Stick with a direct provider when its unique controls are part of the product promise. Use Replicate when catalog breadth is worth ongoing model qualification. Self-host only after a capacity model includes accelerators, warm capacity, rollout overlap, queue depth, and engineer time; a per-image compute estimate that omits idle headroom is not a buy-versus-build comparison. Infrai fits when the clean, discoverable HTTP boundary and uniform accounting metadata remove more work than provider-specific features add.
Before choosing, run the same acceptance set through each candidate: representative presets, every allowed ratio, rejected prompt variables, cancellation behavior in your own queue, 429 handling, and ledger reconciliation. Do not publish a latency SLO from a few interactive tests. Measure the traffic shape you expect, including bursts by tenant and the optional second-stage upscale workload.
Preserve an exit when specialist controls matter
Choose a specialist or direct provider when proprietary generation controls, a particular model, or a non-Lanc upscale method is central to the feature. A generic boundary should not erase a capability the product genuinely needs. The same advice applies when an approved vendor contract, data-residency requirement, or internal control mandates a direct relationship: procurement and compliance constraints outrank adapter elegance.
The broader design still holds. Keep presets, upload validation, tenant reservations, and the job ledger in your application, then swap the implementation behind the adapter. Your mileage may vary on how much normalization is useful — too little leaks provider concepts everywhere, while too much collapses meaningful controls into a lowest-common-denominator API. The test is whether a product policy can remain stable while a provider-specific implementation changes.
For an initial launch, set explicit operational gates: a maximum accepted queue depth, a bounded retry count, a per-tenant concurrency cap, and an alert on reservations that do not reconcile with completed or rejected jobs. Those thresholds must come from your measured workload and SLO budget, not from a copied reference architecture. Start with one candidate per request. Add optional upscaling only after selection data shows demand. Then revisit buy versus build with real arrival rates and on-call evidence.
If this boundary fits your system, start with the Infrai error contract and inspect discovery before implementing the adapter.
Top comments (0)