import asyncio import hashlib import math import os import time from dataclasses import dataclass from datetime import date from typing import Any from risk_config import RiskConfig try: # pragma: no cover from redis import asyncio as redis_asyncio except Exception: # pragma: no cover redis_asyncio = None def _now_ms() -> int: return int(time.time() * 1000) def _utc_day() -> str: return date.today().isoformat() def stable_hash(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() def estimate_tokens(text: str) -> int: if not text: return 0 ascii_chars = sum(1 for ch in text if ord(ch) < 128) non_ascii = len(text) - ascii_chars ascii_tokens = math.ceil(ascii_chars / 4) non_ascii_tokens = math.ceil(non_ascii * 1.5) return max(ascii_tokens + non_ascii_tokens, 1) @dataclass(frozen=True) class RiskIdentity: request_id: str session_hash: str ip_hash: str route: str method: str @dataclass(frozen=True) class RiskDecision: allowed: bool status_code: int = 200 reason: str = "" error_code: str = "" retry_after_seconds: int = 0 delay_ms: int = 0 class RiskRejected(RuntimeError): def __init__(self, decision: RiskDecision) -> None: super().__init__(decision.reason or decision.error_code or "request rejected") self.decision = decision class BaseRiskBackend: async def incr_window(self, key: str, ttl_seconds: int) -> int: raise NotImplementedError async def get_float(self, key: str) -> float: raise NotImplementedError async def add_float(self, key: str, value: float, ttl_seconds: int) -> float: raise NotImplementedError async def get_int(self, key: str) -> int: raise NotImplementedError async def set_int(self, key: str, value: int, ttl_seconds: int) -> None: raise NotImplementedError async def set_float(self, key: str, value: float, ttl_seconds: int) -> None: raise NotImplementedError async def acquire_lock(self, key: str, ttl_seconds: int) -> bool: raise NotImplementedError async def release_lock(self, key: str) -> None: raise NotImplementedError class InMemoryRiskBackend(BaseRiskBackend): def __init__(self) -> None: self.values: dict[str, tuple[float, float]] = {} self.locks: dict[str, float] = {} self.guard = asyncio.Lock() def _purge(self) -> None: now = time.time() for key, (_, expires_at) in list(self.values.items()): if expires_at and expires_at <= now: self.values.pop(key, None) for key, expires_at in list(self.locks.items()): if expires_at <= now: self.locks.pop(key, None) async def incr_window(self, key: str, ttl_seconds: int) -> int: async with self.guard: self._purge() value, _ = self.values.get(key, (0.0, 0.0)) next_value = int(value) + 1 self.values[key] = (float(next_value), time.time() + ttl_seconds) return next_value async def get_float(self, key: str) -> float: async with self.guard: self._purge() return float(self.values.get(key, (0.0, 0.0))[0]) async def add_float(self, key: str, value: float, ttl_seconds: int) -> float: async with self.guard: self._purge() current, _ = self.values.get(key, (0.0, 0.0)) next_value = current + value self.values[key] = (next_value, time.time() + ttl_seconds) return next_value async def get_int(self, key: str) -> int: return int(await self.get_float(key)) async def set_int(self, key: str, value: int, ttl_seconds: int) -> None: async with self.guard: self._purge() self.values[key] = (float(value), time.time() + ttl_seconds) async def set_float(self, key: str, value: float, ttl_seconds: int) -> None: async with self.guard: self._purge() self.values[key] = (float(value), time.time() + ttl_seconds) async def acquire_lock(self, key: str, ttl_seconds: int) -> bool: async with self.guard: self._purge() if key in self.locks: return False self.locks[key] = time.time() + ttl_seconds return True async def release_lock(self, key: str) -> None: async with self.guard: self.locks.pop(key, None) class RedisRiskBackend(BaseRiskBackend): def __init__(self, redis_url: str) -> None: if redis_asyncio is None: raise RuntimeError("redis package is not installed") self.redis = redis_asyncio.from_url(redis_url, encoding="utf-8", decode_responses=True) async def incr_window(self, key: str, ttl_seconds: int) -> int: value = await self.redis.incr(key) if value == 1: await self.redis.expire(key, ttl_seconds) return int(value) async def get_float(self, key: str) -> float: value = await self.redis.get(key) if value is None: return 0.0 return float(value) async def add_float(self, key: str, value: float, ttl_seconds: int) -> float: current = await self.get_float(key) next_value = current + value await self.redis.set(key, next_value, ex=ttl_seconds) return next_value async def get_int(self, key: str) -> int: value = await self.redis.get(key) if value is None: return 0 try: return int(value) except (TypeError, ValueError): return int(float(value)) async def set_int(self, key: str, value: int, ttl_seconds: int) -> None: await self.redis.set(key, value, ex=ttl_seconds) async def set_float(self, key: str, value: float, ttl_seconds: int) -> None: await self.redis.set(key, value, ex=ttl_seconds) async def acquire_lock(self, key: str, ttl_seconds: int) -> bool: return bool(await self.redis.set(key, "1", ex=ttl_seconds, nx=True)) async def release_lock(self, key: str) -> None: await self.redis.delete(key) class RiskController: def __init__(self, config: RiskConfig) -> None: self.config = config self.prefix = "llmtext:risk" redis_url = os.getenv("REDIS_URL", "").strip() if redis_url and redis_asyncio is not None: self.backend: BaseRiskBackend = RedisRiskBackend(redis_url) else: self.backend = InMemoryRiskBackend() def _api_key(self, identity: RiskIdentity, scope: str) -> str: return f"{self.prefix}:api:{scope}:{identity.session_hash}:{identity.ip_hash}" def _llm_key(self, identity: RiskIdentity, scope: str) -> str: return f"{self.prefix}:llm:{scope}:{identity.session_hash}:{identity.ip_hash}" def _budget_key(self, scope: str, scope_hash: str, current_day: str) -> str: return f"{self.prefix}:budget:{scope}:{scope_hash}:{current_day}" def _lock_key(self, scope: str, scope_hash: str) -> str: return f"{self.prefix}:lock:{scope}:{scope_hash}" def _circuit_key(self, scope: str) -> str: return f"{self.prefix}:circuit:{scope}" def _failure_key(self, model: str) -> str: return f"{self.prefix}:failure:{model}" async def check_api(self, identity: RiskIdentity, *, scope: str = "default") -> RiskDecision: key = self._api_key(identity, scope) count = await self.backend.incr_window(key, self.config.api_window_seconds) if count > self.config.api_hard_limit_per_window: return RiskDecision( allowed=False, status_code=429, reason="请求过于频繁,请稍后再试", error_code="api_rate_limited", retry_after_seconds=self.config.api_window_seconds, ) if count > self.config.api_soft_limit_per_window: overflow = count - self.config.api_soft_limit_per_window delay_ms = min(self.config.delay_cap_ms, overflow * self.config.delay_step_ms) return RiskDecision(allowed=True, delay_ms=delay_ms) return RiskDecision(allowed=True) async def check_llm( self, identity: RiskIdentity, *, scope: str, estimated_cost: float, ) -> RiskDecision: global_circuit = await self.backend.get_int(self._circuit_key("global")) model_circuit = await self.backend.get_int(self._circuit_key(scope)) if global_circuit > 0 or model_circuit > 0: return RiskDecision( allowed=False, status_code=503, reason="当前推理服务繁忙,请稍后再试", error_code="llm_circuit_open", retry_after_seconds=self.config.model_circuit_ttl_seconds, ) if estimated_cost > self.config.single_request_max_cost_usd: return RiskDecision( allowed=False, status_code=429, reason="单次请求成本过高,已被拒绝", error_code="llm_cost_too_high", ) day = _utc_day() session_budget = await self.backend.get_float(self._budget_key("session", identity.session_hash, day)) ip_budget = await self.backend.get_float(self._budget_key("ip", identity.ip_hash, day)) global_budget = await self.backend.get_float(self._budget_key("global", "global", day)) if session_budget + estimated_cost > self.config.daily_budget_session_usd: return RiskDecision(False, 429, "当前匿名会话今日额度已用尽", "session_budget_exhausted", 3600) if ip_budget + estimated_cost > self.config.daily_budget_ip_usd: return RiskDecision(False, 429, "当前网络环境今日额度已用尽", "ip_budget_exhausted", 3600) if global_budget + estimated_cost > self.config.daily_budget_global_usd: await self.backend.set_int(self._circuit_key("global"), 1, self.config.model_circuit_ttl_seconds) return RiskDecision(False, 503, "今日全局推理预算已耗尽", "global_budget_exhausted", 3600) key = self._llm_key(identity, scope) count = await self.backend.incr_window(key, self.config.llm_window_seconds) if count > self.config.llm_hard_limit_per_window: return RiskDecision(False, 429, "推理请求过于频繁,请稍后重试", "llm_rate_limited", self.config.llm_window_seconds) if count > self.config.llm_soft_limit_per_window: overflow = count - self.config.llm_soft_limit_per_window delay_ms = min(self.config.delay_cap_ms, overflow * self.config.delay_step_ms) return RiskDecision(True, delay_ms=delay_ms) return RiskDecision(True) async def reserve_budget(self, identity: RiskIdentity, estimated_cost: float) -> None: day = _utc_day() ttl_seconds = 60 * 60 * 24 await self.backend.add_float(self._budget_key("session", identity.session_hash, day), estimated_cost, ttl_seconds) await self.backend.add_float(self._budget_key("ip", identity.ip_hash, day), estimated_cost, ttl_seconds) await self.backend.add_float(self._budget_key("global", "global", day), estimated_cost, ttl_seconds) async def acquire_execution_slot(self, identity: RiskIdentity, *, model: str) -> list[str]: ttl_seconds = 60 * 15 keys = [ self._lock_key("session", f"{identity.session_hash}:{identity.request_id}"), self._lock_key("global", identity.request_id), ] session_running = await self.backend.get_int(self._lock_key("session-count", identity.session_hash)) global_running = await self.backend.get_int(self._lock_key("global-count", "global")) if session_running >= self.config.session_concurrency_limit: raise RiskRejected( RiskDecision(False, 429, "当前会话并发推理过多,请稍后重试", "session_concurrency_limited", 30) ) if global_running >= self.config.global_concurrency_limit: raise RiskRejected( RiskDecision(False, 503, "当前全局推理负载过高,请稍后重试", "global_concurrency_limited", 30) ) acquired: list[str] = [] for key in keys: ok = await self.backend.acquire_lock(key, ttl_seconds) if not ok: for acquired_key in acquired: await self.backend.release_lock(acquired_key) raise RiskRejected( RiskDecision(False, 429, "当前请求正在执行,请勿重复提交", "duplicate_request", 10) ) acquired.append(key) await self.backend.add_float(self._lock_key("session-count", identity.session_hash), 1.0, ttl_seconds) await self.backend.add_float(self._lock_key("global-count", "global"), 1.0, ttl_seconds) return acquired async def release_execution_slot(self, identity: RiskIdentity, lock_keys: list[str], *, model: str) -> None: for key in lock_keys: await self.backend.release_lock(key) session_count_key = self._lock_key("session-count", identity.session_hash) global_count_key = self._lock_key("global-count", "global") session_count = max(0.0, await self.backend.get_float(session_count_key) - 1.0) global_count = max(0.0, await self.backend.get_float(global_count_key) - 1.0) await self.backend.set_float(session_count_key, session_count, 60 * 15) await self.backend.set_float(global_count_key, global_count, 60 * 15) async def record_model_result(self, *, model: str, success: bool) -> None: if success: await self.backend.set_int(self._failure_key(model), 0, self.config.model_circuit_ttl_seconds) return failures = await self.backend.incr_window(self._failure_key(model), self.config.model_circuit_ttl_seconds) if failures >= self.config.model_circuit_breaker_failures: await self.backend.set_int(self._circuit_key(model), 1, self.config.model_circuit_ttl_seconds) _risk_controller: RiskController | None = None def get_risk_controller(config: RiskConfig) -> RiskController: global _risk_controller if _risk_controller is None: _risk_controller = RiskController(config) return _risk_controller def reset_risk_controller() -> None: global _risk_controller _risk_controller = None