OctoHub 0.8.0: al proxy le salió una API unificada de medios

Hace un mes, la pregunta que nos hizo construir OctoHub era: tu agente hizo cuarenta llamadas a modelos — ¿qué envió, cuánto costó y qué upstream respondió?

Aquí está la misma pregunta con el dinero cambiado de sitio. Tu agente generó once imágenes iterando sobre un diseño, después un vídeo de nueve segundos, y después leyó el resumen en voz alta con una voz sintética. Hubo cuatro proveedores implicados. Tres de ellos facturan en unidades que no son tokens — segundos de GPU, segundos de vídeo, caracteres. Uno de ellos no te dijo en absoluto lo que costó. Ahora: ¿a qué cliente se lo cobras?

Para los chat completions ya lo habíamos respondido. Para todo lo demás, la respuesta seguía siendo una hoja de cálculo.

OctoHub 0.8.0 cierra el hueco. La generación de imágenes, la generación de vídeo, la síntesis de voz y la transcripción pasan ahora por el mismo proxy, las mismas claves de cliente, las mismas listas de permitidos, el mismo registro de peticiones y la misma columna de coste que /v1/completions. Cinco proveedores — fal, ElevenLabs, Replicate, Runway y OpenRouter — detrás de un único envoltorio JSON.


La división del trabajo que lo hizo pequeño

Esto aterrizó sobre octolib 0.36.1, etiquetada esa misma tarde, que es donde vive de verdad el stack de medios: structs de petición tipados para las cuatro tareas, los adaptadores de proveedor, el ciclo de vida de los jobs, los descriptores de capacidades y una tabla de tarifas de referencia para ponerle precio a lo que los proveedores no tarifan por su cuenta. Cubrimos esa release en la ronda de principios de septiembre.

El lado de OctoHub se deduce de una sola regla: todo lo que va de modelos pertenece a octolib, todo lo que va de tenants pertenece aquí. La gramática de enrutado, los adaptadores, los job handles y el pricing son de octolib. Las claves, las cuotas, la persistencia, las métricas y la API que sale por el cable son de OctoHub.

Esa regla es la razón de que la config no ganara sintaxis nueva. Un alias de medios es un alias de modelo:

[media_models]
"flux" = ["fal:fal-ai/flux/dev", "replicate:black-forest-labs/flux-1.1-pro"]
"veo"  = ["openrouter:google/veo-3.1"]
"tts"  = ["elevenlabs:eleven_flash_v2_5"]

[providers.fal]        # concurrency and rate windows, unchanged
concurrency = 8
requests_per_minute = 60

La misma gramática provider:model, la misma semántica de lista-significa-balanceo-de-carga, el mismo limitador por proveedor. Los medios nunca alimentan tokens_per_minute — los proveedores de medios no reportan tokens —, pero las ventanas se indexan por nombre de proveedor y se comparten con los completions, así que en un proveedor que uses para las dos cosas, un presupuesto de tokens que tu tráfico de chat ya haya gastado le cerrará la puerta a una petición de medios.

Una mala configuración falla al arrancar, no en la primera petición que ya cuesta dinero: un proveedor desconocido, un provider:model malformado, una lista de mirrors vacía o un alias que choca con [models] hacen que el servidor se niegue a arrancar.


Cuatro tareas, un envoltorio

POST /v1/images/generations     generate | edit | inpaint | variation
POST /v1/videos                 text_to_video | image_to_video | reference_to_video | extend | edit
POST /v1/audio/speech
POST /v1/audio/transcriptions
GET  /v1/media/{id}             fetch or advance a job
POST /v1/media/{id}/cancel
GET  /v1/media/models           capabilities, parameters, reference price

Son endpoints de cliente, autenticados exactamente igual que los completions — la misma clave bearer, la misma lista de modelos permitidos por clave, la misma correlación por X-Request-Id:

curl -sX POST http://127.0.0.1:8080/v1/images/generations \
  -H "Authorization: Bearer <client-key>" \
  -d '{"model":"flux","prompt":"a red panda astronaut","count":2,"size":"1024x1024"}'

Toda respuesta — imagen, vídeo, voz, transcripción, terminada o todavía en marcha — es el mismo objeto:

{
  "id": "med_9f3c1e0b…", "object": "media", "task": "text_to_image",
  "status": "succeeded", "model": "fal-ai/flux/dev", "provider": "fal",
  "progress": 1.0,
  "artifacts": [ { "kind": "image", "media_type": "image/png",
                   "source": { "type": "url", "value": "https://…" },
                   "size_bytes": 812345, "expires_at": 1767225600 } ],
  "usage": { "cost": 0.08, "cost_source": "provider", "currency": "USD",},
  "warnings": [], "safety": { "status": "passed",}, "error": null
}

Una sola forma significa un solo camino de código para la persistencia, un solo formato de fila para el log y una sola cosa que parsear en tu cliente. La transcripción es la única tarea cuyo payload no es un artefacto, así que añade un objeto result con text, language, segments y words.

Dos desviaciones deliberadas respecto a la API de OpenAI, dichas de entrada en vez de descubiertas: todo es JSON, nunca multipart/form-data — las entradas binarias son objetos {"type":"url"…} o {"type":"base64"…} — y edit, inpaint y variation de imagen son un campo mode en lugar de rutas aparte. /v1/images/generations toma prestada la ruta de OpenAI y sus nombres model / prompt / size, pero no el formato que va por el cable — el número de imágenes es count, no n, y la respuesta es el envoltorio de arriba en lugar del {"created", "data"} de OpenAI. Un SDK de OpenAI no lo va a parsear; llámalo por HTTP plano o con un wrapper fino.


Un job que sobrevive a la petición que lo arrancó

Esta es la parte que de verdad se diferencia de hacer de proxy para un chat completion, y donde están las decisiones de diseño.

Un completion es una llamada que retorna o falla. Un job de medios compromete dinero upstream en el momento en que el proveedor lo acepta, y después puede correr durante minutos. Un vídeo no es una petición lenta; es una compra seguida de una espera. Todo lo demás sale de tomarse eso en serio:

La fila se escribe antes de la espera, no después. En cuanto acepta un proveedor basado en cola — fal, Replicate, Runway, el vídeo de OpenRouter —, OctoHub persiste el registro y el JobHandle, que no lleva credenciales, y solo entonces se pone a esperar. Un reinicio, un timeout, un cliente que cuelga: ninguno de ellos puede dejar huérfano un job que ya has pagado. El handle está en tu base de datos y el job se puede reanudar desde él. (Los endpoints síncronos de ElevenLabs y de OpenRouter no tienen cola que pueda devolver un handle — hacen el trabajo entero dentro de la llamada de envío, así que no hay ventana en la que interrumpirlos ni nada que reanudar.)

Un 202 no es un fallo. Envía wait: false y recibes el id en cuanto el proveedor acepta. Envía wait: true y supera server.upstream_timeout_secs y recibes ese mismo 202 con status: "queued" o "running". El trabajo remoto continúa; el id está vivo; no se ha perdido nada. Sondea GET /v1/media/{id} cuando estés listo.

Lo que hace avanzar un job es el sondeo — no hay worker en segundo plano. Un no-objetivo deliberado: un worker significaría un planificador, leases y un segundo modo de fallo para los jobs que no espera nadie. La consecuencia está dicha en la documentación en vez de escondida: un job que nunca sondeas se queda en queued y su coste no se registra jamás. Leer un job que ya terminó es gratis y no vuelve a cobrar nunca — una fila terminal se sirve desde la base de datos sin ninguna llamada upstream.

El permiso de proveedor cubre solo el envío. El limitador de concurrencia por proveedor de OctoHub protege la llamada de envío y luego libera el permiso. Un vídeo de cuatro minutos no deja clavada una de tus ocho ranuras de fal durante cuatro minutos. La ranura por tenant, en cambio, se mantiene durante toda la petición, así que el trabajo de medios de un cliente consume el mismo presupuesto que sus completions.

El failover ocurre en el envío, que es donde resulta seguro. Activa server.failover_on_error — desactivado por defecto, igual que para los completions — y un fallo del proveedor en el envío descarta ese candidato y le pasa la petición al siguiente mirror del alias. El fallo cuenta además para la racha de errores de ese proveedor: pon server.provider_error_cooldown_secs (0, desactivado, por defecto) y tres fallos consecutivos del lado del proveedor lo mandan a cooldown, que lo ordena por detrás de los candidatos sanos en vez de bloquearlo. Con los valores por defecto, el fallo vuelve directo a quien llamó. En cualquier caso, una vez que un job está aceptado no hay nada sobre lo que hacer failover — ya está pagado.

Los registros están acotados a la clave que los creó. El id de otro tenant devuelve 404, no 403 — no te toca enterarte de que un id existe.


El problema de los parámetros, y la respuesta honesta

Cada proveedor de medios tiene una idea distinta de qué pinta tiene una petición. fal quiere num_inference_steps y guidance_scale; algunos endpoints llaman text al prompt; Runway vende créditos y piensa en sus propios nombres de modelo. Una API unificada tiene que decidir qué hacer con eso, y hay dos respuestas malas: exponer solo la intersección (inútil), o inventarse una capa de traducción que finja que todo es igual (una mentira, y cara).

La respuesta de OctoHub tiene tres partes:

Un núcleo portable con una sola grafía en todas partesprompt, count, seed, size, duration_secs, negative_prompt, output_format. size acepta "1024x1024" o "16:9"; cualquier otra cosa es un 400. Portable significa un mismo nombre, no soporte universal: Runway no tiene equivalente para count, negative_prompt ni output_format, y el endpoint de vídeo de OpenRouter tampoco, así que con la política estricta por defecto ahí acaban en un 400 en vez de ignorarse en silencio — que es la tercera parte, la de aquí abajo.

Una vía de escape que deja pasar cualquier cosa tal cual, con espacio de nombres por proveedor:

"provider_options": {
  "fal": { "input": { "num_inference_steps": 28, "guidance_scale": 3.5 },
           "field_map": { "prompt": "text" } }
}

field_map remapea un nombre portable a como lo llame de verdad el endpoint — así el prompt portable sigue funcionando contra un endpoint cuyo campo es text. Manda todos los espacios de nombres a la vez cuando un alias abarca varios proveedores; solo se reenvía el del candidato ganador y el resto se descartan, que es lo único que hace usable un alias multiproveedor.

Una política para lo que pasa cuando un parámetro no se puede respetar. unsupported_parameters: "error" (el valor por defecto) falla antes de gastar dinero — lo correcto para producción. "warn_and_drop" lo descarta y devuelve una advertencia — útil cuando un alias se abre en abanico por proveedores con soporte desigual. Eliges por petición, porque solo tú sabes cuál de las dos querías decir.

Y GET /v1/media/models te dice cuál es cuál antes de que gastes nada: los flags de capacidad de ejecución y de parámetros de cada candidato configurado, los límites, el JSON Schema de provider_options de su propio adaptador y el precio de referencia. Dos asperezas de la 0.8.0, que ibas a encontrar de todas formas: el descubrimiento sondea todos los candidatos a través del adaptador de imagen, así que el campo tasks dice ["text_to_image"] incluso para un alias de vídeo, y un candidato elevenlabs no tiene adaptador de imagen ninguno — vuelve con un precio y un descriptor a null. Muchos campos de capacidades dicen honestamente unknown — los adaptadores no pueden conocer el esquema de cada endpoint, y decirlo es mejor que una respuesta equivocada dicha con seguridad. Justo para eso existe la vía de escape.


Un coste que se niega a adivinar

Esta es la funcionalidad de la que va de verdad la release, y el punto en el que fuimos más tercos.

usage.cost es el número que se factura. usage.cost_source dice de dónde salió:

cost_source Qué significa
provider El upstream devolvió dólares de verdad. OpenRouter y Replicate lo hacen.
estimate Calculado en local a partir de la tabla de tarifas de referencia de octolib.
unavailable Nada pudo ponerle precio — cost es null.

null no es cero. Una petición a la que nada pudo ponerle precio se registra como sin precio, nunca como gratis. Aparece en octohub_media_cost_unknown_total y lleva una advertencia cost_unavailable, en lugar de rebajar en silencio tu gasto total y hacer que un panel parezca mejor que la realidad.

Las estimaciones vienen de la tabla de referencia de octolib, que entiende de unidades porque los proveedores también: ElevenLabs factura caracteres, Runway vende segundos de vídeo convertidos a partir de créditos, fal recurre al tiempo de reloj de GPU porque es la única cantidad que reportan sus métricas de cola. Donde una tarifa sería una suposición, no hay tarifa — los modelos de comunidad de Replicate facturan segundos de GPU contra una clase de GPU desconocida, así que no resuelven a nada y se quedan sin precio en vez de acabar sellados con un número plausible.

El hueco conocido, dicho en voz alta: la transcripción de ElevenLabs se queda sin precio. Scribe factura la duración del audio de entrada, que no se devuelve en la respuesta, y ninguna tarifa de referencia la cubre. La transcripción en los demás proveedores sí recibe un número — fal cae en su cajón de sastre por segundo de GPU, y Replicate y OpenRouter le ponen precio a partir del importe en dólares que reporte el upstream. Cuando podamos ponerle precio a Scribe, lo haremos; hasta entonces es unavailable, no 0.00.

Del lado agregado, GET /v1/admin/usage gana media_count y total_cost — que ahora suma completions, embeddings y medios en el único número que de verdad pondrías en una factura. GET /v1/admin/media lista registros individuales con los mismos filtros que los otros dos, los más nuevos primero, incluidos los jobs en vuelo con un estado no terminal y un completed_at a null. Un job es su registro en un estado anterior; no hay una cola aparte que inspeccionar.

Prometheus, por task, model y provider — más una etiqueta api_key_id en el contador de peticiones cuando metrics.per_key está activo. Los contadores de coste siguen sin etiqueta por clave; el gasto por tenant sale de GET /v1/admin/usage, donde es exacto en vez de muestreado:

octohub_media_requests_total{task,model,provider,status}
octohub_media_duration_seconds{task,model,provider}
octohub_media_cost_microusd_total{task,model,provider,source}
octohub_media_cost_unknown_total{task,model,provider}

Los costes se cuentan en micro-USD porque un contador de dólares para imágenes que cuestan $0.003 es un generador de errores de redondeo. A propósito no hay un gauge de jobs pendientes: uno preciso tendría que contar las filas no terminales de la base de datos, y un contador en proceso estaría mal en el momento en que otra réplica sondee un job o el job sobreviva a un reinicio.


A qué dijimos que no

Las funcionalidades que no están aquí sostienen tanto como las que sí:

OctoHub no se convierte en un almacén de blobs. No hay object store, ni CDN, ni ciclo de vida de artefactos que gestionar, y nunca va a buscar un artefacto por ti. Un proveedor que responde con una URL se guarda como URL — la fila lleva un enlace y metadatos, nada más, y eso es la mayor parte del tráfico. Un proveedor que responde con el payload en sí es la excepción que tienes que dimensionar: la voz de ElevenLabs siempre lo hace, y fal, Replicate u OpenRouter a veces. Esos bytes van en base64 dentro de la respuesta, y ese mismo base64 se persiste en la columna result del registro — porque esa fila es exactamente lo que reproduce un GET /v1/media/{id} posterior sin tocar el upstream. Un MP3 en la tabla media ocupa alrededor de 4/3 de su propio tamaño, así que un despliegue con mucho texto a voz debería contar con una tabla que es mitad libro de cuentas, mitad mediateca.

Las rutas de archivo que envía el cliente se rechazan. El MediaSource de octolib soporta file, provider_file y object_storage; los tres devuelven 400 aquí. Una ruta dentro de una petición a un servidor es una petición para leer el sistema de archivos del servidor — un agujero SSRF/LFI, no una funcionalidad. Al base64 inline se le comprueba el tamaño contra media.max_source_bytes (20 MiB por defecto) antes de que nada llegue a un proveedor, y el payload nunca aterriza en la base de datos: la petición almacenada guarda la forma y el número de bytes, no los bytes.

Las credenciales de upstream se quedan en el servidor. Las claves de proveedor vienen del entorno del servidor (FAL_API_KEY, ELEVENLABS_API_KEY y compañía) y no se aceptan nunca desde un cliente. provider_options.<provider>.cost_estimate se rechaza de plano — el precio se resuelve en el servidor, y un cliente no es quién para decirte cuánto te debe.

Tampoco están en la 0.8.0: el TTS en streaming y multipart/form-data. Los dos son aditivos el día que alguien los necesite de verdad — aunque el streaming necesitará antes su propia respuesta para el coste, porque el camino de voz en streaming de octolib no reporta usage en absoluto.


Actualizar

La tabla media se crea al arrancar junto a las demás, en SQLite, MySQL y PostgreSQL — sin paso de migración. La configuración existente sigue funcionando: si no añades ningún [media_models], los nuevos endpoints simplemente no tienen nada que enrutar y el resto del proxy se comporta exactamente igual que en la 0.7.

# Linux x86_64, static musl build
curl -fsSL https://github.com/Muvon/octohub/releases/download/0.8.0/octohub-0.8.0-x86_64-unknown-linux-musl.tar.gz | tar xz
./octohub

Se publican binarios para seis targets (Linux musl, macOS, Windows — x86_64 y ARM64), o cargo build --release desde fuente.

La release está marcada como breaking por una sola razón: el trait Storage ganó métodos de medios. Eso solo importa si mantienes tu propio backend de almacenamiento contra las tripas de OctoHub; si ejecutas el binario, no hay nada que cambiar. Una cosa más pequeña que conviene saber: desde la 0.7.12, OctoHub reenvía las cabeceras de atribución de punta a punta, y a partir de este ciclo se identifica ante el upstream como Octohub/<version> en vez de con el valor por defecto genérico de octolib — así que los paneles del lado del proveedor nombran al proxy que hizo la llamada.


El sentido de todo esto

La razón para poner un proxy delante de tus modelos nunca fue el enrutado. Fue que un único lugar por el que pasa cada petición es el único lugar donde existe la verdad completa. Ese argumento no se debilita cuando las peticiones empiezan a devolver píxeles y audio en vez de tokens — se refuerza, porque los medios son donde el coste por petición deja de ser un error de redondeo y pasa a ser la factura.

A partir de la 0.8.0, la respuesta a «qué cliente generó este vídeo, en qué proveedor y cuánto costó» es una fila en tu propia base de datos, al lado de los chat completions, en la misma moneda, con una columna que reconoce cuándo no lo sabe nadie.

— Don

OctoHub es código abierto bajo Apache-2.0, desarrollado por Muvon Un Limited. Consíguelo en GitHub — issues y pull requests bienvenidos. Documentación completa de medios: doc/11-media.md.