rmi-backend/app/rate_limiter.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

685 lines
24 KiB
Python

"""
Rate-Limit-Aware Multi-Provider System
=======================================
Tracks usage across ALL free AI providers in real-time (Redis + memory).
Smart dispatcher routes to the provider with most remaining capacity.
Embedding cache avoids redundant API calls.
Provider limits (verified June 2026):
OpenRouter free (no credits): 50 req/day, 20 rpm
OpenRouter free ($10+ credits): 1000 req/day, 20 rpm ← we have credits
NVIDIA NIM free: 1000 credits, 40 rpm ← different keys, same account
Local BGE: unlimited, 0 cost
Hash fallback: unlimited, 0 cost
Auto-detects credit status and adjusts limits accordingly.
"""
import asyncio
import contextlib
import hashlib
import logging
import os
import time
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger("rate.limiter")
# ──────────────────────────────────────────────────────────────
# Verified Provider Limits (June 2026)
# ──────────────────────────────────────────────────────────────
@dataclass
class ProviderLimit:
id: str
name: str
model: str
dims: int
base_url: str
key_env: str
rpm: int # Requests per minute
rpd: int # Requests per day
free: bool = True
priority: int = 100 # Higher = preferred within free tier
timeout: float = 15.0
# Configured limits - auto-detected from credit status
PROVIDERS: list[ProviderLimit] = []
def configure_providers(has_openrouter_credits: bool = True):
"""Configure provider limits based on credit status."""
global PROVIDERS
# OpenRouter - different limits based on credit status
or_rpd = 1000 if has_openrouter_credits else 50
PROVIDERS = [
# ── Tier 1: Google Gemini #1 (3072d, FREE, best quality) ──
ProviderLimit(
id="gemini_1",
name="Google Gemini #1",
model="gemini-embedding-001",
dims=3072,
base_url="https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent",
key_env="GEMINI_API_KEY",
rpm=30,
rpd=1000,
free=True,
priority=115,
),
# ── Tier 1b: Google Gemini #2 (3072d, separate project quota) ──
ProviderLimit(
id="gemini_2",
name="Google Gemini #2",
model="gemini-embedding-001",
dims=3072,
base_url="https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent",
key_env="GEMINI_API_KEY_2",
rpm=30,
rpd=1000,
free=True,
priority=110,
),
# ── Tier 1c: Google Gemini #3 (3072d, separate project quota) ──
ProviderLimit(
id="gemini_3",
name="Google Gemini #3",
model="gemini-embedding-001",
dims=3072,
base_url="https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent",
key_env="GEMINI_API_KEY_3",
rpm=30,
rpd=1000,
free=True,
priority=105,
),
# ── Tier 1d: Google Vertex AI (768d, Cloud credits - separate from AI Studio) ──
ProviderLimit(
id="vertex_ai",
name="Google Vertex AI",
model="text-embedding-004",
dims=768,
base_url="vertex", # Special - handled by gcloud_manager
key_env="",
rpm=50,
rpd=5000,
free=True,
priority=102,
),
# ── Tier 2: Mistral Embed (1024d, 1B free tokens/month) ──
ProviderLimit(
id="mistral",
name="Mistral Embed",
model="mistral-embed",
dims=1024,
base_url="https://api.mistral.ai/v1/embeddings",
key_env="MISTRAL_API_KEY",
rpm=60,
rpd=50000,
free=True,
priority=98,
),
# ── Tier 3: NVIDIA via OpenRouter (2048d, FREE) ──
ProviderLimit(
id="nv_openrouter",
name="NVIDIA (OpenRouter)",
model="nvidia/llama-nemotron-embed-vl-1b-v2:free",
dims=2048,
base_url="https://openrouter.ai/api/v1/embeddings",
key_env="OPENROUTER_API_KEY",
rpm=20,
rpd=or_rpd,
free=True,
priority=100,
),
# ── Tier 2: NVIDIA Direct (separate quota from OpenRouter) ──
ProviderLimit(
id="nv_direct",
name="NVIDIA Direct",
model="nvidia/llama-nemotron-embed-vl-1b-v2",
dims=2048,
base_url="https://integrate.api.nvidia.com/v1/embeddings",
key_env="NVIDIA_API_KEY",
rpm=40,
rpd=1000,
free=True,
priority=95,
),
# ── Tier 3: BGE-M3 via OpenRouter (paid, minimized) ──
ProviderLimit(
id="bge_m3",
name="BGE-M3 (OpenRouter)",
model="BAAI/bge-m3",
dims=1024,
base_url="https://openrouter.ai/api/v1/embeddings",
key_env="OPENROUTER_API_KEY",
rpm=10,
rpd=100,
free=False,
priority=20,
),
# ── Tier 4: Local BGE-small (CPU, always available) ──
ProviderLimit(
id="local_bge",
name="Local BGE-small",
model="local/bge-small-en-v1.5",
dims=384,
base_url="",
key_env="",
rpm=99999,
rpd=99999,
free=True,
priority=10,
),
# ── Tier 5: Hash fallback (zero cost, deterministic) ──
ProviderLimit(
id="hash",
name="Hash Fallback",
model="hash-sha256",
dims=384,
base_url="",
key_env="",
rpm=99999,
rpd=99999,
free=True,
priority=0,
),
]
# Default: assume credits
configure_providers(has_openrouter_credits=True)
# ──────────────────────────────────────────────────────────────
# Redis-Backed Rate Tracker
# ──────────────────────────────────────────────────────────────
class RateTracker:
"""Tracks per-provider usage in Redis + memory. Survives restarts."""
def __init__(self):
self._redis = None
self._memory: dict[str, dict[str, int]] = {} # provider_id → {minute, day, total}
self._last_flush: dict[str, float] = {}
async def _connect(self):
if self._redis:
return
try:
import redis.asyncio as aioredis
self._redis = aioredis.Redis(
host=os.getenv("REDIS_HOST", "rmi-redis"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD", ""),
db=int(os.getenv("REDIS_DB", "0")),
socket_connect_timeout=2,
decode_responses=True,
)
await self._redis.ping()
except Exception as e:
logger.warning(f"RateTracker Redis unavailable: {e} - memory-only mode")
self._redis = None
async def _get_counts(self, provider_id: str) -> dict[str, int]:
"""Get current usage counts for a provider."""
today = time.strftime("%Y-%m-%d")
minute_key = f"rmi:rate:{provider_id}:{today}:{int(time.time() // 60)}"
if self._redis:
try:
day = await self._redis.get(f"rmi:rate:{provider_id}:{today}")
minute = await self._redis.get(minute_key)
total = await self._redis.get(f"rmi:rate:{provider_id}:total")
return {
"day": int(day or 0),
"minute": int(minute or 0),
"total": int(total or 0),
}
except Exception:
pass
# Fallback to memory
return self._memory.get(provider_id, {"day": 0, "minute": 0, "total": 0})
async def _incr(self, provider_id: str):
"""Increment usage counters."""
today = time.strftime("%Y-%m-%d")
minute_key = f"rmi:rate:{provider_id}:{today}:{int(time.time() // 60)}"
if self._redis:
try:
pipe = self._redis.pipeline()
pipe.incr(f"rmi:rate:{provider_id}:{today}")
pipe.expire(f"rmi:rate:{provider_id}:{today}", 86400)
pipe.incr(minute_key)
pipe.expire(minute_key, 120)
pipe.incr(f"rmi:rate:{provider_id}:total")
await pipe.execute()
return
except Exception:
pass
# Memory fallback
mem = self._memory.setdefault(provider_id, {"day": 0, "minute": 0, "total": 0})
mem["day"] += 1
mem["minute"] += 1
mem["total"] += 1
async def can_call(self, provider: ProviderLimit) -> bool:
"""Check if we can call this provider without hitting limits."""
counts = await self._get_counts(provider.id)
# Use 90% of limit as safety margin
day_margin = int(provider.rpd * 0.9)
minute_margin = int(provider.rpm * 0.9)
if counts["day"] >= day_margin:
logger.debug(f"{provider.name}: daily limit approached ({counts['day']}/{provider.rpd})")
return False
if counts["minute"] >= minute_margin:
logger.debug(f"{provider.name}: RPM limit approached ({counts['minute']}/{provider.rpm})")
return False
return True
async def record(self, provider: ProviderLimit):
"""Record a successful call."""
await self._incr(provider.id)
async def remaining(self, provider: ProviderLimit) -> dict[str, int]:
"""Get remaining capacity."""
counts = await self._get_counts(provider.id)
return {
"day_remaining": max(0, provider.rpd - counts["day"]),
"minute_remaining": max(0, provider.rpm - counts["minute"]),
"total_used": counts["total"],
}
async def stats(self) -> dict[str, Any]:
"""Get usage stats for all providers."""
result = {"providers": [], "timestamp": time.time()}
for p in PROVIDERS:
counts = await self._get_counts(p.id)
result["providers"].append(
{
"id": p.id,
"name": p.name,
"free": p.free,
"day_used": counts["day"],
"day_limit": p.rpd,
"minute_used": counts["minute"],
"minute_limit": p.rpm,
"total": counts["total"],
"available": await self.can_call(p),
"day_pct": round(counts["day"] / max(p.rpd, 1) * 100, 1),
}
)
return result
# ──────────────────────────────────────────────────────────────
# Embedding Cache
# ──────────────────────────────────────────────────────────────
class EmbeddingCache:
"""Caches embedding results to avoid redundant API calls.
Uses content hash → embedding mapping. Same text always returns
same embedding from the same provider. Zero API cost for repeats.
"""
def __init__(self, max_size: int = 10000):
self._cache: dict[str, tuple[list[float], str]] = {} # hash → (vector, provider)
self._max = max_size
self._hits = 0
self._misses = 0
def key(self, text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()[:32]
def get(self, text: str) -> tuple[list[float], str] | None:
k = self.key(text)
if k in self._cache:
self._hits += 1
return self._cache[k]
self._misses += 1
return None
def set(self, text: str, vector: list[float], provider: str):
if len(self._cache) >= self._max:
# Evict oldest 10%
keys = list(self._cache.keys())[: self._max // 10]
for k in keys:
del self._cache[k]
self._cache[self.key(text)] = (vector, provider)
# Persist to Redis asynchronously (best-effort)
self._save_to_redis()
def _save_to_redis(self):
"""Persist cache to Redis so it survives restarts."""
try:
import json
import redis
r = redis.Redis(
host="localhost",
port=6379,
db=3,
password=os.environ.get("REDIS_PASSWORD", ""),
socket_connect_timeout=2,
socket_timeout=2,
)
# Save 500 most recent entries with full vectors
entries = {k: [v[0], v[1]] for k, v in list(self._cache.items())[-500:]}
r.setex("embed_cache:data", 86400, json.dumps(entries))
except Exception:
pass
def load_from_redis(self):
"""Restore cache from Redis on startup."""
try:
import json
import redis
r = redis.Redis(
host="localhost",
port=6379,
db=3,
password=os.environ.get("REDIS_PASSWORD", ""),
socket_connect_timeout=2,
socket_timeout=2,
)
raw = r.get("embed_cache:data")
if raw:
entries = json.loads(raw)
for k, (vec, prov) in entries.items():
self._cache[k] = (vec, prov)
logger.info(f"Loaded {len(entries)} cache entries from Redis")
except Exception as e:
logger.info(f"Cache restore skipped: {e}")
def stats(self) -> dict:
total = self._hits + self._misses
return {
"size": len(self._cache),
"hits": self._hits,
"misses": self._misses,
"hit_rate": round(self._hits / max(total, 1) * 100, 1),
}
# ──────────────────────────────────────────────────────────────
# Smart Embedding Dispatcher
# ──────────────────────────────────────────────────────────────
class EmbeddingDispatcher:
"""Routes embedding requests to the best provider with available capacity."""
def __init__(self):
self.tracker = RateTracker()
self.cache = EmbeddingCache()
self._local_model = None
self._providers = PROVIDERS
async def init(self):
await self.tracker._connect()
# Restore cache from Redis if available
self.cache.load_from_redis()
try:
import sentence_transformers
self._local_model = sentence_transformers.SentenceTransformer("BAAI/bge-small-en-v1.5", device="cpu")
logger.info("Local BGE-small loaded")
except Exception as e:
logger.warning(f"Local model: {e}")
async def embed(self, texts: list[str], task: str = "default") -> tuple[list[list[float]], str]:
"""Smart embed with quality-tier routing.
task: 'scam_detection', 'user_search', 'news_ingestion', 'bulk_ingestion', etc.
Routes to appropriate provider tier - saves premium credits for critical tasks.
"""
from app.embed_tiers import get_allowed_providers, get_min_dims, get_tier
get_tier(task)
allowed = get_allowed_providers(task)
min_dims = get_min_dims(task)
if not texts:
return [], "none"
# Check cache first
cached = []
to_embed = []
for t in texts:
c = self.cache.get(t)
if c:
cached.append(c[0])
else:
to_embed.append(t)
if not to_embed:
return cached, "cache"
# Pick best available provider - filtered by tier
vectors = None
provider_name = "none"
for p in sorted(self._providers, key=lambda x: -x.priority):
# Tier filter: only use providers allowed for this task
if allowed and p.id not in allowed:
if p.id not in ("local_bge", "hash"): # Local/hash always available as fallback
continue
# Dimension filter
if p.dims < min_dims and p.id not in ("local_bge", "hash"):
continue
if not await self.tracker.can_call(p):
continue
# ── Local ──
if p.id == "local_bge":
try:
vecs = await self._embed_local(to_embed)
if vecs:
await self.tracker.record(p)
provider_name = p.name
vectors = vecs
break
except Exception as e:
logger.warning(f"Local: {e}")
continue
# ── Hash ──
if p.id == "hash":
vecs = self._embed_hash(to_embed)
await self.tracker.record(p)
provider_name = p.name
vectors = vecs
break
# ── Vertex AI (uses gcloud_manager, not direct API) ──
if p.id == "vertex_ai":
try:
from app.gcloud_manager import get_gcloud
gcloud = get_gcloud()
vecs = await gcloud.embed_vertex(to_embed)
if vecs:
await self.tracker.record(p)
provider_name = p.name
vectors = vecs
break
except Exception as e:
logger.warning(f"Vertex AI: {e}")
continue
# ── API providers ──
key = os.environ.get(p.key_env, "") or os.environ.get(p.key_env.replace("_KEY", "_API_KEY"), "")
if not key or len(key) < 10:
continue
try:
vecs = await self._embed_api(p, key, to_embed)
if vecs:
await self.tracker.record(p)
provider_name = p.name
vectors = vecs
break
except Exception as e:
logger.warning(f"{p.name}: {e}")
continue
# Absolute last resort
if vectors is None:
vectors = self._embed_hash(to_embed)
provider_name = "hash_emergency"
# Cache results
for text, vec in zip(to_embed, texts, strict=False):
self.cache.set(text, vec, provider_name)
# Stream usage to BigQuery (best-effort, non-blocking)
with contextlib.suppress(Exception):
asyncio.create_task(self._log_to_bigquery(provider_name, task, len(texts)))
# Merge with cached
result = []
ci = 0
for t in texts:
c = self.cache.get(t)
if c and ci < len(cached):
result.append(cached[ci])
ci += 1
elif vectors and len(result) - ci < len(vectors):
result.append(vectors[len(result) - ci])
else:
result.append([])
return result, provider_name
async def _log_to_bigquery(self, provider: str, task: str, count: int):
"""Log embedding usage to BigQuery (best-effort, fire-and-forget)."""
try:
from app.bigquery_pipeline import stream_embedding_usage
from app.rate_limiter import PROVIDERS
dims = next((p.dims for p in PROVIDERS if p.name == provider), 0)
await stream_embedding_usage(provider, task, dims, count)
except Exception:
pass
async def _embed_api(self, p: ProviderLimit, key: str, texts: list[str]) -> list[list[float]] | None:
import httpx
# Gemini uses different auth (key in query param) and different payload format
if p.id.startswith("gemini"):
vectors = []
async with httpx.AsyncClient(timeout=p.timeout) as c:
for text in texts:
r = await c.post(
f"{p.base_url}?key={key}",
json={"content": {"parts": [{"text": text}]}},
)
if r.status_code == 200:
data = r.json()
vec = data.get("embedding", {}).get("values", [])
if vec:
vectors.append(vec)
elif r.status_code == 429:
logger.warning(f"{p.name} rate limited (429)")
return None
else:
logger.warning(f"{p.name} HTTP {r.status_code}")
return None
return vectors if vectors else None
# OpenRouter / NVIDIA format
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
if "openrouter" in p.base_url:
headers["HTTP-Referer"] = "https://rugmunch.io"
headers["X-Title"] = "RMI SENTINEL"
payload = {"model": p.model, "input": texts}
async with httpx.AsyncClient(timeout=p.timeout) as c:
r = await c.post(p.base_url, json=payload, headers=headers)
if r.status_code == 429:
logger.warning(f"{p.name} rate limited (429)")
return None
if r.status_code != 200:
logger.warning(f"{p.name} HTTP {r.status_code}: {r.text[:150]}")
return None
data = r.json()
return [e.get("embedding", []) for e in data.get("data", [])]
async def _embed_local(self, texts: list[str]) -> list[list[float]] | None:
if not self._local_model:
try:
import sentence_transformers
self._local_model = sentence_transformers.SentenceTransformer("BAAI/bge-small-en-v1.5", device="cpu")
except Exception:
return None
loop = asyncio.get_event_loop()
vecs = await loop.run_in_executor(None, lambda: self._local_model.encode(texts, normalize_embeddings=True))
return vecs.tolist() if hasattr(vecs, "tolist") else [[float(x) for x in v] for v in vecs]
def _embed_hash(self, texts: list[str]) -> list[list[float]]:
import numpy as np
vecs = []
for t in texts:
h = hashlib.sha256(t.encode()).digest() + hashlib.md5(t.encode()).digest()
v = [(int.from_bytes(h[i : i + 4], "big") / 2**32) * 2 - 1 for i in range(0, min(len(h), 1536), 4)]
while len(v) < 384:
v.append(0.0)
v = v[:384]
norm = np.sqrt(sum(x * x for x in v))
if norm > 0:
v = [x / norm for x in v]
vecs.append(v)
return vecs
async def stats(self) -> dict:
return {
"rate_limits": await self.tracker.stats(),
"cache": self.cache.stats(),
"providers_configured": len(self._providers),
}
def health(self) -> dict:
"""Quick health check (no async needed)."""
available = sum(1 for p in self._providers if p.free)
return {
"providers": len(self._providers),
"free_providers": available,
"local_model_loaded": self._local_model is not None,
}
# ── Singleton ──
_dispatcher: EmbeddingDispatcher | None = None
async def get_dispatcher() -> EmbeddingDispatcher:
global _dispatcher
if _dispatcher is None:
_dispatcher = EmbeddingDispatcher()
await _dispatcher.init()
return _dispatcher
async def smart_embed(texts: list[str]) -> tuple[list[list[float]], str]:
return await (await get_dispatcher()).embed(texts)