Skip to content

Commit 297937f

Browse files
committed
fix(review-feedback-1002): address latest review comments
1 parent f53ba81 commit 297937f

2 files changed

Lines changed: 136 additions & 2 deletions

File tree

src/agent/llm_adapter.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,13 @@ def call_completion(
327327
hit_rate_limit = True
328328
# Brief backoff before trying the next model; avoids hammering
329329
# the same provider when multiple models share one account.
330-
time.sleep(min(2.0, (time.time() - started_at) * 0.1 + 0.5))
330+
backoff_sleep = min(2.0, (time.time() - started_at) * 0.1 + 0.5)
331+
if timeout is not None and timeout > 0:
332+
remaining_timeout = max(0.0, float(timeout) - (time.time() - started_at))
333+
if remaining_timeout > 0:
334+
time.sleep(min(backoff_sleep, remaining_timeout))
335+
else:
336+
time.sleep(backoff_sleep)
331337
continue
332338
except litellm.ContextWindowExceededError as e:
333339
logger.warning("Agent LLM context window exceeded on %s: %s", model, e)
@@ -338,7 +344,7 @@ def call_completion(
338344
last_error = e
339345
continue
340346

341-
suffix = " (rate-limited)" if hit_rate_limit else ""
347+
suffix = " (rate-limit encountered during fallback)" if hit_rate_limit else ""
342348
error_msg = f"All LLM models failed{suffix}. Last error: {last_error}"
343349
logger.error(error_msg)
344350
return LLMResponse(content=error_msg, provider="error")

tests/test_agent_pipeline.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,134 @@ def fake_call(_messages, _tools, model, **kwargs):
861861
self.assertEqual(timeouts[0], ("openai/gpt-4o-mini", 10.0))
862862
self.assertEqual(timeouts[1], ("anthropic/claude-3-5-sonnet-20241022", 3.0))
863863

864+
@patch("src.agent.llm_adapter.Router")
865+
def test_llm_adapter_rate_limit_backoff_is_bounded_by_remaining_timeout(self, _mock_router):
866+
"""Rate-limit backoff should sleep, but never longer than the remaining timeout budget."""
867+
mock_cfg = MagicMock()
868+
mock_cfg.agent_litellm_model = "gpt-4o-mini"
869+
mock_cfg.litellm_model = None
870+
mock_cfg.litellm_fallback_models = ["anthropic/claude-3-5-sonnet-20241022"]
871+
mock_cfg.llm_model_list = []
872+
mock_cfg.llm_temperature = 0.7
873+
mock_cfg.gemini_api_keys = []
874+
mock_cfg.anthropic_api_keys = []
875+
mock_cfg.openai_api_keys = []
876+
mock_cfg.deepseek_api_keys = []
877+
mock_cfg.openai_base_url = None
878+
879+
from src.agent.llm_adapter import LLMToolAdapter
880+
adapter = LLMToolAdapter(config=mock_cfg)
881+
882+
class FakeRateLimitError(Exception):
883+
pass
884+
885+
timeouts = []
886+
887+
def fake_call(_messages, _tools, model, **kwargs):
888+
timeouts.append((model, kwargs.get("timeout")))
889+
if model == "openai/gpt-4o-mini":
890+
raise FakeRateLimitError("rate limited")
891+
return MagicMock(content="ok")
892+
893+
adapter._call_litellm_model = MagicMock(side_effect=fake_call)
894+
895+
with patch("src.agent.llm_adapter.litellm.RateLimitError", FakeRateLimitError), \
896+
patch("src.agent.llm_adapter.logger.warning"), \
897+
patch("src.agent.llm_adapter.time.sleep") as mock_sleep, \
898+
patch("src.agent.llm_adapter.time.time", side_effect=[0.0, 0.0, 8.0, 8.8, 9.2]):
899+
result = adapter.call_completion(
900+
messages=[{"role": "user", "content": "hi"}],
901+
tools=[],
902+
timeout=10.0,
903+
)
904+
905+
self.assertEqual(result.content, "ok")
906+
self.assertEqual(timeouts[0], ("openai/gpt-4o-mini", 10.0))
907+
self.assertEqual(timeouts[1][0], "anthropic/claude-3-5-sonnet-20241022")
908+
self.assertAlmostEqual(timeouts[1][1], 0.8)
909+
mock_sleep.assert_called_once()
910+
self.assertAlmostEqual(mock_sleep.call_args.args[0], 1.2)
911+
912+
@patch("src.agent.llm_adapter.Router")
913+
def test_llm_adapter_context_window_error_skips_sleep(self, _mock_router):
914+
"""Context-window errors should continue fallback immediately without backoff."""
915+
mock_cfg = MagicMock()
916+
mock_cfg.agent_litellm_model = "gpt-4o-mini"
917+
mock_cfg.litellm_model = None
918+
mock_cfg.litellm_fallback_models = ["anthropic/claude-3-5-sonnet-20241022"]
919+
mock_cfg.llm_model_list = []
920+
mock_cfg.llm_temperature = 0.7
921+
mock_cfg.gemini_api_keys = []
922+
mock_cfg.anthropic_api_keys = []
923+
mock_cfg.openai_api_keys = []
924+
mock_cfg.deepseek_api_keys = []
925+
mock_cfg.openai_base_url = None
926+
927+
from src.agent.llm_adapter import LLMToolAdapter
928+
adapter = LLMToolAdapter(config=mock_cfg)
929+
930+
class FakeContextWindowExceededError(Exception):
931+
pass
932+
933+
def fake_call(_messages, _tools, model, **_kwargs):
934+
if model == "openai/gpt-4o-mini":
935+
raise FakeContextWindowExceededError("window exceeded")
936+
return MagicMock(content="ok")
937+
938+
adapter._call_litellm_model = MagicMock(side_effect=fake_call)
939+
940+
with patch(
941+
"src.agent.llm_adapter.litellm.ContextWindowExceededError",
942+
FakeContextWindowExceededError,
943+
), patch("src.agent.llm_adapter.time.sleep") as mock_sleep:
944+
result = adapter.call_completion(messages=[{"role": "user", "content": "hi"}], tools=[])
945+
946+
self.assertEqual(result.content, "ok")
947+
mock_sleep.assert_not_called()
948+
949+
@patch("src.agent.llm_adapter.Router")
950+
def test_llm_adapter_reports_rate_limit_suffix_when_any_fallback_hit_limit(self, _mock_router):
951+
"""Final error should note earlier rate limiting even if the last error differs."""
952+
mock_cfg = MagicMock()
953+
mock_cfg.agent_litellm_model = "gpt-4o-mini"
954+
mock_cfg.litellm_model = None
955+
mock_cfg.litellm_fallback_models = ["anthropic/claude-3-5-sonnet-20241022"]
956+
mock_cfg.llm_model_list = []
957+
mock_cfg.llm_temperature = 0.7
958+
mock_cfg.gemini_api_keys = []
959+
mock_cfg.anthropic_api_keys = []
960+
mock_cfg.openai_api_keys = []
961+
mock_cfg.deepseek_api_keys = []
962+
mock_cfg.openai_base_url = None
963+
964+
from src.agent.llm_adapter import LLMToolAdapter
965+
adapter = LLMToolAdapter(config=mock_cfg)
966+
967+
class FakeRateLimitError(Exception):
968+
pass
969+
970+
class FakeContextWindowExceededError(Exception):
971+
pass
972+
973+
def fake_call(_messages, _tools, model, **_kwargs):
974+
if model == "openai/gpt-4o-mini":
975+
raise FakeRateLimitError("rate limited")
976+
raise FakeContextWindowExceededError("window exceeded")
977+
978+
adapter._call_litellm_model = MagicMock(side_effect=fake_call)
979+
980+
with patch("src.agent.llm_adapter.litellm.RateLimitError", FakeRateLimitError), \
981+
patch(
982+
"src.agent.llm_adapter.litellm.ContextWindowExceededError",
983+
FakeContextWindowExceededError,
984+
), \
985+
patch("src.agent.llm_adapter.time.sleep"):
986+
result = adapter.call_completion(messages=[{"role": "user", "content": "hi"}], tools=[])
987+
988+
self.assertEqual(result.provider, "error")
989+
self.assertIn("All LLM models failed (rate-limit encountered during fallback).", result.content)
990+
self.assertIn("window exceeded", result.content)
991+
864992

865993
# ============================================================
866994
# _safe_int tests

0 commit comments

Comments
 (0)