Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,19 @@ REDIS_URL=redis://localhost:6379/0
# Claves API de proveedores
GROQ_API_KEY=tu_clave_groq_aqui
OPENROUTER_API_KEY=tu_clave_openrouter_aqui
OPENAI_API_KEY=tu_clave_openai_aqui
# OLLAMA_API_KEY= # Opcional, Ollama local no requiere autenticación

# Configuración de proveedores
GROQ_BASE_URL=https://api.groq.com/openai/v1
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENAI_BASE_URL=https://api.openai.com/v1
OLLAMA_BASE_URL=http://localhost:11434

# Modelos por defecto por proveedor
GROQ_DEFAULT_MODEL=llama-3.3-70b-versatile
OPENROUTER_DEFAULT_MODEL=openai/gpt-3.5-turbo
OPENAI_DEFAULT_MODEL=openai/gpt-3.5-turbo
OLLAMA_DEFAULT_MODEL=llama3.2:1b

# Timeouts (en segundos)
Expand All @@ -46,6 +49,7 @@ MAX_CONCURRENT_STREAMS=10
# Útil para ajustar planes o límites específicos del proveedor
# GROQ_RATE_LIMIT=30
# OPENROUTER_RATE_LIMIT=20
# OPENAI_RATE_LIMIT=30
# OLLAMA_RATE_LIMIT=100

# Autenticación
Expand Down
34 changes: 19 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ModelRouter

**API HTTP asíncrona con streaming** que orquesta múltiples proveedores de LLM (Groq, OpenRouter y Ollama) con fallback automático y observabilidad.
**API HTTP asíncrona con streaming** que orquesta múltiples proveedores de LLM con fallback automático y observabilidad.

[![CI/CD](https://github.com/HC-ONLINE/ModelRouter/workflows/CI%2FCD%20Pipeline/badge.svg)](https://github.com/HC-ONLINE/ModelRouter/actions)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
Expand All @@ -11,10 +11,14 @@

## Características Principales

* **Orquestación Multi-proveedor:** Fallback automático entre Groq, OpenRouter y Ollama.
* **Streaming Nativo:** Soporte para Server-Sent Events (SSE).
* **Resiliencia:** Rate limiting, blacklist temporal y backoff exponencial.
* **Production Ready:** Métricas Prometheus, logs estructurados y Dockerizado.
- **Orquestación Multi-proveedor:** Fallback automático entre:
- Groq
- OpenRouter
- OpenAI
- Ollama
- **Streaming Nativo:** Soporte para Server-Sent Events (SSE).
- **Resiliencia:** Rate limiting, blacklist temporal y backoff exponencial.
- **Production Ready:** Métricas Prometheus, logs estructurados y Dockerizado.

---

Expand Down Expand Up @@ -67,12 +71,12 @@ curl -N -X POST http://localhost:8000/stream \

### Documentación Detallada

* [Arquitectura](docs/architecture.md) - Cómo funciona internamente.
* [Configuración](docs/configuration.md) - Variables de entorno y rate limits.
* [Ejemplos de Uso](docs/examples.md) - Ejemplos con `curl` y `fetch`.
* [Desarrollo](docs/development.md) - Guía para contribuir, tests y linting.
* [Observabilidad](docs/observability.md) - Métricas y Logs.
* [Seguridad](docs/security.md) - Notas de seguridad y legal.
- [Arquitectura](docs/architecture.md) - Cómo funciona internamente.
- [Configuración](docs/configuration.md) - Variables de entorno y rate limits.
- [Ejemplos de Uso](docs/examples.md) - Ejemplos con `curl` y `fetch`.
- [Desarrollo](docs/development.md) - Guía para contribuir, tests y linting.
- [Observabilidad](docs/observability.md) - Métricas y Logs.
- [Seguridad](docs/security.md) - Notas de seguridad y legal.

Estos documentos están en la carpeta `docs/`.

Expand Down Expand Up @@ -102,10 +106,10 @@ Este proyecto está bajo la Licencia Apache-2.0 (Apache License 2.0). Ver [LICEN

Este proyecto es para **uso personal**. Asegúrate de:

* Leer y cumplir los **Terms of Service** de los proveedores usados
* No usar rotación de proveedores para **evadir límites** de uso
* Respetar **rate limits** y políticas de cada proveedor
* No almacenar/procesar datos sensibles sin las medidas de seguridad apropiadas
- Leer y cumplir los **Terms of Service** de los proveedores usados
- No usar rotación de proveedores para **evadir límites** de uso
- Respetar **rate limits** y políticas de cada proveedor
- No almacenar/procesar datos sensibles sin las medidas de seguridad apropiadas

**El autor no se hace responsable del uso indebido de esta herramienta.**

Expand Down
25 changes: 13 additions & 12 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,23 @@

## Completado

- [X] Scaffold proyecto + Docker
- [X] Adapters Groq, OpenRouter y Ollama
- [X] Router con fallback
- [X] Orchestrator
- [X] Endpoints /chat y /stream
- [X] Métricas y logging
- [X] Tests unitarios
- [X] CI/CD
- [X] Definir Rate Limiting por proveedor
- [X] Selección Explícita de Modelo por Proveedor
- [X] Permitir especificar de forma opcional un proveedor en la request
- [x] Scaffold proyecto + Docker
- [x] Adapters Groq, OpenRouter y Ollama
- [x] Router con fallback
- [x] Orchestrator
- [x] Endpoints /chat y /stream
- [x] Métricas y logging
- [x] Tests unitarios
- [x] CI/CD
- [x] Definir Rate Limiting por proveedor
- [x] Selección Explícita de Modelo por Proveedor
- [x] Permitir especificar de forma opcional un proveedor en la request
- [x] Soporte para más proveedores (OpenAI)

## Próximos pasos

- [ ] Persistencia de historiales (PostgreSQL)
- [ ] Soporte para más proveedores (Anthropic, OpenAI)
- [ ] Soporte para más proveedores (Anthropic, Gemini, etc.)
- [ ] Dashboard Grafana pre-configurado

para mas información, ver las issues en el repositorio de GitHub.
5 changes: 5 additions & 0 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,19 @@ class Settings(BaseSettings):
# Claves API
groq_api_key: Optional[str] = None
openrouter_api_key: Optional[str] = None
openai_api_key: Optional[str] = None
ollama_api_key: Optional[str] = None # Opcional, Ollama local no lo requiere

# URLs base de proveedores
groq_base_url: str = "https://api.groq.com/openai/v1"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
openai_base_url: str = "https://api.openai.com/v1"
ollama_base_url: str = "http://localhost:11434"

# Modelos por defecto por proveedor
groq_default_model: str = "llama-3.3-70b-versatile"
openrouter_default_model: str = "openai/gpt-3.5-turbo"
openai_default_model: str = "gpt-4o-mini"
ollama_default_model: str = "llama3.2:1b"

# Timeouts (segundos)
Expand All @@ -57,6 +60,7 @@ class Settings(BaseSettings):
# El límite global solo se usa si se elimina explícitamente este campo.
groq_rate_limit: int = 30
openrouter_rate_limit: int = 20
openai_rate_limit: int = 30
ollama_rate_limit: int = 100

# Autenticación
Expand All @@ -80,6 +84,7 @@ def get_provider_rate_limit(self, provider_name: str) -> int:
provider_limits = {
"groq": self.groq_rate_limit,
"openrouter": self.openrouter_rate_limit,
"openai": self.openai_rate_limit,
"ollama": self.ollama_rate_limit,
}
return provider_limits.get(provider_name) or self.rate_limit_requests_per_minute
Expand Down
14 changes: 14 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from api.providers.base import ProviderAdapter
from api.providers.groq_adapter import GroqAdapter
from api.providers.openrouter_adapter import OpenRouterAdapter
from api.providers.openai_adapter import OpenAIAdapter
from api.providers.ollama_adapter import OllamaAdapter
from api.router import Router
from api.orchestrator import Orchestrator
Expand Down Expand Up @@ -77,6 +78,19 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"OPENROUTER_API_KEY no configurada, OpenRouter no estará disponible"
)

if settings.openai_api_key:
openai_adapter = OpenAIAdapter(
http_client=http_client,
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
timeout=settings.provider_timeout,
default_model=settings.openai_default_model,
)
providers.append(openai_adapter)
logger.info("Proveedor OpenAI configurado")
else:
logger.warning("OPENAI_API_KEY no configurada, OpenAI no estará disponible")

# Ollama: siempre intentar configurar (no requiere API key obligatoria)
try:
ollama_adapter = OllamaAdapter(
Expand Down
184 changes: 184 additions & 0 deletions api/providers/openai_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""
Adapter para OpenAI API.
Implementa el contrato ProviderAdapter para interactuar con OpenAI.
"""

from collections.abc import AsyncGenerator
import json
import logging
from typing import Optional
import httpx

from api.providers.base import ProviderAdapter
from api.schemas import ChatRequest, ChatResponse, ProviderError
from api.infra.http_client import HTTPClient

logger = logging.getLogger(__name__)


class OpenAIAdapter(ProviderAdapter):
"""Adapter para OpenAI API."""

name = "openai"

DEFAULT_MODEL = "gpt-4o-mini"

def __init__(
self,
http_client: HTTPClient,
api_key: str,
base_url: str = "https://api.openai.com/v1",
timeout: float = 30.0,
default_model: Optional[str] = None,
):
super().__init__(http_client, api_key, base_url, timeout)
self.default_model = default_model or self.DEFAULT_MODEL

def _build_payload(self, request: ChatRequest) -> dict:
messages = [
{"role": msg.role, "content": msg.content} for msg in request.messages
]

payload = {
"model": request.model or self.default_model,
"messages": messages,
"max_tokens": request.max_tokens,
"temperature": request.temperature,
"stream": request.stream,
}

return payload

async def stream(self, request: ChatRequest) -> AsyncGenerator[str, None]:
request.stream = True
payload = self._build_payload(request)
url = f"{self.base_url}/chat/completions"
headers = self._get_headers()

try:
async for chunk_bytes in self.http_client.stream_post(
url=url,
json=payload,
headers=headers,
timeout=self.timeout,
):
chunk_text = chunk_bytes.decode("utf-8")

for line in chunk_text.split("\n"):
line = line.strip()

if not line or not line.startswith("data: "):
continue

data_str = line[6:]

if data_str == "[DONE]":
break

try:
data = json.loads(data_str)
delta = data["choices"][0].get("delta", {})
content = delta.get("content")

if content:
yield content

except Exception:
continue

except httpx.HTTPStatusError as e:
raise self._handle_http_error(e.response.status_code, str(e))
except httpx.TimeoutException as e:
raise ProviderError(
provider=self.name,
code="TIMEOUT",
message=f"Timeout al conectar con OpenAI: {str(e)}",
retriable=True,
original_error=e,
)
except Exception as e:
from api.utils import log_provider_error

log_provider_error(
logger,
provider=self.name,
error_code="UNKNOWN_ERROR",
request_id=getattr(request, "request_id", None),
exc=e,
)
raise ProviderError(
provider=self.name,
code="UNKNOWN_ERROR",
message=f"Error inesperado: {str(e)}",
retriable=False,
original_error=e,
)

async def generate(self, request: ChatRequest) -> ChatResponse:
request.stream = False
payload = self._build_payload(request)
url = f"{self.base_url}/chat/completions"
headers = self._get_headers()

try:
response = await self.http_client.post(
url=url,
json=payload,
headers=headers,
timeout=self.timeout,
)

response.raise_for_status()
data = response.json()

if "choices" in data and len(data["choices"]) > 0:
text = data["choices"][0]["message"]["content"]
usage = data.get("usage", {})
provider_meta = {
"tokens_prompt": usage.get("prompt_tokens"),
"tokens_completion": usage.get("completion_tokens"),
"tokens_total": usage.get("total_tokens"),
}

return ChatResponse(
text=text,
provider=self.name,
model=data.get("model", self.default_model),
provider_meta=provider_meta,
)
else:
raise ProviderError(
provider=self.name,
code="INVALID_RESPONSE",
message="Respuesta de OpenAI no contiene choices",
retriable=False,
)

except httpx.HTTPStatusError as e:
raise self._handle_http_error(e.response.status_code, str(e))
except httpx.TimeoutException as e:
raise ProviderError(
provider=self.name,
code="TIMEOUT",
message=f"Timeout al conectar con OpenAI: {str(e)}",
retriable=True,
original_error=e,
)

except Exception as e:
from api.utils import log_provider_error

log_provider_error(
logger,
provider=self.name,
error_code="UNKNOWN_ERROR",
request_id=getattr(request, "request_id", None),
exc=e,
)
raise ProviderError(
provider=self.name,
code="UNKNOWN_ERROR",
message=f"Error inesperado: {str(e)}",
retriable=False,
original_error=e,
)
Loading