«Compatible con OpenAI» es la frase más sobrecargada del mercado de infraestructura de IA. Puede significar «puedes apuntar el SDK de OpenAI a nuestra URL y una llamada de chat básica responde» — que es el 80% fácil — o puede significar «cada campo, cada evento de streaming, cada ida y vuelta de tool-call y cada número de usage se comporta tal como tu código ya espera». La distancia entre esas dos cosas es donde viven los incidentes de producción. Este artículo es la guía de campo: qué tiene que coincidir de verdad para que tu código actual funcione sin cambios, qué se comporta de forma idéntica entre upstreams y qué difiere en silencio cuando el modelo detrás de la forma de OpenAI es en realidad Claude (Anthropic) o Gemini (Google) en lugar de GPT.
Brievio es un gateway compatible con OpenAI por delante de los modelos genuinos de primera mano, así que esto está escrito desde el asiento del traductor — la capa que tiene que hacer que la Messages API de Anthropic y la Vertex API de Google salgan ambas por la tubería con forma de OpenAI. Seré concreto sobre dónde la abstracción es limpia y dónde tiene fugas, porque fingir que nunca las tiene es como acabas recibiendo una alerta a las 2 de la madrugada.
Qué tiene que significar «compatible» de verdad
La compatibilidad no es una casilla de marketing; es un contrato con el SDK que ya importaste. Las librerías de OpenAI para Python y Node hacen suposiciones firmes sobre el formato de transporte. Un gateway solo es compatible si las respeta todas:
- El esquema de la petición.
POST /v1/chat/completionsconmodel,messages(una lista de objetos rol/contenido) y los ajustes opcionales —temperature,max_tokens,top_p,stop,tools,response_format. Los parámetros desconocidos deberían aceptarse e ignorarse, no devolver un 400. - La envoltura de la respuesta. Un objeto con
id,object: «chat.completion»,model,choices[](cada uno conmessage,index,finish_reason) y un bloqueusage. Los SDK deserializan a objetos tipados; si falta un campo,resp.choices[0].message.contentfalla en la máquina de otra persona. - El protocolo de streaming. Server-Sent Events con el centinela
data: [DONE]y objetosdeltapor token. Esto es lo más común que los gateways «compatibles» hacen mal de forma sutil. - Formas de error y códigos HTTP. Un 429 tiene que parecer un límite de tasa; un 400 tiene que llevar un objeto
errorcon untypey unmessage. La lógica de reintento y backoff del SDK se apoya en esto.
Aquí está la base — la parte que todo el mundo hace bien. Cambian dos líneas y la llamada devuelve un objeto de completion normal:
# La idea central: cambia dos líneas, conserva tu código.
# Mismo SDK, misma forma de petición, mismo objeto de respuesta — otro modelo detrás.
from openai import OpenAI
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1", # antes https://api.openai.com/v1
)
resp = client.chat.completions.create(
model="claude-sonnet-4-6", # antes gpt-4o
messages=[
{"role": "system", "content": "You are concise."},
{"role": "user", "content": "Summarize the CAP theorem in two sentences."},
],
temperature=0.2,
max_tokens=300,
)
print(resp.choices[0].message.content)
print(resp.usage) # prompt_tokens / completion_tokens / total_tokens — los mismos campos
# resp.id, resp.model, resp.choices[0].finish_reason: todos presentes y con la forma de OpenAI.Si un gateway no puede hacer al menos esto, aléjate. Pero esto es lo mínimo indispensable, no la meta. La pregunta interesante es qué pasa cuando activas las funciones que tu aplicación real usa.
Streaming: donde la compatibilidad tiene fugas sin avisar
El streaming es la función con más probabilidad de estar técnicamente presente y prácticamente rota. El iterador de streaming del SDK espera tres cosas: un content type text/event-stream, deltas que llegan de forma incremental en choices[0].delta.content y una línea literal data: [DONE] para cerrar el stream. Si fallas en cualquiera de ellas, el síntoma es enloquecedor — funciona en tu prueba con curl, se cuelga en producción.
# El streaming es donde la "compatibilidad" ingenua tiene fugas. El contrato del que dependes:
# - Content-Type: text/event-stream
# - cada evento es "data: {json}\n\n", los deltas llegan en choices[0].delta.content
# - el stream termina con un centinela literal "data: [DONE]\n\n"
stream = client.chat.completions.create(
model="gemini-2-5-pro",
messages=[{"role": "user", "content": "Explain B-trees in one paragraph."}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
print(delta.content, end="", flush=True)
# Cosas que rompen a los clientes si un gateway las hace mal:
# - almacenar toda la respuesta y luego soltarla como un solo trozo (no es streaming real)
# - omitir el centinela [DONE] (algunos SDK se quedan colgados esperándolo)
# - usage solo al final — pasa stream_options={"include_usage": True} para obtenerlo.El fallo de «streaming falso» más común es un gateway que llama al upstream, espera la respuesta entera y luego la emite como uno o dos trozos grandes. El SDK no da error — simplemente pierdes todo el sentido del streaming (el tiempo hasta el primer token sigue siendo pésimo). Un gateway de verdad mantiene la conexión abierta con el upstream y reenvía cada token a medida que llega. Para Claude eso significa traducir los eventos content_block_delta de Anthropic a eventos chat.completion.chunk de OpenAI al vuelo; para Gemini, el mismo trabajo contra el formato de streaming de Vertex. La salida se ve idéntica para tu código, pero la maquinaria de debajo hace una traducción real evento por evento.
Una diferencia genuina que conviene conocer: el usage en respuestas en streaming. OpenAI solo incluye el bloque usage en el chunk final si pasas stream_options={«include_usage»: true}. Un buen gateway respeta esa bandera contra cualquier upstream para que tu código de contabilidad de tokens no tenga que tratar el modelo como un caso especial. Consulta el contrato de streaming completo en la documentación de chat completions.
Herramientas y function calling: misma forma, motor distinto
El tool calling es la función donde la abstracción de OpenAI demuestra su valor — porque los tres proveedores tienen formatos nativos completamente distintos, y un gateway lo oculta todo. Tú envías el array tools de OpenAI; recibes de vuelta tool_calls en el mensaje. Lo que ocurre en medio es una traducción real:
# Tool / function calling: el lado de la petición coincide con OpenAI exactamente.
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}]
resp = client.chat.completions.create(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
tools=tools,
tool_choice="auto",
)
# El modelo devuelve choices[0].message.tool_calls — una lista, cada uno con un id,
# .function.name y .function.arguments (un *string* JSON al que debes hacer json.loads).
for call in resp.choices[0].message.tool_calls or []:
print(call.id, call.function.name, call.function.arguments)
# Luego añades un mensaje {"role": "tool", "tool_call_id": call.id, "content": result}
# y vuelves a llamar. Ese bucle es idéntico tanto si el upstream es
# Claude como Gemini — el gateway mapea el formato de herramientas nativo de cada
# proveedor a los tool_calls de OpenAI a la salida, y a la inversa a la entrada.Por debajo, Anthropic devuelve bloques de contenido tool_use con un objeto input; Gemini devuelve partes functionCall con args. El gateway mapea ambos a la forma tool_calls[] de OpenAI — incluido el detalle de que OpenAI entrega arguments como un string JSON al que debes hacer json.loads, no como un objeto ya parseado. Tu bucle de ejecución de herramientas — leer las llamadas, ejecutar las funciones, añadir mensajes role: «tool», volver a llamar — es byte a byte el mismo sea cual sea la familia a la que apuntes. Esa es toda la propuesta de valor: escribe el agente una vez, cambia de modelo con un string.
Las advertencias honestas, porque existen:
- Llamadas a herramientas en paralelo. Las tres familias pueden pedir varias herramientas en un mismo turno, pero difieren en con cuánta agresividad lo hacen para un prompt dado. No supongas que el número o el orden exactos se trasladan entre modelos — maneja una lista, no una cantidad fija.
- Esquemas de herramientas estrictos / estructurados. La aplicación de JSON-schema con
strict: truede OpenAI es una función de los modelos de OpenAI. En Claude y Gemini el gateway pasa tu esquema como la definición de la herramienta y el modelo se ajusta de cerca, pero la garantía es del upstream, no una magia que el gateway pueda fabricar. - Matices de
tool_choice.autoy forzar una función concreta están bien soportados en todas partes; las combinaciones exóticas merecen una prueba rápida en cada modelo que vayas a poner en producción.
Visión y modo JSON: paso directo, con sus bordes
La visión usa el formato de partes de contenido multimodal de OpenAI — una lista que mezcla entradas text e image_url. Contra un modelo que ve imágenes de forma nativa (Gemini 2.5 Pro/Flash, la familia Claude), el gateway reenvía la imagen y la llamada multimodal simplemente funciona. El modo JSON — response_format: { type: «json_object» } — restringe la salida a un objeto parseable:
# Visión: el formato de partes de contenido multimodal de OpenAI, reenviado a un
# modelo que admite imágenes de forma nativa. Funciona tanto con URL como con data URI en base64.
resp = client.chat.completions.create(
model="gemini-2-5-pro",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "What's in this chart? Give me the trend."},
{"type": "image_url", "image_url": {
"url": "https://example.com/q3-revenue.png",
}},
],
}],
)
print(resp.choices[0].message.content)
# Modo JSON — pide un objeto con parseo garantizado:
resp = client.chat.completions.create(
model="claude-sonnet-4-6",
messages=[{"role": "user", "content": "Extract name and email as JSON."}],
response_format={"type": "json_object"},
)
import json
data = json.loads(resp.choices[0].message.content) # parsea, siempre.Dónde están los bordes: los límites de entrada de imagen (dimensiones máximas, número máximo de imágenes por petición, tipos MIME aceptados) los fija cada upstream, no se los inventa el gateway — así que un TIFF de 50 MB que Gemini rechaza también será rechazado detrás de la forma de OpenAI, con un error traducido. Y el modo json_object garantiza JSON válido, no JSON que coincida con tu esquema concreto; si necesitas una estructura particular, descríbela en el prompt y valídala tras el parseo. Esto no son bugs del gateway — es el contrato del modelo subyacente asomando, que es justo lo que quieres que un traductor fiel preserve.
Embeddings y las cosas que de verdad no se trasladan
Dos superficies más que vale la pena nombrar con honestidad. Los embeddings (/v1/embeddings) son simples y estables — pero los vectores no son intercambiables entre modelos. Un embedding de Gemini y uno de OpenAI viven en espacios distintos con dimensionalidad distinta; no puedes mezclarlos en un mismo índice ni comparar sus similitudes de coseno. Elige un modelo de embeddings y vuelve a embeber todo tu corpus si cambias. La API es compatible; las matemáticas no.
Y las fugas que ninguna cantidad de parches de compatibilidad puede tapar — las funciones específicas de cada proveedor que sencillamente no tienen un campo de OpenAI que las transporte:
- Caché de prompts de Anthropic. Los puntos de corte nativos
cache_controlviven en la Messages API de Anthropic. Sobre la forma de OpenAI obtienes en su lugar el caché de prefijo automático al estilo de OpenAI; para controlar el caché de forma explícita usas el endpoint nativo/v1/messages. (Ambos funcionan en Brievio — consulta la documentación de la API). - Los tokenizadores difieren según la familia. «1.000 tokens» no es la misma longitud de cadena entre GPT, Claude y Gemini — cada uno tiene su propio tokenizador. Así que los presupuestos de
max_tokensy tus estimaciones de coste cambian cuando cambias de modelo, aunque el nombre del campo no haya cambiado. Un buen gateway reporta los conteos de tokens honestos de cada upstream enusage; no puede hacer que tres tokenizadores coincidan, y no deberías fiarte de uno que finge que sí. - Pensamiento extendido / razonamiento. El pensamiento extendido de Claude y los modos de pensamiento de Gemini se exponen de forma distinta al razonamiento de OpenAI. El contenido llega; la fontanería exacta de los campos es específica de cada modelo, así que no fijes en duro la forma de razonamiento de un proveedor para todos ellos.
- Semántica del system prompt. Los tres aceptan un mensaje de sistema, pero lo ponderan y lo truncan de forma ligeramente distinta. El comportamiento se traslada; no es idéntico bit a bit. Prueba tus prompts en cada modelo.
Cómo un buen gateway normaliza todo esto
El trabajo de la capa de compatibilidad es ser un traductor fiel y sin pérdidas en el camino habitual, y honesto en los bordes. En concreto, eso significa: mapear el esquema de la petición en ambas direcciones; traducir los eventos de streaming token a token, centinela incluido; convertir el formato de herramientas nativo de cada proveedor a y desde tool_calls; preservar la semántica de finish_reason; pasar imágenes reales a los modelos con capacidad de visión; y — la parte en la que es fácil hacer trampa — reportar los conteos de tokens reales del upstream en lugar de un número inflado. En Brievio los modelos detrás de la forma son los genuinos de primera mano, trazables hasta AWS Bedrock y Google Vertex, así que el comportamiento que estás normalizando es el del modelo real, no el de un sustituto más barato. Si quieres comprobarlo por ti mismo, las cuatro pruebas de ¿es tu Claude realmente Claude? llevan alrededor de un minuto.
De todo esto salen dos principios para cualquiera que construya sobre un endpoint «compatible». Primero, prueba las funciones que de verdad usas — una llamada de chat que pasa no te dice nada sobre si el streaming se vacía de forma incremental ni sobre si los IDs de tool-call van y vuelven bien. Segundo, respeta las fugas: los tokenizadores, los espacios de embeddings, la sintaxis del caché y las formas de razonamiento son propiedades del upstream, y el gateway honesto sobre ellas es el que puedes confiar en producción. La compatibilidad es un espectro, y su parte útil es la que sobrevive a tu carga de trabajo real — no la que sobrevive a una demo.
La conclusión concreta
Apunta el SDK de OpenAI a https://api.brievio.com/v1, cambia el string del modelo y ejecuta tu suite de pruebas actual — no un hello-world, tu suite. Ejercita el streaming con include_usage, haz una ida y vuelta de tool-call, envía una imagen, pide un json_object. Si las cuatro pasan en el modelo que piensas poner en producción, la migración es de verdad de dos líneas. Donde necesites una función específica de un proveedor — caché explícito de Anthropic, controles de razonamiento nativos — baja a los endpoints nativos para ese caso y conserva la forma de OpenAI en todo lo demás. ¿Quieres el paso a paso para portar una base de código de OpenAI existente? Empieza por llamar a Claude con el SDK de OpenAI, y luego repasa la lista de modelos para elegir qué corre detrás de la forma.