Spaces:
Sleeping
Sleeping
File size: 7,314 Bytes
49a470d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | """
llm_cache.py — Gap 2.3: Distributed LLM response cache via Upstash Redis REST.
Architettura:
- Upstash Redis REST API (HTTP, no TCP persistente — funziona su HF Spaces/Railway)
- Cache key: sha256(model + json(messages))[:32], prefisso "llm:"
- TTL: 3600s (1 ora, configurabile via LLM_CACHE_TTL)
- Graceful degradation: no-op completo se UPSTASH_REDIS_REST_URL non impostato
- Cache solo chat() non-streaming con temperature ≤ 0.5 e senza tool results
- Max value size: 32 KB — skip write se risposta più lunga
- Stats in-process: hits/misses/writes/errors/skipped + hit_rate
Env vars:
UPSTASH_REDIS_REST_URL — es. https://xxx.upstash.io (obbligatoria per attivare)
UPSTASH_REDIS_REST_TOKEN — Bearer token Upstash
LLM_CACHE_TTL — secondi TTL per entry (default: 3600)
LLM_CACHE_MAX_BYTES — max byte per value (default: 32768 = 32 KB)
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import httpx
from fastapi import APIRouter
router = APIRouter(prefix="/api/cache", tags=["cache"])
_logger = logging.getLogger("llm_cache")
_CACHE_TTL_S = int(os.getenv("LLM_CACHE_TTL", "3600"))
_CACHE_MAX_BYTES = int(os.getenv("LLM_CACHE_MAX_BYTES", "32768")) # 32 KB
_KEY_PREFIX = "llm:"
# ── Stats in-process (reset a ogni restart) ───────────────────────────────────
_stats: dict[str, int] = {
"hits": 0, "misses": 0, "writes": 0, "errors": 0, "skipped": 0,
}
def _cfg() -> tuple[str, str] | tuple[None, None]:
"""Ritorna (url, token) Upstash o (None, None) se non configurato."""
url = (os.getenv("UPSTASH_REDIS_REST_URL") or "").rstrip("/")
token = (os.getenv("UPSTASH_REDIS_REST_TOKEN") or "").strip()
return (url, token) if (url and token) else (None, None)
# ── Cache key ──────────────────────────────────────────────────────────────────
def cache_key(model: str, messages: list) -> str:
"""SHA-256 deterministico dei messaggi — chiave identica tra istanze diverse."""
payload = json.dumps(
{"m": model, "msgs": messages},
sort_keys=True, ensure_ascii=True, separators=(",", ":"),
)
return _KEY_PREFIX + hashlib.sha256(payload.encode()).hexdigest()[:32]
def should_cache(messages: list, temperature: float) -> bool:
"""
True se la coppia (messages, temperature) è cacheabile in modo sicuro.
Skip quando:
- temperature > 0.5 (output non-deterministico — alta varianza)
- almeno 1 message con role "tool" (risultati specifici del contesto agente)
- system prompt > 8000 chars (variabilità troppo alta, hit-rate bassa)
"""
if temperature > 0.5:
return False
for m in messages:
role = m.get("role", "")
if role == "tool":
return False
if role == "system" and len(str(m.get("content") or "")) > 8000:
return False
return True
# ── Upstash Redis REST GET ────────────────────────────────────────────────────
async def get_cached(key: str) -> str | None:
"""
GET da Upstash Redis REST.
Timeout 2s — non blocca mai il path principale LLM.
Ritorna None su miss, errore o cache non configurata.
"""
url, token = _cfg()
if not url:
return None
try:
async with httpx.AsyncClient(timeout=2.0) as c:
r = await c.get(
f"{url}/get/{key}",
headers={"Authorization": f"Bearer {token}"},
)
if r.status_code != 200:
_stats["errors"] += 1
return None
result = r.json().get("result")
if result is not None:
_stats["hits"] += 1
_logger.debug("llm_cache HIT key=…%s", key[-8:])
return str(result)
_stats["misses"] += 1
_logger.debug("llm_cache MISS key=…%s", key[-8:])
return None
except Exception as _e:
_stats["errors"] += 1
_logger.debug("llm_cache GET error (%s)", type(_e).__name__)
return None
# ── Upstash Redis REST SET ────────────────────────────────────────────────────
async def set_cached(key: str, value: str, ttl_s: int = _CACHE_TTL_S) -> None:
"""
SET su Upstash Redis REST via pipeline endpoint.
Fire-and-forget — non rilancia mai eccezioni.
Skippa se il value supera _CACHE_MAX_BYTES (risposta molto lunga, hit-rate bassa).
"""
_value_bytes = len(value.encode("utf-8"))
if _value_bytes > _CACHE_MAX_BYTES:
_stats["skipped"] += 1
_logger.debug(
"llm_cache SKIP write key=…%s (size=%d B > max=%d B)",
key[-8:], _value_bytes, _CACHE_MAX_BYTES,
)
return
url, token = _cfg()
if not url:
return
try:
async with httpx.AsyncClient(timeout=3.0) as c:
r = await c.post(
f"{url}/pipeline",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
content=json.dumps([["SET", key, value, "EX", str(ttl_s)]]).encode(),
)
if r.status_code == 200:
_stats["writes"] += 1
_logger.debug("llm_cache WRITE key=…%s ttl=%ds", key[-8:], ttl_s)
else:
_stats["errors"] += 1
_logger.warning("llm_cache write HTTP %d: %s", r.status_code, r.text[:80])
except Exception as _e:
_stats["errors"] += 1
_logger.debug("llm_cache SET error (%s)", type(_e).__name__)
def get_stats() -> dict:
url, _ = _cfg()
total = _stats["hits"] + _stats["misses"]
out: dict = {
"configured": url is not None,
"backend": "upstash_redis_rest" if url else "disabled",
"ttl_s": _CACHE_TTL_S,
"max_value_bytes": _CACHE_MAX_BYTES,
**_stats,
"hit_rate": round(_stats["hits"] / total, 3) if total else 0.0,
}
if not url:
out["note"] = (
"Impostare UPSTASH_REDIS_REST_URL e UPSTASH_REDIS_REST_TOKEN "
"in Railway/HF Spaces per attivare il caching distribuito LLM."
)
return out
# ── Router ────────────────────────────────────────────────────────────────────
@router.get("/stats")
async def cache_stats():
"""
Gap 2.3 observability: statistiche cache LLM distribuita.
Ritorna:
- configured: True se Upstash è configurato via env vars
- backend: "upstash_redis_rest" | "disabled"
- hits / misses / writes / errors / skipped (dall'ultimo restart)
- hit_rate: hits / (hits + misses)
- ttl_s, max_value_bytes: parametri di configurazione
- note: istruzioni setup se non configurato
"""
return get_stats()
|