Auditamos Open Responses: pasar la suite no es cumplir la spec
Open Responses es la spec abierta y multi-provider construida sobre la Responses API de OpenAI. Ollama, vLLM, OpenRouter y Hugging Face ya la sirven. Leímos sus dos releases, el OpenAPI y la suite de compliance de 17 tests, corrimos la suite contra Ollama 0.30.6 y después sondeamos los MUST que la suite no cubre. El servidor pasó los tests HTTP básicos. Después ignoró tool_choice, truncation y previous_response_id, y respondió a cada request con HTTP 200.
Para un gateway, la interfaz es el producto. Los agentes nos hablan a través de una superficie compatible con OpenAI, y esa superficie se está moviendo. Chat Completions se diseñó para chat por turnos. La Responses API se diseñó alrededor de tool calls, razonamiento y estado en streaming. Open Responses es el intento de convertir esa segunda forma en un estándar neutral, para que un cliente describa un request una vez y lo ejecute en cualquier provider.
Esa es exactamente la promesa de un router. Así que la pregunta para nosotros es concreta: si agregamos /v1/responses y lo enrutamos entre upstreams, ¿qué heredamos? ¿Qué garantiza la spec, qué revisa realmente su suite de tests y qué hacen los servidores reales cuando un request sale del camino feliz?
Nuestras fuentes, todas leídas o ejecutadas el 2 de octubre de 2026: el repositorio openresponses/openresponses en 92c12d9, incluyendo los dos releases de la especificación, los OpenAPI fechados, el charter técnico y src/lib/compliance-tests.ts; los docs de compatibilidad con OpenAI de Ollama y un Ollama 0.30.6 local; los docs de vLLM y su código de Responses en 58b3298; los docs de la Responses API de OpenRouter; el post de lanzamiento de Hugging Face; el OpenAPI actual de OpenAI; las specs de transporte de x402 v2; y sondas sin autenticación contra cuatro endpoints alojados, incluido el nuestro.
Qué es Open Responses
Open Responses se lanzó el 15 de enero de 2026, la fecha de su primer release de la spec. Se describe a sí misma como "an open-source specification and ecosystem for building multi-provider, interoperable LLM interfaces based on the OpenAI Responses API". El post de lanzamiento de Hugging Face lo resume así: "Initiated by OpenAI, built by the open source AI community". La spec define un endpoint principal, POST /responses. El segundo release, fechado el 24 de abril de 2026, agregó POST /responses/compact y un modo WebSocket sobre el mismo recurso.
El diseño descansa en cuatro ideas. El agentic loop: un request puede dejar que el modelo llame tools, lea resultados y continúe antes de devolver el control. Los items: la unidad atómica de contexto, como un message, un function_call o un item reasoning, usables como input y como output. El streaming semántico: eventos como response.output_item.added y response.output_text.delta en lugar de fragmentos de texto crudo. Y las máquinas de estado: cada item está in_progress, incomplete o completed, con transiciones definidas.
Las extensiones tienen una regla dura. Los items, eventos y hosted tools específicos de un provider MUST llevar un prefijo del provider, como en openai:web_search_call. Los clientes MUST poder ignorar eventos de extensión desconocidos y aun así reconstruir la respuesta canónica. Es la regla correcta para un router. Permite que cada upstream innove sin romper a los clientes que atendemos.
La gobernanza está por escrito. Un charter técnico pone la spec bajo un Technical Steering Committee cuyos asientos los ocupan personas, no empresas, y dice "No single vendor may control a majority of Core Maintainer seats". El archivo CONTRIBUTING nombra a Steve Coffey, de OpenAI, como Lead Core Maintainer, y a siete core maintainers más: uno más de OpenAI, dos de Hugging Face y uno de cada uno de Databricks, Amazon, Ollama y OpenRouter. El texto de la spec es CC-BY-4.0. El código es Apache 2.0.
La página de inicio muestra doce respaldos: NVIDIA, Vercel, OpenRouter, Hugging Face, LM Studio, Databricks, Red Hat, AWS, Ollama, OpenAI, vLLM y Llama Stack. Anthropic y Google no están entre ellos, aunque la motivación de la propia spec promete requests que corran "on OpenAI, Anthropic, Gemini, or local models".
Un dato estructural más importa después. CONTRIBUTING dice que los archivos fuente del OpenAPI "are copied from OpenAI's first-party API", que deben mantenerse intactos y que los agregados de Open Responses viven en un archivo de parches aparte. En la práctica, el schema es un snapshot filtrado de la API de OpenAI más prosa normativa. Ha habido dos snapshots. El último cambio al repositorio llegó a mediados de julio, cuando la spec ganó URLs versionadas.
Lo que la spec hace bien
Varios de sus MUST son exactamente las garantías que un agente autónomo necesita de un endpoint de inferencia.
allowed_tools separa lo que el modelo puede ver de lo que puede llamar. La lista completa de tools se queda en el contexto, así que el prompt cache sobrevive, mientras el request acota el conjunto invocable:
{
"model": "any-model",
"input": "Send an email to [email protected] with subject 'hi'.",
"tools": [
{ "type": "function", "name": "get_weather", ... },
{ "type": "function", "name": "send_email", ... }
],
"tool_choice": {
"type": "allowed_tools", "mode": "auto",
"tools": [{ "type": "function", "name": "get_weather" }]
}
}
La spec no deja lugar a dudas: "Servers MUST enforce allowed_tools as a hard constraint", y una llamada a una tool fuera de la lista "MUST be rejected or suppressed by the server". La presenta como gobernanza de tools por request para políticas por tenant, roles de usuario y feature flags. Para una plataforma de agentes, eso es un control de seguridad, no una comodidad.
truncation: "disabled" deja que el cliente elija un fallo duro en lugar de una pérdida silenciosa de contexto: "The server MUST NOT truncate any input. If the combined context exceeds the model's maximum context window, the request MUST fail with an error instead of silently dropping content". Un agente que paga por token, o que actúa sobre un contrato largo, quiere justamente eso.
previous_response_id permite continuar sin reenviar la transcripción. Cuando está presente, "the server MUST load both the input and output associated with that prior response" y preservar su orden. Los items de razonamiento tienen tres campos opcionales: content crudo, encrypted_content opaco y un summary apto para usuarios. El post de lanzamiento de Hugging Face destaca que los providers ahora pueden exponer el razonamiento crudo. Los modelos de OpenAI exponían solo resúmenes y contenido cifrado.
Qué revisa la suite de compliance
El repositorio incluye una suite de aceptación, también expuesta como herramienta web en el sitio de la spec. En 92c12d9 tiene 17 tests. Uno de ellos, el test del schema de phase en el output, nunca toca un servidor: su request está anotado "Local schema fixture; no HTTP request is sent". Los otros 16 se dividen en siete tests HTTP básicos (texto simple, system prompt, multi-turn, tool calling, input de imagen, streaming, phase del assistant), siete tests de WebSocket y dos de /responses/compact.
Lee la lista por lo que falta. Ningún test envía allowed_tools. Ninguno envía tool_choice: "none", "required" ni una función forzada. Ninguno envía truncation. Ninguno usa previous_response_id sobre HTTP, ni store. Ninguno verifica el terminador [DONE] del stream, que según la spec el servidor MUST enviar. El manejo de errores se revisa solo en dos casos: un request de compactación sin modelo, que debe devolver 400 o 422, y una respuesta previa inexistente sobre WebSocket.
Y el schema contra el que validan los tests acepta "usage": null. El validador generado dice usage: z.union([usageSchema, z.null()]). Un servidor puede pasar todos los tests HTTP sin reportar nunca lo que consumió un request.
Así que "pasa la suite" certifica la forma. No certifica el comportamiento en ninguno de los MUST de arriba.
La corrimos contra Ollama
Los docs de Ollama dicen que /v1/responses llegó en v0.13.3 y que "Only the non-stateful flavor is supported". Corrimos la suite con Bun contra un Ollama 0.30.6 local, un test a la vez, usando llama3.2:3b fijado a CPU porque la GPU del host estaba ocupada con otras cargas. La velocidad del modelo es irrelevante para estas pruebas. Su capacidad no, y marcamos el único lugar donde importó.
# Suite Open Responses @ 92c12d9 vs Ollama 0.30.6 (llama3.2:3b, CPU), 2 oct 2026
basic-response PASS
system-prompt PASS
multi-turn PASS
tool-calling PASS
streaming-response PASS 21 eventos
assistant-phase PASS
response-output-phase-schema PASS fixture local, no se envía request
image-input FAIL 400, el modelo es solo texto
compact-response FAIL 404 page not found
compact-missing-model FAIL 404 page not found
websocket-* (7 tests) FAIL WebSocket connection failed
Pasaron seis de los dieciséis tests que llegan a un servidor. El patrón es limpio: el núcleo HTTP de la época del lanzamiento funciona, y nada del release de abril. No hay /responses/compact ni modo WebSocket.
El fallo de imagen es del modelo, no del protocolo. Llama 3.2 3B no tiene visión. Pero el error es instructivo. Ollama devolvió un envelope externo cuyo campo message era a su vez un error codificado en JSON del backend de inferencia, con su propio code, message y type. Un cliente que parsea una sola capa ve un string.
Después probamos lo que la suite se salta
Enviamos los requests que la suite nunca envía, contra el mismo servidor. Para la prueba de truncation armamos un segundo alias del modelo con un contexto de 2,048 tokens. El mismo input, medido en el alias por defecto de 65,536 tokens, son 4,833 tokens.
# Sondas fuera de la suite, Ollama 0.30.6, 2 oct 2026
# request HTTP status campo devuelto observado
allowed_tools=[get_weather], prompt email 200 completed tool_choice "auto" llamó send_email, 3 de 3
tool_choice "none" 200 completed tool_choice "auto" llamó send_email, 2 de 2
tool_choice {function: get_weather} 200 completed tool_choice "auto" llamó send_email, 2 de 2
tool_choice "required", sin tool necesaria 200 completed tool_choice "auto" solo texto, sin llamada, 2 de 2
truncation "disabled", input de 4,833 200 completed truncation input_tokens 2,047
tokens en contexto de 2,048 "disabled"
store true 200 completed store false sin error
previous_response_id (real, turno previo) 200 completed null turno previo no cargado
previous_response_id (inexistente) 200 completed null sin error
terminador del stream - - - sin [DONE] tras response.completed
modelo desconocido 404 - - type "not_found_error", code null
Todas las formas de tool_choice se descartaron. Restringido a get_weather, el modelo llamó a send_email en las tres corridas, y el servidor lo devolvió como una respuesta completada normal. Con "none", igual llamó a la tool. La respuesta devolvió "auto" cada vez. Ese eco es el único rastro de lo que pasó.
Truncation fue al revés. La respuesta devolvió "disabled" mientras usage.input_tokens reportaba 2,047 de los 4,833 tokens enviados. Se descartó cerca del 58% del prompt. Aquí el eco mintió y solo el conteo de usage dijo la verdad.
El estado simplemente no estaba. En el primer turno le dimos al modelo un nombre en clave, BLUEFIN. En el segundo, enviado con el ID de esa respuesta como previous_response_id, se lo preguntamos. El servidor devolvió 200 con previous_response_id: null, y el modelo respondió "Nightshade." Un ID que nunca existió también devolvió 200.
Para ser justos con Ollama: sus docs listan previous_response_id y truncation como no soportados, y tool_choice no está entre los campos de request que marcan como soportados. Nada de esto está oculto. El problema es el modo de fallo. Un campo no soportado se acepta, se ignora y se responde con HTTP 200 y status: "completed". Y la suite oficial certifica al servidor de todos modos.
La propia definición de compliance de la spec es "an API that implements this spec directly or is a proper superset of Open Responses". Un servidor que ignora allowed_tools no es un superset. Es un subconjunto que devuelve el mismo status code que un superset.
El resto del ecosistema
vLLM, otro de los respaldos, es explícito en su código sobre una degradación silenciosa. A menos que el operador defina VLLM_ENABLE_RESPONSES_API_STORE=1, store: true se desactiva en silencio: "we opted to implicitly disable store and process the request anyway, as we assume most users do not intend to actually store the response". Con el flag activado, "Messages are kept in memory only", y "Enabling this option will cause a memory leak, as stored messages are never removed from memory until the server terminates". Un previous_response_id desconocido sí devuelve un error de not-found.
OpenRouter eligió la versión honesta de no tener estado. Sus docs dicen: "Requests that set store: true or a non-null previous_response_id are rejected with a 400 error". Su vocabulario de errores es deliberadamente chico: invalid_prompt, rate_limit_exceeded, image_content_policy_violation, server_error. Los docs dicen que context_length_exceeded se colapsa en invalid_prompt, y agregan un campo error_type de nivel superior para recuperar la causa precisa. Su página de docs de Responses también muestra un ejemplo de streaming con response.content_part.delta y response.done, nombres de eventos que la spec no define. Sin una key no pudimos revisar el stream real.
Los envelopes de error tampoco coinciden. Enviamos el mismo request sin autenticación a tres endpoints de Responses alojados. OpenAI respondió 401 con {"error":{"message":...,"type":"invalid_request_error","param":null,"code":null}}. OpenRouter respondió 401 con {"error":{"message":"No cookie auth credentials found","code":401}}, sin type y con un code numérico. El router de Hugging Face respondió 401 con una página HTML. La spec dice que los servidores sin streaming "MUST return data only as application/json", aunque una capa de autenticación delante de la API quizá no cuente.
La spec no es consistente consigo misma en este punto. Su tabla de tipos de error lista invalid_request, not_found, server_error, model_error y too_many_requests. Su propio ejemplo de error usa invalid_request_error con el code model_not_found. Ollama envió not_found_error con code nulo. Un router que quiera hacer failover ante "modelo no encontrado" tiene que reconocer al menos tres grafías.
La capa de cobro no está en la spec
Para un gateway que cobra por llamada, cuatro huecos importan más que la conformidad.
Primero, usage. El objeto Usage de la spec tiene cinco números: tokens de input, de output y totales, más cached_tokens y reasoning_tokens. No hay escrituras de cache. El OpenAPI actual de OpenAI exige cache_write_tokens en el usage de Responses, y Anthropic cobra una escritura de cache de 5 minutos a 1.25 veces la tarifa de input, como cubrimos en el cache decide el precio. El snapshot va detrás de su fuente. No hay campo de costo ni moneda. Y, como vimos, usage puede ser null.
Segundo, errores de pago. La tabla de errores cubre 400, 404, 429 y 500. No hay 402. Sobre HTTP eso está bien. El transporte HTTP de x402 v2 pone todo el protocolo en headers: "All x402 protocol information is communicated through headers (PAYMENT-REQUIRED, PAYMENT-SIGNATURE, PAYMENT-RESPONSE)". El body de Responses nunca necesita enterarse.
El modo WebSocket es distinto. x402 v2 define exactamente tres transportes: HTTP, MCP y A2A. En un socket, el único intercambio HTTP es el request de upgrade. Un servidor puede exigir el pago ahí, pero entonces un solo pago abre una conexión que puede correr turnos response.create secuenciales hasta el límite de 60 minutos de la spec. No hay lugar para un header por turno. El precio por llamada de x402 no sobrevive al transporte. Un socket necesita un saldo prepagado o un esquema de pago a nivel de sesión que todavía no existe.
Tercero, el descubrimiento del precio. Con previous_response_id, el servidor reconstruye el contexto como input previo más output previo más input nuevo. Con truncation en auto, después puede recortarlo. El cliente no conoce su conteo de tokens de input cuando firma. Un pago exact fijo no puede igualar eso; un máximo con liquidación medida sí. Ese es el caso para el scheme upto.
Cuarto, la portabilidad del estado. encrypted_content en los items de razonamiento es opaco, con un "format and cryptographic properties" que son "provider-specific". La compactación devuelve un item compaction que también es un blob cifrado. Los items de extensión "SHOULD be treated as specific to that implementation and not assumed to be portable". Un router que hace failover de un provider a otro en medio de una conversación no puede pasarle al segundo los items cifrados del primero. Las cadenas de fallback de modelos necesitan una regla nueva en cuanto aparecen esos items.
Qué significa para LLM4Agents
Hoy nuestro gateway habla Chat Completions. Un POST /v1/chat/completions sin autenticación devuelve un 402 con un requisito x402 v2 exact: 10,000 unidades atómicas de USDC en Base (eip155:8453), un centavo por llamada. POST /v1/responses devuelve 404, y nuestro OpenAPI público no lo lista. Nuestra función de fallback, un array models de dos o tres slugs, existe solo en Chat Completions.
Open Responses es la siguiente superficie obvia. Hugging Face dice que el objetivo es un formato compartido "practically capable of replacing chat completions". Ollama, vLLM, OpenRouter y el router de Hugging Face ya sirven /v1/responses.
La amenaza es la herencia. Si hacemos proxy de Open Responses de forma ingenua, nuestros clientes reciben el comportamiento que medimos dondequiera que un servidor así esté detrás de nosotros: restricciones de tools descartadas, truncation oculta, estado ignorado, todo con HTTP 200. Nuestra función de fallback convierte el razonamiento cifrado en un error entre providers. previous_response_id convierte un gateway de pagos sin estado en un almacén de transcripciones de clientes. Y el modo WebSocket no encaja en absoluto con x402 por llamada.
La oportunidad es el mismo hecho visto desde el otro lado. Un gateway es el único lugar donde los MUST pueden cumplirse sin importar el upstream. Aplicar allowed_tools en el gateway le da al operador de un agente una garantía que hoy no da ningún servidor de modelos pequeño. Contar tokens antes de reenviar hace que truncation: "disabled" signifique lo que dice. Eso es una razón para enrutar a través de nosotros, no solo una casilla de compatibilidad.
Cómo mantenerse en la frontera
Primero, lanzar un POST /v1/responses sin estado y fallar con honestidad. Rechazar store: true y cualquier previous_response_id con un 400 hasta que realmente guardemos estado, como hace OpenRouter. Nunca responder un campo no soportado con 200.
Segundo, aplicar los MUST en el gateway en lugar de confiar en los upstreams. Validar cada function_call del output contra tool_choice y allowed_tools antes de devolverlo, y ante una violación suprimirlo o devolver un model error, como permite la spec. Contar los tokens de input antes de reenviar. Con truncation: "disabled" y un input demasiado grande, devolver 400. Después de cada llamada, comparar nuestro conteo con el usage.input_tokens del upstream y alertar ante una diferencia.
Tercero, convertir usage en un registro de cobro. Nunca devolver null. Agregar cache_write_tokens, lo que nos mantiene como un superset propio. Llevar el cargo, en unidades atómicas de USDC, y la referencia del settlement x402 en un campo o evento de extensión con prefijo llm4agents:, para que los clientes portables puedan ignorarlo y los nuestros conciliarlo.
Cuarto, fijar el precio según lo que controla el cliente. Usar exact solo cuando todo el input está en el request y el output tiene tope. Usar upto, con el máximo declarado en el 402, siempre que el contexto del lado del servidor pueda crecer.
Quinto, darle al fallback una regla de estado. En cuanto una cadena contiene un item de razonamiento cifrado o de compactación, fijarla a ese provider. Si hay que hacer failover, descartar explícitamente los items cifrados y los de prefijo ajeno, y avisarle al cliente con un evento con prefijo.
Sexto, dejar el modo WebSocket para después y, cuando lo agreguemos, protegerlo con el saldo de una API key con fondos. Mantener x402 sobre HTTP hasta que exista un esquema a nivel de sesión. Tratar el output de /responses/compact como estado atado al provider.
Séptimo, probar los upstreams de forma continua y empujar los arreglos hacia el proyecto. Correr la suite oficial más el set de sondas de arriba contra cada upstream de Responses de forma periódica, y publicar los resultados. Después, abrir propuestas por el proceso del repositorio para tests que cubran allowed_tools, tool_choice, truncation, previous_response_id sobre HTTP y el terminador [DONE], y para cache_write_tokens en Usage. Cómo debe fallar un servidor es lo que esta spec más necesita, y esos tests son el lugar para empezar.
Inferencia que falla en voz alta
Un gateway compatible con OpenAI, pagado por llamada en USDC sobre x402.
Registra tu agente