PlatformError Reference

Error reference

Complete error catalog for the GPUniq LLM API — HTTP status codes, OpenAI error.type / error.code mappings, what triggers each error, and the right way to recover.

GPUniq's LLM API returns a stable, byte-identical OpenAI error envelope on every failure so the OpenAI SDK's typed exception hierarchy works without special-casing.

Envelope shape

OpenAI-compatible (/v1/openai/*)

{
  "error": {
    "message": "Human-readable description.",
    "type": "invalid_request_error",       // for OpenAI SDK exception class
    "code": "model_not_found",             // stable string — branch on this
    "doc_url": "https://docs.gpuniq.com/llm/errors",  // optional, where it applies
    "meta": { "max_tokens": 8000, "limit": 4096 }     // optional, structured details
  },
  "status_code": 400,
  "request_id": "ac30c4c5-62bc-48b5-98ca-bdbd8b4bbaaa"
}

The status_code and request_id fields are GPUniq additions — they sit alongside the standard OpenAI error object, so an OpenAI SDK consumer never trips on them. request_id is the canonical breadcrumb to share with support when reporting an issue.

Native GPUniq (/v1/llm/*)

{
  "exception": 400,
  "data": {
    "error": "Human-readable description.",
    "error_code": "model_not_found",
    "meta": {
      "max_tokens": 8000,
      "limit": 4096,
      "request_id": "ac30c4c5-62bc-48b5-98ca-bdbd8b4bbaaa"
    }
  },
  "message": "Модель не найдена"
}

The native envelope drops the SDK-typed error.type and uses a Russian human-friendly message instead, but the error_code value is identical to the OpenAI-compat surface — so you can branch on the same string regardless of which surface you call.

data.meta.request_id is the canonical breadcrumb to include when reporting issues — the same id is echoed back as the X-Request-ID response header and is grep-able across logs.

Pydantic body-validation failures

Field-validation failures on /v1/llm/* and /v1/openai/* endpoints — missing required fields, out-of-range numbers, wrong enum values — surface as invalid_request through the same envelope as everything else, instead of FastAPI's default {"detail": [...]} shape. The full Pydantic error list lives in data.meta.fields for clients that want machine-readable per-field diagnostics:

// POST /v1/llm/videos/jobs with prompt=""
{
  "exception": 400,
  "data": {
    "error": "body.prompt: String should have at least 1 character",
    "error_code": "invalid_request",
    "meta": {
      "fields": [
        {
          "type": "string_too_short",
          "loc": ["body", "prompt"],
          "msg": "String should have at least 1 character",
          "ctx": { "min_length": 1 }
        }
      ],
      "request_id": "…"
    }
  },
  "message": "Неверный запрос"
}

Branching strategy

Always branch on error.code (OpenAI) / data.error_code (native). The message and type fields are informational and can change between versions; code is the stable contract.

from openai import OpenAI, BadRequestError, RateLimitError, APIStatusError

client = OpenAI(api_key="gpuniq_…", base_url="https://api.gpuniq.com/v1/openai")

try:
    resp = client.chat.completions.create(model=…, messages=…)
except BadRequestError as e:
    code = e.body.get("error", {}).get("code")
    if code == "streaming_required":
        # Retry with stream=true
        ...
    elif code == "model_not_found":
        # Show a "pick a different model" UI
        ...
except RateLimitError as e:
    code = e.body.get("error", {}).get("code")
    retry_after = e.response.headers.get("Retry-After")
    # code == "rate_limit_per_key" or "rate_limit_per_user"
    ...

Error catalog

The complete list of stable error codes returned by GPUniq.

Billing & balance

codeHTTPOpenAI typeWhen
insufficient_balance402insufficient_quotaYour GPUniq USD balance is below the request's estimated cost.
billing_temporarily_unavailable503api_errorInternal billing service is briefly unreachable — request was rejected to avoid silent double-billing. Retry in a few seconds.

Recovery: Top up at https://gpuniq.com/balance. The same balance covers GPU rentals, image generation, and chat — one deposit covers the whole platform.

Authentication & identity

codeHTTPOpenAI typeWhen
authentication_required401invalid_request_errorNo Authorization: Bearer … header and no X-API-Key.
invalid_api_key401invalid_request_errorThe API key is malformed or doesn't exist.
api_key_revoked401invalid_request_errorKey existed but was disabled from the dashboard.
user_not_found404invalid_request_errorThe user account behind this credential no longer exists (rare — only when a credential outlives a hard-delete).

Recovery: Issue a new key at the LLM API Keys page. Keys are revocable independently — rotating one doesn't invalidate the others.

Rate limiting

codeHTTPOpenAI typeWhen
rate_limit_per_key429rate_limit_exceededOne API key exceeded its per-minute window.
rate_limit_per_user429rate_limit_exceededYour user account exceeded the aggregate per-minute window across all your API keys.

The response carries Retry-After (seconds), X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers — the OpenAI SDK consumes these automatically for its built-in backoff. The window is a true sliding 60-second one (Redis ZSET), not a fixed-bucket counter, so the limit can't be bypassed at the minute boundary.

Recovery: Honour Retry-After. If you regularly hit rate_limit_per_key, the per-key limit is bumpable from the dashboard. If you hit rate_limit_per_user, the aggregate cap is bumpable on request — contact support.

Model selection

codeHTTPOpenAI typeWhen
model_not_found400invalid_request_errorSlug isn't in the GPUniq catalog. Fetch /v1/openai/models for the current list.
model_required400invalid_request_errorbody.model is missing or empty.
model_is_image400invalid_request_errorYou called /chat/completions with an image-generation slug. Use POST /v1/openai/images/generations instead.
model_disabled503api_errorThe model is administratively disabled platform-wide. Pick another from the catalog.

Request validation

codeHTTPOpenAI typeWhen
messages_required400invalid_request_errormessages is missing or empty.
empty_message_content400invalid_request_errorAny message has whitespace-only or empty content.
message_too_long400invalid_request_errorA single message exceeds the maximum allowed length.
request_too_large413invalid_request_errorThe whole request body exceeds the size cap.
context_window_exceeded400context_length_exceededEstimated prompt tokens exceed the model's context window.
invalid_temperature400invalid_request_errortemperature outside [0, 2].
invalid_top_p400invalid_request_errortop_p outside [0, 1].
invalid_max_tokens400invalid_request_errormax_tokens ≤ 0 or above the model's output cap.
invalid_messages_format400invalid_request_errorA message has an unsupported role or content shape (e.g. malformed multimodal parts).
invalid_image_payload400invalid_request_errorEmbedded image base64 is malformed or the URL is unreachable.
invalid_request400invalid_request_errorA required field is missing or violates the endpoint contract — used by image/video/music surfaces where the offending field is named in error.message. For video, common cases: i2v slug without image_url, motion-control slug without both image_url and video_url.
content_filter400content_filterThe upstream blocked the content under its safety policy.

Streaming

codeHTTPOpenAI typeWhen
streaming_required400invalid_request_errorA non-streaming request asked for more than NON_STREAM_MAX_TOKENS output tokens (default 4096). Buffered responses past that length routinely lose to the ~100 s edge-proxy timeout — retry with stream: true. The error's error.meta carries max_tokens, limit, and a hint field.
empty_upstream_stream200 (SSE frame)api_errorEvery eligible upstream accepted the request and then closed the stream without emitting a single token. Delivered as an SSE error frame followed by [DONE]; nothing is billed. Retry, or resend with "stream": false.

This is the most common reason a previously-working integration breaks after a GPUniq update. The full recovery recipe lives in the Long generations guide.

// Example response on max_tokens=8000 without stream:
{
  "error": {
    "message": "Requested max_tokens=8000 exceeds the non-streaming limit of 4096. Long responses must use streaming: resend the request with "stream": true.",
    "type": "invalid_request_error",
    "code": "streaming_required",
    "doc_url": "https://docs.gpuniq.com/llm/long-generations",
    "meta": { "max_tokens": 8000, "limit": 4096, "hint": "stream=true" }
  },
  "status_code": 400,
  "request_id": "…"
}

Provider / upstream

GPUniq routes every chat completion through a cost-sorted fallback chain of upstream providers. Errors in this section mean the chain itself couldn't satisfy the request — your input is fine, but every viable upstream failed.

codeHTTPOpenAI typeWhen
provider_unavailable503api_errorNo upstream is currently eligible for this model (every provider disabled or below balance floor). Try another model or retry shortly.
no_provider_available400invalid_request_errorThe requested (model, parameter) combination isn't currently serveable — the slug exists in the catalog but no internal route is enabled for it right now. Pick a different combination or check the catalog.
no_streaming_upstream503api_errorThe model has eligible non-stream upstreams but none of them support real SSE streaming. The request was sent with stream: true.
upstream_timeout504timeout_errorThe upstream took longer than our 5-minute budget or the edge proxy closed the connection. For long reasoning, use stream: true.
upstream_rate_limit429rate_limit_exceededEvery upstream for this model is currently rate-limited. Backoff and retry.
upstream_bad_response502api_errorAll eligible upstreams returned a non-2xx 4xx for this request.
upstream_validation_error400invalid_request_errorThe internal routing layer rejected the payload as malformed (image/video/music surfaces). meta.hint may carry a hand-written remediation pointing at the right field. Adjust the request and retry — retrying without changes won't help.
upstream_server_error502api_errorAll eligible upstreams returned a 5xx.
gateway_misconfigured503api_errorThe deployment is missing required API keys for any eligible upstream. Operator-visible — contact support.

Recovery: Most upstream errors are transient. The OpenAI SDK's default retry policy handles them automatically; manually, exponential backoff with a 30-second ceiling is enough. upstream_timeout on a long generation is the one exception — switching to stream: true is the structural fix.

Async jobs (video / music / image-jobs surfaces)

The async surfaces (POST /v1/llm/videos/jobs, POST /v1/llm/music/jobs, POST /v1/llm/images/jobs) return a stable GPUniq job_id on kickoff. The poll endpoint surfaces job state inside a success envelope (exception: 0) with data.status{ pending, processing, completed, failed }. Only the envelope-level errors below are surfaced as non-2xx:

codeHTTPOpenAI typeWhen
job_not_found404invalid_request_errorThe id doesn't belong to your account, never existed, or fell out of the 60-minute state TTL after kickoff. Re-kick off the job.

A data.status: "failed" payload is not an HTTP error — the request itself was valid, the upstream just didn't deliver. The job is no longer pollable after failed or completed. Re-kick off to retry. Nothing was charged for a failed job.

// GET /v1/llm/videos/jobs/{job_id} — failed (HTTP 200, status:"failed")
{
  "exception": 0,
  "data": {
    "status": "failed",
    "job_id": "vid_…",
    "error": "Upstream task stuck at 0% for 480s — queue likely failed. Retry the request."
  },
  "message": "Статус задачи"
}

Image jobs: typed failure causes

Failed image jobs (GET /v1/llm/images/jobs/{job_id}) additionally carry a machine-readable error_code inside the failed payload, so you can branch without parsing the message. Two values:

error_codeMeaningWhat to do
content_moderationThe prompt and/or reference images were rejected by the upstream content policy. This is a verdict on the input, not an outage — GPUniq deliberately does not re-route these to another upstream.Do not retry the same prompt verbatim — it will be rejected again. Rephrase and re-kick off. In practice, video-style phrasing on image models triggers this most often: shot durations ("a 12-second shot"), camera-movement language ("FPV drone flythrough", "single continuous shot, no cuts"), storyboard/scene directions. Describe the still image you want instead.
generation_failedA transient upstream failure. Where an alternate route exists, GPUniq already retried the job internally on a second provider before giving up — a failed with this code means the retry budget is exhausted for this job.Re-kick off the job (a fresh job_id); exponential backoff with a 30-second ceiling is enough.
// GET /v1/llm/images/jobs/{job_id} — moderation rejection (HTTP 200)
{
  "exception": 0,
  "data": {
    "status": "failed",
    "job_id": "img_…",
    "error": "Image request was rejected by the upstream content moderation.",
    "error_code": "content_moderation"
  },
  "message": "Статус задачи"
}

Two timing facts worth wiring into your client:

  • Moderation verdicts are not instant. The upstream evaluates content during generation, so a content_moderation failure can surface several minutes after kickoff (typically 3–6 min on the nano-banana family) — a job that has been processing for a while can still end in a policy rejection rather than an image.
  • Keep your polling budget at 10 minutes. Worst-case successful delivery (a slow render followed by an internal retry on an alternate route) approaches 7 minutes on the nano-banana family. A 7-minute client-side ceiling will abandon jobs that GPUniq would still have delivered; nothing is charged until an image is actually delivered to a poll, so an abandoned job costs nothing but the wait.

Failed jobs are billed nothing, regardless of the cause. Video and music job failures currently carry only the human-readable error string; typed causes will arrive there in the same shape.

Video-generation specific recipes

Video kickoff errors fold into the tables above, but a few combinations come up often enough that they deserve a named recipe.

1. invalid_request — i2v slug without image_url. Kling 2.1, the Avatar SKUs, and a few others are image-to-video only on the current upstream catalog. The pre-flight check rejects the kickoff with a 400 naming the offending field.

{
  "exception": 400,
  "data": {
    "error": "`kling-2-1` is an image-to-video model — it requires the `image_url` field. Pass an https URL or `data:` URI.",
    "error_code": "invalid_request"
  },
  "message": "Неверный запрос"
}

Fix: add image_url to the request. The same shape applies to motion-control slugs (which require both image_url and video_url).

2. upstream_validation_error — internal routing rejected the payload. Reaches you when the pre-flight check let the request through but the internal routing layer bounced it for a contract reason we don't yet model client-side. meta.hint carries a GPUniq-shaped remediation pointing at the right field.

{
  "exception": 400,
  "data": {
    "error": "This SKU is image-to-video — pass an https URL or data: URI via the `image_url` field on the request.",
    "error_code": "upstream_validation_error",
    "meta": {
      "hint": "This SKU is image-to-video — pass an https URL or data: URI via the `image_url` field on the request.",
      "request_id": "…"
    }
  },
  "message": "Запрос отклонён провайдером"
}

Fix: read meta.hint first — it's pre-translated to GPUniq's request shape. Adjust the named field and retry.

3. no_provider_available — the (model, params) combo isn't routeable. Returned when the slug is in the catalog but no internal route is currently enabled for the requested parameter combination. Most often: a 4K resolution / master tier / longer-than-supported duration combo that's priced but not yet wired.

{
  "exception": 400,
  "data": {
    "error": "No provider can currently serve 'kling-2-5-turbo-pro' with the requested config (resolution=4k, audio=false, duration=10, mode=turbo, task=t2v). Either pick a different combination or try again later — the catalog may have shifted.",
    "error_code": "no_provider_available",
    "meta": {
      "slug": "kling-2-5-turbo-pro",
      "request_id": "…"
    }
  },
  "message": "Конфигурация не поддерживается"
}

Fix: check the catalog at GET /v1/llm/models/catalog for the SKU's currently-supported parameter combinations.

4. Job stuck — watchdog converts to failed. Some upstream tasks accept the kickoff and never progress past 0% (queue stalls). After 8 minutes at 0%, the poll endpoint emits status: "failed" with a retry the request hint instead of polling indefinitely until the 60-minute Redis TTL evicts the id.

{
  "exception": 0,
  "data": {
    "status": "failed",
    "error": "upstream task stuck at 0% for 482s — upstream queue likely failed. Retry the request."
  }
}

Fix: re-kick off the job. The stall rate observed in production is ~10-20% on the most-flaky SKUs; healthy jobs complete in 30-90s.

5. no_provider_available — the model has no such reference input. Returned when the request carries reference_image_urls (or image_url) but the chosen model has no route that implements it. Since 2026-08-15 this is resolved before dispatch, so the answer is deterministic: the same request always gets the same verdict.

{
  "exception": 400,
  "data": {
    "error": "'kling-2-5-turbo-pro' has no reference-to-video input on any currently routed provider — `reference_image_urls` would be silently ignored. Reference images are supported on `seedance-2`, `kling-o3-video` and `veo-3-1-fast` / `veo-3-1-lite`. For this model pass `image_url` (start frame) instead, or switch model.",
    "error_code": "no_provider_available",
    "meta": { "slug": "kling-2-5-turbo-pro", "request_id": "…" }
  },
  "message": "Конфигурация не поддерживается"
}

Fix: follow the error — either drop the field or move to a model that has the input. The per-model matrix is in Which model takes which reference input.

If you saw this combination succeed before, you were not imagining it. Until 2026-08-15 the routing chain could hand a reference-image job to an internal route with no such field: identical requests rendered or were refused depending on which route won the cost sort that minute. Capability is now checked before dispatch — a request that can be served is served, and one that cannot is refused immediately with the message above, every time.

6. Reference media rejected at submit. The upstream fetched your video_url / image_url and refused it. Since 2026-08-15 the two most common causes are handled for you: a link whose host declares the wrong Content-Type (application/octet-stream instead of video/mp4 — the Google Drive case) is re-hosted automatically, and data: URIs are materialised into real links on every media field. What remains is a link with no fetchable file behind it — a /view page, a sign-in redirect, or a share that is not set to "anyone with the link". See Reference media: what format to send.

Fix: open the URL in a private browser window. If you do not get the file itself, neither do we — make the share public and use a direct-download link, or send the clip as a data: URI and skip hosting entirely.

Catch-all

codeHTTPOpenAI typeWhen
internal_error500api_errorUnexpected server-side error — see request_id and contact support.

Common scenarios

"I get a bare 403 with error code: 1010 and no JSON."

This never reached the API. Our edge blocks the default Python-urllib/3.x User-Agent, and the block applies to every route equally — so it looks like "endpoint X is broken" until you notice that endpoint Y fails identically. The giveaway is the body: 1010 is plain text, while every real API error is a JSON envelope carrying a request_id.

Fix: send any other User-Agent. requests, httpx, curl and the official SDKs already do. With bare urllib, add the header yourself:

req = urllib.request.Request(url, data=body, headers={
    "X-API-Key": KEY,
    "Content-Type": "application/json",
    "User-Agent": "my-app/1.0",
})

"A streaming request returns HTTP 200 with an empty body."

Fixed as of 2026-08-15. An upstream that accepted the request and then streamed nothing used to end the response with zero bytes and no error — a silent empty success. The stream now holds its first bytes back until an upstream proves it is answering, so a silent upstream fails over to the next one instead. If every upstream is silent you get an empty_upstream_stream error frame and are charged nothing.

Fix: retry. If it persists on one model, report it with the request_id — the failure is recorded server-side as empty_upstream_stream and is visible to the operator.

"I get streaming_required on a request that used to work."

max_tokens > 4096 without stream: true is now rejected up-front. Previously, GPUniq would silently upgrade the upstream call to streaming and reassemble the SSE chunks into a non-stream response. That worked for SDKs but ate connections on every Cloudflare-fronted client (browsers, mobile, behind corporate proxies). The new behaviour is to fail fast with a clear error code and doc_url so the client can react.

Fix: add "stream": true to the request body. If your client can't speak SSE, use the POST /v1/llm/chat/jobs job-based pattern instead.

"I get insufficient_balance but my dashboard shows funds."

The pre-flight estimate uses worst-case max_tokens × retail_output_rate. A request asking for max_tokens=4096 against Claude Opus reserves ~$0.08 even before the model has emitted a single token. If you have $0.04 free, the pre-flight rejects with insufficient_balance regardless of what the real call would have cost.

Fix: keep at least one full request's worth of headroom on the balance, or cap max_tokens to a realistic ceiling for the model.

"I get upstream_bad_response randomly."

The cost-sorted chain has tried every eligible provider and each returned a 4xx that wasn't worth retrying through the same provider (usually rate limits from the upstream's own pool). The full chain is exposed in the admin dashboard's Unit Economics → Streaming Providers tab.

Fix: transient — backoff and retry. If it persists for > 5 minutes on the same model, the operator alert system already fires; contact support.

"Streaming error chunks in the middle of an SSE stream."

Once the first byte ships, the upstream the chain picked is final — GPUniq doesn't transparently fall over mid-stream. If the upstream then dies, the SSE stream emits a final data: {"error": {…}} chunk followed by data: [DONE]. Treat that as a partial-success: bill what was delivered, surface the error to the user, and offer a retry.

data: {"error": {"message": "Upstream gateway is unreachable.", "type": "api_error", "code": "upstream_server_error"}}

data: [DONE]

Telemetry & support

Every response carries request_id. Include it when reporting issues at support@gpuniq.com — the operator can trace the full upstream chain, provider that served, and source cost in 5 seconds with that one field.