spicyapiДокументация
Текст и чат
Основное содержимое

Текст и потоковые ответы

Как вызывать текстовые модели в официальном формате OpenAI, Anthropic или Google Gemini и правильно обращаться с диалогами, инструментами, потоками и расходами.

Текстовые модели отвечают в рамках того же HTTP-запроса: никакой задачи, которую нужно опрашивать, здесь нет. Можно дождаться ответа целиком, а можно получать его потоком — небольшими частями, пока модель ещё пишет, как в чат-приложении, где ответ набирается у вас на глазах. Части потока приходят в формате Server-Sent Events (SSE): это обычные текстовые строки, начинающиеся с data:, которые идут одна за другой по соединению, открытому до тех пор, пока ответ не будет готов.

Выбор доступной текстовой модели

Примеры для командной строки на этой странице написаны для терминала macOS или Linux и используют jq — небольшую утилиту для работы с JSON в командной строке (brew install jq в macOS, sudo apt install jq в Debian или Ubuntu). В Windows запускайте их в WSL или пользуйтесь примерами для Python SDK.

Сначала поместите API-ключ в переменную окружения — именованное значение, которое терминал передаёт запускаемым из него командам. Так ключ не придётся вставлять в саму команду:

export SPICY_API_KEY="sk-spicy-paste-your-key-here"

Выведите список текстовых моделей, которые этот ключ может вызывать прямо сейчас:

curl -s "https://api.spicyapi.ai/api/v1/models?modality=text" \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  | jq -r '.data.items[] | select(.enabled and .available) | .model'

Должен появиться список ID моделей, по одному в строке. Скопируйте один из них и сохраните в SPICY_MODEL — его читают все примеры ниже:

export SPICY_MODEL="MODEL_ID_FROM_CATALOG"   # замените одной из строк списка выше

MODEL_ID_FROM_CATALOG — всего лишь заполнитель, никогда не отправляйте его как есть. Прежде чем полагаться на инструменты, изображения, настройки рассуждений или структурированный вывод, прочитайте inputSchema этой модели через GET /api/v1/models/{model}: эти возможности работают, только если они перечислены в схеме. Совместимость с протоколом не наделяет каждую модель всеми возможностями.

Один и тот же ID модели работает с любым из протоколов ниже, кто бы ни создал модель: модель другого разработчика можно вызвать в формате Anthropic, а модель не от Google — в формате Gemini.

Поддерживаемые протоколы

У всех этих эндпоинтов общие API-ключ и каталог моделей, а также одинаковые лимиты запросов, проверка параметров и тарификация; различаются только форматы запросов и ответов. Выбирайте тот, который уже понимает ваш код или SDK.

ПротоколЭндпоинтЗаголовок с API-ключом
OpenAI Chat CompletionsPOST /v1/chat/completionsAuthorization: Bearer
OpenAI ResponsesPOST /v1/responsesAuthorization: Bearer
Anthropic MessagesPOST /v1/messagesx-api-key или Authorization: Bearer
Google GeminiPOST /v1beta/models/{model}:generateContentx-goog-api-key или Authorization: Bearer
Google Gemini (потоковый)POST /v1beta/models/{model}:streamGenerateContentx-goog-api-key или Authorization: Bearer
Нативный поток с подтверждённой ценойPOST /api/v1/jobs/streamAuthorization: Bearer

На /v1 и /v1beta ответы и ошибки следуют правилам своих протоколов и не содержат нативной оболочки {code,msg,data,request_id}, поэтому не разбирайте их клиентом, который читает только body.data. В формате Gemini ID модели указывается в пути URL; косые черты в ID остаются как есть, кодировать их не нужно.

Передавайте ключ только в заголовке запроса. x-api-key и x-goog-api-key работают только на /v1 и /v1beta; нативный /api/v1 принимает только Authorization: Bearer. Параметр ?key= в адресе запроса не принимается никогда: ключ, однажды попавший в URL, остаётся в истории браузера, на прокси-серверах и в журналах доступа. Все официальные SDK передают ключ в заголовке, так что их это ограничение не затрагивает.

Работа через официальный SDK

Поменяйте только базовый URL и ключ — всё остальное работает так же, как обычно в каждом SDK.

SDKБазовый URL
OpenAI (openai)https://api.spicyapi.ai/v1
Anthropic (anthropic)https://api.spicyapi.ai
Google GenAI (google-genai)https://api.spicyapi.ai

SDK Anthropic и Google сами добавляют /v1 или /v1beta, поэтому в их базовом URL части с версией нет, а базовый URL для SDK OpenAI, наоборот, должен включать /v1. Все три примера на Python ниже читают из окружения SPICY_API_KEY, а также SPICY_MODEL, выбранный в актуальном каталоге.

OpenAI
import os
from openai import OpenAI

client = OpenAI(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai/v1")
reply = client.chat.completions.create(
    model=os.environ["SPICY_MODEL"],
    messages=[{"role": "user", "content": "Explain a rainbow in one sentence."}],
)
print(reply.choices[0].message.content)
Anthropic
import os
import anthropic

client = anthropic.Anthropic(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai")
message = client.messages.create(
    model=os.environ["SPICY_MODEL"],
    max_tokens=256,
    messages=[{"role": "user", "content": "Explain a rainbow in one sentence."}],
)
print(message.content[0].text)
Google GenAI
import os
from google import genai
from google.genai import types

client = genai.Client(
    api_key=os.environ["SPICY_API_KEY"],
    http_options=types.HttpOptions(base_url="https://api.spicyapi.ai"),
)
response = client.models.generate_content(
    model=os.environ["SPICY_MODEL"],
    contents="Explain a rainbow in one sentence.",
)
print(response.text)

Слой совместимости берёт цену в момент приёма и не принимает расчёт стоимости

/v1 и /v1beta списывают деньги по цене, действующей в момент приёма запроса. Они не принимают quoteId и expectedCost, поэтому у отдельного вызова на этом пути нет верхней границы стоимости: агент, который зациклился и вышел из-под контроля, так и продолжит тратить.

Ограничить расходы можно двумя способами, и большинству интеграций нужны оба:

  • Каждый вызов по отдельности. Получите расчёт стоимости через нативный POST /api/v1/jobs/quote, затем отправьте POST /api/v1/jobs/stream с полученными quoteId и expectedCost. Если за это время цена изменилась, запрос не примут с кодом 40901, а не спишут больше, чем вы согласовали. См. Нативный поток с подтверждённой ценой.
  • Всё, что тратит этот ключ. Задайте для ключа лимиты расходов: Дневной лимит, Месячный бюджет и Общий лимит. Они действуют на всех путях, включая этот, и при превышении запрос получает 40202, а не выливается в счёт побольше. См. Аутентификация.

Ключи идемпотентности на текстовых эндпоинтах

Заголовок Idempotency-Key не даёт сетевому повтору списать деньги дважды. Но на текстовых эндпоинтах он не позволяет получить потерянный ответ, потому что ответ передаётся вам потоком и нигде не сохраняется:

  • Если снова отправить ключ, запрос с которым уже завершился, с тем же телом, придёт 400 (This Idempotency-Key was already used for a completed request, and its answer cannot be replayed. Use a new Idempotency-Key to send a new request), и ничего сверх уже списанного не спишется. В записи задачи текста ответа тоже не будет.
  • Если тот прежний запрос завершился ошибкой, повторная отправка с тем же ключом и тем же телом вернёт ту же ошибку, что и в первый раз, и ничего не спишет.
  • Если отправить этот ключ с другим телом, придёт 409.

Поэтому для каждого нового сообщения отправляйте новый ключ (например, $(uuidgen) в командной строке или uuid.uuid4() в Python), а прежний ключ используйте, только когда после сетевой ошибки заново отправляете в точности тот же запрос, — понимая, что ответ на этот запрос уже не вернуть. Если ответ нужен снова, отправьте запрос с новым ключом и оплатите его.

В SDK передавайте ключ в каждом вызове отдельно, а не в объекте клиента: если задать один ключ на весь клиент, каждое сообщение после первого будет отклоняться с 409 в течение 24 часов:

Idempotency-Key для каждого вызова
import os, uuid

# SDK OpenAI (client из примера для OpenAI выше). SDK Anthropic принимает
# тот же аргумент extra_headers в client.messages.create().
reply = client.chat.completions.create(
    model=os.environ["SPICY_MODEL"],
    messages=[{"role": "user", "content": "Explain a rainbow in one sentence."}],
    extra_headers={"Idempotency-Key": str(uuid.uuid4())},
)

# SDK Google GenAI (client и types из примера для Google GenAI выше):
# http_options внутри config отдельного вызова
response = client.models.generate_content(
    model=os.environ["SPICY_MODEL"],
    contents="Explain a rainbow in one sentence.",
    config=types.GenerateContentConfig(
        http_options=types.HttpOptions(headers={"Idempotency-Key": str(uuid.uuid4())}),
    ),
)

SDK OpenAI и Anthropic при таймаутах сами повторяют запрос с теми же заголовками. Если такой повтор приходит к запросу, который мы уже приняли, SDK выбрасывает ошибку 400 (bad request) с сообщением, приведённым выше: первая попытка была обработана и оплачена, а ответ на неё потерян.

Модели SpicyAPI в клиенте командной строки Grok

Эта настройка заставляет клиент командной строки от xAI отправлять запросы в SpicyAPI: после неё он работает с моделями из каталога SpicyAPI, а не с собственным эндпоинтом xAI. Семейство моделей Grok, опубликованное в каталоге, тоже входит в их число — вызывайте его по идентификатору модели в SpicyAPI, как любую другую модель. Клиент умеет направлять пользовательскую модель на любой эндпоинт, совместимый с OpenAI. Добавьте запись ниже в файл ~/.grok/config.toml; env_key — это имя переменной окружения, из которой клиент читает ключ, так что сам ключ в файле никогда не хранится.

~/.grok/config.toml
[model.spicyapi]
model = "MODEL_ID_FROM_CATALOG"
base_url = "https://api.spicyapi.ai/v1"
name = "SpicyAPI"
env_key = "SPICY_API_KEY"

[models]
default = "spicyapi"

Замените MODEL_ID_FROM_CATALOG точным ID текстовой модели из актуального каталога вида <publisher>/<model>/<task>; заполнитель на месте никогда не оставляйте. Затем запустите клиент командой grok -m spicyapi или переключитесь прямо в сеансе командой /model spicyapi. Клиент работает по протоколу Chat Completions, поэтому соответствие параметров, признаки конца потока и форма ошибок, описанные ниже, действуют без изменений.

Соответствие параметров в разных протоколах

Поля конкретного протокола сначала преобразуются в канонические имена параметров платформы, а затем проверяются по inputSchema выбранной модели. Если модель не поддерживает параметр, возвращается 400: такой параметр никогда не отбрасывается молча, пока запрос оплачивается как обычно. Таблица ниже показывает только соответствие полей, а «—» означает, что в протоколе нет аналогичного поля. Принимает ли конкретная модель параметр и какие значения допускает, определяет inputSchema этой модели.

Канонический параметрChat CompletionsResponsesMessagesGemini
messagesmessagesinstructions, inputsystem, messagessystemInstruction, contents
max_tokensmax_tokens, max_completion_tokensmax_output_tokensmax_tokensgenerationConfig.maxOutputTokens
temperaturetemperaturetemperaturetemperaturegenerationConfig.temperature
top_ptop_ptop_ptop_pgenerationConfig.topP
toolstoolstools (тип function)tools (пользовательские инструменты)tools[].functionDeclarations
tool_choicetool_choicetool_choicetool_choicetoolConfig.functionCallingConfig
response_formatresponse_formattext.format—generationConfig.responseMimeType вместе с responseSchema или responseJsonSchema
reasoning_effortreasoning_effortreasoning.effort—generationConfig.thinkingConfig

Поля, которых нет в таблице, точно так же преобразуются в канонические параметры и проверяются моделью; всё, чего нет в её inputSchema, возвращает 400. Например, seed есть в inputSchema только у некоторых моделей; у остальных seed в Chat и generationConfig.seed в Gemini возвращают 400. Настройки сэмплирования и последовательности stop — исключение: если модель их не принимает, они игнорируются (названы в заголовке ответа X-Spicy-Ignored-Params), а значение за пределами допустимого для модели заменяется ближайшим допустимым (названо в X-Spicy-Adjusted-Params). Это касается temperature, top_p и штрафов, stop в Chat, stop_sequences в Messages, generationConfig.stopSequences в Gemini и уровней reasoning_effort.

Несколько исключений:

  • Поля, которые имеют смысл только на собственной платформе протокола, игнорируются и на генерацию не влияют: user, metadata и store в Chat; user, metadata, store и include в Responses; metadata в Messages.
  • Responses не поддерживает previous_response_id; передавайте всю историю в input.
  • safetySettings в Gemini принимается, но ни на что не влияет. cachedContent и серверные инструменты, такие как googleSearch и codeExecution, не поддерживаются и возвращают 400. Изображения передавайте как inlineData (base64) или fileData.fileUri (ссылка https); принимает ли модель изображения, определяет её inputSchema.

Отправка потокового запроса Chat

Когда SPICY_API_KEY и SPICY_MODEL заданы, как описано выше, эти команды записывают запрос в файл и получают ответ потоком. При необходимости замените пример промпта, а перед отправкой сверьте параметры модели с её текущей схемой. Каждый запуск отправляет новый Idempotency-Key, потому что каждый запуск — это новое сообщение.

jq -n --arg model "$SPICY_MODEL" '{
  model: $model,
  messages: [{role: "user", content: "Explain a rainbow in one sentence."}],
  stream: true
}' > chat-request.json

curl --fail-with-body --no-buffer --max-time 120 \
  https://api.spicyapi.ai/v1/chat/completions \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @chat-request.json

--no-buffer выводит каждую часть сразу, как только она пришла, а --max-time 120 прекращает ожидание через две минуты. Строки, похожие на приведённые ниже, должны появляться одна за другой, а в самом конце — data: [DONE]. Ответ разбит на части delta.content; чтобы получить весь текст, соедините их по порядку. Последний фрагмент перед [DONE] содержит usage.

data: {"id":"chatcmpl-req_01k...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"A rainbow"}}]}

data: {"id":"chatcmpl-req_01k...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" forms when"}}]}

data: [DONE]

uuidgen входит в состав macOS; в Linux он устанавливается вместе с пакетом uuid-runtime.

Отправка запроса Gemini

Здесь используются те же SPICY_API_KEY и SPICY_MODEL и снова новый Idempotency-Key: это другой запрос, не тот, что в примере Chat, поэтому повторное использование того ключа вернуло бы 409. ID модели указывается в URL, так что поля с моделью в теле запроса нет. Заключите URL в двойные кавычки и запишите переменную как ${SPICY_MODEL}, чтобы командная оболочка не приняла следующее за ней двоеточие за модификатор переменной.

jq -n '{
  systemInstruction: {parts: [{text: "Answer in one sentence."}]},
  contents: [{role: "user", parts: [{text: "Explain a rainbow."}]}],
  generationConfig: {temperature: 0.7, maxOutputTokens: 256}
}' > gemini-request.json

curl --fail-with-body --no-buffer --max-time 120 \
  "https://api.spicyapi.ai/v1beta/models/${SPICY_MODEL}:streamGenerateContent?alt=sse" \
  -H "x-goog-api-key: $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @gemini-request.json

Если потоковая передача не нужна, замените :streamGenerateContent?alt=sse на :generateContent и читайте ответ из candidates[0].content.parts[].text; расход указан в usageMetadata. Без alt=sse потоковый запрос возвращает один массив JSON, который выдаётся постепенно; официальные SDK добавляют alt=sse сами.

Нативный поток с подтверждённой ценой

POST /api/v1/jobs/stream — единственный текстовый эндпоинт, который принимает расчёт стоимости, поэтому он может отказаться выполнять запрос, если цена изменилась. Тело запроса устроено так же, как у createTask: model и input, поля которого берутся из inputSchema модели (для текстовых моделей это messages и другие канонические параметры из таблицы выше).

# 1. Один раз запишите запрос в файл.
jq -n --arg model "$SPICY_MODEL" '{
  model: $model,
  input: {messages: [{role: "user", content: "Explain a rainbow in one sentence."}]}
}' > native-request.json

# 2. Получите расчёт стоимости. Он ничего не создаёт и ничего не списывает.
QUOTE=$(curl -s https://api.spicyapi.ai/api/v1/jobs/quote \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @native-request.json)
echo "$QUOTE" | jq '{code, msg, estimatedCost: .data.estimatedCost, maxCharge: .data.maxCharge, expiresAt: .data.expiresAt}'

# 3. В течение пяти минут добавьте quoteId и expectedCost к неизменённому запросу и запустите поток.
jq --argjson quote "$QUOTE" \
  '. + {quoteId: $quote.data.quoteId, expectedCost: $quote.data.estimatedCost}' \
  native-request.json > native-confirmed.json

curl --fail-with-body --no-buffer --max-time 120 \
  https://api.spicyapi.ai/api/v1/jobs/stream \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @native-confirmed.json

Шаг 2 выводит code: 200 и цену в долларах США; любой другой code означает, что расчёт стоимости не удался, а msg объясняет почему. Если вызов потока завершается ошибкой ещё до того, как отправлен хоть какой-то текст, — например, 40901, потому что изменилась цена или сам запрос, или 40201 из-за недостатка средств на балансе, — вы получаете обычный JSON {code,msg,data,request_id}, и ничего не резервируется. Если же поток уже начался, ошибка завершает его событием event: error с data: {"code":"stream_interrupted"}, и за такой запрос ничего не списывается. Модели изображений, видео и аудио возвращают здесь 503; для них используйте createTask.

Ответ приходит строками SSE data:. В отличие от /v1/chat/completions, этот эндпоинт не обещает единый формат событий для всех моделей, поэтому проверьте свой разбор потока на выбранной модели; если нужен один стабильный формат, используйте эндпоинт /v1 и ограничьте расходы лимитами ключа.

Отправка изображений, видео и аудио текстовой модели

Некоторые текстовые модели читают не только текст, но и изображения, а часть из них — ещё и видео с аудио. Вы отправляете медиафайл вместе с вопросом, а ответ по-прежнему приходит текстом. Что именно принимает модель, указано в её схеме, а не следует из протокола. Проверьте выбранную модель:

curl -s "https://api.spicyapi.ai/api/v1/models/$SPICY_MODEL" \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  | jq -c '[.data.inputSchema.properties.messages.items.properties.content.items.oneOf[]?.properties.type.const]'

В списке перечислены части содержимого, которые принимает модель. ["text","image_url"] означает текст и изображения; модель, которая читает ещё и видео с аудио, дополнительно перечисляет video_url, audio_url и input_audio. Пустой список означает, что модель принимает только текст. Если отправить часть, которой нет в списке, вернётся 400, и плата не списывается.

Чтобы отправить медиа, сделайте content сообщения списком частей, а не строкой: часть text для вопроса и по одной части на каждый файл:

jq -n --arg model "$SPICY_MODEL" '{
  model: $model,
  stream: true,
  messages: [{
    role: "user",
    content: [
      {type: "text", text: "What is in this picture?"},
      {type: "image_url", image_url: {url: "https://example.com/photo.jpg"}}
    ]
  }]
}' > image-request.json

curl --fail-with-body --no-buffer --max-time 120 \
  https://api.spicyapi.ai/v1/chat/completions \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @image-request.json

Для каждого вида медиа своя часть:

ЧастьФормаОткуда берётся файл
Изображение{"type": "image_url", "image_url": {"url": "…"}}Публичный HTTPS URL, загруженный файл (spicy://f/…) или небольшой URI data:
Видео{"type": "video_url", "video_url": {"url": "…"}}Загруженный файл (spicy://f/…) или публичный HTTPS URL
Аудиофайл{"type": "audio_url", "audio_url": {"url": "spicy://f/…"}}Только загруженный файл
Встроенное аудио{"type": "input_audio", "input_audio": {"data": "<base64>", "format": "wav"}}Base64 в самом запросе, wav или mp3

Как загрузить файл и получить его URI spicy://f/…, описано в разделе Медиа; загруженные файлы хранятся сутки. Из того, как каждый вид медиа попадает к модели, вытекает несколько правил:

  • Изображения могут быть публичным HTTPS URL, который отдаёт сам файл изображения (а не страницу, где оно показано), загруженным файлом или URI data: в пределах лимитов для встроенных данных. JPEG и PNG работают на каждой модели, которая принимает изображения.
  • Видео и аудио лучше всего отправлять загрузкой. Мы читаем загруженный файл и передаём его модели внутри запроса, поэтому загруженные видео и аудио в одном запросе могут весить в сумме не более 14 МБ — видео в MP4 или WebM, аудио в MP3 или WAV. При превышении вернётся 400, и плата не списывается. Публичный URL видео передаётся дальше как ссылка и работает, только если провайдер модели может его скачать.
  • Встроенное аудио (input_audio) следует формату OpenAI и должно уместиться в тело запроса размером 2 МиБ, поэтому подходит для коротких фрагментов; более длинное аудио загрузите и отправьте как audio_url.

В одно сообщение можно положить несколько файлов, каждый отдельной частью, и медиа могут быть и в более ранних сообщениях истории.

Остальные протоколы передают изображения в своей форме, и до модели всё доходит одинаково. Части с видео и аудио отправляются через Chat Completions и нативный поток.

ПротоколЧасть с изображением
Chat Completions{"type": "image_url", "image_url": {"url": "…"}}
Responses{"type": "input_image", "image_url": "…"}
Anthropic Messages{"type": "image", "source": {"type": "url", "url": "…"}} или источник base64
GeminiinlineData (base64) или fileData.fileUri (HTTPS URL)

Стоимость. Медиа считается входными токенами по обычной входной ставке модели — отдельной цены за файл нет. Изображение обычно добавляет от нескольких сотен до нескольких тысяч токенов в зависимости от модели и размера изображения; видео и аудио дают тем больше токенов, чем они длиннее. Точное число указано в блоке usage ответа.

Что резервируется заранее. Перед выполнением запроса мы резервируем верхнюю оценку, а после расчёта освобождаем разницу. Для запроса, в котором из медиа только изображения, загруженные вами или переданные прямо в запросе, оценка включает фиксированный запас на каждое изображение. Для запроса с видео или аудио либо с изображением, заданным публичным URL, оценка рассчитывается по всему входному лимиту модели, потому что заранее его размер неизвестен, — поэтому зарезервированная сумма может заметно превышать итоговое списание. Загружайте изображения, чтобы резерв оставался небольшим, держите на балансе достаточно средств для резерва или заранее запросите расчёт через POST /api/v1/jobs/quote, чтобы увидеть его.

В Playground. На модели, которая принимает медиа, в поле сообщения появляется кнопка вложения. Можно также вставить снимок экрана или перетащить файлы в поле; в одно сообщение помещается до четырёх вложений, а видео и аудио среди них могут весить в сумме до 14 МБ. У моделей, читающих только текст, кнопки нет.

Диалоги и вызовы инструментов

Chat Completions

  • Без потока: читайте choices[0].message.
  • С потоком: обрабатывайте choices[].delta по одному событию SSE за раз, а не по сетевым пакетам. Собирайте tool_calls по index, а проверяйте и выполняйте вызов только тогда, когда его arguments получены полностью.
  • Сохраняйте сообщение ассистента в истории и возвращайте каждый результат в сообщении tool с соответствующим tool_call_id.

Responses

  • Передавайте всю историю в input; previous_response_id отклоняется.
  • Возвращайте результаты функций элементами function_call_output с соответствующим call_id.

Messages

  • Используйте блоки содержимого text, tool_use и tool_result, а не форму сообщений Chat.

Gemini

  • В contents чередуются две роли: user и model.
  • Когда модель хочет вызвать инструмент, она возвращает часть functionCall. Выполнив инструмент, оберните результат в часть functionResponse и отправьте её в следующем содержимом с ролью user, указав то же name, что и в вызове.

Завершение, ошибки и обрывы соединения

ПротоколНормальное завершениеГде указан расход
Chat Completionsdata: [DONE]usage в последнем фрагменте
ResponsesСобытие response.completedresponse.usage в этом событии
Messagesmessage_stop после message_deltausage в message_delta
GeminiПоследнее событие с finishReason; [DONE] не бываетusageMetadata

Прежде чем читать поток, проверьте HTTP-статус и Content-Type: ошибки возвращаются в собственном JSON-формате каждого протокола.

ПротоколТело ошибки
Chat Completions, Responses{"error": {"message", "type", "param", "code"}}
Messages{"type": "error", "error": {"type", "message"}}
Gemini{"error": {"code", "message", "status"}}

HTTP-статусы во всех четырёх протоколах одинаковые. status в Gemini использует названия статусов Google: 400 — INVALID_ARGUMENT, 401 — UNAUTHENTICATED, 402 — FAILED_PRECONDITION, 403 — PERMISSION_DENIED, 404 — NOT_FOUND, 409 — ABORTED, 429 — RESOURCE_EXHAUSTED, 500 — INTERNAL, 503 — UNAVAILABLE и 504 — DEADLINE_EXCEEDED. Ветвите логику кода по статусу и типу ошибки, но никогда — по тексту сообщения.

Если что-то ломается уже после начала потока, Chat и Gemini отправляют событие с объектом ошибки, Responses — событие ошибки, а Messages — event: error. Ваш клиент должен обрабатывать эти события ошибок, а также таймауты и соединение, которое закрылось без обычного признака завершения. Если поток идёт через ваш собственный прокси, отключите в нём буферизацию ответа, иначе части будут приходить пачками.

Сколько вы платите, если поток остановился раньше времени

  • Вы отключились (закрыли вкладку, прервали запрос, сработал таймаут клиента): отключение не останавливает оплату. С вас списывается расход, о котором сообщает модель, а у многих моделей он охватывает весь ответ — включая текст, который вы так и не получили, — но никогда не больше суммы, зарезервированной при приёме запроса. Неполученный текст позже получить нельзя. Чтобы тратить меньше, уменьшайте max_tokens, а не обрывайте соединение.
  • Модель или наш сервис дали сбой посреди потока (вы получили событие ошибки или поток закончился без признака завершения): запрос помечается как завершившийся ошибкой, и ничего не списывается.
  • Модель прислала вам текст или рассуждения, а затем её ответ был признан пустым или отказом: то, что вы уже получили, оплачивается по расходу, о котором сообщает модель, но не больше зарезервированной суммы, и поток завершается нормально. Деньги возвращаются полностью, только если до вас не дошло совсем ничего.
  • Запрос не уложился в серверный срок: он завершается ошибкой, и ничего не списывается.

Тайм-ауты и потоковая передача

Без потоковой передачи ответ приходит целиком, когда он готов, а длинные ответы и долгие рассуждения требуют времени: модели вроде Grok, GLM, DeepSeek Pro и Claude Opus могут думать несколько минут, прежде чем что-то написать, особенно при высоком reasoning_effort. Сетевая периферия перед API закрывает соединение, по которому примерно 100 секунд ничего не приходило, поэтому запросы обоих видов держатся открытыми, пока модель работает.

  • Запрос без потоковой передачи, который всё ещё выполняется спустя примерно 30 секунд, начинает ответ раньше. Вы получаете статус 200, Content-Type: application/json и заголовок X-Spicy-Keepalive: 1, затем каждые 15 секунд по переводу строки и в конце тело JSON. JSON допускает пробельные символы перед значением, поэтому стандартные парсеры (json.loads, JSON.parse, response.json()) и официальные SDK читают тело без изменений. Запрос, который завершился раньше, получает обычный ответ со своим настоящим кодом статуса и без этого заголовка.
  • После этого момента ошибка приходит в теле, а не в статусе. Статус 200 уже отправлен, поэтому в теле будет обычный объект ошибки протокола из таблицы выше, с тем же кодом и тем же сообщением; в формате Gemini error.code по-прежнему содержит HTTP-статус, который был бы у этой ошибки. Если есть заголовок X-Spicy-Keepalive, проверьте, нет ли в теле объекта error, прежде чем читать ответ: официальные SDK не считают 200 ошибкой. За неудачный запрос плата не списывается.
  • Потоковая передача тоже держит соединение открытым. Пока модель ещё работает, в потоковом ответе идут строки keep-alive (комментарии : keep-alive в SSE, пустые строки в формате Gemini), поэтому соединение не простаивает, сколько бы модель ни думала. Ваш парсер должен их пропускать, как того требует формат SSE; официальные SDK уже так делают.
  • Для всего, что может занять много времени, лучше используйте stream: true. Вы видите ответ по мере того, как он пишется, а ошибка приходит событием ошибки, которое SDK понимают. Chat Completions, Responses и Messages принимают stream: true; в формате Gemini вызывайте :streamGenerateContent?alt=sse вместо :generateContent. Изменённое тело — это новый запрос, поэтому отправляйте его с новым Idempotency-Key: повторное использование старого вернёт 409.
  • Запрос, оборванный сетевой периферией, не оплачивается. Если примерно 100 секунд к вам ничего не приходит и соединение закрывается, запрос появляется в разделе Консоль → Логи и в GET /api/v1/jobs как неудачный, с errorCode timeout и сообщением о том, что произошло. Если отправить его повторно с тем же Idempotency-Key, это сообщение вернётся в виде ошибки.
  • Ваши собственные ограничения по времени продолжают действовать. Общий тайм-аут в вашем HTTP-клиенте или в прокси, которым вы управляете, тоже завершит запрос, а если к вам уже что-то пришло (в том числе строка keep-alive), это считается отключением с вашей стороны (см. Сколько вы платите, если поток остановился раньше времени). Официальные SDK OpenAI по умолчанию ждут до 10 минут; примеры с curl на этой странице сдаются через 2 минуты, так что для долгих рассуждений увеличьте --max-time.

Так включается потоковая передача в SDK OpenAI и в curl:

OpenAI Python
import os
from openai import OpenAI

client = OpenAI(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai/v1")
stream = client.chat.completions.create(
    model=os.environ["SPICY_MODEL"],
    messages=[{"role": "user", "content": "Explain a rainbow in one sentence."}],
    stream=True,
)
for chunk in stream:
    if chunk.choices and chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="", flush=True)
print()
OpenAI Node.js
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.SPICY_API_KEY, baseURL: "https://api.spicyapi.ai/v1" });
const stream = await client.chat.completions.create({
  model: process.env.SPICY_MODEL,
  messages: [{ role: "user", content: "Explain a rainbow in one sentence." }],
  stream: true,
});
for await (const chunk of stream) {
  process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
process.stdout.write("\n");
curl
curl --fail-with-body --no-buffer --max-time 900 \
  https://api.spicyapi.ai/v1/chat/completions \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @chat-request.json

В примере для Node.js используется await на верхнем уровне, поэтому запускайте его как ES-модуль (например, сохраните как stream.mjs). Команда curl отправляет chat-request.json из раздела Отправка потокового запроса Chat, где уже задано stream: true; с --max-time 900 на ответ отводится до 15 минут.

Расчёт стоимости и оплата

Если стоимость нужно подтверждать явно, используйте нативный поток с расчётом стоимости. /v1 и /v1beta не принимают данные расчёта стоимости и применяют цену, действующую в момент приёма запроса.

Текстовые вызовы не возвращают ID задачи, а маркер конца текста не доказывает, что списание окончательное. Чтобы узнать, сколько стоил вызов, запросите список недавних задач тем же API-ключом — GET /api/v1/jobs?model=<model>&from=<YYYY-MM-DD> — и найдите нужную по createdAt или посмотрите в разделе Консоль → Логи. Считайте cost окончательным, только когда settled равно true. См. Асинхронные задачи и Оплата.

reasoning_tokens уже входит в completion_tokens: не прибавляйте его второй раз.

Другие форматы запросов

Эти примеры показывают только оболочку протокола. Замените MODEL_ID_FROM_CATALOG актуальной моделью, которая поддерживает показанные поля; сам заполнитель никогда не отправляйте.

POST /v1/responses
{
  "model": "MODEL_ID_FROM_CATALOG",
  "input": "Explain a rainbow in one sentence.",
  "stream": false
}
POST /v1/messages
{
  "model": "MODEL_ID_FROM_CATALOG",
  "max_tokens": 256,
  "messages": [{"role": "user", "content": "Explain a rainbow in one sentence."}],
  "stream": false
}

Что читать дальше

Расчёт цены и совместимость протоколов · Идемпотентность · Оплата · Интеграция в рабочей среде · Диагностика и восстановление

На этой странице