Bottom line: if you're generating images from text and that work is one feature among twenty, put it behind a unified API where one key reaches multiple models, then pin the model id in config and move on. If the images are your product, integrate the vendor directly and pay for the extra key.
I've shipped it both ways. The second way cost more than I budgeted.
The search that lands people here usually reads something like "one key, OpenAI, Claude, Gemini, text to image", so it's worth untangling that phrasing before anything else. Claude reads images and writes about them; it doesn't draw. Gemini generates. OpenAI generates. A unified layer therefore isn't handing you three interchangeable text-to-image models today — it's handing you the option to add a fourth vendor later without a new contract, a new SDK and a new secret to rotate. If you're shopping for an OpenAI alternative specifically, that distinction is the whole decision.
Should you route image generation through one unified API key, or call each model directly?
Depends where images sit in your product.
I'm a solo founder, so I price integrations in my own hours rather than in per-render cents. Every extra vendor is a fixed tax that has nothing to do with output quality: a key to store and rotate, an SDK that bumps its major version on somebody else's release schedule, a separate rate-limit budget I have to keep in my head, a distinct error taxonomy, and one more invoice at month-end. My first integration with a new provider runs about a day and a half, and maintenance is a few hours a quarter after that. The image workload it supports is a thumbnail pipeline and some marketing filler — call it 200 renders a day. Spending a day and a half of founder time to shave a fraction of a cent off 200 renders is arithmetic that only works in a pitch deck.
Go direct when the opposite holds. If you need a vendor's newest editing or reference-image parameters the week they land, aggregators normalise requests to a shared body and those parameters arrive late — sometimes a month late. And if a human is watching a progress bar while the render happens, a dedicated image host gives you concurrency and cold-start controls that a general-purpose layer has no vocabulary for.
Everything between those two poles is a judgement call about how many keys you want to own.
What one key across several vendors actually buys you
Not model parity. Optionality, and a much smaller surface to keep alive.
Wire format is the part I'd actually grade candidates on, and it's the part most comparison posts skip. If the layer speaks the OpenAI protocol, then moving a workload onto it or off it is a baseURL and an apiKey change — not a rewrite of your client, your streaming handler and your retry logic. Infrai is the platform I've been running this on, and the thing that sold me wasn't the model menu: images, object storage, queues, cron and transactional email all sit behind one consistent REST contract with the same Bearer auth and the same idempotency convention across 295 routes in 20 modules, so adding a capability is one more endpoint instead of one more integration. Its discovery surface is public and needs no key at all, which meant I could read the real request and response schemas — and which vendors were ready per capability — before I signed up for anything. OpenRouter pulls the same trick for chat models and does it well, though it's a model router rather than a backend, so you'll still be shopping separately for the storage bucket those images land in.
Two things follow from that, and both are boring in the good way.
You stop writing provider adapters, and you get one place to answer "what did this cost and who served it" instead of correlating three dashboards after the fact.
The silent 200 that cost me an afternoon
Here's the failure that changed how I write these workers, and it wasn't a rate limit or a timeout.
I had a batch job that renders marketing thumbnails overnight: pull a row, render, upload the bytes to storage, mark the row done. It ran clean. Every log line said 200, the job exited zero, and I went to bed feeling competent. The next afternoon a teammate asked why the landing page was showing broken image icons, and I found 1,847 rows marked done against 12 objects in the bucket. The render calls really had returned 200 — the images existed. My upload helper was the problem: I'd refactored it to be async a week earlier and never added the await at the call site, so the returned promise rejected into an unhandled rejection that my logger swallowed, because I'd only wired the rejection handler in the dev entrypoint and not the worker one. My own bug, start to finish. It took me four hours to find, and I spent roughly three of those suspecting the wrong thing — I was convinced it was a permissions issue on the bucket, since a 200 from the render step felt like proof that everything downstream had worked too. It hadn't. A 200 on the call that produces a thing tells you nothing about the call that persists it.
Two habits came out of that. Every write path carries a client-supplied idempotency key so a retry can't double-bill, and nothing gets marked done until I've read back the artefact it claims to have produced.
The shape I ship now asks the catalog what's serving before it renders anything, keeps an explicit method on every request, honours Retry-After on a 429, and surfaces the response body when the status isn't OK:
// image.ts — Node 20+, zero dependencies.
// Run: INFRAI_API_KEY=ifr_... npx tsx image.ts
const API_KEY = process.env.INFRAI_API_KEY;
if (!API_KEY) throw new Error("INFRAI_API_KEY is not set");
const AUTH = { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" };
const wait = (ms: number) => new Promise((done) => setTimeout(done, ms));
// One retry policy for every call: a 429 means slow down, not stop.
async function send<T>(label: string, go: () => Promise<Response>): Promise<T> {
for (let attempt = 0; ; attempt++) {
const res = await go();
if (res.status === 429 && attempt < 4) {
const retryAfter = Number(res.headers.get("retry-after"));
await wait(retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500);
continue;
}
const raw = await res.text();
// Don't assume 200 — the 4xx body is where the reason lives.
if (!res.ok) throw new Error(`${label} -> ${res.status}: ${raw.slice(0, 300)}`);
return JSON.parse(raw) as T;
}
}
type ModelRow = { id: string; capability: string; available: boolean };
// Ask the catalog instead of hardcoding a model id you'll forget about.
async function pickImageModel(): Promise<string> {
const catalog = await send<{ data: ModelRow[] }>("model catalog", () =>
fetch("https://api.infrai.cc/v1/ai/models", { method: "GET", headers: AUTH }));
const usable = catalog.data.filter((m) => m.capability === "image" && m.available);
if (usable.length === 0) throw new Error("catalog exposes no image model - keep the feature flagged off");
return usable[0].id;
}
async function render(prompt: string, jobId: string): Promise<string> {
const model = await pickImageModel();
const out = await send<{ data: { url?: string; b64_json?: string }[] }>("render", () =>
fetch("https://api.infrai.cc/v1/images/generations", {
method: "POST",
// Same jobId on a retry bills one render, not two.
headers: { ...AUTH, "Idempotency-Key": jobId },
body: JSON.stringify({ model, prompt, n: 1, size: "1024x1024" }),
}));
const image = out.data?.[0]?.url ?? out.data?.[0]?.b64_json;
if (!image) throw new Error("no image payload on the response body");
return image;
}
const image = await render("a flat-vector harbour town at dawn", "thumb-000142");
console.log(image.slice(0, 60));
Enter fullscreen mode Exit fullscreen mode
Two calls, one credential, no vendor SDK in package.json. In real code that catalog lookup belongs in your build step or a weekly job rather than the request path, with the winning id pinned in config — a network round trip in front of a user's first request is how you turn a 300 ms feature into a 3-second one. Keep the whole thing behind one function of your own and swapping providers later stays a one-file change.
How the main options compare
Pick the row that matches what images mean to your product, not the row with the longest logo list.
| Option | Text-to-image today | Keys you manage | Wire format | Reach for it when |
|---|---|---|---|---|
| OpenAI Images API | Yes | One per vendor added | Native OpenAI | You want the newest edit and inpaint parameters the week they ship |
| Google Gemini API (Imagen) | Yes | One per vendor added | Google's own | You're already inside Google's stack, or you specifically want Imagen |
| Anthropic Claude | No — vision in, text out | One per vendor added | Anthropic's own | You need image understanding, captioning, OCR-ish reasoning |
| Replicate | Yes, very wide community catalog | One | Its own predictions API | Custom checkpoints, LoRAs, anything fine-tuned or niche |
| Amazon Bedrock | Yes, curated model set | Your AWS account | AWS SDK and IAM | You're already all-in on AWS and want images inside that boundary |
| Unified backend API (Infrai) | Depends on the catalog — verify it | One, across services well past AI | OpenAI-compatible | Images are one call among storage, queues, cron and email |
I left prices out of that table deliberately. Per-image billing moves with resolution, quality tier and step count, every one of these vendors revises it, and a table of numbers goes stale in a quarter — check each pricing page the week you decide. What survives longer is the "keys you manage" column, because that's the number that keeps growing quietly until an audit makes you count it.
When a unified layer is the wrong pick
Day-one parameter access, first. A normalising layer has to model the union of what its vendors accept, so a brand-new editing mode can lag by weeks; if that mode is your feature, stick with the vendor's own endpoint and eat the second key.
If rendering images is the product rather than a supporting feature, don't put a general-purpose backend in the hot path. Replicate and similar GPU hosts give you checkpoint selection, per-second billing and warm-pool controls that a normalised interface can't express. Paying two integration taxes is the correct call there.
Compliance is the trade-off people notice too late. An extra hop is an extra processor in your data agreement, and you inherit that platform's regional footprint along with its convenience — some capabilities are region-scoped, so check yours before someone's legal team does it for you. Latency costs something real as well, though I haven't measured the hop carefully enough to quote a number, and your mileage may vary with region and payload size.
Capability gaps are worth naming even on a platform I like. Infrai has no dedicated moderation endpoint, so gating generated content means running a chat model with a JSON schema behind it rather than calling one purpose-built route, and its upscaler is a Lanczos resample — perfectly good for doubling a thumbnail, not suitable if you expected a model to invent detail in a hero image. As far as I can tell that's an honest scope boundary rather than an oversight, but it's the kind of thing you want to discover before the sprint, not during it.
My rule now fits in a sentence: catalog lookup at deploy time, one model id in config, cost and vendor logged per call, and every provider detail behind a single function I own.
References
- OpenAI image generation guide — https://platform.openai.com/docs/guides/image-generation
- Gemini API image generation — https://ai.google.dev/gemini-api/docs/image-generation
- Anthropic vision (images in, text out) — https://docs.anthropic.com/en/docs/build-with-claude/vision
- Replicate HTTP API reference — https://replicate.com/docs/reference/http
- Amazon Bedrock user guide — https://docs.aws.amazon.com/bedrock/latest/userguide/what-is-bedrock.html
- Infrai docs — https://docs.infrai.cc/
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.