Примеры для чата
Полные рабочие программы для многошаговых диалогов, ролевого общения, потоковой передачи в свой интерфейс и вызова инструментов на Python, Node.js и curl.
Каждый пример на этой странице — целая программа: задайте две переменные окружения, скопируйте код и запустите. Все примеры вызывают POST https://api.spicyapi.ai/v1/chat/completions через официальные библиотеки OpenAI, поэтому тот же код работает с любой текстовой моделью каталога. Раздел Текст и потоковые ответы объясняет сам протокол; здесь показано, как на его основе реализовать распространённые сценарии.
Перед началом
1. Сохраните API-ключ в переменной окружения, чтобы не вставлять его в код:
export SPICY_API_KEY="sk-spicy-paste-your-key-here"2. Выберите текстовую модель из актуального каталога и сохраните её ID в SPICY_MODEL. Каждый пример читает эту переменную. Команда выводит текстовые модели, которые ваш ключ может вызвать прямо сейчас (нужен jq):
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'
export SPICY_MODEL="MODEL_ID_FROM_CATALOG" # replace with one line from the list aboveID текстовых моделей имеют формат publisher/model/chat. MODEL_ID_FROM_CATALOG — только заполнитель; не отправляйте его буквально.
3. Проверьте, что принимает модель. Модели различаются: некоторые принимают temperature, другие — нет; у reasoning_effort разные значения для разных моделей; изображения принимает только часть моделей. Неподдерживаемые параметры сэмплирования, например temperature или frequency_penalty, игнорируются и перечисляются в заголовке ответа X-Spicy-Ignored-Params. Распространённые настройки клиентов по умолчанию также обрабатываются: неподдерживаемый stop игнорируется, неподдерживаемый n: 1 означает один ответ, а распознанный уровень рассуждения при необходимости заменяется ближайшим поддерживаемым. Заголовок X-Spicy-Adjusted-Params показывает скорректированные настройки. Неподдерживаемые функциональные поля, например tools или response_format, по-прежнему возвращают 400. Команда выводит поля, значения reasoning_effort и типы содержимого сообщений, принимаемые моделью:
curl -s "https://api.spicyapi.ai/api/v1/models/$SPICY_MODEL" \
-H "Authorization: Bearer $SPICY_API_KEY" \
| jq '.data.inputSchema.properties | {
fields: keys,
reasoning_effort: .reasoning_effort.enum,
content_parts: [.messages.items.properties.content.items.oneOf[]?.properties.type.const]
}'Для краткого обзора всех текстовых моделей GET /v1/models?modality=text возвращает supported_parameters, context_length и max_output_tokens каждой модели. Он перечисляет только названия полей, поэтому для допустимых значений используйте команду выше.
4. Установите библиотеку OpenAI для своего языка: pip install openai для Python или npm install openai для Node.js 18 и новее. Сохраняйте программы Node.js с расширением .mjs (они используют await на верхнем уровне) и запускайте через node file.mjs.
Оставьте запас для рассуждения
Многие текстовые модели рассуждают перед ответом, и эти токены входят в max_tokens. В примерах max_tokens не задан, поэтому каждая модель использует своё значение по умолчанию. Если задаёте его, оставляйте достаточный запас (несколько тысяч токенов): слишком маленький лимит может полностью уйти на рассуждение. Тогда запрос завершится ошибкой с предложением увеличить max_tokens или уменьшить reasoning_effort. Такая ошибка не оплачивается.
Многошаговый диалог
API не сохраняет память между вызовами. Диалог — список сообщений, который вы храните и заново отправляете целиком на каждом шаге: сначала сообщение system, затем чередующиеся user и assistant. После каждого ответа добавляйте его в список как сообщение assistant.
Каждое повторно отправленное сообщение снова оплачивается как входной текст, поэтому при росте диалога удаляйте самые старые шаги. Всегда сохраняйте сообщение system и удаляйте сообщения парами «пользователь/ассистент», чтобы история по-прежнему начиналась с сообщения user.
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai/v1")
MODEL = os.environ["SPICY_MODEL"]
KEEP = 20 # messages kept after the system prompt; an even number keeps user/assistant pairs together
history = [{"role": "system", "content": "You are a patient cooking coach. Keep every answer under 80 words."}]
while True:
try:
user_text = input("you> ").strip()
except EOFError: # Ctrl-D
break
if user_text in ("", "quit", "exit"):
break
history.append({"role": "user", "content": user_text})
reply = client.chat.completions.create(model=MODEL, messages=history)
answer = reply.choices[0].message.content or ""
print(f"coach> {answer}\n")
history.append({"role": "assistant", "content": answer})
# The API remembers nothing between calls: resend the conversation every turn,
# and trim it so the prompt (and what you pay for it) stops growing.
history = history[:1] + history[1:][-KEEP:]Модель отвечает на последнее сообщение с учётом всего, что было до него. Если затем спросить, какие ингредиенты вы упоминали, она их знает. Модели рассуждения также возвращают reasoning_content рядом с content; для обычного диалога отправлять его обратно не нужно.
Ролевое общение с карточкой персонажа
Персонаж — тщательно написанное сообщение system. Поскольку API не сохраняет память, карточка передаётся с каждым запросом; это помогает сохранить персонажа и на пятидесятом шаге. Хорошая карточка отвечает на шесть вопросов:
| Часть | Что написать | Пример |
|---|---|---|
| Личность | Кто персонаж: два-три факта, определяющих поведение | Бывший детектив, который теперь смотрит за маяком |
| Манера речи | Как он говорит: длина предложений, словарь, привычки, обращение к пользователю | Сдержанно, вслух отмечает мелкие детали, называет пользователя «друг» |
| Мир | Обстановка и один-два факта, на которых строится история | Рыбацкий городок 1920-х, где в полночь звонит колокол затонувшего корабля |
| Границы | Что персонаж делает и чего не делает, как выйти из истории | Остаётся в обстановке; кратко отвечает на (OOC: ...) вне роли; сохраняет историю подходящей для широкой аудитории |
| Повествование | Лицо и время повествования, оформление речи | Третье лицо, прошедшее время; реплики в кавычках |
| Формат | Длина и завершение каждого ответа | Не более 120 слов, в конце два-три пронумерованных варианта |
Два приёма делают ролевое общение естественным. Добавьте первую реплику персонажа в историю как сообщение assistant, чтобы пользователь сразу увидел ответ в роли. Завершайте каждый ответ вопросом или выбором, чтобы было понятно, как продолжить; в интерактивной истории пронумерованные варианты позволяют ответить одной цифрой.
Штурман фантастического дирижабля. Ответ поступает постепенно, слово за словом.
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai/v1")
MODEL = os.environ["SPICY_MODEL"]
CARD = {
"name": "Captain Ilsa Varn",
"identity": "Navigator of the airship Kestrel. Forty years old, calm under pressure, dry sense of humour, fiercely loyal to her crew.",
"voice": "Short, vivid sentences full of wind and weather. Calls the user 'deckhand'. No modern slang.",
"world": "Islands drift on warm updrafts above an endless sea of cloud. Storms here are alive and hungry, and maps go out of date within a week.",
"boundaries": (
"Stay in character and inside this world. If the user asks about the real world, react as Ilsa would: puzzled but polite. "
"If the user writes (OOC: ...), step out of the story for one short, plain answer, then return. "
"Keep the story suitable for a general audience."
),
"narration": "Describe the scene in the second person ('You feel the deck tilt...'). Put Ilsa's spoken lines in quotation marks.",
"format": "At most 120 words. End every reply with a question or a choice, so the user always knows how to continue.",
}
system_prompt = "\n".join(
[f"You are {CARD['name']}, a character in an interactive story."]
+ [f"{key.capitalize()}: {value}" for key, value in CARD.items() if key != "name"]
)
history = [
{"role": "system", "content": system_prompt},
{"role": "assistant", "content": "\"Welcome aboard, deckhand. Storm wall to the north, a lost island to the east. Which way do we steer?\""},
]
print(history[-1]["content"])
while True:
try:
user_text = input("\nyou> ").strip()
except EOFError: # Ctrl-D
break
if user_text in ("", "quit", "exit"):
break
history.append({"role": "user", "content": user_text})
answer = ""
stream = client.chat.completions.create(model=MODEL, messages=history, stream=True)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
piece = chunk.choices[0].delta.content
answer += piece
print(piece, end="", flush=True)
print()
history.append({"role": "assistant", "content": answer})Те же шесть частей подходят для любого жанра. Компьютеру научно-фантастического корабля, компаньону для ежедневных дел или рассказчику текстового приключения нужна лишь другая карточка:
You are Juniper, a friendly running coach inside a fitness app.
Identity: a former trail runner, upbeat and practical, never judgemental.
Voice: warm and brief; uses the user's name when they give it; celebrates small wins.
World: the user's own week. Remember goals and aches they mention earlier in this conversation.
Boundaries: give general fitness encouragement only; for pain, injury or medical questions, suggest seeing a professional. Stay in character; answer (OOC: ...) plainly.
Narration: first person, present tense, talking directly to the user.
Format: at most 80 words; end with one concrete suggestion for today.Для длинных историй делайте карточку короткой и стабильной. Когда история разрастается, заменяйте самые старые шаги краткой заметкой «История к этому моменту» во втором сообщении system, не допуская бесконечного роста промпта.
Потоковая передача в свой интерфейс
Потоковая передача показывает ответ по мере написания и делает чат отзывчивым. Шесть правил охватывают все модели каталога:
- Запрашивайте данные об использовании, если нужны числа токенов. Потоки следуют правилам OpenAI: без
stream_options.include_usageни один фрагмент не содержитusage. При"include_usage": trueнепосредственно перед[DONE]приходит дополнительный фрагмент с итоговымusageи пустым спискомchoices; у всех остальных фрагментов —usage: null. - Проверяйте
choicesдо чтенияchoices[0]. Все фрагменты, кроме последнего с данными об использовании, содержат хотя бы один вариант ответа. Это важно только при запросе этих данных, но проверка ничего не стоит и в обоих случаях защищает цикл. - Рассуждение передаётся отдельно. Модели рассуждения передают мысли в
delta.reasoning_content, а ответ — вdelta.content. Первое можно показывать приглушённым, свёрнутым или не показывать; ответом является только второе. finish_reason: "length"означает обрезанный ответ. Модель достиглаmax_tokens. Увеличьте его или уменьшитеreasoning_effort.- Ошибка может прийти в середине потока. После начала передачи ошибка приходит событием с объектом
error, а библиотеки OpenAI вызывают исключение. Такой неудавшийся запрос не оплачивается. Если модель уже завершила ответ до разрыва своего соединения, ошибки не будет: поток заканчивается нормально, и ответ оплачивается как обычный. Подробнее: Завершение, ошибки и разрывы соединения. - Досрочное закрытие не останавливает оплату. Закрытие соединения не останавливает модель; оплачивается использование, которое она сообщает. Вместо этого уменьшите
max_tokens(Оплата при досрочном закрытии потока).
Вывод в терминал по всем шести правилам: приглушённое рассуждение, обычный цвет ответа и итоговая строка с числами токенов.
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": "Write a four-line poem about a cat who lives in a lighthouse."}],
stream=True,
stream_options={"include_usage": True},
)
finish_reason = None
usage = None
thinking_shown = False
for chunk in stream:
# With include_usage, only the last chunk before [DONE] carries usage; it has no choices.
if chunk.usage:
usage = chunk.usage
if not chunk.choices:
continue
choice = chunk.choices[0]
thinking = getattr(choice.delta, "reasoning_content", None) # reasoning models only
if thinking:
thinking_shown = True
print(f"\033[2m{thinking}\033[0m", end="", flush=True) # dimmed
if choice.delta.content:
if thinking_shown:
print("\n")
thinking_shown = False
print(choice.delta.content, end="", flush=True)
if choice.finish_reason:
finish_reason = choice.finish_reason
print()
if finish_reason == "length":
print("[stopped at max_tokens: raise it, or lower reasoning_effort]")
if usage:
print(f"[{usage.prompt_tokens} prompt tokens, {usage.completion_tokens} completion tokens]")Вызов инструментов
Инструменты позволяют модели попросить вашу программу выполнить действие (бросить кубик, найти заказ, прочитать календарь), а затем ответить на основе результата. Цикл всегда один и тот же:
- Отправьте сообщения вместе с
tools— списком функций, описанных через JSON Schema. - Если ответ содержит
tool_calls, добавьте сообщение ассистента в точности как получено, самостоятельно выполните каждый вызов и добавьте для каждого сообщениеtoolс соответствующимtool_call_id. - Отправьте всё снова. Повторяйте, пока ответ не перестанет содержать
tool_calls: этот ответ и будет итоговым.
Считайте аргументы недоверенными входными данными: разберите и проверьте их; при ошибке верните её как результат инструмента, а не завершайте программу аварийно. Ограничьте число циклов, чтобы запутавшаяся модель не повторяла вызовы бесконечно. Сейчас все текстовые модели каталога принимают tools; прежде чем принудительно выбирать инструмент, проверьте значения tool_choice модели, поскольку некоторые принимают только auto и none.
import json
import os
import random
from openai import OpenAI
client = OpenAI(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai/v1")
MODEL = os.environ["SPICY_MODEL"]
def roll_dice(count, sides):
rolls = [random.randint(1, sides) for _ in range(count)]
return {"rolls": rolls, "total": sum(rolls)}
LOCAL_TOOLS = {"roll_dice": roll_dice}
TOOLS = [{
"type": "function",
"function": {
"name": "roll_dice",
"description": "Roll dice for a tabletop game and return every roll and the total.",
"parameters": {
"type": "object",
"properties": {
"count": {"type": "integer", "minimum": 1, "maximum": 10, "description": "How many dice to roll"},
"sides": {"type": "integer", "enum": [4, 6, 8, 10, 12, 20], "description": "Sides on each die"},
},
"required": ["count", "sides"],
},
},
}]
messages = [
{"role": "system", "content": "You are the game master of a fantasy adventure. Whenever the rules call for a roll, use roll_dice; never invent a result. A roll of 12 or more on a d20 succeeds."},
{"role": "user", "content": "I try to pick the lock on the old tower door. Roll a d20 for me."},
]
for _ in range(5): # a hard stop, so a confused model cannot loop forever
reply = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS)
message = reply.choices[0].message
# Send the assistant turn back exactly as it came, tool calls included.
messages.append(message.model_dump(exclude_none=True))
if not message.tool_calls:
print(message.content)
break
for call in message.tool_calls:
tool = LOCAL_TOOLS.get(call.function.name)
try:
result = tool(**json.loads(call.function.arguments or "{}")) if tool else {"error": "unknown tool"}
except (TypeError, ValueError) as error: # the model sent arguments the tool cannot use
result = {"error": str(error)}
print(f"[tool] {call.function.name}({call.function.arguments}) -> {result}")
messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})Первый ответ заканчивается с finish_reason: "tool_calls", а content равно null; последний — с "stop". Модели рассуждения также могут включать reasoning_content в сообщение ассистента. Сохраняйте его при повторной отправке, как делают примеры, добавляющие сообщение без изменений. При потоковой передаче вызов инструмента приходит частями; объединяйте их по index, как описано в разделе Диалоги и вызовы инструментов.
Ответ в JSON
Если программе нужны данные, а не обычный текст, запрашивайте JSON через response_format у моделей, чья inputSchema его содержит, и опишите поля в промпте. Всегда разбирайте ответ с обработкой ошибок: ответ, обрезанный из-за max_tokens, не является корректным JSON.
import json
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": "system", "content": "Reply with a JSON object only, with the keys title (string), genre (string) and hook (one sentence)."},
{"role": "user", "content": "Invent a cosy mystery novel set in a mountain bakery."},
],
response_format={"type": "json_object"},
)
try:
book = json.loads(reply.choices[0].message.content)
print(book["title"], "-", book["hook"])
except (json.JSONDecodeError, KeyError, TypeError):
print("Not the JSON we asked for:", reply.choices[0].message.content)Если модель не перечисляет response_format, не передавайте это поле и опирайтесь только на системный промпт; блок try выше в этом случае ещё важнее.
Если что-то пошло не так
Ошибки возвращаются в формате OpenAI, {"error": {"message", "type", "param", "code"}}, поэтому библиотеки OpenAI вызывают стандартные исключения. Обрабатывайте HTTP-статус и error.code; показывайте людям error.message, поскольку он объясняет, что изменить. Если ошибка относится к одному полю, param указывает его путём через точки, например temperature или messages.1.content.0.
| Статус | error.code | Что произошло | Что делать |
|---|---|---|---|
| 400 | unsupported_parameter | Модель не принимает поле (например, logprobs: is not a supported field) или тип части сообщения (например, часть image_url у текстовой модели без изображений). Параметры сэмплирования, такие как temperature, не отклоняются: они игнорируются и перечисляются в X-Spicy-Ignored-Params | Удалите поле. Проверьте поля модели, как описано в разделе Перед началом |
| 400 | invalid_request | Поле отсутствует или содержит недопустимое значение, например reasoning_effort: must be one of: "low", "high", "max"; либо модель не является чат-моделью (param — model) | Исправьте значение из сообщения или выберите модель с ID, заканчивающимся на /chat |
| 400 | content_rejected | Правила контента модели отклонили запрос | Переформулируйте; повторная отправка тех же входных данных приведёт к тому же отказу |
| 401 | invalid_api_key | Ключ отсутствует, написан неверно или отозван | Проверьте SPICY_API_KEY; ключи начинаются с sk-spicy- |
| 402 | insufficient_balance, spend_cap_exceeded | Баланс или лимит расходов не покрывает сумму резерва для запроса | Пополните баланс, повысьте лимит или уменьшите max_tokens (см. ниже) |
| 404 | model_not_found | Нет модели с таким ID (param — model). Сообщение показывает формат ID и, если такой вариант есть, ближайшую доступную чат-модель | Скопируйте точный ID из актуального каталога |
| 404 | not_found | Модель существует, но сейчас недоступна для вызова | Выберите другую модель из актуального каталога |
| 409 | conflict | Idempotency-Key повторно использован с другим телом запроса | Отправляйте каждое новое сообщение с новым ключом; см. Ключи идемпотентности на текстовых эндпоинтах |
| 429 | rate_limit_exceeded | Слишком много запросов | Подождите и повторите; если есть заголовок Retry-After, он указывает число секунд |
| 503 | upstream_failed, model_unavailable | Ошибка модели или временная недоступность. Ничего не оплачивается | Повторите позже с новым Idempotency-Key |
Почему запрос может вернуть 402, хотя баланс выглядит достаточным: перед выполнением текстового запроса резервируется сумма, покрывающая сообщения и весь объём выходных токенов max_tokens по ценам модели (её max_tokens по умолчанию, если поле не задано). Вы оплачиваете только фактически использованные токены, а остаток резерва освобождается после завершения запроса. Но до запуска весь резерв должен помещаться в доступный баланс. Реалистичный max_tokens уменьшает резерв. См. Оплата.
Как узнать стоимость вызова
Каждый принятый запрос выполняется как задача, которую ответ указывает в заголовке X-Spicy-Task-Id, независимо от потоковой передачи. Прочитайте заголовок, затем найдите задачу: cost становится итоговым, когда settled равно true. Запрос, отклонённый до принятия (например, из-за ошибки валидации), не создаёт задачу и не содержит этого заголовка.
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai/v1")
raw = client.chat.completions.with_raw_response.create(
model=os.environ["SPICY_MODEL"],
messages=[{"role": "user", "content": "Say hello in one sentence."}],
)
reply = raw.parse() # the usual ChatCompletion object
print(reply.choices[0].message.content)
print("task:", raw.headers.get("x-spicy-task-id"))При потоковой передаче заголовок присутствует в ответе до первого фрагмента; with_raw_response в Python и .withResponse() в Node.js работают так же при stream: true. Элементы GET /api/v1/jobs также содержат requestId создавшего их вызова — это заголовок ответа X-Request-Id.
Дополнительные материалы
Текст и потоковые ответы · Переход с OpenAI или OpenRouter · Каталог моделей и схемы · Оплата · Ошибки
Текст и потоковые ответы
Как вызывать текстовые модели в официальном формате OpenAI, Anthropic или Google Gemini и правильно обращаться с диалогами, инструментами, потоками и расходами.
Переход с OpenAI или OpenRouter
Как подключить существующий код с библиотеками OpenAI к SpicyAPI: базовый URL, ключ, ID модели, отличия полей запроса и настройки, которые нужно убрать после OpenRouter.

