Anthropic Messages, Chat Completions y Responses: cómo elegir, convertir y entender los límites de compatibilidad
Un HTTP 200 no significa que un agente sea compatible. Tres ciclos completos de herramientas muestran las diferencias reales entre Messages, Chat Completions y Responses, con pasos de migración, prueba y diagnóstico.
Índice
Un mismo agente puede seguir recibiendo 200 después de cambiar el endpoint y los nombres de los campos, pero dejar de ejecutar herramientas, devolver JSON que no cumple la Schema o perder de repente el contexto entre turnos. Normalmente no significa que el modelo se haya vuelto «menos capaz», sino que la aplicación ha tratado tres protocolos distintos como si fueran una sola interfaz.
Una migración solo puede considerarse correcta si se comprueba al menos en tres niveles:
- El formato se acepta: el servidor puede interpretar la solicitud y devuelve un estado satisfactorio.
- El comportamiento es equivalente: se llaman las herramientas, los resultados vuelven correctamente al modelo, el stream termina por completo y el contexto multivuelta se mantiene coherente.
- Las capacidades se conservan: la Schema estricta, el estado de reasoning nativo, las herramientas alojadas y las salidas estructuradas no se ignoran ni degradan en silencio.
HTTP 200 solo demuestra el primer nivel. Una conversión sencilla puede bastar para una solicitud que genera un bloque de texto. Cuando intervienen agentes, herramientas, argumentos en streaming, estado multivuelta o modelos de reasoning, hay que validar la cadena completa de interacción.
Conclusión práctica: el protocolo debe seguir al cliente y a las capacidades, no al nombre del modelo
| Escenario | Punto de partida más adecuado | Motivo |
|---|---|---|
Una aplicación existente ya usa de forma estable el SDK de OpenAI y messages | Chat Completions | Requiere menos cambios y permite conservar el ciclo actual de mensajes y herramientas |
| Un agente nuevo de OpenAI necesita herramientas alojadas, Items tipados o continuidad de estado en el servidor | Responses | OpenAI lo recomienda actualmente para proyectos nuevos y ofrece un conjunto más amplio de capacidades para agentes |
| Claude Code, una aplicación nativa de Claude o un flujo que depende de funciones propias de Claude | Anthropic Messages | Los bloques de contenido, resultados de herramientas, thinking y demás comportamiento siguen el contrato nativo de Anthropic |
| Un gateway propio o un router multimodelo | Mantener un adaptador independiente para cada protocolo upstream | Un único «JSON universal» no puede representar todas las capacidades nativas sin pérdidas |
OpenAI sigue admitiendo Chat Completions, por lo que una aplicación estable no tiene que reescribirse de inmediato solo porque exista una interfaz más reciente. La migración resulta más razonable para proyectos nuevos o cuando se necesitan capacidades nativas de Responses. Anthropic Messages tampoco es una interfaz de OpenAI con un campo renombrado a messages: sus bloques de contenido, traspaso de herramientas, eventos de streaming y reglas de estado forman un contrato propio.
Diferencias esenciales entre las tres API
En este artículo, Completions se refiere a Chat Completions, no al antiguo endpoint /v1/completions.
| Dimensión | OpenAI Chat Completions | OpenAI Responses | Anthropic Messages |
|---|---|---|---|
| Endpoint | /v1/chat/completions | /v1/responses | /v1/messages |
| Entrada principal | messages | Items en input; también admite mensajes simples | messages, normalmente con un system independiente en el nivel superior |
| Salida principal | choices[].message | Items tipados en output[] | Bloques de contenido en content[] |
| Definición de herramientas | tools[].function | name y parameters aparecen directamente en tools[] | input_schema dentro de tools[] |
| Argumentos de herramientas | function.arguments, una cadena JSON | arguments, una cadena JSON | tool_use.input, un objeto JSON |
| ID de correlación | tool_calls[].id | call_id | tool_use.id |
| Devolución del resultado | role: "tool" + tool_call_id | function_call_output + call_id | tool_result + tool_use_id dentro de un mensaje user |
| Estado multivuelta | La aplicación reenvía el historial de mensajes | Reenvío de Items, previous_response_id o Conversations | La aplicación reenvía mensajes y bloques de contenido |
| Salida estructurada final | response_format | text.format | output_config.format |
| Streaming | choices[].delta | Eventos tipados de Responses | Eventos de message/content block |
La tabla puede hacer que todo parezca una cuestión de renombrar campos. Los fallos reales suelen aparecer en la segunda solicitud: después de que el modelo emita una llamada a herramienta, ¿cómo la ejecuta la aplicación, qué ID debe conservar y con qué rol y orden devuelve el resultado? A continuación se recorre la misma tarea sin efectos secundarios en los tres protocolos.
Ejemplo común: consultar un plan de prueba
El usuario pregunta:
Consulta el plan
teamy dime si admite cobro por uso cuando se supera lo incluido.
La herramienta se llama get_plan_info. Solo lee datos locales fijos y no produce efectos secundarios externos, por lo que resulta apropiada para probar una migración de protocolo.
Los datos del plan son sintéticos y se usan únicamente con fines didácticos. No representan planes, precios ni derechos reales de OpenAI, Anthropic o BetterToken. Las tres secuencias de solicitudes y respuestas muestran la estructura del protocolo; no son registros de ejecuciones reales de la API.
La herramienta de la aplicación puede implementarse como una función independiente del protocolo:
from __future__ import annotations
import json
from typing import Any
PLAN_FIXTURES: dict[str, dict[str, Any]] = {
"team": {
"plan_code": "team",
"display_name": "Team",
"billing_mode": "usage_based",
"included_requests": 10_000,
"overage_allowed": True,
"source_version": "fixture-2026-09-01",
}
}
def execute_tool(name: str, raw_arguments: str | dict[str, Any]) -> str:
"""Ejecuta la herramienta didáctica de solo lectura y devuelve una cadena JSON que puede enviarse directamente al modelo."""
if isinstance(raw_arguments, str):
arguments = json.loads(raw_arguments)
elif isinstance(raw_arguments, dict):
arguments = raw_arguments
else:
raise TypeError("los argumentos de la herramienta deben ser una cadena JSON o un objeto")
if name != "get_plan_info":
raise ValueError(f"herramienta desconocida: {name}")
if set(arguments) != {"plan_code"}:
raise ValueError("get_plan_info solo acepta plan_code")
plan_code = arguments["plan_code"]
if not isinstance(plan_code, str):
raise TypeError("plan_code debe ser una cadena")
plan = PLAN_FIXTURES.get(plan_code)
if plan is None:
return json.dumps(
{"ok": False, "error": "plan_not_found", "plan_code": plan_code},
ensure_ascii=False,
)
return json.dumps({"ok": True, "data": plan}, ensure_ascii=False)
Aunque la solicitud active una Schema estricta, la aplicación debe conservar sus propias validaciones de entrada. El modo estricto limita los argumentos que genera el modelo, pero no sustituye la autorización, la validez de enumeraciones, la idempotencia ni las comprobaciones de seguridad de la lógica de negocio.
Chat Completions: ciclo completo de una herramienta
Primera solicitud: pedir al modelo que genere una llamada a herramienta
Los siguientes ejemplos usan el endpoint oficial de OpenAI para mostrar el protocolo. Al conectarse a un servicio compatible, hay que sustituir Base URL, el método de autenticación y el Model ID según la documentación del proveedor.
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"messages": [
{
"role": "system",
"content": "Eres un asistente de planes. Responde únicamente con los datos devueltos por la herramienta; no hagas suposiciones."
},
{
"role": "user",
"content": "Consulta el plan team y dime si admite cobro por uso cuando se supera lo incluido."
}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_plan_info",
"description": "Consultar datos de prueba fijos por código de plan",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
}
],
"tool_choice": "required",
"parallel_tool_calls": false
}'
La aplicación debe leer tool_calls en el mensaje assistant. La siguiente respuesta conserva únicamente los campos necesarios para continuar:
{
"choices": [
{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_plan_001",
"type": "function",
"function": {
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}
Hay dos valores que no pueden perderse:
tool_calls[0].id: debe devolverse sin cambios comotool_call_iden la segunda solicitud.function.arguments: es una cadena JSON. Primero hay que analizarla y después aplicar la validación propia de Schema y de negocio.
Ejecuta la herramienta:
tool_result = execute_tool(
"get_plan_info",
"{\"plan_code\":\"team\"}",
)
Segunda solicitud: devolver al modelo el resultado de la herramienta
Chat Completions exige conservar el mensaje assistant que contiene la llamada original y añadir después un mensaje de resultado con role: "tool".
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"messages": [
{
"role": "system",
"content": "Eres un asistente de planes. Responde únicamente con los datos devueltos por la herramienta; no hagas suposiciones."
},
{
"role": "user",
"content": "Consulta el plan team y dime si admite cobro por uso cuando se supera lo incluido."
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_plan_001",
"type": "function",
"function": {
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_plan_001",
"content": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"display_name\":\"Team\",\"billing_mode\":\"usage_based\",\"included_requests\":10000,\"overage_allowed\":true,\"source_version\":\"fixture-2026-09-01\"}}"
}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_plan_info",
"description": "Consultar datos de prueba fijos por código de plan",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
}
]
}'
Un mensaje final representativo sería:
{
"choices": [
{
"message": {
"role": "assistant",
"content": "El plan Team admite cobro por uso una vez superado lo incluido. Los datos de prueba incluyen 10.000 solicitudes y overage_allowed es true."
},
"finish_reason": "stop"
}
]
}
Si el adaptador solo convierte el primer turno del usuario pero no conserva los tool_calls del assistant, o si introduce un ID incorrecto en tool_call_id, la segunda solicitud ya no continúa la misma llamada a herramienta.
Responses: ciclo completo de una herramienta
Responses representa mensajes, reasoning, llamadas y resultados como distintos tipos de Item. No se debe asumir que output[0] siempre contiene el texto final; la lógica debe ramificarse según el type de cada Item.
Primera solicitud: pedir al modelo un Item function_call
curl https://api.openai.com/v1/responses \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"instructions": "Eres un asistente de planes. Responde únicamente con los datos devueltos por la herramienta; no hagas suposiciones.",
"input": "Consulta el plan team y dime si admite cobro por uso cuando se supera lo incluido.",
"tools": [
{
"type": "function",
"name": "get_plan_info",
"description": "Consultar datos de prueba fijos por código de plan",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
],
"tool_choice": "required",
"parallel_tool_calls": false,
"store": false
}'
Un Item representativo de llamada a herramienta sería:
{
"id": "resp_plan_001",
"object": "response",
"output": [
{
"type": "function_call",
"id": "fc_plan_001",
"call_id": "call_plan_001",
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}",
"status": "completed"
}
]
}
El resultado debe correlacionarse con call_id. id: "fc_plan_001" identifica al Item en sí y no debe sustituir a call_id.
Ejecuta la herramienta:
tool_result = execute_tool(
"get_plan_info",
"{\"plan_code\":\"team\"}",
)
Segunda solicitud: devolver un function_call_output
El ejemplo siguiente usa reproducción manual sin estado, por lo que vuelve a enviar instructions, la entrada original del usuario, la llamada a herramienta y su resultado.
curl https://api.openai.com/v1/responses \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"instructions": "Eres un asistente de planes. Responde únicamente con los datos devueltos por la herramienta; no hagas suposiciones.",
"input": [
{
"role": "user",
"content": "Consulta el plan team y dime si admite cobro por uso cuando se supera lo incluido."
},
{
"type": "function_call",
"call_id": "call_plan_001",
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}"
},
{
"type": "function_call_output",
"call_id": "call_plan_001",
"output": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"display_name\":\"Team\",\"billing_mode\":\"usage_based\",\"included_requests\":10000,\"overage_allowed\":true,\"source_version\":\"fixture-2026-09-01\"}}"
}
],
"tools": [
{
"type": "function",
"name": "get_plan_info",
"description": "Consultar datos de prueba fijos por código de plan",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
],
"store": false
}'
Un Item final de salida representativo sería:
{
"id": "resp_plan_002",
"object": "response",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "El plan Team admite cobro por uso una vez superado lo incluido. Los datos de prueba incluyen 10.000 solicitudes y overage_allowed es true."
}
]
}
]
}
Si se utiliza continuidad de estado en el servidor, se puede permitir que la primera respuesta se guarde y usar el siguiente formato en la segunda solicitud:
{
"model": "YOUR_OPENAI_MODEL",
"previous_response_id": "resp_plan_001",
"input": [
{
"type": "function_call_output",
"call_id": "call_plan_001",
"output": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"overage_allowed\":true}}"
}
]
}
Un previous_response_id pertenece al servicio upstream que creó esa respuesta. No puede entregarse a otro proveedor para continuarla. Tampoco hace gratuita la entrada histórica: la documentación actual de OpenAI indica que los token de entrada anteriores de la cadena siguen facturándose como input.
Cuando la respuesta contiene un reasoning Item, la reproducción sin estado también debe conservar el Item correspondiente tal como exige la documentación. No se puede eliminar para crear un «formato uniforme» y afirmar después que el contexto de reasoning sigue siendo equivalente.
Anthropic Messages: ciclo completo de una herramienta
Messages representa la llamada como un bloque tool_use dentro del contenido assistant y devuelve el resultado como un bloque tool_result en el siguiente mensaje user. Los argumentos ya son un objeto, no una cadena JSON pendiente de analizar.
Primera solicitud: pedir a Claude que devuelva tool_use
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "YOUR_CLAUDE_MODEL",
"max_tokens": 512,
"system": "Eres un asistente de planes. Responde únicamente con los datos devueltos por la herramienta; no hagas suposiciones.",
"messages": [
{
"role": "user",
"content": "Consulta el plan team y dime si admite cobro por uso cuando se supera lo incluido."
}
],
"tools": [
{
"name": "get_plan_info",
"description": "Consultar datos de prueba fijos por código de plan",
"strict": true,
"input_schema": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
],
"tool_choice": {
"type": "tool",
"name": "get_plan_info"
}
}'
Forzar una herramienta concreta depende de que el modelo y la configuración seleccionados lo admitan. Si el modelo de destino no lo permite, usa auto y comprueba en la aplicación que realmente se haya devuelto una llamada a herramienta.
Una respuesta representativa sería:
{
"id": "msg_plan_001",
"type": "message",
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_plan_001",
"name": "get_plan_info",
"input": {
"plan_code": "team"
}
}
],
"stop_reason": "tool_use"
}
El objeto input puede pasarse directamente al ejecutor de la herramienta:
tool_result = execute_tool(
"get_plan_info",
{"plan_code": "team"},
)
Segunda solicitud: colocar tool_result en el mensaje user inmediatamente posterior
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "YOUR_CLAUDE_MODEL",
"max_tokens": 512,
"system": "Eres un asistente de planes. Responde únicamente con los datos devueltos por la herramienta; no hagas suposiciones.",
"messages": [
{
"role": "user",
"content": "Consulta el plan team y dime si admite cobro por uso cuando se supera lo incluido."
},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_plan_001",
"name": "get_plan_info",
"input": {
"plan_code": "team"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_plan_001",
"content": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"display_name\":\"Team\",\"billing_mode\":\"usage_based\",\"included_requests\":10000,\"overage_allowed\":true,\"source_version\":\"fixture-2026-09-01\"}}"
}
]
}
],
"tools": [
{
"name": "get_plan_info",
"description": "Consultar datos de prueba fijos por código de plan",
"strict": true,
"input_schema": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
]
}'
Una respuesta final representativa sería:
{
"id": "msg_plan_002",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "El plan Team admite cobro por uso una vez superado lo incluido. Los datos de prueba incluyen 10.000 solicitudes y overage_allowed es true."
}
],
"stop_reason": "end_turn"
}
Messages impone un orden claro: tool_result debe aparecer inmediatamente después del mensaje assistant que contiene el tool_use correspondiente. Si un mismo turno assistant genera varias llamadas a herramientas del cliente, todos los bloques de resultados deben volver en el siguiente mensaje user y correlacionarse uno a uno mediante tool_use_id. Si ese mensaje user también incluye texto normal, los bloques de resultados deben ir antes del texto.
Qué puede mapearse directamente y qué conversiones son inevitablemente con pérdida
| Capacidad | Evaluación de la conversión | Tratamiento correcto |
|---|---|---|
| Texto ordinario del usuario | Suele mapearse directamente | Conservar texto, orden y tipos multimodales, no solo las cadenas visibles |
| Schema básica de función | Puede cambiarse de forma | Convertir entre function.parameters, parameters de Responses e input_schema de Messages, y volver a validar el subconjunto de JSON Schema compatible |
| Argumentos de herramientas | Es necesario convertir el tipo | Las dos interfaces de OpenAI suelen devolver cadenas JSON; Messages devuelve un objeto. Normalizar, analizar y validar antes de entrar en la lógica de negocio |
| ID de llamada | Hay que conservar su significado, pero no reutilizar namespaces | Mantener un canonical call ID interno junto con el ID original del upstream y devolver el campo propio del protocolo |
| Llamadas paralelas | Se pueden admitir, pero nunca correlacionar por posición del array | Asociar cada resultado mediante tool_call_id, call_id o tool_use_id |
| Instrucciones system/developer | Pueden sufrir pérdidas | Diferenciar alcance global, de etapa de conversación y de un solo turno; degradar o rechazar explícitamente si el protocolo de destino no puede expresar el alcance original |
| Salida estructurada final | Los campos no son intercambiables de forma mecánica | Chat usa response_format, Responses usa text.format y Messages usa output_config.format |
| Argumentos en streaming | Requieren un parser específico del protocolo | Acumular fragmentos por evento e ID de llamada y analizar JSON solo después del evento de finalización |
| Estado multivuelta del servidor | No existe un equivalente universal | Los ID de estado como previous_response_id quedan ligados a su upstream; entre upstreams, reenviar el contexto visible o mantener sticky routing |
| Estado thinking/reasoning | Normalmente no puede convertirse sin pérdidas | Conservar Items opacos, thinking blocks, firmas o contenido cifrado exactamente como exija el protocolo nativo; nunca inventarlos |
| Herramientas alojadas | A menudo no tienen equivalente directo | Declarar por separado compatibilidad y alternativas para web search, file search, computer use, server tools y funciones similares |
| Generación de varios candidatos | Puede no tener equivalente | No suponer que n de Chat Completions se mapea directamente a Responses; realizar varias solicitudes en la aplicación o cambiar el comportamiento del producto |
Por tanto, la abstracción interna más fiable para un gateway no es un objeto enorme que contenga todos los campos posibles. Conviene modelar por separado mensajes, alcance de instrucciones, definiciones de herramientas, llamadas, resultados, identificadores de estado, eventos de streaming y estado nativo opaco. Si una capacidad no puede expresarse, se debe devolver un estado explícito de «no compatible» o «conversión con pérdida», en lugar de eliminar el campo en silencio.
strict, response_format y text.format resuelven problemas distintos
Un error habitual en una migración es tratar «argumentos válidos para una herramienta» y «respuesta final con una forma JSON determinada» como si fueran una sola función.
| Objetivo | Chat Completions | Responses | Anthropic Messages |
|---|---|---|---|
| Restringir argumentos de una llamada | tools[].function.strict | tools[].strict | tools[].strict |
| Restringir la salida final del modelo | response_format | text.format | output_config.format |
El strict de una herramienta limita cómo llama el modelo a la función. La salida estructurada final limita el contenido entregado al usuario. Un agente puede necesitar ambas cosas a la vez: primero llamar a una herramienta con argumentos estrictos y después devolver el resultado final bajo una JSON Schema fija.
La documentación actual de OpenAI incluye además una diferencia de valores por defecto fácil de pasar por alto:
- Las llamadas de función en Chat Completions no son estrictas por defecto.
- Cuando se omite
stricten Responses, el servicio intenta normalizar la Schema al modo estricto. Si no es compatible, puede volver al modo no estricto y mostrarstrict: falseen la definición analizada.
Para dejar clara la intención y no depender de valores por defecto distintos, las solicitudes de producción deben indicar deliberadamente strict: true o strict: false. Una Schema estricta también debe cumplir los requisitos correspondientes, como prohibir propiedades adicionales y enumerar todos los campos obligatorios.
Más importante aún: una capa de compatibilidad puede aceptar un campo sin aplicar la restricción. La documentación oficial de Anthropic sobre compatibilidad con el SDK de OpenAI indica que, en esa capa concreta, se ignoran campos como function strict, response_format y reasoning_effort, y que la mayoría de los campos no compatibles no producen errores. Por ello, una solicitud puede devolver 200 aunque la Schema o la configuración de reasoning no se hayan aplicado.
Esto no significa que Anthropic Messages nativo carezca de capacidades equivalentes. Messages nativo admite entrada estricta para herramientas y usa output_config.format para el JSON final. El diagnóstico debe empezar respondiendo a una pregunta: ¿se está llamando a Messages nativo o a una capa compatible con OpenAI?
El alcance de system, developer e instrucciones no se conserva concatenando cadenas
Las interfaces de estilo OpenAI admiten distintos roles en el historial y Responses añade instructions. Anthropic Messages ha usado tradicionalmente un system en el nivel superior. En septiembre de 2026, algunos modelos actuales también admiten role: "system" en mitad de una conversación, pero no todos, y existen restricciones de posición y de orden respecto a las herramientas.
Al mismo tiempo, la capa de compatibilidad de Anthropic con el SDK de OpenAI recopila los mensajes system/developer, los concatena con saltos de línea y los eleva a un único prompt de sistema al principio. Eso permite procesar la solicitud, pero altera su momento y alcance originales. Una instrucción developer prevista solo a partir del octavo turno puede cambiar la semántica de los siete primeros al trasladarse al inicio.
Un adaptador más seguro distingue primero tres niveles dentro de la aplicación:
- Instrucciones globales: se aplican a toda la conversación.
- Instrucciones de etapa: empiezan a aplicarse desde un turno determinado.
- Instrucciones de un turno: controlan únicamente la tarea actual.
Solo debe mapearse una instrucción cuando el protocolo de destino pueda expresar el mismo alcance. Si no puede, hay que elegir una estrategia explícita: mantener la solicitud en un modelo que admita la capacidad, degradar la instrucción y registrar la diferencia, o rechazar la migración. La concatenación silenciosa requiere poco código, pero suele producir «la solicitud fue correcta, el comportamiento cambió».
El streaming requiere una máquina de estados, no concatenar token de texto
Las tres interfaces ofrecen streaming, pero sus eventos no son equivalentes:
- Chat Completions suele acumular texto y fragmentos de
tool_callsdesdechoices[].delta. - Responses emite eventos tipados como
response.output_text.delta,response.function_call_arguments.delta,response.function_call_arguments.done,response.completedyerror. - Messages usa
message_start,content_block_start,content_block_delta,content_block_stop,message_deltaymessage_stop; los argumentos llegan fragmentados medianteinput_json_delta.partial_json.
Los argumentos pueden dividirse así:
{"plan_
code":"te
am"}
Ninguno de estos fragmentos es JSON válido por sí solo. Hay que acumularlos por ID de llamada o índice de bloque y analizarlos únicamente al recibir el evento de finalización correspondiente:
from __future__ import annotations
import json
from collections import defaultdict
from typing import Any
class ToolArgumentAssembler:
def __init__(self) -> None:
self._buffers: dict[str, list[str]] = defaultdict(list)
def add_delta(self, call_id: str, fragment: str) -> None:
self._buffers[call_id].append(fragment)
def finish(self, call_id: str) -> dict[str, Any]:
if call_id not in self._buffers:
raise KeyError(f"call_id desconocido: {call_id}")
raw = "".join(self._buffers.pop(call_id))
value = json.loads(raw)
if not isinstance(value, dict):
raise TypeError("los argumentos de la herramienta deben decodificarse como un objeto")
return value
def discard(self, call_id: str) -> None:
self._buffers.pop(call_id, None)
El adaptador también debe registrar un estado terminal explícito:
created -> receiving -> completed
\-> failed
\-> disconnected
disconnected no equivale a completed. Anthropic Messages puede emitir un event: error dentro del stream después de que la conexión HTTP ya se haya establecido correctamente; Responses también dispone de eventos de error independientes. Mirar solo el estado HTTP inicial o tratar el cierre de la conexión como una finalización natural puede truncar argumentos o la respuesta final.
El parser debe tolerar tipos de evento desconocidos: registrarlos y omitir los que no afecten a la capacidad actual, en vez de bloquear todo el cliente cada vez que el servidor añada un evento.
El estado multivuelta y el estado de reasoning no pueden inventarse
Chat Completions y los flujos tradicionales de Messages suelen depender de que la aplicación reenvíe el historial. Responses también puede mantener estado del servidor mediante previous_response_id o Conversations. Su concepto de «turno anterior» no es intercambiable.
Cuando un gateway recibe un ID de estado, solo tiene tres estrategias válidas:
- Sticky routing: enviar las solicitudes posteriores al mismo upstream que creó el estado.
- Reproducción completa: reenviar todos los mensajes, llamadas, resultados y elementos de estado nativo que se puedan reproducir legalmente.
- Rechazo explícito: si el upstream de destino no puede continuar, devolver un error diagnosticable y permitir que el cliente reinicie la conversación.
No se debe pasar un previous_response_id de OpenAI a Anthropic ni presentar un ID interno de conversación del gateway como si otro proveedor pudiera entenderlo.
El estado de reasoning tampoco se resuelve renombrando campos:
- En configuraciones sin estado o con determinadas políticas de retención, Responses puede devolver reasoning Items cifrados que deben reenviarse en la siguiente solicitud.
- Los flujos de thinking de Anthropic pueden incluir thinking blocks, firmas u otro estado opaco. Al usar herramientas y conversaciones multivuelta deben conservarse según la documentación nativa.
- En septiembre de 2026, el uso manual de Anthropic
thinking.type: "enabled"conbudget_tokensestá obsoleto en modelos de generación 4.6 y se rechaza en 4.7 y posteriores; los modelos más nuevos usan adaptive thinking y el control de effort correspondiente.
Por eso no puede establecerse una regla permanente que equipare OpenAI reasoning_effort con Anthropic budget_tokens. Una descripción correcta de capacidades debe incluir el modelo de destino, su modo thinking actual y la estrategia de degradación cuando no esté disponible.
Herramientas paralelas: correlacionar por ID, nunca por posición
Un modelo puede solicitar varias herramientas en un turno. Los tiempos de ejecución varían y los resultados pueden llegar en otro orden. El adaptador necesita mantener una relación como esta:
canonical_call_id
-> provider
-> provider_call_id
-> tool_name
-> validated_arguments
-> execution_status
-> result
Al devolver resultados:
- Chat Completions crea un mensaje
role: "tool"por resultado e incluye eltool_call_idcorrespondiente. - Responses crea un Item
function_call_outputpor resultado e incluye elcall_idcorrespondiente. - Messages coloca los bloques
tool_resulten el turno user inmediatamente posterior e incluye en cada uno sutool_use_id.
Durante las pruebas de migración conviene empezar con parallel_tool_calls: false, completar el flujo de una sola herramienta y después activar el paralelismo. En producción, las herramientas con efectos secundarios —enviar correo, cobrar o crear recursos— también necesitan claves de idempotencia. Los reintentos de red, una desconexión del stream o la repetición por el upstream pueden entregar la misma llamada semántica otra vez; el texto generado por el modelo no basta para saber si ya se ejecutó.
Por qué HTTP 200 no basta para validar compatibilidad
Una prueba útil de migración debe cubrir, como mínimo, estos caminos:
| Prueba | Criterio de aprobación |
|---|---|
| Texto normal | El contenido es legible y el alcance system/developer se comporta como se espera |
| Una llamada a herramienta | Nombre, argumentos, ID, resultado y respuesta final forman un ciclo completo |
| Llamadas paralelas | Cada resultado se asocia por ID sin cruces ni pérdidas |
| Argumentos en streaming | Los fragmentos se ensamblan por completo y el JSON se analiza al finalizar |
| Schema estricta de herramienta | Los campos y tipos no válidos se rechazan como se espera o se degradan de forma explícita |
| Salida estructurada final | La respuesta cumple la Schema indicada, no solo «parece JSON» |
| Error de ejecución | El modelo recibe un error estructurado y no entra en bucle ni inventa éxito |
| Continuidad multivuelta | El segundo turno puede citar hechos del primero y las reglas de estado están claras |
| reasoning/thinking | El modo declarado funciona y el estado nativo no se elimina ni se fabrica |
| Errores dentro del stream y desconexiones | El cliente distingue finalización, fallo e interrupción |
| Error controlado de API | Tipo de error, request ID y política de reintento siguen siendo diagnosticables |
Usa entradas y fixtures fijos y registra por separado, para cada protocolo:
- si el resultado de negocio final es equivalente;
- si la llamada y la devolución del resultado están completas;
- latencia P50 y P95;
- usage de input, output y cache;
- tipo de error, request ID y estado terminal;
- qué capacidades se han degradado explícitamente.
No registres API Keys, prompts sensibles completos ni salidas privadas. Como mínimo, los logs de error deben conservar HTTP status, upstream error type/code, un mensaje breve, request ID, endpoint, protocolo, Model ID y estado terminal del stream. De lo contrario, model_not_found, falta de permisos y rutas incompatibles pueden convertirse en un único 400 imposible de diagnosticar.
Diagnóstico por síntomas: dónde se rompió el agente
| Síntoma | Causa habitual | Diagnóstico y solución |
|---|---|---|
Devuelve 200, pero el modelo nunca llama a una herramienta | No se envió la definición, se ignoró tool_choice, el modelo no admite herramientas o el prompt no es suficiente | Imprimir la solicitud final; comprobar el modelo y la capa compatible; dejar una única herramienta de solo lectura y forzar o pedir explícitamente su uso |
| El modelo devuelve una llamada, pero la aplicación no la ejecuta | La aplicación sigue leyendo un campo antiguo, como solo message.content | Leer tool_calls, el Item function_call o el bloque tool_use según el protocolo |
| Falla el análisis del JSON de argumentos | Se trató un fragmento de streaming como JSON completo o se intentó analizar de nuevo un objeto como cadena | Esperar al evento de finalización; determinar primero si el valor es cadena u objeto |
Aparecen campos adicionales pese a strict | La capa compatible lo ignora, la Schema no cumple el modo estricto o no se está llamando al endpoint nativo | Verificar endpoint y documentación; establecer strict explícitamente; añadir una prueba de regresión que infrinja a propósito la Schema |
| La segunda solicitud indica que falta el resultado | El ID no coincide o no se conservó el primer Item assistant/tool | Guardar la llamada upstream y su ID sin cambios; devolver el resultado justo donde lo exige el protocolo |
Messages devuelve tool_use ids ... without tool_result | tool_result no sigue inmediatamente a la llamada o se insertó texto antes | Colocar todos los tool_result correspondientes en el siguiente mensaje user y antes del texto opcional |
| El streaming se detiene o solo entrega media lista de argumentos | El cliente espera únicamente un marcador de fin de texto y no trata estados terminales de argumentos o errores | Implementar una máquina de eventos separada para cada protocolo y distinguir completed, failed, error y disconnected |
| El segundo turno olvida el primero | Se omitieron historial, llamada o result Items; o previous_response_id pertenece a otro upstream | Reenviar el contexto visible completo o mantener sticky routing; no transferir ID de estado entre proveedores |
| Una instrucción system empieza a aplicarse demasiado pronto al cambiar de interfaz | La capa compatible elevó un system/developer intermedio al principio | Modelar el alcance; degradar de forma visible o mantener el protocolo nativo cuando el mapeo sin pérdidas sea imposible |
| La herramienta se ejecuta dos veces | Reintento de solicitud, repetición tras desconexión o falta de idempotencia | Usar herramientas de solo lectura en pruebas; generar la clave de idempotencia de herramientas con efectos secundarios a partir de un canonical call ID |
| La salida final es JSON, pero a veces faltan campos | El prompt solo pide «devuelve JSON» y no activa salida estructurada | Usar response_format, text.format u output_config.format según la interfaz y volver a validar en la aplicación |
Secuencia de migración más segura
- Identifica el protocolo que el cliente envía realmente. No lo deduzcas del nombre del modelo. Registra el endpoint completo, el método del SDK, los campos superiores y los tipos de eventos del stream.
- Enumera los comportamientos que deben mantenerse. Como mínimo: herramientas, llamadas paralelas, Schema estricta, salida estructurada final, estado multivuelta, streaming y thinking/reasoning.
- Prioriza el protocolo nativo. Si una capacidad puede implementarse directamente con Messages o Responses nativos, evita una capa compatible adicional.
- Crea una matriz de capacidades de conversión. Marca cada función como compatible por completo, compatible con pérdidas o no compatible, y haz visible el resultado al caller.
- Ejecuta un ciclo completo de dos solicitudes con un fixture sin efectos secundarios. No basta con comprobar que una solicitud devuelve texto; ejecuta la herramienta y devuelve su resultado.
- Después prueba paralelismo, streaming y rutas de error. Solo cuando pase el flujo normal deben activarse herramientas con efectos secundarios y tráfico real.
- Aumenta el tráfico gradualmente y compara métricas. Observa corrección, latencia, usage, errores y ejecuciones duplicadas, no solo la tasa de HTTP satisfactorio.
Cómo elegir el punto de entrada de BetterToken
BetterToken ofrece rutas distintas para clientes distintos. El protocolo sigue determinado por el wire contract que use realmente el cliente:
- Chat Completions: la URL completa es
https://www.bettertoken.ai/v1/chat/completions. Para SDK o herramientas que añaden la ruta automáticamente, Base URL suele serhttps://www.bettertoken.ai/v1. Consulta la referencia de Chat Completions API. - Codex / Responses: la documentación actual de Codex usa
base_url = "https://www.bettertoken.ai/v1"ywire_api = "responses"; Codex añade/responses. Consulta la guía de Codex. - Claude Code / Messages: la documentación actual usa
ANTHROPIC_BASE_URL=https://bettertoken.ai, sin/v1al final de Base URL; el cliente añade/v1/messages. Consulta la guía de Claude Code.
Usar el mismo Dashboard, API Key o nombre de modelo no convierte tres protocolos en un solo formato. Para una herramienta existente, elige el protocolo que espera. Para un agente propio, valida las capacidades necesarias con los ciclos completos y la matriz de aceptación de este artículo.
Preguntas frecuentes
¿OpenAI-compatible equivale a una réplica completa de OpenAI API?
No. Normalmente significa que ciertos endpoints y estructuras pueden utilizarse desde clientes de estilo OpenAI. Modelos, parámetros, eventos de streaming, herramientas, salida estructurada, herramientas alojadas y semántica de errores deben comprobarse por separado.
¿Basta con sustituir Base URL y API Key?
A veces, para solicitudes de texto simples que ya usan el mismo wire contract. Un agente con herramientas aún exige validar definiciones, devolución de resultados en la segunda solicitud, eventos de streaming, Schema estricta, estado y errores. Si el cliente espera Responses, /chat/completions no basta; si espera Messages, un endpoint de estilo OpenAI no se adapta automáticamente.
¿Puede un adaptador universal convertir los tres protocolos?
Puede cubrir texto ordinario y parte del ciclo de funciones, pero no debe prometer compatibilidad completa sin pérdidas. El estado gestionado por el proveedor, las herramientas alojadas, el estado opaco de thinking/reasoning, determinados alcances system y funciones específicas del modelo suelen carecer de equivalente universal. El adaptador debe exponer una matriz de capacidades y la información de degradación.
¿Por qué pasan los unit tests pero falla el agente real?
Muchas pruebas solo simulan la primera respuesta. No validan la devolución del resultado en la segunda solicitud, las llamadas paralelas, los fragmentos en streaming ni la continuidad de estado. Amplía la prueba a «solicitud del usuario → llamada del modelo → ejecución en la aplicación → devolución del resultado → respuesta final» para descubrir fallos reales del protocolo.
¿Conviene migrar primero Chat Completions o Responses?
Una aplicación estable en Chat Completions puede seguir funcionando y migrar capacidad por capacidad según el valor de negocio. Un agente nuevo de OpenAI, o uno que necesite Items tipados, herramientas alojadas o estado de Responses, es mejor construirlo directamente sobre Responses. Los criterios son las capacidades y el coste de migración, no si el nombre de la interfaz parece más reciente.