Inteligencia Artificial / Backend 12 min lectura

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

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, httpie o 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/completions compatible 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: HTTPBearer extrae el token del encabezado Authorization: Bearer <API_KEY>. Si no coincide con el valor de la variable de entorno API_KEY, devuelve 403. Esto evita que cualquiera use tu API sin permiso.
  • Rate limiting: slowapi limita 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/completions que esperan muchas librerías (como openai Python SDK). Esto significa que puedes apuntar el cliente de OpenAI directamente a tu API.
  • Streaming real: para stream=True, la función stream_ollama_response consume 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=True para 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.

Etiquetas: #fastapi #ollama #streaming #api #llm #autenticacion