@@ -6052,8 +6052,9 @@ def test_clipped_prior_turn_still_carries_the_ask_it_closes_on(self):
60526052 [{"role" : "user" , "content" : turn }, {"role" : "user" , "content" : "go ahead" }],
60536053 "go ahead" ,
60546054 3 ,
6055- 200 ,
6056- False ,
6055+ budget_chars = 10_000 ,
6056+ per_turn_chars = 200 ,
6057+ include_assistant = False ,
60576058 )
60586059
60596060 assert "multi-region gateway" in quoted [0 ][1 ]
@@ -6216,7 +6217,167 @@ def test_prior_turn_window(self, messages, current_ask, window, per_turn_chars,
62166217 """
62176218 from litellm .router_strategy .complexity_router .complexity_router import _extract_prior_turns
62186219
6219- assert _extract_prior_turns (messages , current_ask , window , per_turn_chars , include_assistant ) == expected
6220+ assert (
6221+ _extract_prior_turns (
6222+ messages ,
6223+ current_ask ,
6224+ window ,
6225+ budget_chars = 10_000 ,
6226+ per_turn_chars = per_turn_chars ,
6227+ include_assistant = include_assistant ,
6228+ )
6229+ == expected
6230+ )
6231+
6232+ @pytest .mark .parametrize (
6233+ "turn_lengths,budget_chars,expected_lengths" ,
6234+ [
6235+ pytest .param ((50 , 50 , 50 ), 10_000 , (50 , 50 , 50 ), id = "a-block-that-fits-is-quoted-whole" ),
6236+ pytest .param ((100 , 100 , 100 ), 250 , (100 , 100 ), id = "oldest-turn-is-dropped-whole" ),
6237+ pytest .param ((500 , 100 ), 400 , (300 , 100 ), id = "only-the-boundary-turn-is-cut" ),
6238+ pytest .param ((900 ,), 300 , (300 ,), id = "a-turn-larger-than-the-budget-is-still-quoted" ),
6239+ pytest .param ((500 , 100 ), 180 , (100 ,), id = "a-remainder-too-small-to-carry-a-sentence-is-dropped" ),
6240+ pytest .param ((50 ,), 0 , (), id = "a-zero-budget-quotes-nothing" ),
6241+ ],
6242+ )
6243+ def test_budget_bounds_the_block_not_each_turn (self , turn_lengths , budget_chars , expected_lengths ):
6244+ """Turns are taken newest first and quoted whole while they fit.
6245+
6246+ The defect this replaces capped every turn independently, so a 785 character turn was cut even
6247+ though the whole block it belonged to was 353 characters. Bounding the block instead means an
6248+ ordinary conversation arrives intact, and when the budget really does run out the older turns
6249+ are dropped entire rather than each arriving mangled. At most one turn is ever cut, and a
6250+ remainder too small to carry a sentence is dropped rather than quoted as two ellipses around a
6251+ fragment. A single turn bigger than the whole budget is still quoted, cut to the budget, since
6252+ dropping it would leave the classifier with no context at all.
6253+ """
6254+ from litellm .router_strategy .complexity_router .complexity_router import _extract_prior_turns
6255+
6256+ messages = [{"role" : "user" , "content" : f"{ i } " * length } for i , length in enumerate (turn_lengths )]
6257+
6258+ quoted = _extract_prior_turns (
6259+ [* messages , {"role" : "user" , "content" : "go ahead" }],
6260+ "go ahead" ,
6261+ len (turn_lengths ),
6262+ budget_chars = budget_chars ,
6263+ per_turn_chars = None ,
6264+ include_assistant = False ,
6265+ )
6266+
6267+ assert tuple (len (text ) for _ , text in quoted ) == expected_lengths
6268+
6269+ @pytest .mark .parametrize ("budget_chars" , [130 , 200 , 351 , 400 , 999 , 8000 ])
6270+ @pytest .mark .parametrize ("turn_lengths" , [(900 ,), (500 , 100 ), (100 , 100 , 100 ), (50 , 50 , 50 )])
6271+ def test_the_quoted_block_never_exceeds_the_budget (self , turn_lengths , budget_chars ):
6272+ """The budget is a ceiling on what is quoted, marker included.
6273+
6274+ Cutting the boundary turn to the remainder and then appending the marker put the block three
6275+ characters over the number an operator configured, which is the kind of drift that makes a
6276+ documented ceiling untrue. Asserted across shapes rather than at the one boundary that happened
6277+ to be wrong, so any future off-by-marker anywhere in the fill is caught here.
6278+ """
6279+ from litellm .router_strategy .complexity_router .complexity_router import _extract_prior_turns
6280+
6281+ messages = [{"role" : "user" , "content" : f"{ i } " * length } for i , length in enumerate (turn_lengths )]
6282+
6283+ quoted = _extract_prior_turns (
6284+ [* messages , {"role" : "user" , "content" : "go ahead" }],
6285+ "go ahead" ,
6286+ len (turn_lengths ),
6287+ budget_chars = budget_chars ,
6288+ per_turn_chars = None ,
6289+ include_assistant = False ,
6290+ )
6291+
6292+ assert sum (len (text ) for _ , text in quoted ) <= budget_chars
6293+
6294+ def test_per_turn_cap_still_clamps_when_an_operator_sets_it (self ):
6295+ """An operator who set the per-turn cap keeps exactly what they configured.
6296+
6297+ The cap stopped being the default, so it has to keep working for the deployments that named it
6298+ deliberately; it applies before the block budget rather than instead of it.
6299+ """
6300+ from litellm .router_strategy .complexity_router .complexity_router import _extract_prior_turns
6301+
6302+ quoted = _extract_prior_turns (
6303+ [{"role" : "user" , "content" : "z" * 900 }, {"role" : "user" , "content" : "go ahead" }],
6304+ "go ahead" ,
6305+ 3 ,
6306+ budget_chars = 10_000 ,
6307+ per_turn_chars = 200 ,
6308+ include_assistant = False ,
6309+ )
6310+
6311+ assert len (quoted [0 ][1 ]) == 203
6312+
6313+ @pytest .mark .asyncio
6314+ async def test_a_long_turn_reaches_the_classifier_whole_by_default (
6315+ self , mock_router_instance , llm_classifier_config
6316+ ):
6317+ """The shipped defaults quote an ordinary long turn without cutting it anywhere.
6318+
6319+ This is the whole point of the change, asserted where a deployment actually meets it: no knob
6320+ set, one turn well past the retired 200 character cap, and no truncation marker in the payload.
6321+ """
6322+ from litellm .router_strategy .complexity_router .complexity_router import _TRUNCATION_MARKER
6323+
6324+ router = ComplexityRouter (
6325+ model_name = "test-complexity-router" ,
6326+ litellm_router_instance = mock_router_instance ,
6327+ complexity_router_config = llm_classifier_config ,
6328+ )
6329+ mock_router_instance .acompletion = AsyncMock (return_value = _llm_response ('{"tier": "SIMPLE"}' ))
6330+ turn = "The incident ran from 02:10 to 02:40 and only streaming was affected. " * 10 + "Now rewrite it"
6331+
6332+ await router .aclassify (
6333+ "go ahead" ,
6334+ messages = [{"role" : "user" , "content" : turn }, {"role" : "user" , "content" : "go ahead" }],
6335+ )
6336+
6337+ user_payload = mock_router_instance .acompletion .call_args .kwargs ["messages" ][1 ]["content" ]
6338+ assert turn in user_payload
6339+ assert _TRUNCATION_MARKER not in user_payload
6340+
6341+ @pytest .mark .asyncio
6342+ async def test_a_turn_dropped_for_budget_still_counts_as_prior_conversation (
6343+ self , mock_router_instance , llm_classifier_config
6344+ ):
6345+ """Dropping turns to fit the budget must not make a long conversation look single-turn.
6346+
6347+ The depth line gates on whether prior conversation exists, not on whether any of it was worth
6348+ quoting, exactly so a continuation is never reported as a context-free first request. A budget
6349+ tight enough to drop every turn is the newest way to reach that mismatch.
6350+ """
6351+ router = ComplexityRouter (
6352+ model_name = "test-complexity-router" ,
6353+ litellm_router_instance = mock_router_instance ,
6354+ complexity_router_config = {** llm_classifier_config , "classifier_context_budget_chars" : 1 },
6355+ )
6356+ mock_router_instance .acompletion = AsyncMock (return_value = _llm_response ('{"tier": "SIMPLE"}' ))
6357+
6358+ await router .aclassify (
6359+ "go ahead" ,
6360+ messages = [
6361+ {"role" : "user" , "content" : "a long earlier request that cannot fit a one character budget" },
6362+ {"role" : "user" , "content" : "go ahead" },
6363+ ],
6364+ )
6365+
6366+ user_payload = mock_router_instance .acompletion .call_args .kwargs ["messages" ][1 ]["content" ]
6367+ assert "Recent conversation" not in user_payload
6368+ assert "Conversation so far" in user_payload
6369+
6370+ def test_context_defaults_bound_the_block_and_leave_turns_uncapped (self ):
6371+ """The shipped defaults: a block budget, and no per-turn cap unless one is named."""
6372+ from litellm .router_strategy .complexity_router .config import (
6373+ DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS ,
6374+ ComplexityRouterConfig ,
6375+ )
6376+
6377+ config = ComplexityRouterConfig ()
6378+
6379+ assert config .classifier_context_budget_chars == DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS
6380+ assert config .classifier_context_per_turn_chars is None
62206381
62216382 def test_prior_turn_context_strips_every_configured_pair (self ):
62226383 """The classifier's context window is stripped with the same pairs as the ask.
@@ -6235,7 +6396,7 @@ def test_prior_turn_context_strips_every_configured_pair(self):
62356396 {"role" : "user" , "content" : "current ask" },
62366397 ]
62376398
6238- assert _extract_prior_turns (messages , "current ask" , 5 , 200 , False , pairs ) == (
6399+ assert _extract_prior_turns (messages , "current ask" , 5 , 10_000 , 200 , False , pairs ) == (
62396400 ("user" , "what about b-trees?" ),
62406401 ("user" , "and heaps?" ),
62416402 )
0 commit comments