mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat(complexity_router): bound the classifier context block, not each turn in it (#38145)
The LLM classifier capped every prior turn at 200 characters independently, so a 785 character turn was cut even when the whole block it belonged to was 353 characters. A character budget now bounds the block: turns are taken newest first and quoted whole while they fit, older turns are dropped whole once it runs out, and only the turn straddling the boundary is cut. The per-turn cap stays as an optional clamp for operators who set it deliberately, defaulting to unset.
This commit is contained in:
parent
a9c7b848f2
commit
31a67561ab
14 changed files with 356 additions and 90 deletions
|
|
@ -19,7 +19,7 @@ import asyncio
|
|||
import random
|
||||
import re
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from itertools import accumulate, islice
|
||||
from itertools import accumulate, islice, takewhile
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, NamedTuple, cast
|
||||
|
||||
|
|
@ -275,6 +275,7 @@ _DEFAULT_REMINDER_MARKERS: Final = ((_REMINDER_OPEN, _REMINDER_CLOSE),)
|
|||
|
||||
_TRUNCATION_MARKER: Final = "..."
|
||||
_TRUNCATION_HEAD_FRACTION: Final = 0.3
|
||||
_MIN_QUOTED_TURN_CHARS: Final = 120
|
||||
|
||||
_CJK_CHARACTER: Final = re.compile("[-ヿㇰ-ㇿ㐀-䶿一-鿿豈-ヲ-ン\U00020000-\U0003ffff]")
|
||||
|
||||
|
|
@ -593,11 +594,40 @@ def _iter_context_turns_newest_first(
|
|||
)
|
||||
|
||||
|
||||
def _turns_within_budget(
|
||||
turns: Sequence[tuple[str, str]],
|
||||
budget_chars: int,
|
||||
) -> tuple[tuple[str, str], ...]:
|
||||
"""The newest-first turns that fit budget_chars, quoted whole wherever they fit.
|
||||
|
||||
Bounding the block rather than every turn in it is what lets an ordinary conversation reach the
|
||||
classifier intact: a per-turn cap cuts a 785 character turn even when the whole block would have
|
||||
been 353 characters, which is three orders of magnitude below anything the classifier call is
|
||||
near. Once the budget does run out the older turns are dropped entire rather than shortened, so
|
||||
at most one turn is ever cut and the rest read as themselves. A remainder too small to carry a
|
||||
sentence buys less signal than the ellipses it would arrive wrapped in, so that turn is dropped.
|
||||
|
||||
The boundary turn is cut to leave room for the marker rather than to the remainder itself, so the
|
||||
quoted block never exceeds budget_chars; the marker is part of what the budget buys, not an extra
|
||||
charged on top of it.
|
||||
"""
|
||||
spent: Final = accumulate(len(text) for _, text in turns)
|
||||
fitting: Final = tuple(takewhile(lambda pair: pair[1] <= budget_chars, zip(turns, spent)))
|
||||
remaining: Final = budget_chars - (fitting[-1][1] if fitting else 0)
|
||||
whole: Final = tuple(turn for turn, _ in fitting)
|
||||
cut_to: Final = remaining - len(_TRUNCATION_MARKER)
|
||||
if len(whole) == len(turns) or cut_to < _MIN_QUOTED_TURN_CHARS:
|
||||
return whole
|
||||
boundary_role, boundary_text = turns[len(whole)]
|
||||
return (*whole, (boundary_role, _truncate(boundary_text, cut_to)))
|
||||
|
||||
|
||||
def _extract_prior_turns(
|
||||
messages: Sequence[Mapping[str, object]],
|
||||
current_ask: str | None,
|
||||
window_size: int,
|
||||
per_turn_chars: int,
|
||||
budget_chars: int,
|
||||
per_turn_chars: int | None,
|
||||
include_assistant: bool,
|
||||
marker_pairs: tuple[tuple[str, str], ...] = _DEFAULT_REMINDER_MARKERS,
|
||||
) -> tuple[tuple[str, str], ...]:
|
||||
|
|
@ -612,19 +642,29 @@ def _extract_prior_turns(
|
|||
window_size counts turns of every eligible role, so with assistant turns included it is the last N
|
||||
of the conversation rather than the last N asks. A turn carrying only tool calls or thinking
|
||||
blocks flattens to empty text and is skipped, so it never spends a slot.
|
||||
|
||||
Three bounds apply and the tightest wins: window_size caps how many turns, budget_chars caps the
|
||||
block they form, and per_turn_chars optionally caps any single one of them before the block is
|
||||
measured. They are separate because they answer separate questions, and only the block bound
|
||||
tracks what the classifier call actually costs.
|
||||
"""
|
||||
if window_size <= 0 or not messages:
|
||||
return ()
|
||||
|
||||
prior: Final = islice(
|
||||
(
|
||||
turn
|
||||
for turn in _iter_context_turns_newest_first(messages, include_assistant, marker_pairs)
|
||||
if turn[1] != current_ask
|
||||
),
|
||||
window_size,
|
||||
prior: Final = tuple(
|
||||
islice(
|
||||
(
|
||||
turn
|
||||
for turn in _iter_context_turns_newest_first(messages, include_assistant, marker_pairs)
|
||||
if turn[1] != current_ask
|
||||
),
|
||||
window_size,
|
||||
)
|
||||
)
|
||||
return tuple((role, _truncate(text, per_turn_chars)) for role, text in reversed(tuple(prior)))
|
||||
clamped: Final = (
|
||||
prior if per_turn_chars is None else tuple((role, _truncate(text, per_turn_chars)) for role, text in prior)
|
||||
)
|
||||
return tuple(reversed(_turns_within_budget(clamped, budget_chars)))
|
||||
|
||||
|
||||
def _decision_is_pinnable(decision: StandardLoggingRoutingDecision | None) -> bool:
|
||||
|
|
@ -1363,6 +1403,7 @@ class ComplexityRouter(CustomLogger):
|
|||
messages,
|
||||
current_ask=prompt,
|
||||
window_size=self.config.classifier_context_window_size,
|
||||
budget_chars=self.config.classifier_context_budget_chars,
|
||||
per_turn_chars=self.config.classifier_context_per_turn_chars,
|
||||
include_assistant=include_assistant,
|
||||
marker_pairs=self._reminder_markers,
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ TIER_SEVERITY_ORDER: Final[tuple[ComplexityTier, ...]] = (
|
|||
DEFAULT_TIER_DISTANCE_PENALTY: Final[float] = 0.5
|
||||
|
||||
DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE: Final[int] = 3
|
||||
DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS: Final[int] = 200
|
||||
DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS: Final[int] = 8000
|
||||
|
||||
|
||||
class KeywordTierRule(BaseModel):
|
||||
|
|
@ -645,12 +645,30 @@ class ComplexityRouterConfig(BaseModel):
|
|||
"classifier_type is 'llm'."
|
||||
),
|
||||
)
|
||||
classifier_context_per_turn_chars: int = Field(
|
||||
default=DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS,
|
||||
classifier_context_budget_chars: int = Field(
|
||||
default=DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS,
|
||||
ge=0,
|
||||
description=(
|
||||
"Maximum characters of prior-turn text quoted to the LLM classifier, across the whole "
|
||||
"context window, per classification call. Turns are taken newest first and quoted whole "
|
||||
"while they fit, so a conversation small enough to quote entirely is never cut; once the "
|
||||
"budget runs out the older turns are dropped whole and only the turn straddling the "
|
||||
"boundary is truncated, into whatever space is left. The current ask and the caller's "
|
||||
"system prompt sit outside this budget and are always sent in full, as does the numbering "
|
||||
"each quoted turn carries. A budget under 120 leaves no room to quote a turn and "
|
||||
"suppresses the block; set classifier_context_window_size to 0 to turn context off "
|
||||
"deliberately. Only applies when classifier_type is 'llm'."
|
||||
),
|
||||
)
|
||||
classifier_context_per_turn_chars: int | None = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
description=(
|
||||
"Maximum character length for each prior turn's text in the classifier context window. "
|
||||
"Turns exceeding this are truncated. Only applies when classifier_type is 'llm'."
|
||||
"Optional cap on each individual prior turn's text, applied before "
|
||||
"classifier_context_budget_chars bounds the block. Unset by default, so one long turn may "
|
||||
"spend the whole budget, which is usually what a follow-up needs; set it when no single "
|
||||
"turn should dominate the context the classifier sees. A capped turn keeps its opening "
|
||||
"and its ending with the middle elided. Only applies when classifier_type is 'llm'."
|
||||
),
|
||||
)
|
||||
classifier_context_include_assistant_turns: bool = Field(
|
||||
|
|
@ -662,9 +680,9 @@ class ComplexityRouterConfig(BaseModel):
|
|||
"word 'yes'. When enabled, classifier_context_window_size counts the last N turns of the "
|
||||
"conversation across both roles rather than the last N user turns, and assistant text is "
|
||||
"sent to the classifier model, which may be a different deployment or provider than the "
|
||||
"routed completion model. Assistant replies share classifier_context_per_turn_chars with "
|
||||
"user turns, so raise it if replies are truncated before the part that carries the "
|
||||
"difficulty. Off by default because enabling it shifts tier decisions, and therefore "
|
||||
"routed completion model. Assistant replies spend classifier_context_budget_chars "
|
||||
"alongside user turns, so raise it if the oldest turns stop being quoted once replies "
|
||||
"join the window. Off by default because enabling it shifts tier decisions, and therefore "
|
||||
"spend, for an already-deployed router. Only applies when classifier_type is 'llm'."
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6052,8 +6052,9 @@ class TestContextAwareClassifier:
|
|||
[{"role": "user", "content": turn}, {"role": "user", "content": "go ahead"}],
|
||||
"go ahead",
|
||||
3,
|
||||
200,
|
||||
False,
|
||||
budget_chars=10_000,
|
||||
per_turn_chars=200,
|
||||
include_assistant=False,
|
||||
)
|
||||
|
||||
assert "multi-region gateway" in quoted[0][1]
|
||||
|
|
@ -6216,7 +6217,167 @@ class TestContextAwareClassifier:
|
|||
"""
|
||||
from litellm.router_strategy.complexity_router.complexity_router import _extract_prior_turns
|
||||
|
||||
assert _extract_prior_turns(messages, current_ask, window, per_turn_chars, include_assistant) == expected
|
||||
assert (
|
||||
_extract_prior_turns(
|
||||
messages,
|
||||
current_ask,
|
||||
window,
|
||||
budget_chars=10_000,
|
||||
per_turn_chars=per_turn_chars,
|
||||
include_assistant=include_assistant,
|
||||
)
|
||||
== expected
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"turn_lengths,budget_chars,expected_lengths",
|
||||
[
|
||||
pytest.param((50, 50, 50), 10_000, (50, 50, 50), id="a-block-that-fits-is-quoted-whole"),
|
||||
pytest.param((100, 100, 100), 250, (100, 100), id="oldest-turn-is-dropped-whole"),
|
||||
pytest.param((500, 100), 400, (300, 100), id="only-the-boundary-turn-is-cut"),
|
||||
pytest.param((900,), 300, (300,), id="a-turn-larger-than-the-budget-is-still-quoted"),
|
||||
pytest.param((500, 100), 180, (100,), id="a-remainder-too-small-to-carry-a-sentence-is-dropped"),
|
||||
pytest.param((50,), 0, (), id="a-zero-budget-quotes-nothing"),
|
||||
],
|
||||
)
|
||||
def test_budget_bounds_the_block_not_each_turn(self, turn_lengths, budget_chars, expected_lengths):
|
||||
"""Turns are taken newest first and quoted whole while they fit.
|
||||
|
||||
The defect this replaces capped every turn independently, so a 785 character turn was cut even
|
||||
though the whole block it belonged to was 353 characters. Bounding the block instead means an
|
||||
ordinary conversation arrives intact, and when the budget really does run out the older turns
|
||||
are dropped entire rather than each arriving mangled. At most one turn is ever cut, and a
|
||||
remainder too small to carry a sentence is dropped rather than quoted as two ellipses around a
|
||||
fragment. A single turn bigger than the whole budget is still quoted, cut to the budget, since
|
||||
dropping it would leave the classifier with no context at all.
|
||||
"""
|
||||
from litellm.router_strategy.complexity_router.complexity_router import _extract_prior_turns
|
||||
|
||||
messages = [{"role": "user", "content": f"{i}" * length} for i, length in enumerate(turn_lengths)]
|
||||
|
||||
quoted = _extract_prior_turns(
|
||||
[*messages, {"role": "user", "content": "go ahead"}],
|
||||
"go ahead",
|
||||
len(turn_lengths),
|
||||
budget_chars=budget_chars,
|
||||
per_turn_chars=None,
|
||||
include_assistant=False,
|
||||
)
|
||||
|
||||
assert tuple(len(text) for _, text in quoted) == expected_lengths
|
||||
|
||||
@pytest.mark.parametrize("budget_chars", [130, 200, 351, 400, 999, 8000])
|
||||
@pytest.mark.parametrize("turn_lengths", [(900,), (500, 100), (100, 100, 100), (50, 50, 50)])
|
||||
def test_the_quoted_block_never_exceeds_the_budget(self, turn_lengths, budget_chars):
|
||||
"""The budget is a ceiling on what is quoted, marker included.
|
||||
|
||||
Cutting the boundary turn to the remainder and then appending the marker put the block three
|
||||
characters over the number an operator configured, which is the kind of drift that makes a
|
||||
documented ceiling untrue. Asserted across shapes rather than at the one boundary that happened
|
||||
to be wrong, so any future off-by-marker anywhere in the fill is caught here.
|
||||
"""
|
||||
from litellm.router_strategy.complexity_router.complexity_router import _extract_prior_turns
|
||||
|
||||
messages = [{"role": "user", "content": f"{i}" * length} for i, length in enumerate(turn_lengths)]
|
||||
|
||||
quoted = _extract_prior_turns(
|
||||
[*messages, {"role": "user", "content": "go ahead"}],
|
||||
"go ahead",
|
||||
len(turn_lengths),
|
||||
budget_chars=budget_chars,
|
||||
per_turn_chars=None,
|
||||
include_assistant=False,
|
||||
)
|
||||
|
||||
assert sum(len(text) for _, text in quoted) <= budget_chars
|
||||
|
||||
def test_per_turn_cap_still_clamps_when_an_operator_sets_it(self):
|
||||
"""An operator who set the per-turn cap keeps exactly what they configured.
|
||||
|
||||
The cap stopped being the default, so it has to keep working for the deployments that named it
|
||||
deliberately; it applies before the block budget rather than instead of it.
|
||||
"""
|
||||
from litellm.router_strategy.complexity_router.complexity_router import _extract_prior_turns
|
||||
|
||||
quoted = _extract_prior_turns(
|
||||
[{"role": "user", "content": "z" * 900}, {"role": "user", "content": "go ahead"}],
|
||||
"go ahead",
|
||||
3,
|
||||
budget_chars=10_000,
|
||||
per_turn_chars=200,
|
||||
include_assistant=False,
|
||||
)
|
||||
|
||||
assert len(quoted[0][1]) == 203
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_long_turn_reaches_the_classifier_whole_by_default(
|
||||
self, mock_router_instance, llm_classifier_config
|
||||
):
|
||||
"""The shipped defaults quote an ordinary long turn without cutting it anywhere.
|
||||
|
||||
This is the whole point of the change, asserted where a deployment actually meets it: no knob
|
||||
set, one turn well past the retired 200 character cap, and no truncation marker in the payload.
|
||||
"""
|
||||
from litellm.router_strategy.complexity_router.complexity_router import _TRUNCATION_MARKER
|
||||
|
||||
router = ComplexityRouter(
|
||||
model_name="test-complexity-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=llm_classifier_config,
|
||||
)
|
||||
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "SIMPLE"}'))
|
||||
turn = "The incident ran from 02:10 to 02:40 and only streaming was affected. " * 10 + "Now rewrite it"
|
||||
|
||||
await router.aclassify(
|
||||
"go ahead",
|
||||
messages=[{"role": "user", "content": turn}, {"role": "user", "content": "go ahead"}],
|
||||
)
|
||||
|
||||
user_payload = mock_router_instance.acompletion.call_args.kwargs["messages"][1]["content"]
|
||||
assert turn in user_payload
|
||||
assert _TRUNCATION_MARKER not in user_payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_turn_dropped_for_budget_still_counts_as_prior_conversation(
|
||||
self, mock_router_instance, llm_classifier_config
|
||||
):
|
||||
"""Dropping turns to fit the budget must not make a long conversation look single-turn.
|
||||
|
||||
The depth line gates on whether prior conversation exists, not on whether any of it was worth
|
||||
quoting, exactly so a continuation is never reported as a context-free first request. A budget
|
||||
tight enough to drop every turn is the newest way to reach that mismatch.
|
||||
"""
|
||||
router = ComplexityRouter(
|
||||
model_name="test-complexity-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config={**llm_classifier_config, "classifier_context_budget_chars": 1},
|
||||
)
|
||||
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "SIMPLE"}'))
|
||||
|
||||
await router.aclassify(
|
||||
"go ahead",
|
||||
messages=[
|
||||
{"role": "user", "content": "a long earlier request that cannot fit a one character budget"},
|
||||
{"role": "user", "content": "go ahead"},
|
||||
],
|
||||
)
|
||||
|
||||
user_payload = mock_router_instance.acompletion.call_args.kwargs["messages"][1]["content"]
|
||||
assert "Recent conversation" not in user_payload
|
||||
assert "Conversation so far" in user_payload
|
||||
|
||||
def test_context_defaults_bound_the_block_and_leave_turns_uncapped(self):
|
||||
"""The shipped defaults: a block budget, and no per-turn cap unless one is named."""
|
||||
from litellm.router_strategy.complexity_router.config import (
|
||||
DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS,
|
||||
ComplexityRouterConfig,
|
||||
)
|
||||
|
||||
config = ComplexityRouterConfig()
|
||||
|
||||
assert config.classifier_context_budget_chars == DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS
|
||||
assert config.classifier_context_per_turn_chars is None
|
||||
|
||||
def test_prior_turn_context_strips_every_configured_pair(self):
|
||||
"""The classifier's context window is stripped with the same pairs as the ask.
|
||||
|
|
@ -6235,7 +6396,7 @@ class TestContextAwareClassifier:
|
|||
{"role": "user", "content": "current ask"},
|
||||
]
|
||||
|
||||
assert _extract_prior_turns(messages, "current ask", 5, 200, False, pairs) == (
|
||||
assert _extract_prior_turns(messages, "current ask", 5, 10_000, 200, False, pairs) == (
|
||||
("user", "what about b-trees?"),
|
||||
("user", "and heaps?"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ import {
|
|||
ClassifierFallback,
|
||||
ClassifierType,
|
||||
ComplexityRouterConfigValue,
|
||||
DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS,
|
||||
DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS,
|
||||
MIN_QUOTED_CONTEXT_TURN_CHARS,
|
||||
DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE,
|
||||
DEFAULT_CLASSIFIER_FALLBACK,
|
||||
DEFAULT_CLASSIFIER_TIMEOUT_MS,
|
||||
|
|
@ -149,6 +150,8 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
const classifierModelMissing =
|
||||
showValidationErrors && value.classifier_type === "llm" && !value.classifier_llm_config?.model;
|
||||
const usesCustomPrompt = Boolean(value.classifier_llm_config?.system_prompt?.trim());
|
||||
const contextBudget = value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS;
|
||||
const contextBudgetQuotesNothing = contextBudget > 0 && contextBudget < MIN_QUOTED_CONTEXT_TURN_CHARS;
|
||||
const classificationRubric = value.classifier_llm_config?.classification_rubric ?? DEFAULT_CLASSIFICATION_RUBRIC;
|
||||
|
||||
const handleClassifierTypeChange = (classifierType: ClassifierType) => {
|
||||
|
|
@ -167,9 +170,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
classifierType === "llm"
|
||||
? value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE
|
||||
: undefined,
|
||||
classifier_context_per_turn_chars:
|
||||
classifier_context_budget_chars:
|
||||
classifierType === "llm"
|
||||
? value.classifier_context_per_turn_chars ?? DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS
|
||||
? value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS
|
||||
: undefined,
|
||||
classifier_context_include_assistant_turns:
|
||||
classifierType === "llm" ? value.classifier_context_include_assistant_turns : undefined,
|
||||
|
|
@ -235,10 +238,10 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
});
|
||||
};
|
||||
|
||||
const handleClassifierContextPerTurnCharsChange = (perTurnChars: number | null) => {
|
||||
const handleClassifierContextBudgetCharsChange = (budgetChars: number | null) => {
|
||||
onChange({
|
||||
...value,
|
||||
classifier_context_per_turn_chars: perTurnChars ?? DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS,
|
||||
classifier_context_budget_chars: budgetChars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -411,17 +414,27 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">Context Per-Turn Character Limit</strong>
|
||||
<strong className="block mb-1 font-semibold">Context Character Budget</strong>
|
||||
<Input
|
||||
type="number"
|
||||
value={value.classifier_context_per_turn_chars ?? DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS}
|
||||
value={value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS}
|
||||
onChange={(event) =>
|
||||
handleClassifierContextPerTurnCharsChange(event.target.value === "" ? null : event.target.valueAsNumber)
|
||||
handleClassifierContextBudgetCharsChange(event.target.value === "" ? null : event.target.valueAsNumber)
|
||||
}
|
||||
min={1}
|
||||
min={0}
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Prior turns longer than this are truncated.</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Total characters of prior conversation sent to the classifier. Turns are taken newest first and quoted
|
||||
whole while they fit, so a short conversation is never cut.
|
||||
</span>
|
||||
{contextBudgetQuotesNothing && (
|
||||
<span className="block text-xs text-destructive">
|
||||
Under {MIN_QUOTED_CONTEXT_TURN_CHARS} characters there is no room to quote a turn that does not already
|
||||
fit, so a long conversation reaches the classifier with no context at all. Set Context Window Size to 0
|
||||
to turn context off deliberately.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ describe("ComplexityRouterConfig", () => {
|
|||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "", timeout_ms: 3000, classification_rubric: "agentic" },
|
||||
classifier_context_window_size: 3,
|
||||
classifier_context_per_turn_chars: 200,
|
||||
classifier_context_budget_chars: 8000,
|
||||
};
|
||||
expect(onChange).toHaveBeenCalledWith(expectedValue);
|
||||
});
|
||||
|
|
@ -130,11 +130,10 @@ describe("ComplexityRouterConfig", () => {
|
|||
expect(screen.getByDisplayValue("750")).toBeInTheDocument();
|
||||
expect(screen.getByText("Context Window Size")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("5")).toBeInTheDocument();
|
||||
expect(screen.getByText("Context Per-Turn Character Limit")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("400")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Context Per-Turn Character Limit")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should default classifier context fields to 3 and 200 when llm is selected without explicit values", () => {
|
||||
it("should default the context window and budget when llm is selected", () => {
|
||||
const llmValue: ComplexityRouterConfigValue = {
|
||||
...defaultValue,
|
||||
classifier_type: "llm",
|
||||
|
|
@ -147,8 +146,42 @@ describe("ComplexityRouterConfig", () => {
|
|||
const windowSizeSection = screen.getByText("Context Window Size").closest("div") as HTMLElement;
|
||||
expect(within(windowSizeSection).getByDisplayValue("3")).toBeInTheDocument();
|
||||
|
||||
const perTurnCharsSection = screen.getByText("Context Per-Turn Character Limit").closest("div") as HTMLElement;
|
||||
expect(within(perTurnCharsSection).getByDisplayValue("200")).toBeInTheDocument();
|
||||
const budgetSection = screen.getByText("Context Character Budget").closest("div") as HTMLElement;
|
||||
expect(within(budgetSection).getByDisplayValue("8000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should warn when the budget is too small to quote any turn that does not already fit", () => {
|
||||
const llmValue: ComplexityRouterConfigValue = {
|
||||
...defaultValue,
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 },
|
||||
classifier_context_budget_chars: 50,
|
||||
};
|
||||
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={llmValue} onChange={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
|
||||
expect(screen.getByText(/no room to quote a turn/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not warn on a budget large enough to quote a turn, nor on a deliberate zero", () => {
|
||||
for (const budget of [120, 8000, 0]) {
|
||||
const { unmount } = renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
modelInfo={mockModelInfo}
|
||||
value={{
|
||||
...defaultValue,
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 },
|
||||
classifier_context_budget_chars: budget,
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.queryByText(/no room to quote a turn/i)).not.toBeInTheDocument();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("should show the assistant-turns switch with its configured value when classifier_type is llm", () => {
|
||||
|
|
@ -229,26 +262,6 @@ describe("ComplexityRouterConfig", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should call onChange with the updated classifier_context_per_turn_chars when edited", () => {
|
||||
const onChange = vi.fn();
|
||||
const llmValue: ComplexityRouterConfigValue = {
|
||||
...defaultValue,
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 },
|
||||
};
|
||||
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={llmValue} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
|
||||
const perTurnCharsSection = screen.getByText("Context Per-Turn Character Limit").closest("div") as HTMLElement;
|
||||
const input = within(perTurnCharsSection).getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "500" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...llmValue,
|
||||
classifier_context_per_turn_chars: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the custom technical keywords field", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ export type { DimensionWeights, TierBoundaries, TokenThresholds };
|
|||
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 3000;
|
||||
export const DEFAULT_TIER_DISTANCE_PENALTY = 0.5;
|
||||
export const DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE = 3;
|
||||
export const DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS = 200;
|
||||
export const DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS = 8000;
|
||||
export const MIN_QUOTED_CONTEXT_TURN_CHARS = 120;
|
||||
export const DEFAULT_SESSION_AFFINITY = false;
|
||||
export const DEFAULT_DEPLOYMENT_AFFINITY = true;
|
||||
|
||||
|
|
@ -137,6 +138,7 @@ export interface ComplexityRouterConfigValue {
|
|||
classifier_type: ClassifierType;
|
||||
classifier_llm_config?: ClassifierLLMConfig;
|
||||
classifier_context_window_size?: number;
|
||||
classifier_context_budget_chars?: number;
|
||||
classifier_context_per_turn_chars?: number;
|
||||
classifier_context_include_assistant_turns?: boolean;
|
||||
classifier_fallback?: ClassifierFallback;
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
classifierType: complexityRouterConfig.classifier_type,
|
||||
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
|
||||
classifierContextWindowSize: complexityRouterConfig.classifier_context_window_size,
|
||||
classifierContextPerTurnChars: complexityRouterConfig.classifier_context_per_turn_chars,
|
||||
classifierContextBudgetChars: complexityRouterConfig.classifier_context_budget_chars,
|
||||
classifierContextIncludeAssistantTurns: complexityRouterConfig.classifier_context_include_assistant_turns,
|
||||
classifierFallback: complexityRouterConfig.classifier_fallback,
|
||||
sessionAffinity: complexityRouterConfig.session_affinity ?? DEFAULT_SESSION_AFFINITY,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const baseParams: BuildComplexityRouterConfigParams = {
|
|||
classifierType: "heuristic",
|
||||
classifierLlmConfig: undefined,
|
||||
classifierContextWindowSize: undefined,
|
||||
classifierContextPerTurnChars: undefined,
|
||||
classifierContextBudgetChars: undefined,
|
||||
classifierContextIncludeAssistantTurns: undefined,
|
||||
classifierFallback: undefined,
|
||||
sessionAffinity: false,
|
||||
|
|
@ -93,39 +93,39 @@ describe("buildComplexityRouterConfig", () => {
|
|||
expect(config.classifier_llm_config).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes classifier_context_window_size and classifier_context_per_turn_chars only when classifier_type is llm", () => {
|
||||
it("includes classifier_context_window_size and classifier_context_budget_chars only when classifier_type is llm", () => {
|
||||
const params: BuildComplexityRouterConfigParams = {
|
||||
...baseParams,
|
||||
classifierType: "llm",
|
||||
classifierLlmConfig: { model: "gpt-4o-mini", timeout_ms: 3000 },
|
||||
classifierContextWindowSize: 5,
|
||||
classifierContextPerTurnChars: 300,
|
||||
classifierContextBudgetChars: 4000,
|
||||
};
|
||||
const config = buildComplexityRouterConfig(params);
|
||||
expect(config.classifier_context_window_size).toBe(5);
|
||||
expect(config.classifier_context_per_turn_chars).toBe(300);
|
||||
expect(config.classifier_context_budget_chars).toBe(4000);
|
||||
});
|
||||
|
||||
it("omits classifier_context_window_size and classifier_context_per_turn_chars when classifier_type is heuristic even if values linger in state", () => {
|
||||
it("omits classifier_context_window_size and classifier_context_budget_chars when classifier_type is heuristic even if values linger in state", () => {
|
||||
const params: BuildComplexityRouterConfigParams = {
|
||||
...baseParams,
|
||||
classifierType: "heuristic",
|
||||
classifierContextWindowSize: 5,
|
||||
classifierContextPerTurnChars: 300,
|
||||
classifierContextBudgetChars: 4000,
|
||||
};
|
||||
const config = buildComplexityRouterConfig(params);
|
||||
expect(config.classifier_context_window_size).toBeUndefined();
|
||||
expect(config.classifier_context_per_turn_chars).toBeUndefined();
|
||||
expect(config.classifier_context_budget_chars).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits classifier_context_window_size and classifier_context_per_turn_chars when classifier_type is llm but neither was set, leaving the backend default", () => {
|
||||
it("omits classifier_context_window_size and classifier_context_budget_chars when classifier_type is llm but neither was set, leaving the backend default", () => {
|
||||
const config = buildComplexityRouterConfig({
|
||||
...baseParams,
|
||||
classifierType: "llm",
|
||||
classifierLlmConfig: { model: "gpt-4o-mini", timeout_ms: 3000 },
|
||||
});
|
||||
expect(config.classifier_context_window_size).toBeUndefined();
|
||||
expect(config.classifier_context_per_turn_chars).toBeUndefined();
|
||||
expect(config.classifier_context_budget_chars).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows classifier_context_window_size of 0, distinct from unset, to send no prior-turn context", () => {
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export interface BuildComplexityRouterConfigParams {
|
|||
classifierType: ClassifierType;
|
||||
classifierLlmConfig: ClassifierLLMConfig | undefined;
|
||||
classifierContextWindowSize: number | undefined;
|
||||
classifierContextPerTurnChars: number | undefined;
|
||||
classifierContextBudgetChars: number | undefined;
|
||||
classifierContextIncludeAssistantTurns: boolean | undefined;
|
||||
classifierFallback: ClassifierFallback | undefined;
|
||||
sessionAffinity: boolean;
|
||||
|
|
@ -111,6 +111,7 @@ export interface ComplexityRouterConfigPayload {
|
|||
classifier_type: ClassifierType;
|
||||
classifier_llm_config?: ClassifierLLMConfig;
|
||||
classifier_context_window_size?: number;
|
||||
classifier_context_budget_chars?: number;
|
||||
classifier_context_per_turn_chars?: number;
|
||||
classifier_context_include_assistant_turns?: boolean;
|
||||
classifier_fallback?: ClassifierFallback;
|
||||
|
|
@ -219,7 +220,7 @@ export const buildComplexityRouterConfig = ({
|
|||
classifierType,
|
||||
classifierLlmConfig,
|
||||
classifierContextWindowSize,
|
||||
classifierContextPerTurnChars,
|
||||
classifierContextBudgetChars,
|
||||
classifierContextIncludeAssistantTurns,
|
||||
classifierFallback,
|
||||
sessionAffinity,
|
||||
|
|
@ -270,8 +271,8 @@ export const buildComplexityRouterConfig = ({
|
|||
classifier_context_window_size: classifierContextWindowSize,
|
||||
}),
|
||||
...(classifierType === "llm" &&
|
||||
classifierContextPerTurnChars !== undefined && {
|
||||
classifier_context_per_turn_chars: classifierContextPerTurnChars,
|
||||
classifierContextBudgetChars !== undefined && {
|
||||
classifier_context_budget_chars: classifierContextBudgetChars,
|
||||
}),
|
||||
...(classifierType === "llm" &&
|
||||
classifierContextIncludeAssistantTurns !== undefined && {
|
||||
|
|
|
|||
|
|
@ -113,18 +113,30 @@ describe("buildUpdatedComplexityRouterConfig classifier context window", () => {
|
|||
expect(result.classifier_context_per_turn_chars).toBe(300);
|
||||
});
|
||||
|
||||
it("persists an edited classifier context window size and per-turn char limit", () => {
|
||||
it("persists an edited classifier context window size", () => {
|
||||
const formValue = {
|
||||
tiers: STORED_LLM.tiers,
|
||||
classifier_type: "llm" as const,
|
||||
classifier_llm_config: STORED_LLM.classifier_llm_config,
|
||||
classifier_context_window_size: 10,
|
||||
classifier_context_per_turn_chars: 500,
|
||||
};
|
||||
const result = buildUpdatedComplexityRouterConfig(STORED_LLM, formValue);
|
||||
|
||||
expect(result.classifier_context_window_size).toBe(10);
|
||||
expect(result.classifier_context_per_turn_chars).toBe(500);
|
||||
});
|
||||
|
||||
it("carries a stored per-turn cap through untouched now that no control sets it", () => {
|
||||
// The modal stopped rendering a per-turn control, so the key left MANAGED_COMPLEXITY_ROUTER_KEYS.
|
||||
// Had it stayed managed, every open-and-save would have silently dropped an operator's cap.
|
||||
const formValue = {
|
||||
tiers: STORED_LLM.tiers,
|
||||
classifier_type: "llm" as const,
|
||||
classifier_llm_config: STORED_LLM.classifier_llm_config,
|
||||
classifier_context_window_size: 10,
|
||||
};
|
||||
const result = buildUpdatedComplexityRouterConfig(STORED_LLM, formValue);
|
||||
|
||||
expect(result.classifier_context_per_turn_chars).toBe(300);
|
||||
});
|
||||
|
||||
it("omits classifier context fields when classifier_type is heuristic even if values linger in state", () => {
|
||||
|
|
@ -132,12 +144,12 @@ describe("buildUpdatedComplexityRouterConfig classifier context window", () => {
|
|||
tiers: STORED_LLM.tiers,
|
||||
classifier_type: "heuristic" as const,
|
||||
classifier_context_window_size: 5,
|
||||
classifier_context_per_turn_chars: 300,
|
||||
classifier_context_budget_chars: 4000,
|
||||
};
|
||||
const result = buildUpdatedComplexityRouterConfig(STORED_LLM, formValue);
|
||||
|
||||
expect(result.classifier_context_window_size).toBeUndefined();
|
||||
expect(result.classifier_context_per_turn_chars).toBeUndefined();
|
||||
expect(result.classifier_context_budget_chars).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not resurrect a stale stored classifier_context_window_size once the form's own value is unset", () => {
|
||||
|
|
@ -151,7 +163,6 @@ describe("buildUpdatedComplexityRouterConfig classifier context window", () => {
|
|||
const result = buildUpdatedComplexityRouterConfig(STORED_LLM, formValue);
|
||||
|
||||
expect(result.classifier_context_window_size).toBeUndefined();
|
||||
expect(result.classifier_context_per_turn_chars).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ describe("EditAutoRouterModal classifier context window", () => {
|
|||
await user.click(await screen.findByText("Advanced: Classification Method"));
|
||||
await screen.findByText("Context Window Size");
|
||||
expect(screen.getByDisplayValue("5")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("300")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Context Per-Turn Character Limit")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
|
|||
"classifier_type",
|
||||
"classifier_llm_config",
|
||||
"classifier_context_window_size",
|
||||
"classifier_context_per_turn_chars",
|
||||
"classifier_context_budget_chars",
|
||||
"classifier_context_include_assistant_turns",
|
||||
"classifier_fallback",
|
||||
"session_affinity",
|
||||
|
|
@ -175,8 +175,8 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
classifier_context_window_size: value.classifier_context_window_size,
|
||||
}),
|
||||
...(value.classifier_type === "llm" &&
|
||||
value.classifier_context_per_turn_chars !== undefined && {
|
||||
classifier_context_per_turn_chars: value.classifier_context_per_turn_chars,
|
||||
value.classifier_context_budget_chars !== undefined && {
|
||||
classifier_context_budget_chars: value.classifier_context_budget_chars,
|
||||
}),
|
||||
...(value.classifier_type === "llm" &&
|
||||
value.classifier_context_include_assistant_turns !== undefined && {
|
||||
|
|
@ -368,9 +368,9 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
typeof parsedConfig.classifier_context_window_size === "number"
|
||||
? parsedConfig.classifier_context_window_size
|
||||
: undefined,
|
||||
classifier_context_per_turn_chars:
|
||||
typeof parsedConfig.classifier_context_per_turn_chars === "number"
|
||||
? parsedConfig.classifier_context_per_turn_chars
|
||||
classifier_context_budget_chars:
|
||||
typeof parsedConfig.classifier_context_budget_chars === "number"
|
||||
? parsedConfig.classifier_context_budget_chars
|
||||
: undefined,
|
||||
classifier_context_include_assistant_turns:
|
||||
typeof parsedConfig.classifier_context_include_assistant_turns === "boolean"
|
||||
|
|
|
|||
|
|
@ -268,6 +268,7 @@ export const buildPresetPrefill = (
|
|||
model: resolve(config.classifier_llm_config.model),
|
||||
},
|
||||
classifier_context_window_size: config.classifier_context_window_size,
|
||||
classifier_context_budget_chars: config.classifier_context_budget_chars,
|
||||
classifier_context_per_turn_chars: config.classifier_context_per_turn_chars,
|
||||
classifier_context_include_assistant_turns: config.classifier_context_include_assistant_turns,
|
||||
session_affinity: config.session_affinity ?? DEFAULT_SESSION_AFFINITY,
|
||||
|
|
|
|||
13
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
13
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -32487,18 +32487,23 @@ export interface components {
|
|||
* @description Replaces the opening instructions of the LLM classifier rubric (the judging-criteria prose) for a custom tier set. The per-tier bullets and the trust-boundary paragraph telling the classifier to ignore tier requests embedded in quoted caller text are always appended after it and cannot be overridden. Requires tier_definitions; a built-in-tier router customizes its prompt via classifier_llm_config.system_prompt or classification_rubric instead.
|
||||
*/
|
||||
classification_prompt?: string | null;
|
||||
/**
|
||||
* Classifier Context Budget Chars
|
||||
* @description Maximum characters of prior-turn text quoted to the LLM classifier, across the whole context window, per classification call. Turns are taken newest first and quoted whole while they fit, so a conversation small enough to quote entirely is never cut; once the budget runs out the older turns are dropped whole and only the turn straddling the boundary is truncated, into whatever space is left. The current ask and the caller's system prompt sit outside this budget and are always sent in full, as does the numbering each quoted turn carries. A budget under 120 leaves no room to quote a turn and suppresses the block; set classifier_context_window_size to 0 to turn context off deliberately. Only applies when classifier_type is 'llm'.
|
||||
* @default 8000
|
||||
*/
|
||||
classifier_context_budget_chars: number;
|
||||
/**
|
||||
* Classifier Context Include Assistant Turns
|
||||
* @description Include assistant turns in the classifier context window, so difficulty stated by the model rather than by the user stays visible: a plan the assistant calls complex, which the user approves with 'yes', is classified on the work being approved instead of on the word 'yes'. When enabled, classifier_context_window_size counts the last N turns of the conversation across both roles rather than the last N user turns, and assistant text is sent to the classifier model, which may be a different deployment or provider than the routed completion model. Assistant replies share classifier_context_per_turn_chars with user turns, so raise it if replies are truncated before the part that carries the difficulty. Off by default because enabling it shifts tier decisions, and therefore spend, for an already-deployed router. Only applies when classifier_type is 'llm'.
|
||||
* @description Include assistant turns in the classifier context window, so difficulty stated by the model rather than by the user stays visible: a plan the assistant calls complex, which the user approves with 'yes', is classified on the work being approved instead of on the word 'yes'. When enabled, classifier_context_window_size counts the last N turns of the conversation across both roles rather than the last N user turns, and assistant text is sent to the classifier model, which may be a different deployment or provider than the routed completion model. Assistant replies spend classifier_context_budget_chars alongside user turns, so raise it if the oldest turns stop being quoted once replies join the window. Off by default because enabling it shifts tier decisions, and therefore spend, for an already-deployed router. Only applies when classifier_type is 'llm'.
|
||||
* @default false
|
||||
*/
|
||||
classifier_context_include_assistant_turns: boolean;
|
||||
/**
|
||||
* Classifier Context Per Turn Chars
|
||||
* @description Maximum character length for each prior turn's text in the classifier context window. Turns exceeding this are truncated. Only applies when classifier_type is 'llm'.
|
||||
* @default 200
|
||||
* @description Optional cap on each individual prior turn's text, applied before classifier_context_budget_chars bounds the block. Unset by default, so one long turn may spend the whole budget, which is usually what a follow-up needs; set it when no single turn should dominate the context the classifier sees. A capped turn keeps its opening and its ending with the middle elided. Only applies when classifier_type is 'llm'.
|
||||
*/
|
||||
classifier_context_per_turn_chars: number;
|
||||
classifier_context_per_turn_chars?: number | null;
|
||||
/**
|
||||
* Classifier Context Window Size
|
||||
* @description Number of prior user turns (tool output and harness reminders excluded) to include as context in the LLM classifier prompt, so a follow-up like 'now do the same for the streaming path' is classified against what it refers to. Counts turns of both roles when classifier_context_include_assistant_turns is enabled. These turns are sent to the classifier model, which may be a different deployment or provider than the routed completion model; that call already carries the current user ask and the caller's system prompt in full. Set to 0 to send neither prior turns nor any conversation context beyond the current ask. Only applies when classifier_type is 'llm'.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue