Spaces:
Sleeping
Sleeping
yc1838 commited on
Commit ·
756ec1b
1
Parent(s): 03425af
feat: add gemini cooldown registry
Browse files- src/lilith_agent/models.py +78 -4
- 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 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|