feat(cli): allow multiple models per autoroute tier

complexity_router already supports a pool of models per tier (randomly
picked per request; adaptive mode specifically needs a pool to choose
within), but the configure wizard only ever let you assign one. Tiers are
now a tuple of model names; the wizard prompt accepts comma-separated
indices to pick more than one per tier.
This commit is contained in:
Krrish Dholakia 2026-07-14 12:06:22 -07:00
parent 60d3d05704
commit 159c7ec8da
5 changed files with 80 additions and 32 deletions

View file

@ -507,7 +507,7 @@ Lists the model groups your key can reach on the proxy, via `/model_group/info`,
lite autoroute configure
```
An interactive wizard. It runs the same model-group discovery as above, splits the results into chat-capable and embedding-capable pools, and asks you to assign a model from the chat pool to each of the four complexity tiers -- SIMPLE, MEDIUM, COMPLEX, REASONING. From there it optionally offers: classifying prompt complexity with an LLM (again picked from your discovered pool) instead of the free built-in heuristic scorer, semantic keyword matching for tier assignment (needs an embedding model from the pool), and adaptive (bandit-based) selection layered on top of tiering.
An interactive wizard. It runs the same model-group discovery as above, splits the results into chat-capable and embedding-capable pools, and asks you to assign one or more models from the chat pool to each of the four complexity tiers -- SIMPLE, MEDIUM, COMPLEX, REASONING (enter comma-separated indices to assign a pool of models to a tier instead of just one; complexity_router picks randomly among a tier's pool per request, and adaptive mode specifically depends on having more than one candidate to choose from). From there it optionally offers: classifying prompt complexity with an LLM (again picked from your discovered pool) instead of the free built-in heuristic scorer, semantic keyword matching for tier assignment (needs an embedding model from the pool), and adaptive (bandit-based) selection layered on top of tiering.
The wizard writes the result to `~/.litellm/autorouter/config.yaml` with `0600` permissions, since the file embeds your real proxy API key. Every model referenced anywhere in that config -- tier targets, the classifier model, the embedding model -- becomes its own `litellm_proxy/<model-name>` deployment whose `api_base` and `api_key` point back at your real proxy. That is the trick that keeps your real proxy's config untouched: every actual network call this generates, whether it is the routed completion, an LLM-classifier call, or an embedding call, forwards transparently through your real, already-running proxy with your real key.

View file

@ -103,7 +103,9 @@ class AutorouteConfig(BaseModel):
base_url: str
api_key: str
tiers: Dict[str, str]
# Each tier maps to a pool of one or more models; complexity_router picks randomly among
# them per request (or, in adaptive mode, learns which to prefer within the pool).
tiers: Dict[str, Tuple[str, ...]]
default_model: str
classifier: ClassifierChoice = Field(default_factory=HeuristicClassifier)
semantic_matching: SemanticMatchingChoice = Field(default_factory=NoSemanticMatching)
@ -115,9 +117,10 @@ def validate_config(config: AutorouteConfig, discovered: Tuple[DiscoveredModel,
chat_names: FrozenSet[str] = frozenset(m.name for m in chat_models(discovered))
embedding_names: FrozenSet[str] = frozenset(m.name for m in embedding_models(discovered))
for tier, model in config.tiers.items():
if model not in chat_names:
raise ConfigGenerationError(f"Tier {tier} references unknown chat model '{model}'")
for tier, models in config.tiers.items():
for model in models:
if model not in chat_names:
raise ConfigGenerationError(f"Tier {tier} references unknown chat model '{model}'")
if config.default_model not in chat_names:
raise ConfigGenerationError(f"default_model '{config.default_model}' is not a known chat model")
@ -152,7 +155,8 @@ def build_generated_model_list(config: AutorouteConfig) -> List[JsonValue]:
to exactly one `litellm_proxy/<name>` deployment forwarding to the customer's real proxy,
plus one `auto_router/complexity_router` deployment tying the tiers together.
"""
referenced_names = {*config.tiers.values(), config.default_model}
referenced_names = {model for models in config.tiers.values() for model in models}
referenced_names.add(config.default_model)
if isinstance(config.classifier, LLMClassifier):
referenced_names.add(config.classifier.model)
if isinstance(config.semantic_matching, SemanticMatching):
@ -163,7 +167,7 @@ def build_generated_model_list(config: AutorouteConfig) -> List[JsonValue]:
]
complexity_router_config: Dict[str, JsonValue] = {
"tiers": dict(config.tiers),
"tiers": {tier: list(models) for tier, models in config.tiers.items()},
"default_model": config.default_model,
}
if isinstance(config.classifier, LLMClassifier):

View file

@ -1,5 +1,5 @@
from pathlib import Path
from typing import Tuple
from typing import List, Optional, Tuple
import click
import yaml
@ -25,25 +25,52 @@ from .config import (
from .process import CONFIG_PATH
def _render_and_prompt_for_model(models: Tuple[DiscoveredModel, ...], prompt_label: str) -> str:
def _render_model_table(models: Tuple[DiscoveredModel, ...], prompt_label: str) -> None:
console = Console()
table = Table(title=f"Pick a model for {prompt_label}")
table = Table(title=f"Pick model(s) for {prompt_label}")
table.add_column("Index", style="cyan", no_wrap=True)
table.add_column("Model", style="magenta")
for i, model in enumerate(models):
table.add_row(str(i + 1), model.name)
console.print(table)
def _parse_indices(choice: str, count: int) -> Optional[Tuple[int, ...]]:
raw_parts = [part.strip() for part in choice.split(",") if part.strip()]
if not raw_parts:
return None
indices: List[int] = []
for part in raw_parts:
try:
index = int(part) - 1
except ValueError:
return None
if not (0 <= index < count):
return None
indices.append(index)
return tuple(dict.fromkeys(indices))
def _render_and_prompt_for_model(models: Tuple[DiscoveredModel, ...], prompt_label: str) -> str:
_render_model_table(models, prompt_label)
while True:
choice = click.prompt(f"\nSelect a model for {prompt_label} by index", type=str).strip()
try:
index = int(choice) - 1
except ValueError:
click.echo("Invalid input. Please enter a number.")
continue
if 0 <= index < len(models):
return models[index].name
click.echo(f"Invalid selection. Please enter a number between 1 and {len(models)}")
indices = _parse_indices(choice, len(models))
if indices is not None and len(indices) == 1:
return models[indices[0]].name
click.echo(f"Invalid selection. Please enter a single number between 1 and {len(models)}")
def _render_and_prompt_for_models(models: Tuple[DiscoveredModel, ...], prompt_label: str) -> Tuple[str, ...]:
_render_model_table(models, prompt_label)
while True:
choice = click.prompt(
f"\nSelect model(s) for {prompt_label} by index (comma-separated for multiple)", type=str
).strip()
indices = _parse_indices(choice, len(models))
if indices is not None:
return tuple(models[i].name for i in indices)
click.echo(f"Invalid selection. Please enter number(s) between 1 and {len(models)}, comma-separated")
def run_configure_wizard(ctx: click.Context) -> Path:
@ -61,9 +88,9 @@ def run_configure_wizard(ctx: click.Context) -> Path:
if not chat_pool:
raise click.ClickException("Your key has no chat-capable models available on this proxy.")
click.echo("Assign a model to each complexity tier (from what your key can access):")
tiers = {tier: _render_and_prompt_for_model(chat_pool, tier) for tier in TIER_NAMES}
default_model = tiers["MEDIUM"]
click.echo("Assign model(s) to each complexity tier (from what your key can access):")
tiers = {tier: _render_and_prompt_for_models(chat_pool, tier) for tier in TIER_NAMES}
default_model = tiers["MEDIUM"][0]
classifier = HeuristicClassifier()
if click.confirm("\nUse an LLM classifier instead of the free heuristic scorer?", default=False):
@ -98,8 +125,8 @@ def run_configure_wizard(ctx: click.Context) -> Path:
CONFIG_PATH.chmod(0o600)
click.echo(f"\nWrote {CONFIG_PATH}")
for tier, model in tiers.items():
click.echo(f" {tier}: {model}")
for tier, models in tiers.items():
click.echo(f" {tier}: {', '.join(models)}")
return CONFIG_PATH

View file

@ -31,10 +31,10 @@ def _base_config(**overrides: Any) -> AutorouteConfig:
"base_url": "http://real-proxy.internal:4000",
"api_key": "sk-real-key",
"tiers": {
"SIMPLE": "gpt-4o-mini",
"MEDIUM": "gpt-4o",
"COMPLEX": "gpt-4o",
"REASONING": "o1",
"SIMPLE": ("gpt-4o-mini",),
"MEDIUM": ("gpt-4o",),
"COMPLEX": ("gpt-4o",),
"REASONING": ("o1",),
},
"default_model": "gpt-4o",
}
@ -146,7 +146,12 @@ class TestValidateConfig:
def test_raises_for_tier_referencing_unknown_model(self):
config = _base_config(
tiers={"SIMPLE": "unknown-model", "MEDIUM": "gpt-4o", "COMPLEX": "gpt-4o", "REASONING": "o1"}
tiers={
"SIMPLE": ("unknown-model",),
"MEDIUM": ("gpt-4o",),
"COMPLEX": ("gpt-4o",),
"REASONING": ("o1",),
}
)
with pytest.raises(ConfigGenerationError, match="unknown-model"):
validate_config(config, DISCOVERED)

View file

@ -71,10 +71,10 @@ class TestRunConfigureWizardHappyPath:
assert result.exit_code == 0, result.output
router_config = _router_config(config_path)
assert router_config["tiers"] == {
"SIMPLE": "gpt-4o-mini",
"MEDIUM": "gpt-4o",
"COMPLEX": "claude-opus",
"REASONING": "o1",
"SIMPLE": ["gpt-4o-mini"],
"MEDIUM": ["gpt-4o"],
"COMPLEX": ["claude-opus"],
"REASONING": ["o1"],
}
assert router_config["default_model"] == "gpt-4o"
assert "classifier_type" not in router_config
@ -82,6 +82,18 @@ class TestRunConfigureWizardHappyPath:
assert "semantic_keyword_matching" not in router_config
assert "adaptive" not in router_config
def test_assigns_multiple_models_to_a_single_tier(self, tmp_path):
result, config_path = _run(
tmp_path,
CHAT_AND_EMBEDDING_GROUPS,
input_str="1,2\n2\n3\n4\nn\nn\nn\n",
)
assert result.exit_code == 0, result.output
router_config = _router_config(config_path)
assert router_config["tiers"]["SIMPLE"] == ["gpt-4o-mini", "gpt-4o"]
assert router_config["default_model"] == "gpt-4o"
def test_writes_config_file_with_restricted_permissions(self, tmp_path):
result, config_path = _run(
tmp_path,