feat(router): add Python Switchyard autorouting strategy

This commit is contained in:
Joshua Valluru 2026-10-31 13:50:51 -07:00
parent c43a1addf2
commit 10f3dd75e5
34 changed files with 2775 additions and 13 deletions

View file

@ -0,0 +1,27 @@
# ADR 002: Virtual routing calls settle usage independently
Status: Proposed
## Context
Switchyard can call an efficient model, judge its response, and either return that response or call a capable model. The existing pre-routing hook only selects a deployment. Repeating an accepted response through another completion would generate a second answer and charge twice
## Decision
Dispatch Switchyard chat requests through an execution strategy before ordinary deployment selection. The strategy calls configured models through the owning Python router, with separate call IDs and inherited caller attribution. Each provider call records its own usage and cost. The virtual request does not record an additional provider charge
Child calls do not own the virtual request's admission reservation. The router host releases that reservation after execution finishes, fails, or is cancelled. When the final provider call streams, release waits for stream completion or closure
Return an accepted provider response unchanged, including usage extras described in ADR 001. Judged responses are buffered. A caller requesting streaming receives the accepted response through the existing response-to-stream conversion, without an additional billing wrapper. Ordinary final calls can stream directly
## Alternatives Considered
Returning only a model name through the pre-routing hook cannot reuse an answer already generated during escalation
Combining every internal call into a fabricated parent usage object would lose model-specific pricing and make provider usage fields ambiguous
## Consequences
Provider execution and billing remain in LiteLLM, while Switchyard owns routing decisions. Logs contain separate provider calls linked to the caller's session
Judged streaming has additional time to first token. Cancellation, reservation cleanup, and the sum of child charges require end-to-end verification. The initial execution strategy supports async Chat Completions only and rejects other endpoint paths until their adapters are implemented

View file

@ -7,7 +7,7 @@ target up at call time, so patches of the proxy functions apply.
"""
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Final
from litellm.router_host import CompressionPolicy, RouterHost
@ -45,6 +45,15 @@ class ProxyRouterHost(RouterHost):
llm_router=llm_router,
)
async def finalize_strategy_request(self, request_kwargs: Mapping[str, object]) -> None:
from litellm.litellm_core_utils.core_helpers import budget_reservation_from_metadata
from litellm.proxy.spend_tracking.budget_reservation import release_or_invalidate_budget_reservation
for key in ("metadata", "litellm_metadata"):
metadata: Final = request_kwargs.get(key)
if isinstance(metadata, Mapping):
await release_or_invalidate_budget_reservation(budget_reservation_from_metadata(metadata))
def compression_policy(
self,
llm_router: "Router | None",

View file

@ -145,6 +145,7 @@ from litellm.router_strategy.auto_router.auto_router_model_naming import (
validate_complexity_router_config_placement,
validate_complexity_router_config_write,
validate_strategy_router_model_write,
validate_switchyard_router_config_write,
)
from litellm.router_strategy.auto_router.auto_router_tuning_baseline import (
is_mutable_tuned_candidate,
@ -374,6 +375,9 @@ def _strategy_router_write_violation(
"""
if incoming_params is None:
return None
switchyard_violation: Final = validate_switchyard_router_config_write(incoming_params.switchyard_router_config)
if switchyard_violation is not None:
return switchyard_violation
config_violation: Final = validate_complexity_router_config_write(
complexity_router_config=(
_effective_complexity_router_config(incoming_params, existing_params)

View file

@ -63,6 +63,9 @@ class RouterHost:
response=httpx.Response(status_code=403, request=httpx.Request("POST", "https://litellm.invalid")),
)
async def finalize_strategy_request(self, request_kwargs: Mapping[str, object]) -> None:
"""Release admission held by a virtual request whose children settle their own usage."""
def compression_policy(
self,
llm_router: "Router | None",

View file

@ -536,6 +536,7 @@ if TYPE_CHECKING:
from litellm.router_strategy.quality_router.quality_router import (
QualityRouter,
)
from litellm.router_strategy.switchyard_router.switchyard_router import SwitchyardAutoRouter
from litellm.types.llms.anthropic_messages.anthropic_response import (
AnthropicMessagesResponse,
)
@ -1769,6 +1770,9 @@ class PythonCore(DeprecatedRouterMembers):
self._core_provider_default_deployment_ids: list[str] = []
self._core_pattern_router = PatternMatchRouter()
self._core_team_pattern_routers: dict[str, PatternMatchRouter] = {} # {"TEAM_ID": PatternMatchRouter}
self._core_switchyard_routers: dict[ # mutable-ok: registry lifecycle mutates model entries
str, list[TaggedPreRoutingStrategy[SwitchyardAutoRouter]]
] = {}
self._core_auto_routers: dict[str, list[TaggedPreRoutingStrategy[AutoRouter]]] = {}
self._core_complexity_routers: dict[str, list[TaggedPreRoutingStrategy[ComplexityRouter]]] = {}
self._core_adaptive_routers: dict[str, list[TaggedPreRoutingStrategy[AdaptiveRouter]]] = {}
@ -3726,6 +3730,13 @@ class PythonCore(DeprecatedRouterMembers):
kwargs["stream"] = stream
kwargs["original_function"] = self._acompletion
if self._core_switchyard_routers:
from litellm.router_strategy.switchyard_router.switchyard_router import SwitchyardAutoRouter
selected: Final = self._select_pre_routing_strategy(model, kwargs)
if selected is not None and isinstance(selected.strategy, SwitchyardAutoRouter):
return await self._execute_switchyard(selected, model, kwargs)
self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
start_time: Final = time.time()
is_prompt_management_model: Final = self._is_prompt_management_model(model)
@ -3773,6 +3784,100 @@ class PythonCore(DeprecatedRouterMembers):
)
raise e
async def _execute_switchyard(
self,
selected: TaggedPreRoutingStrategy[PreRoutingStrategy],
model: str,
request_kwargs: Mapping[str, object],
) -> ModelResponse | CustomStreamWrapper | AsyncIterable[ModelResponseStream]:
from litellm.router_strategy.switchyard_router.execution import (
ACTIVE_SWITCHYARD,
FinalizingStream,
SwitchyardReplay,
child_call_kwargs,
scoped_switchyard_headers,
switchyard_execution,
)
from litellm.router_strategy.switchyard_router.switchyard_router import SwitchyardAutoRouter
strategy: Final = selected.strategy
if not isinstance(strategy, SwitchyardAutoRouter):
raise TypeError("Expected a Switchyard execution strategy")
if ACTIVE_SWITCHYARD.get():
raise ValueError("Switchyard targets cannot be auto-router deployments")
marker: Final = self._selected_strategy_marker_deployment(model, selected.tags, request_kwargs)
await router_host().authorize_strategy_inference(
deployment=marker, request_kwargs=request_kwargs, llm_router=self._core_public()
)
defaults: Final = dict(self._forwardable_alias_marker_params(model, selected.tags, request_kwargs))
forwarded: Final = tuple(key for key in defaults if key not in request_kwargs)
values: Final = {
**defaults,
**request_kwargs,
**({_ALIAS_MARKER_FORWARDED_PARAMS_KWARG: forwarded} if forwarded else {}),
}
config: Final = strategy.config
affinity: Final = (
config.custom
if config.algorithm == "custom"
else config.stage.classifier
if config.algorithm == "stage"
else config.capability
)
message_hash_fallback: Final = affinity.message_hash_fallback if affinity is not None else False
headers: Final = scoped_switchyard_headers(values, message_hash_fallback, strategy.session_namespace)
async def execute_model(target: str, payload: Mapping[str, object]) -> ModelResponse | CustomStreamWrapper:
child: Final = dict(child_call_kwargs(values, payload))
decision: Final[StandardLoggingRoutingDecision] = {
"router_model_name": selected.deployment.model_name if selected.deployment else model,
"router_type": "switchyard",
"routed_model": target,
"signals": (f"algorithm:{config.algorithm}",),
**(
{"router_config_fingerprint": selected.definition_fingerprint}
if selected.definition_fingerprint is not None
else {}
),
}
self._record_routing_decision(child, decision)
request_tags: Final = get_routing_tags_from_request_kwargs(values)
if (
selected.tags
and request_tags
and is_valid_deployment_tag(selected.tags, request_tags, self._core_tag_filtering_match_any)
):
self._stamp_or_clear_metadata_key(
child,
CONSUMED_REQUEST_TAGS_METADATA_KEY,
ConsumedRequestTagsStamp(model_group=target, tags=selected.tags),
)
if self._select_pre_routing_strategy(target, child) is not None:
raise ValueError("Switchyard targets cannot be auto-router deployments")
child_messages: Final = TypeAdapter(list[AllMessageValues]).validate_python(child.get("messages"))
child_stream: Final = child.get("stream") is True
child_options: Final = {key: value for key, value in child.items() if key not in {"messages", "stream"}}
return await self.acompletion(model=target, messages=child_messages, stream=child_stream, **child_options)
async def finalize() -> None:
await router_host().finalize_strategy_request(request_kwargs=request_kwargs)
try:
with switchyard_execution():
response: Final = await strategy.execute(values, execute_model, headers=headers)
except BaseException:
await finalize()
raise
if isinstance(response, CustomStreamWrapper):
return FinalizingStream(
response.__aiter__(), finalize, response.aclose, get_hidden_params_dict(response), response.logging_obj
)
await finalize()
if request_kwargs.get("stream") is True and isinstance(response, ModelResponse):
options: Final = request_kwargs.get("stream_options")
return SwitchyardReplay(response, isinstance(options, Mapping) and options.get("include_usage") is True)
return response
@staticmethod
def _combine_fallback_usage(
fallback_item: ModelResponseStream,
@ -11126,6 +11231,17 @@ class PythonCore(DeprecatedRouterMembers):
"""Deployment tags used to disambiguate strategy registries keyed by model_name."""
return tuple(deployment.litellm_params.tags or ())
def init_switchyard_router_deployment(self, deployment: Deployment) -> None:
from litellm.router_strategy.switchyard_router import SwitchyardAutoRouter, SwitchyardRouterConfig
config: Final = SwitchyardRouterConfig.model_validate(deployment.litellm_params.switchyard_router_config)
self._register_pre_routing_strategy(
registry=self._core_switchyard_routers,
deployment=deployment,
strategy=SwitchyardAutoRouter(config),
strategy_label="Switchyard",
)
def init_auto_router_deployment(self, deployment: Deployment):
"""
Initialize the auto-router deployment.
@ -11365,7 +11481,12 @@ class PythonCore(DeprecatedRouterMembers):
return
model_name: Final = deployment.model_name
tags: Final = self._deployment_tags(deployment)
for registry in (self._core_auto_routers, self._core_complexity_routers, self._core_quality_routers):
for registry in (
self._core_auto_routers,
self._core_complexity_routers,
self._core_quality_routers,
self._core_switchyard_routers,
):
self._unregister_pre_routing_strategy(registry, model_name, tags)
if self._unregister_pre_routing_strategy(self._core_adaptive_routers, model_name, tags):
self._sync_adaptive_router_hooks()
@ -11575,6 +11696,7 @@ class PythonCore(DeprecatedRouterMembers):
self._core_team_public_model_names = frozenset()
# Reset per-strategy router registries so hot-reload doesn't leave
# stale routers pointing at the old model_list.
self._core_switchyard_routers = {}
self._core_quality_routers = {}
self._core_complexity_routers = {}
self._core_auto_routers = {}
@ -11770,6 +11892,9 @@ class PythonCore(DeprecatedRouterMembers):
#########################################################
# Check if this is an auto-router deployment
#########################################################
if classify_strategy_router_model(deployment.litellm_params.model) == "switchyard":
self.init_switchyard_router_deployment(deployment)
if self._is_auto_router_deployment(litellm_params=deployment.litellm_params):
self.init_auto_router_deployment(deployment=deployment)
@ -16417,6 +16542,7 @@ class PythonCore(DeprecatedRouterMembers):
self._core_complexity_routers,
self._core_adaptive_routers,
self._core_quality_routers,
self._core_switchyard_routers,
)
if not any(registries):
return None
@ -16510,7 +16636,9 @@ class PythonCore(DeprecatedRouterMembers):
registered_model_name: str,
request_kwargs: Mapping[str, object],
) -> str:
if is_native_compaction_call():
from litellm.router_strategy.switchyard_router.execution import ACTIVE_SWITCHYARD
if is_native_compaction_call() or ACTIVE_SWITCHYARD.get():
return registered_model_name
if not any(
(
@ -16518,6 +16646,7 @@ class PythonCore(DeprecatedRouterMembers):
self._core_complexity_routers,
self._core_adaptive_routers,
self._core_quality_routers,
self._core_switchyard_routers,
)
):
return registered_model_name
@ -16573,8 +16702,11 @@ class PythonCore(DeprecatedRouterMembers):
spend metadata is stamped before routing and the response carries the tier group the
strategy picked.
"""
from litellm.router_strategy.switchyard_router.execution import ACTIVE_SWITCHYARD
self._restore_client_output_ceiling(request_kwargs)
self._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None)
if not ACTIVE_SWITCHYARD.get():
self._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None)
requested_registered_model_name: Final = self._core_get_model_from_alias(model=model) or model
registered_model_name: Final = await self._resolve_claude_code_session_router(
model=model,
@ -16597,6 +16729,8 @@ class PythonCore(DeprecatedRouterMembers):
model=registered_model_name, request_kwargs=request_kwargs
)
if selected_strategy is None:
if ACTIVE_SWITCHYARD.get():
return None
await arm_compaction(request_kwargs, None)
self._stamp_or_clear_metadata_key(
request_kwargs=request_kwargs, key=SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY, value=None

View file

@ -12,6 +12,7 @@ the router silently dropping the deployment at load time under
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
from itertools import chain
from types import MappingProxyType
from typing import Final, Literal, TypeAlias
@ -26,7 +27,7 @@ from litellm.router_strategy.complexity_router.config import (
AUTO_ROUTER_MODEL_PREFIX: Final = "auto_router/"
StrategyRouterKind = Literal["semantic", "complexity", "adaptive", "quality"]
StrategyRouterKind = Literal["semantic", "complexity", "adaptive", "quality", "switchyard"]
StrategyRouterDependencyRole: TypeAlias = Literal[
"tier", "default", "classifier", "embedding", "evaluation", "compactor"
@ -52,6 +53,7 @@ STRATEGY_ROUTER_PARAM_FIELDS: Final[frozenset[str]] = frozenset(
"complexity_router_default_model",
"adaptive_router_config",
"quality_router_config",
"switchyard_router_config",
"quality_router_default_model",
}
)
@ -64,6 +66,7 @@ _REQUIRED_FIELD_GROUPS: Final[Mapping[StrategyRouterKind, tuple[tuple[str, ...],
),
"complexity": (("complexity_router_config", "complexity_router_default_model"),),
"adaptive": (("adaptive_router_config",),),
"switchyard": (("switchyard_router_config",),),
"quality": (("quality_router_config", "quality_router_default_model"),),
}
@ -82,6 +85,8 @@ def classify_strategy_router_model(model: str) -> StrategyRouterKind | None:
return "complexity"
if remainder.startswith("adaptive_router"):
return "adaptive"
if remainder.startswith("switchyard"):
return "switchyard"
if remainder.startswith("quality_router"):
return "quality"
return "semantic"
@ -141,6 +146,12 @@ def strategy_router_dependencies(
return _named(litellm_params.get("auto_router_default_model"), "default") + _named(
litellm_params.get("auto_router_embedding_model"), "embedding"
)
if kind == "switchyard":
models: Final = _mapping(_mapping(litellm_params.get("switchyard_router_config")).get("models"))
dependencies: Final = tuple(
_pool(names, "classifier" if group == "judge" else "tier") for group, names in models.items()
)
return tuple(dict.fromkeys(chain.from_iterable(dependencies)))
if kind == "adaptive":
return _pool(_mapping(litellm_params.get("adaptive_router_config")).get("available_models"), "tier")
if kind == "quality":
@ -363,6 +374,22 @@ def capability_limit_violation(*, capability: GatedAutoRouterCapability, held: i
)
def validate_switchyard_router_config_write(config: Mapping[str, object] | None) -> str | None:
from pydantic import ValidationError
from litellm.router_strategy.switchyard_router.config import SwitchyardRouterConfig
if config is None:
return None
try:
_ = SwitchyardRouterConfig.model_validate(config)
except ValidationError as exc:
first: Final = exc.errors()[0]
location: Final = ".".join(str(part) for part in first.get("loc", ())) or "switchyard_router_config"
return f"switchyard_router_config is invalid at {location}: {first.get('msg', 'invalid value')}"
return None
def validate_complexity_router_config_write(complexity_router_config: Mapping[str, object] | None) -> str | None:
"""Reject a complexity config the router would refuse to build a deployment from.

View file

@ -0,0 +1,4 @@
from .config import SwitchyardRouterConfig
from .switchyard_router import SwitchyardAutoRouter
__all__ = ["SwitchyardAutoRouter", "SwitchyardRouterConfig"]

View file

@ -0,0 +1,141 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Final, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class CapabilityConfig(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
base_threshold: float = Field(default=0.5, ge=0, le=1)
threshold_step: float = Field(default=0, ge=0, le=1)
session_affinity: bool = False
message_hash_fallback: bool = False
recent_turn_window: int | None = Field(default=None, gt=0)
max_output_tokens: int = Field(default=4096, gt=0)
prompt: str | None = None
response_format_type: Literal["json_schema", "json_object"] = "json_schema"
@model_validator(mode="after")
def validate_affinity(self) -> CapabilityConfig:
if self.message_hash_fallback and not self.session_affinity:
raise ValueError("message_hash_fallback requires session_affinity")
return self
class DeescalationConfig(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
strong_min_calls: int = Field(gt=0)
confirmations: int = Field(gt=0)
strong_max_calls: int | None = Field(default=None, gt=0)
weak_cooldown_calls: int = Field(default=0, ge=0)
@model_validator(mode="after")
def validate_call_bounds(self) -> DeescalationConfig:
if self.strong_max_calls is not None and self.strong_max_calls < self.strong_min_calls:
raise ValueError("strong_max_calls must be at least strong_min_calls")
return self
class EscalationConfig(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
confirmations: int = Field(default=2, gt=0)
recent_turn_window: int = Field(default=28, gt=0)
window_message_chars: int = Field(default=500, ge=50)
max_output_tokens: int = Field(default=4096, gt=0)
prompt: str | None = None
response_format_type: Literal["json_schema", "json_object"] = "json_schema"
deescalation: DeescalationConfig | None = None
class CustomConfig(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
prompt: str = Field(min_length=1)
response_schema: Mapping[str, object]
selector: str = Field(min_length=1)
default_target: str = Field(min_length=1)
session_affinity: bool = False
message_hash_fallback: bool = False
recent_turn_window: int | None = Field(default=None, gt=0)
max_output_tokens: int = Field(default=4096, gt=0)
@model_validator(mode="after")
def validate_affinity(self) -> CustomConfig:
if self.message_hash_fallback and not self.session_affinity:
raise ValueError("message_hash_fallback requires session_affinity")
return self
class StageConfig(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
picker: Literal["efficient_first", "capable_first"] = "efficient_first"
confidence_threshold: float = Field(default=0.5, ge=0, le=1)
recent_window: int | None = Field(default=None, gt=0)
escalation_note: str | None = None
deescalation_note: str | None = None
only_on_wrong_signal_escalation: bool = True
capable_system_prompt: str | None = None
efficient_system_prompt: str | None = None
tool_semantics: Mapping[str, tuple[str, ...]] | None = None
classifier: CapabilityConfig | None = None
@model_validator(mode="after")
def validate_notes_and_tools(self) -> StageConfig:
if self.deescalation_note is not None and self.escalation_note is None:
raise ValueError("deescalation_note requires escalation_note")
if self.tool_semantics is not None and set(self.tool_semantics) - {"observe", "mutate", "plan", "new"}:
raise ValueError("tool_semantics supports observe, mutate, plan, and new categories")
return self
class SwitchyardRouterConfig(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
algorithm: Literal["random", "capability", "custom", "escalation", "stage"] = "capability"
models: Mapping[str, tuple[str, ...]]
weights: tuple[float, ...] | None = None
seed: int | None = Field(default=None, ge=0, le=18446744073709551615)
fallback_enabled: bool = True
capability: CapabilityConfig = Field(default_factory=CapabilityConfig)
escalation: EscalationConfig = Field(default_factory=EscalationConfig)
custom: CustomConfig | None = None
stage: StageConfig = Field(default_factory=StageConfig)
@model_validator(mode="after")
def validate_models(self) -> SwitchyardRouterConfig:
if any(
not group or not names or any(not name.strip() for name in names) for group, names in self.models.items()
):
raise ValueError("model groups and model names must be nonempty")
required: Final = self.required_groups()
if any(group not in self.models for group in required):
raise ValueError(f"{self.algorithm} requires model groups {', '.join(required)}")
if self.algorithm == "custom" and self.custom is None:
raise ValueError("custom routing requires custom configuration")
if self.custom is not None and self.custom.default_target not in self.models:
raise ValueError("custom.default_target must name a configured model group")
if self.weights is not None:
import math
if self.algorithm != "random":
raise ValueError("weights are only supported for random routing")
if len(self.weights) != len(self.models.get("any", ())):
raise ValueError("weights must match the any model group")
if any(not math.isfinite(weight) or weight < 0 for weight in self.weights) or not any(self.weights):
raise ValueError("weights must be finite, nonnegative, and have a positive total")
return self
def required_groups(self) -> tuple[str, ...]:
if self.algorithm == "random":
return ("any",)
if self.algorithm == "custom":
return ("judge",)
if self.algorithm == "stage" and self.stage.classifier is None:
return ("efficient", "capable")
return ("efficient", "capable", "judge")

View file

@ -0,0 +1,178 @@
from __future__ import annotations
import json
from collections.abc import AsyncIterator, Awaitable, Callable, Generator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar
from hashlib import sha256
from typing import Final
from litellm._uuid import uuid
from litellm.litellm_core_utils.internal_call_metadata import forwarded_internal_call_metadata
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import convert_to_streaming_response_async
from litellm.types.utils import ModelResponse, ModelResponseStream
ACTIVE_SWITCHYARD: Final[ContextVar[bool]] = ContextVar("active_switchyard", default=False)
_EXECUTION_CONTEXT_KEYS: Final = frozenset(
{
"litellm_trace_id",
"litellm_session_id",
"timeout",
"request_timeout",
"num_retries",
"disable_fallbacks",
"fallbacks",
"context_window_fallbacks",
"content_policy_fallbacks",
"tags",
"enable_tag_filtering",
"proxy_server_request",
"turn_off_message_logging",
"user",
"guardrails",
}
)
_CALL_LOCAL_KEYS: Final = frozenset(
{
"model",
"original_function",
"litellm_call_id",
"litellm_logging_obj",
"metadata",
"litellm_metadata",
}
)
@contextmanager
def switchyard_execution() -> Generator[None]:
token: Final = ACTIVE_SWITCHYARD.set(True)
try:
yield
finally:
ACTIVE_SWITCHYARD.reset(token)
def child_call_kwargs(parent: Mapping[str, object], payload: Mapping[str, object]) -> Mapping[str, object]:
metadata: Final = parent.get("metadata")
litellm_metadata: Final = parent.get("litellm_metadata")
return {
**{key: value for key, value in parent.items() if key in _EXECUTION_CONTEXT_KEYS},
**{key: value for key, value in payload.items() if key not in _CALL_LOCAL_KEYS},
"metadata": forwarded_internal_call_metadata(
metadata if isinstance(metadata, Mapping) else None, "switchyard_router"
),
**(
{"litellm_metadata": forwarded_internal_call_metadata(litellm_metadata, "switchyard_router")}
if isinstance(litellm_metadata, Mapping)
else {}
),
"litellm_call_id": str(uuid.uuid4()),
}
def scoped_switchyard_headers(
request: Mapping[str, object], message_hash_fallback: bool = False, sdk_namespace: str | None = None
) -> Mapping[str, str]:
proxy_request: Final = request.get("proxy_server_request")
raw_headers: Final = proxy_request.get("headers") if isinstance(proxy_request, Mapping) else None
headers: Final = raw_headers if isinstance(raw_headers, Mapping) else {}
metadata: Final = request.get("litellm_metadata") or request.get("metadata")
caller: Final = metadata if isinstance(metadata, Mapping) else {}
caller_key: Final = caller.get("user_api_key_hash")
sdk_session: Final = request.get("litellm_session_id")
sdk_context: Final = (
sdk_namespace is not None
and proxy_request is None
and "user_api_key_auth" not in caller
and isinstance(sdk_session, str)
and bool(sdk_session)
)
caller_scope: Final = (
(caller_key, caller.get("user_api_key_team_id"))
if isinstance(caller_key, str) and caller_key
else (sdk_namespace,)
if sdk_context
else (str(uuid.uuid4()),)
)
scope: Final = json.dumps(caller_scope, sort_keys=True)
normalized: Final = {key.lower(): value for key, value in headers.items() if isinstance(key, str)}
aliases: Final = (
"x-switchyard-session-id",
"x-claude-code-session-id",
"x-nemo-relay-session-id",
"x-session-id",
"session-id",
)
session: Final = (
sdk_session
if sdk_context
else next((normalized[key] for key in aliases if isinstance(normalized.get(key), str)), None)
)
fallback: Final = (
json.dumps(request.get("messages"), sort_keys=True, default=str) if message_hash_fallback else str(uuid.uuid4())
)
return {"x-switchyard-session-id": sha256(f"{scope}:{session or fallback}".encode()).hexdigest()}
class SwitchyardReplay:
def __init__(self, response: ModelResponse, include_usage: bool) -> None:
self._hidden_params = response._hidden_params # pyright: ignore[reportPrivateUsage] # proxy response metadata contract
self.response: Final = response
self.include_usage: Final = include_usage
def __aiter__(self) -> AsyncIterator[ModelResponseStream]:
return self._frames()
async def _frames(self) -> AsyncIterator[ModelResponseStream]:
payload: Final = self.response.model_dump(exclude={"usage"})
async for frame in convert_to_streaming_response_async(payload):
yield frame
if self.include_usage:
usage_frame: Final = ModelResponseStream(
id=self.response.id,
created=self.response.created,
model=self.response.model,
choices=[],
usage=self.response.usage,
)
yield usage_frame
class FinalizingStream:
def __init__(
self,
stream: AsyncIterator[ModelResponseStream],
finalize: Callable[[], Awaitable[None]],
close: Callable[[], Awaitable[None]],
hidden_params: object,
logging_obj: object,
) -> None:
self.stream: Final = stream
self.finalize: Final = finalize
self.close: Final = close
self._hidden_params = hidden_params
self.logging_obj = logging_obj
self.closed = False
def __aiter__(self) -> FinalizingStream:
return self
async def __anext__(self) -> ModelResponseStream:
if self.closed:
raise StopAsyncIteration
try:
with switchyard_execution():
return await self.stream.__anext__()
except BaseException:
await self.aclose()
raise
async def aclose(self) -> None:
if self.closed:
return
self.closed = True
try:
await self.close()
finally:
await self.finalize()

View file

@ -0,0 +1,249 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
from importlib import import_module
from typing import Final, Protocol, runtime_checkable
from .config import CapabilityConfig, SwitchyardRouterConfig
class Aggregate(Protocol):
@property
def response(self) -> Mapping[str, object]: ...
class ModelCall(Protocol):
@property
def models(self) -> Sequence[str]: ...
@property
def request(self) -> Mapping[str, object]: ...
async def respond(self, work: Awaitable[Aggregate]) -> None: ...
class ModelStep(Protocol):
@property
def call(self) -> ModelCall: ...
class Outcome(Protocol):
@property
def selected_model_ids(self) -> Sequence[str]: ...
@property
def request(self) -> Mapping[str, object]: ...
@property
def fallback_request(self) -> Mapping[str, object] | None: ...
@property
def response(self) -> Aggregate | None: ...
class DoneStep(Protocol):
@property
def outcome(self) -> Outcome: ...
class StepTypes(Protocol):
CallModel: type[ModelStep]
Done: type[DoneStep]
class ResponseTypes(Protocol):
Agg: Callable[[Mapping[str, object]], Aggregate]
class Algorithm(Protocol):
def run_stream(
self,
request: Mapping[str, object],
models: Mapping[str, Sequence[str]],
*,
headers: Mapping[str, str] | None = None,
) -> AsyncIterator[ModelStep | DoneStep]: ...
class ClassifierFactory(Protocol):
def capability(self, *, config: object) -> object: ...
def escalation(self, *, config: object) -> object: ...
def custom(self, *, default_target: str, config: object) -> object: ...
@runtime_checkable
class NativeApi(Protocol):
Step: StepTypes
LlmResponse: ResponseTypes
LlmClassifierConfig: ClassifierFactory
ContextWindowExceededError: type[Exception]
LibsyError: type[Exception]
def TaskClassifierConfig(
self,
base_threshold: float,
*,
threshold_step: float,
session_affinity: bool,
message_hash_fallback: bool,
recent_turn_window: int | None,
max_output_tokens: int,
prompt: str | None,
response_format_type: str,
) -> object: ...
def CustomClassifierConfig(
self,
prompt: str,
response_schema: Mapping[str, object],
selector: str,
*,
session_affinity: bool,
message_hash_fallback: bool,
recent_turn_window: int | None,
max_output_tokens: int,
) -> object: ...
def EscalationClassifierConfig(
self,
*,
confirmations: int,
recent_turn_window: int,
window_message_chars: int,
deescalation: object | None,
max_output_tokens: int,
prompt: str | None,
response_format_type: str,
) -> object: ...
def DeescalationConfig(
self,
*,
strong_min_calls: int,
confirmations: int,
strong_max_calls: int | None,
weak_cooldown_calls: int,
) -> object: ...
def LlmFallback(self, *, config: object) -> object: ...
def random(self, *, weights: Sequence[float] | None, seed: int | None) -> Algorithm: ...
def llm_classifier(self, config: object) -> Algorithm: ...
def stage_router(
self,
*,
picker: str,
confidence_threshold: float,
recent_window: int | None,
escalation_note: str | None,
deescalation_note: str | None,
only_on_wrong_signal_escalation: bool,
capable_system_prompt: str | None,
efficient_system_prompt: str | None,
tool_semantics: Mapping[str, Sequence[str]] | None,
classifier: object | None,
) -> Algorithm: ...
class NativeExtension(Protocol):
@property
def libsy(self) -> NativeApi: ...
@runtime_checkable
class NativeLoader(Protocol):
def load_native(self) -> NativeExtension: ...
def load_native() -> NativeApi:
try:
module: Final = import_module("switchyard_rust._native")
except ImportError as error:
raise ImportError("Switchyard routing requires the optional dependency nemo-switchyard==0.4.0a1") from error
if not isinstance(module, NativeLoader):
raise TypeError("Installed Switchyard does not expose the required libsy API")
return module.load_native().libsy
def capability_config(api: NativeApi, config: CapabilityConfig) -> object:
return api.TaskClassifierConfig(
base_threshold=config.base_threshold,
threshold_step=config.threshold_step,
session_affinity=config.session_affinity,
message_hash_fallback=config.message_hash_fallback,
recent_turn_window=config.recent_turn_window,
max_output_tokens=config.max_output_tokens,
prompt=config.prompt,
response_format_type=config.response_format_type,
)
def build_algorithm(api: NativeApi, config: SwitchyardRouterConfig) -> Algorithm:
if config.algorithm == "random":
return api.random(weights=config.weights, seed=config.seed)
if config.algorithm == "stage":
stage: Final = config.stage
fallback: Final = (
api.LlmFallback(config=capability_config(api, stage.classifier)) if stage.classifier is not None else None
)
return api.stage_router(
picker=stage.picker,
confidence_threshold=stage.confidence_threshold,
recent_window=stage.recent_window,
escalation_note=stage.escalation_note,
deescalation_note=stage.deescalation_note,
only_on_wrong_signal_escalation=stage.only_on_wrong_signal_escalation,
capable_system_prompt=stage.capable_system_prompt,
efficient_system_prompt=stage.efficient_system_prompt,
tool_semantics=stage.tool_semantics,
classifier=fallback,
)
if config.algorithm == "capability":
return api.llm_classifier(api.LlmClassifierConfig.capability(config=capability_config(api, config.capability)))
if config.algorithm == "custom":
custom: Final = config.custom
if custom is None:
raise ValueError("custom routing requires custom configuration")
return api.llm_classifier(
api.LlmClassifierConfig.custom(
default_target=custom.default_target,
config=api.CustomClassifierConfig(
prompt=custom.prompt,
response_schema=custom.response_schema,
selector=custom.selector,
session_affinity=custom.session_affinity,
message_hash_fallback=custom.message_hash_fallback,
recent_turn_window=custom.recent_turn_window,
max_output_tokens=custom.max_output_tokens,
),
)
)
escalation: Final = config.escalation
release: Final = escalation.deescalation
deescalation: Final = (
api.DeescalationConfig(
strong_min_calls=release.strong_min_calls,
confirmations=release.confirmations,
strong_max_calls=release.strong_max_calls,
weak_cooldown_calls=release.weak_cooldown_calls,
)
if release is not None
else None
)
return api.llm_classifier(
api.LlmClassifierConfig.escalation(
config=api.EscalationClassifierConfig(
confirmations=escalation.confirmations,
recent_turn_window=escalation.recent_turn_window,
window_message_chars=escalation.window_message_chars,
deescalation=deescalation,
max_output_tokens=escalation.max_output_tokens,
prompt=escalation.prompt,
response_format_type=escalation.response_format_type,
)
)
)

View file

@ -0,0 +1,154 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Final, TypeAlias
from uuid import uuid4
from litellm.exceptions import (
APIConnectionError,
APIError,
ContextWindowExceededError,
InternalServerError,
RateLimitError,
ServiceUnavailableError,
Timeout,
)
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
from litellm.types.router import PreRoutingHookResponse
from litellm.types.utils import ModelResponse
from .config import SwitchyardRouterConfig
from .native import Aggregate, NativeApi, build_algorithm, load_native
from .translation import chat_request, normalize_request, normalize_response
Executor: TypeAlias = Callable[[str, Mapping[str, object]], Awaitable[ModelResponse | CustomStreamWrapper]]
def eligible_fallback(error: Exception) -> bool:
return isinstance(
error,
(
APIConnectionError,
Timeout,
RateLimitError,
ContextWindowExceededError,
InternalServerError,
ServiceUnavailableError,
),
) or (isinstance(error, APIError) and error.status_code >= 500)
class _Execution:
def __init__(
self, config: SwitchyardRouterConfig, api: NativeApi, request: Mapping[str, object], executor: Executor
) -> None:
self.config = config
self.api = api
self.request = request
self.executor = executor
self.responses: Mapping[str, ModelResponse] = {}
self.provider_error: Exception | None = None
async def call_candidates(
self,
candidates: Sequence[str],
request: Mapping[str, object],
*,
stream: bool,
fallback_request: Mapping[str, object] | None = None,
intermediate: bool = False,
) -> ModelResponse | CustomStreamWrapper:
if not candidates:
raise ValueError("Switchyard returned no model candidates")
target: Final = candidates[0]
if not any(target in models for models in self.config.models.values()):
raise ValueError("Switchyard selected an unconfigured model")
payload: Final = chat_request(
request,
self.request,
model=target,
stream=stream,
judge=intermediate and target in self.config.models.get("judge", ()) and bool(request.get("instructions")),
)
try:
return await self.executor(target, payload)
except Exception as error:
if not self.config.fallback_enabled or len(candidates) == 1 or not eligible_fallback(error):
raise
return await self.call_candidates(
candidates[1:], fallback_request or request, stream=stream, intermediate=intermediate
)
async def respond(self, candidates: Sequence[str], request: Mapping[str, object]) -> Aggregate:
try:
response: Final = await self.call_candidates(candidates, request, stream=False, intermediate=True)
except Exception as error:
self.provider_error = error
if isinstance(error, ContextWindowExceededError):
raise self.api.ContextWindowExceededError(str(error)) from error
raise
self.provider_error = None
if not isinstance(response, ModelResponse):
raise TypeError("Switchyard intermediate calls must return aggregate chat responses")
response_id: Final = str(uuid4())
self.responses = {**self.responses, response_id: response}
return self.api.LlmResponse.Agg(normalize_response(response, response_id))
def completed_response(self, aggregate: Aggregate) -> ModelResponse:
response_id: Final = aggregate.response.get("id")
if not isinstance(response_id, str) or response_id not in self.responses:
raise ValueError("Switchyard returned an unknown completed response")
return self.responses[response_id]
class SwitchyardAutoRouter:
def __init__(self, config: SwitchyardRouterConfig, *, native_api: NativeApi | None = None) -> None:
self.config = config
self.session_namespace: Final[str] = str(uuid4())
self._api = native_api if native_api is not None else load_native()
self._algorithm = build_algorithm(self._api, config)
async def async_pre_routing_hook(
self,
model: str,
request_kwargs: Mapping[str, object],
messages: Sequence[Mapping[str, object]] | None = None,
input: str | Sequence[object] | None = None,
specific_deployment: bool | None = False,
) -> PreRoutingHookResponse | None:
raise ValueError("Switchyard supports async chat completions only")
async def execute(
self, request: Mapping[str, object], executor: Executor, headers: Mapping[str, str] | None = None
) -> ModelResponse | CustomStreamWrapper:
normalized: Final = normalize_request(request)
execution: Final = _Execution(self.config, self._api, request, executor)
try:
return await self._run(execution, normalized, request.get("stream") is True, headers)
except self._api.LibsyError:
if execution.provider_error is not None:
raise execution.provider_error
raise
async def _run(
self,
execution: _Execution,
normalized: Mapping[str, object],
stream: bool,
headers: Mapping[str, str] | None,
) -> ModelResponse | CustomStreamWrapper:
async for step in self._algorithm.run_stream(normalized, self.config.models, headers=headers):
if isinstance(step, self._api.Step.CallModel):
await step.call.respond(execution.respond(step.call.models, step.call.request))
elif isinstance(step, self._api.Step.Done):
if step.outcome.response is not None:
return execution.completed_response(step.outcome.response)
return await execution.call_candidates(
step.outcome.selected_model_ids,
step.outcome.request,
stream=stream,
fallback_request=step.outcome.fallback_request,
)
else:
raise TypeError("Switchyard returned a step unsupported by the configured chat routing algorithms")
raise ValueError("Switchyard ended without a routing outcome")

View file

@ -0,0 +1,226 @@
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from typing import Final
from pydantic import BaseModel, TypeAdapter
from litellm.types.utils import ModelResponse
_MAPPING: Final = TypeAdapter(Mapping[str, object])
_SEQUENCE: Final = TypeAdapter(tuple[object, ...])
_CONTROLLED: Final = frozenset(
{
"model",
"messages",
"tools",
"tool_choice",
"temperature",
"top_p",
"top_k",
"max_tokens",
"max_completion_tokens",
"response_format",
"reasoning_effort",
"stream",
"stream_options",
}
)
def mapping(value: object) -> Mapping[str, object]:
return _MAPPING.validate_python(value)
def sequence(value: object) -> tuple[object, ...]:
return _SEQUENCE.validate_python(value)
def text_blocks(content: object) -> tuple[Mapping[str, object], ...]:
if content is None:
return ()
if isinstance(content, str):
return ({"type": "text", "text": content},)
blocks: Final = tuple(mapping(block) for block in sequence(content))
if any(block.get("type") != "text" or not isinstance(block.get("text"), str) for block in blocks):
raise ValueError("Switchyard currently supports text and function-tool chat content only")
return blocks
def normalized_tool_call(raw: object) -> Mapping[str, object]:
call: Final = mapping(raw)
function: Final = mapping(call.get("function"))
arguments: Final = function.get("arguments")
if call.get("type") != "function" or not isinstance(arguments, str):
raise ValueError("Switchyard supports function tool calls with JSON arguments only")
return {"type": "tool_call", "id": call["id"], "name": function["name"], "arguments": json.loads(arguments)}
def normalized_message(raw: object) -> Mapping[str, object]:
message: Final = mapping(raw)
role: Final = message.get("role")
blocks: Final = text_blocks(message.get("content"))
if role == "tool":
return {
"role": "tool",
"content": [
{"type": "tool_result", "tool_call_id": message["tool_call_id"], "content": blocks, "is_error": None}
],
}
if role not in ("system", "developer", "user", "assistant"):
raise ValueError("Switchyard received an unsupported chat role")
calls: Final = tuple(normalized_tool_call(call) for call in sequence(message.get("tool_calls") or ()))
refusal: Final = message.get("refusal")
refusals: Final = ({"type": "refusal", "text": refusal},) if isinstance(refusal, str) else ()
return {"role": role, "content": (*blocks, *calls, *refusals)}
def normalized_tool(raw: object) -> Mapping[str, object]:
tool: Final = mapping(raw)
if tool.get("type") != "function":
raise ValueError("Switchyard currently supports function tools only")
function: Final = mapping(tool.get("function"))
return {
"name": function["name"],
"description": function.get("description"),
"parameters": function.get("parameters", {}),
"strict": function.get("strict"),
}
def normalized_tool_choice(raw: object) -> Mapping[str, object] | None:
if raw is None:
return None
if raw in ("auto", "none", "required"):
return {"type": raw}
choice: Final = mapping(raw)
if choice.get("type") != "function":
raise ValueError("Switchyard currently supports function tool_choice only")
return {"type": "tool", "data": {"name": mapping(choice.get("function"))["name"]}}
def normalize_request(request: Mapping[str, object]) -> Mapping[str, object]:
messages: Final = tuple(normalized_message(message) for message in sequence(request.get("messages")))
if not messages:
raise ValueError("Switchyard requires at least one message")
if request.get("n", 1) != 1:
raise ValueError("Switchyard requires n=1")
return {
"model": "auto",
"instructions": [],
"messages": messages,
"tools": tuple(normalized_tool(tool) for tool in sequence(request.get("tools") or ())),
"tool_choice": normalized_tool_choice(request.get("tool_choice")),
"sampling": {
"temperature": request.get("temperature"),
"top_p": request.get("top_p"),
"top_k": request.get("top_k"),
},
"output": {
"max_output_tokens": request.get("max_completion_tokens", request.get("max_tokens")),
"response_format": request.get("response_format"),
},
"reasoning": {"effort": request.get("reasoning_effort"), "raw": None},
"stream": False,
}
def chat_message(raw: object, originals: Sequence[object]) -> Mapping[str, object]:
message: Final = mapping(raw)
for original in originals:
if json.dumps(normalized_message(original), sort_keys=True) == json.dumps(message, sort_keys=True):
return mapping(original)
blocks: Final = tuple(mapping(block) for block in sequence(message.get("content", ())))
text: Final = "".join(str(block["text"]) for block in blocks if block.get("type") == "text")
calls: Final = tuple(
{
"id": block["id"],
"type": "function",
"function": {"name": block["name"], "arguments": json.dumps(block["arguments"])},
}
for block in blocks
if block.get("type") == "tool_call"
)
if message.get("role") == "tool":
if len(blocks) != 1 or blocks[0].get("type") != "tool_result":
raise ValueError("Switchyard produced an unsupported tool result")
result: Final = blocks[0]
return {
"role": "tool",
"tool_call_id": result["tool_call_id"],
"content": tuple(mapping(block) for block in sequence(result["content"])),
}
if any(block.get("type") not in ("text", "tool_call", "refusal") for block in blocks):
raise ValueError("Switchyard produced unsupported normalized message content")
refusals: Final = tuple(str(block["text"]) for block in blocks if block.get("type") == "refusal")
return {
"role": message["role"],
"content": text or None,
**({"tool_calls": calls} if calls else {}),
**({"refusal": "".join(refusals)} if refusals else {}),
}
def chat_tool_choice(raw: object) -> object:
if raw is None:
return None
choice: Final = mapping(raw)
if choice.get("type") in ("auto", "none", "required"):
return choice["type"]
if choice.get("type") == "tool":
return {"type": "function", "function": mapping(choice["data"])}
raise ValueError("Switchyard produced unsupported tool_choice")
def chat_request(
normalized: Mapping[str, object], original: Mapping[str, object], *, model: str, stream: bool, judge: bool
) -> Mapping[str, object]:
originals: Final = sequence(original.get("messages", ()))
instructions: Final = sequence(normalized.get("instructions", ()))
messages: Final = sequence(normalized.get("messages", ()))
sampling: Final = mapping(normalized.get("sampling", {}))
output: Final = mapping(normalized.get("output", {}))
reasoning: Final = mapping(normalized.get("reasoning", {}))
token_key: Final = "max_completion_tokens" if not judge and "max_completion_tokens" in original else "max_tokens"
options: Final[Mapping[str, object]] = {
**sampling,
token_key: output.get("max_output_tokens"),
"response_format": output.get("response_format"),
"reasoning_effort": reasoning.get("effort"),
"tool_choice": chat_tool_choice(normalized.get("tool_choice")),
}
tools: Final = tuple(
{"type": "function", "function": {k: v for k, v in mapping(tool).items() if v is not None}}
for tool in sequence(normalized.get("tools", ()))
)
passthrough: Final = {} if judge else {key: value for key, value in original.items() if key not in _CONTROLLED}
return {
**passthrough,
**{key: value for key, value in options.items() if value is not None},
"model": model,
"messages": [chat_message(message, originals) for message in (*instructions, *messages)],
"stream": stream,
**({"tools": tools} if tools else {}),
**({"stream_options": original["stream_options"]} if stream and "stream_options" in original else {}),
}
def normalize_response(response: ModelResponse, response_id: str) -> Mapping[str, object]:
body: Final = mapping(BaseModel.model_dump(response))
choices: Final = tuple(mapping(choice) for choice in sequence(body.get("choices")))
return {"id": response_id, "model": body.get("model"), "outputs": tuple(normalized_output(c) for c in choices)}
def normalized_output(choice: Mapping[str, object]) -> Mapping[str, object]:
reasons: Final = {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
"content_filter": "content_filter",
}
reason: Final = choice.get("finish_reason")
return {
**normalized_message(choice.get("message")),
"stop_reason": reasons.get(str(reason), "unknown") if reason is not None else None,
}

View file

@ -217,6 +217,7 @@ class SpecializedRouterOptions:
complexity_router_default_model: str | None = None
adaptive_router_config: Mapping[str, object] | None = None
adaptive_router_default_model: str | None = None
switchyard_router_config: Mapping[str, object] | None = None
quality_router_config: Mapping[str, object] | None = None
quality_router_default_model: str | None = None

View file

@ -676,6 +676,7 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams):
adaptive_router_default_model: str | None = None
adaptive_router_config: dict | None = None
# quality-router params
switchyard_router_config: Mapping[str, object] | None = None
quality_router_config: dict | None = None
quality_router_default_model: str | None = None

View file

@ -3284,6 +3284,7 @@ ClassifierFailureReason = Literal[
InternalCallOrigin = Literal[
"switchyard_router",
"autorouter_classifier",
"autorouter_compaction",
"shadow_eval_router",
@ -3314,7 +3315,7 @@ class StandardLoggingRoutingDecision(TypedDict, total=False):
"""Per-request provenance for a pre-routing strategy (auto-router) decision."""
router_model_name: str
router_type: ReadOnly[Literal["complexity", "adaptive", "quality", "semantic"]]
router_type: ReadOnly[Literal["complexity", "adaptive", "quality", "semantic", "switchyard"]]
router_config_id: ReadOnly[str]
router_config_updated_at: ReadOnly[str]
router_config_fingerprint: ReadOnly[str]

View file

@ -54,6 +54,7 @@ Documentation = "https://docs.litellm.ai"
# sub-packages and the opentelemetry trio move in lockstep, and grpcio is
# supply-chain-pinned to a vetted, aged release.
[project.optional-dependencies]
switchyard = ["nemo-switchyard==0.4.0a1"]
proxy = [
"gunicorn>=23.0.0,<24.0",
"uvicorn>=0.33.0,<1.0",
@ -202,6 +203,7 @@ litellm-proxy = "litellm_cli.main:litellm_proxy_cli"
[dependency-groups]
dev = [
"nemo-switchyard==0.4.0a1",
"numpy>=1.26.0,<3.0",
"diff-cover==9.7.2",
"hypothesis==6.165.10",

View file

@ -0,0 +1,61 @@
from typing import Final
import pytest
from pydantic import ValidationError
from litellm.router_strategy.switchyard_router.config import SwitchyardRouterConfig
@pytest.mark.parametrize(
"extra",
[
{"weights": [1]},
{"weights": [-1, 2]},
{"weights": [0, 0]},
{"weights": [float("inf"), 1]},
{"models": {"any": []}},
{"models": {"any": [""]}},
{"seed": -1},
{"unknown": True},
],
)
def test_invalid_random_configuration_is_rejected(extra: dict[str, object]) -> None:
with pytest.raises(ValidationError):
SwitchyardRouterConfig.model_validate({"algorithm": "random", "models": {"any": ["a", "b"]}, **extra})
@pytest.mark.parametrize(
"extra",
[
{"capability": {"message_hash_fallback": True}},
{"capability": {"base_threshold": 1.1}},
{"escalation": {"deescalation": {"strong_min_calls": 5, "strong_max_calls": 2, "confirmations": 1}}},
{"stage": {"deescalation_note": "return"}},
{"stage": {"tool_semantics": {"unknown": ["tool"]}}},
],
)
def test_inconsistent_classifier_configuration_is_rejected(extra: dict[str, object]) -> None:
with pytest.raises(ValidationError):
SwitchyardRouterConfig.model_validate(
{
"models": {"efficient": ["a"], "capable": ["b"], "judge": ["j"]},
**extra,
}
)
def test_custom_configuration_roundtrip_keeps_schema_and_fallback_order() -> None:
source: Final[dict[str, object]] = {
"algorithm": "custom",
"models": {"judge": ["j"], "code": ["a", "b"]},
"custom": {
"prompt": "Choose a target",
"selector": "/target",
"default_target": "code",
"response_schema": {"type": "object", "properties": {"target": {"type": "string"}}},
},
}
parsed: Final = SwitchyardRouterConfig.model_validate(source)
restored: Final = SwitchyardRouterConfig.model_validate_json(parsed.model_dump_json())
assert restored == parsed
assert restored.models["code"] == ("a", "b")

View file

@ -0,0 +1,143 @@
from typing import Final
import pytest
from litellm.router_strategy.switchyard_router.execution import (
SwitchyardReplay,
child_call_kwargs,
scoped_switchyard_headers,
)
from litellm.types.utils import ModelResponse
def test_child_calls_preserve_caller_without_sharing_log_identity_or_budget_reservation():
parent: Final = {
"metadata": {"user_api_key_hash": "caller", "user_api_key_budget_reservation": "reserved"},
"litellm_call_id": "parent",
"litellm_logging_obj": object(),
"litellm_trace_id": "trace",
}
payload: Final = {"model": "fast", "messages": [{"role": "user", "content": "hello"}], "stream": False}
first: Final = child_call_kwargs(parent, payload)
second: Final = child_call_kwargs(parent, payload)
assert first["litellm_call_id"] != second["litellm_call_id"]
assert first["litellm_trace_id"] == "trace"
assert "litellm_logging_obj" not in first
assert first["metadata"] == {"user_api_key_hash": "caller", "internal_call_origin": "switchyard_router"}
assert parent["metadata"]["user_api_key_budget_reservation"] == "reserved"
def test_session_cache_keys_are_scoped_and_never_forward_credentials():
request: Final = {
"metadata": {"user_api_key_hash": "first"},
"proxy_server_request": {"headers": {"authorization": "secret", "x-session-id": "same-session"}},
}
first: Final = scoped_switchyard_headers(request)
other: Final = scoped_switchyard_headers({**request, "metadata": {"user_api_key_hash": "second"}})
assert tuple(first) == ("x-switchyard-session-id",)
assert first == scoped_switchyard_headers(request)
assert first != other
assert "secret" not in str(first)
assert "same-session" not in str(first)
def test_message_hash_fallback_is_scoped_without_client_session_header():
request: Final = {"metadata": {"user_api_key_hash": "first"}, "messages": [{"role": "user", "content": "same"}]}
first: Final = scoped_switchyard_headers(request, True)
assert first == scoped_switchyard_headers(request, True)
assert first != scoped_switchyard_headers({**request, "metadata": {"user_api_key_hash": "second"}}, True)
@pytest.mark.asyncio
@pytest.mark.parametrize("include_usage", [True, False])
async def test_buffered_stream_replays_content_and_preserves_original_usage(include_usage: bool):
response: Final = ModelResponse(
id="answer",
model="fast",
created=123,
choices=[{"index": 0, "message": {"role": "assistant", "content": "hello world"}, "finish_reason": "stop"}],
usage={"prompt_tokens": 4, "completion_tokens": 2, "total_tokens": 6},
)
replay: Final = SwitchyardReplay(response, include_usage)
frames: Final = [frame async for frame in replay]
assert "".join(choice.delta.content or "" for frame in frames for choice in frame.choices) == "hello world"
assert any(choice.finish_reason == "stop" for frame in frames for choice in frame.choices)
if include_usage:
assert frames[-1].usage == response.usage
assert frames[-1].choices == []
else:
assert all(getattr(frame, "usage", None) is None for frame in frames)
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", [False, True])
async def test_stream_finalizes_once_on_completion_or_failure(failure: bool):
from collections.abc import AsyncIterator
from unittest.mock import AsyncMock
from litellm.router_strategy.switchyard_router.execution import FinalizingStream
from litellm.types.utils import ModelResponseStream
async def source() -> AsyncIterator[ModelResponseStream]:
yield ModelResponseStream(id="first")
if failure:
raise ValueError("provider failed")
stream: Final = source()
finalize: Final = AsyncMock()
wrapped: Final = FinalizingStream(stream, finalize, stream.aclose, {}, None)
assert (await anext(wrapped)).id == "first"
with pytest.raises(ValueError if failure else StopAsyncIteration):
await anext(wrapped)
await wrapped.aclose()
finalize.assert_awaited_once()
@pytest.mark.asyncio
async def test_stream_finalizes_on_disconnect_before_first_chunk():
from collections.abc import AsyncIterator
from unittest.mock import AsyncMock
from litellm.router_strategy.switchyard_router.execution import FinalizingStream
from litellm.types.utils import ModelResponseStream
async def source() -> AsyncIterator[ModelResponseStream]:
yield ModelResponseStream(id="unread")
stream: Final = source()
finalize: Final = AsyncMock()
wrapped: Final = FinalizingStream(stream, finalize, stream.aclose, {}, None)
await wrapped.aclose()
with pytest.raises(StopAsyncIteration):
await anext(wrapped)
finalize.assert_awaited_once()
def test_judge_payload_does_not_inherit_parent_generation_controls():
parent: Final = {
"tools": [{"type": "function", "function": {"name": "run"}}],
"tool_choice": "required",
"max_completion_tokens": 1000,
"response_format": {"type": "json_object"},
"temperature": 0.9,
"timeout": 10,
"litellm_trace_id": "trace",
}
child: Final = child_call_kwargs(parent, {"messages": [{"role": "user", "content": "classify"}]})
assert not {"tools", "tool_choice", "max_completion_tokens", "response_format", "temperature"}.intersection(child)
assert child["timeout"] == 10
assert child["litellm_trace_id"] == "trace"
def test_sdk_sessions_are_stable_within_router_and_isolated_between_routers():
request: Final = {"litellm_session_id": "conversation"}
first: Final = scoped_switchyard_headers(request, sdk_namespace="router-one")
assert first == scoped_switchyard_headers(request, sdk_namespace="router-one")
assert first != scoped_switchyard_headers(request, sdk_namespace="router-two")
def test_proxy_missing_identity_cannot_reuse_sdk_scope():
request: Final = {"litellm_session_id": "conversation", "proxy_server_request": {"headers": {}}}
assert scoped_switchyard_headers(request, sdk_namespace="router") != scoped_switchyard_headers(
request, sdk_namespace="router"
)

View file

@ -0,0 +1,253 @@
import asyncio
from collections.abc import Mapping
from typing import Final
import pytest
from litellm.exceptions import APIError, AuthenticationError, ServiceUnavailableError
from litellm.router_strategy.switchyard_router import SwitchyardAutoRouter, SwitchyardRouterConfig
from litellm.types.utils import ModelResponse
def response(model: str, text: str) -> ModelResponse:
return ModelResponse(
model=model,
choices=[{"message": {"role": "assistant", "content": text}, "finish_reason": "stop"}],
usage={"prompt_tokens": 4, "completion_tokens": 3, "total_tokens": 7, "provider_extra_usage": {"count": 2}},
)
class ScriptedProvider:
def __init__(self, replies: Mapping[str, ModelResponse | Exception]):
self.replies = replies
self.calls: tuple[tuple[str, Mapping[str, object]], ...] = ()
async def __call__(self, model: str, request: Mapping[str, object]) -> ModelResponse:
self.calls = (*self.calls, (model, request))
reply: Final = self.replies[model]
if isinstance(reply, Exception):
raise reply
return reply
def classifier_config(algorithm: str, **extra: object) -> SwitchyardRouterConfig:
return SwitchyardRouterConfig.model_validate(
{
"algorithm": algorithm,
"models": {"efficient": ["fast"], "capable": ["strong"], "judge": ["judge"]},
**extra,
}
)
@pytest.mark.asyncio
async def test_weighted_routing_executes_only_selected_target_and_preserves_response() -> None:
answer: Final = response("fast", "done")
provider: Final = ScriptedProvider({"fast": answer})
router: Final = SwitchyardAutoRouter(
SwitchyardRouterConfig.model_validate(
{
"algorithm": "random",
"models": {"any": ["fast", "slow"]},
"weights": [1, 0],
"seed": 42,
}
)
)
actual: Final = await router.execute({"messages": [{"role": "user", "content": "hello"}]}, provider)
assert actual is answer
assert tuple(model for model, _ in provider.calls) == ("fast",)
@pytest.mark.asyncio
@pytest.mark.parametrize(("probability", "selected"), [(0.9, "fast"), (0.1, "strong")])
async def test_capability_judge_selects_model_and_does_not_receive_user_stop(probability: float, selected: str) -> None:
answer: Final = response(selected, "answer")
provider: Final = ScriptedProvider(
{
"judge": response(
"judge",
'{"p_solve":'
+ str(probability)
+ ',"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported"}',
),
selected: answer,
}
)
router: Final = SwitchyardAutoRouter(classifier_config("capability"))
actual: Final = await router.execute({"messages": [{"role": "user", "content": "hello"}], "stop": ["}"]}, provider)
assert actual is answer
assert tuple(model for model, _ in provider.calls) == ("judge", selected)
assert "stop" not in provider.calls[0][1]
assert provider.calls[1][1]["stop"] == ["}"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("verdict", "selected", "calls"),
[
('{"escalate":false,"category":"none","new_evidence":false,"reason":"progress"}', "fast", ("fast", "judge")),
(
'{"escalate":true,"category":"repetition","new_evidence":true,"reason":"stuck"}',
"strong",
("fast", "judge", "strong"),
),
],
)
async def test_escalation_reuses_accepted_answer_or_executes_capable_model(
verdict: str, selected: str, calls: tuple[str, ...]
) -> None:
efficient: Final = response("fast", "draft")
capable: Final = response("strong", "answer")
provider: Final = ScriptedProvider({"judge": response("judge", verdict), "fast": efficient, "strong": capable})
router: Final = SwitchyardAutoRouter(classifier_config("escalation", escalation={"confirmations": 1}))
actual: Final = await router.execute({"messages": [{"role": "user", "content": "hello"}]}, provider)
assert actual is (efficient if selected == "fast" else capable)
assert tuple(model for model, _ in provider.calls) == calls
assert actual.usage.model_dump()["provider_extra_usage"] == {"count": 2}
@pytest.mark.asyncio
async def test_custom_classifier_selects_runtime_group() -> None:
answer: Final = response("code", "result")
provider: Final = ScriptedProvider({"judge": response("judge", '{"target":"coding"}'), "code": answer})
router: Final = SwitchyardAutoRouter(
SwitchyardRouterConfig.model_validate(
{
"algorithm": "custom",
"models": {"judge": ["judge"], "coding": ["code"], "general": ["fast"]},
"custom": {
"prompt": "Choose target",
"selector": "/target",
"default_target": "general",
"response_schema": {
"type": "object",
"required": ["target"],
"properties": {"target": {"type": "string", "enum": ["coding", "general"]}},
},
},
}
)
)
actual: Final = await router.execute({"messages": [{"role": "user", "content": "write code"}]}, provider)
assert actual is answer
assert tuple(model for model, _ in provider.calls) == ("judge", "code")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"error", [APIError(503, "unavailable", "fake", "fast"), ServiceUnavailableError("unavailable", "fake", "fast")]
)
async def test_service_failure_uses_configured_fallback(error: Exception) -> None:
answer: Final = response("backup", "answer")
provider: Final = ScriptedProvider({"fast": error, "backup": answer})
router: Final = SwitchyardAutoRouter(
classifier_config(
"stage",
models={"efficient": ["fast", "backup"], "capable": ["strong"]},
)
)
actual: Final = await router.execute({"messages": [{"role": "user", "content": "hello"}]}, provider)
assert actual is answer
assert tuple(model for model, _ in provider.calls) == ("fast", "backup")
@pytest.mark.asyncio
async def test_authentication_failure_is_not_retried_on_another_model() -> None:
error: Final = AuthenticationError("denied", "fake", "fast")
provider: Final = ScriptedProvider({"fast": error})
router: Final = SwitchyardAutoRouter(
classifier_config(
"stage",
models={"efficient": ["fast", "backup"], "capable": ["strong"]},
)
)
with pytest.raises(AuthenticationError) as raised:
await router.execute({"messages": [{"role": "user", "content": "hello"}]}, provider)
assert raised.value is error
assert tuple(model for model, _ in provider.calls) == ("fast",)
@pytest.mark.asyncio
async def test_intermediate_authentication_error_preserves_public_error_type() -> None:
error: Final = AuthenticationError("denied", "fake", "fast")
provider: Final = ScriptedProvider({"fast": error})
router: Final = SwitchyardAutoRouter(classifier_config("escalation", escalation={"confirmations": 1}))
with pytest.raises(AuthenticationError) as raised:
await router.execute({"messages": [{"role": "user", "content": "hello"}]}, provider)
assert raised.value is error
assert tuple(model for model, _ in provider.calls) == ("fast",)
@pytest.mark.asyncio
async def test_escalation_confirmations_are_isolated_between_sessions() -> None:
efficient: Final = response("fast", "draft")
capable: Final = response("strong", "answer")
provider: Final = ScriptedProvider(
{
"fast": efficient,
"strong": capable,
"judge": response(
"judge", '{"escalate":true,"category":"repetition","new_evidence":true,"reason":"stuck"}'
),
}
)
router: Final = SwitchyardAutoRouter(classifier_config("escalation", escalation={"confirmations": 2}))
first: Final = await router.execute(
{"messages": [{"role": "user", "content": "first"}]}, provider, headers={"x-switchyard-session-id": "session-a"}
)
separate: Final = await router.execute(
{"messages": [{"role": "user", "content": "second"}]},
provider,
headers={"x-switchyard-session-id": "session-b"},
)
confirmed: Final = await router.execute(
{"messages": [{"role": "user", "content": "third"}]}, provider, headers={"x-switchyard-session-id": "session-a"}
)
assert (first is efficient, separate is efficient, confirmed is capable) == (True, True, True)
assert tuple(model for model, _ in provider.calls) == ("fast", "judge", "fast", "judge", "fast", "judge", "strong")
@pytest.mark.asyncio
async def test_escalation_buffers_intermediate_calls_for_stream_requests() -> None:
answer: Final = response("fast", "draft")
provider: Final = ScriptedProvider(
{
"fast": answer,
"judge": response("judge", '{"escalate":false,"category":"none","new_evidence":false,"reason":"progress"}'),
}
)
router: Final = SwitchyardAutoRouter(classifier_config("escalation", escalation={"confirmations": 1}))
actual: Final = await router.execute({"messages": [{"role": "user", "content": "hello"}], "stream": True}, provider)
assert actual is answer
assert tuple((model, payload["stream"]) for model, payload in provider.calls) == (("fast", False), ("judge", False))
@pytest.mark.asyncio
async def test_cancelling_judge_cancels_provider_work() -> None:
started: Final = asyncio.Event()
cancelled: Final = asyncio.Event()
never: Final = asyncio.Event()
async def pending_judge(model: str, request: Mapping[str, object]) -> ModelResponse:
started.set()
try:
await never.wait()
finally:
cancelled.set()
return response(model, "unreachable")
router: Final = SwitchyardAutoRouter(classifier_config("capability"))
task: Final = asyncio.create_task(
router.execute({"messages": [{"role": "user", "content": "hello"}]}, pending_judge)
)
entered: Final = asyncio.create_task(started.wait())
done, _ = await asyncio.wait((task, entered), return_when=asyncio.FIRST_COMPLETED)
if task in done:
entered.cancel()
await task
pytest.fail("Routing finished before the judge executed")
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert cancelled.is_set()

View file

@ -0,0 +1,95 @@
from typing import Final
import pytest
from litellm.router_strategy.switchyard_router.translation import chat_request, normalize_request, normalize_response
from litellm.types.utils import ModelResponse
def test_chat_roundtrip_preserves_tools_history_and_provider_options() -> None:
request: Final[dict[str, object]] = {
"messages": [
{"role": "system", "content": "Be precise"},
{"role": "user", "content": "Read the file", "name": "reader"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "call-1", "type": "function", "function": {"name": "read", "arguments": '{"path":"a"}'}}
],
},
{"role": "tool", "tool_call_id": "call-1", "content": "contents"},
],
"tools": [{"type": "function", "function": {"name": "read", "parameters": {"type": "object"}}}],
"tool_choice": {"type": "function", "function": {"name": "read"}},
"temperature": 0.2,
"max_completion_tokens": 50,
"parallel_tool_calls": False,
"stop": ["END"],
"metadata": {"trace": "abc"},
"stream": True,
"stream_options": {"include_usage": True},
}
actual: Final = chat_request(normalize_request(request), request, model="fast", stream=True, judge=False)
assert actual == {
**request,
"model": "fast",
"tools": ({"type": "function", "function": {"name": "read", "parameters": {"type": "object"}}},),
}
def test_text_response_with_null_tool_calls_normalizes() -> None:
response: Final = ModelResponse(
model="fast",
choices=[
{
"message": {"role": "assistant", "content": "answer", "tool_calls": None},
"finish_reason": "stop",
}
],
)
assert normalize_response(response, "internal-id") == {
"id": "internal-id",
"model": "fast",
"outputs": (
{"role": "assistant", "content": ({"type": "text", "text": "answer"},), "stop_reason": "end_turn"},
),
}
def test_null_tools_are_normalized_to_empty_catalog() -> None:
request: Final = normalize_request({"messages": [{"role": "user", "content": "hello"}], "tools": None})
assert request["tools"] == ()
def test_judge_rewrite_removes_user_tools_limits_and_stop_sequences() -> None:
request: Final[dict[str, object]] = {
"messages": [{"role": "user", "content": "task"}],
"stop": ["}"],
"max_completion_tokens": 1,
"tools": [{"type": "function", "function": {"name": "read", "parameters": {}}}],
}
rewritten: Final[dict[str, object]] = {
"messages": [{"role": "user", "content": [{"type": "text", "text": "Judge task"}]}],
"instructions": [{"role": "system", "content": [{"type": "text", "text": "Return JSON"}]}],
"output": {"max_output_tokens": 200, "response_format": {"type": "json_object"}},
}
assert chat_request(rewritten, request, model="judge", stream=False, judge=True) == {
"model": "judge",
"stream": False,
"messages": [{"role": "system", "content": "Return JSON"}, {"role": "user", "content": "Judge task"}],
"max_tokens": 200,
"response_format": {"type": "json_object"},
}
@pytest.mark.parametrize(
"content",
[
[{"type": "image_url", "image_url": {"url": "https://example.com/a.png"}}],
[{"type": "input_audio", "input_audio": {"data": "abc"}}],
],
)
def test_unsupported_content_is_rejected_before_execution(content: object) -> None:
with pytest.raises(ValueError, match="text and function-tool"):
normalize_request({"messages": [{"role": "user", "content": content}]})

View file

@ -790,3 +790,20 @@ def test_inferred_custom_tiers_claim_existing_gateway_capability(
) -> None:
capabilities: Final = claimed_capabilities({"classifier_type": classifier, "tiers": tiers})
assert any(capability.key == "tier_or_classifier_prompt" for capability in capabilities) is expected
def test_switchyard_dependencies_include_targets_and_judges():
params: Final = {
"model": "auto_router/switchyard",
"switchyard_router_config": {"models": {"efficient": ["fast"], "capable": ["strong"], "judge": ["judge"]}},
}
dependencies: Final = strategy_router_dependencies(params)
assert {(entry.model_name, entry.role) for entry in dependencies} == {
("fast", "tier"),
("strong", "tier"),
("judge", "classifier"),
}
assert (
validate_strategy_router_model_write("auto_router/switchyard", frozenset({"switchyard_router_config"})) is None
)
assert validate_strategy_router_model_write("auto_router/switchyard", frozenset()) is not None

View file

@ -116,6 +116,8 @@ export interface AutoRouterDeployment extends AutoRouterCandidateDeployment {
auto_router_embedding_model?: string | null;
adaptive_router_config?: unknown;
adaptive_router_default_model?: string | null;
switchyard_router_config?: unknown;
switchyard_router_default_model?: string | null;
quality_router_config?: unknown;
quality_router_default_model?: string | null;
} | null;

View file

@ -43,6 +43,27 @@ const semanticDeployment = {
};
describe("autoRouterRows", () => {
it("shows Switchyard targets and permits editing database-backed routers", () => {
const deployment = {
model_name: "switchyard",
model_info: { id: "switchyard-id", db_model: true },
litellm_params: {
model: "auto_router/switchyard_router",
switchyard_router_config: {
algorithm: "capability",
models: { efficient: ["small"], capable: ["large", "small"], judge: ["judge"] },
},
},
};
const row = toAutoRouterRow(deployment, 0, ADMIN, null);
const expected = {
kind: "switchyard",
typeLabel: "Switchyard",
canEdit: true,
targets: ["small", "large", "judge"],
};
expect(row).toMatchObject(expected);
});
it("classifies a complexity router and unions its tier models as targets", () => {
const row = toAutoRouterRow(complexityDeployment, 0, ADMIN, null);

View file

@ -83,6 +83,10 @@ const configManaged = (label: string, config: Record<string, unknown>): Presenta
/** How each strategy renders itself, given its own config object. */
const PRESENTERS: Record<AutoRouterKind, (config: Record<string, unknown>) => Presentation> = {
switchyard: (config) => ({
typeLabel: "Switchyard",
targets: dedupe(Object.values(asRecord(config.models)).flatMap(asStringArray)),
}),
complexity: (config) => ({
typeLabel: complexityTypeLabel(config),
targets: dedupe(Object.values(asRecord(config.tiers)).flatMap(normalizeTierModels)),

View file

@ -0,0 +1,316 @@
import React, { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
SWITCHYARD_ALGORITHMS,
SWITCHYARD_DEFAULTS,
restoreSwitchyardDefaults,
switchyardRecord,
switchyardRequiredGroups,
type SwitchyardAlgorithm,
type SwitchyardDraft,
} from "./switchyard_config";
type Setting = {
key: string;
label: string;
kind: "number" | "boolean" | "text" | "json" | "format" | "picker";
hint?: string;
};
const CLASSIFIER_SETTINGS: Setting[] = [
{ key: "session_affinity", label: "Session affinity", kind: "boolean" },
{ key: "message_hash_fallback", label: "Message hash fallback", kind: "boolean" },
{ key: "recent_turn_window", label: "Recent turn window", kind: "number", hint: "Leave empty to use all turns" },
{ key: "max_output_tokens", label: "Classifier output token limit", kind: "number" },
];
const PROMPT_SETTINGS: Setting[] = [
{ key: "prompt", label: "Classifier prompt", kind: "text", hint: "Leave empty to use the built-in prompt" },
{ key: "response_format_type", label: "Classifier response format", kind: "format" },
];
const SETTINGS: Record<SwitchyardAlgorithm, Setting[]> = {
random: [
{
key: "weights",
label: "Model weights (JSON)",
kind: "json",
hint: "One weight per model in the any group in the order above. Leave empty for equal weights",
},
{ key: "seed", label: "Random seed", kind: "number", hint: "Optional nonnegative integer" },
],
capability: [
{
key: "base_threshold",
label: "Capability threshold",
kind: "number",
hint: "LiteLLM preset 0.5, between 0 and 1",
},
{ key: "threshold_step", label: "Threshold step", kind: "number" },
...CLASSIFIER_SETTINGS,
...PROMPT_SETTINGS,
],
custom: [
{ key: "prompt", label: "Custom classifier prompt", kind: "text" },
{ key: "response_schema", label: "Response schema (JSON)", kind: "json" },
{ key: "selector", label: "Target selector", kind: "text" },
{ key: "default_target", label: "Default target group", kind: "text" },
...CLASSIFIER_SETTINGS,
],
escalation: [
{ key: "confirmations", label: "Escalation confirmations", kind: "number" },
{ key: "recent_turn_window", label: "Recent turn window", kind: "number" },
{ key: "window_message_chars", label: "Characters per history message", kind: "number" },
{ key: "max_output_tokens", label: "Classifier output token limit", kind: "number" },
...PROMPT_SETTINGS,
{
key: "deescalation",
label: "De-escalation (JSON)",
kind: "json",
hint: "Optional object with strong_min_calls, confirmations, strong_max_calls and weak_cooldown_calls. Leave empty to disable",
},
],
stage: [
{ key: "picker", label: "Stage picker", kind: "picker" },
{
key: "confidence_threshold",
label: "Confidence threshold",
kind: "number",
hint: "LiteLLM preset 0.5, between 0 and 1",
},
{ key: "recent_window", label: "Recent stage window", kind: "number" },
{ key: "escalation_note", label: "Escalation note", kind: "text" },
{ key: "deescalation_note", label: "De-escalation note", kind: "text" },
{ key: "only_on_wrong_signal_escalation", label: "Escalate only on wrong signal", kind: "boolean" },
{ key: "capable_system_prompt", label: "Capable model system prompt", kind: "text" },
{ key: "efficient_system_prompt", label: "Efficient model system prompt", kind: "text" },
{
key: "tool_semantics",
label: "Tool semantics (JSON)",
kind: "json",
hint: "Map tool categories to lists of tool names",
},
{
key: "classifier",
label: "Fallback capability classifier (JSON)",
kind: "json",
hint: "Optional capability configuration. Requires a judge model group",
},
],
};
function SettingInput({
setting,
id,
value,
onChange,
}: {
setting: Setting;
id: string;
value: unknown;
onChange: (next: unknown) => void;
}) {
if (setting.kind === "boolean")
return (
<input id={id} type="checkbox" checked={Boolean(value)} onChange={(event) => onChange(event.target.checked)} />
);
if (setting.kind === "number")
return (
<Input
id={id}
type="number"
step="any"
value={typeof value === "number" ? value : ""}
onChange={(event) => onChange(event.target.value === "" ? null : Number(event.target.value))}
/>
);
if (setting.kind === "format" || setting.kind === "picker") {
const options = setting.kind === "format" ? ["json_schema", "json_object"] : ["efficient_first", "capable_first"];
return (
<Select value={String(value ?? "")} onValueChange={(next) => next && onChange(next)}>
<SelectTrigger id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
const textValue = typeof value === "string" ? value : JSON.stringify(value, null, 2);
return (
<Textarea
id={id}
value={value == null ? "" : textValue}
onChange={(event) => onChange(event.target.value || null)}
/>
);
}
export default function SwitchyardConfig({
value,
onChange,
modelNames = [],
}: {
value: SwitchyardDraft;
onChange: (value: SwitchyardDraft) => void;
modelNames?: string[];
}) {
const id = useId();
const [newGroup, setNewGroup] = useState("");
const algorithm = value.algorithm as SwitchyardAlgorithm;
const knownAlgorithm = SWITCHYARD_ALGORITHMS.includes(algorithm);
const config = algorithm === "random" ? value : switchyardRecord(value[algorithm]);
const defaults = SWITCHYARD_DEFAULTS[algorithm] ?? {};
const models = switchyardRecord(value.models);
const requiredGroups = switchyardRequiredGroups(algorithm, Boolean(switchyardRecord(value.stage).classifier));
const groups = Array.from(new Set([...requiredGroups, ...Object.keys(models)]));
const update = (key: string, next: unknown) =>
onChange(
algorithm === "random" ? { ...value, [key]: next } : { ...value, [algorithm]: { ...config, [key]: next } },
);
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor={`${id}-algorithm`}>Switchyard algorithm</Label>
<Select
value={String(value.algorithm ?? "")}
onValueChange={(next) => next && onChange({ ...value, algorithm: next })}
>
<SelectTrigger id={`${id}-algorithm`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SWITCHYARD_ALGORITHMS.map((item) => (
<SelectItem key={item} value={item}>
{item.charAt(0).toUpperCase() + item.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Switching algorithms keeps your other settings. Model names must refer to configured LiteLLM models.
</p>
</div>
<p className="text-xs text-muted-foreground">
Supports chat completions. Escalation may buffer responses before streaming the accepted answer.
</p>
<fieldset className="space-y-3">
<legend className="font-medium">Model groups</legend>
<p className="text-xs text-muted-foreground">
Enter ordered, comma-separated model names for each group. Later models are fallback candidates. Stage routing
only needs a judge when its fallback classifier is enabled.
</p>
<datalist id={`${id}-models`}>
{modelNames.map((name) => (
<option key={name} value={name} />
))}
</datalist>
{groups.map((group) => (
<div key={group} className="space-y-1">
<Label htmlFor={`${id}-group-${group}`}>{group} models</Label>
<div className="flex gap-2">
<Input
id={`${id}-group-${group}`}
list={`${id}-models`}
value={Array.isArray(models[group]) ? models[group].join(", ") : ""}
placeholder="model-name, fallback-model"
onChange={(event) =>
onChange({
...value,
models: { ...models, [group]: event.target.value.split(",").map((name) => name.trim()) },
})
}
/>
{!requiredGroups.includes(group) && (
<Button
type="button"
variant="outline"
aria-label={`Remove ${group} group`}
onClick={() =>
onChange({
...value,
models: Object.fromEntries(Object.entries(models).filter(([name]) => name !== group)),
})
}
>
Remove
</Button>
)}
</div>
</div>
))}
<div className="flex gap-2">
<Input
aria-label="New model group name"
value={newGroup}
onChange={(event) => setNewGroup(event.target.value)}
placeholder="New target group name"
/>
<Button
type="button"
variant="outline"
disabled={!newGroup.trim() || groups.includes(newGroup.trim())}
onClick={() => {
onChange({ ...value, models: { ...models, [newGroup.trim()]: [] } });
setNewGroup("");
}}
>
Add group
</Button>
</div>
</fieldset>
<details className="rounded-md border p-4">
<summary className="cursor-pointer font-medium">Advanced settings</summary>
<div className="mt-4 space-y-4">
<p className="text-xs text-muted-foreground">
Switchyard defaults are shown unless noted as LiteLLM presets. Empty optional fields use the default
behavior.
</p>
<Label>
<input
type="checkbox"
checked={value.fallback_enabled !== false}
onChange={(event) => onChange({ ...value, fallback_enabled: event.target.checked })}
/>{" "}
Enable model failure fallback
</Label>
{(SETTINGS[algorithm] ?? []).map((setting) => {
const current = config[setting.key] === undefined ? defaults[setting.key] : config[setting.key];
const fieldId = `${id}-${setting.key}`;
return (
<div key={setting.key} className="space-y-1">
<Label htmlFor={fieldId}>{setting.label}</Label>
<SettingInput
setting={setting}
id={fieldId}
value={current}
onChange={(next) => update(setting.key, next)}
/>
{setting.hint && <p className="text-xs text-muted-foreground">{setting.hint}</p>}
{setting.key === "prompt" && algorithm !== "custom" && (
<Button type="button" variant="link" onClick={() => update("prompt", null)}>
Restore built-in prompt
</Button>
)}
</div>
);
})}
<Button
type="button"
variant="outline"
disabled={!knownAlgorithm}
onClick={() => onChange(restoreSwitchyardDefaults(value))}
>
Restore defaults
</Button>
</div>
</details>
</div>
);
}

View file

@ -0,0 +1,46 @@
import React from "react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, renderWithProviders, screen, waitFor, chooseSelectOption } from "../../../tests/test-utils";
import SwitchyardStrategyPicker from "./SwitchyardStrategyPicker";
const { create } = vi.hoisted(() => ({ create: vi.fn().mockResolvedValue({}) }));
vi.mock("../networking", () => ({ modelCreateCall: create }));
vi.mock("../llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn().mockResolvedValue([]),
fetchAutoRouterModels: vi.fn().mockResolvedValue([]),
}));
const props = { handleOk: vi.fn(), accessToken: "test", userRole: "Admin" };
describe("Switchyard creation", () => {
it("creates the Switchyard strategy with basic models and advanced tuning", async () => {
renderWithProviders(
<SwitchyardStrategyPicker {...props}>
<p>Complexity setup</p>
</SwitchyardStrategyPicker>,
);
await chooseSelectOption(userEvent.setup(), screen.getByLabelText("Routing strategy"), "Switchyard");
fireEvent.change(screen.getByLabelText("efficient models"), { target: { value: "small" } });
fireEvent.change(screen.getByLabelText("capable models"), { target: { value: "large, backup" } });
fireEvent.change(screen.getByLabelText("judge models"), { target: { value: "judge" } });
fireEvent.click(screen.getByText("Advanced settings"));
fireEvent.change(screen.getByLabelText("Capability threshold"), { target: { value: "0.6" } });
fireEvent.click(screen.getByRole("button", { name: "Add Auto Router" }));
await waitFor(() =>
expect(create).toHaveBeenCalledWith("test", {
model_name: "switchyard-router",
litellm_params: {
model: "auto_router/switchyard_router",
switchyard_router_config: {
algorithm: "capability",
models: { efficient: ["small"], capable: ["large", "backup"], judge: ["judge"] },
fallback_enabled: true,
capability: { base_threshold: 0.6 },
},
},
model_info: {},
}),
);
});
});

View file

@ -0,0 +1,128 @@
import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "@/lib/toast";
import { canCreateAutoRouterForTeam, canModifyModel } from "@/utils/modelPermissions";
import TeamDropdown from "../common_components/team_dropdown";
import { modelCreateCall } from "../networking";
import { fetchAutoRouterModels, fetchAvailableModels } from "../llm_calls/fetch_models";
import SwitchyardConfig from "./SwitchyardConfig";
import { newSwitchyardConfig, switchyardConfigError, switchyardConfigSchema } from "./switchyard_config";
import type { AddAutoRouterTabProps } from "./add_auto_router_tab";
function AddSwitchyardRouter({
accessToken,
handleOk,
createScope,
userId,
userRole,
teams = null,
}: AddAutoRouterTabProps) {
const [name, setName] = useState("switchyard-router");
const [teamId, setTeamId] = useState<string | null>(null);
const [config, setConfig] = useState(newSwitchyardConfig);
const [saving, setSaving] = useState(false);
const requiresTeam = createScope === "team-required";
const actor = { userRole, userID: userId ?? null, isViewOnly: false };
const { data: models = [] } = useQuery({
queryKey: ["availableModels", "switchyard", accessToken, teamId],
queryFn: () => (requiresTeam ? fetchAutoRouterModels(accessToken, teamId) : fetchAvailableModels(accessToken)),
enabled: Boolean(accessToken) && (!requiresTeam || Boolean(teamId)),
});
const teamError = requiresTeam && !teamId ? "Select a team to continue" : null;
const nameError = name.trim() ? null : "Auto router name is required";
const memberManaged = requiresTeam && Boolean(teamId) && !canModifyModel(actor, teams, { teamId, isDbModel: true });
const permissionError = memberManaged ? "Switchyard routers must be created by a team or proxy administrator" : null;
const error = teamError ?? permissionError ?? nameError ?? switchyardConfigError(config);
const save = async () => {
if (error || saving) return;
setSaving(true);
try {
const payload = {
model_name: name,
litellm_params: {
model: "auto_router/switchyard_router",
switchyard_router_config: switchyardConfigSchema.parse(config),
},
model_info: requiresTeam ? { team_id: teamId } : {},
};
await modelCreateCall(accessToken, payload);
toast.success(`Successfully created Auto Router: ${name}`);
handleOk();
} catch (cause) {
toast.fromError(cause);
} finally {
setSaving(false);
}
};
return (
<Card>
<CardContent>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
<div className="space-y-2">
<Label htmlFor="switchyard-create-name">Auto Router Name</Label>
<Input id="switchyard-create-name" value={name} onChange={(event) => setName(event.target.value)} />
</div>
{requiresTeam && (
<div className="space-y-2">
<Label htmlFor="switchyard-team">Select Team</Label>
<TeamDropdown
id="switchyard-team"
value={teamId}
onChange={setTeamId}
filterTeam={(team) => canCreateAutoRouterForTeam(actor, team)}
/>
</div>
)}
<SwitchyardConfig
value={config}
onChange={setConfig}
modelNames={models.filter((model) => model.mode !== "embedding").map((model) => model.model_group)}
/>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<Button type="submit" disabled={Boolean(error) || saving}>
Add Auto Router
</Button>
</form>
</CardContent>
</Card>
);
}
export default function SwitchyardStrategyPicker({
children,
...props
}: AddAutoRouterTabProps & { children: React.ReactNode }) {
const [strategy, setStrategy] = useState("complexity");
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="auto-router-strategy">Routing strategy</Label>
<Select value={strategy} onValueChange={(next) => next && setStrategy(next)}>
<SelectTrigger id="auto-router-strategy" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="complexity">Complexity</SelectItem>
<SelectItem value="switchyard">Switchyard</SelectItem>
</SelectContent>
</Select>
</div>
{strategy === "switchyard" ? <AddSwitchyardRouter {...props} /> : children}
</div>
);
}

View file

@ -1,3 +1,4 @@
import SwitchyardStrategyPicker from "./SwitchyardStrategyPicker";
import { AutoRouterAvailabilityContext, useAutoRouterAvailability } from "./AutoRouterAvailability";
import { decisionDeploymentName } from "./jev_classifier_config";
import DecisionModelSelect from "./DecisionModelSelect";
@ -91,7 +92,7 @@ import {
type FamilyPreference,
} from "./auto_setup";
interface AddAutoRouterTabProps {
export interface AddAutoRouterTabProps {
handleOk: () => void;
accessToken: string;
userRole: string;
@ -843,4 +844,10 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
);
};
export default AddAutoRouterTab;
const AddAutoRouterWithStrategy: React.FC<AddAutoRouterTabProps> = (props) => (
<SwitchyardStrategyPicker {...props}>
<AddAutoRouterTab {...props} />
</SwitchyardStrategyPicker>
);
export default AddAutoRouterWithStrategy;

View file

@ -13,10 +13,12 @@
* prefixes are matched first, and only a bare `auto_router/<name>` is the semantic router.
*/
export type AutoRouterKind = "complexity" | "adaptive" | "quality" | "semantic";
export type AutoRouterKind = "complexity" | "adaptive" | "quality" | "semantic" | "switchyard";
export interface AutoRouterParams {
model?: string | null;
switchyard_router_config?: unknown;
switchyard_router_default_model?: string | null;
complexity_router_config?: unknown;
complexity_router_default_model?: string | null;
auto_router_config?: unknown;
@ -42,6 +44,14 @@ const startsWith = (params: AutoRouterParams, prefix: string): boolean => params
/** Ordered; the semantic entry matches anything left and must stay last. */
export const AUTO_ROUTER_STRATEGIES: readonly AutoRouterStrategy[] = [
{
kind: "switchyard",
label: "Switchyard",
configKey: "switchyard_router_config",
defaultModelKey: "switchyard_router_default_model",
hasEditor: true,
matches: (p) => startsWith(p, "auto_router/switchyard_router") || p.switchyard_router_config != null,
},
{
kind: "complexity",
label: "Complexity",
@ -87,8 +97,9 @@ export const isComplexityRouter = (params: AutoRouterParams | null | undefined):
/** Any `auto_router/*` deployment, whatever its strategy. Use for listing and filtering. */
export const isAutoRouterDeployment = (params: AutoRouterParams | null | undefined): boolean =>
params?.model?.startsWith("auto_router/") === true ||
params?.complexity_router_config != null ||
params?.auto_router_config != null;
[params?.complexity_router_config, params?.auto_router_config, params?.switchyard_router_config].some(
(config) => config != null,
);
/**
* Whether EditAutoRouterModal understands this deployment. It only speaks complexity and

View file

@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
hydrateSwitchyardConfig,
restoreSwitchyardDefaults,
SWITCHYARD_DEFAULTS,
switchyardConfigError,
switchyardConfigSchema,
} from "./switchyard_config";
const models = { efficient: ["small"], capable: ["large", "backup"], judge: ["judge"] };
describe("Switchyard configuration", () => {
it("preserves unknown configuration and parses JSON editing fields on save", () => {
const stored = {
algorithm: "escalation",
models,
future_option: "keep",
escalation: { future_judge_option: 9, deescalation: { strong_min_calls: 3, confirmations: 2 } },
custom: null,
};
const draft = hydrateSwitchyardConfig(JSON.stringify(stored));
const edited = {
...draft,
escalation: { ...stored.escalation, confirmations: 4, deescalation: '{"strong_min_calls":5,"confirmations":3}' },
};
expect(switchyardConfigSchema.parse(edited)).toEqual({
...stored,
escalation: { ...stored.escalation, confirmations: 4, deescalation: { strong_min_calls: 5, confirmations: 3 } },
});
});
it("restores tuning defaults while preserving models and future settings", () => {
const customConfig = {
algorithm: "capability",
models,
future: true,
capability: { base_threshold: 0.9, prompt: "override", future_setting: 4 },
};
const restored = restoreSwitchyardDefaults(customConfig);
expect(restored).toMatchObject({
models,
future: true,
capability: { ...SWITCHYARD_DEFAULTS.capability, future_setting: 4 },
});
});
it("validates random weights against candidates rather than group count", () => {
expect(
switchyardConfigError({ algorithm: "random", models: { any: ["small", "large"] }, weights: "[1, 2]" }),
).toBeNull();
expect(
switchyardConfigError({ algorithm: "random", models: { any: ["small", "large"] }, weights: "[1]" }),
).toContain("one weight per model");
expect(switchyardConfigError({ algorithm: "random", models: { any: ["small"] }, weights: "[0]" })).toContain(
"positive weight",
);
});
it("requires judge for classifiers but permits stage without a classifier", () => {
const solvers = { efficient: ["small"], capable: ["large"] };
expect(switchyardConfigError({ algorithm: "capability", models: solvers })).toContain("judge");
expect(switchyardConfigError({ algorithm: "stage", models: solvers })).toBeNull();
expect(switchyardConfigError({ algorithm: "stage", models: solvers, stage: { classifier: "{}" } })).toContain(
"judge",
);
});
it("rejects invalid JSON, missing custom fields, and incompatible affinity", () => {
expect(switchyardConfigError({ algorithm: "random", models: { any: ["small"] }, weights: "bad" })).toContain(
"weights",
);
expect(switchyardConfigError({ algorithm: "custom", models })).toContain("response_schema");
expect(
switchyardConfigError({ algorithm: "capability", models, capability: { message_hash_fallback: true } }),
).toContain("requires session affinity");
expect(switchyardConfigError({ algorithm: "capability", models, capability: { base_threshold: 2 } })).toContain(
"base_threshold",
);
});
it("omits random weights when switching to a classifier and keeps the draft intact", () => {
const draft = { algorithm: "capability", models, weights: [1, 2] };
expect(switchyardConfigSchema.parse(draft)).not.toHaveProperty("weights");
expect(draft.weights).toEqual([1, 2]);
});
});

View file

@ -0,0 +1,202 @@
import { z } from "zod";
export const SWITCHYARD_ALGORITHMS = ["random", "capability", "custom", "escalation", "stage"] as const;
export type SwitchyardAlgorithm = (typeof SWITCHYARD_ALGORITHMS)[number];
export type SwitchyardDraft = Record<string, unknown>;
export const switchyardRecord = (value: unknown): Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const jsonValue = (value: unknown): unknown => {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
};
const positiveInteger = z.number().int().positive();
const optionalWindow = positiveInteger.nullable().optional();
const responseFormat = z.enum(["json_schema", "json_object"]).optional();
const optionalPrompt = z.string().nullable().optional();
const classifierFields = {
session_affinity: z.boolean().optional(),
message_hash_fallback: z.boolean().optional(),
recent_turn_window: optionalWindow,
max_output_tokens: positiveInteger.optional(),
};
const capabilityFields = {
...classifierFields,
base_threshold: z.number().min(0).max(1).optional(),
threshold_step: z.number().nonnegative().optional(),
prompt: optionalPrompt,
response_format_type: responseFormat,
};
const affinityError = (
value: { message_hash_fallback?: boolean; session_affinity?: boolean },
ctx: z.RefinementCtx,
) => {
if (value.message_hash_fallback && !value.session_affinity)
ctx.addIssue({
code: "custom",
path: ["message_hash_fallback"],
message: "Message hash fallback requires session affinity",
});
};
const capabilitySchema = z.object(capabilityFields).passthrough().superRefine(affinityError);
const deescalationFields = {
strong_min_calls: positiveInteger,
confirmations: positiveInteger,
strong_max_calls: positiveInteger.nullable().optional(),
weak_cooldown_calls: z.number().int().nonnegative().optional(),
};
const deescalationSchema = z
.object(deescalationFields)
.passthrough()
.superRefine((value, ctx) => {
if (value.strong_max_calls != null && value.strong_max_calls < value.strong_min_calls)
ctx.addIssue({ code: "custom", message: "Maximum calls must be at least minimum calls" });
});
const modelsSchema = z.record(z.string().min(1), z.array(z.string().trim().min(1)).min(1));
const escalationFields = {
confirmations: positiveInteger.optional(),
recent_turn_window: positiveInteger.optional(),
window_message_chars: z.number().int().min(50).optional(),
max_output_tokens: positiveInteger.optional(),
prompt: optionalPrompt,
response_format_type: responseFormat,
deescalation: z.preprocess(jsonValue, deescalationSchema.nullable().optional()),
};
const customFields = {
...classifierFields,
prompt: z.string().min(1).optional(),
response_schema: z.preprocess(jsonValue, z.record(z.string(), z.unknown()).optional()),
selector: z.string().min(1).optional(),
default_target: z.string().min(1).optional(),
};
const stageFields = {
picker: z.enum(["efficient_first", "capable_first"]).optional(),
confidence_threshold: z.number().min(0).max(1).optional(),
recent_window: optionalWindow,
escalation_note: optionalPrompt,
deescalation_note: optionalPrompt,
only_on_wrong_signal_escalation: z.boolean().optional(),
capable_system_prompt: optionalPrompt,
efficient_system_prompt: optionalPrompt,
tool_semantics: z.preprocess(jsonValue, z.record(z.string(), z.array(z.string())).nullable().optional()),
classifier: z.preprocess(jsonValue, capabilitySchema.nullable().optional()),
};
const configFields = {
algorithm: z.enum(SWITCHYARD_ALGORITHMS),
models: z.preprocess(jsonValue, modelsSchema),
weights: z.preprocess(jsonValue, z.array(z.number().nonnegative()).min(1).nullable().optional()),
seed: z.number().int().nonnegative().nullable().optional(),
fallback_enabled: z.boolean().optional(),
capability: capabilitySchema.optional(),
escalation: z.object(escalationFields).passthrough().optional(),
custom: z.object(customFields).passthrough().superRefine(affinityError).nullable().optional(),
stage: z.object(stageFields).passthrough().optional(),
};
export function switchyardRequiredGroups(algorithm: string, hasStageClassifier: boolean): string[] {
if (algorithm === "random") return ["any"];
if (algorithm === "custom") return ["judge"];
if (algorithm === "stage" && !hasStageClassifier) return ["efficient", "capable"];
return ["efficient", "capable", "judge"];
}
export const switchyardConfigSchema = z
.object(configFields)
.passthrough()
.superRefine((config, ctx) => {
for (const group of switchyardRequiredGroups(config.algorithm, Boolean(config.stage?.classifier))) {
if (!config.models[group]?.length)
ctx.addIssue({ code: "custom", path: ["models", group], message: `Select a model for ${group}` });
}
if (!Object.keys(config.models).length)
ctx.addIssue({ code: "custom", path: ["models"], message: "Add at least one target group" });
if (config.algorithm !== "random" || !config.weights) return;
const validWeights =
config.weights.length === config.models.any?.length && config.weights.some((weight) => weight > 0);
if (!validWeights)
ctx.addIssue({
code: "custom",
path: ["weights"],
message: "Provide one weight per model in the any group, with at least one positive weight",
});
})
.superRefine((config, ctx) => {
if (config.algorithm !== "custom") return;
for (const field of ["prompt", "response_schema", "selector", "default_target"] as const) {
if (!config.custom?.[field])
ctx.addIssue({ code: "custom", path: ["custom", field], message: `${field} is required for custom routing` });
}
if (config.custom?.default_target && !config.models[config.custom.default_target])
ctx.addIssue({
code: "custom",
path: ["custom", "default_target"],
message: "Default target must name a model group",
});
})
.transform((config) =>
config.algorithm === "random"
? config
: Object.fromEntries(Object.entries(config).filter(([key]) => key !== "weights")),
);
export const SWITCHYARD_DEFAULTS: Record<SwitchyardAlgorithm, Record<string, unknown>> = {
random: {},
capability: {
base_threshold: 0.5,
threshold_step: 0,
session_affinity: false,
message_hash_fallback: false,
recent_turn_window: null,
max_output_tokens: 4096,
prompt: null,
response_format_type: "json_schema",
},
escalation: {
confirmations: 2,
recent_turn_window: 28,
window_message_chars: 500,
max_output_tokens: 4096,
prompt: null,
response_format_type: "json_schema",
deescalation: null,
},
custom: { session_affinity: false, message_hash_fallback: false, recent_turn_window: null, max_output_tokens: 4096 },
stage: {
picker: "efficient_first",
confidence_threshold: 0.5,
recent_window: null,
escalation_note: null,
deescalation_note: null,
only_on_wrong_signal_escalation: true,
capable_system_prompt: null,
efficient_system_prompt: null,
tool_semantics: null,
classifier: null,
},
};
export const newSwitchyardConfig = (): SwitchyardDraft => ({
algorithm: "capability",
models: {},
fallback_enabled: true,
});
export const restoreSwitchyardDefaults = (config: SwitchyardDraft): SwitchyardDraft => {
const algorithm = config.algorithm as SwitchyardAlgorithm;
if (algorithm === "random") return { ...config, weights: null, seed: null, fallback_enabled: true };
return {
...config,
fallback_enabled: true,
[algorithm]: { ...switchyardRecord(config[algorithm]), ...SWITCHYARD_DEFAULTS[algorithm] },
};
};
export const hydrateSwitchyardConfig = (value: unknown): SwitchyardDraft => switchyardRecord(jsonValue(value));
export const switchyardConfigError = (config: SwitchyardDraft): string | null => {
const result = switchyardConfigSchema.safeParse(config);
return result.success
? null
: result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(". ");
};

View file

@ -0,0 +1,92 @@
import React from "react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import EditSwitchyardRouter from "./EditSwitchyardRouter";
const { patch } = vi.hoisted(() => ({ patch: vi.fn().mockResolvedValue({}) }));
vi.mock("../networking", () => ({ modelPatchUpdateCall: patch }));
vi.mock("../llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn().mockResolvedValue([]),
fetchAutoRouterModels: vi.fn().mockResolvedValue([]),
}));
const modelData = {
model_name: "switchyard",
model_info: { id: "router-id" },
litellm_params: {
model: "auto_router/switchyard_router",
switchyard_router_config: {
algorithm: "capability",
models: { efficient: ["small"], capable: ["large"], judge: ["judge"] },
capability: { base_threshold: 0.7, future_option: "preserve" },
},
},
};
beforeEach(() => vi.clearAllMocks());
describe("Switchyard editor", () => {
it("saves advanced edits under the strategy key and preserves unrecognized settings", async () => {
const onSuccess = vi.fn();
renderWithProviders(
<EditSwitchyardRouter
isVisible
accessToken="test"
modelData={modelData}
onCancel={vi.fn()}
onSuccess={onSuccess}
/>,
);
fireEvent.click(screen.getByText("Advanced settings"));
fireEvent.change(screen.getByLabelText("Capability threshold"), { target: { value: "0.8" } });
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() =>
expect(patch).toHaveBeenCalledWith(
"test",
{
model_name: "switchyard",
litellm_params: {
switchyard_router_config: {
...modelData.litellm_params.switchyard_router_config,
capability: { base_threshold: 0.8, future_option: "preserve" },
},
},
},
"router-id",
),
);
expect(onSuccess).toHaveBeenCalled();
});
it("blocks invalid tuning and permits restoring defaults before save", () => {
renderWithProviders(
<EditSwitchyardRouter
isVisible
accessToken="test"
modelData={modelData}
onCancel={vi.fn()}
onSuccess={vi.fn()}
/>,
);
fireEvent.click(screen.getByText("Advanced settings"));
fireEvent.change(screen.getByLabelText("Capability threshold"), { target: { value: "2" } });
expect(screen.getByRole("button", { name: "Save Changes" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Restore defaults" }));
expect(screen.getByRole("button", { name: "Save Changes" })).toBeEnabled();
});
it("blocks unsupported member-managed updates", () => {
renderWithProviders(
<EditSwitchyardRouter
isVisible
isMemberManaged
accessToken="test"
modelData={modelData}
onCancel={vi.fn()}
onSuccess={vi.fn()}
/>,
);
expect(screen.getByLabelText("Auto Router Name")).toBeDisabled();
expect(screen.getByRole("button", { name: "Save Changes" })).toBeDisabled();
expect(screen.getByRole("alert")).toHaveTextContent("must be edited by a team or proxy administrator");
expect(patch).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,109 @@
import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@/lib/toast";
import { modelPatchUpdateCall } from "../networking";
import { fetchAutoRouterModels, fetchAvailableModels } from "../llm_calls/fetch_models";
import SwitchyardConfig from "../add_model/SwitchyardConfig";
import { hydrateSwitchyardConfig, switchyardConfigError, switchyardConfigSchema } from "../add_model/switchyard_config";
interface SwitchyardDeployment {
model_name?: string;
litellm_params?: Record<string, unknown>;
model_info?: { id?: string; team_id?: string | null; [key: string]: unknown };
}
export default function EditSwitchyardRouter({
isVisible,
onCancel,
onSuccess,
modelData,
accessToken,
isMemberManaged = false,
}: {
isVisible: boolean;
onCancel: () => void;
onSuccess: (value: SwitchyardDeployment) => void;
modelData: SwitchyardDeployment;
accessToken: string;
isMemberManaged?: boolean;
}) {
const [name, setName] = useState(modelData.model_name ?? "");
const [config, setConfig] = useState(() =>
hydrateSwitchyardConfig(modelData.litellm_params?.switchyard_router_config),
);
const [saving, setSaving] = useState(false);
const teamId = modelData.model_info?.team_id;
const { data: models = [] } = useQuery({
queryKey: ["availableModels", "switchyard", accessToken, teamId],
queryFn: () => (teamId ? fetchAutoRouterModels(accessToken, teamId) : fetchAvailableModels(accessToken)),
enabled: isVisible && Boolean(accessToken),
});
const nameError = !name.trim() ? "Auto router name is required" : switchyardConfigError(config);
const error = isMemberManaged ? "Switchyard routers must be edited by a team or proxy administrator" : nameError;
const save = async () => {
if (error || saving || !modelData.model_info?.id) return;
setSaving(true);
try {
const params = { switchyard_router_config: switchyardConfigSchema.parse(config) };
const patch = isMemberManaged ? { litellm_params: params } : { model_name: name, litellm_params: params };
await modelPatchUpdateCall(accessToken, patch, modelData.model_info.id);
onSuccess({
...modelData,
model_name: isMemberManaged ? modelData.model_name : name,
litellm_params: { ...modelData.litellm_params, ...params },
});
toast.success("Auto router configuration updated successfully");
onCancel();
} catch (cause) {
toast.fromError(cause);
} finally {
setSaving(false);
}
};
return (
<Dialog open={isVisible} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[760px]">
<DialogHeader>
<DialogTitle>Edit Switchyard auto router</DialogTitle>
<DialogDescription>Configure the routing algorithm and model groups</DialogDescription>
</DialogHeader>
<Label htmlFor="switchyard-router-name">Auto Router Name</Label>
<Input
id="switchyard-router-name"
value={name}
disabled={isMemberManaged}
onChange={(event) => setName(event.target.value)}
/>
<SwitchyardConfig
value={config}
onChange={setConfig}
modelNames={models.filter((model) => model.mode !== "embedding").map((model) => model.model_group)}
/>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button disabled={Boolean(error) || saving} onClick={() => void save()}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -1,3 +1,4 @@
import EditSwitchyardRouter from "./EditSwitchyardRouter";
import { useQuery } from "@tanstack/react-query";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { autoRouterListKey, fetchAllModelDeployments } from "@/app/(dashboard)/hooks/models/useModels";
@ -34,7 +35,7 @@ import {
getCustomTierRowsError,
resolveComplexityDefaultModel,
} from "../add_model/tier_rows";
import { isComplexityRouter } from "../add_model/auto_router_strategies";
import { autoRouterStrategy, isComplexityRouter } from "../add_model/auto_router_strategies";
import {
type BuildComplexityRouterConfigParams,
type StoredComplexityRouterConfig,
@ -806,4 +807,11 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
);
};
export default EditAutoRouterModal;
const AutoRouterEditor: React.FC<EditAutoRouterModalProps> = (props) =>
autoRouterStrategy(props.modelData?.litellm_params).kind === "switchyard" ? (
props.isVisible && <EditSwitchyardRouter key={props.modelData?.model_info?.id} {...props} />
) : (
<EditAutoRouterModal {...props} />
);
export default AutoRouterEditor;