Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/client/cli/commands/configure.py: 0%
225 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
1"""Persistent Claude Code and Codex gateway configuration."""
3import os
4import sys
5from collections.abc import Callable, Sequence
6from dataclasses import dataclass
7from pathlib import Path
8from types import MappingProxyType
9from typing import Final
11import click
12from InquirerPy import inquirer
13from InquirerPy.base.control import Choice
14from pydantic import BaseModel
16from litellm.proxy.common_utils.model_listing_utils import (
17 CLAUDE_CODE_CLIENT,
18 CLAUDE_CODE_PICKER_PATTERN,
19 GATEWAY_CLIENT_HEADER,
20)
22from .agents import codex_config_path
23from .auth import CliContextObj
24from .claude_settings import (
25 STARTING_MODEL_ROLE,
26 ClaudeSettingsError,
27 ModelChoice,
28 StartOn,
29 StaticToken,
30 UnconfigureOutcome,
31 UnpinModel,
32 claude_settings_path,
33 configure_claude_settings,
34 configure_state_path,
35 preflight_claude_settings,
36 settings_file_owners,
37 unconfigure_claude_settings,
38)
39from .codex_settings import (
40 CodexSettingsError,
41 configure_codex_settings,
42 preflight_codex_settings,
43 unconfigure_codex_settings,
44)
45from .config import normalize_base_url
46from .pi import ListedModel, ListingFailure, PiSyncError, fetch_model_listing
48_LISTED_MODELS_SHOWN: Final = 20
49_CLAUDE_TARGET: Final = "claude"
50_CODEX_TARGET: Final = "codex"
51_TARGETS: Final = ((_CLAUDE_TARGET, "Claude Code (CLI)"), (_CODEX_TARGET, "Codex (CLI)"))
52_KEEP_DEFAULT_MODEL: Final = "Keep Claude Code's own default"
53_CLAUDE_CODE_VIEW: Final = MappingProxyType(
54 {"anthropic-version": "2023-06-01", GATEWAY_CLIENT_HEADER: CLAUDE_CODE_CLIENT}
55)
56_MODEL_OPTION_HELP: Final = (
57 f"Proxy model to set as {STARTING_MODEL_ROLE}. Must be listed on /v1/models for the key; without it, "
58 "Claude Code keeps its own default and a pin an earlier configure made is let go of. Nothing pins Claude "
59 "Code's sub-agent or background tiers; `lite autoroute start` is the mode that does."
60)
63def resolve_credential(ctx: click.Context, api_key: str | None) -> StaticToken:
64 """The long-lived key written into settings.json: --api-key, `lite --api-key` or LITELLM_PROXY_API_KEY.
66 A `lite login` credential is never written: it expires within a day, and keeping it fresh would mean
67 Claude Code running `lite` through `apiKeyHelper` on every credential refresh.
68 """
69 ctx_obj: Final[CliContextObj] = ctx.obj
70 explicit: Final = api_key or (None if ctx_obj.get("api_key_from_token_file") else ctx_obj.get("api_key"))
71 if not explicit:
72 raise ClaudeSettingsError(
73 "`lite configure` needs a long-lived virtual key: pass --api-key, `lite --api-key`, or set "
74 "LITELLM_PROXY_API_KEY. Your `lite login` credential expires within a day, so it is not written "
75 "into agent settings."
76 )
77 if not explicit.strip() or any(ord(char) <= 32 or ord(char) == 127 for char in explicit):
78 raise ClaudeSettingsError("The virtual key must not be blank or contain whitespace or control characters.")
79 return StaticToken(explicit)
82@dataclass(frozen=True, slots=True)
83class _Listing:
84 models: tuple[ListedModel, ...]
86 @property
87 def ids(self) -> tuple[str, ...]:
88 return tuple(model.id for model in self.models)
91def _preflight(target: str) -> None:
92 try:
93 if target == _CLAUDE_TARGET:
94 preflight_claude_settings(claude_settings_path(os.environ))
95 else:
96 preflight_codex_settings(codex_config_path(os.environ))
97 except (ClaudeSettingsError, CodexSettingsError) as e:
98 raise click.ClickException(str(e)) from e
101def _start(
102 ctx: click.Context, base_url: str, api_key: str | None, target: str = _CLAUDE_TARGET
103) -> tuple[StaticToken, _Listing]:
104 _preflight(target)
105 try:
106 credential: Final = resolve_credential(ctx, api_key)
107 except ClaudeSettingsError as e:
108 raise click.ClickException(str(e))
109 return credential, _listed_models(base_url, credential.token, target)
112def _listing_error(base_url: str, error: PiSyncError, target: str) -> str:
113 """The hint that fits how the listing failed: only an unreachable proxy gets the "is it running" question."""
114 if error.kind is ListingFailure.REJECTED:
115 return f"LiteLLM rejected your key (HTTP {error.status}). Pass a valid --api-key."
116 if error.kind is ListingFailure.UNREACHABLE:
117 return (
118 f"Could not connect. Is the proxy at {base_url} running, and is --base-url (or LITELLM_PROXY_URL) correct?"
119 )
120 if error.kind is ListingFailure.EMPTY:
121 name: Final = "Claude Code" if target == _CLAUDE_TARGET else "Codex"
122 return f"{error.message} {name} would have nothing to run; give the key access to at least one model."
123 return f"The proxy at {base_url} answered, so check that it is a LiteLLM proxy and is healthy."
126def _listed_models(base_url: str, key: str, target: str = _CLAUDE_TARGET) -> _Listing:
127 listed: Final = fetch_model_listing(
128 base_url, key, headers=_CLAUDE_CODE_VIEW if target == _CLAUDE_TARGET else MappingProxyType({})
129 )
130 if isinstance(listed, PiSyncError):
131 raise click.ClickException(_listing_error(base_url, listed, target))
132 return _Listing(listed)
135def _starting_model(model: str, listing: _Listing) -> str | None:
136 source: Final = next((listed.id for listed in listing.models if listed.source_model == model), None)
137 return source or next((listed.id for listed in listing.models if listed.id == model), None)
140def _model_choice(model: str | None) -> ModelChoice:
141 return StartOn(model) if model is not None else UnpinModel()
144def _validated_model(model: str | None, listing: _Listing, base_url: str) -> str | None:
145 starting: Final = _starting_model(model, listing) if model is not None else None
146 if model is not None and starting is None:
147 shown: Final = ", ".join(listing.ids[:_LISTED_MODELS_SHOWN])
148 raise click.ClickException(f"{model!r} is not served by {base_url} for this key. /v1/models lists: {shown}.")
149 return starting
152def _apply_claude(base_url: str, credential: StaticToken, listing: _Listing, model: str | None) -> None:
153 listed: Final = listing.ids
154 starting: Final = _validated_model(model, listing, base_url)
155 settings_path: Final = claude_settings_path(os.environ)
156 try:
157 configure_claude_settings(
158 base_url,
159 credential,
160 _model_choice(starting),
161 settings_path,
162 configure_state_path(settings_path),
163 settings_file_owners(settings_path),
164 )
165 except ClaudeSettingsError as e:
166 raise click.ClickException(str(e))
167 in_picker: Final = sum(1 for listed_model in listed if CLAUDE_CODE_PICKER_PATTERN.search(listed_model))
168 click.echo(f"Configured Claude Code: {settings_path} now routes through {base_url}.")
170 click.echo("Credential: your virtual key, stored in the file as ANTHROPIC_AUTH_TOKEN.")
171 click.echo(
172 f"Starting model: {starting} ({STARTING_MODEL_ROLE}); switch any time with /model."
173 if starting is not None
174 else "Starting model: not pinned (Claude Code's default, or a model you set yourself); switch with /model, or "
175 "pass --model to start on a proxy model. Without a pin, a resumed session re-sends the model its transcript "
176 "recorded, which behind a raw-model auto-router is the tier model."
177 )
178 click.echo(
179 f"/model will list all {len(listed)} of the proxy's models."
180 if in_picker == len(listed)
181 else f"/model will list {in_picker} of the proxy's {len(listed)} models: Claude Code shows only ids containing "
182 "'claude' or 'anthropic', and this proxy does not list the rest under such names."
183 )
184 click.echo("Start `claude` from any terminal. Undo with `lite unconfigure claude`.")
185 if settings_path.is_symlink():
186 click.echo(
187 f"Note: {settings_path} is a symlink to {settings_path.resolve()}, so your key now lives in "
188 "that file; keep it out of version control.",
189 err=True,
190 )
193def _pick_targets() -> tuple[str, ...]:
194 picked: Final = inquirer.checkbox(
195 message="Which agents should route through LiteLLM?",
196 choices=[Choice(value, name=label, enabled=True) for value, label in _TARGETS],
197 validate=lambda chosen: len(chosen) > 0,
198 invalid_message="Pick at least one.",
199 ).execute()
200 return tuple(str(value) for value in picked)
203def _pick_model(listed: Sequence[str]) -> str | None:
204 picked: Final = inquirer.fuzzy(
205 message="Model Claude Code starts on (type to filter; /model switches any time):",
206 choices=[_KEEP_DEFAULT_MODEL, *listed],
207 default=listed[0] if listed else _KEEP_DEFAULT_MODEL,
208 ).execute()
209 return None if picked == _KEEP_DEFAULT_MODEL else str(picked)
212def _pick_codex_model(listed: Sequence[str]) -> str:
213 choices: Final = list(listed) # mutable-ok: InquirerPy's choices parameter requires a list
214 return str(inquirer.fuzzy(message="Model Codex starts on (type to filter):", choices=choices).execute())
217def _apply_codex(base_url: str, credential: StaticToken, listing: _Listing, model: str) -> None:
218 _validated_model(model, listing, base_url)
219 settings_path: Final = codex_config_path(os.environ)
220 try:
221 configure_codex_settings(base_url, credential.token, model, settings_path)
222 except CodexSettingsError as e:
223 raise click.ClickException(str(e)) from e
224 click.echo(f"Configured Codex: {settings_path} now routes through {base_url}.")
225 click.echo(f"Starting model: {model}. Credential: your virtual key, stored in the private provider settings.")
226 click.echo("Start `codex` from any terminal. Undo with `lite unconfigure codex`.")
227 if settings_path.is_symlink():
228 click.echo(f"Note: your key now lives in {settings_path.resolve()}; keep it out of version control.", err=True)
231@dataclass(frozen=True, slots=True)
232class _Setup:
233 target: str
234 listing: _Listing
235 model: str | None
238def _choose_setup(
239 base_url: str,
240 target: str,
241 credential: StaticToken,
242 pick_model: Callable[[Sequence[str]], str | None],
243 pick_codex_model: Callable[[Sequence[str]], str],
244) -> _Setup:
245 listing: Final = _listed_models(base_url, credential.token, target)
246 model: Final = (
247 pick_model(tuple(item.source_model or item.id for item in listing.models))
248 if target == _CLAUDE_TARGET
249 else pick_codex_model(listing.ids)
250 )
251 _validated_model(model, listing, base_url)
252 return _Setup(target, listing, model)
255def interactive_configure(
256 ctx: click.Context,
257 pick_targets: Callable[[], tuple[str, ...]] = _pick_targets,
258 pick_model: Callable[[Sequence[str]], str | None] = _pick_model,
259 pick_codex_model: Callable[[Sequence[str]], str] = _pick_codex_model,
260) -> None:
261 """`lite configure` with no agent named: ask which agents to wire and which model to pin."""
262 targets: Final = pick_targets()
263 if not targets:
264 return
265 for target in targets:
266 _preflight(target)
267 try:
268 credential: Final = resolve_credential(ctx, None)
269 except ClaudeSettingsError as e:
270 raise click.ClickException(str(e)) from e
271 base_url: Final[str] = ctx.obj["base_url"]
272 setups: Final = tuple(
273 _choose_setup(base_url, target, credential, pick_model, pick_codex_model) for target in targets
274 )
275 for setup in setups:
276 if setup.target == _CLAUDE_TARGET:
277 _apply_claude(base_url, credential, setup.listing, setup.model)
278 elif setup.model is not None:
279 _apply_codex(base_url, credential, setup.listing, setup.model)
282class _ConnectionOptions(BaseModel):
283 api_key: str | None = None
284 gateway_url: str | None = None
287def _connection_settings(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> CliContextObj:
288 """The context object a subcommand runs with: its own --api-key / --gateway-url over the group's, over `lite`'s."""
289 ctx_obj: Final[CliContextObj] = ctx.obj
290 group: Final = (
291 _ConnectionOptions.model_validate(ctx.parent.params)
292 if ctx.parent is not None and ctx.parent.command.name == "configure"
293 else _ConnectionOptions()
294 )
295 key: Final = api_key if api_key is not None else group.api_key
296 url: Final = gateway_url if gateway_url is not None else group.gateway_url
297 normalized: Final = normalize_base_url(url if url is not None else ctx_obj["base_url"])
298 connection: Final[CliContextObj] = {
299 **ctx_obj,
300 "base_url": normalized.removesuffix("/v1"),
301 "base_url_explicit": url is not None or ctx_obj.get("base_url_explicit", False),
302 "api_key": key if key is not None else ctx_obj.get("api_key"),
303 "api_key_from_token_file": False if key is not None else ctx_obj.get("api_key_from_token_file", False),
304 }
305 return connection
308def _connection_context(ctx: click.Context, settings: CliContextObj) -> click.Context:
309 return click.Context(ctx.command, parent=ctx.parent, obj=settings)
312@click.group(name="configure", invoke_without_command=True)
313@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key to store in the selected agents.")
314@click.option(
315 "--gateway-url", "--base-url", default=None, help="Gateway URL; defaults to `lite --base-url` / LITELLM_PROXY_URL."
316)
317@click.pass_context
318def configure_group(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> None:
319 """Persistently route a coding agent through your LiteLLM proxy.
321 With no agent named, asks which agents to wire and which proxy model to pin.
322 """
323 if ctx.invoked_subcommand is not None:
324 return
325 settings: Final = _connection_settings(ctx, api_key, gateway_url)
326 connection: Final = _connection_context(ctx, settings)
327 if not sys.stdin.isatty():
328 raise click.ClickException(
329 "`lite configure` asks questions, so it needs a terminal. Non-interactively, run "
330 "`lite configure claude --api-key <key> --model <model>` or "
331 "`lite configure codex --api-key <key> --model <model>`."
332 )
333 if settings.get("base_url_explicit"):
334 interactive_configure(connection)
335 return
336 prompted: Final = _connection_settings(connection, None, click.prompt("Gateway URL", default=settings["base_url"]))
337 interactive_configure(_connection_context(connection, prompted))
340@click.group(name="unconfigure")
341def unconfigure_group() -> None:
342 """Undo `lite configure` for a coding agent."""
345@configure_group.command(name="claude")
346@click.option(
347 "--api-key",
348 "api_key",
349 default=None,
350 help="Long-lived LiteLLM virtual key written into Claude Code's settings. Defaults to the `lite --api-key` / "
351 "LITELLM_PROXY_API_KEY value; required, since a `lite login` credential expires within a day.",
352)
353@click.option("--model", default=None, help=_MODEL_OPTION_HELP)
354@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, including any deployment path prefix.")
355@click.pass_context
356def configure_claude(ctx: click.Context, api_key: str | None, model: str | None, gateway_url: str | None) -> None:
357 """Route every Claude Code session through your LiteLLM proxy until `lite unconfigure claude`.
359 Patches ~/.claude/settings.json in place: the proxy URL, your virtual key as a static token,
360 and gateway model discovery so /model lists the proxy's models; --model picks the one Claude
361 Code starts on and resumes with. Every other
362 setting is kept, and what changed is recorded so `lite unconfigure claude` can put it back.
363 Assumes the proxy is already running.
364 """
365 settings: Final = _connection_settings(ctx, api_key, gateway_url)
366 credential, listing = _start(_connection_context(ctx, settings), settings["base_url"], api_key)
367 _apply_claude(settings["base_url"], credential, listing, model)
370@configure_group.command(name="codex")
371@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key to store in Codex's user config.")
372@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, including any deployment path prefix.")
373@click.option("--model", required=True, help="Gateway model Codex starts on, as listed by /v1/models for your key.")
374@click.pass_context
375def configure_codex(ctx: click.Context, api_key: str | None, gateway_url: str | None, model: str) -> None:
376 """Route plain `codex` through the gateway until `lite unconfigure codex`."""
377 settings: Final = _connection_settings(ctx, api_key, gateway_url)
378 credential, listing = _start(_connection_context(ctx, settings), settings["base_url"], api_key, _CODEX_TARGET)
379 _apply_codex(settings["base_url"], credential, listing, model)
382@unconfigure_group.command(name="codex")
383def unconfigure_codex() -> None:
384 """Restore only Codex settings still holding what configure wrote."""
385 settings_path: Final = codex_config_path(os.environ)
386 try:
387 outcome: Final = unconfigure_codex_settings(settings_path)
388 except CodexSettingsError as e:
389 raise click.ClickException(str(e)) from e
390 if outcome.file_removed:
391 click.echo(f"Removed {settings_path}; it held only settings created by `lite configure codex`.")
392 elif outcome.restored:
393 click.echo(f"Restored in {settings_path}: {', '.join(outcome.restored)}.")
394 else:
395 click.echo(f"Nothing in {settings_path} was still ours to restore.")
396 if outcome.kept:
397 click.echo(f"Left as you changed them since: {', '.join(outcome.kept)}.")
400@unconfigure_group.command(name="claude")
401def unconfigure_claude() -> None:
402 """Return Claude Code's settings to what they were before `lite configure claude`.
404 Also undoes `lite login --config-claude`. Only keys still holding what configure wrote are
405 put back; anything you changed since is left as it is and named in the output.
406 """
407 settings_path: Final = claude_settings_path(os.environ)
408 state_path: Final = configure_state_path(settings_path)
409 try:
410 outcome: Final = unconfigure_claude_settings(settings_path, state_path, settings_file_owners(settings_path))
411 except ClaudeSettingsError as e:
412 raise click.ClickException(str(e))
413 _report_unconfigure(settings_path, state_path, outcome)
416def _report_unconfigure(settings_path: Path, state_path: Path, outcome: UnconfigureOutcome) -> None:
417 """Say what unconfigure did, naming only keys whose value it changed."""
418 if outcome.file_removed:
419 click.echo(
420 f"No settings file remains at {settings_path}; it held nothing but `lite configure claude`'s own keys."
421 )
422 elif outcome.restored:
423 click.echo(f"Restored in {settings_path}: {', '.join(outcome.restored)}.")
424 else:
425 click.echo(f"Nothing in {settings_path} was still ours to restore.")
426 if outcome.kept:
427 click.echo(f"Left as you changed them since: {', '.join(outcome.kept)}.")
428 if outcome.withheld:
429 click.echo(
430 "Left removed, since the file now points at a different server than they were issued for: "
431 + "; ".join(f"{item.key} (captured with {item.endpoint})" for item in outcome.withheld)
432 + f". They stay in {state_path}: point env.ANTHROPIC_BASE_URL back and run `lite unconfigure claude` "
433 "again to put them back, or delete that file to drop them."
434 )
437__all__ = ("configure_group", "interactive_configure", "resolve_credential", "unconfigure_group")