mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
feat(router): respect caller output budgets in complexity routing (#48632)
Co-authored-by: stuti612 <stutiprasad63@gmail.com>
This commit is contained in:
parent
52562c47f8
commit
e0d1e64591
16 changed files with 448 additions and 100 deletions
|
|
@ -1122,6 +1122,27 @@ def as_output_cap(value: object) -> int | None:
|
|||
return cap if cap >= 0 else None
|
||||
|
||||
|
||||
def stamped_client_ceiling(request_kwargs: Mapping[str, object]) -> Mapping[str, int] | None:
|
||||
"""None means unstamped; an empty mapping means the original caller supplied no limit."""
|
||||
stamped: Final = next(
|
||||
(
|
||||
bucket.get(CLIENT_OUTPUT_CEILING_METADATA_KEY)
|
||||
for bucket in (request_kwargs.get("metadata"), request_kwargs.get("litellm_metadata"))
|
||||
if isinstance(bucket, dict) and CLIENT_OUTPUT_CEILING_METADATA_KEY in bucket
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not isinstance(stamped, dict):
|
||||
return None
|
||||
return MappingProxyType(
|
||||
{
|
||||
carrier: cap
|
||||
for carrier, value in stamped.items()
|
||||
if carrier in OUTPUT_TOKEN_CEILING_PARAMS and (cap := as_output_cap(value)) is not None
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _exact_base_model_max_input_tokens(base_model: str) -> int | None:
|
||||
try:
|
||||
model_info: Final = litellm.get_model_info(model=base_model)
|
||||
|
|
@ -15235,9 +15256,7 @@ class Router:
|
|||
request_kwargs: dict,
|
||||
responses_call: bool,
|
||||
) -> bool:
|
||||
"""Apply a routing strategy's per-tier litellm_params on top of the request and report
|
||||
whether they pinned an output ceiling, so the caller can hand the request its own ceiling
|
||||
back on a routing pass that pins none."""
|
||||
"""Apply per-tier litellm_params and report whether they pinned an output ceiling."""
|
||||
if not tier_litellm_params:
|
||||
return False
|
||||
reject_api_key_secret_reference(tier_litellm_params)
|
||||
|
|
@ -15250,34 +15269,26 @@ class Router:
|
|||
return not OUTPUT_TOKEN_CEILING_PARAMS.isdisjoint(surface_tier_params)
|
||||
|
||||
@staticmethod
|
||||
def _restore_client_ceiling_no_tier_pins(request_kwargs: MutableMapping[str, object]) -> None:
|
||||
"""A model-group fallback re-enters routing with the kwargs an earlier auto-router pass
|
||||
already rewrote, so a ceiling sized for that pass's tier would ride onto a group no tier
|
||||
chose. When this pass pins none, hand the request back exactly the carriers the caller
|
||||
sent, which the first pinning pass stamped. The stamp lives in a metadata bucket a
|
||||
caller can also write, so the proxy strips the key at ingestion and this read takes
|
||||
nothing but the three ceiling carriers as integers: no other key ever reaches kwargs."""
|
||||
stamped: Final = next(
|
||||
(
|
||||
bucket.get(CLIENT_OUTPUT_CEILING_METADATA_KEY)
|
||||
for bucket in (request_kwargs.get("metadata"), request_kwargs.get("litellm_metadata"))
|
||||
if isinstance(bucket, dict) and CLIENT_OUTPUT_CEILING_METADATA_KEY in bucket
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not isinstance(stamped, dict):
|
||||
def _restore_client_output_ceiling(request_kwargs: MutableMapping[str, object]) -> None:
|
||||
"""Restore caller intent before routing, so neither a previous tier pin nor alias defaults
|
||||
become the next attempt's budget. The proxy strips caller-written stamps at ingestion."""
|
||||
callers_ceiling: Final = stamped_client_ceiling(request_kwargs)
|
||||
if callers_ceiling is None:
|
||||
return
|
||||
callers_ceiling: Final = MappingProxyType(
|
||||
{
|
||||
carrier: cap
|
||||
for carrier, value in stamped.items()
|
||||
if carrier in OUTPUT_TOKEN_CEILING_PARAMS and (cap := as_output_cap(value)) is not None
|
||||
}
|
||||
)
|
||||
for carrier in OUTPUT_TOKEN_CEILING_PARAMS:
|
||||
request_kwargs.pop(carrier, None)
|
||||
request_kwargs.update(callers_ceiling)
|
||||
|
||||
@staticmethod
|
||||
def _stamp_client_output_ceiling(
|
||||
request_kwargs: dict[str, object], # mutable-ok: preserve request-owned metadata across routing attempts
|
||||
) -> None:
|
||||
_, metadata_bucket = get_or_create_metadata_bucket(request_kwargs)
|
||||
metadata_bucket.setdefault(
|
||||
CLIENT_OUTPUT_CEILING_METADATA_KEY,
|
||||
{carrier: request_kwargs[carrier] for carrier in OUTPUT_TOKEN_CEILING_PARAMS if carrier in request_kwargs},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _drop_client_carriers_a_tier_pin_supersedes(
|
||||
request_kwargs: dict[str, object],
|
||||
|
|
@ -15294,15 +15305,7 @@ class Router:
|
|||
one setting under three names, and a provider handed two of them either
|
||||
rejects the request or picks one by iteration order."""
|
||||
if not OUTPUT_TOKEN_CEILING_PARAMS.isdisjoint(tier_litellm_params):
|
||||
_, metadata_bucket = get_or_create_metadata_bucket(request_kwargs)
|
||||
metadata_bucket.setdefault(
|
||||
CLIENT_OUTPUT_CEILING_METADATA_KEY,
|
||||
{
|
||||
carrier: request_kwargs[carrier]
|
||||
for carrier in OUTPUT_TOKEN_CEILING_PARAMS
|
||||
if carrier in request_kwargs
|
||||
},
|
||||
)
|
||||
Router._stamp_client_output_ceiling(request_kwargs)
|
||||
for carrier in OUTPUT_TOKEN_CEILING_PARAMS:
|
||||
request_kwargs.pop(carrier, None)
|
||||
if "reasoning_effort" not in tier_litellm_params:
|
||||
|
|
@ -15374,14 +15377,12 @@ class Router:
|
|||
model = pre_routing_hook_response.model
|
||||
messages = pre_routing_hook_response.messages
|
||||
record_pre_routing_selection(request_kwargs, model)
|
||||
tier_pins_ceiling: Final = self._pin_tier_params_onto_request(
|
||||
self._pin_tier_params_onto_request(
|
||||
model=model,
|
||||
tier_litellm_params=pre_routing_hook_response.litellm_params if pre_routing_hook_response else None,
|
||||
request_kwargs=request_kwargs,
|
||||
responses_call=responses_call,
|
||||
)
|
||||
if not tier_pins_ceiling:
|
||||
self._restore_client_ceiling_no_tier_pins(request_kwargs)
|
||||
#########################################################
|
||||
|
||||
# Resolve the strategy and logger AFTER the pre-routing hook, since
|
||||
|
|
@ -15539,14 +15540,12 @@ class Router:
|
|||
model = pre_routing_hook_response.model
|
||||
messages = pre_routing_hook_response.messages
|
||||
record_pre_routing_selection(request_kwargs, model)
|
||||
tier_pins_ceiling: Final = self._pin_tier_params_onto_request(
|
||||
self._pin_tier_params_onto_request(
|
||||
model=model,
|
||||
tier_litellm_params=pre_routing_hook_response.litellm_params if pre_routing_hook_response else None,
|
||||
request_kwargs=request_kwargs,
|
||||
responses_call=responses_call,
|
||||
)
|
||||
if not tier_pins_ceiling:
|
||||
self._restore_client_ceiling_no_tier_pins(request_kwargs)
|
||||
|
||||
# 2. Get healthy deployments
|
||||
healthy_deployments: Final = await self.async_get_healthy_deployments(
|
||||
|
|
@ -15896,6 +15895,7 @@ class Router:
|
|||
spend metadata is stamped before routing and the response carries the tier group the
|
||||
strategy picked.
|
||||
"""
|
||||
self._restore_client_output_ceiling(request_kwargs)
|
||||
self._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None)
|
||||
requested_registered_model_name: Final = self.get_model_from_alias(model=model) or model
|
||||
registered_model_name: Final = await self._resolve_claude_code_session_router(
|
||||
|
|
@ -16062,7 +16062,8 @@ class Router:
|
|||
# them) until `_update_kwargs_with_deployment` drops any the selected
|
||||
# deployment sets itself (its own `aws_region_name` beats the marker's).
|
||||
# Per-tier `litellm_params` on the hook response are deliberate overrides
|
||||
# the caller applies on top, so those keys are never forwarded here.
|
||||
# the caller applies on top, so those keys and equivalent output-limit
|
||||
# names are never tracked as alias defaults here.
|
||||
marker_params: Final = (
|
||||
self._forwardable_alias_marker_params(
|
||||
model=registered_model_name, strategy_tags=selected_strategy.tags, request_kwargs=request_kwargs
|
||||
|
|
@ -16073,9 +16074,16 @@ class Router:
|
|||
tier_param_keys: Final = (
|
||||
tuple(pre_routing_hook_response.litellm_params or ()) if pre_routing_hook_response is not None else ()
|
||||
)
|
||||
newly_forwarded: Final = tuple(
|
||||
(key, value) for key, value in marker_params if key not in request_kwargs and key not in tier_param_keys
|
||||
overridden_marker_keys: Final = frozenset(tier_param_keys) | (
|
||||
OUTPUT_TOKEN_CEILING_PARAMS if not OUTPUT_TOKEN_CEILING_PARAMS.isdisjoint(tier_param_keys) else frozenset()
|
||||
)
|
||||
newly_forwarded: Final = tuple(
|
||||
(key, value)
|
||||
for key, value in marker_params
|
||||
if key not in request_kwargs and key not in overridden_marker_keys
|
||||
)
|
||||
if pre_routing_hook_response is not None:
|
||||
self._stamp_client_output_ceiling(request_kwargs)
|
||||
request_kwargs.pop(_ALIAS_MARKER_FORWARDED_PARAMS_KWARG, None)
|
||||
request_kwargs.update(newly_forwarded)
|
||||
if newly_forwarded:
|
||||
|
|
|
|||
|
|
@ -107,6 +107,20 @@ Session affinity, user-turn classification, adaptive routing, routing plugins, c
|
|||
|
||||
When cache costs change the model, the routing decision reports `cause: prompt_cache_cost`. Its signals include the original model, classification cause and both estimated costs
|
||||
|
||||
### Output token limits
|
||||
|
||||
Set `max_tokens_from_tier_model` under `complexity_router_config` to choose how a routed request's output limit is determined:
|
||||
|
||||
| Setting | Effective output limit |
|
||||
| --- | --- |
|
||||
| `true` (default) | Replace the caller's limit with the selected model group's ceiling |
|
||||
| `"clamp"` | Use the smaller of the caller's positive limit and the selected model group's ceiling |
|
||||
| `false` | Keep the caller's limit |
|
||||
|
||||
Clamp reads `max_tokens`, `max_completion_tokens` and `max_output_tokens`. If several are supplied, it uses the smallest positive value. With no positive caller limit, it uses the model ceiling. For example, a caller requesting 100 tokens keeps that limit on a model with a 32,768-token ceiling; a request for 100,000 is capped at 32,768. Rerouting uses the original caller budget, and cache-aware cost estimates use the same effective limit.
|
||||
|
||||
All modes preserve explicit output limits in a tier's own `litellm_params`. An unknown ceiling leaves the caller's value unchanged. In the dashboard, select **Respect caller budget** under **Advanced settings > Compatibility > Output token limit** to enable clamp.
|
||||
|
||||
### Capability forecasting
|
||||
|
||||
Set `classifier_type: capability` to use
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import random
|
|||
import re
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
|
||||
from functools import partial
|
||||
from itertools import accumulate, chain, islice, takewhile
|
||||
from threading import Lock
|
||||
from types import MappingProxyType
|
||||
|
|
@ -161,6 +162,17 @@ def _tier_name(tier: ComplexityTier | str) -> str:
|
|||
return tier.value if isinstance(tier, ComplexityTier) else tier
|
||||
|
||||
|
||||
def _callers_output_cap(request_kwargs: Mapping[str, object]) -> int | None:
|
||||
"""The smallest positive output ceiling the caller sent under any of its three names, read
|
||||
from the stamp when an earlier routing pass already rewrote the kwargs."""
|
||||
from litellm.router import as_output_cap, stamped_client_ceiling
|
||||
|
||||
stamped: Final = stamped_client_ceiling(request_kwargs)
|
||||
carriers: Final = request_kwargs if stamped is None else stamped
|
||||
caps: Final = tuple(as_output_cap(carriers.get(carrier)) for carrier in OUTPUT_TOKEN_CEILING_PARAMS)
|
||||
return min((cap for cap in caps if cap is not None and cap > 0), default=None)
|
||||
|
||||
|
||||
def _built_in_tier_or_none(tier_name: str) -> ComplexityTier | None:
|
||||
"""The built-in tier a `tiers` key names, or None when the key is an operator-defined name."""
|
||||
return ComplexityTier.__members__.get(tier_name)
|
||||
|
|
@ -3028,7 +3040,9 @@ class ComplexityRouter(CustomLogger):
|
|||
|
||||
raise ValueError(f"No model configured for tier {tier_key} and no default_model set")
|
||||
|
||||
def _litellm_params_for_model(self, tier: ComplexityTier | str | None, model: str) -> Mapping[str, object]:
|
||||
def _litellm_params_for_model(
|
||||
self, tier: ComplexityTier | str | None, model: str, request_kwargs: Mapping[str, object]
|
||||
) -> Mapping[str, object]:
|
||||
entries: Final = self.config.tier_model_configs.get(_tier_name(tier), ()) if tier is not None else ()
|
||||
entry: Final = next((candidate for candidate in entries if candidate.model_name == model), None)
|
||||
explicit: Final = entry.litellm_params if entry is not None else MappingProxyType({})
|
||||
|
|
@ -3037,7 +3051,12 @@ class ComplexityRouter(CustomLogger):
|
|||
ceiling: Final = self._group_output_ceiling(model)
|
||||
if ceiling is None:
|
||||
return explicit
|
||||
return MappingProxyType({**explicit, "max_tokens": ceiling})
|
||||
callers_cap: Final = (
|
||||
_callers_output_cap(request_kwargs) if self.config.max_tokens_from_tier_model == "clamp" else None
|
||||
)
|
||||
return MappingProxyType(
|
||||
{**explicit, "max_tokens": ceiling if callers_cap is None else min(callers_cap, ceiling)}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _pick_from_tier_value(model: str | Sequence[str], tier_key: str) -> str:
|
||||
|
|
@ -3768,7 +3787,7 @@ class ComplexityRouter(CustomLogger):
|
|||
response: PreRoutingHookResponse,
|
||||
messages: list[dict[str, object]] | None, # mutable-ok: forwarded verbatim to the list-typed re-pick
|
||||
resolved_messages: Sequence[Mapping[str, object]] | None,
|
||||
request_kwargs: dict, # mutable-ok: same shape the hook receives
|
||||
request_kwargs: dict[str, object], # mutable-ok: same shape the hook receives
|
||||
context_fit: _RequestContextFit | None = None,
|
||||
retained_pin: _SessionAffinityPin | None = None,
|
||||
) -> PreRoutingHookResponse:
|
||||
|
|
@ -3865,7 +3884,7 @@ class ComplexityRouter(CustomLogger):
|
|||
conversation_continuing=bool(decision.get("conversation_continuing", True))
|
||||
if decision is not None
|
||||
else True,
|
||||
tier_litellm_params=self._litellm_params_for_model(new_tier, new_model),
|
||||
tier_litellm_params=self._litellm_params_for_model(new_tier, new_model, request_kwargs),
|
||||
context_escalation_original_tier=(
|
||||
decision.get("context_escalation_original_tier") if decision is not None else None
|
||||
),
|
||||
|
|
@ -3876,7 +3895,7 @@ class ComplexityRouter(CustomLogger):
|
|||
return HookResponse(
|
||||
model=new_model,
|
||||
messages=response.messages,
|
||||
litellm_params=self._litellm_params_for_model(new_tier, new_model),
|
||||
litellm_params=self._litellm_params_for_model(new_tier, new_model, request_kwargs),
|
||||
routing_decision=new_decision,
|
||||
)
|
||||
|
||||
|
|
@ -3904,7 +3923,7 @@ class ComplexityRouter(CustomLogger):
|
|||
model_name: str,
|
||||
messages: list[dict[str, Any]] | None, # mutable-ok: forwarded verbatim to the router's own probe
|
||||
input: str | list | None, # mutable-ok: mirrors the owner's own input parameter, which this forwards verbatim
|
||||
request_kwargs: dict, # mutable-ok: same shape the hook receives
|
||||
request_kwargs: dict[str, object], # mutable-ok: same shape the hook receives
|
||||
) -> bool:
|
||||
"""Whether the router would find a deployment for this group ON THIS REQUEST.
|
||||
|
||||
|
|
@ -3962,7 +3981,7 @@ class ComplexityRouter(CustomLogger):
|
|||
messages: list[dict[str, object]] | None, # mutable-ok: forwarded verbatim to the list-typed re-pick
|
||||
input: str | list | None, # mutable-ok: mirrors the owner's own input parameter, which this forwards verbatim
|
||||
resolved_messages: Sequence[Mapping[str, object]] | None,
|
||||
request_kwargs: dict, # mutable-ok: same shape the hook receives
|
||||
request_kwargs: dict[str, object], # mutable-ok: same shape the hook receives
|
||||
context_fit: _RequestContextFit | None = None,
|
||||
retained_pin: _SessionAffinityPin | None = None,
|
||||
) -> PreRoutingHookResponse:
|
||||
|
|
@ -4045,14 +4064,14 @@ class ComplexityRouter(CustomLogger):
|
|||
classifier_model=decision.get("classifier_model"),
|
||||
classifier_cost=decision.get("classifier_cost"),
|
||||
conversation_continuing=bool(decision.get("conversation_continuing", True)),
|
||||
tier_litellm_params=self._litellm_params_for_model(candidate_tier, new_model),
|
||||
tier_litellm_params=self._litellm_params_for_model(candidate_tier, new_model, request_kwargs),
|
||||
context_escalation_original_tier=decision.get("context_escalation_original_tier"),
|
||||
previous_decision=decision,
|
||||
)
|
||||
return response.model_copy(
|
||||
update={
|
||||
"model": new_model,
|
||||
"litellm_params": self._litellm_params_for_model(candidate_tier, new_model),
|
||||
"litellm_params": self._litellm_params_for_model(candidate_tier, new_model, request_kwargs),
|
||||
"routing_decision": new_decision,
|
||||
}
|
||||
)
|
||||
|
|
@ -4090,14 +4109,14 @@ class ComplexityRouter(CustomLogger):
|
|||
classifier_model=decision.get("classifier_model"),
|
||||
classifier_cost=decision.get("classifier_cost"),
|
||||
conversation_continuing=bool(decision.get("conversation_continuing", True)),
|
||||
tier_litellm_params=self._litellm_params_for_model(None, default_model),
|
||||
tier_litellm_params=self._litellm_params_for_model(None, default_model, request_kwargs),
|
||||
context_escalation_original_tier=decision.get("context_escalation_original_tier"),
|
||||
previous_decision=decision,
|
||||
)
|
||||
return response.model_copy(
|
||||
update={
|
||||
"model": default_model,
|
||||
"litellm_params": self._litellm_params_for_model(None, default_model),
|
||||
"litellm_params": self._litellm_params_for_model(None, default_model, request_kwargs),
|
||||
"routing_decision": default_decision,
|
||||
}
|
||||
)
|
||||
|
|
@ -4580,7 +4599,9 @@ class ComplexityRouter(CustomLogger):
|
|||
if pin_placement is not None and pin_context_original_tier is not None
|
||||
else pin_source_tier
|
||||
)
|
||||
session_tier_litellm_params: Final = self._litellm_params_for_model(routed_pin_tier, routed_model)
|
||||
session_tier_litellm_params: Final = self._litellm_params_for_model(
|
||||
routed_pin_tier, routed_model, request_kwargs
|
||||
)
|
||||
has_original_messages: Final = messages is not None and len(messages) > 0
|
||||
return self._with_session_deployment_affinity(
|
||||
await self._gate_response_health(
|
||||
|
|
@ -4678,14 +4699,14 @@ class ComplexityRouter(CustomLogger):
|
|||
choice: Final = await choose_cached_model(
|
||||
router=self.litellm_router_instance,
|
||||
config=self.config,
|
||||
params_for_model=self._litellm_params_for_model,
|
||||
params_for_model=partial(self._litellm_params_for_model, request_kwargs=request_kwargs),
|
||||
response=response,
|
||||
request_kwargs=request_kwargs,
|
||||
messages=messages,
|
||||
)
|
||||
if choice is None or not context_fit.accepts(choice.model):
|
||||
return response
|
||||
params: Final = self._litellm_params_for_model(choice.tier, choice.model)
|
||||
params: Final = self._litellm_params_for_model(choice.tier, choice.model, request_kwargs)
|
||||
decision: Final[StandardLoggingRoutingDecision] = {
|
||||
**response.routing_decision,
|
||||
"routed_model": choice.model,
|
||||
|
|
@ -4714,7 +4735,7 @@ class ComplexityRouter(CustomLogger):
|
|||
async def _classify_and_route(
|
||||
self,
|
||||
model: str,
|
||||
request_kwargs: dict,
|
||||
request_kwargs: dict[str, object],
|
||||
messages: list[dict[str, object]] | None = None,
|
||||
input: str | list | None = None,
|
||||
specific_deployment: bool | None = False,
|
||||
|
|
@ -4772,7 +4793,7 @@ class ComplexityRouter(CustomLogger):
|
|||
ComplexityTier.MEDIUM, messages, resolved_messages, request_kwargs
|
||||
)
|
||||
fallback_tier: Final = None if default_model_first else ComplexityTier.MEDIUM
|
||||
default_tier_params: Final = self._litellm_params_for_model(fallback_tier, routed_model)
|
||||
default_tier_params: Final = self._litellm_params_for_model(fallback_tier, routed_model, request_kwargs)
|
||||
return PreRoutingHookResponse(
|
||||
model=routed_model,
|
||||
messages=messages if has_original_messages else None,
|
||||
|
|
@ -4810,7 +4831,7 @@ class ComplexityRouter(CustomLogger):
|
|||
_tier_name(plan_floor),
|
||||
routed_model,
|
||||
)
|
||||
plan_tier_params: Final = self._litellm_params_for_model(plan_floor, routed_model)
|
||||
plan_tier_params: Final = self._litellm_params_for_model(plan_floor, routed_model, request_kwargs)
|
||||
return PreRoutingHookResponse(
|
||||
model=routed_model,
|
||||
messages=messages if has_original_messages else None,
|
||||
|
|
@ -4839,7 +4860,9 @@ class ComplexityRouter(CustomLogger):
|
|||
)
|
||||
keyword_plan_floored: Final = routed_tier != escalated_tier
|
||||
routed_model = await self._pick_model_for_tier(routed_tier, messages, resolved_messages, request_kwargs)
|
||||
keyword_tier_litellm_params: Final = self._litellm_params_for_model(routed_tier, routed_model)
|
||||
keyword_tier_litellm_params: Final = self._litellm_params_for_model(
|
||||
routed_tier, routed_model, request_kwargs
|
||||
)
|
||||
keyword_cause: Final[RoutingDecisionCause] = (
|
||||
"plan_mode"
|
||||
if keyword_plan_floored
|
||||
|
|
@ -4919,7 +4942,7 @@ class ComplexityRouter(CustomLogger):
|
|||
outcome.signals,
|
||||
fallback_model,
|
||||
)
|
||||
fallback_tier_params: Final = self._litellm_params_for_model(None, fallback_model)
|
||||
fallback_tier_params: Final = self._litellm_params_for_model(None, fallback_model, request_kwargs)
|
||||
return PreRoutingHookResponse(
|
||||
model=fallback_model,
|
||||
messages=messages if has_original_messages else None,
|
||||
|
|
@ -5001,7 +5024,7 @@ class ComplexityRouter(CustomLogger):
|
|||
routed_model,
|
||||
)
|
||||
|
||||
tier_litellm_params: Final = self._litellm_params_for_model(tier, routed_model)
|
||||
tier_litellm_params: Final = self._litellm_params_for_model(tier, routed_model, request_kwargs)
|
||||
classifier_model: Final = (
|
||||
f"{outcome.jev_verdict.provider}/{outcome.jev_verdict.model}"
|
||||
if outcome.cause == "jev_classifier" and outcome.jev_verdict is not None
|
||||
|
|
|
|||
|
|
@ -1462,7 +1462,7 @@ class ComplexityRouterConfig(LiteLLMBaseModel):
|
|||
"wording the built-ins don't cover, or after a client release changes its strings."
|
||||
),
|
||||
)
|
||||
max_tokens_from_tier_model: bool = Field(
|
||||
max_tokens_from_tier_model: bool | Literal["clamp"] = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Set max_tokens on every routed request to the output ceiling of the tier model it "
|
||||
|
|
@ -1473,7 +1473,8 @@ class ComplexityRouterConfig(LiteLLMBaseModel):
|
|||
"from each deployment's model_info and then the model cost map; a tier model with a "
|
||||
"deployment whose ceiling is unknown keeps the caller's value. A max_tokens, "
|
||||
"max_completion_tokens or max_output_tokens in the tier's own litellm_params still "
|
||||
"wins. Set false to forward the caller's value unchanged."
|
||||
'wins. Set "clamp" to keep a smaller positive caller value and cap a larger one at '
|
||||
"the ceiling. Set false to forward the caller's value unchanged."
|
||||
),
|
||||
)
|
||||
route_housekeeping_to_cheapest_tier: bool = Field(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
|
@ -148,7 +149,7 @@ async def _select(
|
|||
return await select_cached_model(
|
||||
router=router,
|
||||
config=config,
|
||||
params_for_model=complexity._litellm_params_for_model,
|
||||
params_for_model=partial(complexity._litellm_params_for_model, request_kwargs=request_kwargs),
|
||||
response=response,
|
||||
body=body,
|
||||
request_kwargs=request_kwargs,
|
||||
|
|
@ -234,7 +235,9 @@ async def test_disabled_setting_does_not_access_prediction_services() -> None:
|
|||
await choose_cached_model(
|
||||
router=_router(),
|
||||
config=config,
|
||||
params_for_model=ComplexityRouter("smart", _router(), config.model_dump())._litellm_params_for_model,
|
||||
params_for_model=partial(
|
||||
ComplexityRouter("smart", _router(), config.model_dump())._litellm_params_for_model, request_kwargs={}
|
||||
),
|
||||
response=_response(),
|
||||
request_kwargs={},
|
||||
messages=None,
|
||||
|
|
@ -348,6 +351,7 @@ def test_repeated_model_in_multiple_tiers_is_only_considered_once() -> None:
|
|||
(True, "custom_endpoint", "cheap"),
|
||||
(True, "compaction", "cheap"),
|
||||
(True, "guardrail", "cheap"),
|
||||
(True, "clamp", "strong"),
|
||||
],
|
||||
)
|
||||
async def test_router_applies_opt_in_and_preserves_failure_semantics(
|
||||
|
|
@ -368,7 +372,9 @@ async def test_router_applies_opt_in_and_preserves_failure_semantics(
|
|||
from litellm.router_strategy.complexity_router.context_compaction import initialize_compaction_state
|
||||
|
||||
config: Final = _config(
|
||||
cache_aware_routing=enabled, cache_aware_routing_timeout_ms=1 if behavior == "deadline" else 2000
|
||||
cache_aware_routing=enabled,
|
||||
cache_aware_routing_timeout_ms=1 if behavior == "deadline" else 2000,
|
||||
max_tokens_from_tier_model="clamp" if behavior == "clamp" else True,
|
||||
)
|
||||
models: Final = _router(
|
||||
strong_output_rate=0.000048 if behavior == "tier_cost" else 0.000015,
|
||||
|
|
@ -423,6 +429,7 @@ async def test_router_applies_opt_in_and_preserves_failure_semantics(
|
|||
body: Final = {
|
||||
**_body(),
|
||||
**({"max_tokens": 1000} if behavior in ("tier_cost", "tier_context") else {}),
|
||||
**({"max_tokens": 100} if behavior == "clamp" else {}),
|
||||
**({"thinking": {"type": "enabled", "budget_tokens": 10000}} if behavior == "unsupported_shape" else {}),
|
||||
}
|
||||
kwargs: Final = {
|
||||
|
|
@ -437,6 +444,7 @@ async def test_router_applies_opt_in_and_preserves_failure_semantics(
|
|||
else {}
|
||||
),
|
||||
**({"guardrails": ["test-guardrail"]} if behavior == "guardrail" else {}),
|
||||
**({"max_tokens": 100} if behavior == "clamp" else {}),
|
||||
}
|
||||
if expected is None:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
|
|
@ -458,6 +466,10 @@ async def test_router_applies_opt_in_and_preserves_failure_semantics(
|
|||
if enabled and behavior == "success":
|
||||
assert requests.qsize() == 4
|
||||
assert response.routing_decision is not None
|
||||
if behavior == "clamp":
|
||||
assert "cache-aware:estimated-cost=0.01950000;original-cost=0.06390000" in (
|
||||
response.routing_decision["signals"] or ()
|
||||
), "both arms must be estimated at the caller's 100 output tokens, not the 1024 default"
|
||||
assert response.routing_decision["cause"] == (
|
||||
"prompt_cache_cost" if expected == "strong" else "heuristic_scorer"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -36,11 +36,12 @@ from litellm._logging import verbose_router_logger
|
|||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.constants import (
|
||||
CLIENT_OUTPUT_CEILING_METADATA_KEY,
|
||||
OUTPUT_TOKEN_CEILING_PARAMS,
|
||||
RETURN_RAW_MODEL_NAME_METADATA_KEY,
|
||||
SESSION_ID_GENERATED_METADATA_KEY,
|
||||
)
|
||||
from litellm.router import as_output_cap
|
||||
from litellm.router import as_output_cap, stamped_client_ceiling
|
||||
from litellm.router_strategy.complexity_router.complexity_router import (
|
||||
_CLASSIFICATION_CURRENT_MESSAGE_ONLY,
|
||||
_CLASSIFICATION_WITH_CONVERSATION,
|
||||
|
|
@ -13733,7 +13734,7 @@ def test_tier_model_params_are_used_by_pools_and_savings_baseline(mock_router_in
|
|||
|
||||
assert router._tier_pools() == {"SIMPLE": ["mini"], "REASONING": ["opus", "abc"]}
|
||||
assert router._hardest_tier_models() == ("opus", "abc")
|
||||
assert router._litellm_params_for_model(ComplexityTier.REASONING, "opus") == {"reasoning_effort": "xhigh"}
|
||||
assert router._litellm_params_for_model(ComplexityTier.REASONING, "opus", {}) == {"reasoning_effort": "xhigh"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -16917,9 +16918,10 @@ class TestMaxTokensFromTierModel:
|
|||
@staticmethod
|
||||
def _router(
|
||||
tier_litellm_params: dict | None = None,
|
||||
max_tokens_from_tier_model: bool | None = None,
|
||||
max_tokens_from_tier_model: bool | Literal["clamp"] | None = None,
|
||||
simple_deployments: list[dict] | None = None,
|
||||
extra_config: dict | None = None,
|
||||
marker_params: Mapping[str, object] | None = None,
|
||||
) -> Router:
|
||||
simple_tier: dict = {"model_name": "small"}
|
||||
if tier_litellm_params:
|
||||
|
|
@ -16934,7 +16936,11 @@ class TestMaxTokensFromTierModel:
|
|||
model_list=[
|
||||
{
|
||||
"model_name": "smart-router",
|
||||
"litellm_params": {"model": "auto_router/complexity_router", "complexity_router_config": config},
|
||||
"litellm_params": {
|
||||
"model": "auto_router/complexity_router",
|
||||
"complexity_router_config": config,
|
||||
**(marker_params or {}),
|
||||
},
|
||||
},
|
||||
*(simple_deployments or [TestMaxTokensFromTierModel.SMALL]),
|
||||
{
|
||||
|
|
@ -17016,6 +17022,167 @@ class TestMaxTokensFromTierModel:
|
|||
|
||||
assert sent["max_tokens"] == 8192
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"prompt, client_kwargs, expected",
|
||||
[
|
||||
(COMPLEX_PROMPT, {"max_tokens": 2000}, ("anthropic/claude-sonnet-5", 2000)),
|
||||
("hi", {"max_tokens": 100000}, ("anthropic/claude-haiku-4-5", 8192)),
|
||||
(COMPLEX_PROMPT, {}, ("anthropic/claude-sonnet-5", 64000)),
|
||||
(COMPLEX_PROMPT, {"max_tokens": 0}, ("anthropic/claude-sonnet-5", 64000)),
|
||||
(COMPLEX_PROMPT, {"max_completion_tokens": 2000}, ("anthropic/claude-sonnet-5", 2000)),
|
||||
(COMPLEX_PROMPT, {"max_tokens": 3000, "max_completion_tokens": 2000}, ("anthropic/claude-sonnet-5", 2000)),
|
||||
],
|
||||
ids=["below-ceiling", "above-ceiling", "omitted", "zero", "other-carrier", "smallest-carrier"],
|
||||
)
|
||||
async def test_clamp_keeps_the_smaller_of_the_client_value_and_the_ceiling(
|
||||
self, prompt: str, client_kwargs: dict[str, int], expected: tuple[str, int]
|
||||
) -> None:
|
||||
sent: Final = await self._routed(self._router(max_tokens_from_tier_model="clamp"), prompt, **client_kwargs)
|
||||
|
||||
assert (sent["model"], sent["max_tokens"]) == expected
|
||||
assert not (OUTPUT_TOKEN_CEILING_PARAMS - {"max_tokens"}) & sent.keys()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clamp_on_the_responses_surface_keeps_its_own_name(self) -> None:
|
||||
router: Final = self._router(max_tokens_from_tier_model="clamp")
|
||||
|
||||
sent: Final = await self._routed_responses(router, self.COMPLEX_PROMPT, max_output_tokens=2000)
|
||||
|
||||
assert (sent["model"], sent["max_output_tokens"]) == ("anthropic/claude-sonnet-5", 2000)
|
||||
assert "max_tokens" not in sent
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("tier_carrier", sorted(OUTPUT_TOKEN_CEILING_PARAMS))
|
||||
@pytest.mark.parametrize("marker_carrier", sorted(OUTPUT_TOKEN_CEILING_PARAMS))
|
||||
@pytest.mark.parametrize("surface", ["chat", "responses"])
|
||||
@pytest.mark.parametrize("caller_cap", [None, 100])
|
||||
async def test_clamp_leaves_the_operators_own_tier_ceiling_in_charge(
|
||||
self,
|
||||
tier_carrier: str,
|
||||
marker_carrier: str,
|
||||
surface: str,
|
||||
caller_cap: int | None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
surface_key: Final = "max_output_tokens" if surface == "responses" else "max_tokens"
|
||||
caller_key: Final = "max_tokens" if surface == "responses" else "max_output_tokens"
|
||||
request_caps: Final = {caller_key: caller_cap} if caller_cap is not None else {}
|
||||
router: Final = self._router(
|
||||
tier_litellm_params={tier_carrier: 4321},
|
||||
max_tokens_from_tier_model="clamp",
|
||||
marker_params={marker_carrier: 2000},
|
||||
simple_deployments=[{
|
||||
"model_name": "small",
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-5.6",
|
||||
"api_key": "test-only",
|
||||
"api_base": "https://tier.test/v1",
|
||||
surface_key: 9000,
|
||||
},
|
||||
"model_info": {"max_output_tokens": 16384},
|
||||
}],
|
||||
)
|
||||
|
||||
with respx.mock(assert_all_mocked=True) as upstream:
|
||||
upstream.post(host="tier.test").mock(side_effect=TestHealthFallbackDispatch._http_response)
|
||||
if surface == "responses":
|
||||
await router.aresponses(model="smart-router", input="hi", **request_caps)
|
||||
else:
|
||||
await router.acompletion(
|
||||
model="smart-router", messages=[{"role": "user", "content": "hi"}], **request_caps
|
||||
)
|
||||
|
||||
assert len(upstream.calls) == 1
|
||||
sent: Final = json.loads(upstream.calls[0].request.content)
|
||||
wire_key: Final = "max_output_tokens" if surface == "responses" else "max_completion_tokens"
|
||||
assert {key: value for key, value in sent.items() if key in OUTPUT_TOKEN_CEILING_PARAMS} == {wire_key: 4321}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"callers_original, expected",
|
||||
[({"max_tokens": 2000}, 2000), ({}, 64000)],
|
||||
ids=["caller-sent-a-value", "caller-sent-nothing"],
|
||||
)
|
||||
async def test_clamp_on_a_rerouted_request_uses_the_callers_original_value(
|
||||
self, callers_original: dict[str, int], expected: int
|
||||
) -> None:
|
||||
"""A retry re-enters routing with kwargs an earlier pass already rewrote; the stamp holds
|
||||
what the caller actually sent."""
|
||||
router: Final = self._router(max_tokens_from_tier_model="clamp")
|
||||
rewritten: Final[dict[str, object]] = {
|
||||
"max_tokens": 8192,
|
||||
"metadata": {CLIENT_OUTPUT_CEILING_METADATA_KEY: callers_original},
|
||||
}
|
||||
|
||||
sent: Final = await self._routed(router, self.COMPLEX_PROMPT, **rewritten)
|
||||
|
||||
assert sent["max_tokens"] == expected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("marker_carrier", sorted(OUTPUT_TOKEN_CEILING_PARAMS))
|
||||
@pytest.mark.parametrize("caller_cap", [None, 3000])
|
||||
@pytest.mark.parametrize("tier_cap", [None, 4321])
|
||||
@pytest.mark.parametrize("known_ceiling", [True, False])
|
||||
async def test_rerouting_preserves_caller_budget_without_adopting_marker_defaults(
|
||||
self, marker_carrier: str, caller_cap: int | None, tier_cap: int | None, known_ceiling: bool
|
||||
) -> None:
|
||||
router: Final = self._router(
|
||||
max_tokens_from_tier_model="clamp",
|
||||
marker_params={marker_carrier: 2000},
|
||||
tier_litellm_params={"max_tokens": tier_cap} if tier_cap else None,
|
||||
simple_deployments=None if known_ceiling else [{
|
||||
"model_name": "small",
|
||||
"litellm_params": {"model": "openai/not-in-any-map", "api_key": "k"},
|
||||
}],
|
||||
)
|
||||
request: Final[dict[str, object]] = {} if caller_cap is None else {"max_output_tokens": caller_cap}
|
||||
await router.async_get_available_deployment(
|
||||
model="smart-router", request_kwargs=request, messages=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
first: Final = {key: request[key] for key in OUTPUT_TOKEN_CEILING_PARAMS if key in request}
|
||||
await router.async_get_available_deployment(
|
||||
model="smart-router", request_kwargs=request,
|
||||
messages=[{"role": "user", "content": self.COMPLEX_PROMPT}],
|
||||
)
|
||||
|
||||
expected_first: Final = (
|
||||
{"max_tokens": tier_cap or caller_cap or 8192}
|
||||
if known_ceiling or tier_cap else
|
||||
{marker_carrier: 2000, **({"max_output_tokens": caller_cap} if caller_cap else {})}
|
||||
)
|
||||
assert first == expected_first
|
||||
assert request["max_tokens"] == (caller_cap or 64000)
|
||||
assert not (OUTPUT_TOKEN_CEILING_PARAMS - {"max_tokens"}) & request.keys()
|
||||
|
||||
await router.async_get_available_deployment(
|
||||
model="smart-router", request_kwargs=request, messages=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
assert {key: request[key] for key in OUTPUT_TOKEN_CEILING_PARAMS if key in request} == first
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("mode", [False, "clamp"])
|
||||
@pytest.mark.parametrize("marker_carrier", sorted(OUTPUT_TOKEN_CEILING_PARAMS))
|
||||
async def test_unpinned_output_limits_still_use_marker_defaults(
|
||||
self, mode: bool | Literal["clamp"], marker_carrier: str
|
||||
) -> None:
|
||||
router: Final = self._router(
|
||||
max_tokens_from_tier_model=mode,
|
||||
marker_params={marker_carrier: 2000},
|
||||
simple_deployments=[{
|
||||
"model_name": "small",
|
||||
"litellm_params": {"model": "openai/not-in-any-map", "api_key": "k"},
|
||||
}],
|
||||
)
|
||||
sent: Final = await self._routed(router)
|
||||
|
||||
assert {key: sent[key] for key in OUTPUT_TOKEN_CEILING_PARAMS if key in sent} == {marker_carrier: 2000}
|
||||
|
||||
def test_an_unknown_mode_is_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ComplexityRouterConfig(tiers={"SIMPLE": "small"}, max_tokens_from_tier_model="sometimes")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_tier_model_with_an_unknown_ceiling_keeps_the_client_value(self):
|
||||
unmapped: dict = {"model_name": "small", "litellm_params": {"model": "openai/not-in-any-map", "api_key": "k"}}
|
||||
|
|
@ -17112,7 +17279,7 @@ class TestMaxTokensFromTierModel:
|
|||
complexity_router_config={"tiers": {"SIMPLE": "small"}, "default_model": "big"},
|
||||
)
|
||||
|
||||
assert dict(strategy._litellm_params_for_model(None, "big")) == {"max_tokens": 64000}
|
||||
assert dict(strategy._litellm_params_for_model(None, "big", {})) == {"max_tokens": 64000}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_fallback_into_a_plain_group_gets_the_callers_ceiling_back(self):
|
||||
|
|
@ -17228,29 +17395,52 @@ class TestMaxTokensFromTierModel:
|
|||
def test_a_client_cap_is_read_as_an_integer_or_ignored(self, value, expected):
|
||||
assert as_output_cap(value) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"request_kwargs, expected",
|
||||
[
|
||||
({"max_tokens": 8192}, None),
|
||||
({"metadata": {CLIENT_OUTPUT_CEILING_METADATA_KEY: {}}}, {}),
|
||||
({"litellm_metadata": {CLIENT_OUTPUT_CEILING_METADATA_KEY: {"max_tokens": "2000"}}}, {"max_tokens": 2000}),
|
||||
(
|
||||
{"metadata": {CLIENT_OUTPUT_CEILING_METADATA_KEY: {"max_output_tokens": 5, "api_key": "x", "max_tokens": True}}},
|
||||
{"max_output_tokens": 5},
|
||||
),
|
||||
({"metadata": {CLIENT_OUTPUT_CEILING_METADATA_KEY: "not-a-dict"}}, None),
|
||||
],
|
||||
ids=["no-stamp", "empty-stamp", "litellm-metadata-bucket", "only-integer-carriers", "malformed-stamp"],
|
||||
)
|
||||
def test_the_stamp_reads_back_only_the_callers_integer_carriers(
|
||||
self, request_kwargs: dict[str, object], expected: dict[str, int] | None
|
||||
) -> None:
|
||||
stamped: Final = stamped_client_ceiling(request_kwargs)
|
||||
|
||||
assert (None if stamped is None else dict(stamped)) == expected
|
||||
|
||||
def test_restoring_the_callers_ceiling_reads_the_stamp_and_replaces_every_carrier(self):
|
||||
stamped: dict = {"max_output_tokens": 500, "metadata": {"_client_output_ceiling": {"max_tokens": 8192}}}
|
||||
Router._restore_client_ceiling_no_tier_pins(stamped)
|
||||
Router._restore_client_output_ceiling(stamped)
|
||||
assert {k: v for k, v in stamped.items() if k != "metadata"} == {"max_tokens": 8192}
|
||||
|
||||
coerced: dict = {
|
||||
"max_tokens": 64000,
|
||||
"metadata": {"_client_output_ceiling": {"max_tokens": "8192", "max_completion_tokens": 100.0}},
|
||||
}
|
||||
Router._restore_client_ceiling_no_tier_pins(coerced)
|
||||
Router._restore_client_output_ceiling(coerced)
|
||||
assert {k: v for k, v in coerced.items() if k != "metadata"} == {
|
||||
"max_tokens": 8192,
|
||||
"max_completion_tokens": 100,
|
||||
}
|
||||
|
||||
unstamped: dict = {"max_tokens": 64000, "metadata": {}}
|
||||
Router._restore_client_ceiling_no_tier_pins(unstamped)
|
||||
Router._restore_client_output_ceiling(unstamped)
|
||||
assert unstamped["max_tokens"] == 64000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pinning_stamps_the_callers_carriers_once(self):
|
||||
router = self._router()
|
||||
request_kwargs: dict = {"max_completion_tokens": 8192}
|
||||
router._stamp_client_output_ceiling(request_kwargs)
|
||||
assert stamped_client_ceiling(request_kwargs) == {"max_completion_tokens": 8192}
|
||||
|
||||
first = router._pin_tier_params_onto_request(
|
||||
model="big", tier_litellm_params={"max_tokens": 64000}, request_kwargs=request_kwargs, responses_call=False
|
||||
|
|
|
|||
|
|
@ -452,7 +452,7 @@ export interface ComplexityRouterConfigValue {
|
|||
route_housekeeping_to_cheapest_tier?: boolean;
|
||||
housekeeping_patterns?: string[];
|
||||
reminder_markers?: ReminderMarkerPair[];
|
||||
max_tokens_from_tier_model?: boolean;
|
||||
max_tokens_from_tier_model?: boolean | "clamp";
|
||||
classifier_plugin_timeout_ms?: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,28 @@
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import React from "react";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
const OUTPUT_TOKEN_POLICIES = [
|
||||
{
|
||||
value: true,
|
||||
label: "Use model ceiling",
|
||||
description: "Replace the caller's output limit with the selected model's ceiling. This is the default.",
|
||||
},
|
||||
{
|
||||
value: "clamp",
|
||||
label: "Respect caller budget",
|
||||
description:
|
||||
"Use the smaller of the caller's output limit and the model ceiling. Use the ceiling when no limit is sent.",
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
label: "Pass through caller limit",
|
||||
description: "Forward the caller's output limit unchanged.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const ResponseFormatControls: React.FC<{
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
|
|
@ -18,18 +39,31 @@ const ResponseFormatControls: React.FC<{
|
|||
<span className="block text-xs text-muted-foreground">
|
||||
Return the resolved underlying model name in responses instead of the autorouter alias.
|
||||
</span>
|
||||
<div className="mt-4 flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.max_tokens_from_tier_model ?? true}
|
||||
onCheckedChange={(enabled) => onChange({ ...value, max_tokens_from_tier_model: enabled })}
|
||||
aria-label="Cap max_tokens at the tier model's output ceiling"
|
||||
/>
|
||||
<strong className="font-semibold">Cap max_tokens at the tier model's output ceiling</strong>
|
||||
<div className="mt-4 space-y-2">
|
||||
<strong className="font-semibold">Output token limit</strong>
|
||||
<RadioGroup
|
||||
value={value.max_tokens_from_tier_model ?? true}
|
||||
onValueChange={(mode: unknown) => {
|
||||
if (typeof mode === "boolean" || mode === "clamp") {
|
||||
onChange({ ...value, max_tokens_from_tier_model: mode });
|
||||
}
|
||||
}}
|
||||
aria-label="Output token limit"
|
||||
>
|
||||
{OUTPUT_TOKEN_POLICIES.map((policy) => (
|
||||
<Label key={String(policy.value)} className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value={policy.value} className="mt-0.5" />
|
||||
<span>
|
||||
<span className="block font-medium">{policy.label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{policy.description}</span>
|
||||
</span>
|
||||
</Label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Explicit tier output limits take precedence. Unknown model ceilings keep the caller's limit.
|
||||
</span>
|
||||
</div>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Replace the caller's max_tokens with the routed tier model's output ceiling so one client value fits
|
||||
every tier. Off forwards the caller's value unchanged.
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1315,6 +1315,17 @@ describe("buildComplexityRouterConfig with an edited tier set", () => {
|
|||
expect(payload.fallback_tier).toBe("CASUAL");
|
||||
});
|
||||
|
||||
it("keeps clamp and explicit output limits on custom tiers", () => {
|
||||
const payload = build({
|
||||
maxTokensFromTierModel: "clamp",
|
||||
tierModelParams: { sec: { "o1-preview": { max_completion_tokens: 700 } } },
|
||||
});
|
||||
expect(payload.max_tokens_from_tier_model).toBe("clamp");
|
||||
expect(payload.tier_model_configs?.SECURITY_REVIEW).toEqual([
|
||||
{ model_name: "o1-preview", litellm_params: { max_completion_tokens: 700 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("forces the LLM classifier even when the form still holds heuristic, which the backend rejects", () => {
|
||||
expect(build({ classifierType: "heuristic" }).classifier_type).toBe("llm");
|
||||
});
|
||||
|
|
@ -1595,6 +1606,15 @@ describe("classifier vision wire payload", () => {
|
|||
});
|
||||
|
||||
describe("advanced complexity router fields", () => {
|
||||
it.each([undefined, true, false, "clamp"] as const)("serializes output token policy %s", (mode) => {
|
||||
const payload = buildComplexityRouterConfig({ ...baseParams, maxTokensFromTierModel: mode });
|
||||
if (mode === false || mode === "clamp") {
|
||||
expect(payload.max_tokens_from_tier_model).toBe(mode);
|
||||
} else {
|
||||
expect(payload).not.toHaveProperty("max_tokens_from_tier_model");
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes lists, reminder markers, and explicit false values", () => {
|
||||
const payload = buildComplexityRouterConfig({
|
||||
...baseParams,
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ export interface BuildComplexityRouterConfigParams {
|
|||
routeHousekeepingToCheapestTier?: boolean;
|
||||
housekeepingPatterns?: string[];
|
||||
reminderMarkers?: ReminderMarkerPair[];
|
||||
maxTokensFromTierModel?: boolean;
|
||||
maxTokensFromTierModel?: ComplexityRouterConfigValue["max_tokens_from_tier_model"];
|
||||
classifierPluginTimeoutMs?: number;
|
||||
}
|
||||
|
||||
|
|
@ -310,7 +310,7 @@ export interface ComplexityRouterConfigPayload {
|
|||
route_housekeeping_to_cheapest_tier?: boolean;
|
||||
housekeeping_patterns?: string[];
|
||||
reminder_markers?: ReminderMarkerPair[];
|
||||
max_tokens_from_tier_model?: boolean;
|
||||
max_tokens_from_tier_model?: ComplexityRouterConfigValue["max_tokens_from_tier_model"];
|
||||
classifier_plugin_timeout_ms?: number;
|
||||
}
|
||||
|
||||
|
|
@ -827,7 +827,9 @@ export const buildComplexityRouterConfig = ({
|
|||
...cleanedLists,
|
||||
...(routeHousekeepingToCheapestTier === false && { route_housekeeping_to_cheapest_tier: false }),
|
||||
...(cleanedReminderMarkers && cleanedReminderMarkers.length > 0 && { reminder_markers: cleanedReminderMarkers }),
|
||||
...(maxTokensFromTierModel === false && { max_tokens_from_tier_model: false }),
|
||||
...((maxTokensFromTierModel === false || maxTokensFromTierModel === "clamp") && {
|
||||
max_tokens_from_tier_model: maxTokensFromTierModel,
|
||||
}),
|
||||
...(hasValidCustomClassifierTimeout && { classifier_plugin_timeout_ms: classifierPluginTimeoutMs }),
|
||||
...scorerKnobs,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -47,6 +47,29 @@ const hydratedState: KeywordMatchingState = {
|
|||
matchThreshold: 0.72,
|
||||
};
|
||||
|
||||
describe("output token policy edits", () => {
|
||||
it.each([undefined, true, false, "clamp"] as const)("preserves and edits stored mode %s", (mode) => {
|
||||
const stored = {
|
||||
...STORED,
|
||||
...(mode !== undefined && { max_tokens_from_tier_model: mode }),
|
||||
tier_model_configs: { SIMPLE: [{ model_name: "gpt-4o-mini", litellm_params: { max_tokens: 700 } }] },
|
||||
};
|
||||
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
|
||||
expect(hydrated.max_tokens_from_tier_model).toBe(mode);
|
||||
const unchanged = buildUpdatedComplexityRouterConfig(stored, { ...hydrated, return_raw_model_name: true });
|
||||
expect(unchanged.max_tokens_from_tier_model).toBe(mode);
|
||||
expect("max_tokens_from_tier_model" in unchanged).toBe(mode !== undefined);
|
||||
for (const nextMode of [true, false, "clamp"] as const) {
|
||||
const saved = buildUpdatedComplexityRouterConfig(stored, {
|
||||
...hydrated,
|
||||
max_tokens_from_tier_model: nextMode,
|
||||
});
|
||||
expect(saved.max_tokens_from_tier_model).toBe(nextMode === true && mode !== true ? undefined : nextMode);
|
||||
expect(saved.tier_model_configs).toEqual(stored.tier_model_configs);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
|
||||
it.each([undefined, false, true])("preserves cache settings through edit and save: %s", (enabled) => {
|
||||
const stored = {
|
||||
|
|
|
|||
|
|
@ -472,9 +472,17 @@ describe("EditAutoRouterModal advanced field round trips", () => {
|
|||
modelPatchUpdateCall.mockClear();
|
||||
});
|
||||
|
||||
it("hydrates housekeeping and reminder fields, then omits the default max-token value after editing", async () => {
|
||||
it("reopens clamp and saves the selected output policy without changing other advanced settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAdvancedModal();
|
||||
renderAdvancedModal({
|
||||
modelData: {
|
||||
...MODEL_DATA,
|
||||
litellm_params: {
|
||||
...MODEL_DATA.litellm_params,
|
||||
complexity_router_config: { ...storedAdvancedConfig, max_tokens_from_tier_model: "clamp" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
openAutoRouterAdvanced("Housekeeping Routing");
|
||||
expect(screen.getByRole("switch", { name: "Route housekeeping calls to the cheapest tier" })).not.toBeChecked();
|
||||
|
|
@ -485,13 +493,17 @@ describe("EditAutoRouterModal advanced field round trips", () => {
|
|||
expect(screen.getByLabelText("Closing tag")).toHaveValue("</a>");
|
||||
|
||||
openAutoRouterAdvanced("Response Format");
|
||||
const maxTokensSwitch = screen.getByRole("switch", { name: "Cap max_tokens at the tier model's output ceiling" });
|
||||
await user.click(maxTokensSwitch);
|
||||
expect(screen.getByRole("radio", { name: /^Respect caller budget/ })).toBeChecked();
|
||||
await user.click(screen.getByRole("radio", { name: /^Pass through caller limit/ }));
|
||||
expect(screen.getByRole("radio", { name: /^Pass through caller limit/ })).toBeChecked();
|
||||
await user.click(screen.getByRole("radio", { name: /^Use model ceiling/ }));
|
||||
expect(screen.getByRole("radio", { name: /^Use model ceiling/ })).toBeChecked();
|
||||
await user.click(screen.getByRole("radio", { name: /^Respect caller budget/ }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /save changes/i })).toBeEnabled());
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalledOnce());
|
||||
|
||||
expect(savedConfig()).not.toHaveProperty("max_tokens_from_tier_model");
|
||||
expect(savedConfig().max_tokens_from_tier_model).toBe("clamp");
|
||||
expect(savedConfig()).toMatchObject({
|
||||
route_housekeeping_to_cheapest_tier: false,
|
||||
housekeeping_patterns: ["conversation title"],
|
||||
|
|
|
|||
|
|
@ -184,7 +184,8 @@ export const hydrateComplexityRouterConfig = (
|
|||
? parsedConfig.reminder_markers.filter(isReminderMarkerPair)
|
||||
: undefined,
|
||||
max_tokens_from_tier_model:
|
||||
typeof parsedConfig.max_tokens_from_tier_model === "boolean"
|
||||
typeof parsedConfig.max_tokens_from_tier_model === "boolean" ||
|
||||
parsedConfig.max_tokens_from_tier_model === "clamp"
|
||||
? parsedConfig.max_tokens_from_tier_model
|
||||
: undefined,
|
||||
classifier_plugin_timeout_ms:
|
||||
|
|
|
|||
|
|
@ -877,6 +877,13 @@ describe("autorouter_presets", () => {
|
|||
});
|
||||
|
||||
describe("buildPresetPrefill", () => {
|
||||
it.each([undefined, true, false, "clamp"] as const)("preserves a preset's output token policy %s", (mode) => {
|
||||
const preset = getPresetByKey("anthropic_family")!;
|
||||
const config = { ...preset.complexity_router_config, max_tokens_from_tier_model: mode };
|
||||
const prefill = buildPresetPrefill(config, groupsOnly(getRequiredModelsInPreset(preset)));
|
||||
expect(prefill.complexityRouterConfig.max_tokens_from_tier_model).toBe(mode);
|
||||
});
|
||||
|
||||
it("preserves Laya settings and drops inactive classifier settings when prefilling", () => {
|
||||
const config = {
|
||||
tiers: { SIMPLE: ["fast"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
|
|
|
|||
|
|
@ -364,6 +364,7 @@ export const buildPresetPrefill = (
|
|||
tier_distance_penalty: config.tier_distance_penalty,
|
||||
adaptive_eligible: config.adaptive_eligible,
|
||||
return_raw_model_name: config.return_raw_model_name,
|
||||
max_tokens_from_tier_model: config.max_tokens_from_tier_model,
|
||||
dimension_weights: hydrateDimensionWeights(config.dimension_weights),
|
||||
custom_dimensions: hydrateCustomDimensions(config.custom_dimensions),
|
||||
tier_boundaries: hydrateTierBoundaries(config.tier_boundaries),
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -42620,10 +42620,10 @@ export interface components {
|
|||
match_threshold: number;
|
||||
/**
|
||||
* Max Tokens From Tier Model
|
||||
* @description Set max_tokens on every routed request to the output ceiling of the tier model it lands on, replacing whatever the caller sent. A caller behind an auto-router cannot pick one value that fits every tier: the smallest tier's ceiling starves a bigger tier's thinking budget, and a bigger tier's ceiling is rejected by the smallest. The ceiling is the smallest max_output_tokens across the tier model's deployments, read from each deployment's model_info and then the model cost map; a tier model with a deployment whose ceiling is unknown keeps the caller's value. A max_tokens, max_completion_tokens or max_output_tokens in the tier's own litellm_params still wins. Set false to forward the caller's value unchanged.
|
||||
* @description Set max_tokens on every routed request to the output ceiling of the tier model it lands on, replacing whatever the caller sent. A caller behind an auto-router cannot pick one value that fits every tier: the smallest tier's ceiling starves a bigger tier's thinking budget, and a bigger tier's ceiling is rejected by the smallest. The ceiling is the smallest max_output_tokens across the tier model's deployments, read from each deployment's model_info and then the model cost map; a tier model with a deployment whose ceiling is unknown keeps the caller's value. A max_tokens, max_completion_tokens or max_output_tokens in the tier's own litellm_params still wins. Set "clamp" to keep a smaller positive caller value and cap a larger one at the ceiling. Set false to forward the caller's value unchanged.
|
||||
* @default true
|
||||
*/
|
||||
max_tokens_from_tier_model: boolean;
|
||||
max_tokens_from_tier_model: boolean | "clamp";
|
||||
/**
|
||||
* Modality Pin Override
|
||||
* @description Let modality_routing replace a kept session-affinity pin on the turns that carry an image. Without this, a session pinned to a text-only model fails every image turn with a provider 400, since the pin is exempt from the modality gate. When enabled, such a turn routes to a capable model for that request only and the stored pin is left untouched, so the next text turn replays the session's own model; the override is reported as cause modality_pin_override and is never itself pinned. Inert unless modality_routing is also enabled.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue