"""Minimal in-memory rate limiter (per (tenant, ip, device_id), rolling 1h window). Kept aligned with the reference implementation's rate_limit.py shape. Only the chat message route uses this; session/history endpoints are cheap and not throttled. State is process-local and resets on restart, which is acceptable for a single-worker ai-kefu deploy. Task 9: 桶键加租户维度——多租户同 IP(共享出口 NAT/公司网络)不互相挤占 配额;limit 经 config.engine.rate_limit_per_hour 逐租户可调(消费点 chat_rest, 默认 DEFAULT_MAX)。 Caveats: - Single-process, in-memory. Under `uvicorn --workers >1` each worker keeps its own `_buckets`, so the effective cap is `max_requests * workers`. - Keyed on (tenant, ip, device_id); a client rotating the `X-Device-Id` header per request bypasses it. This is a light safeguard against accidental GLM-cost burn, NOT abuse-proof. """ import time from collections import defaultdict _buckets: dict[tuple[str, str, str], list[float]] = defaultdict(list) WINDOW = 3600 DEFAULT_MAX = 30 def check( tenant: str | None, ip: str | None, device_id: str | None, *, max_requests: int = DEFAULT_MAX, ) -> bool: """Return True if the request is under the cap (and record it), else False.""" key = (tenant or "", ip or "", device_id or "") now = time.time() _buckets[key] = [t for t in _buckets[key] if t > now - WINDOW] if len(_buckets[key]) >= max_requests: return False _buckets[key].append(now) return True def _reset_for_test() -> None: _buckets.clear()