yc1838 commited on
Commit
756ec1b
·
1 Parent(s): 03425af

feat: add gemini cooldown registry

Browse files
Files changed (2) hide show
  1. src/lilith_agent/models.py +78 -4
  2. tests/test_models.py +111 -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 typing import Any, Sequence
7
 
8
  from langchain_anthropic import ChatAnthropic
@@ -106,6 +107,68 @@ log = logging.getLogger(__name__)
106
 
107
  LMSTUDIO_DEFAULT_BASE_URL = "http://localhost:1234/v1"
108
  _NO_THINK = "/no_think"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
 
111
  class _NoThinkWrapper(BaseChatModel):
@@ -170,15 +233,26 @@ class _RetryWrapper(BaseChatModel):
170
  """Wraps any BaseChatModel to apply exponential backoff on 429 errors."""
171
 
172
  inner: BaseChatModel
 
 
173
 
174
  @property
175
  def _llm_type(self) -> str:
176
  return f"retry-{self.inner._llm_type}"
177
 
178
  def _generate(self, *args, **kwargs):
179
- for attempt in Retrying(**_sync_retry_params()):
180
- with attempt:
181
- return self.inner._generate(*args, **kwargs)
 
 
 
 
 
 
 
 
 
182
 
183
  async def _agenerate(self, *args, **kwargs):
184
  async for attempt in AsyncRetrying(**_async_retry_params()):
@@ -217,7 +291,7 @@ def _build(provider: str, model: str, cfg: Config) -> BaseChatModel:
217
 
218
  # helper to wrap final model
219
  def _wrap(m):
220
- return _RetryWrapper(inner=m)
221
 
222
  if provider == "ollama":
223
  return _wrap(ChatOllama(model=model, num_predict=max_tokens))
 
3
  import asyncio
4
  import logging
5
  import time
6
+ from dataclasses import dataclass
7
  from typing import Any, Sequence
8
 
9
  from langchain_anthropic import ChatAnthropic
 
107
 
108
  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
117
+ class RateLimitCooldownError(Exception):
118
+ provider: str
119
+ model: str
120
+ cooldown_seconds: int
121
+ original_error: str
122
+
123
+ def __str__(self) -> str:
124
+ return (
125
+ f"rate limited provider={self.provider} model={self.model} "
126
+ f"cooldown={self.cooldown_seconds}s original={self.original_error}"
127
+ )
128
+
129
+
130
+ def _reset_rate_limit_state_for_tests() -> None:
131
+ _cooldown_until.clear()
132
+ _rate_limit_exhaustions.clear()
133
+
134
+
135
+ def _gemini_lane(provider: str | None, model_name: str | None) -> tuple[str, str] | None:
136
+ if provider == "google" and model_name in _GEMINI_COOLDOWN_MODELS:
137
+ return (provider, model_name)
138
+ return None
139
+
140
+
141
+ def _cooldown_seconds_for_exhaustion(count: int) -> int:
142
+ idx = min(max(count, 1), len(_COOLDOWN_LADDER_SECONDS)) - 1
143
+ return _COOLDOWN_LADDER_SECONDS[idx]
144
+
145
+
146
+ def _sleep_active_cooldown(lane: tuple[str, str] | None) -> None:
147
+ if lane is None:
148
+ return
149
+ remaining = _cooldown_until.get(lane, 0.0) - time.monotonic()
150
+ if remaining > 0:
151
+ time.sleep(remaining)
152
+
153
+
154
+ def _record_success(lane: tuple[str, str] | None) -> None:
155
+ if lane is None:
156
+ return
157
+ _rate_limit_exhaustions[lane] = 0
158
+ _cooldown_until.pop(lane, None)
159
+
160
+
161
+ def _record_exhausted_rate_limit(lane: tuple[str, str], exc: BaseException) -> RateLimitCooldownError:
162
+ count = _rate_limit_exhaustions.get(lane, 0) + 1
163
+ _rate_limit_exhaustions[lane] = count
164
+ cooldown = _cooldown_seconds_for_exhaustion(count)
165
+ _cooldown_until[lane] = time.monotonic() + cooldown
166
+ return RateLimitCooldownError(
167
+ provider=lane[0],
168
+ model=lane[1],
169
+ cooldown_seconds=cooldown,
170
+ original_error=str(exc),
171
+ )
172
 
173
 
174
  class _NoThinkWrapper(BaseChatModel):
 
233
  """Wraps any BaseChatModel to apply exponential backoff on 429 errors."""
234
 
235
  inner: BaseChatModel
236
+ provider: str | None = None
237
+ model_name: str | None = None
238
 
239
  @property
240
  def _llm_type(self) -> str:
241
  return f"retry-{self.inner._llm_type}"
242
 
243
  def _generate(self, *args, **kwargs):
244
+ lane = _gemini_lane(self.provider, self.model_name)
245
+ _sleep_active_cooldown(lane)
246
+ try:
247
+ for attempt in Retrying(**_sync_retry_params()):
248
+ with attempt:
249
+ result = self.inner._generate(*args, **kwargs)
250
+ _record_success(lane)
251
+ return result
252
+ except Exception as exc:
253
+ if lane is not None and is_retryable_rate_limit(exc):
254
+ raise _record_exhausted_rate_limit(lane, exc) from exc
255
+ raise
256
 
257
  async def _agenerate(self, *args, **kwargs):
258
  async for attempt in AsyncRetrying(**_async_retry_params()):
 
291
 
292
  # helper to wrap final model
293
  def _wrap(m):
294
+ return _RetryWrapper(inner=m, provider=provider, model_name=model)
295
 
296
  if provider == "ollama":
297
  return _wrap(ChatOllama(model=model, num_predict=max_tokens))
tests/test_models.py CHANGED
@@ -2,12 +2,15 @@ import pytest
2
 
3
  from langchain_core.runnables import Runnable, RunnableLambda
4
  from langchain_core.messages import AIMessage
 
5
 
6
  from lilith_agent.models import (
7
  _RetryWrapper,
8
  _NoThinkWrapper,
9
  _BoundRetryWrapper,
10
  _BoundNoThinkWrapper,
 
 
11
  is_retryable_rate_limit,
12
  )
13
 
@@ -25,6 +28,55 @@ class _FakeChatModel:
25
  return RunnableLambda(lambda msgs: AIMessage(content="ok"))
26
 
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  def test_retry_wrapper_bind_tools_returns_runnable():
29
  inner = _FakeChatModel()
30
  wrapper = _RetryWrapper.model_construct(inner=inner)
@@ -84,3 +136,62 @@ def test_genai_client_error_400_is_not_retryable():
84
  exc = _make_genai_client_error(400)
85
 
86
  assert is_retryable_rate_limit(exc) is False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  from langchain_core.runnables import Runnable, RunnableLambda
4
  from langchain_core.messages import AIMessage
5
+ from langchain_core.outputs import ChatGeneration, ChatResult
6
 
7
  from lilith_agent.models import (
8
  _RetryWrapper,
9
  _NoThinkWrapper,
10
  _BoundRetryWrapper,
11
  _BoundNoThinkWrapper,
12
+ RateLimitCooldownError,
13
+ _reset_rate_limit_state_for_tests,
14
  is_retryable_rate_limit,
15
  )
16
 
 
28
  return RunnableLambda(lambda msgs: AIMessage(content="ok"))
29
 
30
 
31
+ @pytest.fixture(autouse=True)
32
+ def _disable_retry_sleeps(monkeypatch):
33
+ async def _no_async_sleep(_seconds: float) -> None:
34
+ return None
35
+
36
+ monkeypatch.setattr("lilith_agent.models._tenacity_sleep", lambda _seconds: None, raising=False)
37
+ monkeypatch.setattr("lilith_agent.models._async_tenacity_sleep", _no_async_sleep, raising=False)
38
+
39
+
40
+ class _FailingGenerateModel:
41
+ _llm_type = "failing"
42
+
43
+ def __init__(self, exc: BaseException):
44
+ self.exc = exc
45
+ self.calls = 0
46
+
47
+ def _generate(self, *args, **kwargs):
48
+ self.calls += 1
49
+ raise self.exc
50
+
51
+ async def _agenerate(self, *args, **kwargs):
52
+ self.calls += 1
53
+ raise self.exc
54
+
55
+ def bind_tools(self, tools, **kwargs):
56
+ def _raise(_msgs):
57
+ raise self.exc
58
+
59
+ return RunnableLambda(_raise)
60
+
61
+
62
+ class _SuccessfulGenerateModel:
63
+ _llm_type = "success"
64
+
65
+ def __init__(self):
66
+ self.calls = 0
67
+
68
+ def _generate(self, *args, **kwargs):
69
+ self.calls += 1
70
+ return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
71
+
72
+ async def _agenerate(self, *args, **kwargs):
73
+ self.calls += 1
74
+ return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
75
+
76
+ def bind_tools(self, tools, **kwargs):
77
+ return RunnableLambda(lambda msgs: AIMessage(content="ok"))
78
+
79
+
80
  def test_retry_wrapper_bind_tools_returns_runnable():
81
  inner = _FakeChatModel()
82
  wrapper = _RetryWrapper.model_construct(inner=inner)
 
136
  exc = _make_genai_client_error(400)
137
 
138
  assert is_retryable_rate_limit(exc) is False
139
+
140
+
141
+ def test_retry_wrapper_records_first_gemini_cooldown(monkeypatch):
142
+ _reset_rate_limit_state_for_tests()
143
+ exc = _make_genai_client_error(429)
144
+ sleeps = []
145
+ monkeypatch.setattr("lilith_agent.models.time.sleep", sleeps.append)
146
+
147
+ wrapper = _RetryWrapper.model_construct(
148
+ inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
149
+ )
150
+
151
+ with pytest.raises(RateLimitCooldownError) as raised:
152
+ wrapper._generate([])
153
+
154
+ assert raised.value.provider == "google"
155
+ assert raised.value.model == "gemini-3.1-pro"
156
+ assert raised.value.cooldown_seconds == 60
157
+ assert sleeps == []
158
+
159
+
160
+ def test_retry_wrapper_escalates_gemini_cooldowns(monkeypatch):
161
+ _reset_rate_limit_state_for_tests()
162
+ exc = _make_genai_client_error(429)
163
+ monkeypatch.setattr("lilith_agent.models.time.sleep", lambda _: None)
164
+ wrapper = _RetryWrapper.model_construct(
165
+ inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
166
+ )
167
+
168
+ with pytest.raises(RateLimitCooldownError) as first:
169
+ wrapper._generate([])
170
+ with pytest.raises(RateLimitCooldownError) as second:
171
+ wrapper._generate([])
172
+ with pytest.raises(RateLimitCooldownError) as third:
173
+ wrapper._generate([])
174
+
175
+ assert first.value.cooldown_seconds == 60
176
+ assert second.value.cooldown_seconds == 120
177
+ assert third.value.cooldown_seconds == 300
178
+
179
+
180
+ def test_success_resets_lane_failure_counter(monkeypatch):
181
+ _reset_rate_limit_state_for_tests()
182
+ exc = _make_genai_client_error(429)
183
+ monkeypatch.setattr("lilith_agent.models.time.sleep", lambda _: None)
184
+ failing = _RetryWrapper.model_construct(
185
+ inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
186
+ )
187
+ success = _RetryWrapper.model_construct(
188
+ inner=_SuccessfulGenerateModel(), provider="google", model_name="gemini-3.1-pro"
189
+ )
190
+
191
+ with pytest.raises(RateLimitCooldownError):
192
+ failing._generate([])
193
+ success._generate([])
194
+ with pytest.raises(RateLimitCooldownError) as raised:
195
+ failing._generate([])
196
+
197
+ assert raised.value.cooldown_seconds == 60