fix(complexity_router): keep both ends of a clipped classifier context turn (#38141)

The LLM classifier's conversation context cut each prior turn head-only, so a turn
opening with an incident report and closing with the actual request reached the
classifier as the incident report alone. Keeping head and tail costs the same
budget and is what the truncation literature measures as best for classifying
long text.
This commit is contained in:
tin-berri 2026-08-24 17:51:13 -04:00 committed by GitHub
parent 6147b3ce6e
commit 40246f43d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 91 additions and 5 deletions

View file

@ -274,6 +274,7 @@ _REMINDER_CLOSE: Final = "</system-reminder>"
_DEFAULT_REMINDER_MARKERS: Final = ((_REMINDER_OPEN, _REMINDER_CLOSE),)
_TRUNCATION_MARKER: Final = "..."
_TRUNCATION_HEAD_FRACTION: Final = 0.3
_CJK_CHARACTER: Final = re.compile("[぀-ヿㇰ-ㇿ㐀-䶿一-鿿豈-﫿ヲ-ン\U00020000-\U0003ffff]")
@ -552,8 +553,21 @@ def _matched_plan_mode_sentinel(
def _truncate(text: str, limit: int) -> str:
"""Cap text at limit characters, marking it so the classifier can tell the turn was cut short."""
return text if len(text) <= limit else f"{text[:limit]}{_TRUNCATION_MARKER}"
"""Cap text at limit characters, keeping both ends and eliding the middle.
A chat turn states its ask at the end, so cutting the tail keeps the preamble and discards the
request the turn exists to make: a turn opening with an incident report and closing with "rewrite
the retry path and prove it cannot livelock" reached the classifier as the incident report alone.
Keeping both ends costs nothing at the same budget and is what the truncation literature finds
best for classifying long text, head+tail measuring above both head-only and tail-only in Sun et
al. 2019. The marker sits at the cut, so the turn reads as having its middle removed rather than
as trailing off mid-thought.
"""
if len(text) <= limit:
return text
head_chars: Final = max(int(limit * _TRUNCATION_HEAD_FRACTION), 0)
tail_chars: Final = max(limit - head_chars, 0)
return f"{text[:head_chars]}{_TRUNCATION_MARKER}{text[len(text) - tail_chars :]}"
def _iter_context_turns_newest_first(

View file

@ -5988,6 +5988,77 @@ class TestContextAwareClassifier:
assert _strip_reminder_blocks(text, pairs) == "<<<begin_main>>> why is my tag stripped?"
@pytest.mark.parametrize(
"text,limit,expected",
[
pytest.param("short", 10, "short", id="under-the-limit-is-untouched"),
pytest.param("exact", 5, "exact", id="exactly-the-limit-is-untouched"),
pytest.param(
"Second request with more details and longer text",
30,
"Second re...tails and longer text",
id="over-the-limit-keeps-both-ends",
),
pytest.param("abcdefghij", 4, "a...hij", id="tiny-limit-still-splits"),
pytest.param("abcdefghij", 1, "...j", id="limit-too-small-for-a-head-keeps-the-tail"),
pytest.param("abcdefghij", 0, "...", id="zero-limit-quotes-nothing"),
pytest.param("日本語のテキストと最後の質問", 6, "日...最後の質問", id="cjk-slices-by-character"),
],
)
def test_truncate_keeps_the_end_of_an_over_long_turn(self, text, limit, expected):
"""A cut turn keeps its tail, because that is where a chat turn puts its ask.
Head-only truncation was the shipped behavior and it discarded exactly the part that carries
the difficulty. The degenerate limits are here because the budget hands this function whatever
space is left rather than a configured constant, so it must stay total: a limit too small to
hold a head degrades to tail-only rather than raising or slicing with a negative index.
"""
from litellm.router_strategy.complexity_router.complexity_router import _truncate
assert _truncate(text, limit) == expected
def test_truncate_holds_its_length_budget(self):
"""Cutting to N spends N characters plus the marker, at every N including the degenerate ones.
The marker is the cost of having cut at all, so it is charged uniformly rather than only once
the limit is large enough to hold a head; a caller sizing a cut against a remaining budget can
therefore price it as limit plus marker without special-casing the small end.
"""
from litellm.router_strategy.complexity_router.complexity_router import _TRUNCATION_MARKER, _truncate
text = "x" * 500
assert all(
len(_truncate(text, limit)) == limit + len(_TRUNCATION_MARKER) for limit in (0, 1, 2, 4, 30, 200, 499)
)
def test_clipped_prior_turn_still_carries_the_ask_it_closes_on(self):
"""The reported defect, at the level the classifier sees it.
A prior turn that opens with an incident report and closes with the request routed to the
cheapest tier, because the 200-character cut kept the report and dropped the request. The
quoted turn must carry both ends.
"""
from litellm.router_strategy.complexity_router.complexity_router import _extract_prior_turns
turn = (
"We run a multi-region gateway and last night the eu-west pod returned 502s on the "
"streaming path only, for thirty minutes, while non-streaming stayed healthy the whole "
"window and the cooldown map was mid-failover. " + "Filler sentence to push past the cap. " * 4
+ "Now rewrite the streaming retry path and prove it cannot livelock."
)
quoted = _extract_prior_turns(
[{"role": "user", "content": turn}, {"role": "user", "content": "go ahead"}],
"go ahead",
3,
200,
False,
)
assert "multi-region gateway" in quoted[0][1]
assert "prove it cannot livelock" in quoted[0][1]
@pytest.mark.parametrize(
"messages,current_ask,window,per_turn_chars,include_assistant,expected",
[
@ -6002,7 +6073,7 @@ class TestContextAwareClassifier:
2,
30,
False,
(("user", "First request"), ("user", "Second request with more detai...")),
(("user", "First request"), ("user", "Second re...tails and longer text")),
id="current-ask-excluded-and-long-turn-marked-as-clipped",
),
pytest.param(
@ -6111,7 +6182,7 @@ class TestContextAwareClassifier:
1,
20,
True,
(("assistant", "a very long plan tha..."),),
(("assistant", "a very...l past the cap"),),
id="assistant-reply-is-clipped-at-per-turn-chars",
),
pytest.param(
@ -6134,7 +6205,8 @@ class TestContextAwareClassifier:
The current ask is excluded by matching it rather than by position, since `aclassify` takes
`prompt` and `messages` separately and a caller may classify other than the newest turn. A turn
cut at per_turn_chars is marked so a clip does not read as an abandoned thought.
over per_turn_chars keeps both ends with its middle elided, so the ask it closes on survives the
cut and the marker does not read as an abandoned thought.
With assistant turns enabled the window is the last N turns of the conversation rather than the
last N asks, which is what makes a plan the assistant called complex visible under a bare "yes".