Ejemplos de chat
Programas completos para conversaciones, rol de personajes, streaming en tu interfaz y llamadas a herramientas con Python, Node.js y curl.
Cada ejemplo de esta página es un programa completo: configura dos variables de entorno, copia el código y ejecútalo. Todos llaman a POST https://api.spicyapi.ai/v1/chat/completions con las bibliotecas oficiales de OpenAI, de modo que el mismo código funciona con todos los modelos de texto del catálogo. Texto y streaming explica el protocolo; aquí verás cómo construir los usos habituales sobre él.
Antes de empezar
1. Guarda tu clave de API en una variable de entorno para no tener que pegarla en el código:
export SPICY_API_KEY="sk-spicy-paste-your-key-here"2. Elige un modelo de texto del catálogo actualizado y guarda su ID en SPICY_MODEL. Todos los ejemplos lo leen. Este comando lista los modelos de texto que tu clave puede llamar ahora; requiere 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 aboveLos ID de modelos de texto tienen el formato publisher/model/chat. MODEL_ID_FROM_CATALOG es solo un marcador: nunca lo envíes literalmente.
3. Comprueba qué admite el modelo. Hay diferencias: algunos aceptan temperature y otros no; reasoning_effort admite valores distintos según el modelo; solo algunos aceptan imágenes. Los campos de muestreo no admitidos, como temperature o frequency_penalty, se ignoran y se indican en la cabecera de respuesta X-Spicy-Ignored-Params. También se gestionan los valores predeterminados habituales de los clientes: si stop no se admite, se ignora; un n: 1 no admitido significa una sola respuesta; y, cuando es necesario, un nivel de razonamiento reconocido se adapta al nivel admitido más cercano. La cabecera X-Spicy-Adjusted-Params muestra los ajustes adaptados. Los campos funcionales no admitidos, como tools o response_format, siguen produciendo un 400. Este comando muestra los campos, los valores de reasoning_effort y los tipos de contenido de mensaje que acepta el modelo:
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]
}'Para una vista rápida de todos los modelos de texto, GET /v1/models?modality=text devuelve supported_parameters, context_length y max_output_tokens de cada uno. Solo enumera nombres de campos; usa el comando anterior para consultar valores permitidos.
4. Instala la biblioteca OpenAI de tu lenguaje: pip install openai para Python o npm install openai para Node.js 18 o posterior. Guarda los programas de Node.js con extensión .mjs, porque usan await en el nivel superior, y ejecútalos con node file.mjs.
Deja espacio para el razonamiento
Muchos modelos de texto razonan antes de responder y ese razonamiento cuenta para max_tokens. Los ejemplos omiten max_tokens para que cada modelo use su valor predeterminado. Si lo configuras, deja un margen amplio, de unos miles de tokens: un valor demasiado bajo puede consumirse solo en razonamiento y la solicitud falla con un mensaje que pide aumentar max_tokens o reducir reasoning_effort. Ese fallo no se cobra.
Conversación de varios turnos
La API no conserva memoria entre llamadas. Una conversación es una lista de mensajes que tú guardas y reenvías completa en cada turno: primero el mensaje system y después mensajes alternos user y assistant. Añade cada respuesta a la lista como mensaje assistant.
Cada mensaje reenviado vuelve a cobrarse como entrada; recorta los turnos más antiguos cuando la conversación crezca. Conserva siempre system y elimina turnos en pares de usuario/asistente para que el historial siga empezando con 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:]El modelo responde al último mensaje usando todo lo anterior: si después preguntas qué ingredientes mencionaste, lo sabe. Los modelos de razonamiento también devuelven reasoning_content junto a content; en una conversación simple no necesitas reenviarlo.
Rol con una ficha de personaje
Un personaje es un mensaje system escrito con cuidado. Como la API no conserva memoria, la ficha acompaña cada solicitud; eso también mantiene al personaje consistente en el turno cincuenta. Una ficha útil responde a seis preguntas:
| Parte | Qué escribir | Ejemplo |
|---|---|---|
| Identidad | Quién es el personaje, con dos o tres hechos que definan su comportamiento | Un detective retirado que ahora cuida un faro |
| Voz | Cómo habla: longitud de frases, vocabulario, hábitos y cómo se dirige al usuario | Habla con calma, comenta detalles pequeños y llama «amigo» al usuario |
| Mundo | El escenario y uno o dos hechos centrales de la historia | Un pueblo pesquero de los años veinte donde la campana de un barco hundido suena a medianoche |
| Límites | Qué hará y qué no, y cómo salir de la historia | Permanece en el escenario, responde brevemente a (OOC: ...) fuera del personaje y mantiene la historia apta para un público general |
| Narración | Persona y tiempo narrativos, y cómo se marca el diálogo | Tercera persona, pasado; diálogos entre comillas |
| Formato | Longitud y cómo termina cada respuesta | Máximo 120 palabras, con dos o tres opciones numeradas al final |
Dos hábitos mejoran el rol. Añade la frase inicial del personaje al historial como mensaje assistant, para que lo primero que vea el usuario ya esté en personaje. Termina cada respuesta con una pregunta u opción para que sepa cómo seguir; en ficción interactiva, las opciones numeradas permiten responder con un dígito.
Un navegante de dirigible fantástico. La respuesta llega en streaming palabra a palabra.
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})Las mismas seis partes funcionan para cualquier género. La computadora de una nave de ciencia ficción, un compañero que ayuda con la rutina diaria o el narrador de una aventura de texto solo necesitan otra ficha:
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.Para historias largas, mantén la ficha breve y estable. Cuando crezca el historial, sustituye los turnos más antiguos por una nota breve de «La historia hasta ahora» en otro mensaje system, en lugar de dejar crecer el prompt sin límite.
Streaming en tu propia interfaz
El streaming muestra la respuesta mientras se escribe y hace que una interfaz de chat se sienta rápida. Seis reglas cubren todos los modelos del catálogo:
- Solicita el uso si quieres contar tokens. Se siguen las reglas de OpenAI: sin
stream_options.include_usage, ningún fragmento tieneusage. Con"include_usage": true, llega un fragmento adicional justo antes de[DONE], con elusagefinal y una listachoicesvacía; los demás tienenusage: null. - Comprueba
choicesantes de leerchoices[0]. Salvo el último fragmento de uso, todos tienen al menos una opción. Solo importa si solicitas el uso, pero la comprobación no cuesta nada y mantiene el bucle seguro en ambos casos. - El razonamiento va separado. Los modelos lo transmiten en
delta.reasoning_contenty la respuesta endelta.content. Muestra el primero atenuado, contraído o no lo muestres; solo el segundo es la respuesta. finish_reason: "length"significa respuesta cortada. El modelo llegó amax_tokens. Auméntalo o reducereasoning_effort.- Los errores pueden llegar a mitad del stream. Un fallo tras empezar llega como evento con un objeto
errory las bibliotecas OpenAI generan una excepción. Esa solicitud fallida no se cobra. Si el modelo ya había terminado al perder la conexión, no recibes un error: el stream termina normalmente y se cobra como cualquier respuesta. Más información: Finalización, errores y desconexiones. - Detenerte antes no detiene la facturación. Cerrar la conexión no detiene el modelo; pagas lo que informe. Reduce
max_tokensen su lugar; consulta Qué pagas cuando el stream se detiene antes.
Un visualizador de terminal que sigue las seis reglas: razonamiento atenuado, respuesta en color completo y recuentos de tokens al final.
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]")Llamadas a herramientas
Las herramientas permiten al modelo pedir a tu programa que haga algo, como tirar dados, consultar un pedido o leer un calendario, y responder después con el resultado. El bucle siempre es igual:
- Envía los mensajes junto con
tools, una lista de funciones descritas mediante JSON Schema. - Si la respuesta tiene
tool_calls, añade el mensaje del asistente exactamente como llegó, ejecuta cada llamada y añade un mensajetoolpor llamada con eltool_call_idcorrespondiente. - Vuelve a enviar todo. Repite hasta que la respuesta no tenga
tool_calls: esa es la respuesta final.
Trata los argumentos como entrada no confiable: interprétalos, compruébalos y devuelve un error como resultado de herramienta en lugar de cerrar el programa por un fallo. Limita las rondas para que un modelo confundido no entre en un bucle infinito. Todos los modelos de texto del catálogo aceptan actualmente tools; consulta los valores de tool_choice antes de forzar una herramienta, porque algunos solo aceptan auto y 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 primera respuesta termina con finish_reason: "tool_calls" y content en null; la final termina con "stop". Los modelos de razonamiento pueden incluir reasoning_content en el turno del asistente: consérvalo al reenviar, como hacen los ejemplos al añadir el mensaje sin cambios. En streaming, la llamada llega por partes; únelas mediante index, como se explica en Conversaciones y llamadas a herramientas.
Salida JSON
Si tu programa necesita datos en lugar de prosa, pide JSON con response_format en modelos cuyo inputSchema lo incluya y describe los campos en el prompt. Interpreta siempre la respuesta con precaución: una salida cortada por max_tokens no es JSON válido.
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)En un modelo que no incluya response_format, omite el campo y depende solo del prompt de sistema; allí el bloque try es todavía más importante.
Cuando algo falla
Los errores llegan con el formato OpenAI {"error": {"message", "type", "param", "code"}}, por lo que las bibliotecas generan sus excepciones habituales. Basa las condiciones en el estado HTTP y error.code; muestra error.message a las personas porque explica qué cambiar. Si afecta a un campo, param lo identifica con una ruta separada por puntos, como temperature o messages.1.content.0.
| Estado | error.code | Qué ocurrió | Qué hacer |
|---|---|---|---|
| 400 | unsupported_parameter | El modelo no acepta el campo, por ejemplo logprobs: is not a supported field, o ese tipo de parte de mensaje, como image_url en un modelo solo de texto. Los campos de muestreo como temperature no se rechazan: se ignoran y se indican en X-Spicy-Ignored-Params | Elimínalo. Consulta los campos como en Antes de empezar |
| 400 | invalid_request | Falta un campo o tiene un valor no admitido, por ejemplo reasoning_effort: must be one of: "low", "high", "max"; o no es un modelo de chat y param es model | Cambia el valor indicado o elige un ID terminado en /chat |
| 400 | content_rejected | La política de contenido del modelo rechazó la solicitud | Reformúlala; reenviar la misma entrada vuelve a rechazarse |
| 401 | invalid_api_key | Falta la clave, tiene un error de escritura o fue revocada | Comprueba SPICY_API_KEY; las claves empiezan por sk-spicy- |
| 402 | insufficient_balance, spend_cap_exceeded | Tu saldo o un límite de gasto no cubre la retención de esta solicitud | Añade fondos, aumenta el límite o reduce max_tokens; véase abajo |
| 404 | model_not_found | Ningún modelo tiene este ID; param es model. El mensaje muestra el formato de ID y, si existe, el modelo de chat disponible más parecido | Copia el ID exacto del catálogo actualizado |
| 404 | not_found | El modelo existe, pero no se puede llamar ahora | Elige otro del catálogo actualizado |
| 409 | conflict | Se reutilizó Idempotency-Key con otro cuerpo | Envía cada mensaje nuevo con una clave nueva; consulta Claves de idempotencia en endpoints de texto |
| 429 | rate_limit_exceeded | Demasiadas solicitudes | Espera y reintenta; si aparece Retry-After, indica los segundos |
| 503 | upstream_failed, model_unavailable | Falló el modelo o no está disponible temporalmente. No se cobra nada | Reintenta más tarde con un Idempotency-Key nuevo |
Por qué puede fallar con 402 aunque tu saldo parezca suficiente: antes de ejecutar una solicitud de texto, se retiene un importe que cubre tus mensajes más todo el max_tokens de salida a los precios del modelo; si lo omites, se usa el max_tokens predeterminado. Solo pagas los tokens utilizados y el resto se libera al terminar, pero toda la retención debe caber en tu saldo disponible antes de empezar. Un max_tokens realista reduce la retención. Consulta Facturación.
Consultar cuánto costó una llamada
Cada solicitud aceptada se ejecuta como tarea y la respuesta la identifica mediante X-Spicy-Task-Id, con o sin streaming. Lee el encabezado y consulta la tarea: cost es definitivo cuando settled es true. Una solicitud rechazada antes de aceptar, por ejemplo por validación, no crea una tarea ni tiene ese encabezado.
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, el encabezado llega antes del primer fragmento; with_raw_response en Python y .withResponse() en Node.js funcionan igual con stream: true. Las entradas de GET /api/v1/jobs también incluyen el requestId de la llamada que las creó, correspondiente al encabezado X-Request-Id.
Sigue leyendo
Texto y streaming · Migrar desde OpenAI u OpenRouter · Catálogo de modelos y esquemas · Facturación · Errores
Texto y streaming
Llama a modelos de texto con el formato oficial de OpenAI, Anthropic o Google Gemini, y gestiona correctamente las conversaciones, las herramientas, los streams y los costos.
Migrar desde OpenAI u OpenRouter
Conecta tu código de OpenAI a SpicyAPI. Cambios de URL base, clave e ID de modelo, campos con otro comportamiento y ajustes que debes quitar de OpenRouter.

