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 anteriorMODEL_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.
| Protocolo | Endpoint | Cabecera de la clave de API |
|---|---|---|
| OpenAI Chat Completions | POST /v1/chat/completions | Authorization: Bearer |
| OpenAI Responses | POST /v1/responses | Authorization: Bearer |
| Anthropic Messages | POST /v1/messages | x-api-key o Authorization: Bearer |
| Google Gemini | POST /v1beta/models/{model}:generateContent | x-goog-api-key o Authorization: Bearer |
| Google Gemini (streaming) | POST /v1beta/models/{model}:streamGenerateContent | x-goog-api-key o Authorization: Bearer |
| Stream nativo con precio confirmado | POST /api/v1/jobs/stream | Authorization: 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.
| SDK | URL 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.
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)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)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/quotey después envíaPOST /api/v1/jobs/streamcon elquoteIdy elexpectedCostdevueltos. Si el precio cambió entre medias, la aceptación falla con40901en 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
40202en 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.
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.
[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ónico | Chat Completions | Responses | Messages | Gemini |
|---|---|---|---|---|
messages | messages | instructions, input | system, messages | systemInstruction, contents |
max_tokens | max_tokens, max_completion_tokens | max_output_tokens | max_tokens | generationConfig.maxOutputTokens |
temperature | temperature | temperature | temperature | generationConfig.temperature |
top_p | top_p | top_p | top_p | generationConfig.topP |
tools | tools | tools (tipo function) | tools (herramientas personalizadas) | tools[].functionDeclarations |
tool_choice | tool_choice | tool_choice | tool_choice | toolConfig.functionCallingConfig |
response_format | response_format | text.format | — | generationConfig.responseMimeType más responseSchema o responseJsonSchema |
reasoning_effort | reasoning_effort | reasoning.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,metadataystoreen Chat;user,metadata,storeeincludeen Responses;metadataen Messages. - Responses no admite
previous_response_id; envía el historial completo eninput. - En Gemini,
safetySettingsse acepta pero no tiene ningún efecto.cachedContenty las herramientas del lado del servidor, comogoogleSearchycodeExecution, no se admiten y devuelven400. Pasa las imágenes comoinlineData(base64) ofileData.fileUri(una URL https); si un modelo acepta imágenes o no lo define suinputSchema.
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.jsonSi 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.jsonEl 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.jsonCada tipo de contenido tiene su propia parte:
| Parte | Forma | De 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
400y 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 comoaudio_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.
| Protocolo | Parte 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 |
| Gemini | inlineData (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[].deltaevento SSE a evento SSE, no paquete de red a paquete de red. Monta lostool_callsporindex, y valida y ejecuta una llamada solo cuando susargumentsestén completos. - Conserva el mensaje del asistente en el historial y devuelve cada resultado en un mensaje
toolcon eltool_call_idcorrespondiente.
Responses
- Envía el historial completo en
input;previous_response_idse rechaza. - Devuelve los resultados de las funciones como elementos
function_call_outputcon elcall_idcorrespondiente.
Messages
- Usa bloques de contenido
text,tool_useytool_resulten lugar de la estructura de mensajes de Chat.
Gemini
contentsalterna entre dos roles,userymodel.- Cuando el modelo quiere llamar a una herramienta, devuelve una parte
functionCall. Después de ejecutar la herramienta, envuelve el resultado en una partefunctionResponsey envíalo en el siguiente contenidouser, con el mismonameque la llamada.
Finalización, errores y desconexiones
| Protocolo | Finalización normal | Consumo |
|---|---|---|
| Chat Completions | data: [DONE] | usage en el último fragmento |
| Responses | Evento response.completed | response.usage en ese evento |
| Messages | message_stop después de message_delta | usage 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:
| Protocolo | Cuerpo 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_tokensen 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/jsony el encabezadoX-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
200ya 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.codeconserva el estado HTTP que habría tenido el error. Cuando apareceX-Spicy-Keepalive, comprueba si el cuerpo trae un objetoerrorantes de leer la respuesta: los SDK oficiales no tratan un200como 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-aliveen 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: truepara 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 aceptanstream: true; en el formato de Gemini, llama a:streamGenerateContent?alt=sseen lugar de:generateContent. Un cuerpo distinto es una solicitud nueva, así que envíala con unIdempotency-Keynuevo: reutilizar el anterior devuelve409. - 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/jobscomo fallida, conerrorCodetimeouty un mensaje que explica qué pasó. Si la reenvías con el mismoIdempotency-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-timepara razonamientos largos.
Así se activa el streaming en los SDK de OpenAI y en curl:
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()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 --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.jsonEl 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.
{
"model": "MODEL_ID_FROM_CATALOG",
"input": "Explain a rainbow in one sentence.",
"stream": false
}{
"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
Cotizaciones y compatibilidad de protocolos
Conoce el precio exacto de una solicitud antes de pagar, envíala a ese precio, recupérate de una respuesta perdida y entiende cómo se cobran las API compatibles de texto y de video.
Ejemplos de chat
Programas completos para conversaciones, rol de personajes, streaming en tu interfaz y llamadas a herramientas con Python, Node.js y curl.

