spicyapiDocumentación
Texto y chat
Contenido principal

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.

Los modelos de texto responden dentro de la misma solicitud HTTP: no hay ninguna tarea que sondear. Puedes esperar a tener la respuesta completa o recibirla en streaming, es decir, en pequeños fragmentos mientras el modelo todavía está escribiendo, como cuando una aplicación de chat va mostrando la respuesta letra a letra. Los fragmentos llegan como Server-Sent Events (SSE) (eventos enviados por el servidor): líneas de texto plano que empiezan por data: y que se envían una tras otra por una conexión que permanece abierta hasta que la respuesta está completa.

Elige un modelo de texto disponible

Los ejemplos de terminal de esta página están pensados para una terminal de macOS o Linux y usan jq, una pequeña herramienta de línea de comandos para JSON (brew install jq en macOS, sudo apt install jq en Debian o Ubuntu). En Windows, ejecútalos en WSL o usa en su lugar los ejemplos con los SDK de Python.

Primero guarda tu clave de API en una variable de entorno, un valor con nombre que tu terminal entrega a los comandos que ejecuta, para que nunca tengas que pegar la clave dentro de un comando:

export SPICY_API_KEY="sk-spicy-paste-your-key-here"

Muestra los modelos de texto que esta clave puede llamar en este momento:

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'

Deberías ver un ID de modelo por línea. Copia uno y guárdalo en SPICY_MODEL; todos los ejemplos siguientes lo leen de ahí:

export SPICY_MODEL="MODEL_ID_FROM_CATALOG"   # sustitúyelo por una línea de la lista anterior

MODEL_ID_FROM_CATALOG es solo un marcador: no lo envíes nunca tal cual. Antes de contar con herramientas, imágenes, ajustes de razonamiento o salida estructurada, lee el inputSchema de ese modelo en GET /api/v1/models/{model}: esas funciones solo están disponibles si el esquema las incluye. Que un protocolo sea compatible no le da a cada modelo todas las capacidades.

El mismo ID de modelo funciona con cualquiera de los protocolos siguientes, sin importar quién haya creado el modelo: puedes llamar al modelo de otro fabricante con el formato de Anthropic, o a un modelo que no es de Google con el formato de Gemini.

Protocolos admitidos

Todos estos endpoints comparten la misma clave de API y el mismo catálogo de modelos, y también los mismos límites de solicitudes, la misma validación de parámetros y la misma facturación; solo cambia el formato de las solicitudes y de las respuestas. Usa el que ya hable tu código o tu SDK.

ProtocoloEndpointCabecera de la clave de API
OpenAI Chat CompletionsPOST /v1/chat/completionsAuthorization: Bearer
OpenAI ResponsesPOST /v1/responsesAuthorization: Bearer
Anthropic MessagesPOST /v1/messagesx-api-key o Authorization: Bearer
Google GeminiPOST /v1beta/models/{model}:generateContentx-goog-api-key o Authorization: Bearer
Google Gemini (streaming)POST /v1beta/models/{model}:streamGenerateContentx-goog-api-key o Authorization: Bearer
Stream nativo con precio confirmadoPOST /api/v1/jobs/streamAuthorization: Bearer

En /v1 y /v1beta, las respuestas y los errores siguen su propio protocolo y no llevan la envoltura nativa {code,msg,data,request_id}, así que no los interpretes con un cliente que solo lee body.data. El formato Gemini pone el ID del modelo en la ruta de la URL; las barras del ID se dejan tal cual y no hace falta codificarlas.

Envía la clave solo en una cabecera de la solicitud. x-api-key y x-goog-api-key solo funcionan en /v1 y /v1beta; la API nativa /api/v1 solo acepta Authorization: Bearer. Un parámetro de consulta ?key= no se acepta nunca: en cuanto una clave aparece en una URL, se queda en el historial del navegador, en los servidores proxy y en los registros de acceso. Todos los SDK oficiales envían la clave en una cabecera, así que no les afecta.

Usa un SDK oficial

Cambia solo la URL base y la clave; todo lo demás funciona como funciona siempre cada SDK.

SDKURL base
OpenAI (openai)https://api.spicyapi.ai/v1
Anthropic (anthropic)https://api.spicyapi.ai
Google GenAI (google-genai)https://api.spicyapi.ai

Los SDK de Anthropic y de Google añaden ellos mismos /v1 o /v1beta, así que su URL base no lleva la parte de la versión; la URL base del SDK de OpenAI, en cambio, tiene que incluir /v1. Los tres ejemplos de Python siguientes leen SPICY_API_KEY del entorno, junto con un SPICY_MODEL elegido del catálogo en vivo.

OpenAI
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": "user", "content": "Explain a rainbow in one sentence."}],
)
print(reply.choices[0].message.content)
Anthropic
import os
import anthropic

client = anthropic.Anthropic(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai")
message = client.messages.create(
    model=os.environ["SPICY_MODEL"],
    max_tokens=256,
    messages=[{"role": "user", "content": "Explain a rainbow in one sentence."}],
)
print(message.content[0].text)
Google GenAI
import os
from google import genai
from google.genai import types

client = genai.Client(
    api_key=os.environ["SPICY_API_KEY"],
    http_options=types.HttpOptions(base_url="https://api.spicyapi.ai"),
)
response = client.models.generate_content(
    model=os.environ["SPICY_MODEL"],
    contents="Explain a rainbow in one sentence.",
)
print(response.text)

La capa de compatibilidad cobra el precio del momento de aceptación y no admite cotizaciones

/v1 y /v1beta cobran el precio vigente en el momento en que se acepta la solicitud. No aceptan quoteId ni expectedCost, así que por esta vía una sola llamada no tiene techo: un bucle de agente que se descontrola sigue gastando.

Hay dos formas de acotarlo, y la mayoría de las integraciones necesitan las dos:

  • Llamada a llamada: pide una cotización con el endpoint nativo POST /api/v1/jobs/quote y después envía POST /api/v1/jobs/stream con el quoteId y el expectedCost devueltos. Si el precio cambió entre medias, la aceptación falla con 40901 en lugar de cobrarte más de lo que aceptaste. Consulta Stream nativo con precio confirmado.
  • Todo lo que gasta esta clave: fija los límites de gasto diario, mensual y acumulado de la clave. Se aplican en todas las vías, también en esta, y superarlos devuelve 40202 en lugar de una factura mayor. Consulta Autenticación.

Claves de idempotencia en los endpoints de texto

Una cabecera Idempotency-Key evita que un reintento de red cobre dos veces. En los endpoints de texto no te permite recuperar una respuesta perdida, porque la respuesta se te envía en streaming y no se almacena:

  • Volver a enviar la clave de una solicitud ya completada con el mismo cuerpo devuelve 400 (This Idempotency-Key was already used for a completed request, and its answer cannot be replayed. Use a new Idempotency-Key to send a new request) y no cobra nada más. El registro de la tarea tampoco contiene el texto.
  • Si aquella solicitud falló, reenviar la misma clave con el mismo cuerpo devuelve el mismo error que entonces y no cobra nada.
  • Volver a enviarla con un cuerpo distinto devuelve 409.

Por tanto: envía una clave nueva con cada mensaje nuevo (por ejemplo $(uuidgen) en una terminal o uuid.uuid4() en Python), y reutiliza una clave solo cuando reenvíes exactamente la misma solicitud tras un error de red, sabiendo que la respuesta a esa solicitud no se puede recuperar. Si quieres volver a obtener una respuesta, envía la solicitud con una clave nueva y págala.

En los SDK, pasa la clave en cada llamada, nunca en el objeto cliente: con una sola clave para todo el cliente, todos los mensajes posteriores al primero se rechazarían con 409 durante 24 horas.

Idempotency-Key por llamada
import os, uuid

# SDK de OpenAI (client del ejemplo de OpenAI de más arriba). El SDK de Anthropic
# acepta el mismo argumento extra_headers en client.messages.create().
reply = client.chat.completions.create(
    model=os.environ["SPICY_MODEL"],
    messages=[{"role": "user", "content": "Explain a rainbow in one sentence."}],
    extra_headers={"Idempotency-Key": str(uuid.uuid4())},
)

# SDK de Google GenAI (client y types del ejemplo de Google GenAI de más arriba):
# http_options dentro de la config de cada llamada
response = client.models.generate_content(
    model=os.environ["SPICY_MODEL"],
    contents="Explain a rainbow in one sentence.",
    config=types.GenerateContentConfig(
        http_options=types.HttpOptions(headers={"Idempotency-Key": str(uuid.uuid4())}),
    ),
)

Los SDK de OpenAI y de Anthropic reintentan los timeouts por su cuenta con las mismas cabeceras. Si uno de esos reintentos llega a una solicitud que ya habíamos aceptado, el SDK lanza un error 400 de solicitud incorrecta con el mensaje anterior: el primer intento se procesó y se cobró, y su respuesta se ha perdido.

Usa modelos de SpicyAPI desde el cliente de línea de comandos de Grok

Esta configuración hace que el cliente de línea de comandos de xAI envíe sus solicitudes a SpicyAPI: a partir de ahí habla con los modelos del catálogo de SpicyAPI y no con el endpoint propio de xAI. Entre ellos está la familia de modelos Grok publicada en el catálogo: la llamas con su ID de modelo en SpicyAPI, igual que cualquier otro modelo. El cliente puede apuntar un modelo personalizado a cualquier endpoint compatible con OpenAI. Añade la entrada siguiente a ~/.grok/config.toml; env_key indica la variable de entorno que lee el cliente, de modo que la clave en sí nunca queda escrita en el archivo.

~/.grok/config.toml
[model.spicyapi]
model = "MODEL_ID_FROM_CATALOG"
base_url = "https://api.spicyapi.ai/v1"
name = "SpicyAPI"
env_key = "SPICY_API_KEY"

[models]
default = "spicyapi"

Sustituye MODEL_ID_FROM_CATALOG por el ID exacto de un modelo de texto del catálogo en vivo, con la forma <publisher>/<model>/<task>; no dejes nunca el marcador. Después arranca con grok -m spicyapi, o cambia dentro de una sesión con /model spicyapi. El cliente habla Chat Completions, así que la correspondencia de parámetros, los finales de stream y los formatos de error que se describen más abajo se aplican sin cambios.

Correspondencia de parámetros entre protocolos

Los campos de cada protocolo se convierten primero a los nombres de parámetro canónicos de la plataforma y después se validan contra el inputSchema del modelo elegido. Un parámetro que el modelo no admite devuelve 400; nunca se descarta en silencio mientras la solicitud se cobra como si nada. La tabla solo muestra la correspondencia, y «—» significa que el protocolo no tiene un campo equivalente. Si un modelo concreto acepta un parámetro, y qué valores permite, lo define el inputSchema de ese modelo.

Parámetro canónicoChat CompletionsResponsesMessagesGemini
messagesmessagesinstructions, inputsystem, messagessystemInstruction, contents
max_tokensmax_tokens, max_completion_tokensmax_output_tokensmax_tokensgenerationConfig.maxOutputTokens
temperaturetemperaturetemperaturetemperaturegenerationConfig.temperature
top_ptop_ptop_ptop_pgenerationConfig.topP
toolstoolstools (tipo function)tools (herramientas personalizadas)tools[].functionDeclarations
tool_choicetool_choicetool_choicetool_choicetoolConfig.functionCallingConfig
response_formatresponse_formattext.format—generationConfig.responseMimeType más responseSchema o responseJsonSchema
reasoning_effortreasoning_effortreasoning.effort—generationConfig.thinkingConfig

Los campos que no aparecen en la tabla también se convierten a parámetros canónicos y los valida el modelo; todo lo que no esté en su inputSchema devuelve 400. Por ejemplo, solo algunos modelos incluyen seed en su inputSchema; en los demás, seed en Chat y generationConfig.seed en Gemini devuelven 400. Los ajustes de muestreo y las secuencias de parada son la excepción: en un modelo que no los admite se ignoran (se nombran en el encabezado de respuesta X-Spicy-Ignored-Params), y un valor fuera de lo que admite el modelo se cambia por el valor aceptado más cercano (se nombra en X-Spicy-Adjusted-Params). Eso abarca temperature, top_p y las penalizaciones, stop en Chat, stop_sequences en Messages, generationConfig.stopSequences en Gemini y los niveles de reasoning_effort. Lee el esquema del modelo que elegiste en lugar de suponer un campo de otro modelo.

Algunas excepciones:

  • Los campos que solo tienen sentido en la plataforma propia de cada protocolo se ignoran y no afectan a la generación: user, metadata y store en Chat; user, metadata, store e include en Responses; metadata en Messages.
  • Responses no admite previous_response_id; envía el historial completo en input.
  • En Gemini, safetySettings se acepta pero no tiene ningún efecto. cachedContent y las herramientas del lado del servidor, como googleSearch y codeExecution, no se admiten y devuelven 400. Pasa las imágenes como inlineData (base64) o fileData.fileUri (una URL https); si un modelo acepta imágenes o no lo define su inputSchema.

Envía un stream de Chat

Con SPICY_API_KEY y SPICY_MODEL definidas como antes, este ejemplo escribe la solicitud en un archivo y recibe la respuesta en streaming. Cambia el prompt de ejemplo si quieres, y valida los parámetros del modelo contra el esquema actual antes de enviar. Cada ejecución envía un Idempotency-Key nuevo, porque cada ejecución es un mensaje nuevo.

jq -n --arg model "$SPICY_MODEL" '{
  model: $model,
  messages: [{role: "user", content: "Explain a rainbow in one sentence."}],
  stream: true
}' > chat-request.json

curl --fail-with-body --no-buffer --max-time 120 \
  https://api.spicyapi.ai/v1/chat/completions \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @chat-request.json

--no-buffer muestra cada fragmento en cuanto llega, y --max-time 120 abandona al cabo de dos minutos. Deberías ver llegar, una a una, líneas como las siguientes y, al final, data: [DONE]. La respuesta está repartida entre los fragmentos delta.content; únelos en orden para obtener el texto completo. El último fragmento antes de [DONE] lleva usage.

data: {"id":"chatcmpl-req_01k...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"A rainbow"}}]}

data: {"id":"chatcmpl-req_01k...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" forms when"}}]}

data: [DONE]

uuidgen viene incluido en macOS; en Linux se instala con el paquete uuid-runtime.

Envía una solicitud de Gemini

Este ejemplo usa las mismas SPICY_API_KEY y SPICY_MODEL, y de nuevo un Idempotency-Key nuevo: es una solicitud distinta de la del ejemplo de Chat, así que reutilizar aquella clave devolvería 409. El ID del modelo va en la URL, así que el cuerpo de la solicitud no lleva ningún campo de modelo. Pon la URL entre comillas dobles y escribe la variable como ${SPICY_MODEL}, para que la shell no interprete los dos puntos que la siguen como un modificador de variable.

jq -n '{
  systemInstruction: {parts: [{text: "Answer in one sentence."}]},
  contents: [{role: "user", parts: [{text: "Explain a rainbow."}]}],
  generationConfig: {temperature: 0.7, maxOutputTokens: 256}
}' > gemini-request.json

curl --fail-with-body --no-buffer --max-time 120 \
  "https://api.spicyapi.ai/v1beta/models/${SPICY_MODEL}:streamGenerateContent?alt=sse" \
  -H "x-goog-api-key: $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @gemini-request.json

Si no necesitas streaming, sustituye :streamGenerateContent?alt=sse por :generateContent y lee la respuesta en candidates[0].content.parts[].text; el consumo está en usageMetadata. Sin alt=sse, una solicitud de streaming devuelve un único array JSON que se va escribiendo poco a poco; los SDK oficiales añaden alt=sse por ti.

Stream nativo con precio confirmado

POST /api/v1/jobs/stream es el único endpoint de texto que acepta una cotización, así que puede negarse a ejecutarse si el precio ha cambiado. El cuerpo de la solicitud tiene la misma forma que en createTask: model más un input cuyos campos salen del inputSchema del modelo (en los modelos de texto, messages y los demás parámetros canónicos de la tabla anterior).

# 1. Escribe la solicitud una sola vez.
jq -n --arg model "$SPICY_MODEL" '{
  model: $model,
  input: {messages: [{role: "user", content: "Explain a rainbow in one sentence."}]}
}' > native-request.json

# 2. Pide la cotización. No crea nada ni cobra nada.
QUOTE=$(curl -s https://api.spicyapi.ai/api/v1/jobs/quote \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @native-request.json)
echo "$QUOTE" | jq '{code, msg, estimatedCost: .data.estimatedCost, maxCharge: .data.maxCharge, expiresAt: .data.expiresAt}'

# 3. Antes de que pasen cinco minutos, añade quoteId y expectedCost a la solicitud sin cambiarla y envíala en streaming.
jq --argjson quote "$QUOTE" \
  '. + {quoteId: $quote.data.quoteId, expectedCost: $quote.data.estimatedCost}' \
  native-request.json > native-confirmed.json

curl --fail-with-body --no-buffer --max-time 120 \
  https://api.spicyapi.ai/api/v1/jobs/stream \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @native-confirmed.json

El paso 2 muestra code: 200 y el precio en dólares estadounidenses; cualquier otro code significa que la cotización falló, y msg explica por qué. Si la llamada de streaming falla antes de enviar ningún texto, por ejemplo con 40901 porque cambió el precio o la solicitud, o con 40201 por saldo insuficiente, recibes el JSON normal {code,msg,data,request_id} y no se retiene nada. Una vez iniciado el streaming, un fallo termina el stream con event: error y data: {"code":"stream_interrupted"}, y no se cobra nada por él. Los modelos de imagen, video y audio devuelven 503 aquí; para ellos usa createTask.

La respuesta llega como líneas SSE data:. A diferencia de /v1/chat/completions, este endpoint no garantiza un mismo formato de evento para todos los modelos, así que prueba tu parser con el modelo que hayas elegido; si necesitas un único formato estable, usa un endpoint /v1 y acota el gasto con los límites de la clave.

Envía imágenes, video y audio a un modelo de texto

Algunos modelos de texto leen imágenes además de texto, y unos pocos también leen video y audio. Envías el contenido multimedia junto con una pregunta y la respuesta sigue siendo texto. Lo que acepta un modelo figura en su esquema; el protocolo no lo da por hecho. Comprueba el modelo que elegiste:

curl -s "https://api.spicyapi.ai/api/v1/models/$SPICY_MODEL" \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  | jq -c '[.data.inputSchema.properties.messages.items.properties.content.items.oneOf[]?.properties.type.const]'

La lista nombra las partes de contenido que acepta el modelo. ["text","image_url"] significa texto e imágenes; un modelo que también lee video y audio incluye además video_url, audio_url e input_audio. Una lista vacía significa solo texto. Si envías una parte que el modelo no incluye en la lista, devuelve 400 y no cobra nada.

Para enviar contenido multimedia, haz que el content del mensaje sea una lista de partes en lugar de una cadena: una parte text para la pregunta y una parte por cada archivo:

jq -n --arg model "$SPICY_MODEL" '{
  model: $model,
  stream: true,
  messages: [{
    role: "user",
    content: [
      {type: "text", text: "What is in this picture?"},
      {type: "image_url", image_url: {url: "https://example.com/photo.jpg"}}
    ]
  }]
}' > image-request.json

curl --fail-with-body --no-buffer --max-time 120 \
  https://api.spicyapi.ai/v1/chat/completions \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @image-request.json

Cada tipo de contenido tiene su propia parte:

ParteFormaDe dónde sale el archivo
Imagen{"type": "image_url", "image_url": {"url": "…"}}Una URL HTTPS pública, un archivo subido (spicy://f/…) o un URI data: pequeño
Video{"type": "video_url", "video_url": {"url": "…"}}Un archivo subido (spicy://f/…) o una URL HTTPS pública
Archivo de audio{"type": "audio_url", "audio_url": {"url": "spicy://f/…"}}Solo un archivo subido
Audio en línea{"type": "input_audio", "input_audio": {"data": "<base64>", "format": "wav"}}Base64 en la solicitud, wav o mp3

Cómo subir un archivo y obtener su URI spicy://f/… se explica en Medios; las subidas se conservan un día. De la forma en que cada tipo llega al modelo se desprenden algunas reglas:

  • Las imágenes pueden ser una URL HTTPS pública que devuelva el archivo de imagen en sí (no una página que lo muestre), un archivo subido o un URI data: dentro de los límites en línea. JPEG y PNG funcionan en todos los modelos que aceptan imágenes.
  • El video y el audio funcionan mejor como subidas. Leemos el archivo subido y se lo entregamos al modelo dentro de la solicitud, por lo que el video y el audio subidos en una misma solicitud pueden sumar como máximo 14 MB: video MP4 o WebM, audio MP3 o WAV. Un total mayor devuelve 400 y no cobra nada. Una URL pública de video se pasa como enlace y solo funciona si el proveedor del modelo puede descargarla.
  • El audio en línea (input_audio) sigue el formato de OpenAI y tiene que caber en el cuerpo de solicitud de 2 MiB, así que sirve para clips cortos; sube cualquier audio más largo y envíalo como audio_url.

Puedes enviar varios archivos en un mismo mensaje, cada uno como su propia parte, y los mensajes anteriores del historial también pueden llevar contenido multimedia.

Los demás protocolos transportan las imágenes con su propia forma, y todos llegan al modelo de manera idéntica. Las partes de video y audio se envían mediante Chat Completions y el flujo nativo.

ProtocoloParte de imagen
Chat Completions{"type": "image_url", "image_url": {"url": "…"}}
Responses{"type": "input_image", "image_url": "…"}
Anthropic Messages{"type": "image", "source": {"type": "url", "url": "…"}}, o una fuente base64
GeminiinlineData (base64) o fileData.fileUri (una URL HTTPS)

Cuánto cuesta. El contenido multimedia se cuenta como tokens de entrada a la tarifa de entrada normal del modelo; no hay un precio aparte por archivo. Una imagen suele sumar de unos cientos a unos miles de tokens según el modelo y el tamaño de la imagen; el video y el audio crecen con su duración. El bloque usage de la respuesta indica la cifra exacta.

Qué se retiene por adelantado. Antes de ejecutar una solicitud retenemos un límite superior y liberamos la diferencia al liquidarla. En una solicitud cuyo único contenido multimedia son imágenes que subiste o enviaste en línea, el límite suma un margen fijo por imagen. En una que lleva video o audio, o una imagen indicada como URL pública, el límite se dimensiona según todo el límite de entrada del modelo, porque su tamaño no se puede conocer de antemano; por eso el monto retenido puede ser mucho mayor que el cobro final. Sube las imágenes para que la retención sea pequeña, mantén saldo suficiente para ella, o cotiza la solicitud antes con POST /api/v1/jobs/quote para verla.

En el Playground. En un modelo que acepta contenido multimedia, el cuadro de mensaje muestra un botón de adjuntar. También puedes pegar una captura de pantalla o soltar archivos sobre el cuadro; cada mensaje admite hasta cuatro adjuntos, y el video y el audio entre ellos pueden sumar 14 MB. Los modelos que solo leen texto no muestran el botón.

Conversaciones y llamadas a herramientas

Chat Completions

  • Sin streaming: lee choices[0].message.
  • Con streaming: procesa choices[].delta evento SSE a evento SSE, no paquete de red a paquete de red. Monta los tool_calls por index, y valida y ejecuta una llamada solo cuando sus arguments estén completos.
  • Conserva el mensaje del asistente en el historial y devuelve cada resultado en un mensaje tool con el tool_call_id correspondiente.

Responses

  • Envía el historial completo en input; previous_response_id se rechaza.
  • Devuelve los resultados de las funciones como elementos function_call_output con el call_id correspondiente.

Messages

  • Usa bloques de contenido text, tool_use y tool_result en lugar de la estructura de mensajes de Chat.

Gemini

  • contents alterna entre dos roles, user y model.
  • Cuando el modelo quiere llamar a una herramienta, devuelve una parte functionCall. Después de ejecutar la herramienta, envuelve el resultado en una parte functionResponse y envíalo en el siguiente contenido user, con el mismo name que la llamada.

Finalización, errores y desconexiones

ProtocoloFinalización normalConsumo
Chat Completionsdata: [DONE]usage en el último fragmento
ResponsesEvento response.completedresponse.usage en ese evento
Messagesmessage_stop después de message_deltausage en message_delta
GeminiÚltimo evento con finishReason; no hay [DONE]usageMetadata

Comprueba el estado HTTP y el Content-Type antes de leer un stream; los errores llegan en el JSON propio de cada protocolo:

ProtocoloCuerpo del error
Chat Completions, Responses{"error": {"message", "type", "param", "code"}}
Messages{"type": "error", "error": {"type", "message"}}
Gemini{"error": {"code", "message", "status"}}

Los códigos de estado HTTP son los mismos en los cuatro protocolos. El campo status de Gemini usa los nombres de estado de Google: 400 es INVALID_ARGUMENT, 401 es UNAUTHENTICATED, 402 es FAILED_PRECONDITION, 403 es PERMISSION_DENIED, 404 es NOT_FOUND, 409 es ABORTED, 429 es RESOURCE_EXHAUSTED, 500 es INTERNAL, 503 es UNAVAILABLE y 504 es DEADLINE_EXCEEDED. Haz que tu código decida según el código de estado y el tipo de error, nunca según el texto del mensaje.

Si algo falla después de empezar el streaming, Chat y Gemini envían un evento con un objeto de error, Responses envía un evento de error y Messages envía event: error. Tu cliente tiene que gestionar estos eventos de error, además de los timeouts y de una conexión que se cierra sin la marca de finalización normal. Si el stream pasa por tu propio proxy, desactiva allí el buffering de respuestas; si no, los fragmentos llegan a golpes.

Qué pagas cuando un stream se corta antes de tiempo

  • Te desconectas tú (cierras la pestaña, abortas la solicitud o salta un timeout del cliente): desconectarte no detiene el cobro. Se te cobra el consumo que notifica el modelo, que en muchos modelos cubre la respuesta completa, incluido el texto que nunca te llegó, y nunca más del importe retenido cuando se aceptó la solicitud. El texto que no recibiste no se puede obtener después. Para gastar menos, baja max_tokens en lugar de desconectarte.
  • El modelo o nuestro servicio fallan a mitad del stream (recibes un evento de error, o el stream termina sin su marca de finalización): la solicitud se marca como fallida y no se cobra nada.
  • El modelo ya te envió texto o razonamiento y después su respuesta se considera vacía o rechazada: lo que ya recibiste se cobra según el consumo que notifica el modelo, nunca más del importe retenido, y el stream termina con normalidad. Solo si no te llegó nada se reembolsa por completo.
  • La solicitud supera su plazo en el servidor: termina como fallo y no se cobra nada.

Tiempos de espera y streaming

Sin streaming, la respuesta llega de una sola vez cuando está lista, y las respuestas largas y los razonamientos largos llevan tiempo: modelos como Grok, GLM, DeepSeek Pro y Claude Opus pueden pensar varios minutos antes de escribir nada, sobre todo con un reasoning_effort alto. El borde de red que hay delante de la API cierra una conexión por la que no ha llegado nada durante unos 100 segundos, así que las solicitudes de ambos tipos se mantienen abiertas mientras el modelo trabaja.

  • Una solicitud sin streaming que sigue en curso pasados unos 30 segundos empieza su respuesta antes. Recibes el estado 200, Content-Type: application/json y el encabezado X-Spicy-Keepalive: 1, luego un salto de línea cada 15 segundos y, al final, el cuerpo JSON. JSON admite espacios en blanco antes de un valor, así que los parsers estándar (json.loads, JSON.parse, response.json()) y los SDK oficiales leen el cuerpo sin cambios. Una solicitud que termina antes recibe una respuesta normal, con su código de estado real y sin ese encabezado.
  • A partir de ese momento, un error va en el cuerpo, no en el estado. El 200 ya se envió, así que el cuerpo contiene el objeto de error habitual del protocolo (el de la tabla de arriba), con el mismo código y el mismo mensaje; en el formato de Gemini, error.code conserva el estado HTTP que habría tenido el error. Cuando aparece X-Spicy-Keepalive, comprueba si el cuerpo trae un objeto error antes de leer la respuesta: los SDK oficiales no tratan un 200 como error. Una solicitud fallida no se cobra.
  • El streaming también mantiene la conexión abierta. Mientras el modelo sigue trabajando, una respuesta en streaming lleva líneas de keep-alive (comentarios : keep-alive en SSE, líneas en blanco en el formato de Gemini), así que la conexión nunca queda inactiva, por mucho que piense el modelo. Tu parser debe ignorarlas, como exige el formato SSE; los SDK oficiales ya lo hacen.
  • Prefiere stream: true para todo lo que pueda tardar. Ves la respuesta a medida que se escribe, y un error llega como un evento de error que los SDK entienden. Chat Completions, Responses y Messages aceptan stream: true; en el formato de Gemini, llama a :streamGenerateContent?alt=sse en lugar de :generateContent. Un cuerpo distinto es una solicitud nueva, así que envíala con un Idempotency-Key nuevo: reutilizar el anterior devuelve 409.
  • Una solicitud cortada por el borde de red no se cobra. Si no te llega nada durante unos 100 segundos y la conexión se cierra, la solicitud aparece en Consola → Registros y en GET /api/v1/jobs como fallida, con errorCode timeout y un mensaje que explica qué pasó. Si la reenvías con el mismo Idempotency-Key, recibes ese mensaje como error.
  • Tus propios límites de tiempo siguen aplicando. Un tiempo de espera total en tu cliente HTTP, o en un proxy que administres, también termina una solicitud, y una vez que te llegó algo (incluida una línea de keep-alive), cuenta como una desconexión de tu parte (consulta Qué pagas cuando un stream se corta antes de tiempo). Los SDK oficiales de OpenAI esperan hasta 10 minutos de forma predeterminada; los ejemplos de curl de esta página se rinden a los 2 minutos, así que sube --max-time para razonamientos largos.

Así se activa el streaming en los SDK de OpenAI y en curl:

OpenAI Python
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": "Explain a rainbow in one sentence."}],
    stream=True,
)
for chunk in stream:
    if chunk.choices and chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="", flush=True)
print()
OpenAI Node.js
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.SPICY_API_KEY, baseURL: "https://api.spicyapi.ai/v1" });
const stream = await client.chat.completions.create({
  model: process.env.SPICY_MODEL,
  messages: [{ role: "user", content: "Explain a rainbow in one sentence." }],
  stream: true,
});
for await (const chunk of stream) {
  process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
process.stdout.write("\n");
curl
curl --fail-with-body --no-buffer --max-time 900 \
  https://api.spicyapi.ai/v1/chat/completions \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @chat-request.json

El ejemplo de Node.js usa await en el nivel superior, así que ejecútalo como módulo ES (por ejemplo, guárdalo como stream.mjs). El comando de curl envía chat-request.json de Envía un stream de Chat, que ya incluye stream: true; con --max-time 900 la respuesta puede tardar hasta 15 minutos.

Cotizaciones y facturación

Si necesitas una confirmación explícita del costo, usa el stream nativo con una cotización. /v1 y /v1beta no aceptan los datos de una cotización; aplican el precio del momento de aceptación.

Las llamadas de texto no devuelven un ID de tarea, y la marca de fin del texto no demuestra cuál es el cobro final. Para saber cuánto costó una llamada, enumera las tareas recientes con la misma clave de API (GET /api/v1/jobs?model=<model>&from=<YYYY-MM-DD>) y localízala por createdAt, o búscala en Consola → Registros. Considera cost definitivo solo cuando settled sea true. Consulta Tareas asíncronas para el historial y la recuperación de tareas, y Facturación.

reasoning_tokens ya está incluido en completion_tokens: no lo sumes dos veces.

Otras formas de solicitud

Estos ejemplos solo describen las envolturas de los protocolos. Sustituye MODEL_ID_FROM_CATALOG por un modelo en vivo que admita los campos que aparecen; no envíes nunca el marcador tal cual.

POST /v1/responses
{
  "model": "MODEL_ID_FROM_CATALOG",
  "input": "Explain a rainbow in one sentence.",
  "stream": false
}
POST /v1/messages
{
  "model": "MODEL_ID_FROM_CATALOG",
  "max_tokens": 256,
  "messages": [{"role": "user", "content": "Explain a rainbow in one sentence."}],
  "stream": false
}

Sigue leyendo

Cotizaciones y compatibilidad de protocolos · Idempotencia · Facturación · Integración en producción · Diagnóstico y recuperación

En esta página