Desarrollo IA

API Decisions de OpenAI: Respuestas Tipadas, No Texto

14 de octubre de 2026
11 min de lectura
Cambio de vías en miniatura donde un carril de acero se divide en tres, con luz suave y neutra
Compartir:

El 6 de octubre de 2026, OpenAI puso la Decisions API en beta pública: un endpoint POST /v1/decisions que lee texto, imágenes o ambos y devuelve respuestas tipadas — una probabilidad, una elección entre tus opciones o un score numérico — en lugar de texto generado. La guía de OpenAI dice que es unas 10 veces más rápida que la Responses API.

Cinco días antes, Cloudflare lanzó Clef, modelos de decisión de pesos abiertos bajo Apache 2.0, así que ahora hay una contraparte abierta. No llamé a la API para este post: lo que sigue es lo que dicen los docs, más el harness que usaría para medir lo que no dicen.

1. Los hechos del lanzamiento

El changelog de la API de OpenAI registra el release el 6 de octubre de 2026 con la línea “Turn text and images into typed answers 10x faster than the Responses API.” La guía marca el endpoint como beta pública, espera disponibilidad general “en las próximas semanas” y nombra a gpt-6-luna como el único modelo soportado.

En la página de modelos de OpenAI, gpt-6-luna se describe como su modelo más eficiente para tareas enfocadas de alto volumen, con Chat Completions, Responses y Batch como endpoints listados. /v1/decisions no está en esa lista, así que no daría por hecho que los precios o rate limits de chat se heredan acá.

Ficha técnica

  • • Estado: beta pública desde el 6-oct-2026 (changelog de OpenAI); GA “en las próximas semanas” según la guía
  • • Endpoint: POST /v1/decisions · modelo: gpt-6-luna, el único valor soportado
  • • Preguntas: predicate (probabilidad), choice (uno de tus valores + probabilidades + confidence), score (índice de nivel ponderado por probabilidad)
  • • Precio: $0.10 por 1M de tokens de input; sin cargo por output, lecturas ni escrituras de caché; aplican multiplicadores por procesamiento regional y contexto largo
  • • Input: texto, o texto + imágenes como data URLs base64 inline; no hay output de texto libre
  • • Datos: Zero Data Retention y HIPAA para clientes elegibles; residencia en EE. UU. y EEE + Suiza

2. Qué cambia frente a chat + Structured Outputs

Structured Outputs y function calling hacen que un modelo de chat escriba JSON que cumple tu schema o los argumentos de una tool. La guía de OpenAI contrasta Decisions con ambos: declarás las preguntas, el endpoint puntúa tus opciones y recibís respuestas tipadas con probabilidades. No hay generación de texto, ni tool calls, ni traza de razonamiento.

Mi lectura, no la de OpenAI: la ganancia es arquitectónica. Una llamada de chat decodifica token a token y cobra el output; una llamada de decisión lee el input una vez, responde todas las preguntas contra él y cobra solo el input. Si tu paso es “cuál de estas N cosas es”, estabas pagando un párrafo que después tirabas. Si leíste mis posts de Jev, la forma te va a sonar conocida (sí/no, choice y score sobre un input compartido, con Jev llamando noul al tipo sí/no), pero el vocabulario es parecido, no idéntico, y los proveedores, límites y contratos difieren, así que no los trataría como intercambiables.

Los tres tipos de pregunta

⚖️

predicate

Una probabilidad de que una afirmación sobre el input sea verdadera. La respuesta trae solo probability, sin campo confidence.

🧭

choice

Elegí uno de 2–255 valores únicos que definís (strings o booleanos). Devuelve la elección, una probabilidad por opción y una confidence.

📊

score

Niveles ordenados, del más bajo al más alto. Devuelve el promedio de los índices de nivel ponderado por probabilidad (desde 0), más la distribución y una confidence.

Dónde le gana a chat + structured outputs

  • • Espacio de respuestas cerrado: routing, triage, compuertas de moderación, filtros de relevancia, chequeos sí/no de políticas
  • • Alto volumen, inputs cortos: solo se cobran tokens de input, y OpenAI declara ~10x menos latencia que Responses
  • • Umbrales: recibís probabilidades para ramificar, no una etiqueta pelada ni un número que el modelo escribió como texto
  • • Varios juicios sobre un mismo input en una sola request (hasta 200 preguntas según la API reference)
  • • Un choice solo puede ser uno de tus valores: garantía de tipo, aunque no de corrección

Dónde no

  • • Todo lo que deba explicar, redactar, planificar, llamar tools o razonar: no hay output de texto libre
  • • La extracción de campos (nombres, montos, fechas) sigue siendo Structured Outputs; la aritmética exacta y la lógica de fechas van en código
  • • Espacio de respuestas abierto o schemas variables: si no podés enumerar opciones, no hay nada que puntuar
  • • Determinismo, calibración, rate limits del endpoint y latencia absoluta no están documentados en la guía, así que te toca medirlos
  • • Un proveedor, un model id, un contrato beta: la portabilidad es tu trabajo

Regla de oro

Si podés listar las respuestas válidas antes de ver el input, usá un endpoint de decisiones. Si no podés, necesitás un modelo que escriba. Los umbrales, permisos y efectos secundarios quedan en código en ambos casos.

3. Quickstart: routing de tickets en una llamada

Mismo ticket, tres preguntas: si el cliente pide un reembolso (predicate), qué equipo se encarga (choice, con una opción other como recomienda la guía) y qué tan urgente es (score). Las formas de request y response siguen la guía y la API reference; los nombres de preguntas y niveles son míos.

Request: tres preguntas sobre un input

HTTP plano con el body documentado: model, input, questions. Seteá OPENAI_API_KEY. No corrí esto.

import os
import requests

URL = "https://api.openai.com/v1/decisions"
HEADERS = {
    "Authorization": "Bearer " + os.environ["OPENAI_API_KEY"],
    "Content-Type": "application/json",
}

QUESTIONS = [
    {"type": "predicate", "name": "refund_requested",
     "instructions": "Does the customer explicitly ask for a refund?"},
    {"type": "choice", "name": "team",
     "instructions": "Which team should handle this ticket?",
     "choices": [
         {"value": "billing", "description": "Payments, invoices, refunds."},
         {"value": "technical", "description": "Bugs, errors, outages."},
         {"value": "shipping", "description": "Delivery and damaged items."},
         {"value": "other", "description": "None of the above fits."},
     ]},
    {"type": "score", "name": "urgency",
     "instructions": "How urgent is this ticket?",
     "levels": [
         {"label": "Can wait", "description": "No time pressure."},
         {"label": "This week", "description": "Needs a reply soon."},
         {"label": "Today", "description": "Customer is blocked now."},
     ]},
]

def decide(text, timeout=10):
    body = {"model": "gpt-6-luna", "input": text, "questions": QUESTIONS}
    r = requests.post(URL, headers=HEADERS, json=body, timeout=timeout)
    r.raise_for_status()
    return {a["name"]: a for a in r.json()["answers"]}

Forma de la response (un boceto, no output real)

Las respuestas vienen en el orden de las preguntas. Todos los valores de abajo son placeholders, no resultados medidos.

{
  "answers": [          // same order as your questions
    { "type": "predicate", "name": "refund_requested", "probability": <0..1> },
    { "type": "choice", "name": "team", "choice": "<one of your values>",
      "confidence": <0..1>,
      "probabilities": [ { "value": "billing", "probability": <0..1> }, ... ] },
    { "type": "score", "name": "urgency", "score": <weighted level index, from 0>,
      "confidence": <0..1>,
      "probabilities": [ { "value": 0, "label": "Can wait", "probability": <0..1> }, ... ] }
    // any question may instead return { "type": "refusal", "name": "..." }
  ],
  "model": "<string>",
  "usage": { "input_tokens": <n>, "output_tokens": <n>, "total_tokens": <n>, ... }
}

Policy gate en tu código

Los umbrales son placeholders hasta que la sección 4 los fije con tus datos etiquetados.

def route(text, t_refund=0.85, t_team=0.80, t_urgent=1.5):
    a = decide(text)
    refund, team, urgency = a["refund_requested"], a["team"], a["urgency"]
    if "refusal" in (refund["type"], team["type"], urgency["type"]):
        return "human_review"            # a question can be refused
    if team["choice"] == "other" or team["confidence"] < t_team:
        return "human_review"            # closed set did not fit, or unsure
    if refund["probability"] >= t_refund:
        return "refund_checks"           # code verifies amount, identity, limits
    if urgency["score"] >= t_urgent:
        return team["choice"] + ":priority"
    return team["choice"]

Dos detalles de la reference que conviene cablear desde el día uno: una pregunta puede volver con type refusal mientras las demás se responden normal, y los nombres son opcionales, así que nombrá todas las preguntas y asociá respuestas por nombre, no por posición.

Cuenta de servilleta del costo

El costo es tokens de input × $0.10 / 1M. Si un ticket más las definiciones de las preguntas pesa unos 500 tokens de input (supuesto mío), son $0.00005 por ticket, unos $50 por millón de tickets antes de multiplicadores regionales o de contexto largo. Leé usage.input_tokens en responses reales antes de confiar en esa estimación.

4. Evaluala antes de confiar

La guía de OpenAI dice que fijes los umbrales de routing, filtrado o revisión con ejemplos etiquetados de tu propia aplicación, pesando falsos positivos contra falsos negativos. No publica ninguna comparación de accuracy contra la Responses API, así que la evaluación es tuya: unos cientos de tickets etiquetados, un set de preguntas congelado y las mediciones de abajo.

Corré el mismo set etiquetado por cada candidato (baseline de reglas, tu llamada actual de chat + Structured Outputs, este endpoint y Clef si querés una opción abierta). Compará precision y recall por clase en tu umbral operativo, calibración, latencia p50/p95 y volumen de revisión. El accuracy solo esconde las fallas que cuestan plata.

Matriz de confusión y latencia

JSONL etiquetado de entrada, precision y recall por clase de salida. Las refusals cuentan como errores, que es como deberías tratarlas.

import json
import time
from collections import Counter, defaultdict

def collect(path):
    rows, lat = [], []
    for line in open(path, encoding="utf-8"):
        ex = json.loads(line)  # {"text": ..., "team": ..., "refund": true/false}
        t0 = time.perf_counter()
        rows.append((ex, decide(ex["text"])))
        lat.append((time.perf_counter() - t0) * 1000)
    lat.sort()
    print("p50 ms", lat[len(lat) // 2], "p95 ms", lat[int(len(lat) * 0.95)])
    return rows

def confusion(rows, labels):
    m = defaultdict(Counter)
    for ex, a in rows:
        t = a["team"]
        m[ex["team"]][t["choice"] if t["type"] == "choice" else "REFUSAL"] += 1
    for gold in labels:
        tp = m[gold][gold]
        fn = sum(m[gold].values()) - tp
        fp = sum(m[g][gold] for g in labels if g != gold)
        p = tp / (tp + fp) if tp + fp else 0.0
        r = tp / (tp + fn) if tp + fn else 0.0
        print(f"{gold:10s} precision={p:.2f} recall={r:.2f} n={tp + fn}")
    return m

Barrido de umbral y error de calibración

Elegí el umbral más bajo que cumpla tu precision objetivo en una mitad y reportá en la mitad reservada.

def sweep(rows, target_precision=0.95):
    best = None
    for k in range(1, 20):
        t = k / 20
        tp = fp = fn = 0
        for ex, a in rows:
            q = a["refund_requested"]
            hit = q["type"] == "predicate" and q["probability"] >= t
            tp += hit and ex["refund"]
            fp += hit and not ex["refund"]
            fn += (not hit) and ex["refund"]
        p = tp / (tp + fp) if tp + fp else 1.0
        r = tp / (tp + fn) if tp + fn else 0.0
        print(f"t={t:.2f} precision={p:.2f} recall={r:.2f}")
        if best is None and p >= target_precision:
            best = t
    return best  # lowest threshold that meets your precision target

def ece(pairs, bins=10):  # pairs: (stated probability, was it right?)
    buckets = [[] for _ in range(bins)]
    for conf, ok in pairs:
        buckets[min(int(conf * bins), bins - 1)].append((conf, ok))
    total = 0.0
    for b in buckets:
        if b:
            gap = abs(sum(c for c, _ in b) / len(b) - sum(o for _, o in b) / len(b))
            total += len(b) / len(pairs) * gap
    return total

# pairs for the choice question, built from rows:
# (max(p["probability"] for p in a["team"]["probabilities"]), a["team"]["choice"] == ex["team"])

Checklist de medición

  1. 1. Partí el set etiquetado: afiná umbrales en una mitad y reportá solo en la otra.
  2. 2. Leé la matriz de confusión de team, incluyendo la clase other y las refusals; ahí se esconden los errores de routing.
  3. 3. Barré el umbral de reembolso y elegí el punto según el costo de un reembolso errado frente a uno perdido.
  4. 4. Calculá el error de calibración sobre la probabilidad de la opción top y sobre confidence por separado; ambos se devuelven y no son el mismo número.
  5. 5. Mandá cada ejemplo dos veces y compará las respuestas: la guía no documenta ni determinismo ni calibración, así que medí ambos.

No te saltees el baseline

Un set de reglas por keywords más un clasificador supervisado chico suele igualar a un juez hosteado en categorías angostas y estables. Si el baseline de reglas entra en tu presupuesto de error, el endpoint es un costo, no una mejora.

5. Límites de la beta, fallbacks y la contraparte abierta

Beta significa que el contrato puede moverse. La guía espera GA en las próximas semanas, el model id es un único valor y no encontré ningún rate limit documentado para este endpoint. La página de gpt-6-luna lista RPM y TPM por tier, pero no incluye /v1/decisions entre sus endpoints, así que confirmá tu propia página de límites antes de un test de carga.

Límites honestos

  • • Beta: formas y límites pueden cambiar antes de GA; fijá tu cliente y logueá el campo model de la response.
  • • Un solo proveedor, un solo model id: sin pesos abiertos y sin fine-tuning documentado en la guía.
  • • Sin texto libre, o sea sin explicación para mostrarle a un revisor; logueá las probabilidades en su lugar.
  • • Imágenes: la guía dice solo base64 inline, mientras la reference de TypeScript también menciona URLs HTTP(S) públicas. Mandaría base64 y esperaría a que GA lo aclare.
  • • Las probabilidades son estimaciones: la guía no declara calibración ni determinismo.
  • • Una pregunta puede ser rechazada, así que tu código necesita una rama de refusal en cada llamada.

Escalera de fallbacks

  1. 1. Timeout corto y reintentos acotados con backoff en 429 y 5xx; respetá Retry-After cuando venga (el help center de OpenAI dice que sus SDKs lo hacen).
  2. 2. Circuit breaker: tras unas pocas fallas, saltá la llamada por un minuto en vez de martillar un endpoint en problemas.
  3. 3. Un modelo de decisión secundario detrás de un adapter: Clef en Workers AI o self-hosted, Jev, o un gateway que los normalice (Vercel documenta un endpoint /decisions compatible con OpenAI con fallbacks por confianza).
  4. 4. Un baseline de reglas determinísticas para los casos baratos y obvios.
  5. 5. Una cola de revisión humana como último escalón: un fallback que bloquea es peor que uno lento.

Reintentos acotados y circuit breaker

Un boceto de los dos primeros escalones. El resto son tus adapters, y quien llama trata None como “bajá por la escalera”.

import random
import time
import requests

RETRYABLE = {429, 500, 502, 503, 504}
breaker = {"fails": 0, "until": 0.0}

def decide_resilient(text, tries=3):
    if time.time() < breaker["until"]:
        return None                      # breaker open: skip the call
    for attempt in range(tries):
        try:
            answers = decide(text, timeout=5)
            breaker["fails"] = 0
            return answers
        except requests.HTTPError as e:
            if e.response.status_code not in RETRYABLE:
                raise                    # 400/401/403: fix the request, do not retry
            wait = e.response.headers.get("Retry-After", "")
            time.sleep(float(wait) if wait.isdigit() else 2 ** attempt + random.random())
        except (requests.Timeout, requests.ConnectionError):
            time.sleep(2 ** attempt + random.random())
    breaker["fails"] += 1
    if breaker["fails"] >= 3:
        breaker.update(fails=0, until=time.time() + 60)
    return None  # caller falls to the next rung: secondary model, rules, human

Cloudflare Clef: la contraparte de pesos abiertos

  • • Lanzado el 1-oct-2026: Clef (más grande) y Clef-flash (enfocado en latencia); pesos en Hugging Face bajo Apache 2.0.
  • • Hosteado en Workers AI como @cf/cloudflare/clef; la página del modelo lista $0.24 por 1M de tokens de input, output sin cargo.
  • • Clef está construido sobre Qwen3.8-27B según el post de Cloudflare, con un encoder de visión y una ventana de 64k.
  • • Request: un state más un objeto questions indexado por id, hasta 64 preguntas y 4 imágenes; no acepta URLs remotas de imagen.
  • • La tabla de latencia de Cloudflare (mediana 209.3 ms para Clef, 38.8 ms para Clef-flash en 43 benchmarks) sale de su propia corrida del Jev Decision Index: es del vendor, así que remedí.

Misma idea, distinto contrato. La página del modelo de Cloudflare llama noul al tipo sí/no, no predicate, y las respuestas vuelven indexadas por id de pregunta, así que es trabajo de adapter, no un cambio de base URL. Cloudflare también dice que no lee, almacena ni entrena con tus requests o responses, salvo clientes que optan por fine-tuning.

Manejo de datos, según lo documentado

La guía dice que la Decisions API soporta Zero Data Retention y uso HIPAA para clientes elegibles, y que residencia de datos y procesamiento regional están disponibles en Estados Unidos y Europa (EEE + Suiza), con primas por procesamiento regional. La elegibilidad y los acuerdos requeridos viven en la guía de data controls de OpenAI, que no reproduje. La reference además acepta un safety_identifier opcional para usuarios finales. Mi consejo, no el de OpenAI: redactá la PII de los tickets que no necesites antes de enviar, como harías con cualquier modelo hosteado.

Veredicto

Tal como está documentada, la Decisions API es una primitiva limpia: tres tipos de pregunta, respuestas tipadas, sin texto que parsear y cobro solo por tokens de input. Para juicios de respuesta cerrada y alto volumen es un endpoint hecho justo para ese trabajo. El 10x es plausible para una llamada que nunca decodifica texto, pero OpenAI no publica latencia absoluta ni cifra de accuracy.

Mi decisión: ponela detrás de un adapter, corré el harness contra tu baseline de reglas y tu llamada actual de Structured Outputs, salí en shadow mode con humanos en la banda incierta y revisá de nuevo cuando llegue GA. Dejá los modelos de chat para todo lo que tenga que escribir, y tratá a Clef o Jev como tu salida de emergencia.

Diego Rodriguez

Diego Rodriguez

Ingeniero Senior Full-Stack & AI

Diego tiene mas de 9 anos de experiencia construyendo aplicaciones potenciadas por IA de produccion, desde orquestacion de LLMs y pipelines RAG hasta deteccion de riesgos con ML y sistemas de trading algoritmico.

Conoce mas sobre Diego →