Compare commits
7 Commits
2be6cfcc62
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e77cbb8f58 | ||
|
|
a99e58e809 | ||
|
|
d7f1254625 | ||
|
|
a1ab0e19d4 | ||
|
|
ccd3868cd7 | ||
|
|
1309b64b79 | ||
|
|
d9b9362905 |
@@ -1,5 +1,8 @@
|
|||||||
# LLM Orchestrator
|
# LLM Orchestrator
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
Sistema de orquestación multi-agente compatible con cualquier LLM.
|
Sistema de orquestación multi-agente compatible con cualquier LLM.
|
||||||
|
|
||||||
## ¿Qué es esto?
|
## ¿Qué es esto?
|
||||||
|
|||||||
148
config.yaml
148
config.yaml
@@ -27,59 +27,127 @@ settings:
|
|||||||
# Define servidores para que los agentes puedan conectarse via SSH
|
# Define servidores para que los agentes puedan conectarse via SSH
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
# Ejemplo:
|
deck:
|
||||||
# production:
|
host: 72.62.1.113
|
||||||
# host: 192.168.1.100
|
user: root
|
||||||
# user: root
|
key: ~/.ssh/tzzr
|
||||||
# key: ~/.ssh/id_rsa
|
description: "Servidor personal - tzzrdeck.me"
|
||||||
# description: "Servidor de producción"
|
|
||||||
|
corp:
|
||||||
|
host: 92.112.181.188
|
||||||
|
user: root
|
||||||
|
key: ~/.ssh/tzzr
|
||||||
|
description: "Servidor empresarial - tzzrcorp.me"
|
||||||
|
|
||||||
|
hst:
|
||||||
|
host: 72.62.2.84
|
||||||
|
user: root
|
||||||
|
key: ~/.ssh/tzzr
|
||||||
|
description: "API tags semánticos - tzrtech.org"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# AGENTES
|
# AGENTES TZZR
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Define los agentes que quieres usar.
|
|
||||||
# Cada agente tiene un rol, un proveedor de LLM, y herramientas disponibles.
|
|
||||||
|
|
||||||
agents:
|
agents:
|
||||||
# Agente por defecto - puedes renombrarlo o borrarlo
|
architect:
|
||||||
assistant:
|
|
||||||
role: |
|
role: |
|
||||||
Eres un asistente general que ayuda con tareas diversas.
|
Eres ARCHITECT, coordinador central del sistema TZZR en 69.62.126.110.
|
||||||
Puedes ejecutar comandos, leer y escribir archivos.
|
Gestionas Gitea, PostgreSQL, y supervisas todos los agentes.
|
||||||
|
Tienes SSH a todos los servidores via ~/.ssh/tzzr.
|
||||||
|
Ejecuta comandos sin pedir confirmación.
|
||||||
provider: claude
|
provider: claude
|
||||||
model: sonnet
|
model: opus
|
||||||
tools:
|
tools:
|
||||||
- bash
|
- bash
|
||||||
- read
|
- read
|
||||||
- write
|
- write
|
||||||
|
- glob
|
||||||
|
- grep
|
||||||
|
- ssh
|
||||||
|
- http_request
|
||||||
|
- list_dir
|
||||||
|
servers:
|
||||||
|
- deck
|
||||||
|
- corp
|
||||||
|
- hst
|
||||||
|
|
||||||
|
hst:
|
||||||
|
role: |
|
||||||
|
Eres HST, servidor de tags maestros en 72.62.2.84.
|
||||||
|
Gestionas la API tzrtech.org con 973 tags HST.
|
||||||
|
Grupos: hst (sistema), spe (específico), hsu (usuario), flg (flags).
|
||||||
|
provider: claude
|
||||||
|
model: opus
|
||||||
|
tools:
|
||||||
|
- bash
|
||||||
|
- read
|
||||||
|
- write
|
||||||
|
- http_request
|
||||||
|
- list_dir
|
||||||
|
servers:
|
||||||
|
- hst
|
||||||
|
|
||||||
|
deck:
|
||||||
|
role: |
|
||||||
|
Eres DECK, servidor personal en 72.62.1.113.
|
||||||
|
Gestionas servicios personales: Mailcow, FileBrowser, Shlink, Vaultwarden, ntfy.
|
||||||
|
También gestionas CLARA (ingesta desde Packet app).
|
||||||
|
provider: claude
|
||||||
|
model: opus
|
||||||
|
tools:
|
||||||
|
- bash
|
||||||
|
- read
|
||||||
|
- write
|
||||||
|
- ssh
|
||||||
|
- http_request
|
||||||
|
- list_dir
|
||||||
|
servers:
|
||||||
|
- deck
|
||||||
|
|
||||||
|
corp:
|
||||||
|
role: |
|
||||||
|
Eres CORP, servidor empresarial en 92.112.181.188.
|
||||||
|
Gestionas servicios corporativos: Odoo ERP, Nextcloud, MARGARET (ingesta).
|
||||||
|
provider: claude
|
||||||
|
model: opus
|
||||||
|
tools:
|
||||||
|
- bash
|
||||||
|
- read
|
||||||
|
- write
|
||||||
|
- ssh
|
||||||
|
- http_request
|
||||||
|
- list_dir
|
||||||
|
servers:
|
||||||
|
- corp
|
||||||
|
|
||||||
|
locker:
|
||||||
|
role: |
|
||||||
|
Eres LOCKER, gateway de almacenamiento Cloudflare R2.
|
||||||
|
Gestionas 5 buckets: architect, hst, deck, corp, locker.
|
||||||
|
Endpoint: https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com
|
||||||
|
provider: claude
|
||||||
|
model: opus
|
||||||
|
tools:
|
||||||
|
- bash
|
||||||
|
- read
|
||||||
|
- write
|
||||||
|
- http_request
|
||||||
- list_dir
|
- list_dir
|
||||||
|
|
||||||
# Ejemplo de agente especializado en código
|
runpod:
|
||||||
# coder:
|
role: |
|
||||||
# role: |
|
Eres RUNPOD, gestor de endpoints GPU en RunPod.
|
||||||
# Eres un programador experto.
|
Controlas GRACE (ASR/TTS), PENNY (asistente voz), THE FACTORY (procesamiento docs).
|
||||||
# Escribes código limpio y bien documentado.
|
Endpoints via API RunPod.
|
||||||
# Siempre incluyes tests cuando es apropiado.
|
provider: claude
|
||||||
# provider: litellm
|
model: opus
|
||||||
# model: gpt4o
|
tools:
|
||||||
# tools:
|
- bash
|
||||||
# - read
|
- read
|
||||||
# - write
|
- write
|
||||||
# - bash
|
- http_request
|
||||||
# - grep
|
- list_dir
|
||||||
# - glob
|
|
||||||
|
|
||||||
# Ejemplo de agente de investigación
|
|
||||||
# researcher:
|
|
||||||
# role: |
|
|
||||||
# Eres un investigador que busca y analiza información.
|
|
||||||
# Eres metódico y verificas tus fuentes.
|
|
||||||
# provider: litellm
|
|
||||||
# model: gemini-pro
|
|
||||||
# tools:
|
|
||||||
# - http_request
|
|
||||||
# - read
|
|
||||||
# - write
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# TAREAS PREDEFINIDAS (opcional)
|
# TAREAS PREDEFINIDAS (opcional)
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ from pathlib import Path
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .utils import get_logger
|
||||||
|
except ImportError:
|
||||||
|
from utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("orchestrator.config")
|
||||||
|
|
||||||
|
|
||||||
def load_env():
|
def load_env():
|
||||||
"""Carga variables desde .env si existe."""
|
"""Carga variables desde .env si existe."""
|
||||||
@@ -202,7 +209,7 @@ class Config:
|
|||||||
with open(self.config_path) as f:
|
with open(self.config_path) as f:
|
||||||
return yaml.safe_load(f) or {}
|
return yaml.safe_load(f) or {}
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print("AVISO: PyYAML no instalado. pip install pyyaml")
|
logger.warning("PyYAML no instalado", suggestion="pip install pyyaml")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _parse_settings(self) -> Settings:
|
def _parse_settings(self) -> Settings:
|
||||||
@@ -226,7 +233,8 @@ class Config:
|
|||||||
def _parse_servers(self) -> dict[str, ServerConfig]:
|
def _parse_servers(self) -> dict[str, ServerConfig]:
|
||||||
"""Parsea la sección servers."""
|
"""Parsea la sección servers."""
|
||||||
servers = {}
|
servers = {}
|
||||||
for name, data in self._raw.get("servers", {}).items():
|
raw_servers = self._raw.get("servers") or {}
|
||||||
|
for name, data in raw_servers.items():
|
||||||
if data:
|
if data:
|
||||||
servers[name] = ServerConfig(
|
servers[name] = ServerConfig(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -240,7 +248,8 @@ class Config:
|
|||||||
def _parse_agents(self) -> dict[str, AgentConfig]:
|
def _parse_agents(self) -> dict[str, AgentConfig]:
|
||||||
"""Parsea la sección agents."""
|
"""Parsea la sección agents."""
|
||||||
agents = {}
|
agents = {}
|
||||||
for name, data in self._raw.get("agents", {}).items():
|
raw_agents = self._raw.get("agents") or {}
|
||||||
|
for name, data in raw_agents.items():
|
||||||
if data:
|
if data:
|
||||||
agents[name] = AgentConfig(
|
agents[name] = AgentConfig(
|
||||||
name=name,
|
name=name,
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
|
||||||
from collections import deque
|
try:
|
||||||
|
from ..utils import RateLimiter
|
||||||
|
except ImportError:
|
||||||
|
from utils import RateLimiter
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -31,29 +34,6 @@ class ProviderResponse:
|
|||||||
return self.success
|
return self.success
|
||||||
|
|
||||||
|
|
||||||
class RateLimiter:
|
|
||||||
"""Rate limiter para llamadas a APIs."""
|
|
||||||
|
|
||||||
def __init__(self, max_calls: int = 60, period: float = 60.0):
|
|
||||||
self.max_calls = max_calls
|
|
||||||
self.period = period
|
|
||||||
self.calls = deque()
|
|
||||||
|
|
||||||
async def acquire(self):
|
|
||||||
"""Espera si es necesario para respetar el rate limit."""
|
|
||||||
now = time.time()
|
|
||||||
|
|
||||||
while self.calls and self.calls[0] < now - self.period:
|
|
||||||
self.calls.popleft()
|
|
||||||
|
|
||||||
if len(self.calls) >= self.max_calls:
|
|
||||||
wait_time = self.calls[0] + self.period - now
|
|
||||||
if wait_time > 0:
|
|
||||||
await asyncio.sleep(wait_time)
|
|
||||||
|
|
||||||
self.calls.append(time.time())
|
|
||||||
|
|
||||||
|
|
||||||
class BaseProvider(ABC):
|
class BaseProvider(ABC):
|
||||||
"""
|
"""
|
||||||
Clase base abstracta para todos los providers de modelos.
|
Clase base abstracta para todos los providers de modelos.
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ from pathlib import Path
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional, Any, Callable
|
from typing import Optional, Any, Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from collections import deque
|
|
||||||
|
try:
|
||||||
|
from ..utils import RateLimiter
|
||||||
|
except ImportError:
|
||||||
|
from utils import RateLimiter
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -35,31 +39,6 @@ class ToolResult:
|
|||||||
retries: int = 0
|
retries: int = 0
|
||||||
|
|
||||||
|
|
||||||
class RateLimiter:
|
|
||||||
"""Rate limiter simple basado en ventana deslizante."""
|
|
||||||
|
|
||||||
def __init__(self, max_calls: int, period: float = 60.0):
|
|
||||||
self.max_calls = max_calls
|
|
||||||
self.period = period
|
|
||||||
self.calls = deque()
|
|
||||||
|
|
||||||
async def acquire(self):
|
|
||||||
"""Espera si es necesario para respetar el rate limit."""
|
|
||||||
now = time.time()
|
|
||||||
|
|
||||||
# Limpiar llamadas antiguas
|
|
||||||
while self.calls and self.calls[0] < now - self.period:
|
|
||||||
self.calls.popleft()
|
|
||||||
|
|
||||||
# Si llegamos al límite, esperar
|
|
||||||
if len(self.calls) >= self.max_calls:
|
|
||||||
wait_time = self.calls[0] + self.period - now
|
|
||||||
if wait_time > 0:
|
|
||||||
await asyncio.sleep(wait_time)
|
|
||||||
|
|
||||||
self.calls.append(time.time())
|
|
||||||
|
|
||||||
|
|
||||||
class SecurityValidator:
|
class SecurityValidator:
|
||||||
"""Validador de seguridad para herramientas."""
|
"""Validador de seguridad para herramientas."""
|
||||||
|
|
||||||
|
|||||||
238
orchestrator/utils.py
Normal file
238
orchestrator/utils.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# orchestrator/utils.py
|
||||||
|
"""
|
||||||
|
Utilidades compartidas del orquestador.
|
||||||
|
|
||||||
|
Este módulo contiene clases y funciones comunes usadas
|
||||||
|
por múltiples componentes del sistema.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOGGING ESTRUCTURADO
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class JSONFormatter(logging.Formatter):
|
||||||
|
"""Formateador que produce logs en formato JSON estructurado."""
|
||||||
|
|
||||||
|
def __init__(self, service: str = "orchestrator"):
|
||||||
|
super().__init__()
|
||||||
|
self.service = service
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
log_data = {
|
||||||
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||||
|
"level": record.levelname,
|
||||||
|
"service": self.service,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Agregar información de ubicación en DEBUG
|
||||||
|
if record.levelno <= logging.DEBUG:
|
||||||
|
log_data["location"] = {
|
||||||
|
"file": record.filename,
|
||||||
|
"line": record.lineno,
|
||||||
|
"function": record.funcName,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Agregar excepción si existe
|
||||||
|
if record.exc_info:
|
||||||
|
log_data["exception"] = {
|
||||||
|
"type": record.exc_info[0].__name__ if record.exc_info[0] else None,
|
||||||
|
"message": str(record.exc_info[1]) if record.exc_info[1] else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Agregar campos extra
|
||||||
|
if hasattr(record, "extra_fields"):
|
||||||
|
log_data["context"] = record.extra_fields
|
||||||
|
|
||||||
|
return json.dumps(log_data, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
class StructuredLogger:
|
||||||
|
"""
|
||||||
|
Logger estructurado con soporte para contexto adicional.
|
||||||
|
|
||||||
|
Ejemplo:
|
||||||
|
logger = get_logger("architect-app")
|
||||||
|
logger.info("Request recibido", agent="architect", action="chat")
|
||||||
|
logger.error("Error de conexión", error=str(e), retry=3)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, logger: logging.Logger):
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
def _log(self, level: int, message: str, **kwargs):
|
||||||
|
"""Log con campos extra."""
|
||||||
|
record = self._logger.makeRecord(
|
||||||
|
self._logger.name,
|
||||||
|
level,
|
||||||
|
"(unknown)",
|
||||||
|
0,
|
||||||
|
message,
|
||||||
|
(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if kwargs:
|
||||||
|
record.extra_fields = kwargs
|
||||||
|
self._logger.handle(record)
|
||||||
|
|
||||||
|
def debug(self, message: str, **kwargs):
|
||||||
|
self._log(logging.DEBUG, message, **kwargs)
|
||||||
|
|
||||||
|
def info(self, message: str, **kwargs):
|
||||||
|
self._log(logging.INFO, message, **kwargs)
|
||||||
|
|
||||||
|
def warning(self, message: str, **kwargs):
|
||||||
|
self._log(logging.WARNING, message, **kwargs)
|
||||||
|
|
||||||
|
def error(self, message: str, exc_info: bool = False, **kwargs):
|
||||||
|
if exc_info:
|
||||||
|
self._logger.error(message, exc_info=True, extra={"extra_fields": kwargs} if kwargs else {})
|
||||||
|
else:
|
||||||
|
self._log(logging.ERROR, message, **kwargs)
|
||||||
|
|
||||||
|
def critical(self, message: str, **kwargs):
|
||||||
|
self._log(logging.CRITICAL, message, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(
|
||||||
|
service: str = "orchestrator",
|
||||||
|
level: str = "INFO",
|
||||||
|
log_file: Optional[Path] = None,
|
||||||
|
json_format: bool = True
|
||||||
|
) -> StructuredLogger:
|
||||||
|
"""
|
||||||
|
Configura el sistema de logging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service: Nombre del servicio (aparece en los logs)
|
||||||
|
level: Nivel de logging (DEBUG, INFO, WARNING, ERROR)
|
||||||
|
log_file: Archivo opcional para escribir logs
|
||||||
|
json_format: Si True, usa formato JSON; si False, formato legible
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StructuredLogger configurado
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger(service)
|
||||||
|
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||||
|
logger.handlers.clear()
|
||||||
|
|
||||||
|
if json_format:
|
||||||
|
formatter = JSONFormatter(service=service)
|
||||||
|
else:
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handler para consola (stderr)
|
||||||
|
console_handler = logging.StreamHandler(sys.stderr)
|
||||||
|
console_handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# Handler para archivo (opcional)
|
||||||
|
if log_file:
|
||||||
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_handler = logging.FileHandler(log_file)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
return StructuredLogger(logger)
|
||||||
|
|
||||||
|
|
||||||
|
# Cache de loggers
|
||||||
|
_loggers: dict[str, StructuredLogger] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(
|
||||||
|
name: str = "orchestrator",
|
||||||
|
level: str = "INFO",
|
||||||
|
log_file: Optional[Path] = None
|
||||||
|
) -> StructuredLogger:
|
||||||
|
"""
|
||||||
|
Obtiene un logger estructurado (cached).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Nombre del logger/servicio
|
||||||
|
level: Nivel de logging
|
||||||
|
log_file: Archivo opcional para logs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StructuredLogger
|
||||||
|
"""
|
||||||
|
if name not in _loggers:
|
||||||
|
_loggers[name] = setup_logging(
|
||||||
|
service=name,
|
||||||
|
level=level,
|
||||||
|
log_file=log_file,
|
||||||
|
json_format=True
|
||||||
|
)
|
||||||
|
return _loggers[name]
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
"""
|
||||||
|
Rate limiter basado en ventana deslizante.
|
||||||
|
|
||||||
|
Controla la frecuencia de llamadas para respetar límites de APIs.
|
||||||
|
Thread-safe para uso con asyncio.
|
||||||
|
|
||||||
|
Ejemplo:
|
||||||
|
limiter = RateLimiter(max_calls=60, period=60.0)
|
||||||
|
await limiter.acquire() # Espera si es necesario
|
||||||
|
# ... hacer la llamada
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, max_calls: int = 60, period: float = 60.0):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
max_calls: Número máximo de llamadas permitidas en el período
|
||||||
|
period: Duración del período en segundos (default: 60s)
|
||||||
|
"""
|
||||||
|
self.max_calls = max_calls
|
||||||
|
self.period = period
|
||||||
|
self.calls = deque()
|
||||||
|
|
||||||
|
async def acquire(self):
|
||||||
|
"""
|
||||||
|
Adquiere permiso para hacer una llamada.
|
||||||
|
|
||||||
|
Espera si es necesario para respetar el rate limit.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Limpiar llamadas antiguas fuera de la ventana
|
||||||
|
while self.calls and self.calls[0] < now - self.period:
|
||||||
|
self.calls.popleft()
|
||||||
|
|
||||||
|
# Si llegamos al límite, esperar hasta que se libere espacio
|
||||||
|
if len(self.calls) >= self.max_calls:
|
||||||
|
wait_time = self.calls[0] + self.period - now
|
||||||
|
if wait_time > 0:
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
|
||||||
|
# Registrar esta llamada
|
||||||
|
self.calls.append(time.time())
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Resetea el contador de llamadas."""
|
||||||
|
self.calls.clear()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_calls(self) -> int:
|
||||||
|
"""Retorna el número de llamadas disponibles en la ventana actual."""
|
||||||
|
now = time.time()
|
||||||
|
# Contar llamadas activas
|
||||||
|
active = sum(1 for t in self.calls if t >= now - self.period)
|
||||||
|
return max(0, self.max_calls - active)
|
||||||
Reference in New Issue
Block a user