@@ -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