DEV Community

Cover image for 7 Image Model APIs I Integrated. Here's What Each One Got Wrong.
PixQuill
PixQuill

Posted on

7 Image Model APIs I Integrated. Here's What Each One Got Wrong.

I needed a way to run the same prompt across multiple image models without rewriting everything seven times. So I integrated seven vendors behind one prompt box.

The abstraction leaked in every direction. Every API got something wrong in a way that cost me a day, then a week, then a month.

These are the specific failures, in the order they broke me.

1. Parameter names are all different, and all wrong

Nobody agrees on how to specify a resolution.

// vendor A
{ "size": "1024x1024" }

// vendor B
{ "width": 1024, "height": 1024 }

// vendor C
{ "aspect_ratio": "1:1", "resolution": "2K" }

// vendor D
{ "output_size": "1024x1024" }
Enter fullscreen mode Exit fullscreen mode

The fourth one is the killer. output_size looks right, gets a string, returns a 200 response, and the output is 768px. No error. Nothing in the logs. You find out three weeks later when you try to print something at 18x24.

Vendor D's actual fix was output_size: "1024_1024" with an underscore. There was no documentation for it. Someone's gist had it.

2. Error shapes are all different, and three of them are 200 OK

This is the single most expensive thing I hit.

// vendor E, actual failure
{ "error": { "message": "Content policy violation", "code": "safety" } }

// vendor F, same failure, HTTP 200
{ "status": "success", "data": [{ "url": null }] }

// vendor G, same failure, HTTP 200
{ "task_status": "failed", "fail_code": 1003 }
Enter fullscreen mode Exit fullscreen mode

Vendor F returns status: "success". That string is the whole reason it took me a month to notice.

Every adapter now has an explicit check that the output URL is non-null and that the pixel dimensions match what was requested. Not because the APIs document it, but because they don't.

3. Reference images have seven different syntaxes

When you attach an image and refer to it in the prompt, every vendor invented its own convention.

Vendor Syntax for "the jacket in image 1"
A the jacket in image 1
B the jacket in <image-1>
C the jacket in @Image1
D the jacket in [Image 1]
E the jacket in [0]
F the jacket in {image_0}
G the jacket in the first attached image

Vendor E is zero-indexed. Vendor G requires prose and ignores any syntax you give it.

The failure mode is silent again: you attach two references, write [Image 1] intending the first one, and get output that used the second. No warning. The image just has the wrong jacket.

I now have a translation layer with an explicit index map, and I assert that the resolved reference count matches the attachment count before the call goes out.

4. Billing units are incomparable

This took real arithmetic to get right.

Vendor Billed by Effective cost of one 2K run
A per megapixel cheapest at low res, scales badly
B per run, flat cheapest at high res
C per run, tiered by model mid
D per output image, x4 for batch depends on batch size
E per compute second varies 3x with prompt length
F per run plus output storage storage dominates at scale

Vendor E bills by compute second, which means the prompt itself is an input to the cost. A longer prompt with the same output costs more. That is not something I would have guessed, and it showed up as a cost spike that looked like a bug.

The practical fix: never show a user a single price. Show them the price for the specific model at the specific resolution they are about to pick. The price before the click is the only honest price.

5. Rate limits are per-vendor, per-model, and sometimes per-tier

// what I assumed
if (rateLimitOk) generate();

// reality
if (
  orgLimitOk &&
  keyLimitOk &&
  modelLimitOk &&
  tierLimitOk &&
  concurrentJobLimitOk
) generate();
Enter fullscreen mode Exit fullscreen mode

Vendor B's limit is per organization. Per key. Per model. Separately. So one popular model can exhaust your quota while four other models sit idle.

The only workable pattern was a token bucket per vendor-model pair with independent backoff. Retry-with-jitter on 429, and never let one vendor's 429 stall the others. That is the failure mode where one rate limit takes down your entire console.

6. Async semantics differ, and one of them lies

Four of the seven return a job you poll. Three return a URL immediately. One returns a URL immediately that 404s for about ninety seconds.

// the lying one
const { url } = await createJob(payload);
// url is real, url is ready, url is not there yet
Enter fullscreen mode Exit fullscreen mode

I now HEAD the returned URL before returning it upstream. It looks like paranoia until you ship it, and then it saves you a class of user-facing 500s.

7. The "no retouch" problem, and the tool I built to hold all this

Each of these fixes is small. The problem is that there are seven of them, per vendor, and every new model release reintroduces them.

So I put all of it behind one console. PixQuill runs more than twenty image models across seven vendors from a single prompt box. The resolution normalization, the error-shape checks, the reference-index translation, the per-model pricing, and the per-vendor rate-limit buckets all live underneath. The prompt goes in, images come out.

I did not build it to hide the differences. I built it because each vendor's difference is a legitimate reason to pick that vendor, and none of them are a reason to make the user learn that vendor's particular way of doing things.

The pattern across all seven

Every failure above was a silent success. A 200 response. A status of "success". An output that exists, is non-null, and is wrong.

Not one of these APIs raises an exception when it fails you. They return valid-looking data and let you discover the problem later, downstream, usually when someone asks why the print came out at half size.

That is the real cost of multi-model integration. Not the adapters. The verification you have to write yourself, because nobody else is going to.

If you are integrating image models right now, budget for the assertion layer. It is not optional, and it is not in any of the docs.

Top comments (0)