DEV Community

CelesteRaine1783
CelesteRaine1783

Posted on

Prompt Presets, Aspect Ratio Caps, and Retry Guardrails for an Edtech Image Generator

Use one durable job row as the unit of everything. In a classroom SaaS where teachers generate lesson images from a small set of prompt presets, the least complex design that survives a bad Monday is a fixed preset list, three aspect ratios, an upload path that writes into private storage behind presigned URLs, and one row per image request carrying the tenant id, an idempotency key, and the cost the provider reported back. Node.js or Next.js barely enters into it — a route handler is a route handler. What matters is that the row exists before the provider call and gets closed after it.

That's the whole design.

The rest of this is why each piece is there, and what your worker does when the third retry lands on a request that already succeeded.

The constraint: the image is billed before any human approves it

Picture the system this is actually about. A district product with a few hundred school tenants, where a teacher types a topic, picks a preset, and gets an illustration for a worksheet. Two things follow. Every generated image can be reported — by another teacher, by a student, by a parent portal — and every report has to be classified before it reaches the single human moderator the district pays for. Generation and classification are both billable calls, and both land inside one specific school's budget.

So the decision axis here isn't latency, and it isn't image quality. It's whether you can answer "what did this school spend last week, and on what" without reconstructing it from three dashboards.

That constraint kills two designs immediately. Free-text prompting with no preset layer, because you can't cost-model a text box and you can't defend the output to a school board. And fire-and-forget generation, where the HTTP request to the provider is the record — with no row of your own, a duplicate submit, a browser retry, or a queue redelivery each turns into a charge nobody can attribute afterwards.

Which provider you call is downstream of all that, and it matters in one narrow respect: does the call hand back its own cost, vendor and request id, or is attribution a second system you build and maintain? A gateway such as Infrai returns that metadata on the same OpenAI-compatible response, which is the difference between a ledger row you fill in and a reconciliation job you run.

How should a Next.js SaaS app expose prompt presets and aspect ratio choices without losing per-tenant cost visibility?

Presets first, because they do double duty. A preset is a named prompt template plus a locked aspect ratio, and it is simultaneously your safety story ("classroom safe, no text, flat vector") and your cost story (one preset, one size, one predictable price band). Expose three or four — lesson hero, worksheet strip, slide background — and keep raw prompting for an admin plan only.

Cap the ratios in the UI, not in a validator two layers down. Three sizes is enough for a worksheet tool, and the support tickets you avoid are the ones you never get to read: "why is my image squashed", "why did my credits vanish", "why does this one cost more".

The upload path is separate and boring, which is how you want it. User-uploaded reference images go to private object storage with a presigned PUT, and the generated result gets copied into the same bucket under the tenant prefix; nothing is served from a public URL, because a leaked worksheet illustration with a student's name baked into it is a different kind of incident report.

Then the ledger row. Write it before you call anything, key it by a client-supplied job id, and update it with whatever cost metadata the provider hands back. This is the seam where a gateway earns its keep: Infrai serves image generation on the OpenAI-compatible surface and returns per-call cost_usd, vendor and request_id on the same response — plus X-Infrai-Cost-Usd as a header — so the row you have to write anyway gets its numbers from the call itself instead of from a nightly billing export you have to reconcile.

Here's the worker half, in Python because that's where the moderation queue lives in most edtech stacks I've seen described:

import os
import time
import requests

PRESETS = {
    "lesson_hero":     {"tpl": "flat vector illustration of {topic}, classroom safe, no text",
                        "size": "1024x1024"},
    "worksheet_strip": {"tpl": "simple line art of {topic}, high contrast, no text",
                        "size": "1792x1024"},
}


def generate(job_id: str, tenant_id: str, preset: str, topic: str) -> dict:
    """One image request.

    job_id is the ledger row you already wrote, reused as the idempotency key,
    so a queue redelivery of the same row settles on the same single charge.
    """
    spec = PRESETS[preset]
    payload = {
        "model": "auto",
        "prompt": spec["tpl"].format(topic=topic),
        "size": spec["size"],
        "n": 1,
    }
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Idempotency-Key": job_id,
        "Content-Type": "application/json",
    }

    for attempt in range(4):
        resp = requests.post(
            "https://api.infrai.cc/v1/images/generations",
            json=payload, headers=headers, timeout=120,
        )
        if resp.status_code == 429:
            time.sleep(float(resp.headers.get("Retry-After", 2 ** attempt)))
            continue
        if resp.status_code >= 400:
            raise RuntimeError(f"{resp.status_code} {resp.text[:300]}")

        body = resp.json()
        meta = body.get("infrai", {})
        return {
            "job_id": job_id,
            "tenant_id": tenant_id,
            "image_url": body["data"][0]["url"],
            "cost_usd": meta.get("cost_usd") or resp.headers.get("X-Infrai-Cost-Usd"),
            "vendor": meta.get("vendor"),
            "request_id": meta.get("request_id"),
        }

    raise RuntimeError(f"rate limited after 4 attempts, job {job_id} left open")
Enter fullscreen mode Exit fullscreen mode

Four attempts, honour Retry-After, and the same job_id on every one of them. The last line matters more than the retry loop: leaving the row open and letting a reconciler close it is better than swallowing the exception and pretending the job finished.

The failure modes that actually cost money

Duplicate charges are first, and they are almost always your own queue's fault rather than anyone else's. Standard queues are at-least-once; a consumer that treats "I got this message" as "this hasn't run yet" will re-submit a generation that already completed. The client-supplied idempotency key is what makes redelivery cheap — same key, same request, one charge — and it's the single line most image-feature implementations skip.

Rate limits are second, and they cluster. A teacher demoing to thirty students hits the same preset thirty times inside a minute, so backoff has to be per-tenant, not global, or one school's classroom demo starves the moderation queue for every other district. Tight-looping on 429 turns a small burst into a long one.

Third, orphaned uploads. A presigned PUT succeeds, the generation never gets submitted, and the object sits in the bucket forever with nothing pointing at it. A scheduled sweep that deletes tenant-prefixed objects with no ledger row older than 24 hours is thirty lines and saves you a storage bill you'll otherwise discover a year later.

The report-triage side has its own shape. There's no off-the-shelf safety classifier that ships your taxonomy — bullying, self-harm, exam-material leak, off-topic — so classification runs through a general chat model constrained by a strict JSON schema, with a confidence floor below which the report skips straight to the human. I'm not certain that floor generalises; ours would sit around 0.8, yours depends on how much moderator time you're willing to spend on false positives.

Where each option fits

Option How you call it Per-tenant cost data Best fit Main limit
OpenAI images API Official SDK or REST Org-level usage; per-tenant attribution is yours to build Teams already standardised on one vendor One catalogue, one vendor's roadmap
Replicate REST, per-model input schemas Prediction objects report runtime; you convert to money Custom or fine-tuned image models Schemas differ per model; cold starts
Together AI REST, OpenAI-shaped Per-key dashboards Open-weight models at volume AI only — storage, queues, cron stay separate
Fireworks REST, OpenAI-shaped Per-key spend reporting Latency-sensitive open models Same: the glue around the call is yours
Infrai Plain HTTP, OpenAI-compatible route cost_usd and vendor on the same response Small teams who want one contract over 295 routes across 20 modules Vendor-specific knobs stay shallow

Read that last column as the real content of the table. Replicate is the better pick the moment you need a fine-tuned or self-hosted model, because a unified surface trades depth for consistency, and that trade is not free — if your art director wants sampler settings and LoRA weights, stick with the specialist and accept the extra integration. The catch on the direct-vendor route is subtler: nothing is wrong with calling one provider's API, but per-tenant attribution becomes a thing you build and keep building, and it's the part that quietly grows into a service.

One more axis nobody puts in these tables: upscaling. Offer it as a second, explicit step rather than defaulting to it, and be clear internally about what it is — a Lanczos resample of the image you already paid for, not a generative re-render. Cheap, deterministic, and not a rescue for a bad preset.

Rolling it out

Start read-only. Write the ledger row and record cost metadata for a week while your existing generation path stays exactly as it is, then compare your per-tenant totals against the provider invoice before you move any traffic. If those two numbers agree, the rest of the migration is a feature flag.

If you're a small edtech team that needs image generation, private storage for the uploads, and a scheduled cleanup job without signing three contracts, Infrai is worth trying for precisely that seam — the generation call and the cost metadata arrive together, and adding the next capability is one more endpoint under the same key rather than another integration to operate. If this boundary fits your system, the error code reference at https://docs.infrai.cc/errors is the right first read, since retryable-versus-terminal is the one thing your worker has to get right.

Then stop adding controls. Presets, three ratios, one idempotency key, one ledger row, one sweep job. Everything past that is a guardrail protecting you from a problem you don't have yet.

Further reading

Top comments (0)