{
  "openapi": "3.1.0",
  "info": {
    "title": "TeamToken — публичный API",
    "version": "1.0.0",
    "description": "Публичная поверхность шлюза TeamToken: текст (OpenAI- и Anthropic-совместимые ручки), картинки, видео, баланс и каталог.\n\nДокумент производный: он собирается из описания контракта, а не пишется руками, поэтому расходиться со страницами справочника ему не с чем.\n\nЦен здесь нет намеренно. Цены живые и меняются без деплоя; единственный их источник — каталожные ручки, отвечающие без ключа: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
  },
  "servers": [
    {
      "url": "https://api.teamtoken.store",
      "description": "Боевой шлюз"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    },
    {
      "apiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "text",
      "description": "Текст. OpenAI- и Anthropic-совместимые ручки чата, ответов и эмбеддингов. Стоимость приходит в самом ответе."
    },
    {
      "name": "images",
      "description": "Картинки. Генерация и правка картинок. Долгая генерация возвращает джоб, который опрашивается по id."
    },
    {
      "name": "videos",
      "description": "Видео. Асинхронная генерация видео из текста, картинки или другого видео плюс выдача результата."
    },
    {
      "name": "account",
      "description": "Аккаунт и каталог. Какие модели доступны и сколько денег на аккаунте ключа. Каталог с ценами открыт без ключа."
    }
  ],
  "paths": {
    "/v1/chat/completions": {
      "post": {
        "operationId": "chatCompletions",
        "summary": "Чат",
        "description": "Основная текстовая ручка: диалог в форме OpenAI.\n\nШлюз проксирует запрос в OpenAI-совместимый апстрим. Перечислены только поля, на которые он смотрит сам; все остальные поля тела (tools, tool_choice, response_format, temperature, seed, logprobs и прочие) передаются апстриму как есть, и мы их не переписываем и не документируем — их смысл задаёт OpenAI-схема.\n\nПотолок вывода (max_tokens или max_completion_tokens) задаёт худшую цену запроса, на которой стоит проверка баланса: чем он больше, тем вероятнее 402 на тонком остатке — при отказе его можно просто уменьшить. Не назвал ни одного — потолок берётся из каталога модели, и запрос всё равно оценивается не в нуль.\n\nСтоимость этого запроса приходит в самом ответе: заголовок x-teamtoken-cost-usd и поле usage.cost_usd в теле. Значение — строка с десятичной записью («0.0000465»), а не число: число после парсинга показывалось бы по правилам языка клиента (Python выдал бы 4.65e-05), строка доходит до кода ровно такой, как записана. Стоимость может и не прийти — тогда её нет ни в заголовке, ни в поле: в нестриминговом ответе так бывает, когда апстрим её не сообщил, в стриме — когда у модели нет тарифа в каталоге. В стриме заголовки уходят раньше, чем стоимость известна, поэтому она дописывается в финальный usage-кадр — попроси его через \"stream_options\": {\"include_usage\": true}. Без include_usage поток проходит через шлюз вообще без разбора кадров, и стоимости в нём не будет.\n\nБайт-в-байт такой же запрос с тем же ключом в пределах короткого TTL (по умолчанию 60 с) не уезжает в модель повторно — шлюз отдаёт тело первого ответа и второй раз денег не берёт. Кэшируется только успешный ответ не больше 256 КБ; всё остальное уедет апстриму заново. У повтора из кэша нет заголовка x-teamtoken-cost-usd (списания не было), а cost_usd в теле — от первого, оплаченного ответа. Отсюда же следствие: уже оплаченный ответ отдаётся и при пустом кошельке — проверка баланса стоит ПОСЛЕ идемпотентности. Порядок проверок до отправки: отключённая модель → модерация → баланс. Ответ без содержимого (ни текста, ни reasoning, ни отказа, ни вызова инструмента) шлюз считает сбоем и, если есть куда переключиться, повторяет запрос прежде, чем отдать его; ответ, оборванный политикой или потолком вывода, пустым не считается, а на стрим правило не распространяется вовсе — 200 у клиента уже начался. Любая ошибка приходит одним конвертом (поле code — не у каждого статуса): { \"error\": { \"message\": \"...\", \"type\": \"...\", \"code\": \"PROVIDER_CODE\" } }.\n\nМодерация, когда она включена, читает текст запроса и блокирует его только по реальному вердикту: недоступный или отвечающий ошибкой модератор запрос пропускает. Ответ всегда несёт то логическое имя модели, которое ты запросил. Если ответ не уложился в таймаут ожидания шлюза (по умолчанию чтение 300 с, в стриме 60 с без байтов), приходит 504; нестриминговую попытку шлюз при этом помечает, и если апстрим всё же досчитал и списал, реконсайлер возвращает сумму на баланс компенсирующим грантом. Из тел ошибок вычищены имена хостов апстрима, поэтому текст ошибки может отличаться от того, что прислал апстрим.\n\nСвоего роута у ручки нет: шлюз пробрасывает запрос апстриму как есть. Поля, не перечисленные выше, доезжают до апстрима без изменений — включая те, что появятся в его API позже, — а ответ возвращается таким, каким его отдал апстрим (плюс наша стоимость запроса).",
        "tags": [
          "text"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/docs/api/chat-completions"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "model": {
                    "type": "string",
                    "description": "логическое имя модели из GET /v1/models; префикс провайдера сворачивается Идентификатор модели. Списка допустимых значений здесь нет намеренно: каталог правится из админки без деплоя, поэтому актуальный список отдают каталожные ручки: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
                  },
                  "messages": {
                    "type": "array",
                    "description": "диалог в форме OpenAI: массив {role, content}; уходит апстриму без изменений"
                  },
                  "stream": {
                    "type": "boolean",
                    "description": "true — ответ приходит SSE-кадрами; отказы шлюза случаются до первого байта"
                  },
                  "stream_options": {
                    "type": "object",
                    "description": "{\"include_usage\": true} — единственный способ получить стоимость в стриме"
                  },
                  "max_tokens": {
                    "type": "integer",
                    "description": "потолок вывода (max_completion_tokens тоже); на нём считается худшая цена запроса"
                  }
                },
                "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": "Успешный ответ\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": "модерация остановила промпт до модели (type: content_policy_violation и категория)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "ключа нет в заголовке или он не наш",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "остатка не хватает на худшую цену этого запроса",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`model_not_found` — модель выключена админом — из GET /v1/models она пропадает тоже",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "апстрим ответил лимитом; повтори с задержкой",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "апстрим недоступен (type: upstream_error)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "504": {
            "description": "таймаут ожидания апстрима: чтение 300 с, в стриме 60 с без байтов",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/responses": {
      "post": {
        "operationId": "responses",
        "summary": "Responses API",
        "description": "OpenAI Responses API — на этот путь настраивается Codex CLI.\n\nПроксируется в OpenAI-совместимый апстрим ровно так же, как чат: перечислены только поля, которые читает сам шлюз, всё остальное тело (tools, reasoning, instructions, text и прочее) уходит как есть. Ручка нужна отдельным пунктом потому, что именно на неё настраивается Codex CLI: wire_api = \"responses\" в ~/.codex/config.toml.\n\nСтоимость этого запроса приходит в самом ответе: заголовок x-teamtoken-cost-usd и поле usage.cost_usd в теле. Значение — строка с десятичной записью («0.0000465»), а не число: число после парсинга показывалось бы по правилам языка клиента (Python выдал бы 4.65e-05), строка доходит до кода ровно такой, как записана. Стоимость может и не прийти — тогда её нет ни в заголовке, ни в поле: в нестриминговом ответе так бывает, когда апстрим её не сообщил, в стриме — когда у модели нет тарифа в каталоге. Стрим здесь идёт именованными SSE-событиями. Шлюз пытается дописать стоимость в кадр с usage (у Responses API он вложен в response.completed), но переписывает только кадр, начинающийся с data: — кадр с отдельной строкой event: уходит нетронутым, и тогда стоимость в стриме не приходит. В нестриминговом ответе стоимость приходит и заголовком, и полем.\n\nБайт-в-байт такой же запрос с тем же ключом в пределах короткого TTL (по умолчанию 60 с) не уезжает в модель повторно — шлюз отдаёт тело первого ответа и второй раз денег не берёт. Кэшируется только успешный ответ не больше 256 КБ; всё остальное уедет апстриму заново. У повтора из кэша нет заголовка x-teamtoken-cost-usd (списания не было), а cost_usd в теле — от первого, оплаченного ответа. Отсюда же следствие: уже оплаченный ответ отдаётся и при пустом кошельке — проверка баланса стоит ПОСЛЕ идемпотентности. Любая ошибка приходит одним конвертом (поле code — не у каждого статуса): { \"error\": { \"message\": \"...\", \"type\": \"...\", \"code\": \"PROVIDER_CODE\" } }.\n\nМодерация, когда она включена, читает текст запроса и блокирует его только по реальному вердикту: недоступный или отвечающий ошибкой модератор запрос пропускает. Ответ всегда несёт то логическое имя модели, которое ты запросил. Если ответ не уложился в таймаут ожидания шлюза (по умолчанию чтение 300 с, в стриме 60 с без байтов), приходит 504; нестриминговую попытку шлюз при этом помечает, и если апстрим всё же досчитал и списал, реконсайлер возвращает сумму на баланс компенсирующим грантом. Из тел ошибок вычищены имена хостов апстрима, поэтому текст ошибки может отличаться от того, что прислал апстрим.\n\nСвоего роута у ручки нет: шлюз пробрасывает запрос апстриму как есть. Поля, не перечисленные выше, доезжают до апстрима без изменений — включая те, что появятся в его API позже, — а ответ возвращается таким, каким его отдал апстрим (плюс наша стоимость запроса).",
        "tags": [
          "text"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/docs/api/responses"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "model": {
                    "type": "string",
                    "description": "логическое имя модели из GET /v1/models; префикс провайдера сворачивается Идентификатор модели. Списка допустимых значений здесь нет намеренно: каталог правится из админки без деплоя, поэтому актуальный список отдают каталожные ручки: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
                  },
                  "input": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "запрос: строка или массив структурированных частей — форма Responses API"
                  },
                  "stream": {
                    "type": "boolean",
                    "description": "true — ответ приходит событиями SSE. Отказы шлюза случаются до первого байта"
                  },
                  "max_output_tokens": {
                    "type": "integer",
                    "description": "потолок вывода в написании Responses API"
                  }
                },
                "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": "Успешный ответ",
            "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": "модерация остановила промпт до модели (type: content_policy_violation и категория)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "ключа нет в заголовке или он не наш",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "остатка не хватает на худшую цену этого запроса",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`model_not_found` — модель выключена админом — из GET /v1/models она пропадает тоже",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "апстрим ответил лимитом; повтори с задержкой",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "апстрим недоступен (type: upstream_error)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "504": {
            "description": "таймаут ожидания апстрима: чтение 300 с, в стриме 60 с без байтов",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/messages": {
      "post": {
        "operationId": "messages",
        "summary": "Сообщения (форма Anthropic)",
        "description": "Тот же чат, но в форме Anthropic: другое тело запроса и ответа.\n\nШлюз принимает Anthropic-форму, но НЕ проксирует её как есть: тело переписывается в chat/completions, а ответ апстрима собирается обратно в Anthropic-сообщение (id — от апстрима, model — та логическая модель, которую ты запросил). Так работает любая модель каталога, а не только Claude.\n\nИз тела переносятся: model, system, messages, max_tokens, temperature, top_p, stream, metadata, stop_sequences (в stop), tools (name/description/input_schema → function) и tool_choice (auto → auto, any → required, tool → конкретная функция). Это единственная текстовая ручка, где остальные поля НЕ доезжают до апстрима: конвертер собирает новое тело по этому списку. tool_use-блоки ассистента и tool_result пользователя конвертируются в обе стороны, finish_reason превращается в stop_reason (stop → end_turn, length → max_tokens, tool_calls → tool_use, content_filter → stop_sequence). Шлюз не требует max_tokens: без него в тело апстриму поле не попадает вовсе, а худшую цену запроса шлюз считает по потолку вывода из каталога модели. Служебный блок телеметрии Anthropic в system (x-anthropic-billing-header: …) отбрасывается: его нонс стоит в начале промпта и рвёт префиксный кэш апстрима на каждом запросе.\n\nСтоимость этого запроса приходит в самом ответе: заголовок x-teamtoken-cost-usd и поле usage.cost_usd в теле. Значение — строка с десятичной записью («0.0000465»), а не число: число после парсинга показывалось бы по правилам языка клиента (Python выдал бы 4.65e-05), строка доходит до кода ровно такой, как записана. Стоимость может и не прийти — тогда её нет ни в заголовке, ни в поле: в нестриминговом ответе так бывает, когда апстрим её не сообщил, в стриме — когда у модели нет тарифа в каталоге. Стрим здесь синтетический: апстрим отвечает целиком, а SSE собирается из готового сообщения — поэтому стоимость известна до первого байта, заголовок приходит и в стриме, а cost_usd лежит в message_start внутри message.usage (в message_delta шлюз кладёт только output_tokens). Идемпотентности на этой ручке нет: Anthropic-ответ шлюз не кэширует, поэтому байт-в-байт такой же повтор уезжает в модель ещё раз и оплачивается ещё раз. Рядом живёт POST /v1/messages/count_tokens — он отвечает {\"input_tokens\": N} и это ОЦЕНКА (символы/4 плюс накладные на сообщение и инструмент), а не работа токенайзера. Любая ошибка приходит одним конвертом (поле code — не у каждого статуса): { \"error\": { \"message\": \"...\", \"type\": \"...\", \"code\": \"PROVIDER_CODE\" } }.\n\nМодерация, когда она включена, читает текст запроса и блокирует его только по реальному вердикту: недоступный или отвечающий ошибкой модератор запрос пропускает. Ответ всегда несёт то логическое имя модели, которое ты запросил. Если ответ не уложился в таймаут ожидания шлюза (по умолчанию чтение 300 с, в стриме 60 с без байтов), приходит 504; нестриминговую попытку шлюз при этом помечает, и если апстрим всё же досчитал и списал, реконсайлер возвращает сумму на баланс компенсирующим грантом. Из тел ошибок вычищены имена хостов апстрима, поэтому текст ошибки может отличаться от того, что прислал апстрим.\n\nСвоего роута у ручки нет: шлюз пробрасывает запрос апстриму как есть. Поля, не перечисленные выше, доезжают до апстрима без изменений — включая те, что появятся в его API позже, — а ответ возвращается таким, каким его отдал апстрим (плюс наша стоимость запроса).",
        "tags": [
          "text"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/docs/api/messages"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "model": {
                    "type": "string",
                    "description": "логическое имя модели из GET /v1/models; префикс провайдера сворачивается Идентификатор модели. Списка допустимых значений здесь нет намеренно: каталог правится из админки без деплоя, поэтому актуальный список отдают каталожные ручки: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
                  },
                  "messages": {
                    "type": "array",
                    "description": "диалог в форме Anthropic: content — строка или массив блоков text / tool_use / tool_result"
                  },
                  "max_tokens": {
                    "type": "integer",
                    "description": "потолок вывода; не обязателен, но на нём считается худшая цена запроса"
                  },
                  "system": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "системный промпт — строка или массив text-блоков, склеивается в одно system-сообщение"
                  },
                  "tools": {
                    "type": "array",
                    "description": "инструменты в форме Anthropic: {name, description, input_schema}"
                  },
                  "tool_choice": {
                    "type": "object",
                    "description": "{\"type\": \"auto\" | \"any\" | \"tool\", \"name\": …} — переводится в auto / required / конкретную функцию"
                  },
                  "stream": {
                    "type": "boolean",
                    "description": "true — ответ приходит событиями Anthropic (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": "Успешный ответ",
            "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": "- модерация остановила промпт до модели (type: content_policy_violation и категория)\n- тело не разбирается как JSON — конвертер в Anthropic-форму читает его сам",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "ключа нет в заголовке или он не наш",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "остатка не хватает на худшую цену этого запроса",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`model_not_found` — модель выключена админом — из GET /v1/models она пропадает тоже",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "апстрим ответил лимитом; повтори с задержкой",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "апстрим недоступен (type: upstream_error)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "504": {
            "description": "таймаут ожидания апстрима: чтение 300 с, в стриме 60 с без байтов",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/embeddings": {
      "post": {
        "operationId": "embeddings",
        "summary": "Эмбеддинги",
        "description": "Векторы для текста, форма OpenAI.\n\nПрямой проброс в OpenAI-совместимый апстрим: своей логики у ручки нет, кроме общей для шлюза — идемпотентности, проверки, что модель не выключена, проверки баланса и стоимости в ответе. Поля, кроме перечисленных (dimensions, encoding_format и прочие), уходят как есть. Модель шлюз за клиента не выбирает: в model нужна строка embedding-модели из каталога.\n\nСтоимость этого запроса приходит в самом ответе: заголовок x-teamtoken-cost-usd и поле usage.cost_usd в теле. Значение — строка с десятичной записью («0.0000465»), а не число: число после парсинга показывалось бы по правилам языка клиента (Python выдал бы 4.65e-05), строка доходит до кода ровно такой, как записана. Стоимость может и не прийти — тогда её нет ни в заголовке, ни в поле: в нестриминговом ответе так бывает, когда апстрим её не сообщил, в стриме — когда у модели нет тарифа в каталоге. Стрима у эмбеддингов нет: шлюз читает ответ целиком. Модерация к этому пути не применяется — она включена только там, где есть промпт для модели.\n\nБайт-в-байт такой же запрос с тем же ключом в пределах короткого TTL (по умолчанию 60 с) не уезжает в модель повторно — шлюз отдаёт тело первого ответа и второй раз денег не берёт. Кэшируется только успешный ответ не больше 256 КБ; всё остальное уедет апстриму заново. У повтора из кэша нет заголовка x-teamtoken-cost-usd (списания не было), а cost_usd в теле — от первого, оплаченного ответа. Отсюда же следствие: уже оплаченный ответ отдаётся и при пустом кошельке — проверка баланса стоит ПОСЛЕ идемпотентности. Любая ошибка приходит одним конвертом (поле code — не у каждого статуса): { \"error\": { \"message\": \"...\", \"type\": \"...\", \"code\": \"PROVIDER_CODE\" } }.\n\nОтвет всегда несёт то логическое имя модели, которое ты запросил. Если ответ не уложился в таймаут ожидания шлюза (по умолчанию чтение 300 с, в стриме 60 с без байтов), приходит 504; нестриминговую попытку шлюз при этом помечает, и если апстрим всё же досчитал и списал, реконсайлер возвращает сумму на баланс компенсирующим грантом. Из тел ошибок вычищены имена хостов апстрима, поэтому текст ошибки может отличаться от того, что прислал апстрим.\n\nСвоего роута у ручки нет: шлюз пробрасывает запрос апстриму как есть. Поля, не перечисленные выше, доезжают до апстрима без изменений — включая те, что появятся в его API позже, — а ответ возвращается таким, каким его отдал апстрим (плюс наша стоимость запроса).",
        "tags": [
          "text"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/docs/api/embeddings"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "model": {
                    "type": "string",
                    "description": "имя embedding-модели из GET /v1/models Идентификатор модели. Списка допустимых значений здесь нет намеренно: каталог правится из админки без деплоя, поэтому актуальный список отдают каталожные ручки: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
                  },
                  "input": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "текст или массив текстов; порядок ответа совпадает с порядком входа"
                  }
                },
                "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": "Успешный ответ",
            "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": "модерация остановила промпт до модели (type: content_policy_violation и категория)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "ключа нет в заголовке или он не наш",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "остатка не хватает на худшую цену этого запроса",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`model_not_found` — модель выключена админом — из GET /v1/models она пропадает тоже",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "апстрим ответил лимитом; повтори с задержкой",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "апстрим недоступен (type: upstream_error)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "504": {
            "description": "таймаут ожидания апстрима: чтение 300 с, в стриме 60 с без байтов",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/images/generations": {
      "post": {
        "operationId": "imagesGenerations",
        "summary": "Генерация картинок",
        "description": "Картинки приходят в base64 тем же ответом; долгая генерация возвращает джоб с id (202).\n\nШлюз создаёт джобу, резервирует под неё деньги (под per-user локом, поэтому два одновременных запроса не пройдут по одному остатку), отправляет её провайдеру и сам опрашивает его до 150 с. Успел — приходит 200 с картинками в `b64_json`; не успел — 202 с `{ id, status, estimated_cost_usd }`, и результат забирается через `GET /v1/images/jobs/{job_id}`. Резерв при этом остаётся на месте, так что деньги не «освобождаются» на время генерации.\n\nСсылок на CDN провайдера в ответе не бывает никогда: если провайдер вернул URL, шлюз скачивает картинку сам и отдаёт base64. Поэтому `response_format` принимается и игнорируется — результат всегда `b64_json`. Неизвестные поля тела тоже молча отбрасываются: наружу уезжает только то, что есть в аллоулисте модели.\n\nПро поля. Неизвестная или выключенная админом модель, отсутствующий `prompt`, нецелое или выходящее за 1–10 `n` и значение параметра вне набора своей модели — всё это 400 у нас, до провайдера. Набор `aspect_ratio` зависит от модели: у части он шире (плюс `3:2`, `2:3`, `21:9`), а часть моделей вместо `aspect_ratio` принимает `orientation`; поле, которого у модели нет, молча отбрасывается. На цену не влияет ни `resolution`, ни то, чем задан кадр: тариф — за картинку. `n` провайдеру не уезжает вовсе — он задаёт размер резерва, а списывается по числу вернувшихся картинок (не вернулось ни одной — по `n`). Отказ провайдера на сабмите по 5xx или сети оставляет деньги в резерве и приносит `id` в теле ошибки: джобу добивает реконсайлер, он же освободит резерв, если провайдер её так и не взял. А вот 4xx провайдера — окончательный отказ: джоба проваливается сразу, и резерв освобождается, не дожидаясь реконсайлера.\n\nДеньги: у завершённой генерации в ответе `cost_usd` — ровно то, что списано, у идущей `estimated_cost_usd` — размер резерва. Провалившаяся генерация не тарифицируется и приходит с `\"cost_usd\": \"0\"`. Счёт за медиа ведём мы сами, отдельно от текста. Ошибки приходят одной формой; `code` в ней есть там, где его дал провайдер, а у отказа на сабмите добавляется `id` джобы: `{ \"error\": { \"message\": \"...\", \"type\": \"...\", \"code\": \"PROVIDER_CODE\" } }`.\n\nРеференс-картинки (image-to-image, тот же персонаж или стиль) передаются в теле как `image` — одна строка или массив; принимаются также `images`, `image_url`, `image_urls`, `input_image`, `init_image`, `ref_images`. Значением может быть публичный URL, data-URL или голый base64, и мешать формы в одном запросе можно: URL шлюз сам не скачивает и передаёт провайдеру ссылкой, а base64 декодирует и передаёт байтами. Байты шлюз узнаёт по сигнатуре (PNG, JPEG, GIF, WebP) и режет на 80 МБ на каждый вход — больше даёт 400 ещё до провайдера. Вход, который прошёл наш предел, может отклонить уже модель по своим правилам: тогда её код приходит в `error.code` — `FILE_TOO_LARGE` по размеру, `FILE_TYPE_NOT_ALLOWED` по типу. Количество референсов шлюз не ограничивает; сколько из них учтёт модель — на её стороне. Значение, которое не разобралось ни как URL, ни как base64, молча отбрасывается: запрос уйдёт без этого референса и без ошибки.\n\nСрок жизни результата — 7 дней. Дальше байты уезжают из базы в архив, `result_url` очищается, и тот же `GET /v1/images/jobs/{job_id}` отвечает `{ \"status\": \"completed\", \"archived\": true, \"data\": [] }`. Это «срок вышел», а не «генерация ничего не дала» — сохраняй картинки у себя сразу, как получил. Строка джобы и её деньги не удаляются никогда: ретеншен уносит только payload.",
        "tags": [
          "images"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/docs/api/images-generations"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "model": {
                    "type": "string",
                    "description": "id модели картинок из каталога Идентификатор модели. Списка допустимых значений здесь нет намеренно: каталог правится из админки без деплоя, поэтому актуальный список отдают каталожные ручки: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
                  },
                  "prompt": {
                    "type": "string",
                    "description": "что нарисовать; шлюз проверяет наличие, длину — модель (`EMPTY_PROMPT`)"
                  },
                  "n": {
                    "type": "integer",
                    "default": 1,
                    "description": "сколько картинок, 1–10; задаёт резерв, списание — по факту"
                  },
                  "aspect_ratio": {
                    "type": "string",
                    "enum": [
                      "1:1",
                      "16:9",
                      "9:16",
                      "4:3",
                      "3:4"
                    ],
                    "default": "1:1",
                    "description": "пропорции; набор зависит от модели"
                  },
                  "resolution": {
                    "type": "string",
                    "enum": [
                      "1K",
                      "2K",
                      "4K"
                    ],
                    "default": "1K",
                    "description": "разрешение; есть не у всех моделей (у `nano-banana-pro` есть)"
                  },
                  "orientation": {
                    "type": "string",
                    "enum": [
                      "landscape",
                      "portrait",
                      "square"
                    ],
                    "default": "square",
                    "description": "ориентация — у моделей, которые не принимают `aspect_ratio`"
                  },
                  "image": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "референс: URL, data-URL или голый base64; массив — несколько"
                  },
                  "response_format": {
                    "type": "string",
                    "description": "принимается ради совместимости и игнорируется"
                  }
                },
                "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": "Успешный ответ",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "200": {
                    "summary": "200",
                    "value": {
                      "created": 0,
                      "cost_usd": "0.027",
                      "data": [
                        {
                          "b64_json": "iVBORw0KGgoAAAANSUhEUgAA…"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Не ошибка: статус описан примером ниже (у медиа `202` значит «генерация ещё идёт»).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "202": {
                    "summary": "202",
                    "value": {
                      "id": "img_9f2c1b7e…",
                      "status": "processing",
                      "estimated_cost_usd": "0.027"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "- тело не JSON, нет `prompt`, модель/значение/`n` вне набора, референс > 80 МБ\n- `FILE_DOWNLOAD_FAILED` — сабмит отклонён: 4xx провайдера отдаём его статусом и кодом, резерв освобождаем",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "ключа нет в заголовке или он не наш",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "остатка не хватает на худшую цену этого запроса",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "- провайдер взял джобу и провалил генерацию; его код — в `error.code`\n- сабмит упал по 5xx или сети: принята джоба или нет — неизвестно",
            "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": "Статус и результат генерации",
        "description": "Опрос джобы, которую генерация вернула с 202: статус и готовые картинки.\n\nРучка, которой забирают результат после 202. Джоба видна только тому, кто её создал: чужой существующий id отвечает так же, как несуществующий, — утёкший id не должен подтверждать даже того, что он настоящий.\n\nФорма ответа зависит от статуса и построена вокруг денег. `completed` — `cost_usd` (списано) и `data` с картинками. `queued` / `processing` / `unknown_submit` — `estimated_cost_usd`, то есть размер ещё живого резерва. `failed` — `cost_usd: \"0\"`, потому что провалившаяся генерация не тарифицируется, а резерв уже освобождён.\n\nСрок жизни результата — 7 дней. Дальше байты уезжают из базы в архив, `result_url` очищается, и тот же `GET /v1/images/jobs/{job_id}` отвечает `{ \"status\": \"completed\", \"archived\": true, \"data\": [] }`. Это «срок вышел», а не «генерация ничего не дала» — сохраняй картинки у себя сразу, как получил. Строка джобы и её деньги не удаляются никогда: ретеншен уносит только payload.",
        "tags": [
          "images"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/docs/api/images-job"
        },
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "description": "id из ответа 202 (`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": "Успешный ответ",
            "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": "ключа нет в заголовке или он не наш",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "джобы с таким id нет — или она не твоя",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/images/edits": {
      "post": {
        "operationId": "imagesEdits",
        "summary": "Правка картинки",
        "description": "Правка — это генерация с референсами: тот же обработчик, тело только JSON.\n\nОтдельного кода за этой ручкой нет: она делегирует в `POST /v1/images/generations`, потому что правка — это и есть генерация с приложенными референсами. Все параметры, ответы, коды ошибок и тарификация — оттуда же.\n\n⚠️ Тело парсится **только как JSON**, поэтому референсы надо передавать JSON-полями (`image` / `images` / `image_url`), а сами байты — data-URL или base64-строкой в значении. Настоящий multipart-upload — а именно его отправляет `images.edit` в OpenAI SDK — падает с 400 «Invalid JSON». Это ограничение шлюза, а не провайдера: пока multipart здесь не разобран, совместимость с SDK на этом маршруте заявлять нельзя. Из `requests`/`curl` шлите JSON, а картинку — data-URL или base64-строкой.\n\nРеференс-картинки (image-to-image, тот же персонаж или стиль) передаются в теле как `image` — одна строка или массив; принимаются также `images`, `image_url`, `image_urls`, `input_image`, `init_image`, `ref_images`. Значением может быть публичный URL, data-URL или голый base64, и мешать формы в одном запросе можно: URL шлюз сам не скачивает и передаёт провайдеру ссылкой, а base64 декодирует и передаёт байтами. Байты шлюз узнаёт по сигнатуре (PNG, JPEG, GIF, WebP) и режет на 80 МБ на каждый вход — больше даёт 400 ещё до провайдера. Вход, который прошёл наш предел, может отклонить уже модель по своим правилам: тогда её код приходит в `error.code` — `FILE_TOO_LARGE` по размеру, `FILE_TYPE_NOT_ALLOWED` по типу. Количество референсов шлюз не ограничивает; сколько из них учтёт модель — на её стороне. Значение, которое не разобралось ни как URL, ни как base64, молча отбрасывается: запрос уйдёт без этого референса и без ошибки.",
        "tags": [
          "images"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/docs/api/images-edits"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "model": {
                    "type": "string",
                    "description": "id модели картинок — тот же каталог, что у генерации Идентификатор модели. Списка допустимых значений здесь нет намеренно: каталог правится из админки без деплоя, поэтому актуальный список отдают каталожные ручки: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
                  },
                  "prompt": {
                    "type": "string",
                    "description": "что изменить; шлюз проверяет наличие, длину — модель (`EMPTY_PROMPT`)"
                  },
                  "image": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "картинка, которую правим: URL, data-URL или base64; массив — если их несколько"
                  }
                },
                "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": "Успешный ответ",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "200": {
                    "summary": "200",
                    "value": {
                      "created": 0,
                      "cost_usd": "0.027",
                      "data": [
                        {
                          "b64_json": "iVBORw0KGgoAAAANSUhEUgAA…"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Не ошибка: статус описан примером ниже (у медиа `202` значит «генерация ещё идёт»).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                },
                "examples": {
                  "202": {
                    "summary": "202",
                    "value": {
                      "id": "img_9f2c1b7e…",
                      "status": "processing",
                      "estimated_cost_usd": "0.027"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "- тело не JSON (в том числе multipart из SDK), нет `prompt` или референс > 80 МБ\n- `FILE_DOWNLOAD_FAILED` — сабмит отклонён: 4xx провайдера отдаём его статусом и кодом, резерв освобождаем",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "ключа нет в заголовке или он не наш",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "остатка не хватает на худшую цену этого запроса",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "- провайдер взял джобу и провалил генерацию; его код — в `error.code`\n- сабмит упал по 5xx или сети: принята джоба или нет — неизвестно",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/videos": {
      "post": {
        "operationId": "videos",
        "summary": "Генерация видео",
        "description": "Ставит генерацию видео в очередь: 202 с id джобы, либо готовый результат сразу при wait: true.\n\nГенерация асинхронная. По умолчанию ручка отвечает `202` с id джобы и суммой резерва (`estimated_cost_usd`), а результат забирается через `GET /v1/videos/{job_id}`. С `wait: true` (и с любым переданным `timeout`) шлюз опрашивает провайдера сам — шаг опроса 2 с, потолок ожидания 90 с, и `timeout` только уменьшает этот бюджет, поднять его выше нельзя. Не успело — придёт `202` с тем же id, джоба остаётся в работе. Опрашивать не обязательно: реконсайлер (проход раз в 150 с) сам досчитает завершившуюся джобу, спишет деньги и сохранит ссылки на результат.\n\nРежимов входа три, и выбирает их не URL, а поля тела: `prompt` — text-to-video; `prompt` + `image` — image-to-video; `prompt` + `video` — video-to-video (edit и motion control, где `image` — персонаж, а `video` — движение). Что движок сделает со входом, решает он; наша проверка одна — движку, которому входное видео обязательно, запрос без него получает `400` ещё у нас, до провайдера. Список моделей живёт в каталоге (`GET /cabinet/api/public/media-models`), но обязательность входа и наборы длительностей каталог не отдаёт: это таблица на нашей стороне, наружу видная только текстом ошибки.\n\n`/v1/video/generations`, `/v1/videos/extend` и `/v1/videos/storyboard` — алиасы этого же обработчика: то же тело, те же ответы и ошибки, разницы в поведении между путями нет. Продолжение ролика и сториборд задаются моделью — в каталоге это отдельные движки (`*-extend`, `*-storyboard`), — а исходный ролик передаётся полем `ref_video_job_id`.\n\nПро поля. Длительность и её набор — за движком: seedance — 4–15 с (по умолчанию 5) · kling — 3–15 у 3.0, 3–10 у edit/o1/motion (5) · kling 2.5/2.6 — только 5 или 10 · kling 2.1 — ровно 10 либо ровно 5, смотря какая модель · veo — 4/6/8 (8) · omni-flash — 4/6/8/10, но каждая строка каталога прибита к одному из них · grok — только 6 · extend — 8 у veo, 6 у grok, 4–15 у seedance · сториборд — 6–30 (6). Значение вне набора → `400` с перечислением допустимых (у диапазона — границ). ⚠️ Строка каталога может прибить длительность жёстко (`fixed_params`) — тогда переданный `duration` до провайдера не доезжает. ⚠️ Без `duration` провайдеру уйдёт значение движка по умолчанию, а резерв посчитается по МАКСИМУМУ движка (`hold_duration_seconds`); списание — по фактической длительности готового ролика. `timeout` ограничен теми же 90 с, нечисловое значение — те же 90.\n\nВход принимается под несколькими именами: `image` — это же `images`, `image_url`, `image_urls`, `input_image`, `init_image`, `ref_images`, а `video` — `videos`, `video_url`, `input_video`, `motion_video`, `ref_video`, `ref_videos`. URL — картинки и видео одинаково — шлюз скачивает сам (свой User-Agent, свой лимит, запрет внутренних адресов на каждом редиректе); не скачалось или пришло не похожее на медиа тело — ссылка уходит провайдеру как есть. ⚠️ Вход, которого движок не ждёт, шлюз всё равно передаёт провайдеру: каталог сверяется только на обязательность. А параметры вне набора движка (`resolution`, `size`, любое незнакомое поле) отбрасываются молча — разрешение и тир задаёт каталог, не запрос.\n\nДо провайдера `400` дают: битый JSON, неизвестная или выключенная модель, отсутствующий `prompt`, `duration` вне набора движка, обязательный и не переданный вход, вход больше 80 МБ (предел на один декодированный файл). `ref_video_job_id` проверяется по владельцу: чужой или несуществующий id → `404`, чтобы утёкший id ничего не подтверждал. Отказ провайдера на сабмите: его 4xx доезжает как есть — с его кодом и очищенной от имён апстрима причиной, — и это окончательно, джоба `failed`, резерв снят; 5xx или сеть означают, что заявка МОГЛА быть принята, и возврат наугад был бы двойным зачислением — джоба уходит в `unknown_submit` и ждёт реконсайлера, который либо её завершит, либо освободит резерв по TTL (у `unknown_submit` он короткий, 15 минут по умолчанию, у остальных — сутки).",
        "tags": [
          "videos"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/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": "имя видео-модели из каталога (`GET /cabinet/api/public/media-models`) Идентификатор модели. Списка допустимых значений здесь нет намеренно: каталог правится из админки без деплоя, поэтому актуальный список отдают каталожные ручки: `GET /cabinet/api/public/models`, `GET /cabinet/api/public/media-models`."
                  },
                  "prompt": {
                    "type": "string",
                    "description": "сцена для генерации; обязателен у каждого видео-движка"
                  },
                  "duration": {
                    "type": "number",
                    "description": "длительность в секундах; значение по умолчанию и набор задаёт движок модели"
                  },
                  "seconds": {
                    "type": "number",
                    "description": "алиас `duration`; если переданы оба, `duration` побеждает"
                  },
                  "aspect_ratio": {
                    "type": "string",
                    "enum": [
                      "16:9",
                      "9:16",
                      "1:1"
                    ],
                    "default": "16:9",
                    "description": "кадр готового ролика. Один и тот же набор у всех видео-движков"
                  },
                  "image": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "входная картинка: URL, data-URL, base64 или uuid картинки у провайдера; массив — несколько"
                  },
                  "video": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "входное видео для video-to-video: те же формы, что у `image`"
                  },
                  "wait": {
                    "type": "boolean",
                    "default": false,
                    "description": "`true` — держать соединение и вернуть результат, но не дольше 90 с"
                  },
                  "timeout": {
                    "type": "number",
                    "default": 90,
                    "description": "сколько секунд ждать; наличие поля включает ожидание даже без `wait`"
                  },
                  "ref_video_job_id": {
                    "type": "string",
                    "description": "id ТВОЕЙ прежней видео-джобы — провайдеру уходит её uuid как исходник"
                  },
                  "scenes": {
                    "type": [
                      "string",
                      "array"
                    ],
                    "description": "сцены сториборд-движка; шлюз их не валидирует, другим движкам поле не уходит"
                  }
                },
                "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": "Успешный ответ",
            "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": "Не ошибка: статус описан примером ниже (у медиа `202` значит «генерация ещё идёт»).",
            "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": "- не проходит нашу проверку до провайдера; причина — в `error.message`\n- `PROVIDER_CODE` — провайдер отказал на сабмите: его 4xx доезжает как есть, джоба `failed`, резерв снят",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "ключа нет в заголовке или он не наш",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "остатка не хватает на худшую цену этого запроса",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`ref_video_job_id` указывает на джобу, которой нет или которая не твоя",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "- 5xx или сеть на сабмите: заявка могла быть принята — `unknown_submit`, резерв держится\n- `PROVIDER_CODE` — при `wait: true` генерация провалилась: резерв снят, в теле `cost_usd: \"0\"`\n- резерв не удалось зафиксировать: джоба провалена, деньги не заняты\n- провайдер ответил `200`, но без id джобы: джоба провалена, резерв снят",
            "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": "Состояние видео-джобы",
        "description": "Состояние видео-джобы и ссылка на готовый ролик.\n\nРучка не просто читает строку: если джоба в `queued` или `processing` и у неё есть id у провайдера, она опрашивает провайдера прямо в этом запросе и, если тот закончил, финализирует джобу — списывает деньги и сохраняет ссылки на результат. Поэтому первый же успешный опрос и отдаёт готовое видео. Джобу в `unknown_submit` (сабмит не подтвердился) эта ручка не опрашивает — её добивает реконсайлер.\n\nТерминальный ответ описывает состояние НАШЕЙ базы, а не слова провайдера, и это не придирка: реконсайлер освобождает резерв у джобы, которая висит дольше суток (порог — настройка), помечая её `failed` и возвращая деньги. Если провайдер закончит после этого, ответ «completed» выдал бы ссылки, на которые `/content` отвечает `404`. Такая джоба честно отвечает `failed`. А пока джоба не терминальна, `status` — это как раз слово провайдера, приведённое к нашему набору: `pending` или `processing`.\n\nСтоимость приходит по состоянию, и поля не пересекаются: `estimated_cost_usd` — резерв, пока джоба идёт; `cost_usd` — фактическое списание, когда готово; `cost_usd: \"0\"` — когда провалено. Оба поля — строки с десятичной записью, а не JSON-числа: число клиентский float-принтер переписал бы (`4.65e-05` вместо `0.0000465`). `duration` есть только в том ответе, который сам довёл джобу до `completed`, а `error` — только в том, который сам её провалил; при повторном чтении строки этих полей нет, ссылки в `data` остаются. Провал генерации здесь виден как `status: \"failed\"` в теле `200`, а не как HTTP-ошибка — HTTP-ошибку по этой ручке даёт только отсутствующий ключ или чужая джоба, — а чужая отвечает ровно как несуществующая, чтобы утёкший id ничего не подтверждал.",
        "tags": [
          "videos"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/docs/api/videos-job"
        },
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "description": "id из ответа на создание — `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": "Успешный ответ",
            "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": "ключа нет в заголовке или он не наш",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "джобы с таким id нет — или она не твоя",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/videos/{job_id}/content": {
      "get": {
        "operationId": "videosContent",
        "summary": "Скачать готовое видео",
        "description": "Отдаёт сами байты ролика — MP4 потоком через шлюз, не редирект.\n\nЭто те самые ссылки, которые приходят в `data` у завершённой джобы, и открыть их может любой ключ того же аккаунта. Ответ — `200` с `Content-Type: video/mp4` и телом-файлом: шлюз качает ролик у провайдера и переливает его тебе. Редиректа нет намеренно — хост, с которого мы берём видео, наружу не показывается, а значит и ссылка на него никому не достанется. Из того же принципа хост результата сверяется с allowlist перед скачиванием, а статус апстрима проверяется ДО начала стрима: иначе клиент получил бы `200`, умирающий на середине тела.\n\nОдин запрос — один ролик. Когда джоба вернула несколько (сториборд, мульти-аутпут), в `data` лежат ссылки с `?i=0`, `?i=1`, … — брать их оттуда, а не собирать руками.\n\nПро срок жизни. У видео в строке джобы лежит ссылка провайдера, а не наши байты, поэтому ретеншен её не обнуляет — переносить ему тут нечего, он только проставляет строке `archived_at`, чтобы та ушла из очереди кандидатов. У видео этот штамп означает ровно «ретеншен закончил с этой строкой», а не «байты уехали». Из этого не следует, что ролик будет доступен вечно: живёт он на стороне провайдера, и его срок кодом не проверяется и нами не гарантируется. Скачивай сразу, как получил.\n\nОдинаковый `404` на все случаи — джобы нет, она чужая, ещё не завершена, результат уже не хранится, индекс `i` за пределами набора, хост результата не в allowlist — сделан намеренно: разные ответы рассказали бы про чужие джобы. `502` проверяется до начала стрима, поэтому это честная ошибка, а не обрыв посреди файла; деньги при этом не при чём — джоба уже оплачена.",
        "tags": [
          "videos"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/docs/api/videos-content"
        },
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "description": "id завершённой джобы — `vid_…`",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "i",
            "in": "query",
            "required": false,
            "description": "какой из роликов джобы отдать; нечисловое значение — `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": "Успешный ответ\n\n**200**\n\n```\nHTTP/1.1 200 OK\ncontent-type: video/mp4\n\n<байты MP4 — тело файла, не JSON>\n```"
          },
          "401": {
            "description": "ключа нет в заголовке или он не наш",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "нечего отдать или отдавать не тебе: джобы нет, она чужая или не завершена",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "источник, откуда шлюз берёт ролик, недоступен или ответил не `200`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/models": {
      "get": {
        "operationId": "models",
        "summary": "Список моделей",
        "description": "Список логических моделей для поля model. Цен здесь нет — они в каталоге.\n\nОтдаёт то же, что /v1/models у OpenAI: объект со списком в data, у каждой записи есть id — ровно та строка, которую надо положить в model. Остальные поля приходят как есть, поэтому в ответе могут оказаться и другие стандартные ключи.\n\nКлюч здесь не обязателен: если заголовка нет, шлюз спрашивает список у апстрима своим доступом. Присланный ключ уезжает апстриму как есть, и его отказ шлюз отдаёт без изменений.\n\nИз списка вычищены две вещи. Первая — служебные имена, по которым шлюз раскладывает трафик внутри себя: их нельзя звать, и наружу они не показываются. Вторая — модели, выключенные администратором: они существуют, но запрос к ним не пойдёт, поэтому и в списке их нет. Цен здесь нет вовсе — за ценами GET /cabinet/api/public/models.",
        "tags": [
          "account"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/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": "Успешный ответ",
            "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": "Баланс ключа",
        "description": "Сколько денег на аккаунте ключа: начислено, потрачено, остаток.\n\nБаланс считается по одной формуле: начислено − расход по тексту − эффективный расход по медиа. Поэтому в ответе три числа и валюта:\n\ngranted — сумма всех начислений на аккаунт; spend — суммарный расход, текст и медиа одним числом; balance — остаток, то есть granted минус spend; currency — всегда USD.\n\nВажное про spend: в медийную часть входит не только списанное, но и зарезервированное под генерации, которые ещё идут. Резерв освобождается, когда джоба завершилась (и превращается в списание) или провалилась (и деньги вернулись). Из-за этого balance после постановки генерации в очередь падает сразу, а не в конце — иначе можно было бы поставить десять генераций на деньги, которых хватает на одну.\n\nДеградация, о которой лучше знать заранее. Если наше хранилище недоступно, форма ответа не меняется, но granted берётся из бюджета аккаунта, в котором медиа уже вычтено; пока бюджет синхронизирован, balance получается тот же. Если недоступна сама проверка ключа и остатка, ручка отвечает 502 и не отдаёт никакого числа: отсутствие ответа честнее выдуманного остатка.",
        "tags": [
          "account"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/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": "Успешный ответ",
            "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": "ключа нет в заголовке или он не наш",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "проверка ключа и остатка недоступна — числа нет, повтори позже",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/cabinet/api/public/models": {
      "get": {
        "operationId": "publicModels",
        "summary": "Каталог текстовых моделей с ценами",
        "description": "Живой список текстовых моделей и цены за 1M токенов. Без ключа.\n\nЭто единственный публичный источник цен на текст. Цены лежат в базе, администратор меняет их без выкатки — значит любая цена, написанная в документации, устареет молча. Поэтому здесь описаны поля, а числа надо читать этой ручкой.\n\nОтвет — массив, по одной записи на логическую модель, отсортированный по имени. Поля: model — имя модели, то же, что уходит в поле model запроса; input_per_1m — цена входных токенов за 1M в USD; output_per_1m — цена выходных токенов за 1M; cache_per_1m — цена попадания в промпт-кэш (нуль значит, что отдельного тарифа нет и кэшированный вход считается как обычный); cache_write_mult — во сколько раз запись в промпт-кэш дороже обычного входа.\n\nВыборка та же, что в кабинете: служебные имена и выключенные администратором модели в неё не попадают. У одной модели внутри может быть несколько тарифов; отдаётся тот, по которому пойдёт запрос по умолчанию.",
        "tags": [
          "account"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/docs/api/public-models"
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "cURL",
            "source": "curl https://api.teamtoken.store/cabinet/api/public/models"
          }
        ],
        "responses": {
          "200": {
            "description": "Успешный ответ\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": "Каталог медиа-моделей с ценами",
        "description": "Живой список моделей картинок и видео с ценой за единицу. Без ключа.\n\nТо же, что каталог текста, но для медиа, и по той же причине без чисел в тексте: цену задаёт администратор в базе, а не релиз.\n\nОтвет — массив, отсортированный по модальности и имени. Поля: model — имя модели для поля model запроса; modality — image или video; billing_unit — за что берутся деньги: per_image (за картинку) или per_second (за секунду видео); unit_price_usd — цена одной такой единицы в USD.\n\nВ каталог попадают только модели, которые включены администратором И у которых цена задана и больше нуля. Отсюда практическое следствие: модель, которой нет в этом списке, вызывать не стоит — либо она выключена, либо у неё нет тарифа.",
        "tags": [
          "account"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/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": "Успешный ответ\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": "Доступность моделей",
        "description": "Снимок доступности моделей: процент успешных запросов и история по дням. Без ключа.\n\nТот же снимок, который рисует публичную страницу статуса. Считает его фоновый цикл, а запрос отдаёт готовое — из процессного кэша или из сохранённого снимка; пересчёт на самом запросе бывает только тогда, когда снимок совсем устарел, то есть цикл встал. Поэтому ручка выдерживает анонимный трафик и не стоит ни одного обращения к моделям.\n\nПоля: updated_at — когда снимок посчитан; overall — сводка по всему шлюзу (operational / degraded / down); groups — группы (текст, картинки, видео), у каждой key, label (готовая подпись) и models. У модели: model; status; success_rate — процент успешных запросов от 0 до 100, null, если данных не было (в знаменателе только успехи и сбои на стороне модели: отказ по вине самого запроса её не роняет); checked_at — время последнего запроса (у текстовых моделей без смещения, у медийных со смещением); history — по одной записи на день, в записи day, rate (тот же процент) и status.\n\nОкно у success_rate разное: у текста — сутки, у медиа — вся история, потому что медиа-запросы редки и суточное окно гасило бы модель, работавшую вчера. Значения status: ok, degraded, down, unavailable (не прошёл ни один запрос), no_data (у текстовой модели данных нет) и awaiting (у медиа-модели ещё нет подтверждённого трафика). Текстовые модели шлюз пингует сам, медийные не пингует — они слишком дороги, и их доступность считается по реальным запросам клиентов. Читать это стоит перед тем, как жаловаться на ошибки: no_data и awaiting значат «данных нет», а не «сломано».",
        "tags": [
          "account"
        ],
        "externalDocs": {
          "description": "Страница справочника",
          "url": "https://teamtoken.store/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": "Успешный ответ",
            "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": "Ключ из кабинета, страница «Ключи»: `Authorization: Bearer sk-…`."
      },
      "apiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "x-api-key",
        "description": "Тот же ключ для клиентов, которые не умеют ставить `Authorization`: `x-api-key: sk-…`."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "Единый конверт ошибки. Форма одна для любого статуса — разбирать её можно один раз на клиент.",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "message": {
                "type": "string",
                "description": "Причина, пригодная для показа человеку."
              },
              "type": {
                "type": "string"
              },
              "code": {
                "type": "string",
                "description": "Код провайдера или шлюза, когда статус его несёт. Частые коды медиа:\n- `EMPTY_PROMPT` — промпт пустой или короче 10 символов\n- `INVALID_VIDEO_FILE` — модели нужен входной video (edit / motion) — добавь поле video\n- `FILE_TOO_LARGE` — провайдер отклонил вход по размеру; вход свыше 80 МБ до него не доходит вовсе\n- `INVALID_INPUT` — параметр вне диапазона или не того типа\n- `VIDEO_DURATION_TOO_LONG` — входное видео длиннее, чем принимает модель\n- `SERVICE_PRICE_NOT_FOUND` — неподдерживаемая модель или комбинация опций — у провайдера нет такого тарифа\n- `GEMINI_RAI_MEDIA_FILTERED` — заблокировано фильтром безопасности провайдера\n- `KLING_GENERATION_FAILED` — генерация не удалась — проверь входную картинку/видео и промпт\n- `SEEDANCE_GENERATION_FAILED` — генерация не удалась — содержимое могло нарушить политику\n- `SYSTEM_ERROR` — временный сбой провайдера — повтори запрос"
              }
            },
            "required": [
              "message"
            ]
          }
        },
        "required": [
          "error"
        ],
        "example": {
          "error": {
            "message": "...",
            "type": "...",
            "code": "PROVIDER_CODE"
          }
        }
      }
    }
  }
}
