{
  "openapi": "3.1.0",
  "info": {
    "title": "TeamToken public API",
    "version": "1.0.0",
    "description": "The public surface of the TeamToken gateway: text (OpenAI- and Anthropic-compatible endpoints), images, video, balance and catalog.\n\nThis document is derived: it is built from the contract description rather than written by hand, so it cannot drift from the reference pages.\n\nPrices are deliberately absent. They are live and change without a deploy; their only source is the catalog endpoints, which answer without a key: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
  },
  "servers": [
    {
      "url": "https://api.teamtoken.store",
      "description": "Production gateway"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    },
    {
      "apiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "text",
      "description": "Text. OpenAI- and Anthropic-compatible chat, responses and embeddings. Every reply carries its own cost."
    },
    {
      "name": "images",
      "description": "Images. Image generation and edits. A long generation returns a job you poll by id."
    },
    {
      "name": "videos",
      "description": "Video. Asynchronous video generation from text, an image or another video, plus result delivery."
    },
    {
      "name": "account",
      "description": "Account & catalog. Which models are available and how much money the key's account has. The priced catalog needs no key."
    }
  ],
  "paths": {
    "/v1/chat/completions": {
      "post": {
        "operationId": "chatCompletions",
        "summary": "Chat completions",
        "description": "The main text route: an OpenAI-shaped conversation.\n\nThe gateway forwards the request to an OpenAI-compatible upstream. Only the fields it looks at itself are listed; every other body field (tools, tool_choice, response_format, temperature, seed, logprobs and the rest) is passed through untouched — their meaning is the OpenAI schema's, not ours.\n\nThe output ceiling (max_tokens or max_completion_tokens) sets the worst-case price the balance check works from: the bigger it is, the likelier a 402 on a thin balance — lowering it is a valid answer to a refusal. Name neither and the ceiling comes from the model's catalog row, so the request is still not priced at zero.\n\nThis request's cost arrives inside the answer: the x-teamtoken-cost-usd header and usage.cost_usd in the body. The value is a decimal string (\"0.0000465\"), not a number: a number would be re-displayed by the client language's own float rules (Python would show 4.65e-05), while a string reaches your code exactly as written. The cost can also fail to arrive at all — then it is in neither the header nor the field: on a non-streaming answer when the upstream did not report it, on a stream when the model has no catalog tariff. On a stream the headers leave before the cost is knowable, so it is written into the final usage frame — ask for it with \"stream_options\": {\"include_usage\": true}. Without include_usage the stream is relayed byte-for-byte with no frame parsing at all, and carries no cost.\n\nA byte-identical request with the same key inside a short TTL (60 s by default) does not reach the model twice — the gateway returns the first answer's body and charges nothing for the second. Only a successful answer of at most 256 KB is cached; anything else goes upstream again. A replay from the cache carries no x-teamtoken-cost-usd header (nothing was charged), and the cost_usd in its body belongs to the first, paid answer. Hence the corollary: an already-paid answer is served even on an empty wallet — the balance check sits AFTER idempotency. The order of pre-flight checks: disabled model → moderation → balance. An answer with no substance at all (no text, no reasoning, no refusal, no tool call) is treated as a failure and retried before it is handed to you, provided there is somewhere left to switch to; an answer cut short by policy or by the output ceiling does not count as empty, and a stream is excluded from the rule entirely, because the 200 has already started. Every error arrives in one envelope (the code field is not on every status): { \"error\": { \"message\": \"...\", \"type\": \"...\", \"code\": \"PROVIDER_CODE\" } }.\n\nModeration, when it is enabled, reads the request text and blocks only on a real verdict: a moderator that is down or erroring lets the request through. The answer always carries back the logical model name you asked for. If it does not fit the gateway's wait (300 s read by default, 60 s without bytes on a stream) you get a 504; the gateway marks that non-streaming attempt, and if the upstream finished and billed anyway, the reconciler credits the amount back to your balance as a compensating grant. Upstream host names are scrubbed out of error bodies, so an error's text can differ from what the upstream sent.\n\nThe endpoint has no route of its own: the gateway forwards the request upstream as is. Fields not listed above reach the upstream unchanged — including ones its API grows later — and the reply is returned as the upstream produced it, plus our cost of the request.",
        "tags": [
          "text"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/chat-completions"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "model": {
                    "type": "string",
                    "description": "the logical model name from GET /v1/models; a provider prefix is folded away Model identifier. The set of accepted values is deliberately absent: the catalog is edited from the admin UI without a deploy, so the current list comes from the catalog endpoints: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
                  },
                  "messages": {
                    "type": "array",
                    "description": "the conversation in OpenAI form: an array of {role, content}; goes upstream unchanged"
                  },
                  "stream": {
                    "type": "boolean",
                    "description": "true — the answer arrives as SSE frames; the gateway's own refusals happen before the first byte"
                  },
                  "stream_options": {
                    "type": "object",
                    "description": "{\"include_usage\": true} — the only way to get the cost while streaming"
                  },
                  "max_tokens": {
                    "type": "integer",
                    "description": "the output ceiling (max_completion_tokens too); the worst-case price is built on it"
                  }
                },
                "required": [
                  "model",
                  "messages"
                ],
                "additionalProperties": true
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/v1/chat/completions \\\n  -H \"Authorization: Bearer sk-…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"model\": \"gpt-5.6-sol\",\n    \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n  }'"
          },
          {
            "lang": "Python",
            "label": "Python",
            "source": "from openai import OpenAI\n\nclient = OpenAI(api_key=\"sk-…\", base_url=\"https://api.teamtoken.store/v1\")\n\nstream = client.chat.completions.create(\n    model=\"gpt-5.6-sol\",\n    messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n    stream=True,\n    stream_options={\"include_usage\": True},   # no include_usage, no cost in the stream\n)\nfor chunk in stream:\n    if chunk.choices and chunk.choices[0].delta.content:\n        print(chunk.choices[0].delta.content, end=\"\")\n    if chunk.usage:                           # the final frame\n        print(\"cost_usd:\", chunk.usage.model_extra[\"cost_usd\"])"
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response\n\n**Stream**\n\n```\ndata: {\"id\":\"chatcmpl-…\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hel\"}}]}\n\ndata: {\"id\":\"chatcmpl-…\",\"object\":\"chat.completion.chunk\",\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":12,\"cost_usd\":\"0.0000465\"}}\n\ndata: [DONE]\n```",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "response": {
                    "summary": "Response",
                    "value": {
                      "id": "chatcmpl-…",
                      "object": "chat.completion",
                      "model": "gpt-5.6-sol",
                      "choices": [
                        {
                          "index": 0,
                          "message": {
                            "role": "assistant",
                            "content": "Hello!"
                          },
                          "finish_reason": "stop"
                        }
                      ],
                      "usage": {
                        "prompt_tokens": 9,
                        "completion_tokens": 12,
                        "cost_usd": "0.0000465"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "moderation stopped the prompt before the model (type: content_policy_violation)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "no key in the request, or the key is not ours",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "the balance does not cover this request's worst-case price",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`model_not_found` — the model is switched off by the admin — it leaves GET /v1/models too",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "the upstream answered with a rate limit; retry with a delay",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "the upstream is unreachable (type: upstream_error)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "504": {
            "description": "a timeout waiting for the upstream: 300 s read, 60 s without bytes on a stream",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/responses": {
      "post": {
        "operationId": "responses",
        "summary": "Responses API",
        "description": "The OpenAI Responses API — the path Codex CLI is configured against.\n\nForwarded to an OpenAI-compatible upstream exactly like chat: only the fields the gateway itself reads are listed, and the rest of the body (tools, reasoning, instructions, text and so on) goes through untouched. It gets its own entry because this is the route Codex CLI is pointed at: wire_api = \"responses\" in ~/.codex/config.toml.\n\nThis request's cost arrives inside the answer: the x-teamtoken-cost-usd header and usage.cost_usd in the body. The value is a decimal string (\"0.0000465\"), not a number: a number would be re-displayed by the client language's own float rules (Python would show 4.65e-05), while a string reaches your code exactly as written. The cost can also fail to arrive at all — then it is in neither the header nor the field: on a non-streaming answer when the upstream did not report it, on a stream when the model has no catalog tariff. Streams here use named SSE events. The gateway tries to write the cost into the usage-bearing frame (the Responses API nests it in response.completed), but it only rewrites a frame that starts with data: — a frame with its own event: line is relayed untouched, and then no cost arrives in the stream. On a non-streaming answer the cost comes both in the header and in the field.\n\nA byte-identical request with the same key inside a short TTL (60 s by default) does not reach the model twice — the gateway returns the first answer's body and charges nothing for the second. Only a successful answer of at most 256 KB is cached; anything else goes upstream again. A replay from the cache carries no x-teamtoken-cost-usd header (nothing was charged), and the cost_usd in its body belongs to the first, paid answer. Hence the corollary: an already-paid answer is served even on an empty wallet — the balance check sits AFTER idempotency. Every error arrives in one envelope (the code field is not on every status): { \"error\": { \"message\": \"...\", \"type\": \"...\", \"code\": \"PROVIDER_CODE\" } }.\n\nModeration, when it is enabled, reads the request text and blocks only on a real verdict: a moderator that is down or erroring lets the request through. The answer always carries back the logical model name you asked for. If it does not fit the gateway's wait (300 s read by default, 60 s without bytes on a stream) you get a 504; the gateway marks that non-streaming attempt, and if the upstream finished and billed anyway, the reconciler credits the amount back to your balance as a compensating grant. Upstream host names are scrubbed out of error bodies, so an error's text can differ from what the upstream sent.\n\nThe endpoint has no route of its own: the gateway forwards the request upstream as is. Fields not listed above reach the upstream unchanged — including ones its API grows later — and the reply is returned as the upstream produced it, plus our cost of the request.",
        "tags": [
          "text"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/responses"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "model": {
                    "type": "string",
                    "description": "the logical model name from GET /v1/models; a provider prefix is folded away Model identifier. The set of accepted values is deliberately absent: the catalog is edited from the admin UI without a deploy, so the current list comes from the catalog endpoints: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
                  },
                  "input": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "the prompt: a string or an array of structured parts — the Responses API shape"
                  },
                  "stream": {
                    "type": "boolean",
                    "description": "true — the answer arrives as SSE events. The gateway's own refusals all happen before the first byte"
                  },
                  "max_output_tokens": {
                    "type": "integer",
                    "description": "the output ceiling in the Responses API spelling"
                  }
                },
                "required": [
                  "model",
                  "input"
                ],
                "additionalProperties": true
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/v1/responses \\\n  -H \"Authorization: Bearer sk-…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"model\": \"gpt-5.6-sol\", \"input\": \"Hello\" }'"
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "response": {
                    "summary": "Response",
                    "value": {
                      "id": "resp_…",
                      "object": "response",
                      "status": "completed",
                      "model": "gpt-5.6-sol",
                      "output": [
                        {
                          "type": "message",
                          "role": "assistant",
                          "content": [
                            {
                              "type": "output_text",
                              "text": "Hello!"
                            }
                          ]
                        }
                      ],
                      "usage": {
                        "input_tokens": 9,
                        "output_tokens": 12,
                        "cost_usd": "0.0000465"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "moderation stopped the prompt before the model (type: content_policy_violation)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "no key in the request, or the key is not ours",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "the balance does not cover this request's worst-case price",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`model_not_found` — the model is switched off by the admin — it leaves GET /v1/models too",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "the upstream answered with a rate limit; retry with a delay",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "the upstream is unreachable (type: upstream_error)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "504": {
            "description": "a timeout waiting for the upstream: 300 s read, 60 s without bytes on a stream",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/messages": {
      "post": {
        "operationId": "messages",
        "summary": "Messages (Anthropic shape)",
        "description": "The same chat, in the Anthropic shape: a different request and response body.\n\nThe gateway accepts the Anthropic shape but does NOT proxy it verbatim: the body is rewritten into chat/completions, and the upstream answer is folded back into an Anthropic message (the id comes from the upstream, the model is the logical one you asked for). That way any catalog model works here, not only Claude.\n\nCarried over from the body: model, system, messages, max_tokens, temperature, top_p, stream, metadata, stop_sequences (as stop), tools (name/description/input_schema → function) and tool_choice (auto → auto, any → required, tool → that named function). This is the one text route where other fields do NOT reach the upstream: the converter builds a fresh body from that list. Assistant tool_use blocks and user tool_result blocks are converted both ways, and finish_reason becomes stop_reason (stop → end_turn, length → max_tokens, tool_calls → tool_use, content_filter → stop_sequence). The gateway does not require max_tokens: without it the field never appears in the body sent upstream, and the request's worst-case price is built on the output ceiling from the model's catalog row. Anthropic's telemetry block inside system (x-anthropic-billing-header: …) is dropped: its per-request nonce sits at the very front of the prompt and busts the upstream's prefix cache every time.\n\nThis request's cost arrives inside the answer: the x-teamtoken-cost-usd header and usage.cost_usd in the body. The value is a decimal string (\"0.0000465\"), not a number: a number would be re-displayed by the client language's own float rules (Python would show 4.65e-05), while a string reaches your code exactly as written. The cost can also fail to arrive at all — then it is in neither the header nor the field: on a non-streaming answer when the upstream did not report it, on a stream when the model has no catalog tariff. The stream here is synthetic: the upstream answers in full and the SSE is built from the finished message — so the cost is known before the first byte, the header is present on streams too, and cost_usd sits in message_start inside message.usage (into message_delta the gateway puts output_tokens only). There is no idempotency on this route: the gateway does not cache an Anthropic answer, so a byte-identical repeat reaches the model again and is paid for again. Alongside lives POST /v1/messages/count_tokens, which answers {\"input_tokens\": N} — an ESTIMATE (characters/4 plus per-message and per-tool overhead), not a tokenizer's verdict. Every error arrives in one envelope (the code field is not on every status): { \"error\": { \"message\": \"...\", \"type\": \"...\", \"code\": \"PROVIDER_CODE\" } }.\n\nModeration, when it is enabled, reads the request text and blocks only on a real verdict: a moderator that is down or erroring lets the request through. The answer always carries back the logical model name you asked for. If it does not fit the gateway's wait (300 s read by default, 60 s without bytes on a stream) you get a 504; the gateway marks that non-streaming attempt, and if the upstream finished and billed anyway, the reconciler credits the amount back to your balance as a compensating grant. Upstream host names are scrubbed out of error bodies, so an error's text can differ from what the upstream sent.\n\nThe endpoint has no route of its own: the gateway forwards the request upstream as is. Fields not listed above reach the upstream unchanged — including ones its API grows later — and the reply is returned as the upstream produced it, plus our cost of the request.",
        "tags": [
          "text"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/messages"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "model": {
                    "type": "string",
                    "description": "the logical model name from GET /v1/models; a provider prefix is folded away Model identifier. The set of accepted values is deliberately absent: the catalog is edited from the admin UI without a deploy, so the current list comes from the catalog endpoints: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
                  },
                  "messages": {
                    "type": "array",
                    "description": "the conversation in Anthropic form: content is a string or an array of text / tool_use / tool_result blocks"
                  },
                  "max_tokens": {
                    "type": "integer",
                    "description": "the output ceiling; optional, and the worst-case price is built on it"
                  },
                  "system": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "the system prompt — a string or an array of text blocks, merged into one system message"
                  },
                  "tools": {
                    "type": "array",
                    "description": "tools in Anthropic form: {name, description, input_schema}"
                  },
                  "tool_choice": {
                    "type": "object",
                    "description": "{\"type\": \"auto\" | \"any\" | \"tool\", \"name\": …} — mapped to auto / required / that named function"
                  },
                  "stream": {
                    "type": "boolean",
                    "description": "true — the answer arrives as Anthropic events (message_start … message_stop)"
                  }
                },
                "required": [
                  "model",
                  "messages"
                ],
                "additionalProperties": true
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/v1/messages \\\n  -H \"x-api-key: sk-…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"model\": \"gpt-5.6-sol\",\n    \"max_tokens\": 512,\n    \"system\": \"Be brief\",\n    \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n  }'"
          },
          {
            "lang": "Python",
            "label": "Python",
            "source": "from anthropic import Anthropic\n\n# base_url without /v1 — the SDK appends the path itself\nclient = Anthropic(api_key=\"sk-…\", base_url=\"https://api.teamtoken.store\")\n\nmsg = client.messages.create(\n    model=\"gpt-5.6-sol\",\n    max_tokens=512,\n    messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\nprint(msg.content[0].text)\nprint(\"cost_usd:\", msg.usage.model_extra[\"cost_usd\"])"
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "response": {
                    "summary": "Response",
                    "value": {
                      "id": "chatcmpl-…",
                      "type": "message",
                      "role": "assistant",
                      "model": "gpt-5.6-sol",
                      "content": [
                        {
                          "type": "text",
                          "text": "Hello!"
                        }
                      ],
                      "stop_reason": "end_turn",
                      "stop_sequence": null,
                      "usage": {
                        "input_tokens": 11,
                        "output_tokens": 2,
                        "cost_usd": "0.0000465"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "- moderation stopped the prompt before the model (type: content_policy_violation)\n- the body does not parse as JSON — the Anthropic converter reads it itself",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "no key in the request, or the key is not ours",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "the balance does not cover this request's worst-case price",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`model_not_found` — the model is switched off by the admin — it leaves GET /v1/models too",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "the upstream answered with a rate limit; retry with a delay",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "the upstream is unreachable (type: upstream_error)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "504": {
            "description": "a timeout waiting for the upstream: 300 s read, 60 s without bytes on a stream",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/embeddings": {
      "post": {
        "operationId": "embeddings",
        "summary": "Embeddings",
        "description": "Vectors for text, in the OpenAI shape.\n\nA straight pass-through to an OpenAI-compatible upstream: this route has no logic of its own beyond the gateway-wide parts — idempotency, the disabled-model check, the balance check and the cost in the answer. Fields other than those listed (dimensions, encoding_format and the rest) are forwarded untouched. The gateway picks no model for you: model must name an embedding model from the catalog.\n\nThis request's cost arrives inside the answer: the x-teamtoken-cost-usd header and usage.cost_usd in the body. The value is a decimal string (\"0.0000465\"), not a number: a number would be re-displayed by the client language's own float rules (Python would show 4.65e-05), while a string reaches your code exactly as written. The cost can also fail to arrive at all — then it is in neither the header nor the field: on a non-streaming answer when the upstream did not report it, on a stream when the model has no catalog tariff. There is no streaming for embeddings: the gateway reads the whole answer. Moderation does not apply to this path — it is only armed where there is a prompt for a model.\n\nA byte-identical request with the same key inside a short TTL (60 s by default) does not reach the model twice — the gateway returns the first answer's body and charges nothing for the second. Only a successful answer of at most 256 KB is cached; anything else goes upstream again. A replay from the cache carries no x-teamtoken-cost-usd header (nothing was charged), and the cost_usd in its body belongs to the first, paid answer. Hence the corollary: an already-paid answer is served even on an empty wallet — the balance check sits AFTER idempotency. Every error arrives in one envelope (the code field is not on every status): { \"error\": { \"message\": \"...\", \"type\": \"...\", \"code\": \"PROVIDER_CODE\" } }.\n\nThe answer always carries back the logical model name you asked for. If it does not fit the gateway's wait (300 s read by default, 60 s without bytes on a stream) you get a 504; the gateway marks that non-streaming attempt, and if the upstream finished and billed anyway, the reconciler credits the amount back to your balance as a compensating grant. Upstream host names are scrubbed out of error bodies, so an error's text can differ from what the upstream sent.\n\nThe endpoint has no route of its own: the gateway forwards the request upstream as is. Fields not listed above reach the upstream unchanged — including ones its API grows later — and the reply is returned as the upstream produced it, plus our cost of the request.",
        "tags": [
          "text"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/embeddings"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "model": {
                    "type": "string",
                    "description": "the name of an embedding model from GET /v1/models Model identifier. The set of accepted values is deliberately absent: the catalog is edited from the admin UI without a deploy, so the current list comes from the catalog endpoints: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
                  },
                  "input": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "the text, or an array of texts; the answer keeps the input order"
                  }
                },
                "required": [
                  "model",
                  "input"
                ],
                "additionalProperties": true
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "# MODEL — an embedding model id from GET https://api.teamtoken.store/v1/models\ncurl https://api.teamtoken.store/v1/embeddings \\\n  -H \"Authorization: Bearer sk-…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \"{\\\"model\\\": \\\"$MODEL\\\", \\\"input\\\": \\\"text to embed\\\"}\""
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "response": {
                    "summary": "Response",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "embedding",
                          "index": 0,
                          "embedding": [
                            0.0023,
                            -0.0091,
                            0.0157
                          ]
                        }
                      ],
                      "usage": {
                        "prompt_tokens": 5,
                        "total_tokens": 5,
                        "cost_usd": "0.0000001"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "moderation stopped the prompt before the model (type: content_policy_violation)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "no key in the request, or the key is not ours",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "the balance does not cover this request's worst-case price",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`model_not_found` — the model is switched off by the admin — it leaves GET /v1/models too",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "the upstream answered with a rate limit; retry with a delay",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "the upstream is unreachable (type: upstream_error)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "504": {
            "description": "a timeout waiting for the upstream: 300 s read, 60 s without bytes on a stream",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/images/generations": {
      "post": {
        "operationId": "imagesGenerations",
        "summary": "Image generation",
        "description": "Images come back as base64 in the same answer; a long generation returns a job id (202).\n\nThe gateway creates a job, reserves the money for it (under a per-user lock, so two concurrent requests cannot both pass on the same balance), submits it to the provider and polls for up to 150s. If that is enough you get 200 with the images in `b64_json`; if not, 202 with `{ id, status, estimated_cost_usd }`, and the result is fetched from `GET /v1/images/jobs/{job_id}`. The reserve stays in place, so the money is not released while the generation runs.\n\nThe reply never contains a provider CDN link: when the provider answers with a URL, the gateway downloads the image itself and returns base64. That is why `response_format` is accepted and ignored — the result is always `b64_json`. Unknown body fields are dropped silently too: only what the model's allowlist knows is forwarded.\n\nAbout the fields. An unknown or admin-disabled model, a missing `prompt`, an `n` that is not an integer or falls outside 1–10, and a parameter value outside its own model's set are all a 400 on our side, before the provider. The `aspect_ratio` set depends on the model: some take more (plus `3:2`, `2:3`, `21:9`), and some take `orientation` instead; a field the model does not have is dropped silently. Neither `resolution` nor the framing affects the price: the tariff is per image. `n` is not forwarded to the provider at all — it sizes the reserve, while the charge follows the number of images that came back (none came back — then `n`). A provider submit failure on a 5xx or the network leaves the money reserved and returns the `id` in the error body: the reconciler finishes the job, or releases the reserve if the provider never took it. A provider 4xx, by contrast, is a definitive rejection: the job fails at once and the reserve is released without waiting for the reconciler.\n\nMoney: a finished generation reports `cost_usd`, exactly what was charged; a running one reports `estimated_cost_usd`, the size of the reserve. A failed generation is not billed and comes back with `\"cost_usd\": \"0\"`. Media billing is ours, kept apart from text. Errors answer in one shape; `code` is there when the provider gave one, and a submit rejection adds the job's `id`: `{ \"error\": { \"message\": \"...\", \"type\": \"...\", \"code\": \"PROVIDER_CODE\" } }`.\n\nReference images (image-to-image, same character or style) go in the body as `image` — a string or an array; `images`, `image_url`, `image_urls`, `input_image`, `init_image` and `ref_images` are accepted too. A value may be a public URL, a data-URL or bare base64, and mixing the forms in one request is fine: a URL is not fetched by us and reaches the provider as a link, while base64 is decoded and sent as bytes. The gateway recognises bytes by their signature (PNG, JPEG, GIF, WebP) and caps each input at 80 MB — anything larger is a 400 before the provider is called. An input that passed our cap may still be refused by the model under its own rules: its code then arrives in `error.code` — `FILE_TOO_LARGE` for size, `FILE_TYPE_NOT_ALLOWED` for type. The gateway puts no limit on the number of references; how many the model actually uses is up to it. A value that parses neither as a URL nor as base64 is dropped silently: the request goes out without that reference and without an error.\n\nA result stays retrievable for 7 days. After that the bytes move out of the database into the archive, `result_url` is cleared, and the same `GET /v1/images/jobs/{job_id}` answers `{ \"status\": \"completed\", \"archived\": true, \"data\": [] }`. That means expired, not \"the generation produced nothing\" — store the images on your side as soon as you get them. The job row and its money are never deleted: retention moves the payload only.",
        "tags": [
          "images"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/images-generations"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "model": {
                    "type": "string",
                    "description": "an image model id from the catalog Model identifier. The set of accepted values is deliberately absent: the catalog is edited from the admin UI without a deploy, so the current list comes from the catalog endpoints: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
                  },
                  "prompt": {
                    "type": "string",
                    "description": "what to draw; we check it is there, the model checks length (`EMPTY_PROMPT`)"
                  },
                  "n": {
                    "type": "integer",
                    "default": 1,
                    "description": "how many images, 1–10; it sizes the reserve, the charge follows the result"
                  },
                  "aspect_ratio": {
                    "type": "string",
                    "enum": [
                      "1:1",
                      "16:9",
                      "9:16",
                      "4:3",
                      "3:4"
                    ],
                    "default": "1:1",
                    "description": "aspect ratio; the accepted set depends on the model"
                  },
                  "resolution": {
                    "type": "string",
                    "enum": [
                      "1K",
                      "2K",
                      "4K"
                    ],
                    "default": "1K",
                    "description": "resolution; not every model has it (`nano-banana-pro` does)"
                  },
                  "orientation": {
                    "type": "string",
                    "enum": [
                      "landscape",
                      "portrait",
                      "square"
                    ],
                    "default": "square",
                    "description": "orientation — for the models that do not take `aspect_ratio`"
                  },
                  "image": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "a reference: a URL, a data-URL or bare base64; an array — several"
                  },
                  "response_format": {
                    "type": "string",
                    "description": "accepted for compatibility and ignored"
                  }
                },
                "required": [
                  "model",
                  "prompt"
                ],
                "additionalProperties": true
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/v1/images/generations \\\n  -H \"Authorization: Bearer sk-…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"model\": \"nano-banana-pro\", \"prompt\": \"a red cube on white\", \"aspect_ratio\": \"1:1\", \"resolution\": \"1K\" }'"
          },
          {
            "lang": "Shell",
            "label": "cURL · image-to-image",
            "source": "curl https://api.teamtoken.store/v1/images/generations \\\n  -H \"Authorization: Bearer sk-…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"model\": \"nano-banana-pro\",\n        \"prompt\": \"the same person, in a forest, golden hour\",\n        \"images\": [\"https://example.com/ref1.jpg\", \"data:image/jpeg;base64,/9j/4AAQ...\"],\n        \"aspect_ratio\": \"1:1\" }'"
          },
          {
            "lang": "Python",
            "label": "Python",
            "source": "import requests\n\nr = requests.post(\n    \"https://api.teamtoken.store/v1/images/generations\",\n    headers={\"Authorization\": \"Bearer sk-…\"},\n    json={\"model\": \"nano-banana-pro\", \"prompt\": \"a red cube on white\",\n          \"aspect_ratio\": \"1:1\", \"resolution\": \"1K\"},\n    timeout=180,  # the gateway polls the provider for up to 150s\n)\nbody = r.json()\n\nif r.status_code == 202:\n    # too slow for one request: the job keeps running and the money stays reserved\n    print(body[\"id\"], body[\"estimated_cost_usd\"])\nelse:\n    print(body[\"cost_usd\"], body[\"data\"][0][\"b64_json\"][:32])"
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "200": {
                    "summary": "200",
                    "value": {
                      "created": 0,
                      "cost_usd": "0.027",
                      "data": [
                        {
                          "b64_json": "iVBORw0KGgoAAAANSUhEUgAA…"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Not an error: the status is described by the example below (for media `202` means «still generating»).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "202": {
                    "summary": "202",
                    "value": {
                      "id": "img_9f2c1b7e…",
                      "status": "processing",
                      "estimated_cost_usd": "0.027"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "- body is not JSON, no `prompt`, model/value/`n` outside its set, reference over 80 MB\n- `FILE_DOWNLOAD_FAILED` — the submit was rejected: the provider's 4xx is echoed with its code, the hold released",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "no key in the request, or the key is not ours",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "the balance does not cover this request's worst-case price",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "- the provider accepted the job and the generation failed; its code is in `error.code`\n- the submit failed on a 5xx or the network: whether the job was accepted is unknown",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "502": {
                    "summary": "502",
                    "value": {
                      "error": {
                        "message": "The request was blocked by the content safety filter.",
                        "type": "generation_error",
                        "code": "GEMINI_RAI_MEDIA_FILTERED"
                      },
                      "cost_usd": "0"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/images/jobs/{job_id}": {
      "get": {
        "operationId": "imagesJob",
        "summary": "Generation status and result",
        "description": "Poll the job a generation returned with 202: status, and the images once they are ready.\n\nThis is how a result is fetched after a 202. A job is visible only to the user who created it: someone else's real id answers exactly like a non-existent one — a leaked id must not even confirm that it is real.\n\nThe shape of the reply follows the status, and it is built around the money. `completed` — `cost_usd` (what was charged) and `data` with the images. `queued` / `processing` / `unknown_submit` — `estimated_cost_usd`, the size of the reserve that is still held. `failed` — `cost_usd: \"0\"`, because a failed generation is not billed and its reserve has already been released.\n\nA result stays retrievable for 7 days. After that the bytes move out of the database into the archive, `result_url` is cleared, and the same `GET /v1/images/jobs/{job_id}` answers `{ \"status\": \"completed\", \"archived\": true, \"data\": [] }`. That means expired, not \"the generation produced nothing\" — store the images on your side as soon as you get them. The job row and its money are never deleted: retention moves the payload only.",
        "tags": [
          "images"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/images-job"
        },
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "description": "the id from the 202 reply (`img_…`)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/v1/images/jobs/img_9f2c1b7e \\\n  -H \"Authorization: Bearer sk-…\""
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "completed": {
                    "summary": "completed",
                    "value": {
                      "id": "img_9f2c1b7e…",
                      "status": "completed",
                      "cost_usd": "0.027",
                      "data": [
                        {
                          "b64_json": "iVBORw0KGgoAAAANSUhEUgAA…"
                        }
                      ]
                    }
                  },
                  "processing": {
                    "summary": "processing",
                    "value": {
                      "id": "img_9f2c1b7e…",
                      "status": "processing",
                      "estimated_cost_usd": "0.027"
                    }
                  },
                  "failed": {
                    "summary": "failed",
                    "value": {
                      "id": "img_9f2c1b7e…",
                      "status": "failed",
                      "cost_usd": "0"
                    }
                  },
                  "archived": {
                    "summary": "archived",
                    "value": {
                      "id": "img_9f2c1b7e…",
                      "status": "completed",
                      "cost_usd": "0.027",
                      "data": [],
                      "archived": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "no key in the request, or the key is not ours",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "no such job — or it is not yours",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/images/edits": {
      "post": {
        "operationId": "imagesEdits",
        "summary": "Image edit",
        "description": "An edit is a generation with references: the same handler, a JSON body only.\n\nThere is no separate code behind this route: it delegates to `POST /v1/images/generations`, because an edit is a generation with references attached. Every parameter, reply, error code and billing rule is the same.\n\n⚠️ The body is parsed **as JSON only**, so references must arrive in JSON fields (`image` / `images` / `image_url`), with the bytes themselves as a data-URL or a base64 string in the value. A real multipart upload — which is exactly what `images.edit` sends from the OpenAI SDK — fails with 400 \"Invalid JSON\". That is a gateway limitation, not a provider one: until multipart is parsed here, SDK compatibility cannot be claimed for this route. From `requests`/`curl`, send JSON with the image as a data-URL or a base64 string.\n\nReference images (image-to-image, same character or style) go in the body as `image` — a string or an array; `images`, `image_url`, `image_urls`, `input_image`, `init_image` and `ref_images` are accepted too. A value may be a public URL, a data-URL or bare base64, and mixing the forms in one request is fine: a URL is not fetched by us and reaches the provider as a link, while base64 is decoded and sent as bytes. The gateway recognises bytes by their signature (PNG, JPEG, GIF, WebP) and caps each input at 80 MB — anything larger is a 400 before the provider is called. An input that passed our cap may still be refused by the model under its own rules: its code then arrives in `error.code` — `FILE_TOO_LARGE` for size, `FILE_TYPE_NOT_ALLOWED` for type. The gateway puts no limit on the number of references; how many the model actually uses is up to it. A value that parses neither as a URL nor as base64 is dropped silently: the request goes out without that reference and without an error.",
        "tags": [
          "images"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/images-edits"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "model": {
                    "type": "string",
                    "description": "an image model id — the same catalog as generation Model identifier. The set of accepted values is deliberately absent: the catalog is edited from the admin UI without a deploy, so the current list comes from the catalog endpoints: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
                  },
                  "prompt": {
                    "type": "string",
                    "description": "what to change; we check it is there, the model checks length (`EMPTY_PROMPT`)"
                  },
                  "image": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "the image being edited: a URL, a data-URL or base64; an array for several"
                  }
                },
                "required": [
                  "model",
                  "prompt"
                ],
                "additionalProperties": true
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/v1/images/edits \\\n  -H \"Authorization: Bearer sk-…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"model\": \"nano-banana-pro\",\n        \"prompt\": \"replace the background with a snowy street at night\",\n        \"image\": \"data:image/png;base64,iVBORw0KGgo...\" }'"
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "200": {
                    "summary": "200",
                    "value": {
                      "created": 0,
                      "cost_usd": "0.027",
                      "data": [
                        {
                          "b64_json": "iVBORw0KGgoAAAANSUhEUgAA…"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Not an error: the status is described by the example below (for media `202` means «still generating»).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "202": {
                    "summary": "202",
                    "value": {
                      "id": "img_9f2c1b7e…",
                      "status": "processing",
                      "estimated_cost_usd": "0.027"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "- the body is not JSON (SDK multipart included), no `prompt`, or a reference over 80 MB\n- `FILE_DOWNLOAD_FAILED` — the submit was rejected: the provider's 4xx is echoed with its code, the hold released",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "no key in the request, or the key is not ours",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "the balance does not cover this request's worst-case price",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "- the provider accepted the job and the generation failed; its code is in `error.code`\n- the submit failed on a 5xx or the network: whether the job was accepted is unknown",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/videos": {
      "post": {
        "operationId": "videos",
        "summary": "Create a video",
        "description": "Queues a video generation: 202 with a job id, or the finished result inline with wait: true.\n\nGeneration is asynchronous. By default the endpoint answers `202` with a job id and the size of the hold (`estimated_cost_usd`), and the result is collected via `GET /v1/videos/{job_id}`. With `wait: true` (or any `timeout` present) the gateway polls the provider itself — 2 s per poll, capped at 90 s; `timeout` can only shorten that budget, never raise it. If the budget runs out you get a `202` with the same job id and the job keeps running. Polling is optional: the reconciler (one pass every 150 s) finalizes a finished job on its own — charges it and stores the result links.\n\nThere are three input modes, chosen by the body rather than by the URL: `prompt` — text-to-video; `prompt` + `image` — image-to-video; `prompt` + `video` — video-to-video (edit and motion control, where `image` is the character and `video` the motion). What the engine does with an input is the engine's business; our own check is one — an engine that requires an input video answers `400` without one on our side, before the provider. The model list lives in the catalog (`GET /cabinet/api/public/media-models`), but the catalog does not serve the required inputs or the duration sets: those are a table on our side, visible from outside only through our error text.\n\n`/v1/video/generations`, `/v1/videos/extend` and `/v1/videos/storyboard` are aliases of this same handler: the same body, the same responses and errors, no per-path behaviour at all. Extending a clip and storyboarding are selected by the model — separate engines in the catalog (`*-extend`, `*-storyboard`) — and the source clip is passed in `ref_video_job_id`.\n\nAbout the fields. Length and its accepted set belong to the engine: seedance — 4–15 s (5 by default) · kling — 3–15 on 3.0, 3–10 on edit/o1/motion (5) · kling 2.5/2.6 — only 5 or 10 · kling 2.1 — exactly 10 or exactly 5, depending on the model · veo — 4/6/8 (8) · omni-flash — 4/6/8/10, though every catalog row pins one of them · grok — 6 only · extend — 8 on veo, 6 on grok, 4–15 on seedance · storyboard — 6–30 (6). A value outside the set answers `400` listing what is allowed (for a range, its bounds). ⚠️ A catalog row may pin the length outright (`fixed_params`), and then the `duration` you sent never reaches the provider. ⚠️ Omit `duration` and the provider gets the engine's default while the hold is sized at the engine's MAXIMUM (`hold_duration_seconds`); the charge follows the finished clip's actual length. `timeout` is clamped at the same 90 s, and a non-numeric value falls back to the same 90.\n\nInputs are accepted under several names: `image` is also `images`, `image_url`, `image_urls`, `input_image`, `init_image`, `ref_images`, and `video` is also `videos`, `video_url`, `input_video`, `motion_video`, `ref_video`, `ref_videos`. URLs — images and videos alike — are fetched by the gateway itself (own User-Agent, own size cap, internal addresses refused at every redirect hop); if the fetch fails or the body does not look like media, the link is forwarded as-is. ⚠️ An input the engine does not expect is still forwarded to the provider: the catalog is consulted for requiredness only. Parameters outside the engine's set (`resolution`, `size`, any unknown field) are dropped silently — resolution and tier come from the catalog, not from the request.\n\nWhat answers `400` before the provider: broken JSON, an unknown or disabled model, a missing `prompt`, a `duration` outside the engine's set, a required input not sent, an input over 80 MB (the cap is per decoded file). `ref_video_job_id` is owner-scoped: someone else's id, or a missing one, answers `404` so a leaked id confirms nothing. A provider submit failure: its 4xx is echoed verbatim — with its code and its reason, scrubbed of upstream names — and that is final, the job is `failed` and the hold released; a 5xx or a network error means the job MAY have been accepted, and refunding on a guess would be a double credit — the job goes to `unknown_submit` and waits for the reconciler, which either completes it or releases the hold on TTL (`unknown_submit` has a short one, 15 minutes by default; everything else, a day).",
        "tags": [
          "videos"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/videos"
        },
        "x-teamtoken-aliases": [
          "/v1/video/generations",
          "/v1/videos/extend",
          "/v1/videos/storyboard"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "model": {
                    "type": "string",
                    "description": "a video model name from the catalog (`GET /cabinet/api/public/media-models`) Model identifier. The set of accepted values is deliberately absent: the catalog is edited from the admin UI without a deploy, so the current list comes from the catalog endpoints: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
                  },
                  "prompt": {
                    "type": "string",
                    "description": "the scene to generate; required by every video engine"
                  },
                  "duration": {
                    "type": "number",
                    "description": "length in seconds; the default and the accepted set come from the model's engine"
                  },
                  "seconds": {
                    "type": "number",
                    "description": "alias of `duration`; if both are sent, `duration` wins"
                  },
                  "aspect_ratio": {
                    "type": "string",
                    "enum": [
                      "16:9",
                      "9:16",
                      "1:1"
                    ],
                    "default": "16:9",
                    "description": "the finished clip's frame. The same set on every video engine"
                  },
                  "image": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "an input image: a URL, a data-URL, base64 or the uuid of an image at the provider; an array — several"
                  },
                  "video": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "an input video for video-to-video: the same forms as `image`"
                  },
                  "wait": {
                    "type": "boolean",
                    "default": false,
                    "description": "`true` — hold the connection and return the result, but no longer than 90 s"
                  },
                  "timeout": {
                    "type": "number",
                    "default": 90,
                    "description": "how many seconds to wait; its presence enables waiting even without `wait`"
                  },
                  "ref_video_job_id": {
                    "type": "string",
                    "description": "the id of a video job of YOURS — its provider uuid is passed as the source"
                  },
                  "scenes": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "scenes for the storyboard engine; unvalidated, and not forwarded on other engines"
                  }
                },
                "required": [
                  "model",
                  "prompt"
                ],
                "additionalProperties": true
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/v1/videos \\\n  -H \"Authorization: Bearer sk-…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"model\": \"seedance-2-fast-720p\",\n        \"prompt\": \"a corgi surfing a neon wave at sunset\",\n        \"duration\": 5,\n        \"wait\": true }'"
          },
          {
            "lang": "Shell",
            "label": "cURL · image-to-video",
            "source": "# оживить картинку / animate an image: prompt + image (base64 и data-URL тоже / base64 and data-URLs too)\ncurl https://api.teamtoken.store/v1/videos \\\n  -H \"Authorization: Bearer sk-…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"model\": \"seedance-2-fast-720p\",\n        \"prompt\": \"slow cinematic push-in, gentle motion\",\n        \"image\": \"https://example.com/photo.jpg\",\n        \"wait\": true }'"
          },
          {
            "lang": "Shell",
            "label": "cURL · motion control",
            "source": "# video-to-video: персонаж с картинки повторяет движение из видео /\n# the character from the image repeats the motion from the video.\n# Поле video ОБЯЗАТЕЛЬНО / the video field is REQUIRED here — без него 400 / a 400 without it.\ncurl https://api.teamtoken.store/v1/videos \\\n  -H \"Authorization: Bearer sk-…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"model\": \"kling-3.0-motion-720p\",\n        \"prompt\": \"the character performs the dance smoothly\",\n        \"image\": \"https://example.com/character.png\",\n        \"video\": \"https://example.com/motion.mp4\",\n        \"wait\": true }'"
          },
          {
            "lang": "Python",
            "label": "Python · 202 + опрос",
            "source": "import time\nimport requests\n\nH = {\"Authorization\": \"Bearer sk-…\"}\n\njob = requests.post(\"https://api.teamtoken.store/v1/videos\", headers=H, json={\n    \"model\": \"seedance-2-fast-720p\",\n    \"prompt\": \"a corgi surfing a neon wave at sunset\",\n    \"duration\": 5,\n}).json()                       # 202: {\"id\": \"vid_…\", \"status\": \"processing\", …}\n\nr = job\nwhile r[\"status\"] not in (\"completed\", \"failed\"):\n    time.sleep(5)               # статусы до терминального / non-terminal: pending, queued, processing\n    r = requests.get(\"https://api.teamtoken.store/v1/videos/\" + job[\"id\"], headers=H).json()\n\nif r[\"status\"] == \"failed\":\n    raise SystemExit(r)         # причина в r[\"error\"] / the reason is in r[\"error\"]\n\n# ссылка из data ведёт на наш /content и требует ключа того же аккаунта /\n# the link in data points at our /content and needs a key of the same account\nmp4 = requests.get(r[\"data\"][0][\"url\"], headers=H).content"
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "200-wait-true": {
                    "summary": "200 · wait: true",
                    "value": {
                      "id": "vid_9f1c4a2b7e0d4f5a8c3b6d1e2f0a7b4c",
                      "status": "completed",
                      "created": 1757500800,
                      "duration": 5,
                      "cost_usd": "0.95",
                      "data": [
                        {
                          "url": "https://api.teamtoken.store/v1/videos/vid_9f1c4a2b7e0d4f5a8c3b6d1e2f0a7b4c/content"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Not an error: the status is described by the example below (for media `202` means «still generating»).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "202-accepted": {
                    "summary": "202 Accepted",
                    "value": {
                      "id": "vid_9f1c4a2b7e0d4f5a8c3b6d1e2f0a7b4c",
                      "status": "processing",
                      "model": "seedance-2-fast-720p",
                      "created": 1757500800,
                      "estimated_cost_usd": "0.95"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "- fails our own check before the provider; the reason is in `error.message`\n- `PROVIDER_CODE` — the provider rejected the submit: its own 4xx is echoed, job `failed`, hold released",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "no key in the request, or the key is not ours",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "the balance does not cover this request's worst-case price",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`ref_video_job_id` points at a job that does not exist or is not yours",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "- a 5xx or network on submit: the job may have been accepted — `unknown_submit`, hold stays\n- `PROVIDER_CODE` — with `wait: true` the generation failed: the hold is released, body has `cost_usd: \"0\"`\n- the hold could not be recorded: the job is failed and the money is free\n- the provider answered `200` with no job id: the job is failed and the hold released",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "502": {
                    "summary": "502 · генерация провалилась",
                    "value": {
                      "error": {
                        "message": "No valid characters detected in the image",
                        "type": "generation_error",
                        "code": "KLING_GENERATION_FAILED"
                      },
                      "cost_usd": "0"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/videos/{job_id}": {
      "get": {
        "operationId": "videosJob",
        "summary": "Video job status",
        "description": "A video job's state, and the link to the finished clip.\n\nThis endpoint does more than read a row: if the job is `queued` or `processing` and has a provider id, it polls the provider inside this very request and, if the provider is done, finalizes the job — charges it and stores the result links. So the first successful poll is also what hands you the finished video. A job in `unknown_submit` (the submit was never confirmed) is not polled here — the reconciler finishes that one.\n\nA terminal answer describes OUR database rather than the provider's claim, and that is not pedantry: the reconciler releases the hold of a job stuck for longer than a day (the threshold is a setting), marking it `failed` and refunding it. Should the provider finish after that, answering \"completed\" would hand out links that `/content` answers `404` to. Such a job honestly reports `failed`. While the job is NOT terminal, though, `status` is the provider's own word folded onto our set: `pending` or `processing`.\n\nCost follows the state, and the fields never overlap: `estimated_cost_usd` is the hold while the job runs; `cost_usd` is the actual charge once it is done; `cost_usd: \"0\"` once it has failed. Both are decimal strings, not JSON numbers: a number would be reprinted by the client's float printer (`4.65e-05` instead of `0.0000465`). `duration` appears only in the answer that itself drove the job to `completed`, and `error` only in the one that itself failed it; a later read of the same row carries neither, while the `data` links stay. A failed generation shows up here as `status: \"failed\"` inside a `200`, not as an HTTP error — the only HTTP errors this endpoint returns are a missing key and someone else's job — and someone else's answers exactly like a missing one, so a leaked id confirms nothing.",
        "tags": [
          "videos"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/videos-job"
        },
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "description": "the id from the create response — `vid_…`",
            "schema": {
              "type": "string"
            }
          }
        ],
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/v1/videos/vid_9f1c4a2b7e0d4f5a8c3b6d1e2f0a7b4c \\\n  -H \"Authorization: Bearer sk-…\""
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "200": {
                    "summary": "200 · идёт",
                    "value": {
                      "id": "vid_9f1c4a2b7e0d4f5a8c3b6d1e2f0a7b4c",
                      "status": "processing",
                      "estimated_cost_usd": "0.95"
                    }
                  },
                  "200-2": {
                    "summary": "200 · готово",
                    "value": {
                      "id": "vid_9f1c4a2b7e0d4f5a8c3b6d1e2f0a7b4c",
                      "status": "completed",
                      "duration": 5,
                      "cost_usd": "0.95",
                      "data": [
                        {
                          "url": "https://api.teamtoken.store/v1/videos/vid_9f1c4a2b7e0d4f5a8c3b6d1e2f0a7b4c/content"
                        }
                      ]
                    }
                  },
                  "200-2-2": {
                    "summary": "200 · провал",
                    "value": {
                      "id": "vid_9f1c4a2b7e0d4f5a8c3b6d1e2f0a7b4c",
                      "status": "failed",
                      "error": {
                        "message": "The request was blocked by the content safety filter.",
                        "code": "GEMINI_RAI_MEDIA_FILTERED"
                      },
                      "cost_usd": "0"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "no key in the request, or the key is not ours",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "no such job — or it is not yours",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/videos/{job_id}/content": {
      "get": {
        "operationId": "videosContent",
        "summary": "Download the finished video",
        "description": "Serves the clip's actual bytes — MP4 streamed through the gateway, not a redirect.\n\nThese are exactly the links a finished job returns in `data`, and any key of the same account can open them. The answer is a `200` with `Content-Type: video/mp4` and the file as its body: the gateway fetches the clip from the provider and pipes it to you. There is deliberately no redirect — the host we take the video from is never shown outside, so nobody gets a link to it. By the same principle the result host is checked against an allowlist before the fetch, and the upstream's status is checked BEFORE streaming starts: otherwise the client would get a `200` that dies mid-body.\n\nOne request, one clip. When a job returned several (storyboard, multi-output), `data` holds links with `?i=0`, `?i=1`, … — take them from there instead of assembling them by hand.\n\nOn lifetime. A video job's row holds the provider's link, not our bytes, so retention never clears it — there is nothing of ours to move, and it only stamps the row's `archived_at` so the row leaves the candidate queue. For video that stamp means precisely \"retention is done with this row\", not \"the bytes have moved out\". That does not make the clip available forever: it lives on the provider's side, and its lifetime is neither checked in our code nor guaranteed by us. Download it as soon as you have it.\n\nOne and the same `404` covers every case — no such job, someone else's, not finished yet, the result no longer stored, an `i` outside the set, a result host outside the allowlist — and that is deliberate: distinct answers would leak facts about other people's jobs. The `502` is checked before streaming starts, so it is an honest error rather than a truncated file; money is not involved — the job is already paid for.",
        "tags": [
          "videos"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/videos-content"
        },
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "description": "the id of a completed job — `vid_…`",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "i",
            "in": "query",
            "required": false,
            "description": "which of the job's clips to serve; a non-numeric value reads as `0`",
            "schema": {
              "type": "integer",
              "default": 0
            }
          }
        ],
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/v1/videos/vid_9f1c4a2b7e0d4f5a8c3b6d1e2f0a7b4c/content \\\n  -H \"Authorization: Bearer sk-…\" \\\n  -o out.mp4"
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response\n\n**200**\n\n```\nHTTP/1.1 200 OK\ncontent-type: video/mp4\n\n<байты MP4 — тело файла, не JSON>\n```"
          },
          "401": {
            "description": "no key in the request, or the key is not ours",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "nothing to serve, or not yours to get: no such job, someone else's, or unfinished",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "the source the gateway fetches the clip from is unreachable or answered non-`200`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/models": {
      "get": {
        "operationId": "models",
        "summary": "List models",
        "description": "The list of logical models for the model field. No prices here — those live in the catalog.\n\nReturns what OpenAI's /v1/models returns: an object with a data list, each entry carrying an id — exactly the string to put in model. Other fields are passed through as-is, so more of the standard keys may show up.\n\nA key is optional here: with no header at all the gateway asks the upstream with its own credential. A key you do send is forwarded as is, and the upstream's refusal comes back unchanged.\n\nTwo things are filtered out. First, the internal names the gateway uses to spread traffic across itself: they are not callable and are never shown. Second, models an admin switched off: they exist, but a request would not reach them, so they are not listed either. There are no prices here at all — for prices see GET /cabinet/api/public/models.",
        "tags": [
          "account"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/models"
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/v1/models \\\n  -H \"Authorization: Bearer sk-…\""
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "response": {
                    "summary": "Response",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "id": "gpt-5.6-sol",
                          "object": "model"
                        },
                        {
                          "id": "gemini-3.1-pro",
                          "object": "model"
                        }
                      ]
                    }
                  }
                }
              }
            }
          }
        },
        "security": []
      }
    },
    "/v1/balance": {
      "get": {
        "operationId": "balance",
        "summary": "Key balance",
        "description": "How much money the key's account has: granted, spent, left.\n\nThe balance comes from one formula: granted − text spend − effective media spend. Hence three numbers plus a currency:\n\ngranted — everything ever credited to the account; spend — total spend, text and media as one number; balance — what is left, granted minus spend; currency — always USD.\n\nWhat matters about spend: its media part counts not only what was charged but also what is held for generations still running. A hold is released when the job finishes (turning into a charge) or fails (returning the money). That is why balance drops the moment you queue a generation rather than at the end — otherwise ten generations could be queued on money that covers one.\n\nDegradation worth knowing in advance. If our storage is unreachable the response shape does not change, but granted is taken from the account budget, which already has media subtracted; while that budget is in sync, balance comes out the same. If the key-and-remainder check itself is unreachable, the endpoint answers 502 and returns no number at all: no answer is more honest than an invented one.",
        "tags": [
          "account"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/balance"
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/v1/balance \\\n  -H \"Authorization: Bearer sk-…\""
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "response": {
                    "summary": "Response",
                    "value": {
                      "object": "balance",
                      "granted": 100,
                      "spend": 37.42,
                      "balance": 62.58,
                      "currency": "USD"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "no key in the request, or the key is not ours",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "the key-and-remainder check is unreachable — there is no number, retry later",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/cabinet/api/public/models": {
      "get": {
        "operationId": "publicModels",
        "summary": "Priced text model catalog",
        "description": "The live list of text models and their per-1M-token prices. No key.\n\nThis is the only public source of text prices. They live in the database and an admin changes them without a deploy — so any price written into a document goes stale silently. Hence: the fields are described here, the numbers are read from this endpoint.\n\nThe answer is an array, one entry per logical model, sorted by name. Fields: model — the model name, the same string the request's model field takes; input_per_1m — price of input tokens per 1M, in USD; output_per_1m — price of output tokens per 1M; cache_per_1m — price of a prompt-cache hit (zero means there is no separate tariff and cached input is billed as ordinary input); cache_write_mult — how much more than ordinary input a write to the prompt cache costs.\n\nThe selection matches the cabinet's: internal names and models an admin switched off are absent. One model may carry several tariffs internally; the one served is the tariff a default request is billed at.",
        "tags": [
          "account"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/public-models"
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/cabinet/api/public/models"
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response\n\n**Response**\n\n```\n[\n  {\n    \"model\": \"gpt-5.6-sol\",\n    \"input_per_1m\": …,\n    \"output_per_1m\": …,\n    \"cache_per_1m\": …,\n    \"cache_write_mult\": …\n  }\n]\n```"
          }
        },
        "security": []
      }
    },
    "/cabinet/api/public/media-models": {
      "get": {
        "operationId": "publicMediaModels",
        "summary": "Priced media model catalog",
        "description": "The live list of image and video models with their per-unit price. No key.\n\nThe same as the text catalog but for media, and numberless in the prose for the same reason: the price is set by an admin in the database, not by a release.\n\nThe answer is an array sorted by modality and name. Fields: model — the model name for the request's model field; modality — image or video; billing_unit — what the money is charged for: per_image or per_second (of video); unit_price_usd — the price of one such unit, in USD.\n\nOnly models that are admin-enabled AND have a price set above zero appear. Practical consequence: a model missing from this list is not worth calling — it is either switched off or has no tariff.",
        "tags": [
          "account"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/public-media-models"
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/cabinet/api/public/media-models"
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response\n\n**Response**\n\n```\n[\n  {\n    \"model\": \"nano-banana-pro\",\n    \"modality\": \"image\",\n    \"billing_unit\": \"per_image\",\n    \"unit_price_usd\": …\n  },\n  {\n    \"model\": \"seedance-2-fast-720p\",\n    \"modality\": \"video\",\n    \"billing_unit\": \"per_second\",\n    \"unit_price_usd\": …\n  }\n]\n```"
          }
        },
        "security": []
      }
    },
    "/cabinet/api/public/model-status": {
      "get": {
        "operationId": "publicModelStatus",
        "summary": "Model availability",
        "description": "A snapshot of model availability: the success rate per model and a per-day history. No key.\n\nThe same snapshot the public status page draws. A background cycle computes it and the request only serves what is ready — from the in-process cache or from the stored snapshot; a recompute inside the request happens only when the snapshot is badly stale, i.e. the cycle has stopped. So the endpoint survives anonymous traffic and costs no model calls.\n\nFields: updated_at — when the snapshot was computed; overall — the gateway-wide summary (operational / degraded / down); groups — the groups (text, images, video), each with key, label (a ready-made caption) and models. Per model: model; status; success_rate — the share of successful requests as a percentage from 0 to 100, null when there was no data (the denominator counts only successes and model-side failures: a request refused on its own merits does not drag the model down); checked_at — the time of the last request (without an offset for text models, with one for media); history — one entry per day, each with day, rate (the same percentage) and status.\n\nThe window behind success_rate differs: a day for text, the whole history for media, because media requests are sparse and a 24-hour window would grey out a model that worked yesterday. Values of status: ok, degraded, down, unavailable (not a single request got through), no_data (no data for a text model) and awaiting (no confirmed traffic for a media model yet). Text models are pinged by the gateway itself; media models are not — they are too expensive, so their availability is counted from real client requests. Worth reading before reporting an error: no_data and awaiting mean \"no data\", not \"broken\".",
        "tags": [
          "account"
        ],
        "externalDocs": {
          "description": "Reference page",
          "url": "https://teamtoken.store/en/docs/api/public-model-status"
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/cabinet/api/public/model-status"
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "response": {
                    "summary": "Response",
                    "value": {
                      "updated_at": "2026-09-10T08:12:03.481920+00:00",
                      "overall": "operational",
                      "groups": [
                        {
                          "key": "text",
                          "label": "Текстовые модели",
                          "models": [
                            {
                              "model": "gpt-5.6-sol",
                              "status": "ok",
                              "success_rate": 100,
                              "checked_at": "2026-09-10T08:11:44.204000",
                              "history": [
                                {
                                  "day": "2026-09-09",
                                  "rate": 98,
                                  "status": "ok"
                                }
                              ]
                            }
                          ]
                        }
                      ]
                    }
                  }
                }
              }
            }
          }
        },
        "security": []
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "A key from the cabinet's «Keys» page: `Authorization: Bearer sk-…`."
      },
      "apiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "x-api-key",
        "description": "The same key for clients that cannot set `Authorization`: `x-api-key: sk-…`."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "The one error envelope. Its shape is the same for every status, so a client parses it once.",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "message": {
                "type": "string",
                "description": "A cause fit to show a human."
              },
              "type": {
                "type": "string"
              },
              "code": {
                "type": "string",
                "description": "Provider or gateway code, when the status carries one. Common media codes:\n- `EMPTY_PROMPT` — prompt is empty or shorter than 10 characters\n- `INVALID_VIDEO_FILE` — the model needs an input video (edit / motion) — add the video field\n- `FILE_TOO_LARGE` — the provider rejected the input's size; over 80 MB it never reaches the provider\n- `INVALID_INPUT` — a parameter is out of range or of the wrong type\n- `VIDEO_DURATION_TOO_LONG` — the input video is longer than the model accepts\n- `SERVICE_PRICE_NOT_FOUND` — unsupported model or option combination — the provider has no price for it\n- `GEMINI_RAI_MEDIA_FILTERED` — blocked by the provider's safety filter\n- `KLING_GENERATION_FAILED` — generation failed — check the input image or video and the prompt\n- `SEEDANCE_GENERATION_FAILED` — generation failed — the content may violate the policy\n- `SYSTEM_ERROR` — temporary provider failure — retry"
              }
            },
            "required": [
              "message"
            ]
          }
        },
        "required": [
          "error"
        ],
        "example": {
          "error": {
            "message": "...",
            "type": "...",
            "code": "PROVIDER_CODE"
          }
        }
      }
    }
  }
}
