/v1/* API. Point the official OpenAI SDK (Python, Node, or plain cURL) at our base URL, use your deAPI token as the API key, and call our models with the request and response shapes you already know.
This is a translation layer over the deAPI inference network — not a rebrand of OpenAI’s models. Everything below documents exactly where the two contracts match and where they deliberately differ. It is the same approach used by Groq, Together AI, and Fireworks AI.
Base URL: https://oai.deapi.ai/v1
Quick start
- Python
- Node.js / TypeScript
- cURL
Authentication
Generate an API token in the deAPI panel. It looks likedpn-sk-<id>|<token> and goes into the standard OpenAI Authorization header:
dpn-sk- prefix is required by this gateway and applies only here — the native API at https://api.deapi.ai takes the raw key without it. Note the literal | in the value: in a shell it must be quoted.
Every request validates the token against your deAPI account, so a revoked token stops working immediately.
Calling from a browser
/v1/* sends CORS headers, so openai-node works in the browser with dangerouslyAllowBrowser: true, as does a plain fetch from any origin. Preflight is answered without authentication (204, Access-Control-Max-Age: 86400), every header the SDK sends is accepted, and these are exposed to scripts:
Timeouts
Image, audio and edit jobs run on a distributed GPU network and the gateway holds the HTTP connection until the job finishes. A request may legitimately take up to 10 minutes, which is exactly where the OpenAI SDK’s own 600 s default sits. For heavy work (image edits, long transcriptions,n > 1) raise it:
- Python
- Node.js / TypeScript
Supported endpoints
Paths that are recognised but not implemented.
POST /v1/videos/edits, POST /v1/videos/extensions, POST /v1/videos/characters, GET /v1/videos/characters/{id}, GET /v1/videos (list), DELETE /v1/videos/{id}, POST /v1/videos/{id}/remix.These exist in the OpenAI spec but have no counterpart in our backend. They answer 404 with code: "endpoint_not_implemented" — the code, not the status, is what tells you the path was understood and there is no typo on your side. Branch on code; a plain 404 from a real endpoint carries a different one (model_not_found, video_not_found).They used to answer 501, and the reason we changed it is worth knowing. Both official SDKs retry every response with status >= 500. A mistyped URL therefore cost you four requests with backoff and surfaced as InternalServerError — your monitoring reported an outage that was not happening. 404 maps to NotFoundError and is not retried, so a typo now fails once, immediately, with a message that names the path.Two codes share the 404, and the difference is only diagnostic: an unrecognised path answers endpoint_not_implemented, while extra segments below a route we do own (/v1/videos/foo/bar) answer endpoint_not_found. Treat them the same way — you have the wrong URL.Calling a real endpoint with the wrong verb is different again: 405 method_not_allowed, and the message names the verb to use.Models
GET /v1/models returns the models available to your account. The catalog is plan-dependent, so two accounts can legitimately see different lists.
GET /v1/models/{model} (client.models.retrieve(...)) returns a single entry in the same shape. Unknown, hidden, or out-of-plan models answer 404 model_not_found.
The response shape is standard: {id, object, created, owned_by}, where owned_by is always depin. created is the date the model first became available on this gateway, not the date the model itself was built — the inference backend publishes no such date, and this is the only one we can state truthfully. Cohorts that arrived together share a timestamp, so it does not order models within a release.
Entries used to report 0 unless the model was also listed on OpenRouter. Every model now carries a real timestamp.
Catalog reads are cached for 5 minutes, so a model added or removed on the deAPI side appears (or disappears) within that window.
When a model name is refused
A name your account cannot use fails in one of three ways, and the code says which:
Build your integration against
GET /v1/models. It is already filtered to your account, so every name in it resolves on the endpoint it belongs to — which makes it the list to check first when a model name is refused.
Why there are no OpenAI model aliases
We publish native model IDs only. Aliasingdall-e-3 onto Flux1schnell would promise an OpenAI parameter contract — its sizes, qualities and defaults — that our models do not implement. A wrong picture is worse than an explicit model name, so model="dall-e-3" returns 404 model_not_found — on POST endpoints as well as on GET /v1/models/{model}.
Migrating from OpenAI
Three parameters change. Everything else — request format, response schema, error envelope — follows the OpenAI specification.Embeddings
400 input_too_large. The batch is all-or-nothing: the first failure cancels the rest, so you receive either every vector or a single error — never a partially filled data[].
usage.prompt_tokens is always 0. The backend does not report token counts and inventing one would be worse than reporting zero. total_tokens mirrors it.
Image generation
revised_prompt is part of the OpenAI shape but is never populated here, so the key is absent — none of our models rewrites your prompt.
quality="auto" is the SDK default and means “pick the best setting for this model” — identical to omitting the field. Quality presets are defined per model because distilled models (Flux Schnell: 2–8 steps) and full models operate in disjoint ranges. A generic “high = 50 steps” rule would silently produce garbage on distilled models.
Rejected fields
Accepted with no change in behaviour:
stream=false, partial_images=0, background="opaque" | "auto", output_format="png" — these describe what we already do. moderation and style are accepted and explicitly declared as not affecting the image.
Output is PNG, RGB, no alpha — measured on the bytes, not assumed. With
response_format="url" you get a pre-signed results.deapi.ai link valid for 5 hours (X-Amz-Expires ≈ 18000, counted from the X-Amz-Date in the same query string). There is no expiry field in the JSON body, and unlike a video there is no route that re-signs the link — once it lapses the image is gone from your side. Download promptly, or request b64_json and skip the question.Image editing
Both content types from the spec are supported.
Both are instruction-following editors, not inpainting models. The gateway itself accepts a multipart body up to 50 MiB, but the platform caps a single image at 10 MB — see Limits & Quotas → File Uploads.
multipart/form-data
image (one or more files), prompt, model, n, size, quality, response_format, user. File type is detected from the bytes, not from the declared part header.
application/json
data:URLs are decoded locally, with no outbound traffic.- Plain URLs are fetched by the gateway: 10 MB limit, publicly reachable addresses only.
file_id→400 file_id_not_supported(there is no Files API here).- A bare string instead of an object →
400. We do not reinterpret it.
stream, output_format, mask, input_fidelity, …) are settled before any address is fetched. The two per-model limits — size and n — are checked after, so an oversized n on an otherwise valid request will still have cost you one outbound fetch of the URL you supplied. A data: URL is decoded locally and never leaves the gateway either way.
Response
Identical envelope to/v1/images/generations — the two endpoints share a shape on the wire.
response_format="b64_json" each element carries b64_json instead of url. With n=2 you get two elements, one per job.
Rejected fields on edits
Everything from image generation, plus:
One deviation in your favour: we accept
quality="hd" here, even though the spec restricts hd to /images/generations. The alias is defined per model and produces a sensible preset, so rejecting it would break working integrations.
Text-to-speech
Returns raw audio bytes, not JSON.
The body is the audio file itself, not JSON:
1.0 on Chatterbox and the whole Qwen3 family — any other value returns 400 speed_out_of_range. Those models have no tempo control, and pretending otherwise would bill you for a request executed against your instructions.
OpenAI voice names work on Kokoro through aliases: alloy → af_alloy, echo → am_echo, fable → bm_fable, onyx → am_onyx, nova → af_nova, shimmer → af_sky. On Chatterbox and Qwen3 they map to that model’s own voices.
Describing a voice instead of picking one
Qwen3_TTS_12Hz_1_7B_VoiceDesign takes a natural-language voice description:
instructionson a model with a voice list →400 instructions_not_supported, and the message names the model that does support it. We refuse instead of ignoring, because a silently dropped field means paying for half of what you asked for.- The voice-design model without
instructions→400 instructions_required. voiceis accepted and ignored on that model — OpenAI types the field as required, but this model has no voice list.
How the language is chosen
The OpenAI spec has nolanguage field for speech, and our backend needs one. The first rule that yields a value wins:
-
An explicit
languagein the request (our extension). -
The voice slug prefix, where the model publishes a mapping.
Kokoro: - The model’s default language.
-
en-us.
Rejected fields on speech
stream_format="audio" is accepted — it describes the current behaviour.
Transcription
The gateway accepts a multipart body up to 50 MiB, but the platform caps a single audio file at 20 MB and a single video file at 50 MB, within a global 75 MB request body cap. See Limits & Quotas → File Uploads.
Response formats
srt and vtt return 400 format_not_supported — the backend returns text and we do not synthesise subtitle files.
Speakers in diarized_json are numbered by first appearance (A, B, …) and are stable within one response, not across calls — the same person may be A in one recording and B in the next. This matches OpenAI’s behaviour without speaker references.
Diarization is available on exactly one model (WhisperLargeV3Ct2). Requesting it elsewhere returns 400 before a billable job is created, and the message names the model that can do it.
Word timings come from the model, never from us
timestamp_granularities=["word"] is served only by models that publish word-level support — today WhisperLargeV3Ct2. On WhisperLargeV3 it is a 400:
200 here and build words[] by splitting each segment’s duration evenly across its words. In a 3-second, 9-word clip every single word came back as exactly 0.333 s — numbers you had no way of telling apart from a measurement. Since a model that does the job properly sits in the same catalog, refusing and naming it is the honest answer.
Fields in verbose_json that carry no real measurement
The shape is always complete so drop-in clients see a stable set of keys, but some values are placeholders rather than measurements:
The
language field in the response is a real detection on WhisperLargeV3Ct2 (the model’s own detection wins over your hint, returned as a Whisper-style name such as english). On WhisperLargeV3 it echoes your request instead.
Rejected fields on transcription
stream=false is accepted; it describes what we already do.
Video
The only family that is asynchronous for the client as well:POST returns immediately and you poll for the result.
Each model validates
seconds and size against its own list — the table is a union, not a menu that applies everywhere.
A failed job carries an
error object inside the video object and the GET itself still returns 200 — the request succeeded, the job did not. Branch on status, not on the status code.GET /v1/videos/{id}/content returns the file:
409 video_not_ready, and 502 video_failed if it failed:
?variant= accepts video only; anything else (thumbnail, spritesheet, …) returns 501 variant_not_supported rather than quietly handing you the main file.
Image-to-video
input_reference is an object, exactly as in the spec — image_url accepts a public URL or a base64 data: URL (10 MB limit; data: URLs are decoded locally). file_id returns 400, since there is no Files API here. A bare string returns 400 rather than being reinterpreted.
All three video models accept input_reference — there is currently no video model on this gateway that takes a prompt but refuses a first frame.
Ltxv_13B_0_9_8_Distilled_FP8 is by far the cheapest of them and used to reject input_reference even though it supports the mode natively. If you built around Ltx2_3_22B_Dist_INT8 or MiniMaxH3_33B_Turbo_INT8 only because of that rejection, it is worth re-measuring.
The reference is fetched while the request is being validated, so an unreachable or oversized address fails the call with 400 input_reference_unreadable and the message carries the status we got. A data: URL is decoded locally and never leaves the gateway.
Fields not to take literally
progressis mostly synthetic:0when queued,100when completed,50in between. It is not a render progress meter.expires_atiscreated_at+ 24 h computed on our side./contentre-resolves a fresh download link on every call, so the window is real — but it is our promise, not a backend guarantee.model,prompt,size,seconds,created_atin theGETresponse come from in-memory state recorded when the job was created. After a gateway restart they come back empty;/contentkeeps working.- An unrecognised backend status is reported as
in_progress, so your polling loop keeps going instead of failing on a transient mismatch.
Errors
Every error under/v1/* uses the OpenAI envelope:
code, not on message. param and code are omitted entirely when they do not apply, so treat them as optional keys rather than expecting empty strings. Some responses — notably 401 for a missing header and every 429 — carry no code at all; for those, type plus the HTTP status is the stable signal.
not_implemented and timeout_error are our extensions to OpenAI’s vocabulary: the first means “this surface will not work this way”, the second “retry with a longer deadline”. Unimplemented paths are 404 endpoint_not_implemented, not 501 — 501 is reserved for a recognised route asked for something it cannot produce, which today is only variant_not_supported.
429 carries two different meanings and type tells them apart: rate_limit_exceeded means too fast, insufficient_quota means out of credit. The second is not solved by waiting.
Common codes
Endpoint-specific codes are listed in the sections above.
Retry guidance
503 upstream_unavailable— retry with backoff; this is our capacity, not your payload.504 upstream_timeout— the job exceeded the wait window; retry, ideally with anIdempotency-Keyso you are not billed twice.502 upstream_job_failed— do not blindly retry; readmessagefirst.429 rate_limit_exceeded— retry after the delay we give you; see Rate limits.429 insufficient_quota— do not retry; top up the account first. You do not have to code around this: the response carriesX-Should-Retry: false, which both official SDKs honour ahead of the status, so it fails once instead of three times.404— never retried by the SDKs, which is the point: a wrong path or an unknown model fails immediately instead of costing you four round-trips.
Request IDs
Every response, including errors, carries anX-Request-Id header. Quote it in support tickets — it is how we find your request in the logs. Request bodies are never logged.
Endpoints that enqueue work also return X-DeAPI-Request-Id with the backend job identifier — one value for single-job endpoints (audio/speech, audio/transcriptions, videos), or a comma-separated list where the request fans out (embeddings, images/generations, images/edits). It is set before the status code, so it is present on error responses too.
Rate limits
Rate limiting is enforced by your deAPI plan and passed through unmodified. On429 you get the OpenAI envelope:
code field on this one — type is the stable signal.
Not every 429 is a rate limit. Running out of credit also returns 429, with "type": "insufficient_quota" and a matching code. Waiting does not clear that one — only a top-up does — so it is sent with X-Should-Retry: false and your SDK stops after the first attempt:
429 carries the accounting headers:
Retry-After and x-should-retry only — the X-RateLimit-* values are there for your dashboards, not for the SDK. For the per-plan numbers, see Limits & Quotas.
A per-minute limit is deliberately left retryable: waiting is the right response and Retry-After says how long. X-Should-Retry: false is sent on the two 429s where retrying cannot work:
insufficient_quota— no amount of waiting changes that answer.- A rate limit whose
Retry-Afterexceeds 120 seconds, i.e. the daily window. Neither SDK waits that long unaided:openai-pythondeclines to retry above 120 s, andopenai-nodehonoursRetry-Afteronly below 60 s and otherwise falls back to a 0.5 s backoff — three requests into a window that reopens hours later, each one another hit on the same counter.
Retry-After is always sent, so you can schedule your own retry either way.
Idempotency
SendIdempotency-Key: <uuid> on a POST and a repeated request with the same key and the same body returns the stored response plus Idempotent-Replayed: true — no second job, no second charge.
POSTonly.GET/HEADand requests without the header pass through untouched.- Scoped per caller and per endpoint. The same key on
/v1/embeddingsand/v1/images/generationsare two independent entries, and two accounts never share one. - Rotating your token invalidates in-flight keys.
- Only
2xxresponses are stored. After a4xxyou are free to fix the request and retry with the same key instead of being served a stale error. - Same key, different body →
422 idempotency_key_reuse. We never quietly serve the previous response. - Bodies over 1 MiB bypass the mechanism entirely — in practice, multipart uploads (
images/edits,audio/transcriptions). Responses over 1 MiB are not stored either: the request runs normally, it simply cannot be replayed. - Two concurrent requests with the same key both execute. There is no in-flight lock; the last one to finish wins. Sequential SDK retries — the common case — are protected.
- A replayed response carries a fresh
X-Request-Id, not the original one. POST /v1/videosis covered too. A retried create would otherwise produce a second job whoseidyou never learn, orphaned and billed. With a key, the sameidcomes back.
Framework integrations
Because deAPI uses the OpenAI API format, it works with any framework that accepts abase_url / api_base parameter.
- LangChain
- LlamaIndex
- Vercel AI SDK
Deliberate differences from OpenAI
A single list of everything that will not behave the way the OpenAI docs describe.A field we cannot honour gets an answer, not silence. Where dropping a field would change the response shape or the product you pay for, we return
400 instead of accepting the request. Where a field is a best-effort hint (and is one at OpenAI too), we accept it and say plainly that it does not change the result. That rule is why several fields that used to pass silently now return 400 — you were paying for requests that did something other than what you asked.Not available yet
deAPI also runs models with no OpenAI counterpart at all — image and video upscaling, background removal, music generation, OCR, character replacement in video. Those stay on the native deAPI API rather than being forced into a foreign contract.
Recent changes
Changed — unimplemented paths answer 404, not 501
Every path this gateway does not serve — GET /v1/videos, DELETE /v1/videos/{id}, /v1/videos/edits, /extensions, /{id}/remix, /characters, /v1/chat/completions, /v1/responses, /v1/files, /v1/moderations, /v1/audio/translations, /v1/images/variations, and anything unrecognised under /v1/* — now answers 404 with code: "endpoint_not_implemented". It used to answer 501.
If you branched on the status code, that branch is now dead. Branch on code instead: it is the part that distinguishes “this gateway does not serve this path” from an ordinary 404 such as model_not_found or video_not_found. 501 survives in one place only — variant_not_supported on /v1/videos/{id}/content.
model_not_found moved the same way: 404 on POST endpoints too, where it used to be 400. One code on two statuses forced every client to handle both.
The reason for the whole move is in Supported endpoints: both SDKs retry any status >= 500, so the old 501 turned a typo into four requests and an InternalServerError.
Added
GET /v1/models/{model}—client.models.retrieve(...)now works instead of refusing the path.- Speaker diarization —
response_format="diarized_json"onWhisperLargeV3Ct2, with sequential speaker labels. - Native word-level timestamps —
timestamp_granularities=["word"]returns the model’s own word timings onWhisperLargeV3Ct2, instead of interpolating them across the segment. Models that publish no word-level support reject the field outright rather than handing back invented offsets. instructionsfor text-to-speech — describe a voice in words onQwen3_TTS_12Hz_1_7B_VoiceDesign.application/jsonon/v1/images/edits— the second content type the spec defines; images are passed asimages: [{"image_url": …}].- New models:
ZAnimeDistill_8Step_INT8,WhisperLargeV3Ct2,MiniMaxH3_33B_Turbo_INT8,Qwen3_TTS_12Hz_1_7B_VoiceDesign. - Browser support —
/v1/*now sends CORS headers, so the official Node SDK runs in the browser withdangerouslyAllowBrowser: true. Preflight no longer fails on authentication, which previously made every browser-side call impossible. Read the warning before you ship it. createdis populated for every model inGET /v1/models. It reports when the model became available here; entries used to be0unless the model was also listed on OpenRouter.- Image-to-video on
Ltxv_13B_0_9_8_Distilled_FP8— the model supports it natively and we were rejecting it. It is the cheapest image-to-video model in the catalog by a wide margin.
Fixed
- Image-to-video now works.
input_referenceused to be silently dropped: the job succeeded, you were billed, and the clip was generated from the prompt alone. It is now an object, routed to the correct backend endpoint. - Models no longer disappear from
/v1/models. The catalog is paginated and only the first page was read, so correctly configured models were missing from the list with no error. response_format="text"on CTranslate2 models returned a raw JSON document, andverbose_jsonreturned an emptysegmentsarray.POST /v1/videos/edits,/extensionsand/charactersanswered405, telling you the path existed and the method was wrong — both untrue. They now answer404 endpoint_not_implemented.languagein transcription is no longer an echo — it reaches the model and affects the transcript.- Running out of credit reported
400— “your request is malformed”, when the request was fine. It is now429 insufficient_quota, sent withX-Should-Retry: falseso your SDK does not retry an answer only a top-up can change: one failed call instead of three. paramnamed the backend’s field, not yours. A validation error oninput,file,input_referenceorresponse_formatcame back pointing at an internal field name that appears nowhere in your request. The names are now translated back to the ones you sent.- Unrecognised paths under
/v1/videos/answered405. The earlier fix covered only the three collection names spelled out in the spec (/edits,/extensions,/characters); every other word still leaked a405. The whole class now answers404.
Changed — may affect existing integrations
Ltxv_13B_0_9_8_Distilled_FP8acceptsinput_reference. Image-to-video is not limited to theLtx2_*andMiniMaxH3_*families; all three video models take a first frame.- Partner models (
gpt-image-1.5,gpt-image-2,nano-banana-2) are not routable here. They are in the native catalog and answer404 model_not_foundon this gateway. upstream_validation_errorwas renamed toinvalid_value— a code from OpenAI’s own vocabulary rather than one of ours.timestamp_granularities=["word"]on a segment-only model now returns400. It used to return200with word timings interpolated evenly across each segment; those numbers looked like measurements and were not.Ltx2_3_22B_Dist_INT8no longer advertises2seconds. The backend requires at least 49 frames and 2 s is 48, so that value always failed;3and5stand in its place.
quality="auto"no longer returns400. It is the SDK’s default value and now maps to the model’s default quality.- Job failures return a real reason. Retryable failures come back as
503, permanent ones as502with a message — previously every failure was an empty502. - TTS is
mp3-only.wavandflacwere withdrawn from the TTS family on the backend and are now rejected locally with400 format_not_supported.
Support
Include theX-Request-Id from the failing response (and X-DeAPI-Request-Id if present) in any report — with those we can trace the request end to end.
Next steps
Models
Discover all available models and their capabilities
Quickstart
Get your API key and make your first request
API v2 Reference
Full native API reference with all endpoints
Pricing
Pay-as-you-go rates per task and model