Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/client/cli/commands/autoroute/wizard.py: 0%
88 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1import sys
2from pathlib import Path
3from typing import Final
5import click
6import yaml
7from InquirerPy import inquirer
8from InquirerPy.base.control import Choice
9from pydantic import JsonValue, TypeAdapter, ValidationError
11from .... import Client
12from .config import (
13 DEFAULT_KEYWORD_TIER_RULES,
14 TIER_NAMES,
15 AutorouteConfig,
16 ConfigGenerationError,
17 DiscoveredModel,
18 HeuristicClassifier,
19 KeywordTierRule,
20 LLMClassifier,
21 NoSemanticMatching,
22 SemanticMatching,
23 build_generated_model_list,
24 chat_models,
25 embedding_models,
26 master_key_from_config,
27 parse_discovered_models,
28 validate_config,
29)
30from .process import CONFIG_PATH, secure_create
33def _is_interactive() -> bool:
34 return sys.stdin.isatty()
37def _fuzzy_pick(models: tuple[DiscoveredModel, ...], prompt_label: str, multiselect: bool) -> list[str]:
38 """Type-to-filter picker over a (possibly huge) model pool, using InquirerPy's fzf-style fuzzy prompt.
40 A plain numbered table + typed index does not scale past a handful of models -- proxies with
41 hundreds of model groups made that interaction unusable. This lets the user narrow the pool by
42 typing a substring instead of scrolling/counting.
44 Assumes the caller already checked interactivity (run_configure_wizard does, once, up front) --
45 checking here too would check the wrong thing under test, where InquirerPy is driven through its
46 own injected input/output rather than the real process stdin.
47 """
48 choices: Final = [Choice(value=model.name, name=model.name) for model in models]
49 toggle_hint: Final = "tab to toggle, " if multiselect else ""
50 while True:
51 result = inquirer.fuzzy(
52 message=f"{prompt_label}: type to filter, {toggle_hint}enter to confirm",
53 choices=choices,
54 multiselect=multiselect,
55 max_height="70%",
56 ).execute()
57 selected = result if multiselect else [result]
58 if selected:
59 return selected
60 click.echo("Select at least one model.")
63def _render_and_prompt_for_model(models: tuple[DiscoveredModel, ...], prompt_label: str) -> str:
64 return _fuzzy_pick(models, prompt_label, multiselect=False)[0]
67def _render_and_prompt_for_models(models: tuple[DiscoveredModel, ...], prompt_label: str) -> tuple[str, ...]:
68 return tuple(_fuzzy_pick(models, prompt_label, multiselect=True))
71def _parse_keywords(raw: str) -> tuple[str, ...]:
72 return tuple(keyword.strip() for keyword in raw.split(",") if keyword.strip())
75def _prompt_for_keyword_tier_rules() -> tuple[KeywordTierRule, ...]:
76 """Let the user supply the semantic-matching keywords per tier, since matching those
77 keywords against the request is the whole point of enabling it. Each prompt is prefilled
78 with the built-in default, so pressing enter keeps it."""
79 click.echo("\nEnter example keywords/phrases per tier (comma-separated); press enter to keep the default:")
80 defaults: Final = {rule.tier: rule.keywords for rule in DEFAULT_KEYWORD_TIER_RULES}
82 def _rule_for(tier: str) -> KeywordTierRule:
83 default_keywords: Final = defaults.get(tier, ())
84 raw: Final = click.prompt(f" {tier} keywords", default=", ".join(default_keywords), show_default=True)
85 return KeywordTierRule(keywords=_parse_keywords(raw) or default_keywords, tier=tier)
87 return tuple(_rule_for(tier) for tier in TIER_NAMES)
90_RAW_CONFIG_ADAPTER: Final = TypeAdapter(dict[str, JsonValue])
93def _load_persisted_master_key(config_path: Path) -> str | None:
94 """The master key from an existing generated config, so a rewrite carries it forward.
96 Lenient on a missing or corrupt file: configure is the regeneration path, so it must
97 succeed from any prior state; a key that cannot be read is simply not carried and `start`
98 mints a fresh one.
99 """
100 if not config_path.exists():
101 return None
102 try:
103 raw: Final = _RAW_CONFIG_ADAPTER.validate_python(yaml.safe_load(config_path.read_text()))
104 except (OSError, UnicodeDecodeError, yaml.YAMLError, ValidationError):
105 return None
106 return master_key_from_config(raw)
109def run_configure_wizard(ctx: click.Context) -> Path:
110 """Discover the caller's accessible models, walk them through tier assignment, write config."""
111 base_url: Final = ctx.obj["base_url"]
112 api_key: Final = ctx.obj["api_key"]
113 client: Final = Client(base_url=base_url, api_key=api_key)
115 raw_models: Final = client.models.list()
116 if not isinstance(raw_models, list):
117 raise click.ClickException(
118 f"Unexpected response from /v1/models: expected a list, got {type(raw_models).__name__}"
119 )
120 discovered: Final = parse_discovered_models(raw_models)
121 chat_pool: Final = chat_models(discovered)
122 embedding_pool: Final = embedding_models(discovered)
124 if not chat_pool:
125 raise click.ClickException("Your key has no chat-capable models available on this proxy.")
127 if not _is_interactive():
128 raise click.ClickException("`lite autoroute configure` requires an interactive terminal.")
130 click.echo("Assign model(s) to each complexity tier (from what your key can access):")
131 tiers: Final = {tier: _render_and_prompt_for_models(chat_pool, tier) for tier in TIER_NAMES}
132 default_model: Final = tiers["MEDIUM"][0]
134 classifier = HeuristicClassifier()
135 if click.confirm("\nUse an LLM classifier instead of the free heuristic scorer?", default=False):
136 classifier_model: Final = _render_and_prompt_for_model(chat_pool, "LLM classifier")
137 classifier = LLMClassifier(model=classifier_model)
139 semantic_matching = NoSemanticMatching()
140 if embedding_pool and click.confirm("\nEnable semantic keyword matching?", default=False):
141 embedding_model: Final = _render_and_prompt_for_model(embedding_pool, "semantic embeddings")
142 keyword_tier_rules: Final = _prompt_for_keyword_tier_rules()
143 semantic_matching = SemanticMatching(embedding_model=embedding_model, keyword_tier_rules=keyword_tier_rules)
145 adaptive: Final = click.confirm("\nEnable adaptive (bandit) selection on top of tiering?", default=False)
147 config: Final = AutorouteConfig(
148 base_url=base_url,
149 api_key=api_key,
150 tiers=tiers,
151 default_model=default_model,
152 classifier=classifier,
153 semantic_matching=semantic_matching,
154 adaptive=adaptive,
155 )
156 try:
157 validate_config(config, discovered)
158 except ConfigGenerationError as e:
159 raise click.ClickException(str(e))
161 model_list: Final = build_generated_model_list(config)
162 persisted_master_key: Final = _load_persisted_master_key(CONFIG_PATH)
163 generated: Final[dict[str, JsonValue]] = (
164 {"model_list": model_list, "general_settings": {"master_key": persisted_master_key}}
165 if persisted_master_key is not None
166 else {"model_list": model_list}
167 )
168 CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
169 with secure_create(CONFIG_PATH) as f:
170 yaml.safe_dump(generated, f, sort_keys=False)
172 click.echo(f"\nWrote {CONFIG_PATH}")
173 for tier, models in tiers.items():
174 click.echo(f" {tier}: {', '.join(models)}")
175 return CONFIG_PATH
178__all__ = ["run_configure_wizard"]