mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Revert "refactor(router): make auto setup quality-only and static"
This reverts commit bc24f70391.
This commit is contained in:
parent
c6a742baca
commit
9c7b85f3dc
26 changed files with 5077 additions and 171 deletions
|
|
@ -459,6 +459,10 @@ async def get_auto_router_recommendation(
|
|||
Literal["economy", "balanced", "high", "max"],
|
||||
Query(description="How close every admitted model must be to the best available quality score"),
|
||||
] = "balanced",
|
||||
optimize_for: Annotated[
|
||||
Literal["cost", "task_completion_speed", "balanced"],
|
||||
Query(description="What to minimize after the selected quality gate is applied"),
|
||||
] = "balanced",
|
||||
team_id: Annotated[str | None, Query(description="Team whose model access the recommendation must use")] = None,
|
||||
) -> AutoRouterRecommendationResponse:
|
||||
"""Build an editable four-tier router from only the model groups this caller can use."""
|
||||
|
|
@ -502,6 +506,7 @@ async def get_auto_router_recommendation(
|
|||
snapshot=snapshot,
|
||||
available_model_deployments=available_deployments,
|
||||
quality_level=quality_level,
|
||||
optimize_for=optimize_for,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
|
|
@ -519,6 +524,7 @@ async def get_auto_router_recommendation(
|
|||
)
|
||||
return AutoRouterRecommendationResponse(
|
||||
quality_level=quality_level,
|
||||
optimize_for=optimize_for,
|
||||
snapshot_id=snapshot.snapshot_id,
|
||||
snapshot_generated_at=snapshot.generated_at,
|
||||
available_model_group_count=len(available_deployments),
|
||||
|
|
|
|||
|
|
@ -7865,6 +7865,12 @@ class Router:
|
|||
# WS session wrappers fire with result=None; per-turn costs tracked by inner calls.
|
||||
if kwargs.get("call_type") in ("_aresponses_websocket", "_arealtime"):
|
||||
return
|
||||
await self._record_auto_setup_response_latency(
|
||||
kwargs=kwargs,
|
||||
completion_response=completion_response,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
standard_logging_object: Final[StandardLoggingPayload | None] = kwargs.get("standard_logging_object", None)
|
||||
if standard_logging_object is None:
|
||||
raise ValueError("standard_logging_object is None")
|
||||
|
|
@ -7976,6 +7982,56 @@ class Router:
|
|||
"litellm.router.Router::deployment_callback_on_success(): Exception occured - %s", e
|
||||
)
|
||||
|
||||
async def _record_auto_setup_response_latency(
|
||||
self,
|
||||
*,
|
||||
kwargs: Mapping[str, object],
|
||||
completion_response: object,
|
||||
start_time: object,
|
||||
end_time: object,
|
||||
) -> None:
|
||||
try:
|
||||
metadata: Final = _SESSION_ADAPTER.validate_python(
|
||||
get_litellm_metadata_from_kwargs(
|
||||
dict(kwargs) # mutable-ok: legacy metadata helper requires a concrete dict copy
|
||||
)
|
||||
)
|
||||
decision: Final = _SESSION_ADAPTER.validate_python(metadata.get("routing_decision"))
|
||||
except ValidationError:
|
||||
return
|
||||
if decision.get("auto_setup_selection_mode") != "runtime_response_latency":
|
||||
return
|
||||
router_model_name = decision.get("router_model_name")
|
||||
tier = decision.get("tier")
|
||||
routed_model = decision.get("routed_model")
|
||||
if not isinstance(router_model_name, str) or not router_model_name:
|
||||
return
|
||||
if not isinstance(tier, str) or not tier:
|
||||
return
|
||||
if not isinstance(routed_model, str) or not routed_model:
|
||||
return
|
||||
registered: Final = self.complexity_routers.get(router_model_name)
|
||||
if not registered or not any(tagged.strategy.config.auto_setup is not None for tagged in registered):
|
||||
return
|
||||
|
||||
from litellm.router_strategy.complexity_router.response_latency import (
|
||||
record_runtime_response_latency,
|
||||
)
|
||||
|
||||
try:
|
||||
await record_runtime_response_latency(
|
||||
router_cache=self.cache,
|
||||
router_model_name=router_model_name,
|
||||
tier=tier,
|
||||
routed_model=routed_model,
|
||||
kwargs=kwargs,
|
||||
response_obj=completion_response,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 -- optional telemetry must never fail a successful completion
|
||||
verbose_router_logger.warning("Could not record Auto setup response latency: %s", exc)
|
||||
|
||||
def sync_deployment_callback_on_success(
|
||||
self,
|
||||
kwargs, # kwargs to completion
|
||||
|
|
|
|||
|
|
@ -71,31 +71,43 @@ still resolve to a deployment in `model_list`; this configuration does not creat
|
|||
### Zero-setup Auto configuration
|
||||
|
||||
The dashboard's Add Router flow can generate the four tiers from the model groups the
|
||||
current admin or team can actually use. Auto setup asks for one product choice: Quality
|
||||
level (Economy, Balanced, High, or Max).
|
||||
current admin or team can actually use. Auto setup asks for two product choices:
|
||||
|
||||
- Quality level: Economy, Balanced, High, or Max
|
||||
- Optimize for: Cost, Task completion speed, or Balanced
|
||||
|
||||
The quality level is a gate, not an optimization weight. For every complexity tier,
|
||||
LiteLLM finds the best available model's conservative LiveBench quality score and admits
|
||||
only models within the selected maximum regret: 15 percentage points for Economy, 7 for
|
||||
Balanced, 3 for High, and 1 for Max. Auto then chooses the lowest estimated cost per
|
||||
completed task among the admitted models. If none of those models has comparable token
|
||||
pricing, Auto chooses the highest conservative quality score instead of failing setup.
|
||||
Balanced, 3 for High, and 1 for Max. The objective then ranks only those admitted models.
|
||||
This means Max still sends trivial work to a smaller model when that model is within one
|
||||
point of the best available quality for trivial work.
|
||||
|
||||
Cost is estimated dollars per completed task, not input or output token price. LiteLLM
|
||||
combines the benchmark request's token mix and completion probability with the configured
|
||||
deployment's input, cache-read, and output rates. When a model group has multiple
|
||||
deployments, Auto uses the most conservative estimate.
|
||||
Cost is benchmark cost per completed task, not input or output token price. It is observed
|
||||
request dollars divided by quality-adjusted completed questions in the same complexity
|
||||
bucket, with partial benchmark credit counted as a fractional completion.
|
||||
|
||||
Auto selects one model group for each tier and returns a normal, editable `heuristic_v2`
|
||||
configuration. It does not add a request-time policy, collect live latency observations,
|
||||
or change how the complexity router runs after creation. Existing routers, manual
|
||||
configurations, and LiteLLM's deployment-level routing are therefore unchanged.
|
||||
Task completion speed uses two forms of evidence:
|
||||
|
||||
The bundled benchmark data is a versioned snapshot. Creating or regenerating a router uses
|
||||
the snapshot shipped with that LiteLLM version; the saved router contains only the four
|
||||
selected tiers and ordinary complexity-router settings.
|
||||
- SIMPLE and MEDIUM tiers learn equal-output response time from this proxy's successful
|
||||
requests: time to first token plus the time to generate the expected number of visible
|
||||
output tokens. Reasoning tokens are excluded. The policy explores every quality-admitted
|
||||
model twice before using a rolling seven-day sample
|
||||
- COMPLEX and REASONING tiers use retry-adjusted Terminal-Bench wall-clock time to a
|
||||
successful task. Results are compared only inside the same agent and benchmark cohort
|
||||
and fall back to cost when comparable speed evidence is unavailable
|
||||
|
||||
Balanced minimizes the combined log-normalized cost and completion-time scores after the
|
||||
same quality gate. The recommendation stores its snapshot ID, objective, admitted models,
|
||||
and rank order directly in the router configuration, so the generated result is visible
|
||||
and editable. Changing a generated tier's model pool in the dashboard removes that Auto
|
||||
selection policy and returns the tier to normal manual pool selection.
|
||||
|
||||
These rules apply only when a router is created through Auto setup. Existing routers,
|
||||
manual configurations, and LiteLLM's deployment-level latency routing are unchanged. The
|
||||
bundled benchmark data is a versioned snapshot: creating or regenerating a router uses the
|
||||
snapshot shipped with that LiteLLM version, while an already saved router keeps its stored
|
||||
policy.
|
||||
|
||||
### Heuristic v2
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,10 +13,16 @@ from typing import Final, Literal
|
|||
|
||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator
|
||||
|
||||
from .config import ComplexityRouterConfig
|
||||
from .config import (
|
||||
AutoSetupCandidate,
|
||||
AutoSetupConfig,
|
||||
AutoSetupObjective,
|
||||
AutoSetupQualityLevel,
|
||||
AutoSetupTierPolicy,
|
||||
ComplexityRouterConfig,
|
||||
)
|
||||
|
||||
SnapshotComplexity = Literal["trivial", "simple", "standard", "complex"]
|
||||
AutoSetupQualityLevel = Literal["economy", "balanced", "high", "max"]
|
||||
|
||||
_SNAPSHOT_FILENAME: Final = "auto_router_snapshot_v0.json"
|
||||
_TIER_COMPLEXITIES: Final[Mapping[str, SnapshotComplexity]] = MappingProxyType(
|
||||
|
|
@ -27,6 +33,7 @@ _TIER_COMPLEXITIES: Final[Mapping[str, SnapshotComplexity]] = MappingProxyType(
|
|||
"REASONING": "complex",
|
||||
}
|
||||
)
|
||||
_EASY_COMPLEXITIES: Final = frozenset(("trivial", "simple"))
|
||||
_VERSION_SEPARATOR: Final = re.compile(r"(?<=\d)\.(?=\d)")
|
||||
_SNAPSHOT_PAYLOAD_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
|
@ -68,12 +75,36 @@ class SnapshotQualityProfile(BaseModel):
|
|||
subtask_count: int = Field(gt=0)
|
||||
|
||||
|
||||
class SnapshotSpeedProfile(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
attempt_count: int = Field(gt=0)
|
||||
benchmark_model_id: str
|
||||
cohort_id: str
|
||||
complexity: SnapshotComplexity
|
||||
cost_per_completed_task_usd: float | None = Field(default=None, ge=0)
|
||||
excluded_infrastructure_trials: int = Field(ge=0)
|
||||
mean_attempt_cost_usd: float | None = Field(default=None, ge=0)
|
||||
mean_cache_read_input_tokens: float | None = Field(default=None, ge=0)
|
||||
mean_output_tokens: float | None = Field(default=None, ge=0)
|
||||
mean_uncached_input_tokens: float | None = Field(default=None, ge=0)
|
||||
observed_duration_p50_ms: float = Field(ge=0)
|
||||
observed_duration_p95_ms: float = Field(ge=0)
|
||||
quality_lower_bound: float = Field(ge=0, le=1)
|
||||
retry_adjusted_completion_ms: float | None = Field(default=None, ge=0)
|
||||
source_id: str
|
||||
success_probability: float = Field(ge=0, le=1)
|
||||
task_count: int = Field(gt=0)
|
||||
terminal_bench_model_id: str
|
||||
|
||||
|
||||
class SnapshotModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
benchmark_model_id: str
|
||||
identity: SnapshotIdentity
|
||||
quality_by_complexity: Mapping[SnapshotComplexity, SnapshotQualityProfile]
|
||||
task_completion_speed_by_complexity: Mapping[SnapshotComplexity, tuple[SnapshotSpeedProfile, ...]]
|
||||
|
||||
|
||||
class AutoRouterSnapshot(BaseModel):
|
||||
|
|
@ -88,7 +119,8 @@ class AutoRouterSnapshot(BaseModel):
|
|||
models: Mapping[str, SnapshotModel]
|
||||
provenance: Mapping[str, object]
|
||||
quality_tiers: Mapping[AutoSetupQualityLevel, SnapshotQualityTier]
|
||||
schema_version: Literal["3.0"]
|
||||
reference_routes: Mapping[str, object]
|
||||
schema_version: Literal["2.1"]
|
||||
snapshot_id: str
|
||||
task_complexities: Mapping[str, Mapping[str, SnapshotComplexity]]
|
||||
|
||||
|
|
@ -105,12 +137,24 @@ class AutoRouterSnapshot(BaseModel):
|
|||
raise ValueError(f"Auto Router snapshot model identity mismatch for {model_id}")
|
||||
if frozenset(model.quality_by_complexity) != expected_complexities:
|
||||
raise ValueError(f"Auto Router snapshot model {model_id} lacks complete quality evidence")
|
||||
if frozenset(model.task_completion_speed_by_complexity) != expected_complexities:
|
||||
raise ValueError(f"Auto Router snapshot model {model_id} lacks complete speed buckets")
|
||||
if model.identity.is_routable != bool(model.identity.litellm_model_keys):
|
||||
raise ValueError(f"Auto Router snapshot routability mismatch for {model_id}")
|
||||
for complexity in expected_complexities:
|
||||
quality = model.quality_by_complexity[complexity]
|
||||
if quality.benchmark_model_id != model_id or quality.complexity != complexity:
|
||||
raise ValueError(f"Auto Router snapshot quality identity mismatch for {model_id}/{complexity}")
|
||||
for speed in model.task_completion_speed_by_complexity[complexity]:
|
||||
token_values = (
|
||||
speed.mean_uncached_input_tokens,
|
||||
speed.mean_cache_read_input_tokens,
|
||||
speed.mean_output_tokens,
|
||||
)
|
||||
if any(value is None for value in token_values) != all(value is None for value in token_values):
|
||||
raise ValueError(f"Auto Router snapshot has partial token evidence for {model_id}/{complexity}")
|
||||
if speed.benchmark_model_id != model_id or speed.complexity != complexity:
|
||||
raise ValueError(f"Auto Router snapshot speed identity mismatch for {model_id}/{complexity}")
|
||||
for alias in model.identity.litellm_model_keys:
|
||||
normalized = _normalized_model_key(alias)
|
||||
owner = seen_aliases.setdefault(normalized, model.identity.family_id)
|
||||
|
|
@ -149,7 +193,7 @@ class AutoSetupDeployment(BaseModel):
|
|||
pricing: AutoSetupDeploymentPricing | None = None
|
||||
|
||||
|
||||
AutoSetupExclusionReason = Literal["no_benchmark_match", "mixed_model_group"]
|
||||
AutoSetupExclusionReason = Literal["no_benchmark_match", "mixed_model_group", "pricing_unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -162,8 +206,9 @@ class AutoSetupInventoryExclusion:
|
|||
class _AvailableProfile:
|
||||
model_name: str
|
||||
benchmark_model_id: str
|
||||
cost_per_completed_task_usd: float | None
|
||||
cost_per_completed_task_usd: float
|
||||
quality: SnapshotQualityProfile
|
||||
speed: tuple[SnapshotSpeedProfile, ...]
|
||||
required_parameters: Mapping[str, object]
|
||||
|
||||
|
||||
|
|
@ -253,7 +298,18 @@ def analyze_auto_setup_inventory(
|
|||
"""Return identity-safe groups and explicit reasons for every excluded group."""
|
||||
|
||||
groups, exclusions = _resolve_groups(snapshot, available_model_deployments)
|
||||
return tuple(group.model_name for group in groups), exclusions
|
||||
eligible: list[str] = []
|
||||
mutable_exclusions = list(exclusions)
|
||||
for group in groups:
|
||||
if any(
|
||||
_quality_completion_cost(model.quality_by_complexity[complexity], group.deployments) is not None
|
||||
for model in group.matches
|
||||
for complexity in _TIER_COMPLEXITIES.values()
|
||||
):
|
||||
eligible.append(group.model_name)
|
||||
else:
|
||||
mutable_exclusions.append(AutoSetupInventoryExclusion(group.model_name, "pricing_unavailable"))
|
||||
return tuple(eligible), tuple(mutable_exclusions)
|
||||
|
||||
|
||||
def _rate_for_input_size(
|
||||
|
|
@ -289,6 +345,19 @@ def _rate_for_input_size(
|
|||
return base
|
||||
|
||||
|
||||
def _base_rate(
|
||||
pricing: AutoSetupDeploymentPricing,
|
||||
field: Literal["input", "output", "cache"],
|
||||
) -> float:
|
||||
if field == "input":
|
||||
return pricing.input_cost_per_token
|
||||
if field == "output":
|
||||
return pricing.output_cost_per_token
|
||||
if pricing.cache_read_input_token_cost is not None:
|
||||
return pricing.cache_read_input_token_cost
|
||||
return pricing.input_cost_per_token
|
||||
|
||||
|
||||
def _quality_completion_cost(
|
||||
profile: SnapshotQualityProfile,
|
||||
deployments: Sequence[AutoSetupDeployment],
|
||||
|
|
@ -312,6 +381,31 @@ def _quality_completion_cost(
|
|||
return max(costs, default=None)
|
||||
|
||||
|
||||
def _speed_completion_cost(
|
||||
speed: SnapshotSpeedProfile,
|
||||
deployments: Sequence[AutoSetupDeployment],
|
||||
) -> float | None:
|
||||
if (
|
||||
speed.success_probability <= 0
|
||||
or speed.mean_uncached_input_tokens is None
|
||||
or speed.mean_cache_read_input_tokens is None
|
||||
or speed.mean_output_tokens is None
|
||||
):
|
||||
return None
|
||||
costs: list[float] = []
|
||||
for deployment in deployments:
|
||||
pricing = deployment.pricing
|
||||
if pricing is None:
|
||||
return None
|
||||
attempt_cost = (
|
||||
speed.mean_uncached_input_tokens * _base_rate(pricing, "input")
|
||||
+ speed.mean_cache_read_input_tokens * _base_rate(pricing, "cache")
|
||||
+ speed.mean_output_tokens * _base_rate(pricing, "output")
|
||||
)
|
||||
costs.append(attempt_cost / speed.success_probability)
|
||||
return max(costs, default=None)
|
||||
|
||||
|
||||
def _available_profiles(
|
||||
snapshot: AutoRouterSnapshot,
|
||||
available_model_deployments: Mapping[str, Sequence[AutoSetupDeployment]],
|
||||
|
|
@ -333,12 +427,19 @@ def _available_profiles(
|
|||
),
|
||||
)
|
||||
cost = _quality_completion_cost(best.quality_by_complexity[complexity], group.deployments)
|
||||
if cost is None:
|
||||
continue
|
||||
priced_speed = tuple(
|
||||
speed.model_copy(update={"cost_per_completed_task_usd": _speed_completion_cost(speed, group.deployments)})
|
||||
for speed in best.task_completion_speed_by_complexity[complexity]
|
||||
)
|
||||
profiles.append(
|
||||
_AvailableProfile(
|
||||
model_name=group.model_name,
|
||||
benchmark_model_id=best.benchmark_model_id,
|
||||
cost_per_completed_task_usd=cost,
|
||||
quality=best.quality_by_complexity[complexity],
|
||||
speed=priced_speed,
|
||||
required_parameters=best.identity.required_parameters,
|
||||
)
|
||||
)
|
||||
|
|
@ -350,8 +451,7 @@ def _cost_rank(profiles: Sequence[_AvailableProfile]) -> tuple[_AvailableProfile
|
|||
sorted(
|
||||
profiles,
|
||||
key=lambda profile: (
|
||||
profile.cost_per_completed_task_usd is None,
|
||||
profile.cost_per_completed_task_usd if profile.cost_per_completed_task_usd is not None else math.inf,
|
||||
profile.cost_per_completed_task_usd,
|
||||
-profile.quality.quality_lower_bound,
|
||||
profile.model_name,
|
||||
),
|
||||
|
|
@ -359,15 +459,146 @@ def _cost_rank(profiles: Sequence[_AvailableProfile]) -> tuple[_AvailableProfile
|
|||
)
|
||||
|
||||
|
||||
def _normalize_log(value: float, values: Sequence[float]) -> float:
|
||||
logged: Final = tuple(math.log(max(item, 1e-12)) for item in values)
|
||||
low: Final = min(logged)
|
||||
high: Final = max(logged)
|
||||
if low == high:
|
||||
return 0.0
|
||||
return (math.log(max(value, 1e-12)) - low) / (high - low)
|
||||
|
||||
|
||||
def _hard_task_rank(
|
||||
profiles: Sequence[_AvailableProfile], objective: AutoSetupObjective
|
||||
) -> tuple[tuple[_AvailableProfile, ...], str]:
|
||||
cost_ranked: Final = _cost_rank(profiles)
|
||||
speed_profiles: Final = tuple(
|
||||
(profile, speed)
|
||||
for profile in profiles
|
||||
for speed in profile.speed
|
||||
if speed.retry_adjusted_completion_ms is not None
|
||||
)
|
||||
if not speed_profiles:
|
||||
return cost_ranked, "fallback_no_speed_evidence"
|
||||
cohorts: dict[ # mutable-ok: local cohort index never escapes
|
||||
str, dict[str, tuple[_AvailableProfile, SnapshotSpeedProfile]]
|
||||
] = {} # mutable-ok: local cohort index is populated before selection
|
||||
for profile, speed in speed_profiles:
|
||||
by_model = cohorts.setdefault(
|
||||
speed.cohort_id,
|
||||
{}, # mutable-ok: local cohort index needs an empty per-cohort bucket
|
||||
)
|
||||
current = by_model.get(profile.model_name)
|
||||
if current is None or speed.attempt_count > current[1].attempt_count:
|
||||
by_model[profile.model_name] = (profile, speed)
|
||||
measured_profiles: Final = tuple(
|
||||
{ # mutable-ok: transient dict provides deterministic model-name deduplication
|
||||
profile.model_name: profile for profile, _ in speed_profiles
|
||||
}.values()
|
||||
)
|
||||
anchor: Final = max(
|
||||
measured_profiles,
|
||||
key=lambda profile: (profile.quality.quality_lower_bound, -profile.cost_per_completed_task_usd),
|
||||
)
|
||||
anchor_cohorts: Final = tuple(cohort_id for cohort_id, by_model in cohorts.items() if anchor.model_name in by_model)
|
||||
selected_cohort = max(
|
||||
anchor_cohorts,
|
||||
key=lambda cohort_id: (
|
||||
len(cohorts[cohort_id]),
|
||||
sum(speed.attempt_count for _, speed in cohorts[cohort_id].values()),
|
||||
cohort_id,
|
||||
),
|
||||
)
|
||||
comparable = tuple(cohorts[selected_cohort].values())
|
||||
if objective == "task_completion_speed":
|
||||
return tuple(
|
||||
profile
|
||||
for profile, _ in sorted(
|
||||
comparable,
|
||||
key=lambda item: (
|
||||
item[1].retry_adjusted_completion_ms or math.inf,
|
||||
-item[0].quality.quality_lower_bound,
|
||||
item[0].model_name,
|
||||
),
|
||||
)
|
||||
), "cohort_scoped"
|
||||
balanced_comparable: list[ # mutable-ok: local typed accumulator is consumed immediately
|
||||
tuple[_AvailableProfile, float, float]
|
||||
] = [] # mutable-ok: local typed accumulator is frozen by the return expression
|
||||
for profile, speed in comparable:
|
||||
cost = speed.cost_per_completed_task_usd
|
||||
completion_ms = speed.retry_adjusted_completion_ms
|
||||
if cost is not None and completion_ms is not None:
|
||||
balanced_comparable.append((profile, cost, completion_ms))
|
||||
if not balanced_comparable:
|
||||
return cost_ranked, "fallback_no_comparable_cost_evidence"
|
||||
costs: Final = tuple(cost for _, cost, _ in balanced_comparable)
|
||||
times: Final = tuple(completion_ms for _, _, completion_ms in balanced_comparable)
|
||||
return tuple(
|
||||
profile
|
||||
for profile, _, _ in sorted(
|
||||
balanced_comparable,
|
||||
key=lambda item: (
|
||||
math.hypot(
|
||||
_normalize_log(item[1], costs),
|
||||
_normalize_log(item[2], times),
|
||||
),
|
||||
-item[0].quality.quality_lower_bound,
|
||||
item[0].model_name,
|
||||
),
|
||||
)
|
||||
), "cohort_scoped"
|
||||
|
||||
|
||||
def _policy_candidate(profile: _AvailableProfile) -> AutoSetupCandidate:
|
||||
return AutoSetupCandidate(
|
||||
model_name=profile.model_name,
|
||||
benchmark_model_id=profile.benchmark_model_id,
|
||||
quality_lower_bound=profile.quality.quality_lower_bound,
|
||||
cost_per_completed_task_usd=profile.cost_per_completed_task_usd,
|
||||
)
|
||||
|
||||
|
||||
def _tier_policy(
|
||||
profiles: Sequence[_AvailableProfile],
|
||||
complexity: SnapshotComplexity,
|
||||
objective: AutoSetupObjective,
|
||||
) -> AutoSetupTierPolicy:
|
||||
cost_ranked: Final = _cost_rank(profiles)
|
||||
if objective == "cost":
|
||||
return AutoSetupTierPolicy(
|
||||
selection_mode="snapshot_ranked",
|
||||
candidates=tuple(_policy_candidate(profile) for profile in cost_ranked),
|
||||
evidence_status="direct",
|
||||
)
|
||||
if complexity in _EASY_COMPLEXITIES:
|
||||
return AutoSetupTierPolicy(
|
||||
selection_mode="runtime_response_latency",
|
||||
candidates=tuple(_policy_candidate(profile) for profile in cost_ranked),
|
||||
cold_start_model=cost_ranked[0].model_name,
|
||||
evidence_status="runtime_response_speed_required",
|
||||
)
|
||||
ranked, evidence_status = _hard_task_rank(profiles, objective)
|
||||
return AutoSetupTierPolicy(
|
||||
selection_mode="snapshot_ranked",
|
||||
candidates=tuple(_policy_candidate(profile) for profile in ranked),
|
||||
evidence_status=evidence_status,
|
||||
)
|
||||
|
||||
|
||||
def build_auto_setup_config(
|
||||
*,
|
||||
snapshot: AutoRouterSnapshot,
|
||||
available_model_deployments: Mapping[str, Sequence[AutoSetupDeployment]],
|
||||
quality_level: AutoSetupQualityLevel,
|
||||
optimize_for: AutoSetupObjective,
|
||||
) -> ComplexityRouterConfig:
|
||||
"""Recompute quality gates and rankings over only the caller's available groups."""
|
||||
|
||||
maximum_regret: Final = snapshot.quality_tiers[quality_level].maximum_quality_regret
|
||||
policies: Final[ # mutable-ok: Pydantic config assembly is local to this builder
|
||||
dict[str, AutoSetupTierPolicy]
|
||||
] = {} # mutable-ok: Pydantic config assembly is local to this builder
|
||||
tiers: Final[dict[str, list[str]]] = {} # mutable-ok: Pydantic config assembly is local to this builder
|
||||
tier_model_configs: Final[ # mutable-ok: Pydantic config assembly is local to this builder
|
||||
dict[str, list[dict[str, object]]]
|
||||
|
|
@ -379,20 +610,21 @@ def build_auto_setup_config(
|
|||
best_quality = max(profile.quality.quality_lower_bound for profile in profiles)
|
||||
floor = max(0.0, best_quality - maximum_regret)
|
||||
admitted = tuple(profile for profile in profiles if profile.quality.quality_lower_bound >= floor)
|
||||
selected = _cost_rank(admitted)[0]
|
||||
tiers[tier_name] = [selected.model_name]
|
||||
parameterized: list[dict[str, object]] = (
|
||||
[ # mutable-ok: Pydantic validates and owns these JSON-shaped rows
|
||||
{ # mutable-ok: each row is a Pydantic input payload
|
||||
"model_name": selected.model_name,
|
||||
"litellm_params": dict( # mutable-ok: Pydantic needs a concrete JSON-shaped params object
|
||||
selected.required_parameters
|
||||
),
|
||||
}
|
||||
]
|
||||
if selected.required_parameters
|
||||
else []
|
||||
)
|
||||
policy = _tier_policy(admitted, complexity, optimize_for)
|
||||
policies[tier_name] = policy
|
||||
tiers[tier_name] = [ # mutable-ok: ComplexityRouterConfig owns this JSON-shaped tier list
|
||||
candidate.model_name for candidate in policy.candidates
|
||||
]
|
||||
parameterized: list[dict[str, object]] = [ # mutable-ok: Pydantic validates and owns these JSON-shaped rows
|
||||
{ # mutable-ok: each row is a Pydantic input payload
|
||||
"model_name": profile.model_name,
|
||||
"litellm_params": dict( # mutable-ok: Pydantic needs a concrete JSON-shaped params object
|
||||
profile.required_parameters
|
||||
),
|
||||
}
|
||||
for profile in admitted
|
||||
if profile.model_name in frozenset(tiers[tier_name]) and profile.required_parameters
|
||||
]
|
||||
if parameterized:
|
||||
tier_model_configs[tier_name] = parameterized
|
||||
|
||||
|
|
@ -401,5 +633,12 @@ def build_auto_setup_config(
|
|||
"tiers": tiers,
|
||||
"tier_model_configs": tier_model_configs,
|
||||
"classifier_type": "heuristic_v2",
|
||||
"auto_setup": AutoSetupConfig(
|
||||
snapshot_id=snapshot.snapshot_id,
|
||||
snapshot_sha256=snapshot.artifact_sha256,
|
||||
quality_level=quality_level,
|
||||
optimize_for=optimize_for,
|
||||
tier_policies=policies,
|
||||
).model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1322,6 +1322,13 @@ class ComplexityRouter(CustomLogger):
|
|||
if tier is not None:
|
||||
tier_name: Final = _tier_name(tier)
|
||||
decision["tier"] = tier_name
|
||||
if self.config.auto_setup is not None:
|
||||
auto_policy: Final = self.config.auto_setup.tier_policies.get(tier_name)
|
||||
if auto_policy is not None:
|
||||
decision["auto_setup_snapshot_id"] = self.config.auto_setup.snapshot_id
|
||||
decision["auto_setup_quality_level"] = self.config.auto_setup.quality_level
|
||||
decision["auto_setup_optimize_for"] = self.config.auto_setup.optimize_for
|
||||
decision["auto_setup_selection_mode"] = auto_policy.selection_mode
|
||||
if not self.config.has_custom_tiers:
|
||||
label = self.config.tier_label(ComplexityTier(tier_name))
|
||||
if label != tier_name:
|
||||
|
|
@ -1815,6 +1822,37 @@ class ComplexityRouter(CustomLogger):
|
|||
raise ValueError(f"Empty model pool for tier {tier_key}")
|
||||
return random.choice(model)
|
||||
|
||||
async def _pick_from_auto_setup_policy(self, tier_key: str, pool: Sequence[str]) -> str | None:
|
||||
setup: Final = self.config.auto_setup
|
||||
if setup is None:
|
||||
return None
|
||||
policy: Final = setup.tier_policies.get(tier_key)
|
||||
if policy is None:
|
||||
return None
|
||||
available: Final = tuple(dict.fromkeys(pool))
|
||||
if not available:
|
||||
raise ValueError(f"No models configured for tier {tier_key}")
|
||||
ranked: Final = tuple(
|
||||
candidate.model_name for candidate in policy.candidates if candidate.model_name in frozenset(available)
|
||||
)
|
||||
if policy.selection_mode == "snapshot_ranked":
|
||||
return ranked[0] if ranked else available[0]
|
||||
|
||||
from .response_latency import select_runtime_response_model
|
||||
|
||||
cold_start: Final = policy.cold_start_model
|
||||
if cold_start is None:
|
||||
raise ValueError(f"Auto setup runtime response-latency policy for {tier_key} has no cold-start model")
|
||||
return await select_runtime_response_model(
|
||||
router_cache=self.litellm_router_instance.cache,
|
||||
router_model_name=self.model_name,
|
||||
tier=tier_key,
|
||||
candidates=policy.candidates,
|
||||
available_models=available,
|
||||
cold_start_model=cold_start,
|
||||
objective=setup.optimize_for,
|
||||
)
|
||||
|
||||
def _tier_pools(self) -> dict[str, list[str]]:
|
||||
return {tier: (models if isinstance(models, list) else [models]) for tier, models in self.config.tiers.items()}
|
||||
|
||||
|
|
@ -1826,17 +1864,25 @@ class ComplexityRouter(CustomLogger):
|
|||
request_kwargs: dict,
|
||||
allowed_models: tuple[str, ...] | None = None,
|
||||
) -> str:
|
||||
tier_key = _tier_name(tier)
|
||||
if not self.config.plugins:
|
||||
if allowed_models is not None:
|
||||
return self._pick_from_tier_value(allowed_models, _tier_name(tier))
|
||||
return self.get_model_for_tier(tier)
|
||||
configured = self.config.tiers.get(tier_key)
|
||||
if configured is None:
|
||||
return self.get_model_for_tier(tier)
|
||||
full_pool = (configured,) if isinstance(configured, str) else tuple(configured)
|
||||
pool = (
|
||||
tuple(model for model in full_pool if model in allowed_models)
|
||||
if allowed_models is not None
|
||||
else full_pool
|
||||
)
|
||||
direct_auto_pick: Final = await self._pick_from_auto_setup_policy(tier_key, pool)
|
||||
return direct_auto_pick if direct_auto_pick is not None else self._pick_from_tier_value(pool, tier_key)
|
||||
|
||||
from litellm.types.router import RoutingContext
|
||||
|
||||
tier_key: Final = _tier_name(tier)
|
||||
metadata_key: Final = get_metadata_variable_name_from_kwargs(request_kwargs)
|
||||
full_pool: Final = tuple(self._tier_pools().get(tier_key, ()))
|
||||
pool: Final = (
|
||||
full_pool = tuple(self._tier_pools().get(tier_key, ()))
|
||||
pool = (
|
||||
tuple(model for model in full_pool if model in allowed_models) if allowed_models is not None else full_pool
|
||||
)
|
||||
if not pool:
|
||||
|
|
@ -1860,7 +1906,12 @@ class ComplexityRouter(CustomLogger):
|
|||
# silently bypassed. Raise instead, matching the Router-level plugin
|
||||
# pipeline's own fail-closed behavior for the same situation.
|
||||
raise ValueError(f"No candidate models left for tier {tier_key} after routing-plugin filtering")
|
||||
return self._pick_from_tier_value(context.candidate_models, tier_key)
|
||||
filtered_auto_pick: Final = await self._pick_from_auto_setup_policy(tier_key, context.candidate_models)
|
||||
return (
|
||||
filtered_auto_pick
|
||||
if filtered_auto_pick is not None
|
||||
else self._pick_from_tier_value(context.candidate_models, tier_key)
|
||||
)
|
||||
|
||||
def _ensure_adaptive_router(self) -> Any | None:
|
||||
if not self.config.adaptive:
|
||||
|
|
|
|||
|
|
@ -27,6 +27,81 @@ class ComplexityTier(str, Enum):
|
|||
REASONING = "REASONING"
|
||||
|
||||
|
||||
AutoSetupQualityLevel = Literal["economy", "balanced", "high", "max"]
|
||||
AutoSetupObjective = Literal["cost", "task_completion_speed", "balanced"]
|
||||
AutoSetupSelectionMode = Literal["snapshot_ranked", "runtime_response_latency"]
|
||||
|
||||
|
||||
class AutoSetupCandidate(BaseModel):
|
||||
"""One benchmark-backed model group in an Auto-generated tier policy."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
model_name: str = Field(min_length=1)
|
||||
benchmark_model_id: str = Field(min_length=1)
|
||||
quality_lower_bound: float = Field(ge=0, le=1)
|
||||
cost_per_completed_task_usd: float = Field(ge=0)
|
||||
|
||||
|
||||
class AutoSetupTierPolicy(BaseModel):
|
||||
"""How an Auto-generated tier chooses from its quality-admitted model groups."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
selection_mode: AutoSetupSelectionMode
|
||||
candidates: tuple[AutoSetupCandidate, ...] = Field(min_length=1)
|
||||
cold_start_model: str | None = None
|
||||
evidence_status: str = Field(min_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_candidates(self) -> "AutoSetupTierPolicy":
|
||||
names: Final = tuple(candidate.model_name for candidate in self.candidates)
|
||||
if len(names) != len(frozenset(names)):
|
||||
raise ValueError("Auto setup tier candidates must use distinct model_name values")
|
||||
if self.selection_mode == "runtime_response_latency":
|
||||
if self.cold_start_model not in names:
|
||||
raise ValueError("A runtime response-latency policy needs a cold_start_model in candidates")
|
||||
elif self.cold_start_model is not None:
|
||||
raise ValueError("cold_start_model is only valid for runtime_response_latency")
|
||||
return self
|
||||
|
||||
|
||||
class AutoSetupConfig(BaseModel):
|
||||
"""Provenance and selection policy stamped only by zero-setup Auto creation."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
schema_version: Literal["1"] = "1"
|
||||
snapshot_id: str = Field(min_length=1)
|
||||
snapshot_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
quality_level: AutoSetupQualityLevel
|
||||
optimize_for: AutoSetupObjective
|
||||
tier_policies: Mapping[str, AutoSetupTierPolicy]
|
||||
|
||||
@field_validator("tier_policies", mode="before")
|
||||
@classmethod
|
||||
def _freeze_tier_policies(cls, value: Mapping[str, object]) -> Mapping[str, object]:
|
||||
return MappingProxyType(dict(value))
|
||||
|
||||
@field_serializer("tier_policies")
|
||||
def _serialize_tier_policies(self, value: Mapping[str, AutoSetupTierPolicy]) -> Mapping[str, AutoSetupTierPolicy]:
|
||||
return dict(value) # mutable-ok: Pydantic JSON serialization requires a concrete mapping
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_tier_policies(self) -> "AutoSetupConfig":
|
||||
expected: Final = frozenset(tier.value for tier in TIER_SEVERITY_ORDER)
|
||||
actual: Final = frozenset(self.tier_policies)
|
||||
if actual != expected:
|
||||
raise ValueError(
|
||||
f"Auto setup tier_policies must define exactly {', '.join(tier.value for tier in TIER_SEVERITY_ORDER)}"
|
||||
)
|
||||
if self.optimize_for == "cost" and any(
|
||||
policy.selection_mode != "snapshot_ranked" for policy in self.tier_policies.values()
|
||||
):
|
||||
raise ValueError("Cost-optimized Auto setup cannot enable runtime response-latency selection")
|
||||
return self
|
||||
|
||||
|
||||
class ClassificationRubric(str, Enum):
|
||||
"""Which calibration examples, and for BUSINESS which tier criteria, the built-in classifier rubric carries."""
|
||||
|
||||
|
|
@ -517,6 +592,14 @@ class ComplexityRouterConfig(BaseModel):
|
|||
default_factory=dict,
|
||||
)
|
||||
|
||||
auto_setup: AutoSetupConfig | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Benchmark snapshot and per-tier selection policy generated by the zero-setup Auto flow. "
|
||||
"Absent on manually configured routers, whose pool selection behavior is unchanged"
|
||||
),
|
||||
)
|
||||
|
||||
tier_definitions: tuple[TierDefinition, ...] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
|
|
@ -1422,6 +1505,24 @@ class ComplexityRouterConfig(BaseModel):
|
|||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_auto_setup(self) -> "ComplexityRouterConfig":
|
||||
setup: Final = self.auto_setup
|
||||
if setup is None:
|
||||
return self
|
||||
if self.adaptive:
|
||||
raise ValueError("auto_setup and adaptive=True cannot both select from the same tier pool")
|
||||
if self.tier_definitions is not None:
|
||||
raise ValueError("auto_setup requires the built-in four-tier complexity ladder")
|
||||
for tier_name, policy in setup.tier_policies.items():
|
||||
configured = self.tiers.get(tier_name)
|
||||
configured_names = frozenset((configured,)) if isinstance(configured, str) else frozenset(configured or ())
|
||||
policy_names = frozenset(candidate.model_name for candidate in policy.candidates)
|
||||
if not policy_names.issubset(configured_names):
|
||||
missing = ", ".join(sorted(policy_names - configured_names))
|
||||
raise ValueError(f"auto_setup tier {tier_name} references models not present in tiers: {missing}")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_tier_param_placement(self) -> "ComplexityRouterConfig":
|
||||
"""Reject a router setting written into a tier entry's request params.
|
||||
|
|
|
|||
248
litellm/router_strategy/complexity_router/response_latency.py
Normal file
248
litellm/router_strategy/complexity_router/response_latency.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import statistics
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timedelta
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Protocol
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
|
||||
|
||||
from litellm._logging import verbose_router_logger
|
||||
|
||||
from .config import AutoSetupCandidate, AutoSetupObjective
|
||||
|
||||
_CACHE_PREFIX: Final = "auto_setup_response_latency:v1"
|
||||
_CACHE_TTL_SECONDS: Final = 7 * 24 * 60 * 60
|
||||
_MAX_SAMPLES_PER_MODEL: Final = 20
|
||||
_MAX_EXPECTED_OUTPUT_SAMPLES: Final = 100
|
||||
_MIN_SAMPLES_PER_MODEL: Final = 2
|
||||
_OBJECT_ADAPTER: Final = TypeAdapter(object)
|
||||
_OBJECT_MAPPING_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
class ResponseLatencyCache(Protocol):
|
||||
async def async_get_cache(
|
||||
self,
|
||||
key: str,
|
||||
parent_otel_span: object | None = None,
|
||||
local_only: bool = False,
|
||||
**kwargs: object, # kwargs-ok: matches the shared cache interface's optional transport parameters
|
||||
) -> object: ...
|
||||
|
||||
async def async_set_cache(
|
||||
self,
|
||||
key: str,
|
||||
value: object,
|
||||
local_only: bool = False,
|
||||
**kwargs: object, # kwargs-ok: matches the shared cache interface's optional transport parameters
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class ResponseLatencySample(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
ttft_ms: float = Field(ge=0)
|
||||
output_tokens_per_ms: float = Field(gt=0)
|
||||
|
||||
|
||||
class ResponseLatencyState(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
samples_by_model: Mapping[str, tuple[ResponseLatencySample, ...]] = Field(
|
||||
default_factory=lambda: MappingProxyType({})
|
||||
)
|
||||
visible_output_tokens: tuple[int, ...] = ()
|
||||
|
||||
|
||||
def _cache_key(router_model_name: str, tier: str) -> str:
|
||||
namespace: Final = hashlib.sha256(router_model_name.encode("utf-8")).hexdigest()[:24]
|
||||
return f"{_CACHE_PREFIX}:{namespace}:{tier}"
|
||||
|
||||
|
||||
def _state(value: object) -> ResponseLatencyState:
|
||||
try:
|
||||
return ResponseLatencyState.model_validate(value or MappingProxyType({}))
|
||||
except ValidationError:
|
||||
return ResponseLatencyState()
|
||||
|
||||
|
||||
def _candidate_order(
|
||||
candidates: Sequence[AutoSetupCandidate],
|
||||
available_models: Sequence[str],
|
||||
cold_start_model: str,
|
||||
) -> tuple[str, ...]:
|
||||
available: Final = frozenset(available_models)
|
||||
ranked: Final = tuple(candidate.model_name for candidate in candidates if candidate.model_name in available)
|
||||
extras: Final = tuple(model for model in available_models if model not in frozenset(ranked))
|
||||
ordered: Final = tuple(dict.fromkeys((*ranked, *extras)))
|
||||
if cold_start_model not in ordered:
|
||||
return ordered
|
||||
return (cold_start_model, *(model for model in ordered if model != cold_start_model))
|
||||
|
||||
|
||||
def _median_prediction_ms(samples: Sequence[ResponseLatencySample], expected_output_tokens: float) -> float:
|
||||
return statistics.median(
|
||||
sample.ttft_ms + expected_output_tokens / sample.output_tokens_per_ms for sample in samples
|
||||
)
|
||||
|
||||
|
||||
def _normalized_log(value: float, values: Sequence[float]) -> float:
|
||||
logged: Final = tuple(math.log(max(item, 1e-12)) for item in values)
|
||||
low: Final = min(logged)
|
||||
high: Final = max(logged)
|
||||
if low == high:
|
||||
return 0.0
|
||||
return (math.log(max(value, 1e-12)) - low) / (high - low)
|
||||
|
||||
|
||||
def _best_observed_model(
|
||||
state: ResponseLatencyState,
|
||||
candidates: Sequence[AutoSetupCandidate],
|
||||
ordered_models: Sequence[str],
|
||||
objective: AutoSetupObjective,
|
||||
) -> str:
|
||||
expected_tokens: Final = statistics.median(state.visible_output_tokens) if state.visible_output_tokens else 1.0
|
||||
latencies: Final[Mapping[str, float]] = MappingProxyType(
|
||||
{model: _median_prediction_ms(state.samples_by_model[model], expected_tokens) for model in ordered_models}
|
||||
)
|
||||
if objective == "task_completion_speed":
|
||||
return min(ordered_models, key=lambda model: (latencies[model], ordered_models.index(model)))
|
||||
|
||||
configured_costs: Final[Mapping[str, float]] = MappingProxyType(
|
||||
{candidate.model_name: candidate.cost_per_completed_task_usd for candidate in candidates}
|
||||
)
|
||||
known_costs: Final = tuple(configured_costs.values())
|
||||
conservative_unknown_cost: Final = max(known_costs) if known_costs else 1.0
|
||||
costs: Final[Mapping[str, float]] = MappingProxyType(
|
||||
{model: configured_costs.get(model, conservative_unknown_cost) for model in ordered_models}
|
||||
)
|
||||
latency_values: Final = tuple(latencies.values())
|
||||
cost_values: Final = tuple(costs.values())
|
||||
return min(
|
||||
ordered_models,
|
||||
key=lambda model: (
|
||||
math.hypot(
|
||||
_normalized_log(latencies[model], latency_values),
|
||||
_normalized_log(costs[model], cost_values),
|
||||
),
|
||||
ordered_models.index(model),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def select_runtime_response_model(
|
||||
*,
|
||||
router_cache: ResponseLatencyCache,
|
||||
router_model_name: str,
|
||||
tier: str,
|
||||
candidates: Sequence[AutoSetupCandidate],
|
||||
available_models: Sequence[str],
|
||||
cold_start_model: str,
|
||||
objective: AutoSetupObjective,
|
||||
) -> str:
|
||||
"""Explore quality-admitted groups, then minimize equal-output response time."""
|
||||
|
||||
ordered: Final = _candidate_order(candidates, available_models, cold_start_model)
|
||||
if not ordered:
|
||||
raise ValueError(f"No Auto setup candidates remain for tier {tier}")
|
||||
try:
|
||||
cached = await router_cache.async_get_cache(key=_cache_key(router_model_name, tier))
|
||||
state: Final = _state(cached)
|
||||
except Exception as exc: # noqa: BLE001 -- optional observations must never make a request unroutable
|
||||
verbose_router_logger.warning("Auto setup response-latency cache read failed: %s", exc)
|
||||
return ordered[0]
|
||||
|
||||
counts: Final[Mapping[str, int]] = MappingProxyType(
|
||||
{model: len(state.samples_by_model.get(model, ())) for model in ordered}
|
||||
)
|
||||
least_observed: Final = min(counts.values())
|
||||
if least_observed < _MIN_SAMPLES_PER_MODEL:
|
||||
return next(model for model in ordered if counts[model] == least_observed)
|
||||
return _best_observed_model(state, candidates, ordered, objective)
|
||||
|
||||
|
||||
def _seconds(value: object) -> float | None:
|
||||
if isinstance(value, datetime):
|
||||
return value.timestamp()
|
||||
if isinstance(value, timedelta):
|
||||
return value.total_seconds()
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def _field(value: object, key: str) -> object | None:
|
||||
if isinstance(value, Mapping):
|
||||
try:
|
||||
return _OBJECT_MAPPING_ADAPTER.validate_python(value).get(key)
|
||||
except ValidationError:
|
||||
return None
|
||||
try:
|
||||
return _OBJECT_ADAPTER.validate_python(object.__getattribute__(value, key))
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
def _visible_output_tokens(response_obj: object) -> int | None:
|
||||
usage = _field(response_obj, "usage")
|
||||
if usage is None:
|
||||
return None
|
||||
total = _field(usage, "completion_tokens")
|
||||
if total is None:
|
||||
total = _field(usage, "output_tokens")
|
||||
if isinstance(total, bool) or not isinstance(total, int | float) or total <= 0:
|
||||
return None
|
||||
details = _field(usage, "completion_tokens_details")
|
||||
if details is None:
|
||||
details = _field(usage, "output_tokens_details")
|
||||
reasoning = _field(details, "reasoning_tokens") if details is not None else None
|
||||
reasoning_tokens = float(reasoning) if isinstance(reasoning, int | float) and not isinstance(reasoning, bool) else 0
|
||||
return max(1, round(float(total) - reasoning_tokens))
|
||||
|
||||
|
||||
def response_latency_sample(
|
||||
kwargs: Mapping[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> tuple[ResponseLatencySample, int] | None:
|
||||
start: Final = _seconds(start_time)
|
||||
end: Final = _seconds(end_time)
|
||||
visible_tokens: Final = _visible_output_tokens(response_obj)
|
||||
if start is None or end is None or end <= start or visible_tokens is None:
|
||||
return None
|
||||
total_ms: Final = (end - start) * 1000
|
||||
completion_start: Final = _seconds(kwargs.get("completion_start_time"))
|
||||
ttft_ms = (
|
||||
(completion_start - start) * 1000 if completion_start is not None and start < completion_start < end else 0.0
|
||||
)
|
||||
generation_ms: Final = max(total_ms - ttft_ms, 1e-6)
|
||||
return ResponseLatencySample(
|
||||
ttft_ms=ttft_ms,
|
||||
output_tokens_per_ms=visible_tokens / generation_ms,
|
||||
), visible_tokens
|
||||
|
||||
|
||||
async def record_runtime_response_latency(
|
||||
*,
|
||||
router_cache: ResponseLatencyCache,
|
||||
router_model_name: str,
|
||||
tier: str,
|
||||
routed_model: str,
|
||||
kwargs: Mapping[str, object],
|
||||
response_obj: object,
|
||||
start_time: object,
|
||||
end_time: object,
|
||||
) -> None:
|
||||
measured: Final = response_latency_sample(kwargs, response_obj, start_time, end_time)
|
||||
if measured is None:
|
||||
return
|
||||
sample, visible_tokens = measured
|
||||
key: Final = _cache_key(router_model_name, tier)
|
||||
state: Final = _state(await router_cache.async_get_cache(key=key))
|
||||
samples: Final = (*state.samples_by_model.get(routed_model, ()), sample)[-_MAX_SAMPLES_PER_MODEL:]
|
||||
updated_state: Final = ResponseLatencyState(
|
||||
samples_by_model=MappingProxyType({**state.samples_by_model, routed_model: samples}),
|
||||
visible_output_tokens=(*state.visible_output_tokens, visible_tokens)[-_MAX_EXPECTED_OUTPUT_SAMPLES:],
|
||||
)
|
||||
await router_cache.async_set_cache(key=key, value=updated_state.model_dump(mode="json"), ttl=_CACHE_TTL_SECONDS)
|
||||
|
|
@ -9,8 +9,11 @@ from typing import Final, Literal, TypeAlias
|
|||
|
||||
from pydantic import BaseModel, Field, computed_field, field_validator, model_validator
|
||||
|
||||
from litellm.router_strategy.complexity_router.auto_setup import AutoSetupQualityLevel
|
||||
from litellm.router_strategy.complexity_router.config import ComplexityRouterConfig
|
||||
from litellm.router_strategy.complexity_router.config import (
|
||||
AutoSetupObjective,
|
||||
AutoSetupQualityLevel,
|
||||
ComplexityRouterConfig,
|
||||
)
|
||||
from litellm.types.utils import StandardLoggingRoutingDecision
|
||||
|
||||
DEFAULT_ROUTING_TEST_ROUTER_NAME: Final[str] = "auto_router_routing_test"
|
||||
|
|
@ -49,13 +52,14 @@ class AutoRouterExcludedModelGroup(BaseModel):
|
|||
"""A caller-visible reason Auto setup did not use an available model group."""
|
||||
|
||||
model_group: str
|
||||
reason: Literal["no_benchmark_match", "mixed_model_group"]
|
||||
reason: Literal["no_benchmark_match", "mixed_model_group", "pricing_unavailable"]
|
||||
|
||||
|
||||
class AutoRouterRecommendationResponse(BaseModel):
|
||||
"""An editable complexity-router config generated from the caller's usable model groups."""
|
||||
|
||||
quality_level: AutoSetupQualityLevel
|
||||
optimize_for: AutoSetupObjective
|
||||
snapshot_id: str
|
||||
snapshot_generated_at: str
|
||||
available_model_group_count: int = Field(ge=0)
|
||||
|
|
|
|||
|
|
@ -2938,6 +2938,16 @@ class StandardLoggingRoutingDecision(TypedDict, total=False):
|
|||
savings_baseline_model: str
|
||||
savings_baseline_deployment_id: str
|
||||
tier_litellm_params: Mapping[str, object] # writable-ok: Pydantic warns on ReadOnly TypedDict fields
|
||||
auto_setup_snapshot_id: str # writable-ok: Pydantic warns on ReadOnly TypedDict fields
|
||||
auto_setup_quality_level: Literal[ # writable-ok: Pydantic rejects ReadOnly fields
|
||||
"economy", "balanced", "high", "max"
|
||||
]
|
||||
auto_setup_optimize_for: Literal[ # writable-ok: Pydantic rejects ReadOnly fields
|
||||
"cost", "task_completion_speed", "balanced"
|
||||
]
|
||||
auto_setup_selection_mode: Literal[ # writable-ok: Pydantic rejects ReadOnly fields
|
||||
"snapshot_ranked", "runtime_response_latency"
|
||||
]
|
||||
|
||||
|
||||
# Fields whose values quote the caller's prompt. Dropped when an operator turns message
|
||||
|
|
@ -2966,6 +2976,10 @@ DERIVED_ROUTING_DECISION_FIELDS: Final[frozenset[str]] = frozenset(
|
|||
"savings_baseline_model",
|
||||
"savings_baseline_deployment_id",
|
||||
"tier_litellm_params",
|
||||
"auto_setup_snapshot_id",
|
||||
"auto_setup_quality_level",
|
||||
"auto_setup_optimize_for",
|
||||
"auto_setup_selection_mode",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ async def test_recommendation_uses_caller_groups_and_returns_an_editable_complex
|
|||
response = await get_auto_router_recommendation(
|
||||
user_api_key_dict=ADMIN,
|
||||
quality_level="economy",
|
||||
optimize_for="task_completion_speed",
|
||||
)
|
||||
|
||||
authorize.assert_awaited_once_with(user_api_key_dict=ADMIN, team_id=None)
|
||||
|
|
@ -144,8 +145,11 @@ async def test_recommendation_uses_caller_groups_and_returns_an_editable_complex
|
|||
assert response.excluded_model_groups == ()
|
||||
assert "unavailable-group" not in response.matched_model_groups
|
||||
assert response.complexity_router_config.classifier_type == "heuristic_v2"
|
||||
assert all(len(models) == 1 for models in response.complexity_router_config.tiers.values())
|
||||
assert "auto_setup" not in response.complexity_router_config.model_dump(mode="json", exclude_none=True)
|
||||
assert response.complexity_router_config.auto_setup is not None
|
||||
assert response.complexity_router_config.auto_setup.optimize_for == "task_completion_speed"
|
||||
assert response.complexity_router_config.auto_setup.tier_policies["SIMPLE"].selection_mode == (
|
||||
"runtime_response_latency"
|
||||
)
|
||||
|
||||
|
||||
def test_auto_setup_pricing_honors_custom_token_rates_and_rejects_non_token_charges() -> None:
|
||||
|
|
@ -194,7 +198,7 @@ def test_auto_setup_pricing_resolves_an_explicit_base_model_before_a_deployment_
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recommendation_excludes_mixed_and_unmatched_but_keeps_unpriced_groups(
|
||||
async def test_recommendation_excludes_mixed_unmatched_and_unpriced_groups(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import litellm.proxy.management_endpoints.auto_router_endpoints as endpoint_module
|
||||
|
|
@ -222,14 +226,15 @@ async def test_recommendation_excludes_mixed_and_unmatched_but_keeps_unpriced_gr
|
|||
response = await get_auto_router_recommendation(
|
||||
user_api_key_dict=ADMIN,
|
||||
quality_level="max",
|
||||
optimize_for="cost",
|
||||
)
|
||||
|
||||
assert response.available_model_group_count == 4
|
||||
assert set(response.matched_model_groups) <= {"safe", "unpriced"}
|
||||
assert response.matched_model_groups
|
||||
assert response.matched_model_groups == ("safe",)
|
||||
assert {(item.model_group, item.reason) for item in response.excluded_model_groups} == {
|
||||
("mixed", "mixed_model_group"),
|
||||
("unknown", "no_benchmark_match"),
|
||||
("unpriced", "pricing_unavailable"),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,32 @@
|
|||
import math
|
||||
import random
|
||||
from unittest.mock import MagicMock, patch
|
||||
from itertools import product
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm import Router
|
||||
from litellm.router_strategy.complexity_router.auto_setup import (
|
||||
AutoRouterSnapshot,
|
||||
AutoSetupDeployment,
|
||||
AutoSetupDeploymentPricing,
|
||||
_quality_completion_cost, # pyright: ignore[reportPrivateUsage] -- regression test for deployment-aware setup ranking
|
||||
_speed_completion_cost, # pyright: ignore[reportPrivateUsage] -- regression test for multi-call trajectory pricing
|
||||
analyze_auto_setup_inventory,
|
||||
build_auto_setup_config,
|
||||
load_auto_router_snapshot,
|
||||
)
|
||||
from litellm.router_strategy.complexity_router.complexity_router import ComplexityRouter
|
||||
from litellm.router_strategy.complexity_router.config import ComplexityTier
|
||||
from litellm.router_strategy.complexity_router.config import (
|
||||
AutoSetupCandidate,
|
||||
ComplexityRouterConfig,
|
||||
ComplexityTier,
|
||||
)
|
||||
from litellm.router_strategy.complexity_router.response_latency import (
|
||||
record_runtime_response_latency,
|
||||
response_latency_sample,
|
||||
select_runtime_response_model,
|
||||
)
|
||||
|
||||
|
||||
def _available_refs(*model_names: str) -> dict[str, tuple[AutoSetupDeployment, ...]]:
|
||||
|
|
@ -51,38 +62,44 @@ def test_snapshot_is_verified_and_quality_gate_is_recomputed_for_available_model
|
|||
snapshot=snapshot,
|
||||
available_model_deployments=available,
|
||||
quality_level="economy",
|
||||
optimize_for="cost",
|
||||
)
|
||||
maximum = build_auto_setup_config(
|
||||
snapshot=snapshot,
|
||||
available_model_deployments=available,
|
||||
quality_level="max",
|
||||
optimize_for="cost",
|
||||
)
|
||||
nano_only = build_auto_setup_config(
|
||||
snapshot=snapshot,
|
||||
available_model_deployments=_available_refs("gpt-5.4-nano"),
|
||||
quality_level="max",
|
||||
optimize_for="cost",
|
||||
)
|
||||
|
||||
assert snapshot.artifact_sha256 == "e191b5f7225e4da7a178eb5fe8780bcea24fb0166be23e2069338c6fe5522cd5"
|
||||
assert economy.tiers["SIMPLE"] == ["gpt-5.4-nano"]
|
||||
assert snapshot.artifact_sha256 == "bab7488212d40d96c5e43f80893c5ca2961d0e25160cb1aebbef36a2422b8a2b"
|
||||
assert economy.tiers["SIMPLE"] == ["gpt-5.4-nano", "gpt-5.6-sol"]
|
||||
assert maximum.tiers["SIMPLE"] == ["gpt-5.6-sol"]
|
||||
assert nano_only.tiers["SIMPLE"] == ["gpt-5.4-nano"]
|
||||
|
||||
|
||||
def test_auto_setup_emits_a_plain_static_complexity_router_config() -> None:
|
||||
def test_speed_only_changes_auto_generated_selection_policy() -> None:
|
||||
config = build_auto_setup_config(
|
||||
snapshot=load_auto_router_snapshot(),
|
||||
available_model_deployments=_available_refs("gpt-5.6-sol", "gpt-5.4-nano"),
|
||||
quality_level="economy",
|
||||
optimize_for="task_completion_speed",
|
||||
)
|
||||
|
||||
payload = config.model_dump(mode="json", exclude_none=True)
|
||||
assert payload["classifier_type"] == "heuristic_v2"
|
||||
assert "auto_setup" not in payload
|
||||
assert all(len(models) == 1 for models in config.tiers.values())
|
||||
assert config.auto_setup is not None
|
||||
assert config.auto_setup.tier_policies["SIMPLE"].selection_mode == "runtime_response_latency"
|
||||
assert config.auto_setup.tier_policies["MEDIUM"].selection_mode == "runtime_response_latency"
|
||||
assert config.auto_setup.tier_policies["COMPLEX"].selection_mode == "snapshot_ranked"
|
||||
assert config.auto_setup.tier_policies["REASONING"].selection_mode == "snapshot_ranked"
|
||||
assert ComplexityRouterConfig(tiers=config.tiers).auto_setup is None
|
||||
|
||||
|
||||
def test_every_snapshot_alias_builds_all_four_quality_setups_even_without_price() -> None:
|
||||
def test_every_priced_snapshot_alias_builds_all_twelve_auto_setups() -> None:
|
||||
snapshot = load_auto_router_snapshot()
|
||||
aliases = tuple(
|
||||
alias
|
||||
|
|
@ -101,17 +118,25 @@ def test_every_snapshot_alias_builds_all_four_quality_setups_even_without_price(
|
|||
)
|
||||
|
||||
for alias, deployment in priced.items():
|
||||
eligible, excluded = analyze_auto_setup_inventory(snapshot, {alias: (deployment,)})
|
||||
assert eligible == (alias,)
|
||||
assert excluded == ()
|
||||
for quality_level in ("economy", "balanced", "high", "max"):
|
||||
if deployment.pricing is None:
|
||||
eligible, excluded = analyze_auto_setup_inventory(snapshot, {alias: (deployment,)})
|
||||
assert not eligible
|
||||
assert [(item.model_group, item.reason) for item in excluded] == [(alias, "pricing_unavailable")]
|
||||
continue
|
||||
for quality_level, objective in product(
|
||||
("economy", "balanced", "high", "max"),
|
||||
("cost", "task_completion_speed", "balanced"),
|
||||
):
|
||||
config = build_auto_setup_config(
|
||||
snapshot=snapshot,
|
||||
available_model_deployments={alias: (deployment,)},
|
||||
quality_level=quality_level,
|
||||
optimize_for=objective,
|
||||
)
|
||||
assert set(config.tiers) == {"SIMPLE", "MEDIUM", "COMPLEX", "REASONING"}
|
||||
assert all(models == [alias] for models in config.tiers.values())
|
||||
assert config.auto_setup is not None
|
||||
assert all(policy.candidates[0].model_name == alias for policy in config.auto_setup.tier_policies.values())
|
||||
|
||||
|
||||
def test_full_priced_inventory_builds_deterministically_for_every_choice() -> None:
|
||||
|
|
@ -130,24 +155,33 @@ def test_full_priced_inventory_builds_deterministically_for_every_choice() -> No
|
|||
}
|
||||
assert len(deployments) == 279
|
||||
|
||||
selected_tiers: dict[str, dict[str, set[str]]] = {}
|
||||
for quality_level in ("economy", "balanced", "high", "max"):
|
||||
cost_tiers: dict[str, dict[str, set[str]]] = {}
|
||||
for quality_level, objective in product(
|
||||
("economy", "balanced", "high", "max"),
|
||||
("cost", "task_completion_speed", "balanced"),
|
||||
):
|
||||
config = build_auto_setup_config(
|
||||
snapshot=snapshot,
|
||||
available_model_deployments=deployments,
|
||||
quality_level=quality_level,
|
||||
optimize_for=objective,
|
||||
)
|
||||
repeated = build_auto_setup_config(
|
||||
snapshot=snapshot,
|
||||
available_model_deployments=dict(reversed(tuple(deployments.items()))),
|
||||
quality_level=quality_level,
|
||||
optimize_for=objective,
|
||||
)
|
||||
assert config.model_dump(mode="json") == repeated.model_dump(mode="json")
|
||||
assert all(len(models) == 1 for models in config.tiers.values())
|
||||
assert all(models and len(models) == len(set(models)) for models in config.tiers.values())
|
||||
assert all(set(models).issubset(deployments) for models in config.tiers.values())
|
||||
selected_tiers[quality_level] = {tier: set(models) for tier, models in config.tiers.items()}
|
||||
if objective == "cost":
|
||||
cost_tiers[quality_level] = {tier: set(models) for tier, models in config.tiers.items()}
|
||||
|
||||
assert set(selected_tiers) == {"economy", "balanced", "high", "max"}
|
||||
for tier in ("SIMPLE", "MEDIUM", "COMPLEX", "REASONING"):
|
||||
assert cost_tiers["max"][tier] <= cost_tiers["high"][tier]
|
||||
assert cost_tiers["high"][tier] <= cost_tiers["balanced"][tier]
|
||||
assert cost_tiers["balanced"][tier] <= cost_tiers["economy"][tier]
|
||||
|
||||
|
||||
def test_seeded_mixed_inventories_never_select_unknown_or_unsafe_groups() -> None:
|
||||
|
|
@ -179,11 +213,15 @@ def test_seeded_mixed_inventories_never_select_unknown_or_unsafe_groups() -> Non
|
|||
for index in range(rng.randint(0, 10))
|
||||
}
|
||||
)
|
||||
for quality_level in ("economy", "balanced", "high", "max"):
|
||||
for quality_level, objective in product(
|
||||
("economy", "balanced", "high", "max"),
|
||||
("cost", "task_completion_speed", "balanced"),
|
||||
):
|
||||
config = build_auto_setup_config(
|
||||
snapshot=snapshot,
|
||||
available_model_deployments=inventory,
|
||||
quality_level=quality_level,
|
||||
optimize_for=objective,
|
||||
)
|
||||
selected = {model_name for models in config.tiers.values() for model_name in models}
|
||||
assert selected
|
||||
|
|
@ -220,11 +258,12 @@ def test_mixed_or_partially_unmatched_model_groups_fail_closed() -> None:
|
|||
snapshot=snapshot,
|
||||
available_model_deployments=inventory,
|
||||
quality_level="economy",
|
||||
optimize_for="cost",
|
||||
)
|
||||
assert all(models == ["safe"] for models in config.tiers.values())
|
||||
|
||||
|
||||
def test_missing_cost_falls_back_to_quality_instead_of_blocking_setup() -> None:
|
||||
def test_schema_valid_missing_cost_and_zero_completion_evidence_fail_closed() -> None:
|
||||
raw = load_auto_router_snapshot().model_dump(mode="json")
|
||||
quality_profiles = raw["models"]["gpt-5.4-nano-xhigh"]["quality_by_complexity"].values()
|
||||
for profile in quality_profiles:
|
||||
|
|
@ -235,29 +274,8 @@ def test_missing_cost_falls_back_to_quality_instead_of_blocking_setup() -> None:
|
|||
snapshot = AutoRouterSnapshot.model_validate(raw)
|
||||
eligible, excluded = analyze_auto_setup_inventory(snapshot, _available_refs("gpt-5.4-nano"))
|
||||
|
||||
assert eligible == ("gpt-5.4-nano",)
|
||||
assert excluded == ()
|
||||
generated = build_auto_setup_config(
|
||||
snapshot=snapshot,
|
||||
available_model_deployments=_available_refs("gpt-5.4-nano"),
|
||||
quality_level="max",
|
||||
)
|
||||
assert all(models == ["gpt-5.4-nano"] for models in generated.tiers.values())
|
||||
|
||||
|
||||
def test_multiple_unpriced_models_choose_the_highest_quality_inside_the_gate() -> None:
|
||||
inventory = {
|
||||
model_name: (AutoSetupDeployment(model_refs=(model_name,), pricing=None),)
|
||||
for model_name in ("gpt-5.6-sol", "gpt-5.4-nano")
|
||||
}
|
||||
|
||||
generated = build_auto_setup_config(
|
||||
snapshot=load_auto_router_snapshot(),
|
||||
available_model_deployments=inventory,
|
||||
quality_level="max",
|
||||
)
|
||||
|
||||
assert generated.tiers["SIMPLE"] == ["gpt-5.6-sol"]
|
||||
assert eligible == ()
|
||||
assert [(item.model_group, item.reason) for item in excluded] == [("gpt-5.4-nano", "pricing_unavailable")]
|
||||
|
||||
|
||||
def test_provider_repricing_and_multi_deployment_groups_are_conservative() -> None:
|
||||
|
|
@ -270,23 +288,42 @@ def test_provider_repricing_and_multi_deployment_groups_are_conservative() -> No
|
|||
model_refs=("gpt-5.6-sol",),
|
||||
pricing=AutoSetupDeploymentPricing(input_cost_per_token=0.00001, output_cost_per_token=0.00002),
|
||||
)
|
||||
profile = snapshot.models["gpt-5.6-sol-max"].quality_by_complexity["trivial"]
|
||||
cheap_cost = _quality_completion_cost(profile, (cheap,))
|
||||
expensive_cost = _quality_completion_cost(profile, (expensive,))
|
||||
mixed_cost = _quality_completion_cost(profile, (cheap, expensive))
|
||||
|
||||
assert cheap_cost is not None
|
||||
assert expensive_cost is not None
|
||||
assert mixed_cost is not None
|
||||
assert math.isclose(expensive_cost, cheap_cost * 10)
|
||||
assert math.isclose(mixed_cost, expensive_cost)
|
||||
|
||||
config = build_auto_setup_config(
|
||||
snapshot=snapshot,
|
||||
available_model_deployments={"cheap": (cheap,), "expensive": (expensive,), "mixed-price": (cheap, expensive)},
|
||||
quality_level="economy",
|
||||
optimize_for="cost",
|
||||
)
|
||||
assert config.tiers["SIMPLE"] == ["cheap"]
|
||||
assert config.auto_setup is not None
|
||||
candidates = config.auto_setup.tier_policies["SIMPLE"].candidates
|
||||
by_name = {candidate.model_name: candidate.cost_per_completed_task_usd for candidate in candidates}
|
||||
assert tuple(candidate.model_name for candidate in candidates) == ("cheap", "expensive", "mixed-price")
|
||||
assert math.isclose(by_name["expensive"], by_name["cheap"] * 10)
|
||||
assert math.isclose(by_name["mixed-price"], by_name["expensive"])
|
||||
|
||||
|
||||
def test_multi_call_trajectory_cost_does_not_apply_single_request_context_thresholds() -> None:
|
||||
snapshot = load_auto_router_snapshot()
|
||||
speed = snapshot.models["gpt-5.6-sol-max"].task_completion_speed_by_complexity["complex"][0]
|
||||
pricing = AutoSetupDeploymentPricing(
|
||||
input_cost_per_token=0.000001,
|
||||
output_cost_per_token=0.000002,
|
||||
cache_read_input_token_cost=0.0000001,
|
||||
input_cost_per_token_above_200k_tokens=0.001,
|
||||
output_cost_per_token_above_200k_tokens=0.002,
|
||||
cache_read_input_token_cost_above_200k_tokens=0.0001,
|
||||
)
|
||||
deployment = AutoSetupDeployment(model_refs=("gpt-5.6-sol",), pricing=pricing)
|
||||
|
||||
actual = _speed_completion_cost(speed, (deployment,))
|
||||
expected_attempt_cost = (
|
||||
(speed.mean_uncached_input_tokens or 0) * pricing.input_cost_per_token
|
||||
+ (speed.mean_cache_read_input_tokens or 0) * (pricing.cache_read_input_token_cost or 0)
|
||||
+ (speed.mean_output_tokens or 0) * pricing.output_cost_per_token
|
||||
)
|
||||
|
||||
assert actual is not None
|
||||
assert math.isclose(actual, expected_attempt_cost / speed.success_probability)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -315,27 +352,164 @@ def test_snapshot_semantic_validation_rejects_inconsistent_artifacts(mutation: s
|
|||
AutoRouterSnapshot.model_validate(raw)
|
||||
|
||||
|
||||
def test_auto_setup_result_has_no_runtime_only_configuration() -> None:
|
||||
def test_auto_setup_cannot_change_adaptive_or_custom_tier_routers() -> None:
|
||||
generated = build_auto_setup_config(
|
||||
snapshot=load_auto_router_snapshot(),
|
||||
available_model_deployments=_available_refs("gpt-5.6-sol"),
|
||||
quality_level="max",
|
||||
optimize_for="cost",
|
||||
)
|
||||
payload = generated.model_dump(mode="json", exclude_none=True)
|
||||
payload = generated.model_dump(mode="json")
|
||||
|
||||
assert "auto_setup" not in payload
|
||||
assert payload["adaptive"] is False
|
||||
assert payload["classifier_type"] == "heuristic_v2"
|
||||
with pytest.raises(ValidationError, match="auto_setup and adaptive"):
|
||||
ComplexityRouterConfig.model_validate({**payload, "adaptive": True})
|
||||
with pytest.raises(ValidationError, match="built-in four-tier"):
|
||||
ComplexityRouterConfig.model_validate(
|
||||
{
|
||||
**payload,
|
||||
"tier_definitions": [
|
||||
{"name": "fast", "description": "Fast"},
|
||||
{"name": "strong", "description": "Strong"},
|
||||
],
|
||||
"tiers": {"fast": ["gpt-5.6-sol"], "strong": ["gpt-5.6-sol"]},
|
||||
"fallback_tier": "fast",
|
||||
"classifier_type": "llm",
|
||||
"classifier_llm_config": {"model": "gpt-5.6-sol"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _Cache:
|
||||
def __init__(self) -> None:
|
||||
self.values: dict[str, object] = {}
|
||||
|
||||
async def async_get_cache(
|
||||
self,
|
||||
key: str,
|
||||
parent_otel_span: object | None = None,
|
||||
local_only: bool = False,
|
||||
**kwargs: object,
|
||||
) -> object:
|
||||
return self.values.get(key)
|
||||
|
||||
async def async_set_cache(
|
||||
self,
|
||||
key: str,
|
||||
value: object,
|
||||
local_only: bool = False,
|
||||
**kwargs: object,
|
||||
) -> None:
|
||||
self.values[key] = value
|
||||
|
||||
|
||||
def _candidate(model_name: str, cost: float) -> AutoSetupCandidate:
|
||||
return AutoSetupCandidate(
|
||||
model_name=model_name,
|
||||
benchmark_model_id=f"benchmark-{model_name}",
|
||||
quality_lower_bound=0.95,
|
||||
cost_per_completed_task_usd=cost,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generated_single_model_tiers_use_the_existing_router_selection_path() -> None:
|
||||
async def test_easy_task_speed_learns_equal_output_response_time() -> None:
|
||||
cache = _Cache()
|
||||
candidates = (_candidate("cheap-slow", 0.01), _candidate("fast", 0.04))
|
||||
|
||||
assert (
|
||||
await select_runtime_response_model(
|
||||
router_cache=cache,
|
||||
router_model_name="auto",
|
||||
tier="SIMPLE",
|
||||
candidates=candidates,
|
||||
available_models=("cheap-slow", "fast"),
|
||||
cold_start_model="cheap-slow",
|
||||
objective="task_completion_speed",
|
||||
)
|
||||
== "cheap-slow"
|
||||
)
|
||||
|
||||
for model, end in (("cheap-slow", 0.30), ("cheap-slow", 0.30), ("fast", 0.15), ("fast", 0.15)):
|
||||
await record_runtime_response_latency(
|
||||
router_cache=cache,
|
||||
router_model_name="auto",
|
||||
tier="SIMPLE",
|
||||
routed_model=model,
|
||||
kwargs={"completion_start_time": 0.10},
|
||||
response_obj={
|
||||
"usage": {
|
||||
"completion_tokens": 20,
|
||||
"completion_tokens_details": {"reasoning_tokens": 10},
|
||||
}
|
||||
},
|
||||
start_time=0.0,
|
||||
end_time=end,
|
||||
)
|
||||
|
||||
measured = response_latency_sample(
|
||||
{"completion_start_time": 0.10},
|
||||
{"usage": {"completion_tokens": 20, "completion_tokens_details": {"reasoning_tokens": 10}}},
|
||||
0.0,
|
||||
0.30,
|
||||
)
|
||||
assert measured is not None and measured[1] == 10
|
||||
assert (
|
||||
await select_runtime_response_model(
|
||||
router_cache=cache,
|
||||
router_model_name="auto",
|
||||
tier="SIMPLE",
|
||||
candidates=candidates,
|
||||
available_models=("cheap-slow", "fast"),
|
||||
cold_start_model="cheap-slow",
|
||||
objective="task_completion_speed",
|
||||
)
|
||||
== "fast"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_easy_task_balanced_uses_both_live_response_time_and_expected_cost() -> None:
|
||||
cache = _Cache()
|
||||
candidates = (
|
||||
_candidate("fast-expensive", 1.0),
|
||||
_candidate("middle", 0.02),
|
||||
_candidate("cheap-slow", 0.01),
|
||||
)
|
||||
for model, end in (("fast-expensive", 0.10), ("middle", 0.12), ("cheap-slow", 0.30)):
|
||||
for _ in range(2):
|
||||
await record_runtime_response_latency(
|
||||
router_cache=cache,
|
||||
router_model_name="auto",
|
||||
tier="SIMPLE",
|
||||
routed_model=model,
|
||||
kwargs={},
|
||||
response_obj={"usage": {"completion_tokens": 10}},
|
||||
start_time=0.0,
|
||||
end_time=end,
|
||||
)
|
||||
|
||||
common = {
|
||||
"router_cache": cache,
|
||||
"router_model_name": "auto",
|
||||
"tier": "SIMPLE",
|
||||
"candidates": candidates,
|
||||
"available_models": tuple(candidate.model_name for candidate in candidates),
|
||||
"cold_start_model": "cheap-slow",
|
||||
}
|
||||
assert await select_runtime_response_model(**common, objective="task_completion_speed") == "fast-expensive"
|
||||
assert await select_runtime_response_model(**common, objective="balanced") == "middle"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_policy_is_deterministic_but_manual_pool_keeps_random_selection() -> None:
|
||||
generated = build_auto_setup_config(
|
||||
snapshot=load_auto_router_snapshot(),
|
||||
available_model_deployments=_available_refs("gpt-5.6-sol", "gpt-5.4-nano"),
|
||||
quality_level="economy",
|
||||
optimize_for="cost",
|
||||
)
|
||||
router_instance = MagicMock()
|
||||
router_instance.cache = AsyncMock()
|
||||
automatic = ComplexityRouter(
|
||||
model_name="auto",
|
||||
litellm_router_instance=router_instance,
|
||||
|
|
@ -348,8 +522,71 @@ async def test_generated_single_model_tiers_use_the_existing_router_selection_pa
|
|||
)
|
||||
|
||||
assert await automatic._pick_model_for_tier(ComplexityTier.SIMPLE, None, None, {}) == "gpt-5.4-nano"
|
||||
assert (
|
||||
await automatic._pick_model_for_tier(
|
||||
ComplexityTier.SIMPLE,
|
||||
None,
|
||||
None,
|
||||
{},
|
||||
allowed_models=("gpt-5.6-sol",),
|
||||
)
|
||||
== "gpt-5.6-sol"
|
||||
)
|
||||
with patch( # test-quality-ok: pinning random choice is required to prove manual routers retain their existing selection path
|
||||
"litellm.router_strategy.complexity_router.complexity_router.random.choice", return_value="second"
|
||||
) as pick:
|
||||
assert await manual._pick_model_for_tier(ComplexityTier.SIMPLE, None, None, {}) == "second"
|
||||
pick.assert_called_once_with(["first", "second"])
|
||||
pick.assert_called_once_with(("first", "second"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_latency_telemetry_is_recorded_only_for_an_auto_runtime_policy() -> None:
|
||||
# test-quality-ok: this callback's observable contract is whether telemetry is emitted
|
||||
generated = build_auto_setup_config(
|
||||
snapshot=load_auto_router_snapshot(),
|
||||
available_model_deployments=_available_refs("gpt-5.6-sol", "gpt-5.4-nano"),
|
||||
quality_level="economy",
|
||||
optimize_for="task_completion_speed",
|
||||
)
|
||||
router = Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "smart",
|
||||
"litellm_params": {
|
||||
"model": "auto_router/complexity_router",
|
||||
"complexity_router_config": generated.model_dump(mode="json"),
|
||||
"complexity_router_default_model": "gpt-5.6-sol",
|
||||
},
|
||||
},
|
||||
{"model_name": "gpt-5.6-sol", "litellm_params": {"model": "gpt-5.6-sol"}},
|
||||
{"model_name": "gpt-5.4-nano", "litellm_params": {"model": "gpt-5.4-nano"}},
|
||||
]
|
||||
)
|
||||
metadata = {
|
||||
"routing_decision": {
|
||||
"router_model_name": "smart",
|
||||
"tier": "SIMPLE",
|
||||
"routed_model": "gpt-5.4-nano",
|
||||
"auto_setup_selection_mode": "runtime_response_latency",
|
||||
}
|
||||
}
|
||||
|
||||
with patch( # test-quality-ok: patching the telemetry boundary avoids mutating the shared runtime latency cache
|
||||
"litellm.router_strategy.complexity_router.response_latency.record_runtime_response_latency",
|
||||
new_callable=AsyncMock,
|
||||
) as recorder:
|
||||
await router._record_auto_setup_response_latency(
|
||||
kwargs={"litellm_params": {"metadata": metadata}},
|
||||
completion_response={"usage": {"completion_tokens": 1}},
|
||||
start_time=0.0,
|
||||
end_time=0.1,
|
||||
)
|
||||
metadata["routing_decision"]["auto_setup_selection_mode"] = "snapshot_ranked"
|
||||
await router._record_auto_setup_response_latency(
|
||||
kwargs={"litellm_params": {"metadata": metadata}},
|
||||
completion_response={"usage": {"completion_tokens": 1}},
|
||||
start_time=0.0,
|
||||
end_time=0.1,
|
||||
)
|
||||
|
||||
recorder.assert_awaited_once()
|
||||
|
|
|
|||
|
|
@ -12703,7 +12703,7 @@ async def test_prompt_management_factory_marks_injection_for_every_deployment(mo
|
|||
assert bucket["litellm_gateway_injected_cache"] == ""
|
||||
|
||||
|
||||
def test_get_configured_mode_reads_deployment_model_info():
|
||||
def test_get_configured_mode_reads_audio_speech_deployment_model_info():
|
||||
router = Router(
|
||||
model_list=[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { getAutoRouterRecommendation, type AutoRouterRecommendationResponse } from "@/components/networking";
|
||||
import type { AutoSetupQualityLevel } from "@/components/add_model/build_complexity_router_config";
|
||||
import type { AutoSetupObjective, AutoSetupQualityLevel } from "@/components/add_model/build_complexity_router_config";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
|
||||
const recommendationKeys = createQueryKeys("autoRouterRecommendation");
|
||||
|
|
@ -9,6 +9,7 @@ const recommendationKeys = createQueryKeys("autoRouterRecommendation");
|
|||
interface UseAutoRouterRecommendationParams {
|
||||
accessToken: string;
|
||||
qualityLevel: AutoSetupQualityLevel;
|
||||
optimizeFor: AutoSetupObjective;
|
||||
teamId?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
|
@ -16,15 +17,16 @@ interface UseAutoRouterRecommendationParams {
|
|||
export const useAutoRouterRecommendation = ({
|
||||
accessToken,
|
||||
qualityLevel,
|
||||
optimizeFor,
|
||||
teamId,
|
||||
enabled,
|
||||
}: UseAutoRouterRecommendationParams) => {
|
||||
const filters = { qualityLevel, ...(teamId ? { teamId } : {}) };
|
||||
const filters = { qualityLevel, optimizeFor, ...(teamId ? { teamId } : {}) };
|
||||
const queryOptions = {
|
||||
queryKey: recommendationKeys.list({
|
||||
filters,
|
||||
}),
|
||||
queryFn: () => getAutoRouterRecommendation(accessToken, qualityLevel, teamId),
|
||||
queryFn: () => getAutoRouterRecommendation(accessToken, qualityLevel, optimizeFor, teamId),
|
||||
enabled: enabled && Boolean(accessToken),
|
||||
staleTime: 30 * 1000,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import type { AutoRouterRecommendationResponse } from "@/components/networking";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import type { AutoSetupQualityLevel } from "./build_complexity_router_config";
|
||||
import type { AutoSetupObjective, AutoSetupQualityLevel } from "./build_complexity_router_config";
|
||||
|
||||
const OBJECTIVE_DESCRIPTION: Record<AutoSetupObjective, string> = {
|
||||
cost: "Spend less while staying within your selected quality level",
|
||||
task_completion_speed: "Finish requests sooner, not just generate tokens faster",
|
||||
balanced: "Balance expected cost with time to completion",
|
||||
};
|
||||
|
||||
const QUALITY_OPTIONS = [
|
||||
{ value: "economy", label: "Economy" },
|
||||
|
|
@ -11,23 +17,26 @@ const QUALITY_OPTIONS = [
|
|||
{ value: "max", label: "Max" },
|
||||
];
|
||||
|
||||
const QUALITY_DESCRIPTION: Record<AutoSetupQualityLevel, string> = {
|
||||
economy: "Allows models up to 15 points below the smartest available option",
|
||||
balanced: "Keeps models within 7 points of the smartest available option",
|
||||
high: "Keeps models within 3 points of the smartest available option",
|
||||
max: "Keeps models within 1 point of the smartest available option",
|
||||
};
|
||||
const OBJECTIVE_OPTIONS = [
|
||||
{ value: "cost", label: "Cost" },
|
||||
{ value: "task_completion_speed", label: "Task completion speed" },
|
||||
{ value: "balanced", label: "Balanced" },
|
||||
];
|
||||
|
||||
interface AutoSetupControlsProps {
|
||||
qualityLevel: AutoSetupQualityLevel;
|
||||
optimizeFor: AutoSetupObjective;
|
||||
onQualityLevelChange: (value: AutoSetupQualityLevel) => void;
|
||||
onOptimizeForChange: (value: AutoSetupObjective) => void;
|
||||
recommendation?: AutoRouterRecommendationResponse;
|
||||
recommendationEnabled: boolean;
|
||||
recommendationPending: boolean;
|
||||
recommendationFailed: boolean;
|
||||
recommendationError: unknown;
|
||||
requiresTeamScope: boolean;
|
||||
hasAutoPolicy: boolean;
|
||||
onRetry: () => void;
|
||||
onRegenerate: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -40,16 +49,31 @@ const readyMessage = (recommendation: AutoRouterRecommendationResponse): string
|
|||
return `Uses ${count} of ${total} available model${total === 1 ? "" : "s"} across four complexity levels`;
|
||||
};
|
||||
|
||||
const recommendationDescription = (optimizeFor: AutoSetupObjective): string => {
|
||||
switch (optimizeFor) {
|
||||
case "cost":
|
||||
return "Favors the lowest expected cost while keeping each request within your selected quality level";
|
||||
case "task_completion_speed":
|
||||
return "Easy requests use live response speed; difficult requests use benchmark completion time";
|
||||
case "balanced":
|
||||
return "Balances expected cost with response speed and benchmark completion time";
|
||||
}
|
||||
};
|
||||
|
||||
export default function AutoSetupControls({
|
||||
qualityLevel,
|
||||
optimizeFor,
|
||||
onQualityLevelChange,
|
||||
onOptimizeForChange,
|
||||
recommendation,
|
||||
recommendationEnabled,
|
||||
recommendationPending,
|
||||
recommendationFailed,
|
||||
recommendationError,
|
||||
requiresTeamScope,
|
||||
hasAutoPolicy,
|
||||
onRetry,
|
||||
onRegenerate,
|
||||
onBack,
|
||||
}: AutoSetupControlsProps) {
|
||||
const excludedCount = recommendation?.excluded_model_groups.length ?? 0;
|
||||
|
|
@ -62,7 +86,7 @@ export default function AutoSetupControls({
|
|||
<div className="text-xs text-muted-foreground">
|
||||
Choose the outcome you want. LiteLLM will build the model tiers for you
|
||||
</div>
|
||||
{recommendation && excludedCount > 0 && (
|
||||
{hasAutoPolicy && excludedCount > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{excludedCount} model{excludedCount === 1 ? " was" : "s were"} left out because Auto setup could not
|
||||
compare {excludedCount === 1 ? "it" : "them"} safely
|
||||
|
|
@ -73,7 +97,7 @@ export default function AutoSetupControls({
|
|||
Back
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">Quality level</label>
|
||||
<Select
|
||||
|
|
@ -92,13 +116,27 @@ export default function AutoSetupControls({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{QUALITY_DESCRIPTION[qualityLevel]}. Auto picks the lowest estimated completion cost inside that range, or
|
||||
the smartest option when price is unavailable
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Quality uses uncertainty-adjusted public benchmark scores for each complexity level
|
||||
Higher levels keep each tier closer to the smartest available model
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">Optimize for</label>
|
||||
<Select
|
||||
items={OBJECTIVE_OPTIONS}
|
||||
value={optimizeFor}
|
||||
onValueChange={(value: AutoSetupObjective | null) => value && onOptimizeForChange(value)}
|
||||
>
|
||||
<SelectTrigger data-testid="auto-objective-selector" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cost">Cost</SelectItem>
|
||||
<SelectItem value="task_completion_speed">Task completion speed</SelectItem>
|
||||
<SelectItem value="balanced">Balanced</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{OBJECTIVE_DESCRIPTION[optimizeFor]}</div>
|
||||
</div>
|
||||
</div>
|
||||
{recommendationPending && recommendation === undefined && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
|
|
@ -118,11 +156,19 @@ export default function AutoSetupControls({
|
|||
)}
|
||||
{recommendation && (
|
||||
<div className="space-y-2 rounded-lg border border-border bg-muted/40 p-3" data-testid="auto-setup-summary">
|
||||
<div className="text-sm font-medium text-foreground">Recommended setup</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{readyMessage(recommendation)}. Each tier is the lowest-cost model with comparable pricing that meets your
|
||||
selected quality level
|
||||
<div className={`text-sm font-medium ${hasAutoPolicy ? "text-foreground" : "text-warning"}`}>
|
||||
{hasAutoPolicy ? "Recommended setup" : "Configuration edited"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{hasAutoPolicy
|
||||
? `${readyMessage(recommendation)}. ${recommendationDescription(optimizeFor)}`
|
||||
: "Your model changes are now treated as a manual configuration"}
|
||||
</div>
|
||||
{!hasAutoPolicy && (
|
||||
<button type="button" className="text-xs text-primary underline" onClick={onRegenerate}>
|
||||
Restore recommended setup
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { fireEvent, renderWithProviders, screen, within } from "../../../tests/t
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { vi } from "vitest";
|
||||
import ComplexityRouterConfig, { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
import { detachAutoSetupAfterPoolEdit } from "./auto_setup_policy";
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults",
|
||||
async () => await import("../../../tests/mocks/complexityScorerDefaults"),
|
||||
|
|
@ -44,6 +45,30 @@ const baseProps = {
|
|||
};
|
||||
|
||||
describe("ComplexityRouterConfig", () => {
|
||||
it("keeps Auto policy for classifier edits and detaches it for model-pool edits", () => {
|
||||
const automatic: ComplexityRouterConfigValue = {
|
||||
...defaultValue,
|
||||
auto_setup: {
|
||||
schema_version: "1",
|
||||
snapshot_id: "snapshot-test",
|
||||
snapshot_sha256: "a".repeat(64),
|
||||
quality_level: "max",
|
||||
optimize_for: "cost",
|
||||
tier_policies: {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(detachAutoSetupAfterPoolEdit(automatic, { ...automatic, classifier_type: "heuristic_v2" }).auto_setup).toBe(
|
||||
automatic.auto_setup,
|
||||
);
|
||||
expect(
|
||||
detachAutoSetupAfterPoolEdit(automatic, {
|
||||
...automatic,
|
||||
tiers: { ...automatic.tiers, SIMPLE: ["gpt-4"] },
|
||||
}).auto_setup,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
|
||||
expect(screen.getByText("Complexity Tier Configuration")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import EscalationKeywords from "./EscalationKeywords";
|
|||
import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules";
|
||||
import SemanticKeywordMatching from "./SemanticKeywordMatching";
|
||||
import { type DimensionWeights, type TierBoundaries, type TokenThresholds } from "./heuristic_scoring_knobs";
|
||||
import type { AutoSetupConfigPayload } from "./build_complexity_router_config";
|
||||
|
||||
export type { DimensionWeights, TierBoundaries, TokenThresholds };
|
||||
export type { CustomTierSet, TierRow } from "./tier_rows";
|
||||
|
|
@ -446,6 +447,8 @@ export interface ComplexityRouterConfigValue {
|
|||
* edit round-trip.
|
||||
*/
|
||||
tier_model_params?: TierModelParamsByTier;
|
||||
/** Present only when the zero-setup Auto flow generated this editable config. */
|
||||
auto_setup?: AutoSetupConfigPayload;
|
||||
}
|
||||
|
||||
/** Session affinity wins where a hand-authored config sets both, matching the backend's own `or`. */
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { vi } from "vitest";
|
|||
import AddAutoRouterTab from "./add_auto_router_tab";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
|
||||
import { getMissingTiersError } from "./build_complexity_router_config";
|
||||
import { getMissingTiersError, type AutoSetupConfigPayload } from "./build_complexity_router_config";
|
||||
import { getSubmitBlockedReason } from "./add_auto_router_tab";
|
||||
import { buildModelAvailability } from "@/lib/autorouter_presets";
|
||||
import { testAutoRouterRouting } from "../networking";
|
||||
|
|
@ -169,17 +169,17 @@ describe("AddAutoRouterTab", () => {
|
|||
expect(useAutoRouterRecommendation).toHaveBeenCalledWith(expect.objectContaining({ enabled: false }));
|
||||
});
|
||||
|
||||
it("shows the quality choice after Configure automatically is selected", async () => {
|
||||
it("shows the two outcome choices after Configure automatically is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await user.click(screen.getByTestId("configure-automatically-button"));
|
||||
|
||||
expect(screen.getByTestId("auto-quality-selector")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("auto-objective-selector")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("auto-objective-selector")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("template-selector")).not.toBeInTheDocument();
|
||||
expect(useAutoRouterRecommendation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ qualityLevel: "balanced", enabled: true }),
|
||||
expect.objectContaining({ qualityLevel: "balanced", optimizeFor: "balanced", enabled: true }),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -195,12 +195,38 @@ describe("AddAutoRouterTab", () => {
|
|||
expect(screen.queryByTestId("auto-quality-selector")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits the editable static config generated for the caller's available model groups", async () => {
|
||||
it("submits the editable policy generated for the caller's available model groups", async () => {
|
||||
const user = userEvent.setup();
|
||||
const autoSetup: AutoSetupConfigPayload = {
|
||||
schema_version: "1" as const,
|
||||
snapshot_id: "snapshot-test",
|
||||
snapshot_sha256: "a".repeat(64),
|
||||
quality_level: "max" as const,
|
||||
optimize_for: "task_completion_speed" as const,
|
||||
tier_policies: Object.fromEntries(
|
||||
["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"].map((tier) => [
|
||||
tier,
|
||||
{
|
||||
selection_mode: tier === "SIMPLE" || tier === "MEDIUM" ? "runtime_response_latency" : "snapshot_ranked",
|
||||
candidates: [
|
||||
{
|
||||
model_name: "smart-group",
|
||||
benchmark_model_id: "benchmark-smart",
|
||||
quality_lower_bound: 0.98,
|
||||
cost_per_completed_task_usd: 0.04,
|
||||
},
|
||||
],
|
||||
...(tier === "SIMPLE" || tier === "MEDIUM" ? { cold_start_model: "smart-group" } : {}),
|
||||
evidence_status: "test",
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
mockFetchAvailableModels.mockResolvedValue([{ model_group: "smart-group", mode: "chat" }]);
|
||||
const recommendationQuery = {
|
||||
data: {
|
||||
quality_level: "max",
|
||||
optimize_for: "task_completion_speed",
|
||||
snapshot_id: "snapshot-test",
|
||||
snapshot_generated_at: "2026-09-03T00:00:00Z",
|
||||
available_model_group_count: 2,
|
||||
|
|
@ -219,6 +245,7 @@ describe("AddAutoRouterTab", () => {
|
|||
deployment_affinity: false,
|
||||
modality_routing: false,
|
||||
modality_pin_override: false,
|
||||
auto_setup: autoSetup,
|
||||
},
|
||||
},
|
||||
isPending: false,
|
||||
|
|
@ -236,17 +263,9 @@ describe("AddAutoRouterTab", () => {
|
|||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
const submitted = vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config;
|
||||
expect(submitted).toMatchObject({
|
||||
tiers: {
|
||||
SIMPLE: ["smart-group"],
|
||||
MEDIUM: ["smart-group"],
|
||||
COMPLEX: ["smart-group"],
|
||||
REASONING: ["smart-group"],
|
||||
},
|
||||
classifier_type: "heuristic_v2",
|
||||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config).toMatchObject({
|
||||
auto_setup: autoSetup,
|
||||
});
|
||||
expect(submitted).not.toHaveProperty("auto_setup");
|
||||
});
|
||||
|
||||
// Detailed Configuration starts collapsed after manual setup is selected; a caller opts into the
|
||||
|
|
|
|||
|
|
@ -30,11 +30,12 @@ import ComplexityRouterConfig, {
|
|||
DEFAULT_DEPLOYMENT_AFFINITY,
|
||||
DEFAULT_TIER_DISTANCE_PENALTY,
|
||||
} from "./ComplexityRouterConfig";
|
||||
import { getRecommendationBlockedReason, type SetupMode } from "./auto_setup_policy";
|
||||
import { detachAutoSetupAfterPoolEdit, getRecommendationBlockedReason, type SetupMode } from "./auto_setup_policy";
|
||||
import { KeywordTierRule } from "./KeywordTierRules";
|
||||
import { DEFAULT_ESCALATION_KEYWORDS } from "./EscalationKeywords";
|
||||
import { DEFAULT_MATCH_THRESHOLD } from "./SemanticKeywordMatching";
|
||||
import {
|
||||
AutoSetupObjective,
|
||||
AutoSetupQualityLevel,
|
||||
BuildComplexityRouterConfigParams,
|
||||
buildComplexityRouterConfig,
|
||||
|
|
@ -187,6 +188,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
|
||||
const [setupMode, setSetupMode] = useState<SetupMode>("manual");
|
||||
const [qualityLevel, setQualityLevel] = useState<AutoSetupQualityLevel>("balanced");
|
||||
const [optimizeFor, setOptimizeFor] = useState<AutoSetupObjective>("balanced");
|
||||
const appliedRecommendationKey = useRef<string | null>(null);
|
||||
|
||||
const [complexityRouterConfig, setComplexityRouterConfig] = useState<ComplexityRouterConfigValue>({
|
||||
|
|
@ -278,6 +280,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
const recommendationParams = {
|
||||
accessToken,
|
||||
qualityLevel,
|
||||
optimizeFor,
|
||||
teamId: requiresTeamScope ? watchedTeamId : undefined,
|
||||
enabled: recommendationEnabled,
|
||||
};
|
||||
|
|
@ -354,6 +357,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
const key = [
|
||||
recommendation.snapshot_id,
|
||||
qualityLevel,
|
||||
optimizeFor,
|
||||
watchedTeamId ?? "",
|
||||
...recommendation.matched_model_groups,
|
||||
].join(":");
|
||||
|
|
@ -362,7 +366,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
setSelectedPreset(undefined);
|
||||
applyPrefill(buildPresetPrefill(recommendation.complexity_router_config, availability));
|
||||
setDetailsExpanded(false);
|
||||
}, [recommendation, setupMode, qualityLevel, watchedTeamId, availability, applyPrefill]);
|
||||
}, [recommendation, setupMode, qualityLevel, optimizeFor, watchedTeamId, availability, applyPrefill]);
|
||||
|
||||
const handlePresetChange = (presetKey: string | undefined) => {
|
||||
if (!presetKey || presetKey === "custom") {
|
||||
|
|
@ -401,6 +405,11 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
hasRecommendation: recommendation !== undefined,
|
||||
};
|
||||
const recommendationBlockedReason = getRecommendationBlockedReason(recommendationStatus);
|
||||
const regenerateAutoSetup = () => {
|
||||
if (!recommendation) return;
|
||||
applyPrefill(buildPresetPrefill(recommendation.complexity_router_config, availability));
|
||||
setDetailsExpanded(false);
|
||||
};
|
||||
const submitBlockedReason =
|
||||
recommendationBlockedReason ??
|
||||
getSubmitBlockedReason(
|
||||
|
|
@ -449,6 +458,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
reasoningOverrideMinScore: complexityRouterConfig.reasoning_override_min_score,
|
||||
enableContextWindowEscalation: complexityRouterConfig.enable_context_window_escalation,
|
||||
contextWindowEscalationBuffer: complexityRouterConfig.context_window_escalation_buffer,
|
||||
autoSetup: complexityRouterConfig.auto_setup,
|
||||
};
|
||||
|
||||
const submitRecommendedRouter = async (name: string) => {
|
||||
|
|
@ -588,14 +598,18 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
{setupMode === "auto" ? (
|
||||
<AutoSetupControls
|
||||
qualityLevel={qualityLevel}
|
||||
optimizeFor={optimizeFor}
|
||||
onQualityLevelChange={setQualityLevel}
|
||||
onOptimizeForChange={setOptimizeFor}
|
||||
recommendation={recommendation}
|
||||
recommendationEnabled={recommendationEnabled}
|
||||
recommendationPending={recommendationPending}
|
||||
recommendationFailed={recommendationFailed}
|
||||
recommendationError={recommendationError}
|
||||
requiresTeamScope={requiresTeamScope}
|
||||
hasAutoPolicy={Boolean(complexityRouterConfig.auto_setup)}
|
||||
onRetry={() => void refetchRecommendation()}
|
||||
onRegenerate={regenerateAutoSetup}
|
||||
onBack={returnToManualSetup}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -700,7 +714,9 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
onEditingTiersChange={setEditingTiers}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
onChange={(next) =>
|
||||
setComplexityRouterConfig((previous) => detachAutoSetupAfterPoolEdit(previous, next))
|
||||
}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
export type SetupMode = "auto" | "manual";
|
||||
|
||||
export interface RecommendationStatus {
|
||||
|
|
@ -23,3 +25,18 @@ export const getRecommendationBlockedReason = ({
|
|||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const autoSetupPoolShape = (value: ComplexityRouterConfigValue): string =>
|
||||
JSON.stringify({
|
||||
tiers: value.tiers,
|
||||
customTierSet: value.custom_tier_set,
|
||||
tierModelParams: value.tier_model_params,
|
||||
});
|
||||
|
||||
export const detachAutoSetupAfterPoolEdit = (
|
||||
previous: ComplexityRouterConfigValue,
|
||||
next: ComplexityRouterConfigValue,
|
||||
): ComplexityRouterConfigValue =>
|
||||
previous.auto_setup && autoSetupPoolShape(previous) !== autoSetupPoolShape(next)
|
||||
? { ...next, auto_setup: undefined }
|
||||
: next;
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ export interface BuildComplexityRouterConfigParams {
|
|||
tierModelParams?: TierModelParamsByTier;
|
||||
enableContextWindowEscalation?: boolean;
|
||||
contextWindowEscalationBuffer?: number;
|
||||
autoSetup?: AutoSetupConfigPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -196,9 +197,34 @@ export interface ComplexityRouterConfigPayload {
|
|||
enable_context_window_escalation?: boolean;
|
||||
context_window_escalation_buffer?: number;
|
||||
tier_model_configs?: Record<string, { model_name: string; litellm_params: TierModelParams }[]>;
|
||||
auto_setup?: AutoSetupConfigPayload;
|
||||
}
|
||||
|
||||
export type AutoSetupQualityLevel = "economy" | "balanced" | "high" | "max";
|
||||
export type AutoSetupObjective = "cost" | "task_completion_speed" | "balanced";
|
||||
|
||||
export interface AutoSetupCandidatePayload {
|
||||
model_name: string;
|
||||
benchmark_model_id: string;
|
||||
quality_lower_bound: number;
|
||||
cost_per_completed_task_usd: number;
|
||||
}
|
||||
|
||||
export interface AutoSetupTierPolicyPayload {
|
||||
selection_mode: "snapshot_ranked" | "runtime_response_latency";
|
||||
candidates: AutoSetupCandidatePayload[];
|
||||
cold_start_model?: string | null;
|
||||
evidence_status: string;
|
||||
}
|
||||
|
||||
export interface AutoSetupConfigPayload {
|
||||
schema_version: "1";
|
||||
snapshot_id: string;
|
||||
snapshot_sha256: string;
|
||||
quality_level: AutoSetupQualityLevel;
|
||||
optimize_for: AutoSetupObjective;
|
||||
tier_policies: Record<string, AutoSetupTierPolicyPayload>;
|
||||
}
|
||||
|
||||
export const serializeTierLabels = (tierLabels: ComplexityTierLabels | undefined): ComplexityTierLabels | undefined => {
|
||||
const renamed = TIER_KEYS.map((tier) => [tier, tierLabels?.[tier]?.trim() ?? ""] as const).filter(
|
||||
|
|
@ -458,6 +484,7 @@ export const buildComplexityRouterConfig = ({
|
|||
tierModelParams,
|
||||
enableContextWindowEscalation,
|
||||
contextWindowEscalationBuffer,
|
||||
autoSetup,
|
||||
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
|
||||
const serializedTierModelConfigs = customTierSet
|
||||
? serializeTierModelConfigs(
|
||||
|
|
@ -524,6 +551,7 @@ export const buildComplexityRouterConfig = ({
|
|||
...(contextWindowEscalationBuffer !== undefined && {
|
||||
context_window_escalation_buffer: contextWindowEscalationBuffer,
|
||||
}),
|
||||
...(autoSetup && { auto_setup: autoSetup }),
|
||||
...scorerKnobs,
|
||||
};
|
||||
if (!customTierSet) return payload;
|
||||
|
|
|
|||
|
|
@ -553,6 +553,7 @@ describe("managed keys survive an untouched open-and-save", () => {
|
|||
"fallback_tier",
|
||||
"classification_prompt",
|
||||
"hybrid_boundary_margin",
|
||||
"auto_setup",
|
||||
]);
|
||||
|
||||
it("carries every managed key a built-in router can hold through hydrate then save", () => {
|
||||
|
|
@ -565,6 +566,21 @@ describe("managed keys survive an untouched open-and-save", () => {
|
|||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it("round-trips the stored Auto policy while its model pools stay unchanged", () => {
|
||||
const autoSetup = {
|
||||
schema_version: "1" as const,
|
||||
snapshot_id: "snapshot-test",
|
||||
snapshot_sha256: "a".repeat(64),
|
||||
quality_level: "max" as const,
|
||||
optimize_for: "cost" as const,
|
||||
tier_policies: {},
|
||||
};
|
||||
const stored = { ...STORED, classifier_type: "heuristic" as const, auto_setup: autoSetup };
|
||||
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
|
||||
|
||||
expect(buildUpdatedComplexityRouterConfig(stored, hydrated).auto_setup).toEqual(autoSetup);
|
||||
});
|
||||
|
||||
it("drops a stored local-scorer threshold when the operator converts the router to custom tiers", () => {
|
||||
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
|
||||
const converted = {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
hydratePlanModeMinTier,
|
||||
hydrateTierLabels,
|
||||
dryRunRejection,
|
||||
type AutoSetupConfigPayload,
|
||||
} from "../add_model/build_complexity_router_config";
|
||||
import { KeywordTierRule } from "../add_model/KeywordTierRules";
|
||||
import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching";
|
||||
|
|
@ -60,6 +61,7 @@ import ComplexityRouterConfig, {
|
|||
DEFAULT_DEPLOYMENT_AFFINITY,
|
||||
DEFAULT_TIER_DISTANCE_PENALTY,
|
||||
} from "../add_model/ComplexityRouterConfig";
|
||||
import { detachAutoSetupAfterPoolEdit } from "../add_model/auto_setup_policy";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -114,6 +116,7 @@ export interface StoredComplexityRouterConfig {
|
|||
return_raw_model_name?: boolean;
|
||||
enable_context_window_escalation?: unknown;
|
||||
context_window_escalation_buffer?: unknown;
|
||||
auto_setup?: AutoSetupConfigPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -202,6 +205,7 @@ export const hydrateComplexityRouterConfig = (
|
|||
typeof parsedConfig.context_window_escalation_buffer === "number"
|
||||
? parsedConfig.context_window_escalation_buffer
|
||||
: undefined,
|
||||
auto_setup: parsedConfig.auto_setup,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -238,6 +242,7 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
|
|||
"reasoning_override_min_score",
|
||||
"enable_context_window_escalation",
|
||||
"context_window_escalation_buffer",
|
||||
"auto_setup",
|
||||
]);
|
||||
|
||||
// Managed only when the caller passes the corresponding state. A caller that does not render
|
||||
|
|
@ -343,6 +348,7 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
tierModelParams: value.tier_model_params,
|
||||
enableContextWindowEscalation: value.enable_context_window_escalation,
|
||||
contextWindowEscalationBuffer: value.context_window_escalation_buffer,
|
||||
autoSetup: value.auto_setup,
|
||||
};
|
||||
const built = buildComplexityRouterConfig(builderParams);
|
||||
|
||||
|
|
@ -723,7 +729,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={(config) => {
|
||||
setComplexityRouterConfig(config);
|
||||
setComplexityRouterConfig((previous) => detachAutoSetupAfterPoolEdit(previous, config));
|
||||
}}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,11 @@ import type {
|
|||
CoordinationRedisTestResponse,
|
||||
} from "@/app/(dashboard)/caching/_components/coordination_redis_settings/types";
|
||||
import { MCP_TOOLS_PREVIEW_FORBIDDEN_MESSAGE } from "./mcp_tools/constants";
|
||||
import type { AutoSetupQualityLevel, ComplexityRouterConfigPayload } from "./add_model/build_complexity_router_config";
|
||||
import type {
|
||||
AutoSetupObjective,
|
||||
AutoSetupQualityLevel,
|
||||
ComplexityRouterConfigPayload,
|
||||
} from "./add_model/build_complexity_router_config";
|
||||
import type { AutoRouterPresetsResponse } from "@/lib/autorouter_presets";
|
||||
import type { VectorStoreIndex } from "@/app/(dashboard)/vector-stores/_components/IndexesTab";
|
||||
import type { RoutingDecision } from "./view_logs/LogDetailsDrawer/RoutingDecisionCard";
|
||||
|
|
@ -2418,13 +2422,14 @@ export interface AutoRouterRoutingTestRequest {
|
|||
|
||||
export interface AutoRouterRecommendationResponse {
|
||||
quality_level: AutoSetupQualityLevel;
|
||||
optimize_for: AutoSetupObjective;
|
||||
snapshot_id: string;
|
||||
snapshot_generated_at: string;
|
||||
available_model_group_count: number;
|
||||
matched_model_groups: string[];
|
||||
excluded_model_groups: Array<{
|
||||
model_group: string;
|
||||
reason: "no_benchmark_match" | "mixed_model_group";
|
||||
reason: "no_benchmark_match" | "mixed_model_group" | "pricing_unavailable";
|
||||
}>;
|
||||
complexity_router_config: ComplexityRouterConfigPayload;
|
||||
}
|
||||
|
|
@ -2432,12 +2437,14 @@ export interface AutoRouterRecommendationResponse {
|
|||
export const getAutoRouterRecommendation = async (
|
||||
accessToken: string,
|
||||
qualityLevel: AutoSetupQualityLevel,
|
||||
optimizeFor: AutoSetupObjective,
|
||||
teamId?: string,
|
||||
): Promise<AutoRouterRecommendationResponse> =>
|
||||
await apiClient.get<AutoRouterRecommendationResponse>("/auto_router/recommendation", {
|
||||
accessToken,
|
||||
query: {
|
||||
quality_level: qualityLevel,
|
||||
optimize_for: optimizeFor,
|
||||
...(teamId ? { team_id: teamId } : {}),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -294,6 +294,7 @@ export const buildPresetPrefill = (
|
|||
return_raw_model_name: config.return_raw_model_name,
|
||||
enable_context_window_escalation: config.enable_context_window_escalation,
|
||||
context_window_escalation_buffer: config.context_window_escalation_buffer,
|
||||
auto_setup: config.auto_setup,
|
||||
},
|
||||
customTechnicalKeywords: config.custom_technical_keywords ?? [],
|
||||
keywordTierRules: hydrateKeywordTierRules(config.keyword_tier_rules ?? []),
|
||||
|
|
|
|||
89
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
89
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -23449,7 +23449,7 @@ export interface components {
|
|||
* Reason
|
||||
* @enum {string}
|
||||
*/
|
||||
reason: "no_benchmark_match" | "mixed_model_group";
|
||||
reason: "no_benchmark_match" | "mixed_model_group" | "pricing_unavailable";
|
||||
};
|
||||
/**
|
||||
* AutoRouterPresetConfig
|
||||
|
|
@ -23509,6 +23509,11 @@ export interface components {
|
|||
excluded_model_groups: components["schemas"]["AutoRouterExcludedModelGroup"][];
|
||||
/** Matched Model Groups */
|
||||
matched_model_groups: string[];
|
||||
/**
|
||||
* Optimize For
|
||||
* @enum {string}
|
||||
*/
|
||||
optimize_for: "cost" | "task_completion_speed" | "balanced";
|
||||
/**
|
||||
* Quality Level
|
||||
* @enum {string}
|
||||
|
|
@ -23592,6 +23597,67 @@ export interface components {
|
|||
/** @description The decision record this request would have written to its log row */
|
||||
routing_decision: components["schemas"]["StandardLoggingRoutingDecision"];
|
||||
};
|
||||
/**
|
||||
* AutoSetupCandidate
|
||||
* @description One benchmark-backed model group in an Auto-generated tier policy.
|
||||
*/
|
||||
AutoSetupCandidate: {
|
||||
/** Benchmark Model Id */
|
||||
benchmark_model_id: string;
|
||||
/** Cost Per Completed Task Usd */
|
||||
cost_per_completed_task_usd: number;
|
||||
/** Model Name */
|
||||
model_name: string;
|
||||
/** Quality Lower Bound */
|
||||
quality_lower_bound: number;
|
||||
};
|
||||
/**
|
||||
* AutoSetupConfig
|
||||
* @description Provenance and selection policy stamped only by zero-setup Auto creation.
|
||||
*/
|
||||
AutoSetupConfig: {
|
||||
/**
|
||||
* Optimize For
|
||||
* @enum {string}
|
||||
*/
|
||||
optimize_for: "cost" | "task_completion_speed" | "balanced";
|
||||
/**
|
||||
* Quality Level
|
||||
* @enum {string}
|
||||
*/
|
||||
quality_level: "economy" | "balanced" | "high" | "max";
|
||||
/**
|
||||
* Schema Version
|
||||
* @default 1
|
||||
* @constant
|
||||
*/
|
||||
schema_version: "1";
|
||||
/** Snapshot Id */
|
||||
snapshot_id: string;
|
||||
/** Snapshot Sha256 */
|
||||
snapshot_sha256: string;
|
||||
/** Tier Policies */
|
||||
tier_policies: {
|
||||
[key: string]: components["schemas"]["AutoSetupTierPolicy"];
|
||||
};
|
||||
};
|
||||
/**
|
||||
* AutoSetupTierPolicy
|
||||
* @description How an Auto-generated tier chooses from its quality-admitted model groups.
|
||||
*/
|
||||
AutoSetupTierPolicy: {
|
||||
/** Candidates */
|
||||
candidates: components["schemas"]["AutoSetupCandidate"][];
|
||||
/** Cold Start Model */
|
||||
cold_start_model?: string | null;
|
||||
/** Evidence Status */
|
||||
evidence_status: string;
|
||||
/**
|
||||
* Selection Mode
|
||||
* @enum {string}
|
||||
*/
|
||||
selection_mode: "snapshot_ranked" | "runtime_response_latency";
|
||||
};
|
||||
/** BaseLitellmParams */
|
||||
BaseLitellmParams: {
|
||||
/**
|
||||
|
|
@ -34656,6 +34722,8 @@ export interface components {
|
|||
adaptive_eligible: "all" | "classified_tier";
|
||||
/** @description Quality vs cost weights for adaptive selection (used when adaptive=True) */
|
||||
adaptive_weights?: components["schemas"]["AdaptiveRouterWeights"];
|
||||
/** @description Benchmark snapshot and per-tier selection policy generated by the zero-setup Auto flow. Absent on manually configured routers, whose pool selection behavior is unchanged */
|
||||
auto_setup?: components["schemas"]["AutoSetupConfig"] | null;
|
||||
/**
|
||||
* Classification Mode
|
||||
* @description When to run the complexity classifier. 'every_request' (the default) classifies every inference request, including the tool-result continuation turns of an agentic loop. 'user_turn' classifies only requests whose newest turn is a new human ask and replays the session's held routing decision on continuation turns, which cuts classifier spend and eliminates mid-loop model switches. Continuations with no held decision to replay (no resolvable session_id, expired pin, fresh restart) still classify. Unlike session_affinity, a new human ask always re-classifies, so a session can still move tiers between asks. Suppressed when plugins are configured, for the same reason session_affinity is: a replayed decision would bypass the plugin pipeline.
|
||||
|
|
@ -36012,6 +36080,23 @@ export interface components {
|
|||
* @description Per-request provenance for a pre-routing strategy (auto-router) decision.
|
||||
*/
|
||||
StandardLoggingRoutingDecision: {
|
||||
/**
|
||||
* Auto Setup Optimize For
|
||||
* @enum {string}
|
||||
*/
|
||||
auto_setup_optimize_for?: "cost" | "task_completion_speed" | "balanced";
|
||||
/**
|
||||
* Auto Setup Quality Level
|
||||
* @enum {string}
|
||||
*/
|
||||
auto_setup_quality_level?: "economy" | "balanced" | "high" | "max";
|
||||
/**
|
||||
* Auto Setup Selection Mode
|
||||
* @enum {string}
|
||||
*/
|
||||
auto_setup_selection_mode?: "snapshot_ranked" | "runtime_response_latency";
|
||||
/** Auto Setup Snapshot Id */
|
||||
auto_setup_snapshot_id?: string;
|
||||
/**
|
||||
* Cause
|
||||
* @enum {string}
|
||||
|
|
@ -41215,6 +41300,8 @@ export interface operations {
|
|||
query?: {
|
||||
/** @description How close every admitted model must be to the best available quality score */
|
||||
quality_level?: "economy" | "balanced" | "high" | "max";
|
||||
/** @description What to minimize after the selected quality gate is applied */
|
||||
optimize_for?: "cost" | "task_completion_speed" | "balanced";
|
||||
/** @description Team whose model access the recommendation must use */
|
||||
team_id?: string | null;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue