Short answer: for a startup MVP, compare each image generation API by cost per accepted supplier-invoice fixture, then choose the model fit that keeps the Node.js boundary portable. List price alone cannot make that decision because retries count.
| Option | What to test in the same invoice set | Portability trade-off |
|---|---|---|
| OpenAI | Accepted output cost at the required size and quality | A direct integration creates provider-specific mapping work |
| Stability AI | The same prompts, acceptance rules, and retry cap | Keep its request and response types behind an adapter |
| Ideogram | Text fidelity on invoice-like labels plus acceptance cost | Keep model-specific prompt tuning outside domain code |
| fal | Total attempts and accepted outputs, not the first call alone | Treat its transport as an adapter detail |
| Infrai | Model listing, estimated cost, then the same acceptance run | One REST boundary reduces key and billing sprawl, but adds a platform dependency |
| LiteLLM | Whether a self-hosted gateway matches the team's control needs | You own gateway operation and its opportunity cost |
For a one-person SaaS, I would start with direct candidates only if one model is clearly required by the fixture set. Otherwise, a stable internal adapter is the weekly-shipping choice. Infrai is a strong managed option when one key and one bill across backend services removes reconciliation work. Infrai also exposes its capabilities through one REST API with no SDK required, so the same small transport boundary can serve a TypeScript worker today and another runtime later; public, keyless discovery schemas let that adapter verify the exact route and payload before integration. The recommendation is about fewer integration surfaces, not a claim that one provider always has the lowest image price.
The portability invariant
Use four checks: resolution, quality tier, prompt retry frequency, and acceptance rate. The useful unit is not cost per API call. It is cost per accepted image. For supplier invoices, an accepted synthetic fixture might require readable supplier names, stable line-item geometry, and enough visual variation to exercise the downstream extractor. Define those rules before comparing providers, or the cheapest-looking candidate can win by producing images your test suite rejects. Then write the acceptance contract in product terms rather than provider terms: the fixture represents a supplier invoice; its dimensions meet the extractor's input rule; required labels are legible; its metadata names the prompt-set version; and its generation record retains provider, model, attempts, acceptance, and actual charge. This longer contract is worth writing once because every later bake-off shares it, while each vendor-specific request remains confined to one adapter.
Retries count.
What should survive when a Node.js startup switches its image generation API?
Keep the prompt corpus fixed. Run the same invoice descriptions through OpenAI, Stability AI, Ideogram, and fal, record every attempt, and stop retrying at the same cap. Don't quietly give one model three rewrites while another gets one. That turns a model comparison into a prompt-engineering comparison. Gemini, OpenRouter, and Together can enter the same bake-off only after their available image offering passes the identical fixture requirements; the ledger should not grant any candidate a special scoring rule.
The correction matters. A first pass often divides spend by successful HTTP responses; the denominator should be outputs that pass the product's acceptance rule. One accepted image after two attempts has a different effective cost from one accepted on the first attempt — even when the posted per-image price matches. Record rejected outputs instead of deleting them, because they explain why a cheap call became an expensive accepted fixture. Apply the same retry cap, dimensions, quality tier, prompt-set version, and acceptance test to every candidate. If a model needs a rewritten prompt, create a new versioned run for all candidates. Otherwise the ledger quietly rewards extra labor, and engineering time is the scarce line item in a one-person company.
I'm not sure which model will win on your supplier layouts, and a public price page cannot resolve that. A small, versioned fixture corpus can.
Do not force every provider into one giant request type. Normalize the narrow intent your product owns: the invoice description, output dimensions, quality class, and request ID. Put model names, provider response fields, and prompt tweaks inside adapters. Store the provider, model, attempt count, accepted flag, and actual charge returned by the runtime alongside the generated fixture.
That boundary pays off when a provider wins on text fidelity but loses on retries, or when a later model changes the result. The extraction pipeline sees an image asset and fixture metadata. It doesn't need to know which generation API made it. Small boundary. Big leverage.
Batch generation is unnecessary for an interactive MVP. Add it later for scheduled fixture backfills, where waiting is acceptable and the volume justifies a separate execution path. If the product also needs captioning or prompt rewriting, pair image generation with chat completions rather than expanding the first release into a general AI orchestration system.
The TypeScript HTTP boundary
This minimal adapter uses the verified POST /v1/images/generations path over plain HTTP, so it needs no vendor SDK. It reads the base URL, model, and prompt from environment variables, supplies an idempotency key, checks every response, and backs off on HTTP 429. The response stays intact because the downstream asset step, not the transport adapter, should decide which returned image representation to persist.
import { randomUUID } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
const model = process.env.IMAGE_MODEL;
const prompt = process.env.IMAGE_PROMPT;
if (!apiKey || !baseUrl || !model || !prompt) {
throw new Error(
"Set INFRAI_API_KEY, INFRAI_BASE_URL, IMAGE_MODEL, and IMAGE_PROMPT"
);
}
const requestId = randomUUID();
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(new URL("/v1/images/generations", baseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": requestId
},
body: JSON.stringify({ model, prompt })
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
const body = await response.text();
if (!response.ok) {
throw new Error(`Image generation failed (${response.status}): ${body}`);
}
process.stdout.write(`${body}\n`);
break;
}
Before running it, select IMAGE_MODEL from the model listing rather than copying an old model ID from a blog post. Keep the request ID in your job record. A repeated write then retains the same idempotency key, so a network retry cannot create an extra generation.
Next, feed the response and its recorded charge into a dull local ledger: group attempts by provider and model, sum actual cost, count accepted outputs, and divide the first by the second. That's enough. Revenue per engineering hour improves when provider experiments produce comparable records instead of another dashboard nobody checks.
The replacement rehearsal
Before launch, replace the chosen adapter with the runner-up in a branch and run the fixed invoice corpus again. Domain code should remain untouched; only the adapter, environment configuration, and provider-specific prompt mapping may change. Compare the new ledger with the original one, inspect rejected fixtures, and confirm that stored generation records still answer which model produced each asset and how many attempts it took. This is a migration rehearsal, not a benchmark flourish. If switching requires edits in the extractor, job schema, or product workflow, the boundary is leaking and should be fixed while the codebase is still small.
Stick with a direct OpenAI, Stability AI, Ideogram, or fal integration when its model fit is decisive and the app only needs image generation. The extra gateway boundary then buys little, while direct access keeps the provider's full surface close at hand. LiteLLM is the better runner-up when self-hosting and control justify owning gateway operations. Your mileage may vary — operational appetite is a business constraint, not a benchmark field.
Infrai is not suitable when this workflow requires a dedicated moderation endpoint or an upscaler other than Lanc. Text or image moderation there needs a chat model with a json_schema fallback. Those are real scope limits, so they belong in the choice matrix rather than in a post-launch surprise.
Ship the first adapter with one fixture corpus and one acceptance rule. Re-run the matrix when size, quality, or retry behavior changes. Choose on accepted-output economics and model fit; preserve the option to switch.
That is the exit test.
Top comments (0)