API privada de LLM con FastAPI, streaming y autenticación: expón tu Ollama como servicio profesional
Convierte tu modelo local de Ollama en un servicio API robusto y listo para producción. Aprende a exponer streaming de tokens, autenticación por clave API, rate limiting, logging y empaquetado con Docker Compose. Ideal para alimentar aplicaciones, chatbots o herramientas internas sin depender de la nube.
Por Equipo Starbyte
API privada de LLM con FastAPI, streaming y autenticación: expón tu Ollama como servicio profesional
Problema real: Ya tienes un modelo open source como Qwen 3.5 ejecutándose en Ollama. Funciona perfecto desde la terminal o desde Open WebUI, pero necesitas integrarlo con otras herramientas internas —un chatbot en tu web, un script de automatización, un asistente para VS Code—. Quieres una API HTTP estándar, con streaming de tokens para que la experiencia sea fluida, protegida con clave para que no cualquiera la use, y con logs mínimos para monitorear el uso. No quieres depender de OpenAI ni subir datos fuera de tu red.
En esta guía construirás exactamente eso: una API REST profesional sobre tu modelo Ollama usando FastAPI. Aprenderás a exponer respuestas completas y streaming auténtico (Server-Sent Events), a proteger los endpoints con API Key, a limitar la tasa de peticiones y a empaquetar todo con Docker Compose para que se levante con un solo comando.
Requisitos previos
- Ollama corriendo en la misma máquina o accesible en red local (
http://localhost:11434). Necesitas al menos un modelo descargado (ej.qwen3.5:latest). - Python 3.10+ con
pip. - Opcional: Docker y Docker Compose para el despliegue encapsulado.
- Conexión de red entre los clientes y el servidor de la API (puede ser localhost si pruebas en la misma máquina).
- Herramienta para probar la API:
curl,httpieo Postman.
1. Arquitectura del servicio
Nuestra API actuará como un proxy controlado y seguro hacia Ollama. El flujo es:
[Clientes autorizados] ---HTTPS (API Key)--> [FastAPI] ---HTTP--> [Ollama en :11434]
La API incluye:
- Un endpoint
POST /v1/chat/completionscompatible con el formato de OpenAI (facilita la integración con herramientas que ya soportan este estándar). - Streaming real usando Server-Sent Events (SSE).
- Validación de API Key en cada petición mediante un middleware.
- Rate limiting basado en IP para evitar abusos.
- Logging estructurado que registra cada solicitud sin almacenar el contenido de los prompts (preservando la privacidad).
- Healthcheck para monitorización.
El código completo ocupará menos de 150 líneas y quedará listo para producción.
2. Estructura del proyecto
Crea la siguiente estructura de archivos:
api-ollama/
├── main.py # Código principal de la API
├── requirements.txt # Dependencias Python
├── .env # Variables de entorno (API_KEY, etc.)
└── docker-compose.yml # (Opcional) Definición del servicio
3. Implementación de la API paso a paso
3.1 Instalar dependencias
requirements.txt:
fastapi==0.115.0
uvicorn[standard]==0.30.6
httpx==0.27.0
python-dotenv==1.0.1
slowapi==0.1.9
Instálalas:
pip install -r requirements.txt
3.2 Código completo de la API (main.py)
import os
import json
import logging
from typing import List, Optional, AsyncGenerator
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.responses import StreamingResponse, JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.middleware.cors import CORSMiddleware
from dotenv import load_dotenv
import httpx
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
load_dotenv()
# ─── Configuración ─────────────────────────────────────────────
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
API_KEY = os.getenv("API_KEY", "sk-miapi-super-secreta")
MODEL_NAME = os.getenv("MODEL_NAME", "qwen3.5:latest")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
# ─── Logging ────────────────────────────────────────────────────
logging.basicConfig(level=LOG_LEVEL,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger("llm_api")
# ─── Rate Limiter ───────────────────────────────────────────────
limiter = Limiter(key_func=get_remote_address)
app = FastAPI(title="LLM API Gateway", version="1.0.0")
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# ─── CORS ───────────────────────────────────────────────────────
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ─── Autenticación ──────────────────────────────────────────────
security = HTTPBearer(auto_error=True)
def verificar_api_key(credentials: HTTPAuthorizationCredentials = Depends(security)):
if not credentials or credentials.credentials != API_KEY:
raise HTTPException(status_code=403, detail="API Key inválida")
return True
# ─── Modelo de petición compatible con OpenAI ──────────────────
from pydantic import BaseModel, Field
class Message(BaseModel):
role: str
content: str
class ChatCompletionRequest(BaseModel):
model: str = MODEL_NAME
messages: List[Message]
stream: bool = False
temperature: Optional[float] = 0.7
max_tokens: int = 2048
# ─── Cliente HTTP reutilizable hacia Ollama ──────────────────
httpx_client = httpx.AsyncClient(timeout=600.0)
# ─── Healthcheck ──────────────────────────────────────────────
@app.get("/health")
async def health():
try:
resp = await httpx_client.get(f"{OLLAMA_BASE_URL}/api/tags")
if resp.status_code == 200:
return {"status": "ok", "ollama": "connected"}
except Exception:
return JSONResponse(status_code=503, content={"status": "error", "ollama": "unreachable"})
# ─── Endpoint de chat ─────────────────────────────────────────
@app.post("/v1/chat/completions")
@limiter.limit("10/minute")
async def chat_completions(
request: Request,
body: ChatCompletionRequest,
authenticated: bool = Depends(verificar_api_key)
):
logger.info(f"Solicitud recibida: modelo={body.model}, stream={body.stream}")
ollama_payload = {
"model": body.model,
"messages": [m.dict() for m in body.messages],
"stream": body.stream,
"options": {
"temperature": body.temperature,
"num_predict": body.max_tokens
}
}
if body.stream:
return StreamingResponse(
stream_ollama_response(ollama_payload),
media_type="text/event-stream"
)
else:
return await completar_sin_stream(ollama_payload)
async def completar_sin_stream(payload: dict):
resp = await httpx_client.post(f"{OLLAMA_BASE_URL}/api/chat", json=payload)
data = resp.json()
return {
"id": "chatcmpl-local",
"object": "chat.completion",
"created": int(__import__("time").time()),
"model": payload["model"],
"choices": [
{
"index": 0,
"message": data.get("message", {}),
"finish_reason": "stop"
}
]
}
async def stream_ollama_response(payload: dict) -> AsyncGenerator[str, None]:
async with httpx.AsyncClient(timeout=600.0) as client:
async with client.stream("POST", f"{OLLAMA_BASE_URL}/api/chat", json=payload) as response:
async for line in response.aiter_lines():
if line.strip():
yield f"{line}\n\n"
yield "data: [DONE]\n\n"
Explicación de los bloques importantes:
- Middleware de autenticación:
HTTPBearerextrae el token del encabezadoAuthorization: Bearer <API_KEY>. Si no coincide con el valor de la variable de entornoAPI_KEY, devuelve 403. Esto evita que cualquiera use tu API sin permiso. - Rate limiting:
slowapilimita a 10 peticiones por minuto desde la misma IP. Esto protege tu hardware local de saturaciones accidentales. - Compatibilidad OpenAI: el endpoint sigue el formato
/v1/chat/completionsque esperan muchas librerías (comoopenaiPython SDK). Esto significa que puedes apuntar el cliente de OpenAI directamente a tu API. - Streaming real: para
stream=True, la funciónstream_ollama_responseconsume la respuesta línea a línea de Ollama y la reenvía como eventos SSE. Los clientes reciben cada token tan pronto se genera. - Logging: cada solicitud se registra con nivel INFO, sin volcar el contenido del prompt, preservando la privacidad de los usuarios finales.
3.3 Archivo .env
Crea un archivo .env con contenido como:
OLLAMA_BASE_URL=http://localhost:11434
API_KEY=sk-miapi-super-secreta
MODEL_NAME=qwen3.5:latest
LOG_LEVEL=INFO
Nunca versiones este archivo en un repositorio público.
4. Ejecución de la API
4.1 En local (desarrollo)
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
4.2 Probar con curl
Petición sin streaming:
curl -X POST http://localhost:8000/v1/chat/completions \
-H "Authorization: Bearer sk-miapi-super-secreta" \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3.5:latest",
"messages": [{"role": "user", "content": "Explica qué es FastAPI en dos frases."}],
"stream": false
}'
Petición con streaming (notarás los tokens llegando poco a poco):
curl -X POST http://localhost:8000/v1/chat/completions \
-H "Authorization: Bearer sk-miapi-super-secreta" \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3.5:latest",
"messages": [{"role": "user", "content": "Cuenta un chiste corto sobre programadores."}],
"stream": true
}'
Petición sin API Key (debe retornar 403):
curl -X POST http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "qwen3.5:latest", "messages":[{"role":"user","content":"Hola"}]}'
5. Despliegue con Docker Compose
Para entornos productivos o para que la API se reinicie automáticamente, encapsúlala en un contenedor junto con Ollama.
5.1 Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
5.2 docker-compose.yml
version: "3.9"
services:
ollama:
image: ollama/ollama:0.1.48
volumes:
- ollama_data:/root/.ollama
ports:
- "11434:11434"
restart: unless-stopped
api:
build: .
ports:
- "8000:8000"
environment:
- OLLAMA_BASE_URL=http://ollama:11434
- API_KEY=${API_KEY:-sk-miapi-super-secreta}
- MODEL_NAME=${MODEL_NAME:-qwen3.5:latest}
-LOG_LEVEL=INFO
depends_on:
- ollama
restart: unless-stopped
volumes:
ollama_data:
Nota: La primera vez, es necesario entrar al contenedor de Ollama y descargar el modelo con:
docker compose exec ollama ollama pull qwen3.5:latest
Levantar los servicios:
API_KEY=sk-miapi-super-secreta docker compose up -d
La API estará accesible en http://localhost:8000, y Ollama no está expuesto al exterior (solo la API lo consume internamente).
6. Clientes compatibles con el estándar OpenAI
Al seguir el formato, puedes usar el SDK de Python de OpenAI apuntando a tu API:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1",
api_key="sk-miapi-super-secreta")
respuesta = client.chat.completions.create(
model="qwen3.5:latest",
messages=[{"role": "user", "content": "¡Hola!"}],
stream=True
)
for chunk in respuesta:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
Esto abre la puerta a integrar tu LLM local con cualquier herramienta que ya hable OpenAI: LangChain, AutoGPT, plugins de VS Code, etc.
7. Errores frecuentes y soluciones
| Error | Causa | Solución |
|---|---|---|
403 API Key inválida |
Falta el header Authorization o el token no coincide con .env |
Verifica que el cliente envíe Authorization: Bearer <clave> con la misma clave definida en API_KEY. |
502 Bad Gateway / 503 Service Unavailable |
Ollama no responde, está caído o la URL base es incorrecta | Asegúrate de que Ollama esté corriendo y que OLLAMA_BASE_URL apunte correctamente. Con Docker Compose, usa el nombre del servicio (ollama:11434). |
ConnectionError al iniciar la API |
El puerto 8000 ya está ocupado o la dirección 0.0.0.0 no está disponible |
Cambia el puerto (--port 8001) o detén el proceso que ocupe el puerto. |
| El streaming no funciona y la respuesta llega completa al final | El servidor web intermedio (Nginx, proxy) está buffereando la respuesta | Desactiva el buffering en el proxy (proxy_buffering off; en Nginx) o prueba directamente contra la API sin proxy. |
Rate limit exceeded |
Has superado el límite de 10 peticiones por minuto desde tu IP | Ajusta el decorador @limiter.limit(...) a una tasa mayor o implementa una lógica de colas. |
La API responde muy lento con stream=False |
Ollama genera todo el texto antes de enviar la respuesta y la conexión HTTP espera | Usa stream=True para empezar a recibir tokens inmediatamente u optimiza el timeout del cliente HTTP. |
| El endpoint de streaming corta la respuesta en mitad de una palabra | Cierre prematuro de la conexión SSE | Asegura que el cliente soporte SSE y no cierre la conexión antes de recibir [DONE]. En curl, usa --no-buffer. |
8. Casos prácticos de uso
8.1 Alimentar un chatbot interno de la empresa
Con la API expuesta en una red interna, montas un frontend sencillo en Next.js que consume el endpoint stream=true. Los empleados chatean con el LLM corporativo sin que los datos salgan de la intranet.
8.2 Automatización de respuestas en tickets de soporte
Un script de Python recibe tickets por webhook, consulta a la API con el historial, obtiene una sugerencia de respuesta, y la deja como borrador en el sistema de tickets. Sin APIs externas.
8.3 Asistente de código en VS Code con Continue
En la configuración de Continue, apuntas apiBase a http://localhost:8000/v1 y la API key configurada. Todo el autocompletado y chat se ejecutan en tu LLM local a través de la API segura.
8.4 Laboratorio de prompt engineering con monitoreo
Al tener logs en la API, puedes medir la latencia y el uso de cada prompt. Construyes una pequeña interfaz que te deja iterar rápido y comparar configuraciones de temperature o max_tokens sin tocar código.
9. Buenas prácticas
- Usa siempre
stream=Truepara mejorar la experiencia de usuario. La percepción de velocidad es mucho mejor si los tokens aparecen progresivamente. - Protege la API con TLS (HTTPS) si se expone más allá de localhost. Puedes usar Caddy o Nginx como proxy inverso con Let's Encrypt.
- Rota la API Key periódicamente y almacénala en un gestor de secretos (Vault, secrets de Docker, etc.).
- Ajusta el rate limit según el hardware. Si tu GPU es potente, puedes permitir más peticiones simultáneas; si el modelo va sobre CPU, limita más para evitar saturaciones.
- No registres los prompts completos (solo metadatos como longitud y modelo). Así preservas la privacidad de los usuarios y minimizas el almacenamiento de datos sensibles.
- Implementa métricas con Prometheus (puedes usar
prometheus-fastapi-instrumentator) para visualizar el uso y las latencias en Grafana. Esta API es una pieza de infraestructura más; trátala como tal. - Empaqueta la API como un servicio systemd si no usas Docker, para que se reinicie automáticamente tras un reinicio del sistema.
10. Cierre con idea clave
Tu modelo local pasa de ser una curiosidad de terminal a un servicio profesional cuando le pones una API bien diseñada por delante. Con menos de 150 líneas de Python, streaming real y un puñado de buenas prácticas, tienes un backend de IA privado, seguro y monitorizable al que cualquier herramienta de tu ecosistema puede conectarse. El valor no está solo en el modelo, sino en lo fácil que resulta integrarlo en todo lo demás.