feat(auto-router): support classifier reasoning effort (#39372)

* feat(auto-router): support classifier reasoning effort

* fix(auto-router): harden classifier reasoning effort

* fix(ui): satisfy classifier config lint limits

* refactor(auto-router): simplify classifier effort support

* fix(auto-router): clear frontend-lint and type-discipline gates, trim LOC

---------

Co-authored-by: Tin Chi Lo <tin@berri.ai>
This commit is contained in:
moe-berri 2026-09-03 08:59:04 -07:00 committed by GitHub
parent 34d4f7f8ae
commit 4990f06acc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 774 additions and 68 deletions

View file

@ -50,6 +50,7 @@ from litellm.constants import (
DEFAULT_HEALTH_CHECK_INTERVAL,
DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER,
DEFAULT_MAX_LRU_CACHE_SIZE,
INTERNAL_CALL_ORIGIN_METADATA_KEY,
RUNTIME_UPDATABLE_ROUTER_SETTINGS,
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
)
@ -231,6 +232,7 @@ from litellm.types.router import (
)
from litellm.types.services import ServiceTypes
from litellm.types.utils import (
AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
PROMPT_QUOTING_ROUTING_DECISION_FIELDS,
CustomPricingLiteLLMParams,
GenericBudgetConfigType,
@ -2168,6 +2170,76 @@ class Router:
verbose_router_logger.debug("Error occurred while printing deployment - %s", e)
raise e
@staticmethod
def _deployment_params_with_request_reasoning_override(
deployment_params: Mapping[str, object], request_kwargs: Mapping[str, object]
) -> dict[str, object]: # mutable-ok: litellm's request pipeline consumes a mutable kwargs mapping
"""Return deployment params whose equivalent effort controls cannot outrank a request override.
Providers expose the same setting through several native carriers. A request-level
``reasoning_effort`` is the portable override, so a deployment's ``thinking`` or nested
``*.effort`` must not remain beside it and either win or trigger a conflicting-params 400.
Every changed mapping is copied so the Router's shared deployment config stays immutable.
"""
sanitized: Final = dict(deployment_params) # mutable-ok: request-local copy protects shared Router state
if request_kwargs.get("reasoning_effort") is None:
return sanitized
sanitized.pop("thinking", None)
Router._pop_effort_from_nested_carrier(sanitized, "output_config")
Router._pop_effort_from_nested_carrier(sanitized, "reasoning")
extra_body: Final = sanitized.get("extra_body")
if isinstance(extra_body, Mapping):
sanitized_extra_body: Final = dict(extra_body) # mutable-ok: request-local nested copy
sanitized_extra_body.pop("reasoning_effort", None)
sanitized_extra_body.pop("thinking", None)
Router._pop_effort_from_nested_carrier(sanitized_extra_body, "output_config")
Router._pop_effort_from_nested_carrier(sanitized_extra_body, "reasoning")
if sanitized_extra_body:
sanitized["extra_body"] = sanitized_extra_body
else:
sanitized.pop("extra_body", None)
return sanitized
@staticmethod
def _is_classifier_internal_call(kwargs: Mapping[str, object]) -> bool:
metadata: Final = kwargs.get("metadata")
litellm_metadata: Final = kwargs.get("litellm_metadata")
return any(
isinstance(candidate, Mapping)
and candidate.get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == AUTOROUTER_CLASSIFIER_CALL_ORIGIN
for candidate in (metadata, litellm_metadata)
)
def _drop_unsupported_classifier_reasoning_effort(
self,
deployment: DeploymentTypedDict,
model: str,
kwargs: dict[str, object], # mutable-ok: fallback must update the active request and its log body together
) -> None:
"""Let a classifier fallback without reasoning support remain a usable fallback.
The dashboard only offers explicitly advertised levels, but an existing config can outlive
a model change and fallbacks can target a different group. Unknown capability fails open;
only a provider that explicitly rejects the parameter has it removed.
"""
if kwargs.get("reasoning_effort") is None or not self._is_classifier_internal_call(kwargs):
return
if self._deployment_accepts_param(deployment, model, "reasoning_effort"):
return
verbose_router_logger.warning(
"litellm.router.py: dropping classifier reasoning_effort for model=%s because the selected deployment does not support it",
model,
)
kwargs.pop("reasoning_effort", None)
proxy_server_request: Final = kwargs.get("proxy_server_request")
if not isinstance(proxy_server_request, dict):
return
body: Final = proxy_server_request.get("body")
if isinstance(body, dict):
body.pop("reasoning_effort", None)
### COMPLETION, EMBEDDING, IMG GENERATION FUNCTIONS
def completion(self, model: str, messages: list[dict[str, str]], **kwargs) -> ModelResponse | CustomStreamWrapper:
@ -2203,9 +2275,16 @@ class Router:
specific_deployment=kwargs.pop("specific_deployment", None),
request_kwargs=kwargs,
)
self._drop_unsupported_classifier_reasoning_effort(
deployment=cast(DeploymentTypedDict, deployment), # cast-ok: selection returns a router deployment
model=model,
kwargs=kwargs,
)
# Check for silent model experiment
# Make a local copy of litellm_params to avoid mutating the Router's state
litellm_params: Final = deployment["litellm_params"].copy()
litellm_params: Final = self._deployment_params_with_request_reasoning_override(
deployment["litellm_params"], kwargs
)
silent_model: Final = litellm_params.pop("silent_model", None)
if silent_model is not None:
@ -3216,6 +3295,11 @@ class Router:
specific_deployment=kwargs.pop("specific_deployment", None),
request_kwargs=kwargs,
)
self._drop_unsupported_classifier_reasoning_effort(
deployment=cast(DeploymentTypedDict, deployment), # cast-ok: selection returns a router deployment
model=model,
kwargs=kwargs,
)
_timeout_debug_deployment_dict = deployment
end_time: Final = time.time()
@ -3237,7 +3321,9 @@ class Router:
# Check for silent model experiment
# Make a local copy of litellm_params to avoid mutating the Router's state
litellm_params: Final = deployment["litellm_params"].copy()
litellm_params: Final = self._deployment_params_with_request_reasoning_override(
deployment["litellm_params"], kwargs
)
silent_model: Final = litellm_params.pop("silent_model", None)
if silent_model is not None:
@ -10255,6 +10341,8 @@ class Router:
total_itpm: int | None = None
total_otpm: int | None = None
configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None
reasoning_efforts_initialized = False
reasoning_efforts_unknown = False
model_list: Final = self.get_model_list(model_name=model_group)
if model_list is None:
return None
@ -10441,10 +10529,23 @@ class Router:
if model_info.get("rpm", None) is not None and _deployment_rpm is None:
_deployment_rpm = model_info.get("rpm")
model_group_info.supported_reasoning_efforts = intersect_supported_reasoning_efforts(
model_group_info.supported_reasoning_efforts,
resolve_supported_reasoning_efforts(model_info, deployment_is_mapped=deployment_is_mapped),
deployment_reasoning_efforts = (
resolve_supported_reasoning_efforts( # rebind-ok: recalculated per deployment
model_info, deployment_is_mapped=deployment_is_mapped
)
)
if deployment_reasoning_efforts is None:
reasoning_efforts_unknown = True
model_group_info.supported_reasoning_efforts = None
elif not reasoning_efforts_initialized:
reasoning_efforts_initialized = True
if not reasoning_efforts_unknown:
model_group_info.supported_reasoning_efforts = deployment_reasoning_efforts
elif not reasoning_efforts_unknown:
model_group_info.supported_reasoning_efforts = intersect_supported_reasoning_efforts(
model_group_info.supported_reasoning_efforts,
deployment_reasoning_efforts,
)
if _deployment_tpm is not None:
if total_tpm is None:
@ -12308,10 +12409,12 @@ class Router:
@staticmethod
def _pop_effort_from_nested_carrier(request_kwargs: dict[str, object], carrier: str) -> None:
nested: Final = request_kwargs.get(carrier)
if not isinstance(nested, dict):
if not isinstance(nested, Mapping):
return
nested.pop("effort", None)
if not nested:
sanitized: Final = {key: value for key, value in nested.items() if key != "effort"}
if sanitized:
request_kwargs[carrier] = sanitized # rebind-ok: copy-on-write, so a shared nested carrier is never edited
else:
request_kwargs.pop(carrier, None)
@staticmethod

View file

@ -255,7 +255,8 @@ model_list:
classifier_type: heuristic_first
heuristic_first_max_tier: SIMPLE
classifier_llm_config:
model: gpt-4o-mini
model: gpt-5-mini
reasoning_effort: low
tiers:
SIMPLE: gpt-4o-mini
MEDIUM: gpt-4o
@ -263,6 +264,10 @@ model_list:
REASONING: o1-preview
```
`classifier_llm_config.reasoning_effort` applies only to the internal classifier call. Omit it to
keep the classifier deployment or provider default, or set a supported value such as `none` or
`low` to override that call.
A request short-circuits, meaning it routes on the scorer's own tier with no classifier call, when
two things hold: the scorer landed at or below `heuristic_first_max_tier`, and it produced at least
one signal. Everything else goes to the classifier, which then decides as it normally would.

View file

@ -28,6 +28,7 @@ from pydantic import BaseModel, create_model
from litellm._logging import verbose_router_logger
from litellm.constants import (
EMPTY_MAPPING,
INTERNAL_CALL_ORIGIN_METADATA_KEY,
RETURN_RAW_MODEL_NAME_METADATA_KEY,
SESSION_ID_GENERATED_METADATA_KEY,
)
@ -42,6 +43,7 @@ from litellm.router_strategy.complexity_router.tier_predictor import (
TierSuccessPredictor,
resolve_tier_artifact,
)
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import (
AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
ModelResponse,
@ -1668,20 +1670,27 @@ class ComplexityRouter(CustomLogger):
)
request_metadata = (request_kwargs or {}).get("litellm_metadata") or (request_kwargs or {}).get("metadata")
metadata: Final = forwarded_internal_call_metadata(request_metadata, AUTOROUTER_CLASSIFIER_CALL_ORIGIN)
metadata: Final = { # mutable-ok: SDK metadata kwarg is enriched by the request pipeline
**forwarded_internal_call_metadata(request_metadata, AUTOROUTER_CLASSIFIER_CALL_ORIGIN),
INTERNAL_CALL_ORIGIN_METADATA_KEY: AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
}
turn_off_message_logging: Final = _effective_turn_off_message_logging(request_kwargs)
messages_for_call: Final = [
messages_for_call: Final[list[AllMessageValues]] = [ # mutable-ok: SDK request payload list is built once
{"role": "system", "content": classifier_system_prompt},
{"role": "user", "content": user_payload},
]
response_format: Final = classifier_response_format
classifier_call_params: Mapping[str, str] = EMPTY_MAPPING
if llm_config.reasoning_effort is not None:
classifier_call_params = MappingProxyType({"reasoning_effort": llm_config.reasoning_effort})
proxy_server_request: Final = {
"body": {
"model": llm_config.model,
"messages": messages_for_call,
"response_format": response_format,
**classifier_call_params,
}
}
@ -1693,6 +1702,7 @@ class ComplexityRouter(CustomLogger):
metadata=metadata,
proxy_server_request=proxy_server_request,
turn_off_message_logging=turn_off_message_logging,
**classifier_call_params,
**_parent_session_kwargs(request_kwargs),
)
content: Final = response.choices[0].message.content

View file

@ -12,6 +12,7 @@ from typing import Annotated, Final, Literal
from pydantic import BaseModel, ConfigDict, Field, SkipValidation, field_serializer, field_validator, model_validator
from litellm.types.llms.openai import REASONING_EFFORT
from litellm.types.router import AdaptiveRouterWeights, ClassifierPlugin, RoutingPlugin
from .tier_predictor import TrainedTierArtifact
@ -432,6 +433,13 @@ class ClassifierLLMConfig(BaseModel):
model: str = Field(
description="Model name (from the router's model_list) to call for classification",
)
reasoning_effort: REASONING_EFFORT | None = Field(
default=None,
description=(
"Reasoning effort override for classifier calls. Leave unset to use "
"the classifier deployment or provider default."
),
)
timeout_ms: int = Field(
default=3000,
description="Timeout budget for the classification call, in milliseconds",

View file

@ -1558,6 +1558,14 @@ class TestLLMClassifierConfig:
assert config.classifier_type == "heuristic"
assert config.classifier_llm_config is None
@pytest.mark.parametrize("reasoning_effort", ["", "ultra"])
def test_classifier_reasoning_effort_rejects_unsupported_values(self, reasoning_effort):
with pytest.raises(ValidationError):
ComplexityRouterConfig(
classifier_type="llm",
classifier_llm_config={"model": "haiku-classifier", "reasoning_effort": reasoning_effort},
)
CUSTOM_TIER_LABELS: Dict[str, str] = {
"SIMPLE": "Cheap",
@ -1871,6 +1879,19 @@ class TestLLMClassifier:
call_kwargs = mock_router_instance.acompletion.call_args.kwargs
assert call_kwargs["metadata"] == {**request_metadata, "internal_call_origin": "autorouter_classifier"}
@pytest.mark.asyncio
async def test_aclassify_stamps_internal_origin_without_caller_metadata(
self, llm_complexity_router, mock_router_instance
):
"""Fallback handling must still recognize the classifier when an SDK caller supplied no metadata."""
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "SIMPLE"}'))
await llm_complexity_router.aclassify("hi")
assert mock_router_instance.acompletion.call_args.kwargs["metadata"] == {
"internal_call_origin": "autorouter_classifier"
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"request_kwargs",
@ -1948,6 +1969,33 @@ class TestLLMClassifier:
"REASONING",
]
@pytest.mark.asyncio
@pytest.mark.parametrize("reasoning_effort", [None, "none", "low"], ids=["omitted", "none", "low"])
async def test_classifier_reasoning_effort_reaches_only_classifier_call(
self, mock_router_instance, llm_classifier_config, reasoning_effort
):
classifier_llm_config = {
**llm_classifier_config["classifier_llm_config"],
**({"reasoning_effort": reasoning_effort} if reasoning_effort is not None else {}),
}
router = ComplexityRouter(
model_name="test-complexity-router",
litellm_router_instance=mock_router_instance,
complexity_router_config={**llm_classifier_config, "classifier_llm_config": classifier_llm_config},
)
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "COMPLEX"}'))
await router.aclassify("explain quantum tunneling in depth")
call_kwargs = mock_router_instance.acompletion.call_args.kwargs
body = call_kwargs["proxy_server_request"]["body"]
if reasoning_effort is None:
assert "reasoning_effort" not in call_kwargs
assert "reasoning_effort" not in body
else:
assert call_kwargs["reasoning_effort"] == reasoning_effort
assert body["reasoning_effort"] == reasoning_effort
@pytest.mark.asyncio
async def test_aclassify_propagates_top_level_turn_off_message_logging(
self, llm_complexity_router, mock_router_instance
@ -8735,9 +8783,10 @@ class TestClassificationRubrics:
[
{"model": "haiku-classifier", "system_prompt": "Grade the data sensitivity of the request."},
{"model": "haiku-classifier", "classification_rubric": "chat"},
{"model": "haiku-classifier", "reasoning_effort": "low"},
{"model": "haiku-classifier"},
],
ids=["custom-prompt", "chat-preset", "neither"],
ids=["custom-prompt", "chat-preset", "reasoning-effort", "neither"],
)
def test_config_survives_a_dump_and_rebuild(self, classifier_llm_config):
"""/auto_router/test_routing dumps this config and hands the dict straight back to

View file

@ -35,6 +35,7 @@ from litellm.router import (
_anthropic_stream_should_drop_pre_content_ping,
_is_retriable_anthropic_status,
)
from litellm.types.router import DeploymentTypedDict
def test_update_kwargs_does_not_mutate_defaults_and_merges_metadata():
@ -9783,11 +9784,12 @@ def test_model_group_info_intersects_supported_reasoning_efforts():
assert result.supported_reasoning_efforts == ("minimal", "low", "medium", "high")
def test_model_group_info_reasoning_efforts_ignore_a_deployment_off_the_map():
def test_model_group_info_reasoning_efforts_are_unknown_when_any_deployment_is_off_the_map():
"""The router fills every ModelInfo key, so a deployment absent from the model map arrives with
supports_reasoning None rather than with the key missing. Its synthesized entry carries no mode,
which is what separates it from a mapped non-reasoning model, and nothing being known about it is
no reason to drop the levels the rest of the group agrees on."""
no evidence that the unknown deployment accepts levels its mapped sibling supports. The group
therefore reports unknown instead of advertising a value routing might send to either one."""
router = litellm.Router(
model_list=[
{
@ -9821,7 +9823,7 @@ def test_model_group_info_reasoning_efforts_ignore_a_deployment_off_the_map():
)
assert result is not None
assert result.supported_reasoning_efforts == ("none", "minimal", "low", "medium", "high", "max")
assert result.supported_reasoning_efforts is None
@ -9958,11 +9960,11 @@ def test_model_group_info_survives_a_junk_typed_operator_effort_value():
assert result.supported_reasoning_efforts == ("none", "minimal", "low", "medium", "high")
def test_model_group_info_reasoning_efforts_ignore_a_mode_the_operator_declared():
def test_model_group_info_reasoning_efforts_are_unknown_for_an_operator_declared_mode():
"""A deployment is registered in the cost map under its own id with whatever model_info the
operator wrote, so a mode they set themselves reads back exactly like one the map supplied. Only
a mode the map supplied marks the deployment as known, or an off-map deployment carrying any
mode empties the group it sits in."""
a mode the map supplied marks the deployment as known. An off-map deployment carrying an
operator mode remains unknown and must keep the whole group's level support unknown."""
from litellm.router_utils.reasoning_effort_capability import resolve_supported_reasoning_efforts
mapped_model = "openai/gpt-5.6-sol"
@ -9993,7 +9995,7 @@ def test_model_group_info_reasoning_efforts_ignore_a_mode_the_operator_declared(
)
assert result is not None
assert result.supported_reasoning_efforts == expected
assert result.supported_reasoning_efforts is None
class TestAddDeploymentApiBaseProviderResolution:
@ -12252,6 +12254,120 @@ class TestTierParamsTheTargetAccepts:
assert accepted == {"reasoning_effort": "max"}
class TestRequestReasoningEffortOverride:
def test_drop_effort_from_nested_carrier_preserves_other_nested_values(self):
params: dict[str, object] = {"output_config": {"effort": "high", "format": "json"}}
litellm.Router._pop_effort_from_nested_carrier(params, "output_config")
assert params == {"output_config": {"format": "json"}}
@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"])
def test_is_classifier_internal_call_recognizes_both_metadata_carriers(self, metadata_key):
kwargs = {metadata_key: {"internal_call_origin": "autorouter_classifier"}}
assert litellm.Router._is_classifier_internal_call(kwargs) is True
assert litellm.Router._is_classifier_internal_call({metadata_key: {}}) is False
def test_removes_every_deployment_native_effort_carrier_without_mutating_shared_config(self):
extra_body: dict[str, object] = {
"reasoning_effort": "high",
"thinking": {"type": "enabled"},
"output_config": {"effort": "high", "format": "json"},
"reasoning": {"effort": "high", "summary": "detailed"},
"provider_option": True,
}
deployment_params: dict[str, object] = {
"model": "bedrock/converse/anthropic.claude-3-7-sonnet",
"thinking": {"type": "enabled", "budget_tokens": 2048},
"output_config": {"effort": "high", "format": {"type": "json_schema"}},
"reasoning": {"effort": "high", "summary": "auto"},
"extra_body": extra_body,
}
sanitized = litellm.Router._deployment_params_with_request_reasoning_override(
deployment_params, {"reasoning_effort": "low"}
)
assert sanitized == {
"model": "bedrock/converse/anthropic.claude-3-7-sonnet",
"output_config": {"format": {"type": "json_schema"}},
"reasoning": {"summary": "auto"},
"extra_body": {
"output_config": {"format": "json"},
"reasoning": {"summary": "detailed"},
"provider_option": True,
},
}
assert deployment_params["thinking"] == {"type": "enabled", "budget_tokens": 2048}
assert deployment_params["output_config"] == {"effort": "high", "format": {"type": "json_schema"}}
assert extra_body["reasoning_effort"] == "high"
@pytest.mark.parametrize("request_kwargs", [{}, {"reasoning_effort": None}])
def test_omitted_override_preserves_deployment_defaults(self, request_kwargs):
deployment_params = {
"model": "deepseek/deepseek-reasoner",
"thinking": {"type": "enabled"},
"output_config": {"effort": "high"},
}
assert (
litellm.Router._deployment_params_with_request_reasoning_override(deployment_params, request_kwargs)
== deployment_params
)
@pytest.mark.asyncio
async def test_280_concurrent_overrides_never_mutate_or_leak_through_shared_deployment_params(self):
deployment_params = {
"model": "fireworks_ai/accounts/fireworks/models/kimi-k2-thinking",
"thinking": {"type": "enabled"},
"output_config": {"effort": "high", "format": "json"},
"extra_body": {"reasoning_effort": "high", "tenant": "shared"},
}
efforts = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
results = await asyncio.gather(
*(
asyncio.to_thread(
litellm.Router._deployment_params_with_request_reasoning_override,
deployment_params,
{"reasoning_effort": efforts[index % len(efforts)]},
)
for index in range(280)
)
)
assert all("thinking" not in result for result in results)
assert all(result["output_config"] == {"format": "json"} for result in results)
assert all(result["extra_body"] == {"tenant": "shared"} for result in results)
assert deployment_params["thinking"] == {"type": "enabled"}
assert deployment_params["output_config"] == {"effort": "high", "format": "json"}
assert deployment_params["extra_body"] == {"reasoning_effort": "high", "tenant": "shared"}
@pytest.mark.parametrize(
("metadata", "should_drop"),
[({"internal_call_origin": "autorouter_classifier"}, True), ({}, False)],
ids=["classifier", "ordinary-request"],
)
def test_only_classifier_calls_drop_effort_for_an_unsupported_fallback(self, metadata, should_drop):
router = litellm.Router(model_list=[])
body: dict[str, object] = {"model": "classifier", "reasoning_effort": "low"}
kwargs: dict[str, object] = {
"reasoning_effort": "low",
"metadata": metadata,
"proxy_server_request": {"body": body},
}
deployment: DeploymentTypedDict = {
"model_name": "fallback",
"litellm_params": {"model": "openai/gpt-4o-mini"},
}
router._drop_unsupported_classifier_reasoning_effort(deployment, "fallback", kwargs)
assert ("reasoning_effort" not in kwargs) is should_drop
assert ("reasoning_effort" not in body) is should_drop
class TestPreRoutingTierDrivesFallbacks:
"""#38832: a complexity/auto router picks a tier behind the router name, but fallback
lookup stayed on the router name, so the tier's configured chain never ran and a

View file

@ -13,6 +13,8 @@ import ClassifierPromptEditor from "./ClassifierPromptEditor";
import CustomTierPromptEditor from "./CustomTierPromptEditor";
import { RestrictedSection, restrictedBy } from "./TierRestrictions";
import HeuristicScoringConfig from "./HeuristicScoringConfig";
import ClassifierReasoningEffortSelect from "./ClassifierReasoningEffortSelect";
import type { ReasoningEffort } from "./complexity_router_tiers";
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
import {
ClassificationFrequency,
@ -154,6 +156,7 @@ interface ClassificationMethodConfigProps {
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
modelOptions: { value: string; label: string }[];
effortOptionsByModel: Record<string, string[] | null | undefined>;
customTechnicalKeywords?: string[];
onCustomTechnicalKeywordsChange?: (keywords: string[]) => void;
showValidationErrors?: boolean;
@ -236,6 +239,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
value,
onChange,
modelOptions,
effortOptionsByModel,
customTechnicalKeywords,
onCustomTechnicalKeywordsChange,
showValidationErrors = false,
@ -251,6 +255,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
const contextBudget = value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS;
const contextBudgetQuotesNothing = contextBudget > 0 && contextBudget < MIN_QUOTED_CONTEXT_TURN_CHARS;
const classificationRubric = value.classifier_llm_config?.classification_rubric ?? DEFAULT_CLASSIFICATION_RUBRIC;
const classifierModel = value.classifier_llm_config?.model ?? "";
const classifierReasoningEffort = value.classifier_llm_config?.reasoning_effort;
const explicitlySupportedClassifierEfforts = effortOptionsByModel[classifierModel];
const handleClassifierTypeChange = (classifierType: ClassifierType) => {
const nextValue: ComplexityRouterConfigValue = {
@ -299,16 +306,33 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
};
const handleClassifierModelChange = (model: string) => {
if (model === value.classifier_llm_config?.model) return;
const { reasoning_effort: _reasoningEffort, ...classifierLlmConfig } = value.classifier_llm_config ?? {
model: "",
timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS,
};
onChange({
...value,
classifier_llm_config: {
...value.classifier_llm_config,
...classifierLlmConfig,
model,
timeout_ms: value.classifier_llm_config?.timeout_ms ?? DEFAULT_CLASSIFIER_TIMEOUT_MS,
timeout_ms: classifierLlmConfig.timeout_ms,
},
});
};
const handleClassifierReasoningEffortChange = (reasoningEffort: ReasoningEffort | undefined) => {
if (!value.classifier_llm_config) return;
const { reasoning_effort: _reasoningEffort, ...classifierLlmConfig } = value.classifier_llm_config;
onChange({
...value,
classifier_llm_config:
reasoningEffort === undefined
? classifierLlmConfig
: { ...classifierLlmConfig, reasoning_effort: reasoningEffort },
});
};
const handleClassifierTimeoutChange = (timeoutMs: number) => {
onChange({
...value,
@ -493,9 +517,16 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
emptyText="No models found"
allowClear={false}
className={classifierModelMissing ? "border-destructive" : undefined}
aria-label="Classifier Model"
/>
{classifierModelMissing && <span className="text-xs text-destructive">A classifier model is required</span>}
</div>
<ClassifierReasoningEffortSelect
model={classifierModel}
value={classifierReasoningEffort}
explicitlySupported={explicitlySupportedClassifierEfforts}
onChange={handleClassifierReasoningEffortChange}
/>
<div>
<Label htmlFor={CLASSIFIER_TIMEOUT_ID} className="block mb-1 font-semibold">
Timeout (ms)

View file

@ -0,0 +1,86 @@
import { Info } from "lucide-react";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { ReasoningEffort } from "./complexity_router_tiers";
const PROVIDER_DEFAULT = "__classifier_provider_default__";
type EffortStatus = "supported" | "unsupported" | "unverified" | undefined;
const effortStatusFor = (
effort: string | undefined,
explicitlySupported: string[] | null | undefined,
): EffortStatus => {
if (effort === undefined) return undefined;
if (!Array.isArray(explicitlySupported)) return "unverified";
return explicitlySupported.includes(effort) ? "supported" : "unsupported";
};
interface ClassifierReasoningEffortSelectProps {
model: string;
value: ReasoningEffort | undefined;
explicitlySupported: string[] | null | undefined;
onChange: (value: ReasoningEffort | undefined) => void;
}
const ClassifierReasoningEffortSelect = ({
model,
value,
explicitlySupported,
onChange,
}: ClassifierReasoningEffortSelectProps) => {
const status = effortStatusFor(value, explicitlySupported);
const options = Array.from(new Set([...(explicitlySupported ?? []), ...(value ? [value] : [])]));
if (!model || options.length === 0) return null;
const optionLabel = (effort: string): string =>
effort === value && status !== "supported" ? `${effort} (${status})` : effort;
return (
<div>
<div className="flex items-center gap-2 mb-1">
<strong className="font-semibold">Reasoning Effort</strong>
<SimpleTooltip content="Sent only to the classifier call. Default leaves the classifier deployment or provider setting unchanged.">
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
</div>
<Select
items={[
{ value: PROVIDER_DEFAULT, label: "Default" },
...options.map((effort) => ({ value: effort, label: optionLabel(effort) })),
]}
value={value ?? PROVIDER_DEFAULT}
onValueChange={(effort: string | null) =>
effort && onChange(effort === PROVIDER_DEFAULT ? undefined : (effort as ReasoningEffort))
}
>
<SelectTrigger aria-label={`Reasoning effort for classifier model ${model}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={PROVIDER_DEFAULT}>Default</SelectItem>
{options.map((effort) => (
<SelectItem key={effort} value={effort}>
{optionLabel(effort)}
</SelectItem>
))}
</SelectContent>
</Select>
{status === "unverified" && (
<p className="mt-1 text-xs text-amber-700 dark:text-amber-400">
This saved effort cannot be verified for the selected model. Choose Default unless you have confirmed provider
support.
</p>
)}
{status === "unsupported" && (
<p className="mt-1 text-xs text-destructive">
This saved effort is not supported by every deployment in the selected model group. Choose Default or a
supported value before saving.
</p>
)}
</div>
);
};
export default ClassifierReasoningEffortSelect;

View file

@ -1124,6 +1124,109 @@ describe("ComplexityRouterConfig per-model reasoning effort", () => {
});
});
describe("ComplexityRouterConfig classifier reasoning effort", () => {
const llmValue: ComplexityRouterConfigValue = {
...defaultValue,
classifier_type: "llm",
classifier_llm_config: { model: "gpt-4", timeout_ms: 3000 },
};
const renderClassifier = (value: ComplexityRouterConfigValue = llmValue, onChange = vi.fn()) => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={value} onChange={onChange} />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
return onChange;
};
it("defaults to the classifier provider setting and offers only supported efforts", async () => {
renderClassifier();
const user = userEvent.setup();
const select = screen.getByRole("combobox", { name: "Reasoning effort for classifier model gpt-4" });
expect(select).toHaveTextContent("Default");
await user.click(select);
expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual([
"Default",
"medium",
"high",
"xhigh",
]);
});
it("stores an explicit effort on the classifier config", async () => {
const onChange = renderClassifier();
const user = userEvent.setup();
await user.click(screen.getByRole("combobox", { name: "Reasoning effort for classifier model gpt-4" }));
await user.click(await screen.findByRole("option", { name: "high" }));
expect(onChange).toHaveBeenCalledWith({
...llmValue,
classifier_llm_config: { model: "gpt-4", timeout_ms: 3000, reasoning_effort: "high" },
});
});
it("removes the effort override when Default is selected", async () => {
const onChange = renderClassifier({
...llmValue,
classifier_llm_config: { model: "gpt-4", timeout_ms: 3000, reasoning_effort: "high" },
});
const user = userEvent.setup();
await user.click(screen.getByRole("combobox", { name: "Reasoning effort for classifier model gpt-4" }));
await user.click(await screen.findByRole("option", { name: "Default" }));
expect(onChange).toHaveBeenCalledWith(llmValue);
});
it("clears the old effort when the classifier model changes", async () => {
const onChange = renderClassifier({
...llmValue,
classifier_llm_config: { model: "gpt-4", timeout_ms: 3000, reasoning_effort: "high" },
});
const user = userEvent.setup();
await user.click(screen.getByRole("combobox", { name: "Classifier Model" }));
await user.click(await screen.findByRole("option", { name: "gpt-3.5-turbo" }));
expect(onChange).toHaveBeenCalledWith({
...llmValue,
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 },
});
});
it.each(["click", "enter"] as const)(
"keeps the effort when the selected model is confirmed by %s",
async (action) => {
const onChange = renderClassifier({
...llmValue,
classifier_llm_config: { model: "gpt-4", timeout_ms: 3000, reasoning_effort: "high" },
});
const user = userEvent.setup();
await user.click(screen.getByRole("combobox", { name: "Classifier Model" }));
if (action === "click") await user.click(await screen.findByRole("option", { name: "gpt-4" }));
else await user.keyboard("{Enter}");
expect(onChange).not.toHaveBeenCalled();
},
);
it.each([
["gpt-4", "max", "max (unsupported)", /not supported by every deployment/],
["claude-3-opus", "low", "low (unverified)", /cannot be verified/],
])("keeps a saved exceptional value visible for %s", (model, effort, label, warning) => {
renderClassifier({
...llmValue,
classifier_llm_config: { model, timeout_ms: 3000, reasoning_effort: effort },
});
expect(screen.getByRole("combobox", { name: `Reasoning effort for classifier model ${model}` })).toHaveTextContent(
label,
);
expect(screen.getByText(warning)).toBeInTheDocument();
});
it.each(["claude-3-opus", "gpt-3.5-turbo"])("hides the effort control when %s has no advertised options", (model) => {
renderClassifier({
...llmValue,
classifier_llm_config: { model, timeout_ms: 3000 },
});
expect(
screen.queryByRole("combobox", { name: `Reasoning effort for classifier model ${model}` }),
).not.toBeInTheDocument();
});
});
describe("ComplexityRouterConfig reasoning effort gating", () => {
it("offers no effort select for a model group without reasoning support", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
@ -1232,12 +1335,13 @@ describe("ComplexityRouterConfig custom technical keywords", () => {
});
it("hides the keywords when the scorer never runs, so they cannot imply an effect they have none", () => {
openClassificationPanel({
const llmWithDefaultFallback = {
...defaultValue,
classifier_type: "llm",
classifier_type: "llm" as const,
classifier_llm_config: llmConfig,
classifier_fallback: "default_model",
});
classifier_fallback: "default_model" as const,
};
openClassificationPanel(llmWithDefaultFallback);
expect(screen.queryByText("Custom Technical Keywords")).not.toBeInTheDocument();
});
});

View file

@ -36,10 +36,11 @@ import ContextWindowEscalationConfig from "./ContextWindowEscalationConfig";
import { Restricted, restrictedBy } from "./TierRestrictions";
import { type TierSetAction, applyTierSetAction, setFallbackTier } from "./tier_set_actions";
import {
REASONING_EFFORT_OPTIONS,
ReasoningEffort,
TierModelParamsByTier,
classifierEffortOptionsForModels,
setTierModelReasoningEffort,
tierEffortOptionsForModels,
tierRowLabel,
} from "./complexity_router_tiers";
import TierModelEffortRows from "./TierModelEffortRows";
@ -124,6 +125,7 @@ export const CLASSIFICATION_RUBRIC_KEYS = Object.keys(CLASSIFICATION_RUBRIC_DESC
export interface ClassifierLLMConfig {
model: string;
timeout_ms: number;
reasoning_effort?: ReasoningEffort;
classification_rubric?: ClassificationRubric;
system_prompt?: string;
}
@ -632,14 +634,8 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
const removeTierRow = (id: string) => dispatch({ kind: "remove", id });
const exitToBuiltInTiers = () => dispatch({ kind: "restore" });
// An absent list means the proxy does not send the field yet, so every level is offered as before.
// An empty list is the group's own answer that its deployments share no level, and is left empty.
const effortOptionsByModel: Record<string, string[]> = Object.fromEntries(
modelInfo.map((model) => [
model.model_group,
model.supported_reasoning_efforts ?? (model.supports_reasoning ? [...REASONING_EFFORT_OPTIONS] : []),
]),
);
const tierEffortOptionsByModel = tierEffortOptionsForModels(modelInfo);
const classifierEffortOptionsByModel = classifierEffortOptionsForModels(modelInfo);
// Embedding models can't serve a chat-completion role, so they're excluded here.
const modelOptions = modelInfo
@ -746,7 +742,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
<TierModelEffortRows
tierLabel={label}
models={row.models}
effortOptionsByModel={effortOptionsByModel}
effortOptionsByModel={tierEffortOptionsByModel}
paramsByModel={row.params}
onEffortChange={(model, effort) => handleTierModelEffortChange(row.id, model, effort)}
/>
@ -818,6 +814,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
value={value}
onChange={onChange}
modelOptions={modelOptions}
effortOptionsByModel={classifierEffortOptionsByModel}
customTechnicalKeywords={customTechnicalKeywords}
onCustomTechnicalKeywordsChange={onCustomTechnicalKeywordsChange}
showValidationErrors={showValidationErrors}

View file

@ -155,7 +155,11 @@ describe("HeuristicScoringConfig", () => {
});
describe("ClassificationMethodConfig scorer gating", () => {
const props = { onChange: vi.fn(), modelOptions: [{ value: "gpt-4o-mini", label: "gpt-4o-mini" }] };
const props = {
onChange: vi.fn(),
modelOptions: [{ value: "gpt-4o-mini", label: "gpt-4o-mini" }],
effortOptionsByModel: {},
};
const withClassifier = (type: ClassifierType, fallback?: ClassifierFallback): ComplexityRouterConfigValue => ({
...BASE,
classifier_type: type,

View file

@ -19,11 +19,12 @@ import { all_admin_roles } from "@/utils/roles";
import { type ModelWriteScope } from "@/utils/modelPermissions";
import TeamDropdown from "../common_components/team_dropdown";
import { type AddAutoRouterValues, handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
import { fetchAvailableModels, type ModelGroup } from "@/components/llm_calls/fetch_models";
import { autoRouterListKey, fetchAllModelDeployments } from "@/app/(dashboard)/hooks/models/useModels";
import ComplexityRouterConfig, {
ComplexityRouterConfigValue,
effectiveClassifierType,
usesLlmClassifier,
DEFAULT_ADAPTIVE_WEIGHTS,
DEFAULT_SESSION_AFFINITY,
DEFAULT_DEPLOYMENT_AFFINITY,
@ -37,6 +38,7 @@ import {
buildComplexityRouterConfig,
getKeywordTierRulesError,
getClassifierModelError,
getClassifierReasoningEffortError,
getMissingTiersError,
getPlanModeTierError,
getSemanticConfigError,
@ -120,14 +122,21 @@ export const getSubmitBlockedReason = (
config: ComplexityRouterConfigValue,
keywordTierRules: KeywordTierRule[],
referencedModelsParams: Parameters<typeof getReferencedModelsError>[0],
availability: ModelAvailability,
): string | null =>
(config.custom_tier_set ? getCustomTierRowsError(config.custom_tier_set) : getTierLabelsError(config.tier_labels)) ??
getMissingTiersError(activeTierRows(config)) ??
getPlanModeTierError(config.plan_mode_min_tier, activeTierRows(config)) ??
getKeywordTierRulesError(keywordTierRules, activeTierRows(config)) ??
getClassifierModelError(config) ??
getReferencedModelsError(referencedModelsParams, availability);
...capabilities: [availability: ModelAvailability, modelInfo?: readonly ModelGroup[]]
): string | null => {
const [availability, modelInfo = []] = capabilities;
return (
(config.custom_tier_set
? getCustomTierRowsError(config.custom_tier_set)
: getTierLabelsError(config.tier_labels)) ??
getMissingTiersError(activeTierRows(config)) ??
getPlanModeTierError(config.plan_mode_min_tier, activeTierRows(config)) ??
getKeywordTierRulesError(keywordTierRules, activeTierRows(config)) ??
getClassifierModelError(config) ??
getClassifierReasoningEffortError(config, modelInfo) ??
getReferencedModelsError(referencedModelsParams, availability)
);
};
const autoRouterSchema = (requiresTeamScope: boolean) =>
z.object({
@ -338,6 +347,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
keywordTierRules,
referencedModelsParams,
groupsOnlyAvailability,
modelInfo,
);
const complexityRouterConfigParams: BuildComplexityRouterConfigParams = {
@ -390,6 +400,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
keywordTierRules,
referencedModelsParams,
groupsOnlyAvailability,
modelInfo,
) ?? getSemanticConfigError({ semanticMatchingEnabled, embeddingModel, keywordTierRules });
if (blockedReason) {
setShowValidationErrors(true);
@ -462,6 +473,12 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
semanticMatchingEnabled,
embeddingModel,
defaultModel: resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model),
classifier: usesLlmClassifier(effectiveClassifierType(complexityRouterConfig))
? {
model: complexityRouterConfig.classifier_llm_config?.model ?? "",
reasoningEffort: complexityRouterConfig.classifier_llm_config?.reasoning_effort,
}
: undefined,
};
const targets = buildAutoRouterTestTargets(testTargetParams);

View file

@ -17,6 +17,7 @@ const targets: AutoRouterTestTarget[] = [
{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" },
{ labels: ["MEDIUM", "COMPLEX"], modelGroup: "claude-sonnet-4", mode: "chat" },
{ labels: ["Embedding"], modelGroup: "voyage-3-5", mode: "embedding" },
{ labels: ["Classifier"], modelGroup: "gpt-5-mini", mode: "chat", requestParams: { reasoning_effort: "low" } },
];
describe("AutoRouterConnectionTest", () => {
@ -30,11 +31,12 @@ describe("AutoRouterConnectionTest", () => {
renderWithProviders(<AutoRouterConnectionTest accessToken="sk-test" targets={targets} />);
await waitFor(() => expect(mock).toHaveBeenCalledTimes(3));
await waitFor(() => expect(mock).toHaveBeenCalledTimes(4));
expect(mock).toHaveBeenCalledWith("sk-test", "gpt-4o-mini", "chat");
expect(mock).toHaveBeenCalledWith("sk-test", "claude-sonnet-4", "chat");
expect(mock).toHaveBeenCalledWith("sk-test", "voyage-3-5", "embedding");
expect(mock).toHaveBeenCalledWith("sk-test", "gpt-5-mini", "chat", { reasoning_effort: "low" });
});
it("shows a success indicator per target when the routing probe passes", async () => {
@ -43,7 +45,7 @@ describe("AutoRouterConnectionTest", () => {
renderWithProviders(<AutoRouterConnectionTest accessToken="sk-test" targets={targets} />);
await waitFor(() => expect(screen.getAllByTestId("test-status-success")).toHaveLength(3));
await waitFor(() => expect(screen.getAllByTestId("test-status-success")).toHaveLength(4));
expect(screen.queryByTestId("test-status-error")).not.toBeInTheDocument();
expect(screen.getByText("MEDIUM, COMPLEX")).toBeInTheDocument();
});
@ -63,7 +65,7 @@ describe("AutoRouterConnectionTest", () => {
expect(await screen.findByTestId("test-error-message")).toBeInTheDocument();
expect(screen.getByTestId("test-error-message")).toHaveTextContent("invalid api key");
expect(screen.getByTestId("test-error-message")).not.toHaveTextContent("litellm.AuthenticationError");
expect(screen.getAllByTestId("test-status-success")).toHaveLength(2);
expect(screen.getAllByTestId("test-status-success")).toHaveLength(3);
});
it("renders a non-litellm error string verbatim", async () => {

View file

@ -29,7 +29,9 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
const run = async () => {
await Promise.all(
targets.map(async (target, index) => {
const result = await testModelGroupConnection(accessToken, target.modelGroup, target.mode);
const result = target.requestParams
? await testModelGroupConnection(accessToken, target.modelGroup, target.mode, target.requestParams)
: await testModelGroupConnection(accessToken, target.modelGroup, target.mode);
if (cancelled) return;
const cleaned: TargetResult =
result.status === "error" ? { status: "error", error: cleanErrorMessage(result.error) } : result;
@ -56,14 +58,14 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
return (
<div className="space-y-3">
<p className="mb-2 text-sm text-muted-foreground">
Each configured tier routes to a saved model group. Test Connection sends a minimal request through the proxy to
each one, exactly as the auto router would.
Test Connection sends a minimal request to every configured tier, classifier, default, and embedding model. The
classifier probe includes its reasoning effort override.
</p>
{targets.map((target, index) => {
const result = results[index] ?? { status: "pending" };
return (
<div
key={`${target.modelGroup}-${target.mode}`}
key={`${target.labels.join("-")}-${target.modelGroup}-${target.mode}`}
data-testid="auto-router-test-row"
className="flex items-start gap-3 rounded-lg border p-3"
>

View file

@ -126,4 +126,32 @@ describe("buildAutoRouterTestTargets", () => {
});
expect(targets).toEqual([{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" }]);
});
it("adds a distinct classifier probe with its reasoning effort", () => {
const targets = buildAutoRouterTestTargets({
tiers: tierEntries(["gpt-5-mini"]),
semanticMatchingEnabled: false,
embeddingModel: undefined,
classifier: { model: "gpt-5-mini", reasoningEffort: "low" },
});
expect(targets).toEqual([
{ labels: ["SIMPLE"], modelGroup: "gpt-5-mini", mode: "chat" },
{
labels: ["Classifier"],
modelGroup: "gpt-5-mini",
mode: "chat",
requestParams: { reasoning_effort: "low" },
},
]);
});
it("omits an empty classifier and omits params when the classifier uses provider defaults", () => {
const base = { tiers: tierEntries([]), semanticMatchingEnabled: false, embeddingModel: undefined };
const emptyClassifier = { ...base, classifier: { model: " " } };
const providerDefaultClassifier = { ...base, classifier: { model: "gpt-5-mini" } };
expect(buildAutoRouterTestTargets(emptyClassifier)).toEqual([]);
expect(buildAutoRouterTestTargets(providerDefaultClassifier)).toEqual([
{ labels: ["Classifier"], modelGroup: "gpt-5-mini", mode: "chat" },
]);
});
});

View file

@ -4,6 +4,7 @@ export interface AutoRouterTestTarget {
labels: string[];
modelGroup: string;
mode: AutoRouterTestMode;
requestParams?: Record<string, unknown>;
}
export interface BuildAutoRouterTestTargetsParams {
@ -14,6 +15,7 @@ export interface BuildAutoRouterTestTargetsParams {
/** The resolved default model - see resolveComplexityDefaultModel. A live fallback destination,
* so it is probed even when no tier lists it. */
defaultModel?: string;
classifier?: { model: string; reasoningEffort?: string };
}
export const buildAutoRouterTestTargets = ({
@ -21,6 +23,7 @@ export const buildAutoRouterTestTargets = ({
semanticMatchingEnabled,
embeddingModel,
defaultModel,
classifier,
}: BuildAutoRouterTestTargetsParams): AutoRouterTestTarget[] => {
const tieredByModel = tiers.reduce<Record<string, string[]>>((acc, [tier, models]) => {
return models.reduce((tierAcc, rawModel) => {
@ -50,5 +53,17 @@ export const buildAutoRouterTestTargets = ({
? [{ labels: ["Embedding"], modelGroup: embeddingModel.trim(), mode: "embedding" as const }]
: [];
return [...tierTargets, ...embeddingTarget];
const classifierModel = classifier?.model.trim();
const classifierTarget: AutoRouterTestTarget[] = classifierModel
? [
{
labels: ["Classifier"],
modelGroup: classifierModel,
mode: "chat",
...(classifier?.reasoningEffort && { requestParams: { reasoning_effort: classifier.reasoningEffort } }),
},
]
: [];
return [...tierTargets, ...embeddingTarget, ...classifierTarget];
};

View file

@ -4,6 +4,7 @@ import {
normalizeClassifierLlmConfig,
getKeywordTierRulesError,
getClassifierModelError,
getClassifierReasoningEffortError,
getMissingTiersError,
hydrateCustomTierSet,
getSemanticConfigError,
@ -506,6 +507,19 @@ describe("classifier prompt and fallback", () => {
expect(buildComplexityRouterConfig(llmParams)).not.toHaveProperty("classifier_fallback");
});
it("keeps an explicit classifier reasoning effort", () => {
const config = buildComplexityRouterConfig({
...llmParams,
classifierLlmConfig: { model: "haiku-classifier", timeout_ms: 400, reasoning_effort: "low" },
});
expect(config.classifier_llm_config?.reasoning_effort).toBe("low");
});
it("omits classifier reasoning effort when the provider default is selected", () => {
const config = buildComplexityRouterConfig(llmParams);
expect(config.classifier_llm_config).not.toHaveProperty("reasoning_effort");
});
it("sends the chat preset the operator picked", () => {
const config = buildComplexityRouterConfig({
...llmParams,
@ -540,12 +554,16 @@ describe("classifier prompt and fallback", () => {
});
it("normalizeClassifierLlmConfig leaves a real prompt untouched and strips an empty one", () => {
expect(normalizeClassifierLlmConfig({ model: "m", timeout_ms: 1, system_prompt: "x" })).toEqual({
const customPromptConfig = { model: "m", timeout_ms: 1, reasoning_effort: "none" as const, system_prompt: "x" };
const emptyPromptConfig = { model: "m", timeout_ms: 1, system_prompt: "" };
const expectedCustomPromptConfig = {
model: "m",
timeout_ms: 1,
reasoning_effort: "none",
system_prompt: "x",
});
expect(normalizeClassifierLlmConfig({ model: "m", timeout_ms: 1, system_prompt: "" })).toEqual({
};
expect(normalizeClassifierLlmConfig(customPromptConfig)).toEqual(expectedCustomPromptConfig);
expect(normalizeClassifierLlmConfig(emptyPromptConfig)).toEqual({
model: "m",
timeout_ms: 1,
});
@ -767,6 +785,26 @@ describe("getClassifierModelError", () => {
});
});
describe("getClassifierReasoningEffortError", () => {
const classifier = {
classifier_type: "llm" as const,
classifier_llm_config: { model: "classifier", timeout_ms: 3000, reasoning_effort: "low" },
};
it.each([
[["low", "medium"], null],
[["medium", "high"], "low reasoning effort is not supported"],
[null, null],
[undefined, null],
])("validates capability levels %o", (supportedReasoningEfforts, expectedError) => {
const error = getClassifierReasoningEffortError(classifier, [
{ model_group: "classifier", supported_reasoning_efforts: supportedReasoningEfforts },
]);
if (expectedError) expect(error).toContain(expectedError);
else expect(error).toBeNull();
});
});
describe("getKeywordTierRulesError orphaned tiers", () => {
const rows = activeTierRows({ tiers });
@ -944,11 +982,16 @@ describe("buildComplexityRouterConfig with an edited tier set", () => {
classifierLlmConfig: {
model: "gpt-4o-mini",
timeout_ms: 3000,
reasoning_effort: "low",
system_prompt: "replace the whole rubric",
classification_rubric: "agentic",
},
});
expect(payload.classifier_llm_config).toEqual({ model: "gpt-4o-mini", timeout_ms: 3000 });
expect(payload.classifier_llm_config).toEqual({
model: "gpt-4o-mini",
timeout_ms: 3000,
reasoning_effort: "low",
});
});
it.each(

View file

@ -1,4 +1,5 @@
import { KeywordTierRule } from "./KeywordTierRules";
import type { ModelGroup } from "../llm_calls/fetch_models";
import {
type CustomTierSet,
type TierRow,
@ -55,12 +56,18 @@ import {
export const normalizeClassifierLlmConfig = ({
model,
timeout_ms,
reasoning_effort,
classification_rubric,
system_prompt,
}: ClassifierLLMConfig): ClassifierLLMConfig =>
system_prompt?.trim()
? { model, timeout_ms, system_prompt }
: { model, timeout_ms, ...(classification_rubric && { classification_rubric }) };
? { model, timeout_ms, ...(reasoning_effort && { reasoning_effort }), system_prompt }
: {
model,
timeout_ms,
...(reasoning_effort && { reasoning_effort }),
...(classification_rubric && { classification_rubric }),
};
interface ScorerKnobInputs {
classifierType: ClassifierType;
@ -268,6 +275,20 @@ export const getClassifierModelError = (
: "Please select a classifier model, or switch back to Heuristic";
};
export const getClassifierReasoningEffortError = (
config: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type" | "classifier_llm_config">,
modelInfo: readonly ModelGroup[],
): string | null => {
if (!usesLlmClassifier(effectiveClassifierType(config))) return null;
const classifierConfig = config.classifier_llm_config;
if (!classifierConfig?.model || !classifierConfig.reasoning_effort) return null;
const supported = modelInfo.find(
(model) => model.model_group === classifierConfig.model,
)?.supported_reasoning_efforts;
if (!Array.isArray(supported) || supported.includes(classifierConfig.reasoning_effort)) return null;
return `${classifierConfig.reasoning_effort} reasoning effort is not supported by every deployment in ${classifierConfig.model}. Choose Default or a supported value.`;
};
export const getSemanticConfigError = ({
semanticMatchingEnabled,
embeddingModel,
@ -295,11 +316,15 @@ export const customTierWireFields = (
tier_definitions: tierDefinitionsFromRows(rows),
...(fallback && { fallback_tier: activeTierName(fallback) }),
classifier_type: "llm",
// Rebuilt from the two fields an edited tier set allows. The backend rejects system_prompt and
// Rebuilt from the fields an edited tier set allows. The backend rejects system_prompt and
// classification_rubric beside tier_definitions, and both live inside this object rather than at
// the top level the omit list covers. The opening instructions ride classification_prompt below.
...(classifierLlmConfig && {
classifier_llm_config: { model: classifierLlmConfig.model, timeout_ms: classifierLlmConfig.timeout_ms },
classifier_llm_config: {
model: classifierLlmConfig.model,
timeout_ms: classifierLlmConfig.timeout_ms,
...(classifierLlmConfig.reasoning_effort && { reasoning_effort: classifierLlmConfig.reasoning_effort }),
},
}),
session_affinity: false,
...(classificationPrompt?.trim() && { classification_prompt: classificationPrompt.trim() }),

View file

@ -1,4 +1,5 @@
import type { ComplexityTier } from "./KeywordTierRules";
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
import { TIER_ORDER } from "./tier_rows";
export type TierModelParams = Record<string, unknown>;
@ -18,6 +19,23 @@ export const REASONING_EFFORT_OPTIONS = ["none", "minimal", "low", "medium", "hi
*/
export type ReasoningEffort = (typeof REASONING_EFFORT_OPTIONS)[number] | (string & {});
export const tierEffortOptionsForModels = (modelInfo: ModelGroup[]): Record<string, string[]> =>
Object.fromEntries(
modelInfo.map((model) => [
model.model_group,
model.supported_reasoning_efforts ?? (model.supports_reasoning ? [...REASONING_EFFORT_OPTIONS] : []),
]),
);
/**
* Stricter than the tier variant on purpose: classifier overrides are new in this release, so an
* unknown capability list stays unknown instead of inventing provider levels.
*/
export const classifierEffortOptionsForModels = (
modelInfo: ModelGroup[],
): Record<string, string[] | null | undefined> =>
Object.fromEntries(modelInfo.map((model) => [model.model_group, model.supported_reasoning_efforts]));
const asRecord = (raw: unknown): Record<string, unknown> | undefined =>
typeof raw === "object" && raw !== null && !Array.isArray(raw) ? (raw as Record<string, unknown>) : undefined;

View file

@ -16,7 +16,7 @@ const storedCustomConfig = (overrides: Record<string, unknown> = {}) => ({
],
fallback_tier: "CASUAL",
classifier_type: "llm",
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000 },
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000, reasoning_effort: "low" },
...overrides,
});
@ -111,7 +111,7 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
const STORED_LLM = {
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "llm",
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000 },
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000, reasoning_effort: "low" },
classifier_context_window_size: 5,
classifier_context_per_turn_chars: 300,
};
@ -522,7 +522,7 @@ describe("managed keys survive an untouched open-and-save", () => {
tier_labels: { SIMPLE: "Cheap" },
classifier_type: "heuristic_first",
heuristic_first_max_tier: "SIMPLE",
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000 },
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000, reasoning_effort: "low" },
classifier_context_window_size: 5,
classifier_context_budget_chars: 4000,
classifier_context_include_assistant_turns: true,

View file

@ -28,6 +28,7 @@ import {
type BuildComplexityRouterConfigParams,
buildComplexityRouterConfig,
getClassifierModelError,
getClassifierReasoningEffortError,
getKeywordTierRulesError,
getMissingTiersError,
getSemanticConfigError,
@ -560,6 +561,12 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
toast.fromError(classifierError);
return;
}
const classifierEffortError = getClassifierReasoningEffortError(complexityRouterConfig, modelInfo);
if (classifierEffortError) {
setShowValidationErrors(true);
toast.fromError(classifierEffortError);
return;
}
// Same guards the create form applies (add_auto_router_tab.tsx). The backend rejects a
// keyword rule with no keyword, and semantic_keyword_matching without an embedding model
// or keyword rules (complexity_router/config.py), so without these a save fails as a raw

View file

@ -52,6 +52,24 @@ describe("fetchAvailableModels", () => {
]);
});
it("preserves absent, unknown, empty, and explicit effort capability states", async () => {
modelHubCallMock.mockResolvedValue({
data: [
{ model_group: "absent", supports_reasoning: true },
{ model_group: "unknown", supports_reasoning: true, supported_reasoning_efforts: null },
{ model_group: "empty", supports_reasoning: true, supported_reasoning_efforts: [] },
{ model_group: "known", supports_reasoning: true, supported_reasoning_efforts: ["low"] },
],
});
expect(await fetchAvailableModels("token")).toEqual([
{ model_group: "absent", supports_reasoning: true },
{ model_group: "empty", supports_reasoning: true, supported_reasoning_efforts: [] },
{ model_group: "known", supports_reasoning: true, supported_reasoning_efforts: ["low"] },
{ model_group: "unknown", supports_reasoning: true, supported_reasoning_efforts: null },
]);
});
it.each([
["an error payload in place of the list", { data: { error: "no access" } }],
["a missing data key", {}],

View file

@ -7,7 +7,7 @@ export interface ModelGroup {
model_group: string;
mode?: string;
supports_reasoning?: boolean;
supported_reasoning_efforts?: string[];
supported_reasoning_efforts?: string[] | null;
}
interface AvailableModel {
@ -25,7 +25,9 @@ const toModelGroup = (item: AvailableModel): ModelGroup => {
model_group: groupName,
...(item.mode && { mode: item.mode }),
...(item.supports_reasoning === true && { supports_reasoning: true }),
...(item.supported_reasoning_efforts && { supported_reasoning_efforts: item.supported_reasoning_efforts }),
...(item.supported_reasoning_efforts !== undefined && {
supported_reasoning_efforts: item.supported_reasoning_efforts,
}),
};
};

View file

@ -617,6 +617,15 @@ describe("buildModelGroupTestRequest", () => {
expect(path).toBe("/v1/embeddings");
expect(body).toEqual({ model: "text-embedding-3-small", input: "test from litellm" });
});
it("adds classifier request parameters to a chat probe", () => {
const { body } = Networking.buildModelGroupTestRequest("gpt-5-mini", "chat", { reasoning_effort: "low" });
expect(body).toEqual({
model: "gpt-5-mini",
messages: [{ role: "user", content: "test from litellm" }],
reasoning_effort: "low",
});
});
});
describe("testMCPToolsListRequest auth headers", () => {

View file

@ -2381,20 +2381,22 @@ export type ModelGroupConnectionResult = { status: "success" } | { status: "erro
export const buildModelGroupTestRequest = (
modelGroup: string,
mode: "chat" | "embedding",
requestParams: Record<string, unknown> = {},
): { path: string; body: Record<string, unknown> } =>
mode === "embedding"
? { path: "/v1/embeddings", body: { model: modelGroup, input: "test from litellm" } }
: {
path: "/v1/chat/completions",
body: { model: modelGroup, messages: [{ role: "user", content: "test from litellm" }] },
body: { ...requestParams, model: modelGroup, messages: [{ role: "user", content: "test from litellm" }] },
};
export const testModelGroupConnection = async (
accessToken: string,
modelGroup: string,
mode: "chat" | "embedding",
requestParams?: Record<string, unknown>,
): Promise<ModelGroupConnectionResult> => {
const { path, body } = buildModelGroupTestRequest(modelGroup, mode);
const { path, body } = buildModelGroupTestRequest(modelGroup, mode, requestParams);
try {
await apiClient.post(path, { accessToken, body });
return { status: "success" };

View file

@ -25211,6 +25211,11 @@ export interface components {
* @description Model name (from the router's model_list) to call for classification
*/
model: string;
/**
* Reasoning Effort
* @description Reasoning effort override for classifier calls. Leave unset to use the classifier deployment or provider default.
*/
reasoning_effort?: ("none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max") | null;
/**
* System Prompt
* @description Replaces the built-in complexity rubric as the classifier's entire system role. When set, neither the default rubric nor the context-window closing line is appended, so the prompt owns the whole taxonomy and the tier names SIMPLE/MEDIUM/COMPLEX/REASONING become whatever buckets it defines: a prompt that classifies data sensitivity routes on that instead of on difficulty. Two consequences of full replacement. The default rubric's closing paragraph is the classifier's prompt-injection defense, telling it that the caller's quoted system prompt and prior turns are material to judge and never instructions; a replacement that omits it lets a caller ask for a tier and get it. And the heuristic fallback still scores complexity, so a router on some other taxonomy wants classifier_fallback='default_model'. Leave unset for the built-in rubric. Only applies when classifier_type is 'llm'.