Migrar las llamadas a funciones de Chat Completions a Responses API no consiste solo en cambiar el endpoint. La aplicación debe recibir cada function_call, validar y ejecutar la función permitida, devolver un function_call_output con el mismo call_id y solicitar otra Response hasta obtener la respuesta final.
Esta guía parte de una integración que ya usa chat.completions.create y funciones personalizadas. No trata la antigua API Completions ni migra Assistants. La primera acción segura es probar una función no destructiva en staging, comparar su traza con la ruta actual y parar si cambia el resultado de negocio o el número de ejecuciones.
El contrato completo es:
definir → recibir function_call → validar y ejecutar en la aplicación → devolver function_call_output con el mismo call_id → verificar la respuesta final
La guía oficial de migración recomienda Responses API para proyectos nuevos, mientras Chat Completions sigue disponible. Por tanto, conviene migrar detrás de un flag reversible y demostrar paridad antes de mover tráfico real.
Qué cambia entre las dos APIs
| Responsabilidad | Chat Completions | Responses API | Prueba necesaria |
|---|---|---|---|
| Método del cliente | client.chat.completions.create() | client.responses.create() | Las dos rutas aceptan la misma entrada |
| Contenido de entrada | messages | input | Se conservan intención e instrucciones |
| Salida del modelo | choices[0].message | Elementos tipados en response.output[] | Se recorre por type |
| Definición de función | Objeto anidado en function | Campos planos de la herramienta | Se revalida el JSON Schema |
| Solicitud de herramienta | message.tool_calls[] | type: "function_call" | Se procesan todos los elementos |
| Resultado | role: "tool" y tool_call_id | type: "function_call_output" y call_id | El ID coincide exactamente |
| Estado | Reenvío de messages | Historial manual, previous_response_id o Conversation | Se elige un único owner del estado |
| Streaming | choices[].delta | Eventos tipados de Responses | Se rehacen parser, mocks y tests |
Hay dos límites que evitan la mayoría de los errores:
- OpenAI no ejecuta una función personalizada. El modelo propone nombre y argumentos; tu backend valida, autoriza y ejecuta.
response.outputes heterogéneo. Puede contener texto, razonamiento u otras llamadas. No se debe asumir queoutput[0]es la función ni usaroutput_textpara controlar el bucle.
La documentación oficial de llamada a funciones es la referencia para nombres de elementos, correlación por call_id, modo estricto, paralelismo y eventos de streaming.
Fija una línea base antes de migrar
Ejecuta una petición conocida en la ruta Chat Completions con una herramienta inocua. Por ejemplo, consulta el pedido ORD-1042 y registra:
- entrada normalizada e instrucciones;
- nombre y versión del esquema;
- argumentos después de validarlos;
- número de ejecuciones y clave de idempotencia;
- resultado de la función;
- respuesta final para el usuario;
- latencia, tokens y clase de error.
El bucle antiguo suele tener esta forma abreviada:
jsconst completion = await client.chat.completions.create({ model, messages, tools: [{ type: "function", function: { name: "get_order_status", description: "Consulta el estado actual de un pedido", parameters: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"], additionalProperties: false }, strict: true } }] }); const calls = completion.choices[0].message.tool_calls ?? []; // Tras ejecutar cada función, se añadía: // { role: "tool", tool_call_id: call.id, content: JSON.stringify(result) }
Conserva temporalmente esta ruta. El criterio de éxito no es «la nueva API devolvió 200», sino «la misma entrada produjo un único efecto autorizado y una respuesta final equivalente».
Bucle completo de Responses API en JavaScript
Este ejemplo consulta un pedido en memoria, por lo que no modifica datos. Recorre toda la salida tipada, valida los argumentos, usa una lista permitida, evita repetir un call_id, conserva los elementos necesarios para el estado manual y sigue llamando a Responses hasta llegar al texto final.
Instala el SDK actual de OpenAI, define OPENAI_API_KEY y OPENAI_MODEL, y guarda el código como bucle-responses.mjs.
jsimport OpenAI from "openai"; const client = new OpenAI(); const model = process.env.OPENAI_MODEL; if (!model) { throw new Error("Define OPENAI_MODEL con un modelo que admita funciones."); } const orders = new Map([ ["ORD-1042", { status: "preparado", eta: "2026-07-30" }] ]); async function getOrderStatus({ order_id }) { return orders.get(order_id) ?? { status: "no_encontrado" }; } const handlers = Object.freeze({ get_order_status: getOrderStatus }); const tools = [{ type: "function", name: "get_order_status", description: "Devuelve el estado actual y la fecha estimada de un pedido.", strict: true, parameters: { type: "object", properties: { order_id: { type: "string", description: "Identificador con formato ORD-1234" } }, required: ["order_id"], additionalProperties: false } }]; function parseAndValidate(call) { if (!Object.hasOwn(handlers, call.name)) { throw new Error(`Función no permitida: ${call.name}`); } const args = JSON.parse(call.arguments); if ( typeof args.order_id !== "string" || !/^ORD-\d{4}$/.test(args.order_id) ) { throw new Error("order_id debe cumplir el formato ORD-1234"); } return args; } // Para mutaciones reales, sustituye este Map por almacenamiento duradero. const executionLedger = new Map(); async function executeOnce(call) { if (executionLedger.has(call.call_id)) { return executionLedger.get(call.call_id); } let result; try { const args = parseAndValidate(call); result = await handlers[call.name](args); } catch (error) { result = { error: "tool_execution_rejected", message: error instanceof Error ? error.message : "Error desconocido" }; } const outputItem = { type: "function_call_output", call_id: call.call_id, output: JSON.stringify(result) }; executionLedger.set(call.call_id, outputItem); return outputItem; } const instructions = [ "Usa get_order_status cuando el usuario proporcione un pedido.", "No inventes el estado.", "Si no existe, explícalo sin afirmar que el pedido es válido." ].join(" "); const input = [{ role: "user", content: "¿Cuál es el estado del pedido ORD-1042?" }]; let finalText = ""; for (let round = 0; round < 5; round += 1) { const response = await client.responses.create({ model, instructions, // se reenvían en cada ronda manual input, tools, parallel_tool_calls: false, // opción conservadora para empezar store: false }); // Conserva todos los elementos, incluido razonamiento relevante. input.push(...response.output); const calls = response.output.filter( (item) => item.type === "function_call" ); if (calls.length === 0) { finalText = response.output_text; if (!finalText) { throw new Error("La Response terminó sin función ni texto final."); } break; } const toolOutputs = await Promise.all(calls.map(executeOnce)); input.push(...toolOutputs); } if (!finalText) { throw new Error("El bucle superó el límite de cinco rondas."); } console.log(finalText);
Ejecución:
bashnpm install openai OPENAI_MODEL="modelo-compatible-con-funciones" node bucle-responses.mjs
El código es reproducible, pero este artículo no presenta una salida de OpenAI como si fuera un resultado observado. En staging debes ejecutarlo con una cuenta autorizada y un modelo actual, guardar la traza tipada y compararla con la línea base.
Por qué están esos controles
strict: true,requiredyadditionalProperties: falsehacen explícito el contrato. El comportamiento exacto de strict y el subconjunto de JSON Schema pueden cambiar; compruébalos en la documentación actual.Object.hasOwn(handlers, call.name)evita convertir un nombre generado por el modelo en ejecución arbitraria.call.call_idse copia sin modificar. Es la unión entre la solicitud de función y el resultado que devuelve la aplicación.Promise.all(calls.map(...))no pierde llamadas. Aun así, el primer despliegue desactiva el paralelismo para simplificar la traza.input.push(...response.output)conserva todos los elementos relevantes, no solo texto.- El límite de rondas detiene bucles sin fin.
- Un error de validación vuelve como resultado estructurado; no provoca una repetición silenciosa de un efecto.
Para reembolsos, emails, cambios de pedido o pagos, el call_id no basta como idempotencia de negocio. Guarda en una base duradera una clave como refund:{order_id}:{amount}:{workflow_id}, confirma el cambio y reutiliza el resultado confirmado en un retry. El Map del ejemplo solo protege un proceso.
Paralelismo: procesa todas las llamadas o desactívalo
Responses puede devolver varios elementos function_call. No unas resultado y solicitud por posición; la correlación correcta usa el call_id original.
Para la primera migración, parallel_tool_calls: false reduce variables. Cuando lo habilites:
- recopila todos los elementos cuyo
typeseafunction_call; - valida cada llamada de forma independiente;
- limita la concurrencia hacia servicios internos;
- ejecuta lecturas independientes en paralelo y serializa escrituras que compitan;
- devuelve exactamente un resultado por llamada aceptada;
- conserva cada
call_id; - espera a completar el lote antes de pedir la siguiente Response.
Una prueba negativa útil inyecta dos llamadas y un elemento que no sea función. Solo pasa si ambas llamadas se ejecutan una vez, los dos resultados conservan el ID correcto y el tercer elemento nunca llega al dispatcher.
Elige una estrategia de estado
La guía de estado de conversación documenta tres rutas. Ninguna es universalmente mejor.
| Estrategia | Qué envía la siguiente petición | Cuándo encaja | Riesgo principal |
|---|---|---|---|
| Historial manual | Input anterior, elementos relevantes de salida y resultados | Necesitas portabilidad, recorte explícito o store: false | Tu código debe conservar orden y elementos requeridos |
previous_response_id | Entrada nueva más el ID anterior | Quieres payload pequeño y aceptas estado encadenado | Las instructions de nivel superior no se heredan; hay que reenviarlas |
| Conversation | Identificador de conversación persistente | Varios procesos necesitan estado duradero compartido | Retención, permisos, borrado y recuperación pasan a ser decisiones de arquitectura |
No combines previous_response_id y Conversation en la misma petición. Antes de elegir estado alojado, revisa los requisitos de retención de tu organización: Responses se almacena por defecto salvo store: false, y el input anterior de una cadena sigue contando como tokens de entrada facturados. Son detalles operativos volátiles y deben confirmarse en la documentación oficial al desplegar.
El historial manual ofrece la mejor observabilidad durante la primera migración. Registra tipos e IDs, no datos sensibles. Después de demostrar paridad, cambia de estrategia solo si sus límites de recuperación y retención encajan.
Streaming: sustituye el reducer de eventos
Un consumidor de Chat Completions suele concatenar choices[].delta.content. Ese código no entiende los eventos tipados de Responses.
Para argumentos de funciones, procesa los eventos actuales indicados por OpenAI:
response.output_item.addedpara registrar el elemento;response.function_call_arguments.deltapara acumular JSON parcial;response.function_call_arguments.donepara cerrar los argumentos;- eventos de finalización y error para cerrar la traza.
Nunca ejecutes una función al recibir un delta. Acumula por identidad del elemento o llamada, espera a .done, parsea el JSON completo y pasa la llamada por el mismo validador, allowlist, control de idempotencia y correlación por call_id que la ruta no streaming.
Los fixtures deben cortar JSON en lugares incómodos: dentro de una cadena, después de una barra invertida y entre caracteres UTF-8. Ver texto completo en pantalla no demuestra que los argumentos hayan terminado.
Fallos observables y corrección
| Síntoma | Ruptura probable | Primera comprobación | Corrección segura |
|---|---|---|---|
| No aparece ninguna llamada | Herramienta ausente, schema incompatible o input insuficiente | Tipos de salida y nombres enviados | Revisa input y definición; no ejecutes nada por tu cuenta |
| La petición rechaza el schema | Forma anidada antigua o error de strict | JSON exacto enviado | Aplana la función y declara los requisitos |
| La función corre, pero no hay respuesta final | El bucle se detuvo tras ejecutar | Segunda llamada a responses.create | Añade el output y continúa |
| Error de llamada no encontrada | call_id incorrecto o ausente | IDs de solicitud y resultado | Copia el ID exacto |
| El efecto ocurre dos veces | Un retry repite la mutación | Ledger duradero | Devuelve el resultado confirmado |
| Falta una llamada de un lote | Solo se leyó output[0] | Número de items tipados | Procesa el lote completo |
| Se pierde contexto o política | Estrategia de estado inconsistente | Input e instrucciones siguientes | Conserva items y reenvía instrucciones |
| Hay texto en streaming, pero falla la herramienta | Se reutilizó el parser de deltas de Chat | Nombres de eventos y argumentos finales | Implementa un reducer tipado |
Separa en observabilidad el transporte, el modelo, la herramienta y el resultado de negocio. Un 200 OK sin resultado correlacionado es un fallo de aplicación.
Checklist de aceptación en staging
No publiques por una revisión de código o un único happy path:
- La ruta Chat y la ruta Responses reciben la misma entrada normalizada, instrucciones, versión de schema y datos seguros.
- Se registran por tipo todos los elementos de
response.output; nada presupone que el primero sea texto o función. - Los argumentos válidos solo ejecutan un handler permitido.
- JSON inválido, herramienta desconocida e ID incorrecto no provocan una llamada.
- Cada
function_callaceptado tiene exactamente unfunction_call_outputcon elcall_idoriginal. - Un fixture de dos llamadas demuestra que no se pierde ninguna; la configuración declara el paralelismo.
- Los retries no repiten una mutación, incluso después de reiniciar el proceso.
- El estado elegido funciona durante dos turnos e incluye persistencia de instrucciones y recuperación.
- Streaming no ejecuta antes de
response.function_call_arguments.done. - Mocks y snapshots usan elementos tipados, no supuestos sobre
choices[0]. - La respuesta final existe, se basa en el resultado y mantiene el efecto de negocio de la línea base.
- Los logs excluyen secretos y payloads sensibles, pero conservan response ID, call ID, herramienta, versión, duración y clase de resultado.
- Un feature flag permite volver a Chat Completions sin repetir un efecto ya confirmado.
Detén el despliegue ante un call_id sin pareja, una mutación duplicada, una llamada paralela perdida, una regresión de política de estado o un resultado de negocio distinto. Corrige esa capa, repite las pruebas negativas y amplía tráfico de forma gradual.
Fuentes y límites de proveedor
Usa la documentación actual de OpenAI como owner del contrato:
- Migrar a Responses API
- Llamada a funciones
- Estado de conversación
- Resumen oficial en español sobre llamada a funciones
Azure OpenAI y los gateways «compatibles con OpenAI» pueden cambiar endpoint, autenticación, nombre de despliegue, estado, retención, streaming o herramientas. Prueba el bucle exacto de cada proveedor: una forma HTTP compatible o un 200 no demuestra semántica equivalente.
Si todavía falla la petición básica o la autenticación, resuélvelo aparte con la guía de clave de OpenAI API. Si aún decides entre invocar una función o pedir datos ajustados a un esquema, consulta Structured Outputs frente a function calling antes de migrar el bucle.
Tu siguiente paso es concreto: ejecuta la consulta inocua del pedido en staging, guarda las trazas antigua y nueva, confirma una sola ejecución y una respuesta final, y activa Responses para una fracción pequeña de tráfico detrás de un flag reversible.



