Recettes de chat
Programmes complets en Python, Node.js et curl pour les conversations, le roleplay, le streaming dans votre interface et les appels d’outils.
Chaque recette est un programme entier : définissez deux variables d’environnement, copiez-le, puis exécutez-le. Toutes appellent POST https://api.spicyapi.ai/v1/chat/completions avec les bibliothèques OpenAI officielles ; le même code fonctionne donc avec chaque modèle de texte du catalogue. Texte et streaming explique le protocole ; cette page montre comment construire les usages courants dessus.
Avant de commencer
1. Placez votre clé API dans une variable d’environnement, pour ne jamais la coller dans le code :
export SPICY_API_KEY="sk-spicy-paste-your-key-here"2. Choisissez un modèle de texte du catalogue actuel et conservez son ID dans SPICY_MODEL. Chaque recette le lit. Cette commande liste les modèles actuellement appelables par votre clé et nécessite 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 aboveLes IDs de texte ont la forme publisher/model/chat. MODEL_ID_FROM_CATALOG est seulement une valeur à remplacer : ne l’envoyez jamais littéralement.
3. Vérifiez ce que ce modèle accepte. Les modèles diffèrent : certains acceptent temperature, d’autres non ; reasoning_effort prend des valeurs différentes selon le modèle ; seuls certains acceptent les images. Les champs d’échantillonnage non pris en charge, tels que temperature ou frequency_penalty, sont ignorés et nommés dans l’en-tête de réponse X-Spicy-Ignored-Params. Les valeurs par défaut courantes des clients sont aussi traitées : un champ stop non pris en charge est ignoré, une valeur n: 1 non prise en charge correspond à une seule réponse, et un niveau de raisonnement reconnu est ajusté au niveau accepté le plus proche si nécessaire. L’en-tête X-Spicy-Adjusted-Params indique les réglages ajustés. Les champs fonctionnels non pris en charge, tels que tools ou response_format, produisent toujours une erreur 400. Cette commande affiche les champs, les valeurs reasoning_effort et les types de contenu de message acceptés :
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]
}'Pour voir tous les modèles de texte ensemble, GET /v1/models?modality=text renvoie leurs supported_parameters, context_length et max_output_tokens. Il liste seulement les noms de champs ; utilisez la commande précédente pour leurs valeurs autorisées.
4. Installez la bibliothèque OpenAI de votre langage : pip install openai pour Python, ou npm install openai pour Node.js 18 ou plus récent. Enregistrez les programmes Node.js avec l’extension .mjs, car ils utilisent await au premier niveau, puis exécutez-les avec node file.mjs.
Gardez une marge pour le raisonnement
Beaucoup de modèles de texte raisonnent avant de répondre, et ce raisonnement compte dans max_tokens. Les recettes omettent max_tokens pour utiliser la valeur par défaut de chaque modèle. Si vous le définissez, gardez une marge généreuse de quelques milliers de tokens : une valeur trop faible peut être entièrement consommée par le raisonnement. La requête échoue alors avec un message demandant d’augmenter max_tokens ou de réduire reasoning_effort. Cet échec n’est pas facturé.
Conversation à plusieurs tours
L’API ne conserve aucune mémoire entre les appels. Une conversation est une liste que vous conservez et renvoyez entièrement à chaque tour : d’abord le message system, puis les messages user et assistant en alternance. Ajoutez chaque réponse à la liste comme message assistant.
Chaque message renvoyé est facturé à nouveau en entrée ; retirez donc les tours les plus anciens lorsque la conversation s’allonge. Conservez toujours system et retirez les tours par paires utilisateur/assistant pour que l’historique commence encore par 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:]Le modèle répond au dernier message en utilisant tout ce qui précède : demandez-lui ensuite quels ingrédients vous avez mentionnés, il les connaît. Les modèles de raisonnement renvoient aussi reasoning_content à côté de content ; pour une conversation simple, vous n’avez pas besoin de le renvoyer.
Roleplay avec une fiche de personnage
Un personnage est un message system rédigé avec soin. Sans mémoire dans l’API, la fiche accompagne chaque requête, ce qui conserve aussi la cohérence du personnage au cinquantième tour. Une bonne fiche répond à six questions :
| Partie | Contenu | Exemple |
|---|---|---|
| Identité | Qui est le personnage, en deux ou trois faits guidant son comportement | Un détective retraité devenu gardien de phare |
| Voix | Façon de parler : longueur des phrases, vocabulaire, habitudes, manière de s’adresser à l’utilisateur | Mesurée, remarque les petits détails à voix haute, appelle l’utilisateur « ami » |
| Univers | Cadre et un ou deux faits au cœur de l’histoire | Un port de pêche des années 1920 où la cloche d’un navire coulé sonne à minuit |
| Limites | Ce que le personnage fait ou non, et comment quitter l’histoire | Reste dans le cadre ; répond brièvement hors personnage à (OOC: ...) ; garde l’histoire adaptée à un public général |
| Narration | Personne narrative, temps et notation des dialogues | Troisième personne, passé ; paroles entre guillemets |
| Format | Longueur et fin de chaque réponse | Au maximum 120 mots, puis deux ou trois choix numérotés |
Deux habitudes améliorent le roleplay. Placez la réplique d’ouverture dans l’historique comme message assistant : le premier texte vu par l’utilisateur est déjà dans le personnage. Terminez chaque réponse par une question ou un choix pour qu’il sache comment continuer ; les choix numérotés d’une fiction interactive permettent de répondre par un chiffre.
Une navigatrice de dirigeable fantastique. La réponse arrive mot à mot en streaming.
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})Les mêmes six parties conviennent à tout genre. L’ordinateur d’un vaisseau de science-fiction, un compagnon pour une routine quotidienne ou le narrateur d’une aventure textuelle demandent seulement une autre fiche :
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.Pour les longues histoires, gardez une fiche courte et stable. Lorsque l’historique grandit, remplacez les premiers tours par une courte note « L’histoire jusqu’ici » dans un second message system, plutôt que de laisser le prompt croître sans limite.
Streaming dans votre propre interface
Le streaming affiche la réponse pendant sa rédaction et rend le chat réactif. Six règles couvrent chaque modèle du catalogue :
- Demandez l’utilisation pour obtenir les nombres de tokens. Les flux suivent OpenAI : sans
stream_options.include_usage, aucun fragment ne porteusage. Avec"include_usage": true, un fragment supplémentaire arrive juste avant[DONE], avec l’usagefinal et une listechoicesvide ; tous les autres portentusage: null. - Vérifiez
choicesavant de lirechoices[0]. Chaque fragment sauf celui de l’utilisation finale comporte au moins un choix. C’est important seulement lorsque vous demandez l’utilisation, mais la vérification ne coûte rien et sécurise les deux cas. - Le raisonnement est séparé. Les modèles de raisonnement diffusent celui-ci dans
delta.reasoning_contentet la réponse dansdelta.content. Affichez le premier atténué, replié ou pas du tout ; seul le second est la réponse. finish_reason: "length"signifie une réponse coupée. Le modèle a atteintmax_tokens. Augmentez-le ou réduisezreasoning_effort.- Les erreurs peuvent arriver en cours de flux. Après le début du streaming, un échec arrive comme événement avec un objet
error, levé comme exception par les bibliothèques OpenAI. Une telle requête échouée n’est pas facturée. Si le modèle avait déjà terminé la réponse au moment de la coupure, aucune erreur n’arrive : le flux se termine normalement et la réponse est facturée comme toute autre. Voir Fin, erreurs et déconnexions. - Arrêter tôt n’arrête pas la facture. Fermer la connexion n’arrête pas le modèle ; vous êtes facturé selon l’utilisation qu’il rapporte. Réduisez plutôt
max_tokens; voir Facturation d’un flux interrompu.
Un affichage de terminal suivant les six règles : raisonnement atténué, réponse en pleine couleur et nombres de tokens sur une ligne finale.
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]")Appels d’outils
Les outils permettent au modèle de demander une opération à votre programme, comme lancer des dés, rechercher une commande ou lire un calendrier, puis de répondre avec le résultat. La boucle est toujours identique :
- Envoyez les messages avec
tools, une liste de fonctions décrites par JSON Schema. - Si la réponse contient
tool_calls, ajoutez le message assistant exactement tel que reçu, exécutez vous-même chaque appel et ajoutez un messagetoolpar appel avec letool_call_idcorrespondant. - Renvoyez le tout. Répétez jusqu’à une réponse sans
tool_calls: c’est la réponse finale.
Considérez les arguments comme non fiables : analysez-les, vérifiez-les et renvoyez une erreur comme résultat d’outil plutôt que de faire planter le programme. Limitez le nombre de tours pour qu’un modèle désorienté ne boucle pas indéfiniment. Chaque modèle de texte du catalogue accepte actuellement tools ; vérifiez les valeurs de tool_choice avant d’imposer un outil précis, car certains n’acceptent que auto et 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)})La première réponse se termine avec finish_reason: "tool_calls" et content à null ; la dernière avec "stop". Les modèles de raisonnement peuvent aussi inclure reasoning_content dans le tour assistant : gardez-le lors du renvoi, comme les exemples qui ajoutent le message inchangé. En streaming, l’appel d’outil arrive par morceaux ; assemblez-les par index selon Conversations et appels d’outils.
Sortie JSON
Lorsque votre programme a besoin de données plutôt que de texte, demandez du JSON avec response_format sur les modèles dont l’inputSchema le liste et décrivez les champs dans le prompt. Analysez toujours la réponse avec précaution : une réponse coupée par max_tokens n’est pas du JSON valide.
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)Pour un modèle ne listant pas response_format, omettez le champ et utilisez uniquement le prompt système ; le bloc try ci-dessus y est encore plus important.
En cas de problème
Les erreurs arrivent dans la forme OpenAI {"error": {"message", "type", "param", "code"}}, et les bibliothèques lèvent leurs exceptions habituelles. Décidez selon le statut HTTP et error.code ; affichez error.message aux utilisateurs puisqu’il indique quoi modifier. Si l’erreur concerne un champ, param le désigne comme chemin à points, par exemple temperature ou messages.1.content.0.
| Statut | error.code | Situation | Action |
|---|---|---|---|
| 400 | unsupported_parameter | Champ refusé, par exemple logprobs: is not a supported field, ou type de partie de message refusé, comme image_url sur un modèle de texte seul. Les champs d’échantillonnage tels que temperature sont ignorés plutôt que refusés et nommés dans X-Spicy-Ignored-Params | Retirez-le. Vérifiez les champs selon Avant de commencer |
| 400 | invalid_request | Champ absent ou valeur refusée, par exemple reasoning_effort: must be one of: "low", "high", "max", ou modèle non destiné au chat, avec param à model | Modifiez la valeur désignée ou choisissez un modèle dont l’ID finit par /chat |
| 400 | content_rejected | Refus de la requête selon la politique de contenu du modèle | Reformulez ; renvoyer l’entrée identique provoque le même refus |
| 401 | invalid_api_key | Clé absente, mal saisie ou révoquée | Vérifiez SPICY_API_KEY ; les clés commencent par sk-spicy- |
| 402 | insufficient_balance, spend_cap_exceeded | Le solde ou un plafond ne couvre pas le montant réservé pour cette requête | Ajoutez des fonds, relevez le plafond ou réduisez max_tokens ; voir ci-dessous |
| 404 | model_not_found | Aucun modèle avec cet ID, avec param à model. Le message indique la forme de l’ID et, s’il existe, le modèle de chat appelable le plus proche | Copiez l’ID exact du catalogue actuel |
| 404 | not_found | Le modèle existe mais n’est pas appelable maintenant | Choisissez un autre modèle du catalogue actuel |
| 409 | conflict | Un Idempotency-Key a été réutilisé avec un corps différent | Chaque nouveau message exige une nouvelle clé ; voir Clés d’idempotence sur les API de texte |
| 429 | rate_limit_exceeded | Trop de requêtes | Attendez puis réessayez ; si Retry-After est présent, il indique les secondes à attendre |
| 503 | upstream_failed, model_unavailable | Échec ou indisponibilité temporaire du modèle. Aucune facturation | Réessayez plus tard avec un nouveau Idempotency-Key |
Pourquoi un 402 peut survenir malgré un solde apparemment suffisant : avant une requête de texte, un montant est réservé pour couvrir les messages et l’intégralité de max_tokens de sortie aux tarifs du modèle, ou son max_tokens par défaut si vous l’omettez. Vous ne payez que les tokens réellement utilisés, et le reste est libéré à la fin, mais la réservation entière doit tenir dans le solde disponible avant le début. Un max_tokens réaliste réduit cette réservation. Voir Facturation.
Trouver le coût d’un appel
Chaque requête acceptée s’exécute comme une tâche, nommée dans l’en-tête X-Spicy-Task-Id avec ou sans streaming. Lisez l’en-tête puis retrouvez la tâche : cost est définitif lorsque settled est true. Une requête refusée avant acceptation, par exemple une erreur de validation, ne crée aucune tâche et n’a pas cet en-tête.
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"))En streaming, l’en-tête est présent avant le premier fragment ; with_raw_response en Python et .withResponse() en Node.js fonctionnent de la même façon avec stream: true. Les entrées de GET /api/v1/jobs portent aussi le requestId de leur appel de création, correspondant à l’en-tête de réponse X-Request-Id.
Pour continuer
Texte et streaming · Migrer depuis OpenAI ou OpenRouter · Catalogue des modèles et schémas · Facturation · Erreurs
Texte et streaming
Appeler les modèles de texte au format officiel OpenAI, Anthropic ou Google Gemini, et gérer correctement conversations, outils, flux et coûts.
Migrer depuis OpenAI ou OpenRouter
Adaptez du code OpenAI à SpicyAPI : URL de base, clé et ID de modèle, différences de paramètres et champs OpenRouter à retirer.

