Custom LLM para ElevenLabs
Aura se expone como un LLM compatible con OpenAI en POST /chat/completions. Los agentes
de ElevenLabs apuntan ahí su "Custom LLM" y Aura responde con su propio prompt, su propio RAG
y su propio modelo.
Es el reemplazo del WebSocket propietario de Retell: mismo cerebro
(ConversationEngine), otro transporte. Como ChatMessage {role, content} ya es el shape de OpenAI, no hay traducción de datos — sólo de transporte.
Contrato del request
Sólo se leen dos campos. Todo el resto (model, temperature, tools, max_tokens,
elevenlabs_extra_body…) se descarta en silencio, no da error.
| Campo | Uso |
|---|---|
messages[] | Los user/assistant se mapean 1:1 a turns. Los system se descartan |
stream | true → SSE. Ausente o false → un chat.completion JSON normal |
Aura controla el prompt. El system que manda ElevenLabs con el prompt configurado en su
panel se ignora: el mensaje de sistema lo arma el engine con PromptsService
(ver Prompts del agente). Cualquier rol desconocido
(developer, tool) cae por el mismo filtro.
Mapeo al contrato del engine:
Campo de InboundTurn | De dónde sale |
|---|---|
turns | messages[] sin los system |
queryText | Contenido del último mensaje user (nunca el del assistant, para no contaminar la query de RAG con una sugerencia previa) |
lastUserContent | Contenido del último mensaje, sea de quien sea — misma particularidad que Retell: un request de completions siempre pide respuesta a lo último de la lista |
minWords / maxTokens / historyLimit | Del perfil de canal |
systemExtras / ragQueryPrefix / agentId | Sin usar en Fase 2 (ver Limitaciones) |
Si después de filtrar no queda ningún mensaje, la respuesta es un 400 normal — ocurre antes de escribir cualquier header.
Formato SSE
Headers:
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no
X-Accel-Buffering: no es para que nginx no bufferee el stream. Hoy el backend no tiene
middleware de compresión; si algún día se agrega, debe excluir text/event-stream.
La secuencia de frames es:
data: {"id":"chatcmpl-…","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"…","choices":[{"index":0,"delta":{"content":"Hola"},"finish_reason":null}]}
data: {"…","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]
Todos los chunks comparten el mismo id y llevan model = el modelo real del LlmAdapter
(no se devuelve el model que mandó el cliente, que sería mentira).
Por qué @Res() y no @Sse()
- ElevenLabs parsea el formato de OpenAI al byte, y
@Sse()se apropia del framing: meterle eldata: [DONE]final es pelearle. - No da un hook limpio para
X-Accel-Buffering. - Adaptar
AsyncIterable → Observablemete una capa justo en el camino del abort, que es el que más fácil se rompe.
El costo es que hay que llamar res.end() a mano y que, con los headers ya enviados, una
excepción no puede renderizar un status.
Los headers se escriben tarde a propósito
runTurn se espera antes de mandar el primer header: cubre la búsqueda de RAG y el fetch
del prompt, que es la parte que más probablemente falle (DB / embeddings). Así una caída
renderiza como un 500 real en vez de un stream con una disculpa adentro.
Aborto y errores
- El cliente corta (
res.on('close')) → se aborta elAbortSignal, se corta el loop y no se escribe nada más (el socket ya no está). No se emitesuggestion_generated. - Falla el LLM a mitad de stream → con los headers afuera un 500 es imposible, así que se
manda el
FALLBACK_REPLYcomo un content frame, el stop frame y[DONE]. Tampoco se emitesuggestion_generated.
FALLBACK_REPLY vive en src/conversation/conversation.constants.ts y lo comparten este
adaptador y el de Retell, para que los dos caminos de voz no deriven.
Perfiles de canal
El bearer token es el discriminador de canal: una sola ruta, y el token resuelve a un perfil. Un canal nuevo es un secreto nuevo más una entrada en el mapa, nunca una ruta nueva.
| Perfil | Token | source | Prompt | maxTokens | minWords | historyLimit |
|---|---|---|---|---|---|---|
voice | CUSTOM_LLM_TOKEN_VOICE | elevenlabs | agent_identity | 80 | 4 | sin tope |
whatsapp | CUSTOM_LLM_TOKEN_WHATSAPP | whatsapp | agent_identity_whatsapp | 200 | 1 | sin tope |
Los números de voz replican a propósito las constantes de retell.gateway.ts
(MAX_RESPONSE_TOKENS, MIN_WORDS_FOR_RAG): los dos caminos de voz tienen que sonar igual, y
ninguno de los dos sigue los Ajustes runtime — un cambio en el panel de
admin no debe alterar el comportamiento de voz.
WhatsApp difiere en todo lo que depende de que sea texto tipeado:
maxTokens: 200porque no hay TTS que respetar y sí lectura en pantalla; con los 80 de voz las respuestas salían cortadas.minWords: 1porque en texto no hay acknowledgments que filtrar, másalwaysRagOnEquipment: truecomo bypass explícito del gate (ver Pipeline RAG).- Prompt propio (
agent_identity_whatsapp): mismo dominio clínico que el de voz pero registro escrito, sin reglas de prosodia y con permiso de listas cortas. Editable desde/anfibios/promptscomo cualquier otro (ver Prompts del agente). - Umbral de RAG más alto, resuelto desde el scope
whatsappde los settings.
CHANNEL_PROFILES (custom-llm.profiles.ts) es la única lista: el guard la itera y resuelve el
secreto de cada perfil por profile.name. Un perfil sin secreto mapeado no matchea nunca — falla
cerrado, no abierto. Un canal nuevo es una entrada ahí más su GuardedSecret, nunca una ruta nueva.
Autenticación
Bearer con secreto compartido, comparado en tiempo constante (secretsMatch).
Authorization: Bearer <CUSTOM_LLM_TOKEN_VOICE | CUSTOM_LLM_TOKEN_WHATSAPP>
Los dos tokens fallan cerrado: sin secreto seteado, el canal que protege rechaza todo (401), y
SecurityBootLogger lo grita al boot. Tienen que ser distintos: el token es lo único que
distingue los canales, así que reusar el de voz en el agente de WhatsApp hace que cada mensaje de
texto se responda como si fuera una llamada.
Este control falla CERRADO, a diferencia de AUDIO_FORK_SECRET: si la variable no está
seteada el endpoint responde 401 a todo, y el SecurityBootLogger lo grita al arrancar. La
inversión es deliberada — un endpoint de LLM sin auth es una canilla de tokens abierta. Con
SECURITY_STRICT=true el boot directamente falla.
El allowlist de CORS no aplica: sólo afecta a requests de browser con Origin, y
ElevenLabs es server-to-server. Si aparece un 401, no es CORS.
La trampa del ValidationPipe global
:::warning No cambiar el tipo del @Body()
En custom-llm.controller.ts el body está tipado con la interface, no con el DTO:
@Body(CHAT_COMPLETION_BODY_PIPE) body: ChatCompletionRequest, // NO `: ChatCompletionRequestDto`
Si alguien "prolija" eso a ChatCompletionRequestDto, el endpoint empieza a tirar 400 en
cada llamada real de ElevenLabs.
:::
main.ts instala un pipe global con forbidNonWhitelisted: true, que rechaza cualquier campo
no declarado — y ElevenLabs manda un montón (model, temperature, tools…). Un
@UsePipes() a nivel de ruta no lo resuelve: los pipes globales corren primero
(pipes.concat(paramPipes) en router-execution-context.js).
La solución se apoya en dos comportamientos de Nest:
ValidationPipe.toValidate()devuelvefalsecuando elmetatypeesObject— y un parámetro tipado con una interface compila justamente aObject. El pipe global lo saltea entero.- Un pipe de parámetro con
expectedTypefuerza la clase contra la que validar.
Así CHAT_COMPLETION_BODY_PIPE valida contra el DTO pero con whitelist: true sin
forbidNonWhitelisted: los campos extra se borran en vez de rechazarse, de forma
recursiva (también dentro de cada messages[]).
Corolario: todo campo que se quiera leer tiene que estar declarado en el DTO, o desaparece.
Por eso los candidatos de correlación se leen del req.body crudo.
Limitaciones de la Fase 2
| Falta | Por qué | Dónde se resuelve |
|---|---|---|
call_id estable | Resuelto en la Fase 3.0 — ver abajo | ✅ |
Resuelto en la Fase 3.3: conversation_snapshot por turno + GET /conversations/active | ✅ (la UI que los consume es la 3.4) | |
Resuelto en la Fase 3.2: tabla messages, idempotente por (conversation_id, turn_index) | ✅ | |
Datos del paciente (systemExtras) | La columna ya existe en conversations y el motor ya la consume; llenarla es la 3.7, y divulgar el nombre exige la 3.8 | Fase 3.7 / 3.8 |
ragQueryPrefix) | Resuelto en la Fase 3.2 — y con expiración, que el camino de Retell no tenía | ✅ |
Filtro elevenlabs en métricas y feedback | Los dropdowns siguen con la unión vieja; los agregados sin filtro ya cuentan esta fuente | Fase 4 |
Estado cross-turn: resuelto (11-ago-2026)
El endpoint deja de ser stateless en la práctica: cada turno resuelve el estado de la conversación
desde la tabla conversations (una sola lectura, antes del motor, porque el equipo pegajoso es un
insumo de la consulta al manual) y persiste el turno después de cerrar el stream, con void,
igual que el event log — el await engine.runTurn() sigue siendo lo único que corre antes del
primer header.
Detalle de las tablas, del lock de equipo y de su expiración: Base de datos.
Monitoreo en vivo: resuelto (12-ago-2026)
Sin operador humano en el loop, nadie lee lo que el agente contesta. La trazabilidad de qué
respondió y con qué score de RAG deja de ser telemetría y pasa a ser el único control de calidad que
queda. Por eso cada turno emite un conversation_snapshot por el socket del copiloto.
| Evento | Cuándo | Contenido |
|---|---|---|
conversation_snapshot | Por turno, después de cerrar el stream | conversation_id, channel, source, transcript, first_turn_index, last_turn_at, equipment, patient_equipment, patient_name |
conversation_ended | Cuando el barrido da la conversación por terminada | conversation_id, channel, reason: 'idle' |
El transcript es la cola (últimos 40 turnos): un hilo de WhatsApp puede correr horas y el socket
no puede remandar toda la historia en cada turno. first_turn_index es el messages.turn_index del
primer turno de esa ventana, y existe porque sin él una posición en el array no identifica al turno —
el suggestion_id del feedback (${conversation_id}_${turn_index}) nombraría un turno distinto cada
vez que la ventana desliza.
Cada turno del transcript es { speaker, text, rag_hit?, rag_score? }. El retrieval es del turno,
no de la conversación: un rag_score a nivel conversación solo podría describir la última
respuesta, y un catch-up no tendría nada verdadero que poner ahí. Los tres estados de esos campos
son distintos y no hay que colapsarlos:
| Estado | Significa |
|---|---|
| ausentes | No hay registro de este turno. El snapshot en vivo solo conoce el turno que acaba de terminar; los anteriores son historia que manda ElevenLabs y sus scores están en messages |
rag_score: null | El turno no consultó el manual (no pasó el gate de palabras, o no hubo match) |
| un número | Lo que scoreó, con rag_hit diciendo si pasó el umbral |
El catch-up los lee de messages.rag_hit/rag_score, así que un dashboard que se reconecta
recupera el score de cada respuesta, no solo de la próxima.
Van por broadcastAll: el monitor es read-only y no hay claim, así que los ven todos los
operadores conectados. No se reusan incoming_call / call_ended, que son del camino Retell y
tienen semántica de llamada: aparece, se reclama, termina en minutos. Un hilo de WhatsApp puede estar
idle tres horas.
broadcastAll no toca el cache de replay del gateway, así que un dashboard que se conecta a mitad de
camino no recibe nada hasta el turno siguiente. El primer pintado es por HTTP:
curl localhost:3000/conversations/active -H "Cookie: $SESSION"
Devuelve el mismo shape que manda el socket, leído de la base y no de un Map — o sea que
sobrevive un redeploy y sirve a las dos instancias, cosa que el getActiveCalls() de Retell nunca
hizo. La ventana que mira es la del canal más tolerante: una conversación que el barrido todavía
no cerró sigue perteneciendo a la pantalla. Único campo que no puede reconstruir: rag_score, porque
la recuperación es de un turno y la fila de conversations no guarda la última.
El fin de conversación es inferido
:::caution Nadie nos avisa cuándo termina una conversación
No existe webhook call_ended en el camino Custom LLM. ElevenLabs cierra por su timeout de duración
máxima o por su herramienta End conversation, y a nosotros no nos dice nada. Somos un endpoint de
LLM, no un participante de la llamada.
:::
Un barrido cada minuto marca ended_at donde last_turn_at quedó más viejo que
conversation.idleMinutes (setting por canal: 15 global, 180 sembrado en whatsapp) y emite
conversation_ended.
Lo que eso implica, y conviene no redescubrir:
- "Terminada" es una inferencia nuestra, no un hecho. Una conversación cerrada del lado de ElevenLabs sigue figurando viva hasta que el barrido la alcance.
- Un paciente que retoma después del barrido genera una conversación nueva desde el punto de
vista de ElevenLabs, con otro
conversation_id. No hay nada que "reabrir". - El
UPDATE ... WHERE ended_at IS NULLes idempotente por naturaleza: dos instancias pueden barrer a la vez sin lock distribuido, y la que pierde no cierra nada y por lo tanto no emite nada.
Correlación: resuelto (11-ago-2026)
Verificado contra un agente real de ElevenLabs (widget de prueba sobre un túnel de
cloudflared): el único identificador que llega es el header x-conversation-id, y llega
solo porque el agente está configurado para inyectarlo.
user, elevenlabs_extra_body y metadata llegan todos undefined. ElevenLabs no manda
ningún identificador propio en el request del Custom LLM, y su doc tampoco documenta ninguno.
:::danger Config obligatoria del agente
El header no es una optimización, es un requisito. Un agente sin él hace que cada turno sea una conversación nueva: no hay error, no hay 500, simplemente nada correlaciona.
En el agente: LLM → Request headers → Add header
| Campo | Valor |
|---|---|
| Type | Variable |
| Header name | x-conversation-id |
| Dynamic variable name | system__conversation_id |
resolveCallId loguea un warn cuando cae al id sintético, justamente para que un agente mal
configurado se vea en los logs.
:::
x-caller-idMisma mecánica, lo agrega el ticket 3.7. El teléfono del paciente es lo único con lo que se puede saber qué equipos tiene, y no viene en el body de ninguna forma:
| Campo | Valor |
|---|---|
| Type | Variable |
| Header name | x-caller-id |
| Dynamic variable name | system__caller_id |
A diferencia del anterior no es obligatorio, y su ausencia no se loguea: el agente de voz no tiene caller id que mandar, y una conversación sin él funciona igual, solo que busca en los catorce manuales en vez de en los del paciente. Ver Pipeline RAG → Filtro por equipo.
El teléfono se normaliza de este lado (el 9 de +549…, el 15 doméstico, el 0 de troncal), así
que da igual con qué forma llegue.
Evidencia: id estable a lo largo de los turnos de una conversación mientras el historial crecía
(conv_0001kzrtsyn2fakb7ked2nxkhafy), y distinto en la siguiente
(conv_2401kzrv3xx6e37t2stswj178sdc). El shape capturado vive en
src/custom-llm/custom-llm.correlation.spec.ts.
El controller prueba, en orden:
- Header
x-conversation-id← el que llega - Campo
user(el estándar de OpenAI) elevenlabs_extra_body.conversation_id/.call_idmetadata.conversation_id- Fallback:
el_<uuid>+warn
Los candidatos 2 a 4 quedan como defensa. El fallback aleatorio queda porque un id ausente no puede tirar una llamada en curso.
No se sintetiza un id "estable" hasheando el primer mensaje: eso agruparía conversaciones distintas, que es peor que no agrupar.
Pendiente: la misma verificación sobre WhatsApp real. La doc de ElevenLabs no dice que el request difiera por canal, lo que no es lo mismo que decir que no difiere.
Otras cosas que manda ElevenLabs
Claves del body: messages, model, max_tokens, stream, stream_options, temperature,
tools.
messagesllega con el historial completo, no solo el último turno.toolssí llega cuando el agente tiene herramientas, en formato OpenAI estándar. Las system tools se resuelven in-band: ElevenLabs espera untool_callsde vuelta y no las ejecuta por su cuenta. Hoy no las reenviamos al LLM ni podemos emitirtool_calls.tool_choiceno llega.stream_options: {include_usage: true}— piden el usage en el stream, y desde la 3.3 se lo mandamos: un chunk conchoices: []y los totales, después del chunk destopy antes de[DONE]. Son los mismos números que ya acumulábamos para el event log. Sin eso, el dashboard de tokens y costos del lado de ElevenLabs sale vacío.model,temperatureymax_tokensse ignoran:maxTokenssale delChannelProfile.- Usan el SDK de Python de OpenAI (
AsyncOpenAI/Python, headersx-stainless-*), conread-timeoutde 600s. - El path lo agrega ElevenLabs. En el campo Server URL va la raíz del host, sin
/chat/completions.
:::caution Backup LLM
Poner Backup LLM configuration en Disabled. Por default ElevenLabs cae a Gemini/GPT-4o
cuando el primario falla, y esos modelos responderían sin nuestros manuales y sin nuestro
prompt clínico. Con Aura como primario eso convierte una caída visible en una respuesta
inventada sobre equipamiento de oxígeno.
:::
Cómo probarlo
# 401: sin token
curl -i -X POST localhost:3000/chat/completions \
-H 'Content-Type: application/json' \
-d '{"messages":[{"role":"user","content":"hola"}]}'
# No streaming — el camino cómodo para debuggear
curl -s -X POST localhost:3000/chat/completions \
-H "Authorization: Bearer $CUSTOM_LLM_TOKEN_VOICE" \
-H 'Content-Type: application/json' \
-d '{"model":"gpt-4o","temperature":0.7,"tools":[],"stream":false,
"messages":[{"role":"system","content":"IGNORAME"},
{"role":"user","content":"el M50 no prende, qué reviso"}]}'
# Streaming SSE (-N desactiva el buffer de curl)
curl -N -X POST localhost:3000/chat/completions \
-H "Authorization: Bearer $CUSTOM_LLM_TOKEN_VOICE" \
-H 'Content-Type: application/json' \
-d '{"stream":true,"messages":[{"role":"user","content":"el M50 no prende, qué reviso"}]}'
En los logs del backend hay que ver: que IGNORAME no esté en el prompt, que el [TURN]
del engine diga system=1 part(s), y un [RAG] HIT. Cortando el curl -N con Ctrl-C a mitad
de stream tiene que aparecer Stream aborted by client y no un suggestion_generated.
:::tip Si probás contra una DB remota
Con el backend local y la DB en la nube, la query del RAG tarda ~1.6 s y el presupuesto por
defecto (QUERY_LATENCY_BUDGET_MS, 500 ms) descarta el contexto aunque haya HIT:
[Knowledge] HIT — score=0.611 …
WARN [RAG] DB query exceeded budget (1662.3ms > 500ms) → no context
[TURN] … ragContext=none
El síntoma es que el agente contesta genérico y parece que el RAG no anda. Subí
QUERY_LATENCY_BUDGET_MS=3000 en el .env local. En producción el valor por defecto asume
que el backend está co-ubicado con la DB — si no lo está, el RAG queda apagado en silencio y
lo único que lo delata son los knowledge_queried con reason: 'budget_exceeded'.
:::
Tests
src/custom-llm/custom-llm.dto.spec.ts— el pipe borra los campos extra en vez de rechazarlos (la regresión que rompería producción), y el aplanado decontent.src/custom-llm/custom-llm.auth.guard.spec.ts— matriz de 401, incluido el caso fail-closed con el secreto sin setear, y un token incorrecto del mismo largo que el esperado (para ejercitartimingSafeEqualy no el atajo de longitud).src/custom-llm/custom-llm.controller.spec.ts— secuencia de frames SSE, orden headers-después-del-RAG, aborto, error del LLM, descarte delsystem, perfil de canal, el camino no-streaming y la precedencia de correlación.