Final_Assignment_Template / tests /test_models.py
yc1838
add deepseek compatibility
28a277f
Raw
History Blame Contribute Delete
17.5 kB
import pytest
from unittest.mock import AsyncMock
from langchain_core.runnables import Runnable, RunnableLambda
from langchain_core.messages import AIMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from lilith_agent.models import (
_RetryWrapper,
_NoThinkWrapper,
_BoundRetryWrapper,
_BoundNoThinkWrapper,
BatchAbortRateLimitError,
QuestionRateLimitStreakError,
RateLimitCooldownError,
_reset_rate_limit_state_for_tests,
batch_rate_limit_pause_seconds,
clear_batch_rate_limit_window,
is_retryable_rate_limit,
rate_limit_question_scope,
)
class _FakeChatModel:
"""Minimal stand-in for a BaseChatModel exposing bind_tools."""
_llm_type = "fake"
def __init__(self):
self.bound_with = None
def bind_tools(self, tools, **kwargs):
self.bound_with = tools
return RunnableLambda(lambda msgs: AIMessage(content="ok"))
@pytest.fixture(autouse=True)
def _disable_retry_sleeps(monkeypatch):
async def _no_async_sleep(_seconds: float) -> None:
return None
monkeypatch.setattr("lilith_agent.models._tenacity_sleep", lambda _seconds: None, raising=False)
monkeypatch.setattr("lilith_agent.models._async_tenacity_sleep", _no_async_sleep, raising=False)
class _FailingGenerateModel:
_llm_type = "failing"
def __init__(self, exc: BaseException):
self.exc = exc
self.calls = 0
def _generate(self, *args, **kwargs):
self.calls += 1
raise self.exc
async def _agenerate(self, *args, **kwargs):
self.calls += 1
raise self.exc
def bind_tools(self, tools, **kwargs):
def _raise(_msgs):
raise self.exc
return RunnableLambda(_raise)
class _SuccessfulGenerateModel:
_llm_type = "success"
def __init__(self):
self.calls = 0
def _generate(self, *args, **kwargs):
self.calls += 1
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
async def _agenerate(self, *args, **kwargs):
self.calls += 1
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
def bind_tools(self, tools, **kwargs):
return RunnableLambda(lambda msgs: AIMessage(content="ok"))
def test_retry_wrapper_bind_tools_returns_runnable():
inner = _FakeChatModel()
wrapper = _RetryWrapper.model_construct(inner=inner)
bound = wrapper.bind_tools([])
assert isinstance(bound, _BoundRetryWrapper)
assert isinstance(bound, Runnable), (
"bind_tools() must return a Runnable so create_react_agent accepts it"
)
def test_retry_wrapper_bound_invoke_passes_through():
inner = _FakeChatModel()
wrapper = _RetryWrapper.model_construct(inner=inner)
bound = wrapper.bind_tools([])
result = bound.invoke([("user", "hi")])
assert isinstance(result, AIMessage)
assert result.content == "ok"
def test_no_think_wrapper_bind_tools_returns_runnable():
inner = _FakeChatModel()
wrapper = _NoThinkWrapper.model_construct(inner=inner, model_name="qwen-test")
bound = wrapper.bind_tools([])
assert isinstance(bound, _BoundNoThinkWrapper)
assert isinstance(bound, Runnable)
def _make_genai_client_error(code: int):
pytest.importorskip("google.genai.errors")
from google.genai.errors import ClientError
return ClientError(
code,
{
"error": {
"code": code,
"status": "RESOURCE_EXHAUSTED" if code == 429 else "INVALID_ARGUMENT",
"message": "test error",
}
},
)
def test_genai_client_error_429_is_retryable():
exc = _make_genai_client_error(429)
assert is_retryable_rate_limit(exc) is True
def test_genai_client_error_400_is_not_retryable():
exc = _make_genai_client_error(400)
assert is_retryable_rate_limit(exc) is False
def test_retry_wrapper_records_first_gemini_cooldown(monkeypatch):
_reset_rate_limit_state_for_tests()
exc = _make_genai_client_error(429)
sleeps = []
monkeypatch.setattr("lilith_agent.models.time.sleep", sleeps.append)
wrapper = _RetryWrapper.model_construct(
inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
)
with pytest.raises(RateLimitCooldownError) as raised:
wrapper._generate([])
assert raised.value.provider == "google"
assert raised.value.model == "gemini-3.1-pro"
assert raised.value.cooldown_seconds == 60
assert sleeps == []
def test_retry_wrapper_escalates_gemini_cooldowns(monkeypatch):
_reset_rate_limit_state_for_tests()
exc = _make_genai_client_error(429)
monkeypatch.setattr("lilith_agent.models.time.sleep", lambda _: None)
wrapper = _RetryWrapper.model_construct(
inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
)
with pytest.raises(RateLimitCooldownError) as first:
wrapper._generate([])
with pytest.raises(RateLimitCooldownError) as second:
wrapper._generate([])
with pytest.raises(RateLimitCooldownError) as third:
wrapper._generate([])
assert first.value.cooldown_seconds == 60
assert second.value.cooldown_seconds == 120
assert third.value.cooldown_seconds == 300
def test_success_resets_lane_failure_counter(monkeypatch):
_reset_rate_limit_state_for_tests()
exc = _make_genai_client_error(429)
monkeypatch.setattr("lilith_agent.models.time.sleep", lambda _: None)
failing = _RetryWrapper.model_construct(
inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
)
success = _RetryWrapper.model_construct(
inner=_SuccessfulGenerateModel(), provider="google", model_name="gemini-3.1-pro"
)
with pytest.raises(RateLimitCooldownError):
failing._generate([])
success._generate([])
with pytest.raises(RateLimitCooldownError) as raised:
failing._generate([])
assert raised.value.cooldown_seconds == 60
def test_same_gemini_lane_shares_cooldown(monkeypatch):
_reset_rate_limit_state_for_tests()
exc = _make_genai_client_error(429)
now = [1000.0]
sleeps = []
monkeypatch.setattr("lilith_agent.models.time.monotonic", lambda: now[0])
monkeypatch.setattr("lilith_agent.models.time.sleep", sleeps.append)
first = _RetryWrapper.model_construct(
inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
)
second = _RetryWrapper.model_construct(
inner=_SuccessfulGenerateModel(), provider="google", model_name="gemini-3.1-pro"
)
with pytest.raises(RateLimitCooldownError):
first._generate([])
now[0] = 1005.0
second._generate([])
assert sleeps == [55.0]
def test_other_gemini_lane_does_not_sleep_for_pro_cooldown(monkeypatch):
_reset_rate_limit_state_for_tests()
exc = _make_genai_client_error(429)
now = [1000.0]
sleeps = []
monkeypatch.setattr("lilith_agent.models.time.monotonic", lambda: now[0])
monkeypatch.setattr("lilith_agent.models.time.sleep", sleeps.append)
pro = _RetryWrapper.model_construct(
inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
)
flash = _RetryWrapper.model_construct(
inner=_SuccessfulGenerateModel(), provider="google", model_name="gemini-3-flash-preview"
)
with pytest.raises(RateLimitCooldownError):
pro._generate([])
now[0] = 1005.0
flash._generate([])
assert sleeps == []
def test_unknown_google_model_does_not_get_gemini_cooldown(monkeypatch):
_reset_rate_limit_state_for_tests()
exc = _make_genai_client_error(429)
monkeypatch.setattr("lilith_agent.models.time.sleep", lambda _: None)
wrapper = _RetryWrapper.model_construct(
inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-unknown"
)
with pytest.raises(type(exc)):
wrapper._generate([])
def _make_genai_quota_error(details: list[dict]):
pytest.importorskip("google.genai.errors")
from google.genai.errors import ClientError
return ClientError(
429,
{
"error": {
"code": 429,
"status": "RESOURCE_EXHAUSTED",
"message": "quota exceeded",
"details": details,
}
},
)
def test_daily_quota_metadata_raises_batch_abort(monkeypatch):
_reset_rate_limit_state_for_tests()
exc = _make_genai_quota_error(
[
{
"@type": "type.googleapis.com/google.rpc.QuotaFailure",
"violations": [{"quotaId": "GenerateRequestsPerDayPerProjectPerModel"}],
}
]
)
monkeypatch.setattr("lilith_agent.models.time.sleep", lambda _: None)
wrapper = _RetryWrapper.model_construct(
inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
)
with pytest.raises(BatchAbortRateLimitError) as raised:
wrapper._generate([])
assert "daily" in raised.value.reason.lower()
def test_long_retry_delay_raises_batch_abort(monkeypatch):
_reset_rate_limit_state_for_tests()
exc = _make_genai_quota_error(
[
{
"@type": "type.googleapis.com/google.rpc.RetryInfo",
"retryDelay": "900s",
}
]
)
monkeypatch.setattr("lilith_agent.models.time.sleep", lambda _: None)
wrapper = _RetryWrapper.model_construct(
inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
)
with pytest.raises(BatchAbortRateLimitError) as raised:
wrapper._generate([])
assert "retry" in raised.value.reason.lower()
def test_question_rate_limit_scope_raises_after_50_events():
_reset_rate_limit_state_for_tests()
exc = _make_genai_client_error(429)
with pytest.raises(QuestionRateLimitStreakError) as raised:
with rate_limit_question_scope():
for _ in range(50):
try:
raise exc
except BaseException as caught:
from lilith_agent.models import record_rate_limit_observation
record_rate_limit_observation(caught)
assert raised.value.count == 50
def test_question_rate_limit_scope_resets_after_success():
_reset_rate_limit_state_for_tests()
exc = _make_genai_client_error(429)
with rate_limit_question_scope():
from lilith_agent.models import record_rate_limit_observation, record_rate_limit_success
for _ in range(49):
record_rate_limit_observation(exc)
record_rate_limit_success()
for _ in range(49):
record_rate_limit_observation(exc)
def test_batch_window_triggers_pause_after_70_rate_limits_in_100_outcomes():
_reset_rate_limit_state_for_tests()
exc = _make_genai_client_error(429)
from lilith_agent.models import record_rate_limit_observation, record_rate_limit_success
for _ in range(70):
record_rate_limit_observation(exc)
for _ in range(30):
record_rate_limit_success()
assert batch_rate_limit_pause_seconds() == 300
clear_batch_rate_limit_window()
assert batch_rate_limit_pause_seconds() is None
def test_batch_window_does_not_trigger_below_threshold():
_reset_rate_limit_state_for_tests()
exc = _make_genai_client_error(429)
from lilith_agent.models import record_rate_limit_observation, record_rate_limit_success
for _ in range(69):
record_rate_limit_observation(exc)
for _ in range(31):
record_rate_limit_success()
assert batch_rate_limit_pause_seconds() is None
def test_bound_retry_wrapper_raises_cooldown_for_gemini_lane(monkeypatch):
_reset_rate_limit_state_for_tests()
exc = _make_genai_client_error(429)
monkeypatch.setattr("lilith_agent.models.time.sleep", lambda _: None)
wrapper = _RetryWrapper.model_construct(
inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
)
bound = wrapper.bind_tools([])
with pytest.raises(RateLimitCooldownError) as raised:
bound.invoke([("user", "hi")])
assert raised.value.model == "gemini-3.1-pro"
@pytest.mark.asyncio
async def test_async_retry_wrapper_raises_cooldown_for_gemini_lane(monkeypatch):
_reset_rate_limit_state_for_tests()
exc = _make_genai_client_error(429)
monkeypatch.setattr("lilith_agent.models.asyncio.sleep", AsyncMock())
wrapper = _RetryWrapper.model_construct(
inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
)
with pytest.raises(RateLimitCooldownError) as raised:
await wrapper._agenerate([])
assert raised.value.model == "gemini-3.1-pro"
@pytest.mark.asyncio
async def test_async_bound_retry_wrapper_raises_cooldown_for_gemini_lane(monkeypatch):
_reset_rate_limit_state_for_tests()
exc = _make_genai_client_error(429)
monkeypatch.setattr("lilith_agent.models.asyncio.sleep", AsyncMock())
wrapper = _RetryWrapper.model_construct(
inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
)
bound = wrapper.bind_tools([])
with pytest.raises(RateLimitCooldownError) as raised:
await bound.ainvoke([("user", "hi")])
assert raised.value.model == "gemini-3.1-pro"
def test_deepseek_config_defaults_and_env(monkeypatch):
from lilith_agent.config import Config
monkeypatch.delenv("GAIA_DEEPSEEK_API_KEY", raising=False)
monkeypatch.delenv("GAIA_DEEPSEEK_BASE_URL", raising=False)
monkeypatch.setenv("DEEPSEEK_API_KEY", "ds-env-key")
cfg = Config.from_env()
assert cfg.deepseek_api_key == "ds-env-key"
assert cfg.deepseek_base_url == "https://api.deepseek.com"
monkeypatch.setenv("GAIA_DEEPSEEK_API_KEY", "gaia-ds-key")
monkeypatch.setenv("GAIA_DEEPSEEK_BASE_URL", "https://deepseek.internal")
cfg = Config.from_env()
assert cfg.deepseek_api_key == "gaia-ds-key"
assert cfg.deepseek_base_url == "https://deepseek.internal"
def test_agent_model_tier_selects_configured_tier(monkeypatch):
from lilith_agent.config import Config
from lilith_agent.models import _resolve_agent_model_choice
monkeypatch.setenv("GAIA_CHEAP_PROVIDER", "deepseek")
monkeypatch.setenv("GAIA_CHEAP_MODEL", "deepseek-v4-flash")
monkeypatch.setenv("GAIA_STRONG_PROVIDER", "deepseek")
monkeypatch.setenv("GAIA_STRONG_MODEL", "deepseek-v4-pro")
monkeypatch.setenv("GAIA_AGENT_MODEL_TIER", "strong")
assert _resolve_agent_model_choice(Config.from_env()) == ("deepseek", "deepseek-v4-pro")
def test_agent_model_direct_override_wins_over_tier(monkeypatch):
from lilith_agent.config import Config
from lilith_agent.models import _resolve_agent_model_choice
monkeypatch.setenv("GAIA_AGENT_MODEL_TIER", "cheap")
monkeypatch.setenv("GAIA_AGENT_PROVIDER", "deepseek")
monkeypatch.setenv("GAIA_AGENT_MODEL", "deepseek-v4-pro")
assert _resolve_agent_model_choice(Config.from_env()) == ("deepseek", "deepseek-v4-pro")
def test_invalid_agent_model_tier_is_rejected(monkeypatch):
from lilith_agent.config import Config
monkeypatch.setenv("GAIA_AGENT_MODEL_TIER", "medium")
with pytest.raises(ValueError, match="GAIA_AGENT_MODEL_TIER"):
Config.from_env()
def test_build_deepseek_uses_openai_compatible_endpoint(monkeypatch):
from dataclasses import replace
from lilith_agent.config import Config
from lilith_agent.models import _build
class FakeChatOpenAI:
def __init__(self, **kwargs):
self.kwargs = kwargs
monkeypatch.setattr("lilith_agent.models.ChatOpenAI", FakeChatOpenAI)
monkeypatch.setattr(
"lilith_agent.models._RetryWrapper",
lambda inner, provider, model_name: inner,
)
cfg = replace(
Config.from_env(),
deepseek_api_key="ds-key",
deepseek_base_url="https://deepseek.internal",
max_tokens=123,
)
model = _build("deepseek", "deepseek-v4-flash", cfg)
assert model.kwargs == {
"model": "deepseek-v4-flash",
"api_key": "ds-key",
"base_url": "https://deepseek.internal",
"max_tokens": 123,
}
def _fake_deepseek_cfg(monkeypatch):
from dataclasses import replace
from lilith_agent.config import Config
class FakeChatOpenAI:
def __init__(self, **kwargs):
self.kwargs = kwargs
monkeypatch.setattr("lilith_agent.models.ChatOpenAI", FakeChatOpenAI)
monkeypatch.setattr(
"lilith_agent.models._RetryWrapper",
lambda inner, provider, model_name: inner,
)
return replace(
Config.from_env(),
deepseek_api_key="ds-key",
deepseek_base_url="https://deepseek.internal",
max_tokens=123,
)
def test_build_deepseek_thinking_disabled_sets_extra_body(monkeypatch):
from lilith_agent.models import _build
cfg = _fake_deepseek_cfg(monkeypatch)
model = _build("deepseek", "deepseek-v4-flash", cfg, thinking=False)
assert model.kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
def test_build_deepseek_thinking_enabled_omits_extra_body(monkeypatch):
from lilith_agent.models import _build
cfg = _fake_deepseek_cfg(monkeypatch)
model = _build("deepseek", "deepseek-v4-flash", cfg)
assert "extra_body" not in model.kwargs