Spaces:
Sleeping
Sleeping
yc1838 commited on
Commit ·
388e04b
1
Parent(s): 4103ce2
feat: track batch rate limit window
Browse files- src/lilith_agent/models.py +29 -1
- tests/test_models.py +30 -0
src/lilith_agent/models.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
| 3 |
import asyncio
|
| 4 |
import logging
|
| 5 |
import time
|
|
|
|
| 6 |
from contextlib import contextmanager
|
| 7 |
from contextvars import ContextVar
|
| 8 |
from dataclasses import dataclass
|
|
@@ -112,9 +113,14 @@ _NO_THINK = "/no_think"
|
|
| 112 |
_GEMINI_COOLDOWN_MODELS = {"gemini-3-flash-preview", "gemini-3.1-pro"}
|
| 113 |
_COOLDOWN_LADDER_SECONDS = (60, 120, 300)
|
| 114 |
_QUESTION_STREAK_LIMIT = 50
|
|
|
|
|
|
|
|
|
|
| 115 |
_cooldown_until: dict[tuple[str, str], float] = {}
|
| 116 |
_rate_limit_exhaustions: dict[tuple[str, str], int] = {}
|
| 117 |
_question_rate_limit_streak: ContextVar[int | None] = ContextVar("question_rate_limit_streak", default=None)
|
|
|
|
|
|
|
| 118 |
|
| 119 |
|
| 120 |
@dataclass
|
|
@@ -149,8 +155,11 @@ class QuestionRateLimitStreakError(Exception):
|
|
| 149 |
|
| 150 |
|
| 151 |
def _reset_rate_limit_state_for_tests() -> None:
|
|
|
|
| 152 |
_cooldown_until.clear()
|
| 153 |
_rate_limit_exhaustions.clear()
|
|
|
|
|
|
|
| 154 |
|
| 155 |
|
| 156 |
@contextmanager
|
|
@@ -163,8 +172,11 @@ def rate_limit_question_scope():
|
|
| 163 |
|
| 164 |
|
| 165 |
def record_rate_limit_observation(exc: BaseException) -> None:
|
|
|
|
|
|
|
|
|
|
| 166 |
current = _question_rate_limit_streak.get()
|
| 167 |
-
if current is None
|
| 168 |
return
|
| 169 |
current += 1
|
| 170 |
_question_rate_limit_streak.set(current)
|
|
@@ -173,10 +185,26 @@ def record_rate_limit_observation(exc: BaseException) -> None:
|
|
| 173 |
|
| 174 |
|
| 175 |
def record_rate_limit_success() -> None:
|
|
|
|
| 176 |
if _question_rate_limit_streak.get() is not None:
|
| 177 |
_question_rate_limit_streak.set(0)
|
| 178 |
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
def _gemini_lane(provider: str | None, model_name: str | None) -> tuple[str, str] | None:
|
| 181 |
if provider == "google" and model_name in _GEMINI_COOLDOWN_MODELS:
|
| 182 |
return (provider, model_name)
|
|
|
|
| 3 |
import asyncio
|
| 4 |
import logging
|
| 5 |
import time
|
| 6 |
+
from collections import deque
|
| 7 |
from contextlib import contextmanager
|
| 8 |
from contextvars import ContextVar
|
| 9 |
from dataclasses import dataclass
|
|
|
|
| 113 |
_GEMINI_COOLDOWN_MODELS = {"gemini-3-flash-preview", "gemini-3.1-pro"}
|
| 114 |
_COOLDOWN_LADDER_SECONDS = (60, 120, 300)
|
| 115 |
_QUESTION_STREAK_LIMIT = 50
|
| 116 |
+
_BATCH_WINDOW_SIZE = 100
|
| 117 |
+
_BATCH_WINDOW_RATE_LIMIT_THRESHOLD = 70
|
| 118 |
+
_BATCH_PAUSE_LADDER_SECONDS = (300, 600, 1200)
|
| 119 |
_cooldown_until: dict[tuple[str, str], float] = {}
|
| 120 |
_rate_limit_exhaustions: dict[tuple[str, str], int] = {}
|
| 121 |
_question_rate_limit_streak: ContextVar[int | None] = ContextVar("question_rate_limit_streak", default=None)
|
| 122 |
+
_batch_rate_limit_window: deque[bool] = deque(maxlen=_BATCH_WINDOW_SIZE)
|
| 123 |
+
_batch_pause_count = 0
|
| 124 |
|
| 125 |
|
| 126 |
@dataclass
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
def _reset_rate_limit_state_for_tests() -> None:
|
| 158 |
+
global _batch_pause_count
|
| 159 |
_cooldown_until.clear()
|
| 160 |
_rate_limit_exhaustions.clear()
|
| 161 |
+
_batch_rate_limit_window.clear()
|
| 162 |
+
_batch_pause_count = 0
|
| 163 |
|
| 164 |
|
| 165 |
@contextmanager
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
def record_rate_limit_observation(exc: BaseException) -> None:
|
| 175 |
+
if not is_retryable_rate_limit(exc):
|
| 176 |
+
return
|
| 177 |
+
_batch_rate_limit_window.append(True)
|
| 178 |
current = _question_rate_limit_streak.get()
|
| 179 |
+
if current is None:
|
| 180 |
return
|
| 181 |
current += 1
|
| 182 |
_question_rate_limit_streak.set(current)
|
|
|
|
| 185 |
|
| 186 |
|
| 187 |
def record_rate_limit_success() -> None:
|
| 188 |
+
_batch_rate_limit_window.append(False)
|
| 189 |
if _question_rate_limit_streak.get() is not None:
|
| 190 |
_question_rate_limit_streak.set(0)
|
| 191 |
|
| 192 |
|
| 193 |
+
def batch_rate_limit_pause_seconds() -> int | None:
|
| 194 |
+
global _batch_pause_count
|
| 195 |
+
if len(_batch_rate_limit_window) < _BATCH_WINDOW_SIZE:
|
| 196 |
+
return None
|
| 197 |
+
if sum(_batch_rate_limit_window) < _BATCH_WINDOW_RATE_LIMIT_THRESHOLD:
|
| 198 |
+
return None
|
| 199 |
+
_batch_pause_count += 1
|
| 200 |
+
idx = min(_batch_pause_count - 1, len(_BATCH_PAUSE_LADDER_SECONDS) - 1)
|
| 201 |
+
return _BATCH_PAUSE_LADDER_SECONDS[idx]
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def clear_batch_rate_limit_window() -> None:
|
| 205 |
+
_batch_rate_limit_window.clear()
|
| 206 |
+
|
| 207 |
+
|
| 208 |
def _gemini_lane(provider: str | None, model_name: str | None) -> tuple[str, str] | None:
|
| 209 |
if provider == "google" and model_name in _GEMINI_COOLDOWN_MODELS:
|
| 210 |
return (provider, model_name)
|
tests/test_models.py
CHANGED
|
@@ -13,6 +13,8 @@ from lilith_agent.models import (
|
|
| 13 |
QuestionRateLimitStreakError,
|
| 14 |
RateLimitCooldownError,
|
| 15 |
_reset_rate_limit_state_for_tests,
|
|
|
|
|
|
|
| 16 |
is_retryable_rate_limit,
|
| 17 |
rate_limit_question_scope,
|
| 18 |
)
|
|
@@ -344,3 +346,31 @@ def test_question_rate_limit_scope_resets_after_success():
|
|
| 344 |
record_rate_limit_success()
|
| 345 |
for _ in range(49):
|
| 346 |
record_rate_limit_observation(exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
QuestionRateLimitStreakError,
|
| 14 |
RateLimitCooldownError,
|
| 15 |
_reset_rate_limit_state_for_tests,
|
| 16 |
+
batch_rate_limit_pause_seconds,
|
| 17 |
+
clear_batch_rate_limit_window,
|
| 18 |
is_retryable_rate_limit,
|
| 19 |
rate_limit_question_scope,
|
| 20 |
)
|
|
|
|
| 346 |
record_rate_limit_success()
|
| 347 |
for _ in range(49):
|
| 348 |
record_rate_limit_observation(exc)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def test_batch_window_triggers_pause_after_70_rate_limits_in_100_outcomes():
|
| 352 |
+
_reset_rate_limit_state_for_tests()
|
| 353 |
+
exc = _make_genai_client_error(429)
|
| 354 |
+
from lilith_agent.models import record_rate_limit_observation, record_rate_limit_success
|
| 355 |
+
|
| 356 |
+
for _ in range(70):
|
| 357 |
+
record_rate_limit_observation(exc)
|
| 358 |
+
for _ in range(30):
|
| 359 |
+
record_rate_limit_success()
|
| 360 |
+
|
| 361 |
+
assert batch_rate_limit_pause_seconds() == 300
|
| 362 |
+
clear_batch_rate_limit_window()
|
| 363 |
+
assert batch_rate_limit_pause_seconds() is None
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def test_batch_window_does_not_trigger_below_threshold():
|
| 367 |
+
_reset_rate_limit_state_for_tests()
|
| 368 |
+
exc = _make_genai_client_error(429)
|
| 369 |
+
from lilith_agent.models import record_rate_limit_observation, record_rate_limit_success
|
| 370 |
+
|
| 371 |
+
for _ in range(69):
|
| 372 |
+
record_rate_limit_observation(exc)
|
| 373 |
+
for _ in range(31):
|
| 374 |
+
record_rate_limit_success()
|
| 375 |
+
|
| 376 |
+
assert batch_rate_limit_pause_seconds() is None
|