Revert "refactor(router): make auto setup quality-only and static"

This reverts commit bc24f70391.
This commit is contained in:
moe-berri 2026-09-03 17:50:28 -07:00
parent c6a742baca
commit 9c7b85f3dc
26 changed files with 5077 additions and 171 deletions

View file

@ -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),

View file

@ -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

View file

@ -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

View file

@ -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"),
}
)

View file

@ -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:

View file

@ -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.

View 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)

View file

@ -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)

View file

@ -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",
}
)

View file

@ -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"),
}

View file

@ -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()

View file

@ -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=[
{

View file

@ -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,
};

View file

@ -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>

View file

@ -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();

View file

@ -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`. */

View file

@ -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

View file

@ -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}

View file

@ -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;

View file

@ -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;

View file

@ -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 = {

View file

@ -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}

View file

@ -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 } : {}),
},
});

View file

@ -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 ?? []),

View file

@ -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;
};