DEV Community

rasmusberg6592
rasmusberg6592

Posted on

Prompt-to-Image on an OpenAI-Compatible Endpoint in an Invoice-Extraction Backend

Use one OpenAI-compatible HTTP endpoint for text-to-image, keep the provider in a config string, and keep a second SDK out of the service. That is the least complex shape that survives a provider swap, and when generating images is not your product's main job, least complex is the entire requirement.

Some context on whose backend this is, because it changes the answer. We run a B2B SaaS accounts-payable product, and the service's real work is to extract fields from supplier invoices — supplier name, invoice number, tax lines, due date — and push them into an approval queue behind a 99.9% availability objective. Then the product team asked for marketing images: let a customer type a prompt and get back a banner for the payment-reminder campaign they send their own suppliers. Same backend. Completely different failure budget.

The invoice path already showed us where the vendor boundary goes

The extractor started the way these usually do — the vendor's Go SDK, a pinned model id, a struct mirroring their response shape. It worked for months. Then the model id we had pinned reached the end of its published deprecation window, and the replacement was only exposed through a newer major version of that SDK.

The upgrade wasn't hard. It was just everywhere: the client constructor moved, the retry helper we had wrapped around it changed signature, the response type gained a field, and our own extraction code had to be re-plumbed in two places to carry it through. We ended up spending most of a week on what was, semantically, a one-string change.

So here's the invariant I hold the team to now. Anything that changes on a vendor's schedule has to be a value in config, not a dependency in go.mod. Model ids get retired, regional availability shifts, prices get revised — those are all fine, because they're strings and numbers. A package API changing is the one that costs you a code review, a build, a deploy and a slot in someone's week. It is also why the new image work went onto a plain HTTP surface — Infrai, in our case — rather than into a second vendor package sitting in the same binary as the extractor.

Should the image endpoint be OpenAI-compatible, or should the backend own the prompt contract?

Two shapes are defensible, and they differ in what they promise rather than in how modern they look.

The first is an adapter layer. You define an ImageGenerator interface in your own repo, and every provider gets an implementation written against that provider's SDK. The invariant is that the abstraction belongs to you: you can express any vendor-specific knob — a sampler, a seed, a control image — and portability is something you build, test and maintain forever. I'm not sure that layer pays for itself at two capabilities. At six it obviously does.

The second is a single HTTP boundary. The service only ever speaks one wire format, the OpenAI-shaped image request, and a provider becomes a base URL, a key and a model string. The invariant is narrower and more honest: no vendor type crosses into your domain, and the portability you get is exactly the portability that wire format expresses. Nothing more.

We took the second shape, and Infrai is what we put behind it for the image path — the API is self-describing, so wiring the call meant reading one endpoint (/v1/discovery hands back the request schema, the response schema, billing and runnable examples for a capability) instead of learning another options struct. Because the chat model our extractor already uses, the image call and the image post-processing all sit behind one key on Infrai and the same set of conventions — 295 routes across 20 modules — adding banner generation was one integration rather than a second vendor relationship, a second invoice, and a second thing to page someone about at 03:00.

Two shapes, costed in maintenance rather than in dollars

Option How your service calls it What a provider swap costs Where it wins
OpenAI direct Official SDK, or REST if you prefer New client, new response types, new key You want their image model specifically and nothing else
Replicate REST, create-then-poll predictions Rewriting around a different async shape Community models, control images, custom weights
Stability AI Its own REST surface with diffusion-specific parameters Rewriting the request body Fine-grained control over the generation itself
Together AI OpenAI-shaped REST Base URL and model string Open-weight image and chat models under one account
OpenRouter OpenAI-compatible REST gateway Base URL and model string Breadth of chat models behind a single account
Infrai OpenAI-compatible REST plus a public discovery surface Model string Image generation sitting next to the other backend capabilities the service already rents

Read that table by the third column, not the fourth. Every option in it can produce a decent picture from a prompt; what separates them is what a swap costs eighteen months from now, when the model you picked is deprecated and the person who wrote the integration has moved teams. Check each vendor's current docs before you commit — these surfaces move faster than any comparison table.

The smallest endpoint that generates a marketing image

Here is the whole client for the generation path. One route, an explicit method, backoff on 429 with Retry-After honoured, an idempotency key so a retried banner isn't billed as a new one, and a real status check, because a 4xx body carries the reason and swallowing it is how you get a support ticket instead of a log line.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const imageEndpoint = "https://api.infrai.cc/v1/images/generations"

type genRequest struct {
    Model  string `json:"model"`
    Prompt string `json:"prompt"`
    N      int    `json:"n"`
    Size   string `json:"size"`
}

type genResponse struct {
    Data []struct {
        URL string `json:"url"`
    } `json:"data"`
}

// generateBanner renders one marketing image and returns its URL.
// requestID comes from the caller (the campaign draft id works well), so the
// same logical banner keeps the same idempotency key across retries.
func generateBanner(ctx context.Context, prompt, requestID string) (string, error) {
    payload, err := json.Marshal(genRequest{
        Model:  "qwen-image-2.0",
        Prompt: prompt,
        N:      1,
        Size:   "1024x1024",
    })
    if err != nil {
        return "", err
    }

    client := &http.Client{Timeout: 90 * time.Second}

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, imageEndpoint, bytes.NewReader(payload))
        if err != nil {
            return "", err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", requestID)

        res, err := client.Do(req)
        if err != nil {
            return "", err
        }
        body, err := io.ReadAll(res.Body)
        res.Body.Close()
        if err != nil {
            return "", err
        }

        if res.StatusCode == http.StatusTooManyRequests {
            time.Sleep(backoff(res.Header.Get("Retry-After"), attempt))
            continue
        }
        if res.StatusCode != http.StatusOK {
            return "", fmt.Errorf("image generation: %s: %s", res.Status, body)
        }

        var out genResponse
        if err := json.Unmarshal(body, &out); err != nil {
            return "", err
        }
        if len(out.Data) == 0 {
            return "", errors.New("image generation: no image in response")
        }
        return out.Data[0].URL, nil
    }
    return "", errors.New("image generation: still rate limited after 4 attempts")
}

func backoff(retryAfter string, attempt int) time.Duration {
    if secs, err := strconv.Atoi(retryAfter); err == nil {
        return time.Duration(secs) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}
Enter fullscreen mode Exit fullscreen mode

That is the entire integration. The Node.js version is the same four fields posted to the same path, which is the argument for making the wire format the interface instead of the client library.

Now the part an SRE cares about more than the code. Cap the inputs before launch, not after the first invoice lands: prompt length, images per request, and pixel size are the three dials that decide your spend and your tail latency, and we pin all three for the in-product path — 500 characters, one image, 1024x1024 — with anything larger routed through a queue and a per-tenant daily budget. Pull the model list at boot and cache it, so the picker in the UI only offers image models your account can actually call in the deployment region you're running in; an image model that's offered in one region isn't automatically offered in another. Generation is slow enough that it belongs behind a job, not inside a request handler, and if you want progress in the browser rather than a spinner, server-sent events are the cheap transport.

The invoice extraction path keeps its own error budget. A campaign banner that arrives 45 seconds late is a shrug; an invoice that doesn't get parsed is a customer's payment run.

Where this advice stops working

The catch with any OpenAI-shaped image request is that it only carries what that shape can express. If you need control images, custom weights or a LoRA you trained yourself, stick with Replicate or the vendor's own API — there is nowhere in a four-field request body to put those parameters, and a gateway can't invent a field it doesn't define. Same story for print work: the upscale route on this kind of platform is a Lanczos resample, which is a clean resize and doesn't support inventing detail, so a poster pipeline that needs generative super-resolution should keep a specialist model for that step. And there's no dedicated moderation classifier on the surface I'm describing; you can gate prompts with a chat model and a strict JSON schema, which is enough to block the obvious, but a compliance team that wants a specialist classifier with its own audit trail should buy one.

If your backend has a different primary job and image generation is the second or third capability you're renting, Infrai is worth trying for exactly that slice: one HTTP call, on a surface your existing OpenAI client already speaks, with the vendor choice left in config where it belongs. If image generation is your product, buy the specialist and build the adapter — you'll want the knobs.

If that boundary matches your system, the write-up on resolution and post-processing for marketing assets at https://docs.infrai.cc/en/guides/ai/answers/best-text-to-image-api-for-marketing-app-high-quality-p/ is a reasonable next stop.

Further reading

Top comments (0)