- 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>
685 lines
24 KiB
Python
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)
|