Módulo 6: Modal — Deployment serverless de LLMs

API REST con Modal

En la cápsula anterior llamabas a Mistral con modal run desde tu terminal. Útil para experimentar, inservible para producción: tu chatbot real, tu app web, tu cliente móvil — ninguno habla "Python con SDK de Modal". Hablan HTTP.

En esta cápsula vas a convertir tu función Mistral en un endpoint HTTPS público que cualquiera puede consumir con curl, fetch, o cualquier librería HTTP. Vamos a usar FastAPI montado dentro de Modal con el decorador @asgi_app, agregar autenticación con un header Authorization, y diseñar el contrato JSON.

Al terminar vas a poder:

  • Exponer una función de Modal como endpoint HTTPS sin servidores intermedios
  • Diseñar un contrato request/response JSON con Pydantic
  • Proteger el endpoint con autenticación por token simple
  • Llamar tu endpoint desde curl y desde Python como cualquier API externa

Por qué importa

Un LLM detrás de modal run es un experimento. Un LLM detrás de https://tu-app.modal.run/chat es infraestructura. Esta cápsula es la que te lleva de uno al otro.

Modal hace esto sorprendentemente simple porque ya construyó el container, el autoscaler, el certificado TLS, y la URL pública. Tú solo declaras el handler.


Modelo mental: una función + un router HTTP

Modal te da dos primitivas para HTTP:

DecoradorCuándo usar
@modal.fastapi_endpoint()Endpoint único, un GET/POST simple, sin múltiples rutas
@modal.asgi_app()App FastAPI/Starlette completa con varios endpoints, middleware, etc

Para algo serio usa @modal.asgi_app() con una FastAPI() adentro — es lo que veremos. La capa FastAPI te da:

  • Routing (/chat, /health, /models)
  • Validación con Pydantic
  • Documentación automática (/docs)
  • Middleware (auth, CORS, logging)

Modal te da:

  • El container que sirve esa app
  • URL pública con TLS
  • Autoscaling

Ejemplo trabajado: endpoint Mistral con auth

Parte del código de la cápsula 04 (la versión con @app.cls) y agrégale el endpoint HTTP. Crea api.py:

# api.py
import os
import modal
from typing import Annotated
from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel, Field

app = modal.App("mistral-api")
pesos_volume = modal.Volume.from_name("mistral-pesos", create_if_missing=True)
CACHE_DIR = "/cache/huggingface"

imagen = (
    modal.Image.debian_slim(python_version="3.11")
    .pip_install(
        "vllm==0.6.3",
        "huggingface_hub[hf_transfer]==0.26.2",
        "fastapi[standard]",
    )
    .env({"HF_HOME": CACHE_DIR, "HF_HUB_ENABLE_HF_TRANSFER": "1"})
)

MODELO = "mistralai/Mistral-7B-Instruct-v0.3"


# Contrato de la API
class ChatRequest(BaseModel):
    prompt: str = Field(..., min_length=1, max_length=4000)
    max_tokens: int = Field(256, ge=1, le=2048)
    temperature: float = Field(0.7, ge=0.0, le=2.0)


class ChatResponse(BaseModel):
    respuesta: str
    modelo: str
    tokens_generados: int


# La clase que corre con GPU
@app.cls(
    image=imagen,
    gpu="A10G",
    volumes={CACHE_DIR: pesos_volume},
    timeout=600,
    container_idle_timeout=300,
)
class MistralService:
    @modal.enter()
    def cargar(self):
        from vllm import LLM
        self.llm = LLM(model=MODELO, download_dir=CACHE_DIR)

    @modal.method()
    def generar(self, prompt: str, max_tokens: int, temperature: float) -> dict:
        from vllm import SamplingParams
        sampling = SamplingParams(temperature=temperature, max_tokens=max_tokens)
        prompt_fmt = f"[INST] {prompt} [/INST]"
        output = self.llm.generate(prompt_fmt, sampling)[0]
        return {
            "texto": output.outputs[0].text.strip(),
            "tokens": len(output.outputs[0].token_ids),
        }


# Helper de autenticación
def verificar_token(authorization: str | None) -> None:
    token_esperado = os.environ.get("API_TOKEN")
    if not token_esperado:
        raise HTTPException(500, "API_TOKEN no configurado en el servidor")
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(401, "Falta header 'Authorization: Bearer <token>'")
    if authorization.removeprefix("Bearer ") != token_esperado:
        raise HTTPException(403, "Token inválido")


# El ASGI app (FastAPI) que Modal va a servir
@app.function(
    image=imagen,
    secrets=[modal.Secret.from_name("mistral-api-token")],
)
@modal.asgi_app()
def web():
    web_app = FastAPI(title="Mistral API")

    @web_app.get("/health")
    def health():
        return {"status": "ok", "modelo": MODELO}

    @web_app.post("/chat", response_model=ChatResponse)
    def chat(
        req: ChatRequest,
        authorization: Annotated[str | None, Header()] = None,
    ):
        verificar_token(authorization)
        servicio = MistralService()
        resultado = servicio.generar.remote(
            req.prompt, req.max_tokens, req.temperature
        )
        return ChatResponse(
            respuesta=resultado["texto"],
            modelo=MODELO,
            tokens_generados=resultado["tokens"],
        )

    return web_app

Setup del secret de autenticación

Antes de deployar, crea el secret con tu token:

modal secret create mistral-api-token API_TOKEN=algun-token-largo-y-aleatorio

Genera un token aleatorio decente con:

python -c "import secrets; print(secrets.token_urlsafe(32))"

Guárdalo en tu password manager local; lo vas a necesitar para llamar el endpoint.


Deployar

Hasta ahora usabas modal run (efímero, se apaga cuando termina el script). Para un endpoint público necesitas modal deploy:

modal deploy api.py

Output:

✓ Created objects.
├── 🔨 Created mount /Users/.../api.py
├── 🔨 Created function MistralService.*.
└── 🔨 Created web function web => https://tu-usuario--mistral-api-web.modal.run

✓ App deployed in 8.2s! 🎉

La URL https://tu-usuario--mistral-api-web.modal.run es pública, con HTTPS, autoscaling, y siempre disponible (aunque puede tener cold start si nadie la usó en 5 minutos).


Probar el endpoint

Healthcheck (sin auth):

curl https://tu-usuario--mistral-api-web.modal.run/health
# {"status":"ok","modelo":"mistralai/Mistral-7B-Instruct-v0.3"}

Chat (con auth):

curl -X POST https://tu-usuario--mistral-api-web.modal.run/chat \
  -H "Authorization: Bearer tu-token-de-arriba" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "Explica REST en 2 frases.", "max_tokens": 200}'

Respuesta esperada:

{
  "respuesta": "REST es un estilo arquitectónico para diseñar APIs donde los recursos se identifican por URLs y se manipulan con métodos HTTP estándar (GET, POST, PUT, DELETE). Es stateless: cada request lleva toda la información necesaria, sin depender de sesiones del lado del servidor.",
  "modelo": "mistralai/Mistral-7B-Instruct-v0.3",
  "tokens_generados": 78
}

Sin token:

curl -X POST https://tu-usuario--mistral-api-web.modal.run/chat \
  -H "Content-Type: application/json" \
  -d '{"prompt": "hola"}'
# {"detail":"Falta header 'Authorization: Bearer <token>'"}

Con token inválido:

curl -X POST https://tu-usuario--mistral-api-web.modal.run/chat \
  -H "Authorization: Bearer xxx" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "hola"}'
# {"detail":"Token inválido"}

Desde Python (cliente):

import os
import httpx

BASE_URL = "https://tu-usuario--mistral-api-web.modal.run"
TOKEN = os.environ["MISTRAL_API_TOKEN"]  # ponlo en tu .env local

response = httpx.post(
    f"{BASE_URL}/chat",
    headers={"Authorization": f"Bearer {TOKEN}"},
    json={"prompt": "¿Qué es Modal?", "max_tokens": 200},
    timeout=60,
)
print(response.json()["respuesta"])

Documentación automática

FastAPI dentro de Modal genera docs automáticamente:

  • Swagger UI: https://tu-usuario--mistral-api-web.modal.run/docs
  • ReDoc: https://tu-usuario--mistral-api-web.modal.run/redoc
  • OpenAPI JSON: https://tu-usuario--mistral-api-web.modal.run/openapi.json

Abre /docs en el navegador. Vas a ver tus dos endpoints (/health, /chat), los schemas de request/response, y puedes probarlos desde ahí.

Trampa: la autenticación funciona también en /docs. Necesitas pegar tu token en el botón "Authorize" arriba a la derecha (Swagger lo entiende vía esquema de seguridad).


Gestionar el deployment

modal app list           # ver tus apps deployadas
modal app logs mistral-api   # logs en vivo
modal app stop mistral-api   # apagar el deployment
modal deploy api.py      # re-deployar (sobreescribe la versión actual)

Re-deploy es atómico: Modal no apaga la versión vieja hasta que la nueva está lista. No hay downtime visible.


Trampas comunes

Trampa 1 — "Me da 500 al llamar /chat y el log dice API_TOKEN no configurado." Olvidaste asociar el secret a la función web. Verifica que secrets=[modal.Secret.from_name("mistral-api-token")] esté en el decorador correcto (en el de web, no en el de MistralService — la auth se valida en web).

Trampa 2 — "El endpoint tarda 60s la primera vez." Es el cold start del container GPU (cargar Mistral). El healthcheck no lo dispara — solo /chat que invoca MistralService. Estrategias para mitigar en la cápsula 06.

Trampa 3 — "Mi auth solo compara strings — ¿es seguro?" Para uso interno o un cliente confiable, sí. Para producción con varios clientes:

  • Usa comparación constante para evitar timing attacks: hmac.compare_digest(token, esperado)
  • Usa tokens por cliente rotables (no un único token compartido)
  • Considera OAuth2/JWT si tienes varios usuarios — pero eso es otra cápsula

Trampa 4 — "Quiero /chat accesible desde mi frontend en otro dominio (CORS error)." Agrega middleware de CORS:

from fastapi.middleware.cors import CORSMiddleware

web_app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://tu-frontend.com"],
    allow_methods=["POST", "GET"],
    allow_headers=["Authorization", "Content-Type"],
)

Trampa 5 — "Cuando llamo el endpoint, Pydantic me rechaza prompts de >4000 caracteres." Lo limitaste tú en el contrato con max_length=4000. Es buena práctica para evitar abuse y costos descontrolados. Sube el límite o quítalo si tu caso real lo necesita.

Trampa 6 — "Quiero streaming de tokens (Server-Sent Events)." Sí se puede con Modal + FastAPI usando StreamingResponse y un generador. Es un patrón propio que merece su propia cápsula — por ahora, el endpoint devuelve la respuesta completa al final.


Ejercicio

Agrega un endpoint POST /chat-batch que reciba una lista de prompts y devuelva sus respuestas, reutilizando MistralService.generar_batch (de la solución de la cápsula 04). Incluye validación de que la lista tenga entre 1 y 10 prompts.

Ver solución
# Schemas adicionales
class BatchRequest(BaseModel):
    prompts: list[str] = Field(..., min_length=1, max_length=10)
    max_tokens: int = Field(256, ge=1, le=2048)
    temperature: float = Field(0.7, ge=0.0, le=2.0)


class BatchResponse(BaseModel):
    respuestas: list[str]
    modelo: str


# Agrega un método al servicio
@app.cls(...)
class MistralService:
    @modal.enter()
    def cargar(self):
        from vllm import LLM
        self.llm = LLM(model=MODELO, download_dir=CACHE_DIR)

    @modal.method()
    def generar_batch(self, prompts: list[str], max_tokens: int, temperature: float) -> list[str]:
        from vllm import SamplingParams
        sampling = SamplingParams(temperature=temperature, max_tokens=max_tokens)
        prompts_fmt = [f"[INST] {p} [/INST]" for p in prompts]
        outputs = self.llm.generate(prompts_fmt, sampling)
        return [o.outputs[0].text.strip() for o in outputs]


# En la función web
@web_app.post("/chat-batch", response_model=BatchResponse)
def chat_batch(
    req: BatchRequest,
    authorization: Annotated[str | None, Header()] = None,
):
    verificar_token(authorization)
    servicio = MistralService()
    respuestas = servicio.generar_batch.remote(
        req.prompts, req.max_tokens, req.temperature
    )
    return BatchResponse(respuestas=respuestas, modelo=MODELO)

Prueba con curl:

curl -X POST https://.../chat-batch \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"prompts": ["¿Qué es FastAPI?", "¿Qué es Modal?"], "max_tokens": 100}'

vLLM va a procesar los prompts batch en GPU — más eficiente que llamadas separadas.


Resumen

Aprendiste:

  • ✅ Convertir una función Modal en endpoint HTTPS público con @modal.asgi_app()
  • ✅ Diseñar contratos de request/response con Pydantic (validación gratis)
  • ✅ Implementar auth con token Bearer usando un modal.Secret
  • modal deploy vs modal run — uno persiste, otro es efímero
  • ✅ Documentación automática en /docs (Swagger UI)

Checkpoint: si puedes hacer curl desde otra terminal y recibir la respuesta de Mistral, tienes un servicio HTTP funcionando.


Siguiente cápsula

En 06 — Autoscaling y cold starts vamos a profundizar en lo que pasa cuando varios clientes llaman tu endpoint a la vez. Verás:

  • Cómo Modal levanta múltiples containers en paralelo
  • Cómo configurar concurrency_limit, container_idle_timeout, keep_warm
  • Estrategias para mantener primer-byte-latency bajo en producción

Es la cápsula que separa "deployment de demo" de "deployment listo para usuarios reales".


Recursos

  1. Modal — Web endpoints — todas las formas de exponer HTTP.
  2. Modal — ASGI apps — el patrón con FastAPI.
  3. FastAPI — Security — auth más allá del Bearer simple.
  4. Pydantic v2 — Field validators — validaciones complejas.
  5. Modal — Logs and monitoringmodal app logs.