yc1838 commited on
Commit
4103ce2
·
1 Parent(s): 8470935

feat: track per-question rate limit streaks

Browse files
Files changed (2) hide show
  1. src/lilith_agent/models.py +49 -2
  2. tests/test_models.py +33 -0
src/lilith_agent/models.py CHANGED
@@ -3,6 +3,8 @@ from __future__ import annotations
3
  import asyncio
4
  import logging
5
  import time
 
 
6
  from dataclasses import dataclass
7
  from typing import Any, Sequence
8
 
@@ -109,8 +111,10 @@ LMSTUDIO_DEFAULT_BASE_URL = "http://localhost:1234/v1"
109
  _NO_THINK = "/no_think"
110
  _GEMINI_COOLDOWN_MODELS = {"gemini-3-flash-preview", "gemini-3.1-pro"}
111
  _COOLDOWN_LADDER_SECONDS = (60, 120, 300)
 
112
  _cooldown_until: dict[tuple[str, str], float] = {}
113
  _rate_limit_exhaustions: dict[tuple[str, str], int] = {}
 
114
 
115
 
116
  @dataclass
@@ -136,11 +140,43 @@ class BatchAbortRateLimitError(Exception):
136
  return f"batch abort rate limit: {self.reason}; original={self.original_error}"
137
 
138
 
 
 
 
 
 
 
 
 
139
  def _reset_rate_limit_state_for_tests() -> None:
140
  _cooldown_until.clear()
141
  _rate_limit_exhaustions.clear()
142
 
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  def _gemini_lane(provider: str | None, model_name: str | None) -> tuple[str, str] | None:
145
  if provider == "google" and model_name in _GEMINI_COOLDOWN_MODELS:
146
  return (provider, model_name)
@@ -289,7 +325,12 @@ class _RetryWrapper(BaseChatModel):
289
  try:
290
  for attempt in Retrying(**_sync_retry_params()):
291
  with attempt:
292
- result = self.inner._generate(*args, **kwargs)
 
 
 
 
 
293
  _record_success(lane)
294
  return result
295
  except Exception as exc:
@@ -320,7 +361,13 @@ class _BoundRetryWrapper(Runnable):
320
  def invoke(self, input, config=None, **kwargs):
321
  for attempt in Retrying(**_sync_retry_params()):
322
  with attempt:
323
- return self._bound.invoke(input, config=config, **kwargs)
 
 
 
 
 
 
324
 
325
  async def ainvoke(self, input, config=None, **kwargs):
326
  async for attempt in AsyncRetrying(**_async_retry_params()):
 
3
  import asyncio
4
  import logging
5
  import time
6
+ from contextlib import contextmanager
7
+ from contextvars import ContextVar
8
  from dataclasses import dataclass
9
  from typing import Any, Sequence
10
 
 
111
  _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
 
140
  return f"batch abort rate limit: {self.reason}; original={self.original_error}"
141
 
142
 
143
+ @dataclass
144
+ class QuestionRateLimitStreakError(Exception):
145
+ count: int
146
+
147
+ def __str__(self) -> str:
148
+ return f"question hit {self.count} consecutive rate-limit events"
149
+
150
+
151
  def _reset_rate_limit_state_for_tests() -> None:
152
  _cooldown_until.clear()
153
  _rate_limit_exhaustions.clear()
154
 
155
 
156
+ @contextmanager
157
+ def rate_limit_question_scope():
158
+ token = _question_rate_limit_streak.set(0)
159
+ try:
160
+ yield
161
+ finally:
162
+ _question_rate_limit_streak.reset(token)
163
+
164
+
165
+ def record_rate_limit_observation(exc: BaseException) -> None:
166
+ current = _question_rate_limit_streak.get()
167
+ if current is None or not is_retryable_rate_limit(exc):
168
+ return
169
+ current += 1
170
+ _question_rate_limit_streak.set(current)
171
+ if current >= _QUESTION_STREAK_LIMIT:
172
+ raise QuestionRateLimitStreakError(count=current) from exc
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)
 
325
  try:
326
  for attempt in Retrying(**_sync_retry_params()):
327
  with attempt:
328
+ try:
329
+ result = self.inner._generate(*args, **kwargs)
330
+ except Exception as observed:
331
+ record_rate_limit_observation(observed)
332
+ raise
333
+ record_rate_limit_success()
334
  _record_success(lane)
335
  return result
336
  except Exception as exc:
 
361
  def invoke(self, input, config=None, **kwargs):
362
  for attempt in Retrying(**_sync_retry_params()):
363
  with attempt:
364
+ try:
365
+ result = self._bound.invoke(input, config=config, **kwargs)
366
+ except Exception as observed:
367
+ record_rate_limit_observation(observed)
368
+ raise
369
+ record_rate_limit_success()
370
+ return result
371
 
372
  async def ainvoke(self, input, config=None, **kwargs):
373
  async for attempt in AsyncRetrying(**_async_retry_params()):
tests/test_models.py CHANGED
@@ -10,9 +10,11 @@ from lilith_agent.models import (
10
  _BoundRetryWrapper,
11
  _BoundNoThinkWrapper,
12
  BatchAbortRateLimitError,
 
13
  RateLimitCooldownError,
14
  _reset_rate_limit_state_for_tests,
15
  is_retryable_rate_limit,
 
16
  )
17
 
18
 
@@ -311,3 +313,34 @@ def test_long_retry_delay_raises_batch_abort(monkeypatch):
311
  wrapper._generate([])
312
 
313
  assert "retry" in raised.value.reason.lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  _BoundRetryWrapper,
11
  _BoundNoThinkWrapper,
12
  BatchAbortRateLimitError,
13
+ QuestionRateLimitStreakError,
14
  RateLimitCooldownError,
15
  _reset_rate_limit_state_for_tests,
16
  is_retryable_rate_limit,
17
+ rate_limit_question_scope,
18
  )
19
 
20
 
 
313
  wrapper._generate([])
314
 
315
  assert "retry" in raised.value.reason.lower()
316
+
317
+
318
+ def test_question_rate_limit_scope_raises_after_50_events():
319
+ _reset_rate_limit_state_for_tests()
320
+ exc = _make_genai_client_error(429)
321
+
322
+ with pytest.raises(QuestionRateLimitStreakError) as raised:
323
+ with rate_limit_question_scope():
324
+ for _ in range(50):
325
+ try:
326
+ raise exc
327
+ except BaseException as caught:
328
+ from lilith_agent.models import record_rate_limit_observation
329
+
330
+ record_rate_limit_observation(caught)
331
+
332
+ assert raised.value.count == 50
333
+
334
+
335
+ def test_question_rate_limit_scope_resets_after_success():
336
+ _reset_rate_limit_state_for_tests()
337
+ exc = _make_genai_client_error(429)
338
+
339
+ with rate_limit_question_scope():
340
+ from lilith_agent.models import record_rate_limit_observation, record_rate_limit_success
341
+
342
+ for _ in range(49):
343
+ record_rate_limit_observation(exc)
344
+ record_rate_limit_success()
345
+ for _ in range(49):
346
+ record_rate_limit_observation(exc)