spicyapiDocumentación
Texto y chat
Contenido principal

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 above

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

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:]

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:

ParteQué escribirEjemplo
IdentidadQuién es el personaje, con dos o tres hechos que definan su comportamientoUn detective retirado que ahora cuida un faro
VozCómo habla: longitud de frases, vocabulario, hábitos y cómo se dirige al usuarioHabla con calma, comenta detalles pequeños y llama «amigo» al usuario
MundoEl escenario y uno o dos hechos centrales de la historiaUn pueblo pesquero de los años veinte donde la campana de un barco hundido suena a medianoche
LímitesQué hará y qué no, y cómo salir de la historiaPermanece en el escenario, responde brevemente a (OOC: ...) fuera del personaje y mantiene la historia apta para un público general
NarraciónPersona y tiempo narrativos, y cómo se marca el diálogoTercera persona, pasado; diálogos entre comillas
FormatoLongitud y cómo termina cada respuestaMá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.

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})

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:

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.

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:

  1. Solicita el uso si quieres contar tokens. Se siguen las reglas de OpenAI: sin stream_options.include_usage, ningún fragmento tiene usage. Con "include_usage": true, llega un fragmento adicional justo antes de [DONE], con el usage final y una lista choices vacía; los demás tienen usage: null.
  2. Comprueba choices antes de leer choices[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.
  3. El razonamiento va separado. Los modelos lo transmiten en delta.reasoning_content y la respuesta en delta.content. Muestra el primero atenuado, contraído o no lo muestres; solo el segundo es la respuesta.
  4. finish_reason: "length" significa respuesta cortada. El modelo llegó a max_tokens. Auméntalo o reduce reasoning_effort.
  5. Los errores pueden llegar a mitad del stream. Un fallo tras empezar llega como evento con un objeto error y 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.
  6. Detenerte antes no detiene la facturación. Cerrar la conexión no detiene el modelo; pagas lo que informe. Reduce max_tokens en 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.

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]")

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:

  1. Envía los mensajes junto con tools, una lista de funciones descritas mediante JSON Schema.
  2. Si la respuesta tiene tool_calls, añade el mensaje del asistente exactamente como llegó, ejecuta cada llamada y añade un mensaje tool por llamada con el tool_call_id correspondiente.
  3. 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.

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

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)

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.

Estadoerror.codeQué ocurrióQué hacer
400unsupported_parameterEl 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-ParamsElimínalo. Consulta los campos como en Antes de empezar
400invalid_requestFalta 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 modelCambia el valor indicado o elige un ID terminado en /chat
400content_rejectedLa política de contenido del modelo rechazó la solicitudReformúlala; reenviar la misma entrada vuelve a rechazarse
401invalid_api_keyFalta la clave, tiene un error de escritura o fue revocadaComprueba SPICY_API_KEY; las claves empiezan por sk-spicy-
402insufficient_balance, spend_cap_exceededTu saldo o un límite de gasto no cubre la retención de esta solicitudAñade fondos, aumenta el límite o reduce max_tokens; véase abajo
404model_not_foundNingú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 parecidoCopia el ID exacto del catálogo actualizado
404not_foundEl modelo existe, pero no se puede llamar ahoraElige otro del catálogo actualizado
409conflictSe reutilizó Idempotency-Key con otro cuerpoEnvía cada mensaje nuevo con una clave nueva; consulta Claves de idempotencia en endpoints de texto
429rate_limit_exceededDemasiadas solicitudesEspera y reintenta; si aparece Retry-After, indica los segundos
503upstream_failed, model_unavailableFalló el modelo o no está disponible temporalmente. No se cobra nadaReintenta 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.

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, 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

En esta página