"""Per-client request caps, in memory. Keys are exact paths, not prefixes. `"/api/stt"` used to match by prefix, which swallowed `/api/stt/check` and `/api/stt/usage` as well: a single /transcribe load spends three of those, so the fourth reload inside a minute was refused and the page reported the transcription service as down. The websocket it was written for never reaches this code at all — `BaseHTTPMiddleware` skips non-http scopes, so websocket limits live in `stt_budget` instead. """ import threading import time # exact path → (max_requests, window_seconds) LIMITS: dict[str, tuple[int, int]] = { "/api/chat": (15, 60), # Voice mode sends one request per sentence, prefetched as the answer # streams, so a low cap counts sentences rather than users: at 10 the # eleventh sentence of one answer was refused and the reply finished in the # browser's built-in voice. edge-tts is unmetered and the result is cached, # so this only exists to stop the Space being used as a free TTS proxy. "/api/tts": (60, 60), "/api/benchmark": (1, 300), # Every call makes an outbound request to Deepgram's auth endpoint, so it # cannot go uncapped. The chat page calls it once per load, /t after each # unexpected socket close: reconnect backoff (1 s doubling to 30 s) keeps # that to about seven in the first minute, then two a minute. "/api/stt/check": (30, 60), "/api/stt/usage": (30, 60), # /t asks once per socket, like the check; each call mints a Deepgram token "/api/stt/token": (30, 60), # /t reports its visit every 5 min and on hide: far under this "/api/stt/session": (30, 60), "/api/captcha/challenge": (10, 60), "/api/captcha/verify": (10, 60), } _buckets: dict[str, list[float]] = {} _lock = threading.Lock() def is_limited(client: str, path: str, now: float | None = None) -> bool: """True when this client has used up `path`'s allowance. Counts the call.""" limit_window = LIMITS.get(path) if limit_window is None: return False limit, window = limit_window now = time.time() if now is None else now key = f"{client}:{path}" with _lock: recent = [t for t in _buckets.get(key, ()) if t > now - window] if len(recent) >= limit: _buckets[key] = recent return True recent.append(now) _buckets[key] = recent return False def reset() -> None: """Drop all buckets. For tests.""" with _lock: _buckets.clear()