Back to blog
Catálogo del gateway LiteLLM de Zetesis Portal: los cuatro presets de modelos con sus costes por token

Zetesis Portal y PayloadAgents: cómo construimos nuestro SaaS

ProductoZetesis PortalPayloadAgentsAgentes

Esta web que estás leyendo corre sobre Zetesis Portal. Es nuestra plataforma: el sistema con el que convertimos el conocimiento de una organización en algo que se puede buscar y preguntar. Y su núcleo es open source: PayloadAgents.

Este post es la visita completa: cómo está construido como SaaS, cómo se despliega solo y por qué abrimos su núcleo.

Multitenancy de verdad

Zetesis Portal es multi-tenant: cada organización tiene su espacio aislado —contenido, documentos, agentes, perfiles de búsqueda, instalaciones de bots y métricas— y solo comparte lo que toca compartir: usuarios, taxonomías y media.

  • El scoping se aplica en Payload con el plugin multi-tenant y roles por tenant: tenant-admin, tenant-viewer, tenant-chat-user, tenant-mcp-user.
  • Un tenant puede ser público —como este blog— y sus contenidos se mezclan con los de tus organizaciones solo si tienes acceso.
  • La integridad se valida en servidor: ninguna relación puede apuntar a objetos de otro tenant, aunque alguien lo pida directamente a la API.

El modelo de contenido: Payload CMS, R2 y el parser de PDF

Todo en el portal es una colección de Payload. Las principales:

  • Posts: artículos con richText localizado (es/en), carpetas, portada y taxonomías. El contenido del catálogo puede ser estático y seedearse —como este mismo post—.
  • Books: libros íntegros troceados en capítulos markdown.
  • Taxonomies: árboles jerárquicos compartidos entre tenants, con breadcrumbs.
  • Media y Documents: los ficheros, con validación de tipo por magic bytes en servidor.

Los binarios viven en Cloudflare R2 (compatible con S3): el plugin de storage sube ahí media y documentos al crearlos, y el bucket no es público —la aplicación sirve los ficheros con un cliente S3 cacheado, y el worker que parsea PDFs descarga el binario por un endpoint interno, sin URLs firmadas ni públicas—.

Cuando un PDF entra en Documents, Payload encola zp.documents.parse: un worker descarga el fichero, valida tipo y tamaño, lo envía a LlamaParse, espera el resultado y escribe el markdown de vuelta en el documento. Desde ese momento se trocea e indexa como cualquier otro contenido. Los errores permanentes van a la DLQ y, si el worker no está configurado, el parseo corre inline como plan B.

Identidad: OIDC con un Keycloak que es producto propio

La autenticación es OIDC contra Keycloak, pero con una vuelta de tuerca: el Keycloak se extrajo del portal y vive como despliegue independiente —Zetesis-Auth—, con su imagen, su chart y su ciclo de releases propios. Un mismo patrón que reusamos en otros ecosistemas.

  • Login con PKCE, account linking y logout RP-initiated.
  • Tres capas de permiso: rol global, roles por tenant y entitlements por suscripción Stripe.
  • Cada tenant se corresponde con una organización de Keycloak: el login sincroniza membresías y roles automáticamente.
  • Los bots se vinculan por identidad: un deep link con token de un solo uso conecta tu cuenta de Telegram, WhatsApp o Discord con tu cuenta del portal.

Agentes: Agno como runtime, agentes como datos

Los agentes no están programados a fuego: son documentos de una colección —prompt, modelo, herramientas, presupuestos— que el runtime descarga y sirve.

  • El runtime es Agno sobre FastAPI: sesiones en Postgres, protocolo AG-UI con streaming, y recarga en caliente vía LISTEN/NOTIFY de Postgres —cambiar un agente en el admin lo actualiza en todas las réplicas sin redeploy—.
  • Cada agente recibe sus herramientas desde un servidor MCP propio con 13 herramientas de búsqueda y síntesis sobre el contenido indexado.
  • Los perfiles de búsqueda definen qué colecciones, taxonomías y carpetas puede consultar cada agente, con rerankers y lentes de re-scoring. Un agente puede tener varios perfiles y elegir cuál usar según la pregunta.
  • Canales: Telegram, WhatsApp, Discord y Teams —Teams con paquete propio publicado, verificación JWT y Adaptive Cards—.

Y la integración va en las dos direcciones: cualquier cliente MCP externo —Claude Desktop, Cursor— puede consultar el portal con tokens de búsqueda por usuario, acotados por perfiles.

La vinculación de canales se hace desde la página de integraciones: eliges el canal, se genera un token de un solo uso y el bot conecta tu cuenta de Telegram, WhatsApp o Discord con tu usuario del portal. Cada tenant puede instalar tantos bots como necesite.

Teams va por otro camino. Existe un paquete propio —agno-microsoft-teams, también publicado en PyPI— que conecta los agentes de Agno con Microsoft Teams, con verificación JWT contra Bot Framework y Adaptive Cards; pero su integración es más manual y menos automatizable: la instalación y la vinculación de cuentas no pasan por el flujo automático de tokens que usan el resto de canales.

Gateway de modelos y observabilidad total

Todo el tráfico de IA pasa por un gateway LiteLLM propio:

  • Claves virtuales por agente con su presupuesto y rate limits; la clave real del proveedor viaja por petición y nunca se almacena sin cifrar.
  • Catálogo curado —chat-estandar, chat-premium, razonador, economico— que oculta el modelo real a los consumidores.
  • Egress cerrado: el runtime solo puede hablar con el gateway; las claves de proveedor ni siquiera se montan en el pod.
  • Coste real por ejecución: el gateway inyecta el coste en el stream y cada run queda en un ledger (llm-usage-events) con tokens, latencia y origen del coste —gateway real o estimación por tabla—.
  • Langfuse autoalojado con trazas anidadas runtime+gateway en la misma traza OTel, y etiquetas por tenant y agente. Prometheus + Grafana para plataforma, con dashboard dedicado del gateway.
  • Los logs llegan solos: cada pod los reporta automáticamente —Promtail y un collector OTel los reenvían a Loki en el hub central de observabilidad—; no hay que montar envío de logs servicio a servicio.

Pagos: las lecciones de Escohotado convertidas en paquete

El sistema de suscripciones nació aprendiendo de un error real. En la primera versión —la de Escohotado— las suscripciones se materializaban en un JSON dentro del usuario y los webhooks no tenían deduplicación: un reintento de Stripe cayendo en otro pod duplicaba suscripciones y permisos.

La versión actual es payload-betterauth-stripe, nuestro paquete sobre better-auth:

  • Suscripciones como colección first-class; los permisos se recalculan en cada acceso en lugar de copiarse al usuario.
  • Deduplicación de webhooks en Postgres: cada evento se reclama con un INSERT ... ON CONFLICT DO NOTHING antes de procesarlo —multi-pod de verdad— y la tabla se crea por migración explícita, auditable.
  • Planes espejo de Stripe con entitlements por plan, y límite diario de tokens por plan que se traduce en un 429 cuando se agota.

La cola: Redis Streams hoy, JetStream mañana

Los trabajos pesados —como el parseado de PDFs con LlamaParse— corren en una cola propia (nexus-queue) con envelope versionado, idempotencia, reintentos y DLQ sobre Redis Streams y Taskiq.

La siguiente iteración ya existe en una rama: una migración a NATS JetStream con runtime propio sin Taskiq —receivers con backoff declarativo, DLQ por advisories, suite de conformidad TypeScript↔Python— y el worker de documentos ya convertido. Aún no está mergeada: es el ejemplo de cómo tratamos la infraestructura —spec primero, conformidad después, merge cuando aguanta—.

Open-core: qué abrimos y cómo se relaciona todo

PayloadAgents es el núcleo abierto (MIT, público); Zetesis Portal es el producto (privado). La relación no es teórica:

  • 11 paquetes npm @zetesis/* y 4 builders Python en PyPI: indexación, Typesense, taxonomías, agentes, métricas, documentos, cola, chat UI, MCP.
  • El portal los consume desde el submódulo git —workspace para TS, paths editables para Python—; npm y PyPI son para los consumidores externos.
  • Publicabilidad real: los paquetes compilan sus tipos sin depender de la aplicación —esa es la pieza que hace el open-core posible y no una promesa—.

Release-please: del commit a producción sin tocar nada

La cadena de release está cerrada de punta a punta:

Loading diagram...

Un conventional commit abre la release PR; al mergear se publican paquetes, imágenes y chart; y un job abre automáticamente el PR de bump en el repo GitOps. Mergear es desplegar.

CI/CD: Mileto, cortes y el homelab

La operación vive en Mileto-Infra-GitOps: ArgoCD app-of-apps sobre clústeres Talos del homelab —cortes para producto, pizarro para plataforma, con secretos en Infisical y External Secrets—, más doco-cd para las máquinas fuera de Kubernetes.

  • Producción en un único nodo del clúster cortes: web y agent-runtime con HPA, gateway LiteLLM en el chart, datos en un namespace compartido —Postgres 17 CNPG, Typesense, Redis—.
  • PR previews efímeros: cada pull request levanta namespace, base de datos y dominio propios, con limpieza automática a las 24 horas.
  • Backups automáticos: WAL continuo + base diaria de Postgres a MinIO offsite, snapshots de Typesense y dumps lógicos, con runbook de restauración.
  • CI en runners self-hosted dentro del propio clúster, con validación estricta de manifiestos y escaneo de secretos.

Conclusiones

Zetesis Portal es la prueba de que todo esto aguanta en producción: lo usamos nosotros cada día, con clientes encima. PayloadAgents es nuestra forma de decir que no hay truco: el núcleo está en abierto, con sus releases, sus tests y su historial de decisiones.

Montar un SaaS así no es elegir tecnologías de una lista: es identidad, agentes, pagos, cola, gateway, observabilidad y GitOps funcionando como una sola pieza. Eso es lo que construimos —y lo que podemos construir para otros—.