spicyapiDocumentation
Texte et chat
Contenu principal

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 above

Les 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.

chat.py
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 :

PartieContenuExemple
IdentitéQui est le personnage, en deux ou trois faits guidant son comportementUn détective retraité devenu gardien de phare
VoixFaçon de parler : longueur des phrases, vocabulaire, habitudes, manière de s’adresser à l’utilisateurMesurée, remarque les petits détails à voix haute, appelle l’utilisateur « ami »
UniversCadre et un ou deux faits au cœur de l’histoireUn port de pêche des années 1920 où la cloche d’un navire coulé sonne à minuit
LimitesCe que le personnage fait ou non, et comment quitter l’histoireReste dans le cadre ; répond brièvement hors personnage à (OOC: ...) ; garde l’histoire adaptée à un public général
NarrationPersonne narrative, temps et notation des dialoguesTroisième personne, passé ; paroles entre guillemets
FormatLongueur et fin de chaque réponseAu 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.

roleplay.py
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 :

Another card: a daily companion
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 :

  1. Demandez l’utilisation pour obtenir les nombres de tokens. Les flux suivent OpenAI : sans stream_options.include_usage, aucun fragment ne porte usage. Avec "include_usage": true, un fragment supplémentaire arrive juste avant [DONE], avec l’usage final et une liste choices vide ; tous les autres portent usage: null.
  2. Vérifiez choices avant de lire choices[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.
  3. Le raisonnement est séparé. Les modèles de raisonnement diffusent celui-ci dans delta.reasoning_content et la réponse dans delta.content. Affichez le premier atténué, replié ou pas du tout ; seul le second est la réponse.
  4. finish_reason: "length" signifie une réponse coupée. Le modèle a atteint max_tokens. Augmentez-le ou réduisez reasoning_effort.
  5. 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.
  6. 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.

stream.py
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 :

  1. Envoyez les messages avec tools, une liste de fonctions décrites par JSON Schema.
  2. 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 message tool par appel avec le tool_call_id correspondant.
  3. 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.

tools.py
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.

json_output.py
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.

Statuterror.codeSituationAction
400unsupported_parameterChamp 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-ParamsRetirez-le. Vérifiez les champs selon Avant de commencer
400invalid_requestChamp 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 à modelModifiez la valeur désignée ou choisissez un modèle dont l’ID finit par /chat
400content_rejectedRefus de la requête selon la politique de contenu du modèleReformulez ; renvoyer l’entrée identique provoque le même refus
401invalid_api_keyClé absente, mal saisie ou révoquéeVérifiez SPICY_API_KEY ; les clés commencent par sk-spicy-
402insufficient_balance, spend_cap_exceededLe solde ou un plafond ne couvre pas le montant réservé pour cette requêteAjoutez des fonds, relevez le plafond ou réduisez max_tokens ; voir ci-dessous
404model_not_foundAucun 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 procheCopiez l’ID exact du catalogue actuel
404not_foundLe modèle existe mais n’est pas appelable maintenantChoisissez un autre modèle du catalogue actuel
409conflictUn Idempotency-Key a été réutilisé avec un corps différentChaque nouveau message exige une nouvelle clé ; voir Clés d’idempotence sur les API de texte
429rate_limit_exceededTrop de requêtesAttendez puis réessayez ; si Retry-After est présent, il indique les secondes à attendre
503upstream_failed, model_unavailableÉchec ou indisponibilité temporaire du modèle. Aucune facturationRé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.

task_id.py
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

Sur cette page