Skip to content

Media doors (OpenAI-format images & videos)

In addition to the native async endpoints, the gateway exposes model-first media doors that speak OpenAI’s API shapes, so the official OpenAI SDK works for image and video generation too:

POST https://api.sociaro.com/v1/images/generations # OpenAI Images (synchronous)
POST https://api.sociaro.com/v1/images # async image job (create)
GET https://api.sociaro.com/v1/images/{image_id} # poll an async image job
POST https://api.sociaro.com/v1/videos # OpenAI Videos format
GET https://api.sociaro.com/v1/videos/{video_id} # poll a video job

As with the chat/messages doors, address models by their catalog slug author/model (see GET /v1/models). Billing, budgets, entitlements and the async ledger behave exactly as if you had called the provider’s native prefix — the doors only translate request/response shapes, never billing.

One door, one model-kind. Each /v1/* door serves exactly one catalog kind. The images door (POST /v1/images/generations) serves only image slugs; the video door (POST /v1/videos) serves only video slugs. Address a slug of the wrong kind and the door returns a 400 whose message names the slug’s actual kind and points you to GET /v1/models — it does not silently route the request. The same one-kind-per-door rule governs the synchronous embeddings, reranks & speech doors (embedding, rerank, audio). The full set of kinds is llm | image | video | embedding | 3d | rerank | audio; read a slug’s kind from GET /v1/models.

The images door accepts the OpenAI Images request shape and serves it through two internal branches, picked automatically from the slug’s provider. Both take the same input fields and return an OpenAI-shaped object with data[].url — you address them identically. (The pass-through branch also forwards the provider’s usage block; the translation branch returns {created, data} without a usage field — billing is metered server-side either way.)

from openai import OpenAI
client = OpenAI(base_url="https://api.sociaro.com/v1", api_key="gw_live_...")
# Seedream (pass-through branch) — already OpenAI-shaped upstream
img = client.images.generate(model="bytedance/seedream-5-0", prompt="a sunset over mountains")
# Qwen-Image (translation branch) — same call, different upstream shape
img = client.images.generate(model="alibaba/qwen-image-2.0", prompt="a sunset over mountains")

Input fields. model (catalog slug, required) and prompt (required) are the envelope. n (number of images, default 1) and size ("WxH", e.g. "1024x1024") are interpreted by the door. Every other fieldresponse_format, quality, negative_prompt, seed, watermark, prompt_extend, … — is overlay-passthrough: forwarded to the provider verbatim, never validated by the gateway (transparent-first).

The two branches:

  • Pass-through (Seedream). The upstream images endpoint is already OpenAI-shaped, so the gateway rewrites only model and forwards the body byte-for-byte — including size presets like "2K" and any provider-specific fields. The response is the upstream’s own data[].url + usage.
  • Translation (Qwen-Image via DashScope). DashScope’s image API is not OpenAI-shaped, so the door translates the request into DashScope’s native multimodal-generation call and translates the response back. size is rewritten to DashScope form ("1024x1024""1024*1024"); overlay fields land under the provider’s parameters. n > 1 is supported and returns several data[].url entries. This branch returns url only, never b64_json — DashScope serves results as short-lived signed links (~24 h), so download promptly. (response_format: "b64_json" is forwarded but has no effect here.)

Notes:

  • Model-kind rule. Only catalog slugs of kind image are served here. A slug of any other kind (chat/llm, video, embedding, …) returns a 400: “model author/x has kind video; this endpoint serves only image models”, pointing you at the right door. The door never silently reroutes a wrong-kind slug.

  • Image editing works through the door — pass an image. For DashScope edit models (alibaba/qwen-image-edit-max, alibaba/qwen-image-edit-plus) include your input image(s) as an image field (a URL or data-URI string, or an array), or images (an array), alongside prompt; the door forwards them into the provider’s edit input. POST /v1/images/edits and multipart uploads remain unsupported (the doors are JSON-only) — pass the image as a URL/data-URI in the JSON body instead:

    client.images.generate(
    model="alibaba/qwen-image-edit-max",
    prompt="make it night",
    extra_body={"image": "https://example.com/in.png"}, # URL or data-URI; or "images": [...]
    )

    If you omit image, an edit model receives a text-only body and the provider rejects it. (The Seedream splice/passthrough branch on byteplus likewise forwards a JSON image field byte-for-byte, so image-to-image works there too when the model supports it.)

  • Asynchronous image slugs. Some image slugs are generated upstream as a job (create + poll), not a single synchronous call — google/nano-banana-2, google/nano-banana-pro, alibaba/qwen-image-max, alibaba/wan2.6-t2i. You can reach them either way, and clients do not need to branch on a 400 for them:

    • On this synchronous door (POST /v1/images/generations) the gateway runs the async job synchronously behind a sync facade: it creates the job and polls internally, blocking for the duration of generation. Three outcomes:
      • 200 with a normal OpenAI Images object — the generation finished within the facade’s ~45-second deadline. This also covers a result that lands right at the deadline: a finished image already in hand is returned as a 200, never discarded in favour of a 202. In the rare withheld-result case (an internal aggregator returned the image somewhere the gateway cannot safely serve or proxy — see the async-door note below), the facade returns the same thing a GET /v1/images/{id} poll of the finished job would: a 200 whose data is missing the withheld entries (empty when all are withheld). The job completed and is billed; the deliverable is withheld by policy — it is not reported as a 502 upstream failure.
      • 202 Accepted with an image task object — {"id":"img_...","object":"image.generation","status":"queued"|"in_progress",...} — only when the job is genuinely still running at the deadline (or an internal poll hiccuped): the request defers to the async door, and you retrieve the job via GET /v1/images/{id}. A 202 is a deferral, not a failure — the job keeps running and is billed once, whichever way you collect it.
      • 503 when the gateway’s sync-facade concurrency is saturated. No job has been created at that point — retry shortly, or use the async door (POST /v1/images) directly.
    • On the async image doors (POST /v1/images + GET /v1/images/{id}) you create the job and poll for the result yourself — the right choice for long-running generations where you do not want to hold a request open.

    (GET /v1/models lists every slug.)

Async images: POST /v1/images + GET /v1/images/{id}

Section titled “Async images: POST /v1/images + GET /v1/images/{id}”

Some image slugs are generated by an asynchronous upstream — you create a job and poll for the result, the same lifecycle as the video door. They are served by a separate pair of doors (the OpenAI SDK’s images.generate() targets the synchronous door above and is not used here — call these over plain HTTP):

Terminal window
# 1. create the job
curl https://api.sociaro.com/v1/images -H "Authorization: Bearer gw_live_..." \
-d '{"model":"google/nano-banana-2","prompt":"a red apple on a white table","aspect_ratio":"1:1","resolution":"2K"}'
# → {"id":"img_...","object":"image.generation","status":"queued","created_at":...}
# 2. poll until terminal
curl https://api.sociaro.com/v1/images/img_... -H "Authorization: Bearer gw_live_..."
# → status: "queued" → "in_progress" → "completed" | "failed"
# on "completed":
# {"id":"img_...","object":"image.generation","status":"completed",
# "data":[{"url":"https://api.sociaro.com/v1/images/content/<token>"}]}

While the job is running the poll returns HTTP 200 with status: "queued" or "in_progress" — a 5xx from this endpoint is a real gateway or upstream fault, never a normal “still generating” signal, so it is safe to fail fast on 5xx.

Today this serves google/nano-banana-2, google/nano-banana-pro, alibaba/qwen-image-max and alibaba/wan2.6-t2i.

Input fields. model (catalog slug) and prompt are the envelope. Provider-native fields — aspect_ratio, resolution (1K/2K/4K), web_search, … — are overlay-passthrough, forwarded verbatim (transparent-first). OpenAI’s size/n are not translated here (the upstream uses its own aspect_ratio/resolution), so pass the native fields. How many images come back is decided by the model, not by a request field.

Result URLs are gateway-proxied. On completed, each data[].url points at GET /v1/images/content/{token} on the gateway, which streams the image bytes. The URL is a self-contained capability — fetch it with a plain GET (no API key needed). The result links are returned on every poll of a finished job, not just the first one that observes completed: at finalization the gateway persists the result (encrypted) in its ledger, and each later poll mints a fresh content-proxy link. Each link is time-limited (~6h from that poll), so re-poll the job id to get a fresh one rather than persisting the URL. Two caveats: the underlying upstream asset is short-lived — if the provider has already expired it, the content URL returns a sanitized 502 "image is no longer available" (re-polling can’t resurrect it; generate again); and jobs finalized before this feature was deployed have no persisted result — their polls return completed without data[]. Results persisted before the 2026-07 crypto hardening (context-bound encryption) also degrade to completed without data[] — generate again if you still need the asset. In the rare case where an internal aggregator returns a result hosted somewhere the gateway cannot safely serve or proxy, that URL is withheld rather than exposed, so data[] may be shorter (or empty) than the number of images the model produced.

Image ids (img_…) are opaque: they encode no provider/routing details and are not interchangeable with video ids (video_…).

Errors & kind rule. As with the other doors, a non-image slug returns a 400 naming its actual kind; upstream/provider failures come back in the OpenAI error envelope without leaking upstream internals. status: "failed" (HTTP 200) carries an error object describing what went wrong.

Videos: POST /v1/videos + GET /v1/videos/{id}

Section titled “Videos: POST /v1/videos + GET /v1/videos/{id}”

The video door accepts OpenAI’s Videos API request and translates it to the provider’s native create/poll calls (e.g. Alibaba Wan via DashScope, ByteDance Seedance):

from openai import OpenAI
client = OpenAI(base_url="https://api.sociaro.com/v1", api_key="gw_live_...")
video = client.videos.create(
model="alibaba/wan2.6-t2v",
prompt="Ocean waves at sunset",
seconds="5", # whole seconds, as a string (OpenAI convention)
size="1280x720", # WxH; the short side picks the provider bucket: 480/720/1080
)
# → {"id": "video_...", "object": "video", "status": "queued",
# "model": "alibaba/wan2.6-t2v", "seconds": "5", "size": "1280x720", ...}
video = client.videos.retrieve(video.id)
# → status: "queued" → "in_progress" → "completed" | "failed"

Model-kind rule. Only catalog slugs of kind video are served here. A slug of any other kind (e.g. an image or chat/llm slug) returns a 400: “model author/x has kind image; this endpoint serves only video models”. The door also returns a 400 when the resolved provider has no OpenAI-compatible video path (“model author/x is not served by this endpoint; use the provider’s native prefix or the sociaro SDK”) — today the video door serves Alibaba Wan (via DashScope) and ByteDance Seedance. The …-spark Wan variants (alibaba/wan2.6-t2v-spark, alibaba/wan2.6-i2v-spark) are not currently available — their aggregator video route is offline, so they are delisted from the catalog and GET /v1/models won’t show them. Read a slug’s kind and author from GET /v1/models.

Passing provider-native fields (overlay-passthrough)

Section titled “Passing provider-native fields (overlay-passthrough)”

The door translates only the OpenAI envelopemodel (→ catalog slug), prompt, seconds and size (→ the provider’s resolution bucket). Any other top-level field in the request body is copied into the provider’s native create call as-is, so you can drive provider-specific knobs through the same door. Typical pass-through fields: ratio, seed, watermark, generate_audio, negative_prompt, audio_url, shot_type.

Terminal window
curl https://api.sociaro.com/v1/videos \
-H "Authorization: Bearer gw_live_..." \
-H "Content-Type: application/json" \
-d '{
"model": "bytedance/seedance-1-5-pro",
"prompt": "Ocean waves at sunset",
"seconds": "5",
"size": "1280x720",
"ratio": "16:9",
"seed": 42,
"watermark": false,
"generate_audio": true
}'

Here model/prompt/seconds/size are the envelope; ratio, seed, watermark and generate_audio ride through untouched into the provider’s body.

The gateway does not validate or interpret pass-through fields (transparent-first). Whatever you send is forwarded verbatim — the provider decides whether to accept it, ignore it, or reject the request. A typo in a provider field surfaces as a provider error, not a gateway one. For full OpenAI Videos semantics and validated parameters, use the SDK or the provider’s native prefix, which exposes the provider’s create call directly. Basic image-to-video is supported via input_reference.

The door returns an OpenAI video object with statuses queued | in_progress | completed | failed. Deviations from OpenAI, by design:

  • video_url (extension). OpenAI serves content via GET /v1/videos/{id}/content, which the gateway does not implement. Instead, every poll that sees completed includes a video_url field: at finalization the gateway persists the result (encrypted) in its ledger, so later polls — served from the ledger snapshot — still carry it. For models served through an internal aggregator the video_url is a gateway content-proxyhttps://api.sociaro.com/v1/videos/content/{token} — that streams the bytes (so the upstream host is never exposed); fetch it like any URL. Each content-proxy link is time-limited (~6h from the poll that minted it) — re-poll the job id for a fresh link rather than persisting the URL. Models served directly by a vendor return that vendor’s CDN URL unchanged (its expiry is the vendor’s). In the rare case where an internal aggregator returns a result hosted somewhere the gateway cannot safely serve or proxy, the URL is withheld rather than exposed — the job reports completed without video_url. The upstream asset itself is short-lived: once the provider expires it, the content URL returns a sanitized 502 "asset is no longer available". Jobs finalized before this feature was deployed have no persisted result — their polls return completed without video_url; the same applies to results persisted before the 2026-07 crypto hardening (context-bound encryption).
  • model is omitted on polls (a deviation from the OpenAI spec). Providers don’t echo the model in poll responses, and the gateway never reveals provider-native ids. The create response echoes your catalog slug — keep it from your create call if you need it later.
  • progress is omitted — the supported providers don’t report it.
  • seconds/size are echoed on create, omitted on polls.
  • Failed jobs carry error: {code, message} when the provider reports a reason; jobs cancelled or expired server-side appear as failed with error.code "cancelled" or "expired_<reason>" (e.g. expired_credential_rotated).

The returned id (video_...) is stable and self-contained — it encodes the inference route and the provider task id. Tenant isolation still applies: polling another organization’s video returns 404.

If you poll immediately after create, a 404 can transiently occur while the job is being committed to the ledger — retry on 404 for ~1 second after create.

For image-to-video, pass input_reference as a string URL (or data: URI) of the first frame. The door translates it into each provider’s native first-frame field — DashScope input.img_url (wan2.6/2.5) or input.media[{type:"first_frame"}] (wan2.7), and the Seedance content[].image_url with role:"first_frame" shape; the rest of the request is the same envelope as text-to-video.

client.videos.create(
model="alibaba/wan2.6-i2v", # an i2v-capable model
prompt="animate this still into gentle waves",
input_reference="https://example.com/first-frame.png",
seconds="5",
)

Notes:

  • input_reference must be a URL string, not a file upload — object/file forms ({"file_id": …}) return 400. Pass a publicly reachable URL or a data: URI; the gateway forwards it verbatim (transparent-first).
  • Only first-frame is translated by the door in v1. First+last-frame and multi-reference inputs are provider-specific — use the native prefix or SDK.
  • Sending input_reference to a model that does not support image-to-video returns 400 (e.g. a …-t2v model). Use an i2v-capable slug.
  • Other top-level fields are not rejected: anything outside the envelope is forwarded to the provider as-is (see Passing provider-native fields).
  • GET /v1/videos (list), DELETE /v1/videos/{id} and GET /v1/videos/{id}/content are not implemented; use GET /gw/async to list your jobs (cancellation support depends on the upstream model — DashScope Wan, for instance, has no native cancel).
  • seconds and size must be supported by the underlying model (the provider validates the translated values; the door validates shape only — the short side of size must be 480, 720 or 1080).
  • Video-door responses are buffered for translation with a 1 MiB cap; a streaming or oversized upstream response yields a 502. (Normal create/poll bodies are small JSON — this only guards against broken upstreams.)

The media doors return errors in the OpenAI envelope ({"error": {"message", "type", "param", "code"}}), translated from the gateway error contract by HTTP status:

HTTPerror.typetypical error.code
400 / 404 / 413invalid_request_errorbad_request, not_found, …
401authentication_errorunauthorized
402 (budget)insufficient_quotabudget_exceeded
403request_forbiddenforbidden
410 (credential rotated)invalid_request_errorcredential_rotated
429rate_limit_error(Retry-After preserved)
5xxserver_error

The original status code and message are always preserved; the gateway’s own error type (or the provider’s error code) is carried in error.code. The images door is pass-through: provider errors are forwarded as-is.

Identical to the native prefixes — see async jobs: a provisional cost is charged at create (against your budget, atomically) and finalized exactly once when the job reaches a terminal state, whether you poll through the door, through the native prefix, or not at all (the gateway’s background poller finalizes abandoned jobs). Usage rows and the ledger record the inference provider and provider-native model — the same axis as native traffic.