Per-provider client rate limit, dashboard-editable (v0.99.95)

Adds a configurable global cap (all clients combined) on requests forwarded to a
given provider, so a hammering client can be throttled per provider — e.g. cap
kilo-spora so the real-estate app can't exhaust kilo's per-IP free-model quota
(which was cascading into 429s for other kilo providers like kilo-stefy and
breaking the lisa rotation).

- config.py: ProviderConfig gains client_rate_limit_rpm / client_rate_limit_rph
  (0 = unlimited). Persists via the existing provider-save path.
- handlers.py: a sliding-window limiter keyed provider:<id>:<window>, enforced at
  all six client entry points (chat, streaming, transcription, TTS, image,
  embeddings) right before the availability gate — excess requests get HTTP 429 +
  Retry-After without building the handler or calling upstream.
- providers.html / user_providers.html: a "Rate Limiting" section with per-minute
  and per-hour inputs (0 = unlimited) in each provider's edit form.

Counts direct requests to a provider; rotation-internal dispatch is not counted
in this version.
Co-Authored-By: 's avatarClaude Opus 4.8 (1M context) <noreply@anthropic.com>
parent d9e3fb9f
......@@ -55,7 +55,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler
from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model, get_max_completion_tokens_for_model
__version__ = "0.99.94"
__version__ = "0.99.95"
__all__ = [
# Config
"config",
......
......@@ -109,6 +109,11 @@ class ProviderConfig(BaseModel):
type: str
api_key_required: bool
rate_limit: float = 0.0
# Per-provider client rate limit: a global cap (all clients combined) on
# requests forwarded to this provider, protecting the upstream from hammering
# (e.g. exhausting a per-IP free-model quota). 0 = unlimited.
client_rate_limit_rpm: int = 0 # max client requests per minute to this provider
client_rate_limit_rph: int = 0 # max client requests per hour to this provider
api_key: Optional[str] = None # Optional API key in provider config
models: Optional[List[ProviderModelConfig]] = None # Optional list of models with their configs
auth_config: Optional[Dict] = None # Unified provider authentication configuration (for all provider types)
......
......@@ -170,6 +170,75 @@ def _provider_in_availability_cooldown(handler) -> bool:
return bool((failure_until and failure_until > now) or (usage_until and usage_until > now))
# Per-provider client rate limiting — a global sliding window (all clients
# combined) that bounds how many requests are forwarded to a given provider,
# protecting the upstream from hammering (e.g. a client exhausting a per-IP
# free-model quota). Configured per provider via client_rate_limit_rpm/rph.
_provider_rl_state: dict = {} # bucket -> list[timestamps]
_provider_rl_lock = threading.Lock()
def _provider_rl_check(bucket: str, window_seconds: int, max_requests: int) -> tuple:
"""Sliding-window check+record. Returns (allowed, retry_after_seconds)."""
if max_requests <= 0:
return True, 0
now = time.time()
cutoff = now - window_seconds
with _provider_rl_lock:
ts = [t for t in _provider_rl_state.get(bucket, []) if t > cutoff]
if len(ts) >= max_requests:
retry_after = int(ts[0] + window_seconds - now) + 1
_provider_rl_state[bucket] = ts
return False, retry_after
ts.append(now)
_provider_rl_state[bucket] = ts
return True, 0
def _provider_rl_limits(provider_config) -> tuple:
"""Extract (rpm, rph) client rate limits from a provider config (object or dict)."""
if provider_config is None:
return 0, 0
if isinstance(provider_config, dict):
rpm = provider_config.get('client_rate_limit_rpm', 0)
rph = provider_config.get('client_rate_limit_rph', 0)
else:
rpm = getattr(provider_config, 'client_rate_limit_rpm', 0)
rph = getattr(provider_config, 'client_rate_limit_rph', 0)
try:
rpm = int(rpm or 0)
except (TypeError, ValueError):
rpm = 0
try:
rph = int(rph or 0)
except (TypeError, ValueError):
rph = 0
return rpm, rph
def _enforce_provider_client_rate_limit(provider_id: str, provider_config) -> None:
"""Reject with 429 when this provider's global client rate limit is exceeded.
A cheap gate meant to run before the handler is built / the upstream is
called, so a hammering client is turned away at the door and never consumes
the provider's upstream quota. 0 = unlimited (the default), so providers
without a configured limit are unaffected.
"""
rpm, rph = _provider_rl_limits(provider_config)
for window, limit, label in ((60, rpm, "per-minute"), (3600, rph, "per-hour")):
allowed, retry_after = _provider_rl_check(f"provider:{provider_id}:{window}", window, limit)
if not allowed:
logging.getLogger(__name__).warning(
f"[{provider_id}] Rejecting request with 429: provider {label} client "
f"rate limit ({limit}) reached; retry in {retry_after}s"
)
raise HTTPException(
status_code=429,
detail=f"Provider '{provider_id}' {label} request limit reached. Retry in {retry_after}s.",
headers={"Retry-After": str(retry_after)},
)
# Registry for proxied content — shared across all RequestHandler instances.
# Values: {"type": "broker", "provider_id": str, "path": str}
# or {"type": "http", "url": str}
......@@ -784,6 +853,7 @@ class RequestHandler:
handler = get_provider_handler(provider_id, api_key, user_id=self.user_id)
logger.info(f"Provider handler obtained: {handler.__class__.__name__}")
_enforce_provider_client_rate_limit(provider_id, self._get_provider_config(provider_id))
_raise_if_provider_unavailable(handler, provider_id)
try:
......@@ -1150,6 +1220,7 @@ class RequestHandler:
handler = get_provider_handler(provider_id, api_key, user_id=self.user_id)
_enforce_provider_client_rate_limit(provider_id, self._get_provider_config(provider_id))
_raise_if_provider_unavailable(handler, provider_id)
# Generate system_fingerprint for this request
......@@ -2302,6 +2373,7 @@ class RequestHandler:
handler = get_provider_handler(provider_id, api_key, user_id=self.user_id)
_enforce_provider_client_rate_limit(provider_id, self._get_provider_config(provider_id))
_raise_if_provider_unavailable(handler, provider_id)
try:
......@@ -2336,6 +2408,7 @@ class RequestHandler:
handler = get_provider_handler(provider_id, api_key, user_id=self.user_id)
_enforce_provider_client_rate_limit(provider_id, self._get_provider_config(provider_id))
_raise_if_provider_unavailable(handler, provider_id)
try:
......@@ -2376,6 +2449,7 @@ class RequestHandler:
handler = get_provider_handler(provider_id, api_key, user_id=self.user_id)
_enforce_provider_client_rate_limit(provider_id, self._get_provider_config(provider_id))
_raise_if_provider_unavailable(handler, provider_id)
try:
......@@ -2414,6 +2488,7 @@ class RequestHandler:
handler = get_provider_handler(provider_id, api_key, user_id=self.user_id)
_enforce_provider_client_rate_limit(provider_id, self._get_provider_config(provider_id))
_raise_if_provider_unavailable(handler, provider_id)
try:
......
......@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "aisbf"
version = "0.99.94"
version = "0.99.95"
description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md"
license = "GPL-3.0-or-later"
......
......@@ -106,7 +106,7 @@ class InstallCommand(_install):
setup(
name="aisbf",
version="0.99.94",
version="0.99.95",
author="AISBF Contributors",
author_email="stefy@nexlab.net",
description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations",
......
......@@ -1807,7 +1807,21 @@ function renderProviderDetails(key) {
</optgroup>
</select>
</div>
${subPanel(`providers:${key}:ratelimit`, window.i18n.t('providers.rate_limit_section') || 'Rate Limiting', `
<div style="border-left: 3px solid #4a9eff; padding-left: 12px;">
<div class="form-group">
<label>${window.i18n.t('providers.client_rate_limit_rpm') || 'Max client requests per minute'} (0 = unlimited)</label>
<input type="number" min="0" value="${provider.client_rate_limit_rpm || 0}" onchange="updateProvider('${key}', 'client_rate_limit_rpm', this.value ? parseInt(this.value) : 0)">
</div>
<div class="form-group">
<label>${window.i18n.t('providers.client_rate_limit_rph') || 'Max client requests per hour'} (0 = unlimited)</label>
<input type="number" min="0" value="${provider.client_rate_limit_rph || 0}" onchange="updateProvider('${key}', 'client_rate_limit_rph', this.value ? parseInt(this.value) : 0)">
</div>
<small style="color: var(--color-muted); display: block;">Global cap (all clients combined) on requests forwarded to this provider. Excess requests get HTTP 429 before the upstream is called — protects the provider from hammering (e.g. exhausting a per-IP free-model quota). 0 = unlimited.</small>
</div>
`, {open: false})}
${subPanel(`providers:${key}:auth`, window.i18n.t('providers.authentication') || 'Authentication', authFieldsHtml, {open: true})}
${subPanel(`providers:${key}:pricing`, window.i18n.t('providers.pricing_section'), `
......
......@@ -1489,7 +1489,20 @@ function renderProviderDetails(key) {
</optgroup>
</select>
</div>
<div style="background: var(--bg-panel); padding: 15px; border-radius: 5px; margin: 15px 0; border-left: 3px solid #4a9eff;">
<h4 style="margin: 0 0 15px 0; color: var(--color-link);">${window.i18n.t('providers.rate_limit_section') || 'Rate Limiting'}</h4>
<div class="form-group">
<label>${window.i18n.t('providers.client_rate_limit_rpm') || 'Max client requests per minute'} (0 = unlimited)</label>
<input type="number" min="0" value="${provider.client_rate_limit_rpm || 0}" onchange="updateProvider('${key}', 'client_rate_limit_rpm', this.value ? parseInt(this.value) : 0)">
</div>
<div class="form-group">
<label>${window.i18n.t('providers.client_rate_limit_rph') || 'Max client requests per hour'} (0 = unlimited)</label>
<input type="number" min="0" value="${provider.client_rate_limit_rph || 0}" onchange="updateProvider('${key}', 'client_rate_limit_rph', this.value ? parseInt(this.value) : 0)">
</div>
<small style="color: var(--color-muted); display: block;">Global cap (all clients combined) on requests to this provider. Excess requests get HTTP 429 before the upstream is called — protects the provider from hammering. 0 = unlimited.</small>
</div>
${authFieldsHtml}
<div style="background: var(--bg-panel); padding: 15px; border-radius: 5px; margin: 15px 0; border-left: 3px solid #4a9eff;">
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment