← Todas las entradas

Migrar sin tocar el SDK

20 ago 2026 · 6 min de lectura · migration, openai-compatible, streaming

"Compatible con OpenAI" se usa con mucha soltura y casi nunca significa lo mismo. En la práctica hay tres niveles: el que acepta el mismo JSON pero devuelve otra forma, el que devuelve la forma correcta pero rompe en streaming o en tool calls, y el que un SDK sin modificar no distingue del original. unbleep apunta al tercero: POST /v1/chat/completions con el esquema de OpenAI en la request y en la response, incluidos los eventos SSE y la envoltura de errores.

Lo que sigue es la migración completa, con lo que cambia y —sobre todo— lo que no.

Los dos cambios

python
from openai import OpenAI

client = OpenAI(
    base_url="https://unbleep.ai/v1",   # was: the default OpenAI endpoint
    api_key="ub_live_9f2c...",          # keys are prefixed: ub_live_ / ub_test_
)

resp = client.chat.completions.create(
    model="unbleep",
    messages=[
        {"role": "system", "content": "You are a security analyst. Be terse."},
        {"role": "user", "content": "Summarise this advisory in five bullets."},
    ],
    temperature=0.2,
    max_tokens=600,
)
print(resp.choices[0].message.content)

Eso es todo. El resto de tu código —reintentos, timeouts, parsing, tipos del SDK, la capa de observabilidad que ya escribiste— sigue igual, porque choices[0].message.content, finish_reason y usage conservan sus nombres y su semántica.

Si prefieres no tocar el código, el SDK lee ambos valores del entorno, así que basta con un cambio de configuración:

bash
export OPENAI_BASE_URL="https://unbleep.ai/v1"
export OPENAI_API_KEY="ub_live_9f2c..."

Ese mismo par cubre buena parte del ecosistema construido sobre el SDK —LangChain, LlamaIndex, Instructor, el Vercel AI SDK, cualquier cosa que exponga un ajuste de base URL.

Las keys llevan prefijo a propósito: ub_live_ y ub_test_ facturan igual contra tu crédito prepago, a la misma tarifa por token. La de test es solo una credencial aparte y revocable, con un techo más bajo —15 peticiones/minuto en vez de las 60 de la cuenta—, no un tier gratuito. El prefijo hace que un secret scanner reconozca una filtración sin reglas a medida.

Y el mismo request en curl, útil para verificar red y credenciales antes de tocar la aplicación:

bash
curl https://unbleep.ai/v1/chat/completions \
  -H "Authorization: Bearer ub_live_9f2c..." \
  -H "Content-Type: application/json" \
  -d '{
    "model": "unbleep",
    "messages": [{"role": "user", "content": "ping"}],
    "max_tokens": 32
  }'

Los modelos

Los IDs son nuestros, no del modelo open-weight que hay debajo. Es deliberado: podemos mejorar los pesos sin que tu código se entere.

El alias sin fecha siempre apunta al build más reciente. Los ids fechados —unbleep-250811, unbleep-high-250811, unbleep-mini-250811— se aceptan, pero hoy resuelven al mismo build que el id sin fecha y la respuesta devuelve el id corto. Son una grafía a prueba de futuro, no una garantía de reproducibilidad: si una evaluación tiene que repetirse, guarda las salidas. GET /v1/models lista lo disponible en el mismo formato que espera tu SDK.

Streaming

Con stream=True la respuesta es text/event-stream: una secuencia de objetos chat.completion.chunk con un delta, cerrada por un literal data: [DONE]. Es exactamente lo que tu cliente ya parsea, así que el bucle no cambia:

python
stream = client.chat.completions.create(
    model="unbleep",
    messages=[{"role": "user", "content": "Walk me through the analysis."}],
    stream=True,
)
for chunk in stream:
    if not chunk.choices:      # el chunk final solo trae usage
        continue
    print(chunk.choices[0].delta.content or "", end="", flush=True)

Dos detalles operativos que ahorran una tarde de depuración. Primero: si tienes un proxy propio delante, desactiva el buffering en esa ruta, o los chunks se acumularán y el streaming dejará de serlo sin ningún error visible. Segundo: los contadores de tokens llegan por defecto —el stream termina con un chunk que trae choices vacío y usage poblado—, así que no hace falta pedirlos; si no los quieres, quítalos con stream_options={"include_usage": False}. Ese chunk final es la razón de comprobar choices antes de indexarlo.

reasoning_content

Esta es la única adición que probablemente quieras usar. Los tiers de razonamiento —unbleep y unbleep-high— piensan antes de responder, y esa traza se expone en un campo aparte, reasoning_content, junto a content. Sigue la convención de DeepSeek, y la normalizamos siempre a ese nombre aunque el backend la llame de otra forma. unbleep-mini no razona: responde directo y no emite el campo.

En streaming el campo llega en el delta, lo que permite mostrar el razonamiento en un panel separado mientras la respuesta se escribe:

python
stream = client.chat.completions.create(
    model="unbleep-high",
    messages=[{"role": "user", "content": "Why does this stack trace point at the wrong frame?"}],
    stream=True,
)
for chunk in stream:
    if not chunk.choices:      # el chunk final solo trae usage
        continue
    delta = chunk.choices[0].delta
    # `reasoning_content` is the chain of thought; keep it out of the user-facing
    # transcript and out of anything you feed back as context.
    if getattr(delta, "reasoning_content", None):
        print(delta.reasoning_content, end="", flush=True)
    if delta.content:
        print(delta.content, end="", flush=True)

Tres cosas que conviene tener claras antes de mandarlo a producción:

usage.completion_tokens_details.reasoning_tokens. Si tu max_tokens es ajustado, el razonamiento se come el presupuesto de la respuesta.

su presupuesto en contestar y no en pensar. reasoning_effort también viaja al modelo si el tier lo soporta.

como mensaje previo degrada el turno siguiente. Guárdalo para auditoría o para tu UI, no para el contexto.

Desde el SDK, el flag viaja en extra_body:

python
resp = client.chat.completions.create(
    model="unbleep",
    messages=[{"role": "user", "content": "phishing or benign?"}],
    max_tokens=4,
    extra_body={"thinking": False},   # spend the budget on the answer, not the trace
)

Con un límite así de corto en un tier de razonamiento, la traza puede gastarse todo el presupuesto y dejarte una respuesta truncada o un content vacío. Presupuesta para las dos cosas, o apaga el razonamiento.

Lo que sí es distinto

Poco, pero conviene saberlo:

cambios. Códigos nuevos: 402 (crédito agotado) y 422 (bloqueado por policy: strict). 429 y 5xx se reintentan con backoff, como siempre.

x-ratelimit-remaining-requests y x-ratelimit-reset-requests vienen en cada respuesta.

top_k, min_p, repetition_penalty, además de tools, response_format, seed). Un campo fuera de la lista se descarta en silencio en lugar de romper la llamada; si un parámetro te importa, verifica su efecto con una llamada de prueba.

Checklist de migración

  1. Cambia base_url y api_key; deja el SDK como está.
  2. Mapea tus nombres de modelo a unbleep / unbleep-high / unbleep-mini; los ids fechados se aceptan pero resuelven al mismo build, así que no los uses como garantía de reproducibilidad.
  3. Corre tu suite con una ub_test_ antes de mover tráfico real.
  4. Agrega 402 y 422 al manejo de errores.
  5. Decide qué haces con reasoning_content: mostrarlo, guardarlo o apagarlo.

El modelo responde sin rechazos reflejos; la responsabilidad por lo que construyes encima sigue siendo tuya, y las líneas están en la política de uso aceptable.

Crea tu cuenta, genera una key de test y haz la primera llamada antes de terminar el café.