mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-29 01:42:19 +00:00
feat(cli): reuse saved agent setup and add reconfigure (#43392)
This commit is contained in:
parent
7aba77197d
commit
013d5fa015
6 changed files with 1391 additions and 362 deletions
|
|
@ -546,7 +546,20 @@ lite configure --api-key sk-... --gateway-url https://your-proxy.example.com
|
|||
|
||||
Select Claude Code, Codex, or both, then choose a gateway model for each selected agent. The wizard validates the key and reads the models your key can access before changing settings. Start either configured agent normally with `claude` or `codex`; the gateway connection persists across terminals without a wrapper or exported API key
|
||||
|
||||
`--gateway-url` also accepts a deployment path prefix and a trailing `/v1`. `--base-url` is an alias. If omitted, setup uses `lite --base-url`, `LITELLM_PROXY_URL`, or the saved CLI URL; the wizard asks for a URL when none was provided
|
||||
Your gateway, virtual key and model choice are saved separately from each agent's undo record. The command saves validated choices before applying them; if applying fails, `lite configure` retries those saved choices. Disconnecting keeps that setup so you can reconnect without repeating the wizard:
|
||||
|
||||
```bash
|
||||
lite unconfigure
|
||||
lite configure
|
||||
```
|
||||
|
||||
`lite configure` reuses all saved setups for the current agent homes. Name an agent to reconnect only that one, such as `lite configure claude` or `lite configure codex`. Saved setup works without a terminal when the key and model are still valid
|
||||
|
||||
Run `lite reconfigure` to edit your choices with the saved values prefilled, or `lite reconfigure codex` to edit one agent. The agent picker selects which setups to edit; unchecked agents keep their settings. For Claude Code, choose its own default in the wizard or use `lite configure claude --default-model` to remove LiteLLM's model pin. Omitting `--model` keeps your saved choice
|
||||
|
||||
`lite unconfigure --forget` undoes settings it still owns and deletes the saved setups, including their saved keys. `lite unconfigure claude --forget` forgets only Claude Code. Both work after an earlier disconnect. Saved setup files have owner-only permissions and follow the same resolved config-file scope as the undo records, including `CLAUDE_CONFIG_DIR` and `CODEX_HOME`. A pending undo record remains available if an original credential could not safely be restored. If the undo record is missing, agent settings are left unchanged and the command asks you to remove any remaining gateway connection and key manually; forgetting the saved profile does not erase unowned agent settings
|
||||
|
||||
`--gateway-url` also accepts a deployment path prefix and a trailing `/v1`. `--base-url` is an alias. Current command-line or environment options override saved setup values. Otherwise an existing setup supplies its own gateway; first setup falls back to the saved CLI URL or prompts for one. Changing the gateway requires a key for that gateway, so an old saved key is never reused for a different destination
|
||||
|
||||
For a scripted setup, name the agent and model:
|
||||
|
||||
|
|
@ -569,9 +582,9 @@ lite --base-url https://your-proxy.example.com configure claude --api-key sk-...
|
|||
claude
|
||||
```
|
||||
|
||||
The key comes from `--api-key` (or `lite --api-key` / `LITELLM_PROXY_API_KEY`) and is written into `env.ANTHROPIC_AUTH_TOKEN`; without one the command refuses, since a `lite login` credential expires within a day and keeping it fresh would mean Claude Code running `lite` through `apiKeyHelper` on every credential refresh. The command checks the key against `GET /v1/models`, then patches `~/.claude/settings.json`: `env.ANTHROPIC_BASE_URL`, the credential, and `env.ENABLE_TOOL_SEARCH` and `env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` when those are missing, so Claude Code's `/model` picker lists the proxy's models (under `claude-router-<UTF-8 hex of the group name>` for a group whose id contains neither `claude` nor `anthropic`, since Claude Code lists only those) and you pick between them as usual. Claude Code keeps its own default model until you switch, so that id has to exist on the proxy for the first message to go through; `--model` (or the interactive prompt below) sets the model Claude Code starts on instead, as the top-level `model` key and as `env.ANTHROPIC_MODEL`, both of which have to be on `/v1/models` for the key. The second one matters for `claude -c` and `claude --resume`: a resumed session otherwise re-sends the model its transcript recorded, which behind an auto-router with `return_raw_model_name: true` is the tier model that answered, and a key scoped to the router alias gets a 403 for it; `ANTHROPIC_MODEL` outranks the transcript on resume. Nothing forces Claude Code's sub-agent or background tiers onto a proxy model, so those built-in ids need to exist on the proxy too; `lite autoroute start` is the mode that pins every tier to one group. Claude Code treats a name it does not know as an unknown model: it prints a one-line `unrecognized_model` note, assumes a 200k context window (the proxy appends `[1m]` for a group whose configured or known input window reaches 1M) and sends no thinking parameters for it, so name the group like a Claude model id to change that. The other credential slots (`env.ANTHROPIC_API_KEY`, a stale `env.ANTHROPIC_AUTH_TOKEN` or `apiKeyHelper`) are removed so they cannot fight the one written. Every other setting is preserved and the file is written atomically with owner-only permissions; if `settings.json` is a symlink into a dotfiles repository, the key is written through to that target and the command says so, so keep it out of version control
|
||||
The key comes from `--api-key` (or `lite --api-key` / `LITELLM_PROXY_API_KEY`), or from this agent's saved setup, and is written into `env.ANTHROPIC_AUTH_TOKEN`; without one the command refuses, since a `lite login` credential expires within a day and keeping it fresh would mean Claude Code running `lite` through `apiKeyHelper` on every credential refresh. The command checks the key against `GET /v1/models`, then patches `~/.claude/settings.json`: `env.ANTHROPIC_BASE_URL`, the credential, and `env.ENABLE_TOOL_SEARCH` and `env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` when those are missing, so Claude Code's `/model` picker lists the proxy's models (under `claude-router-<UTF-8 hex of the group name>` for a group whose id contains neither `claude` nor `anthropic`, since Claude Code lists only those) and you pick between them as usual. On first setup without a model choice, Claude Code keeps its own default model until you switch, so that id has to exist on the proxy for the first message to go through; `--model` (or the interactive prompt below) sets the model Claude Code starts on instead, as the top-level `model` key and as `env.ANTHROPIC_MODEL`, both of which have to be on `/v1/models` for the key. The second one matters for `claude -c` and `claude --resume`: a resumed session otherwise re-sends the model its transcript recorded, which behind an auto-router with `return_raw_model_name: true` is the tier model that answered, and a key scoped to the router alias gets a 403 for it; `ANTHROPIC_MODEL` outranks the transcript on resume. Nothing forces Claude Code's sub-agent or background tiers onto a proxy model, so those built-in ids need to exist on the proxy too; `lite autoroute start` is the mode that pins every tier to one group. Claude Code treats a name it does not know as an unknown model: it prints a one-line `unrecognized_model` note, assumes a 200k context window (the proxy appends `[1m]` for a group whose configured or known input window reaches 1M) and sends no thinking parameters for it, so name the group like a Claude model id to change that. The other credential slots (`env.ANTHROPIC_API_KEY`, a stale `env.ANTHROPIC_AUTH_TOKEN` or `apiKeyHelper`) are removed so they cannot fight the one written. Every other setting is preserved and the file is written atomically with owner-only permissions; if `settings.json` is a symlink into a dotfiles repository, the key is written through to that target and the command says so, so keep it out of version control
|
||||
|
||||
Plain `lite configure`, with no agent named, asks which agents to wire and which gateway model each starts on, picked from `/v1/models` with a type-to-filter prompt. All choices and selected config files are checked before the first settings write. If a later filesystem write fails, the output identifies each agent already configured and its undo command
|
||||
On first use, plain `lite configure` asks which agents to wire and which gateway model each starts on, picked from `/v1/models` with a type-to-filter prompt. Later runs validate and reapply the saved setups. All choices and selected config files are checked before the first settings write. If a later filesystem write fails, the output identifies each agent already configured and its undo command
|
||||
|
||||
What the command changed is recorded in `~/.litellm/claude_configure_state.json` (previous values plus fingerprints of what was written, never a second copy of the key). `lite unconfigure claude` restores each of those keys only if it still holds what `configure` wrote, so anything you changed since is left alone and named in the output; a `settings.json` or `env` object that only existed because of `configure` is removed again. Ownership moves only by a write: running `configure` again (a re-login is one) refreshes the record only for the keys its merge changed, keeps the original snapshot of a key that still holds what it wrote, and snapshots afresh a key you changed in between, so `unconfigure` brings back whatever the repeat displaced and never adopts your edit as its own. A credential (`env.ANTHROPIC_API_KEY`, `env.ANTHROPIC_AUTH_TOKEN`, `apiKeyHelper`) is put back only when the restored file points at the `ANTHROPIC_BASE_URL` it was captured next to; otherwise it stays removed, the output says which server it belonged to, and the receipt is kept so pointing the URL back and running `unconfigure` again finishes the job. It also undoes `lite login --config-claude`, which writes through the same path. Both refuse to run while a `lite up` or `lite autoroute start` session holds a backup, and that check comes before any request
|
||||
|
||||
|
|
@ -587,7 +600,7 @@ Claude Opus 5 ██████████████████████
|
|||
|
||||
After the first response, the status line uses the latest routed model recorded by `GET /auto_router/session?session_id=...`, so it can show the tier model even when the transcript contains the router alias. If no session record is available, it falls back to Claude Code's transcript. Session records and costs are cached for five seconds under a per-user `$TMPDIR/litellm-statusline-<uid>` directory. The gateway records turns asynchronously, so the display can briefly lag a completed turn. Any virtual key may read its own sessions. The baseline is the priciest model in the router's hardest tier, the same counterfactual the auto-router's savings reports use. `lite unconfigure claude` removes the `statusLine` entry only while it still points at that script
|
||||
|
||||
After upgrading the CLI, rerun your original `lite configure claude` command with the same gateway, key and model choice to refresh `~/.litellm/statusline.py`. Keep any explicit `--model` value: omitting it removes the earlier model pin. Package upgrades alone do not refresh this installed copy
|
||||
After upgrading the CLI, run `lite configure claude` to refresh `~/.litellm/statusline.py` using the saved setup. If your setup predates saved profiles, supply the original gateway, key and model once. Package upgrades alone do not refresh this installed copy
|
||||
|
||||
`lite codex` registers the same script as a Codex `Stop` hook for the launch, so after each turn Codex prints the same block as a system message. Codex asks once to trust the hook; the answer is remembered for later launches.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,284 +1,43 @@
|
|||
"""Persistent Claude Code and Codex gateway configuration."""
|
||||
"""Commands for saved Claude Code and Codex gateway setup."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import click
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.proxy.common_utils.model_listing_utils import (
|
||||
CLAUDE_CODE_CLIENT,
|
||||
CLAUDE_CODE_PICKER_PATTERN,
|
||||
GATEWAY_CLIENT_HEADER,
|
||||
)
|
||||
|
||||
from .agents import codex_config_path
|
||||
from .auth import CliContextObj
|
||||
from .claude_settings import (
|
||||
STARTING_MODEL_ROLE,
|
||||
ClaudeSettingsError,
|
||||
ModelChoice,
|
||||
StartOn,
|
||||
StaticToken,
|
||||
UnconfigureOutcome,
|
||||
UnpinModel,
|
||||
claude_settings_path,
|
||||
configure_claude_settings,
|
||||
configure_state_path,
|
||||
preflight_claude_settings,
|
||||
settings_file_owners,
|
||||
unconfigure_claude_settings,
|
||||
)
|
||||
from .codex_settings import (
|
||||
CodexSettingsError,
|
||||
configure_codex_settings,
|
||||
preflight_codex_settings,
|
||||
unconfigure_codex_settings,
|
||||
)
|
||||
from .codex_settings import CodexSettingsError, unconfigure_codex_settings
|
||||
from .config import normalize_base_url
|
||||
from .pi import ListedModel, ListingFailure, PiSyncError, fetch_model_listing
|
||||
|
||||
_LISTED_MODELS_SHOWN: Final = 20
|
||||
_CLAUDE_TARGET: Final = "claude"
|
||||
_CODEX_TARGET: Final = "codex"
|
||||
_TARGETS: Final = ((_CLAUDE_TARGET, "Claude Code (CLI)"), (_CODEX_TARGET, "Codex (CLI)"))
|
||||
_KEEP_DEFAULT_MODEL: Final = "Keep Claude Code's own default"
|
||||
_CLAUDE_CODE_VIEW: Final = MappingProxyType(
|
||||
{"anthropic-version": "2023-06-01", GATEWAY_CLIENT_HEADER: CLAUDE_CODE_CLIENT}
|
||||
from .configure_profiles import (
|
||||
TARGETS,
|
||||
Target,
|
||||
forget_saved_setup,
|
||||
read_saved_setup,
|
||||
receipt_path_for,
|
||||
settings_path_for,
|
||||
setup_locks,
|
||||
setup_profile_path,
|
||||
)
|
||||
_MODEL_OPTION_HELP: Final = (
|
||||
f"Proxy model to set as {STARTING_MODEL_ROLE}. Must be listed on /v1/models for the key; without it, "
|
||||
"Claude Code keeps its own default and a pin an earlier configure made is let go of. Nothing pins Claude "
|
||||
"Code's sub-agent or background tiers; `lite autoroute start` is the mode that does."
|
||||
from .configure_setup import (
|
||||
MODEL_OPTION_HELP,
|
||||
ConnectionSettings,
|
||||
configure_targets,
|
||||
interactive_configure,
|
||||
pick_targets,
|
||||
resolve_credential,
|
||||
)
|
||||
|
||||
|
||||
def resolve_credential(ctx: click.Context, api_key: str | None) -> StaticToken:
|
||||
"""The long-lived key written into settings.json: --api-key, `lite --api-key` or LITELLM_PROXY_API_KEY.
|
||||
|
||||
A `lite login` credential is never written: it expires within a day, and keeping it fresh would mean
|
||||
Claude Code running `lite` through `apiKeyHelper` on every credential refresh.
|
||||
"""
|
||||
ctx_obj: Final[CliContextObj] = ctx.obj
|
||||
explicit: Final = api_key or (None if ctx_obj.get("api_key_from_token_file") else ctx_obj.get("api_key"))
|
||||
if not explicit:
|
||||
raise ClaudeSettingsError(
|
||||
"`lite configure` needs a long-lived virtual key: pass --api-key, `lite --api-key`, or set "
|
||||
"LITELLM_PROXY_API_KEY. Your `lite login` credential expires within a day, so it is not written "
|
||||
"into agent settings."
|
||||
)
|
||||
if not explicit.strip() or any(ord(char) <= 32 or ord(char) == 127 for char in explicit):
|
||||
raise ClaudeSettingsError("The virtual key must not be blank or contain whitespace or control characters.")
|
||||
return StaticToken(explicit)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Listing:
|
||||
models: tuple[ListedModel, ...]
|
||||
|
||||
@property
|
||||
def ids(self) -> tuple[str, ...]:
|
||||
return tuple(model.id for model in self.models)
|
||||
|
||||
|
||||
def _preflight(target: str) -> None:
|
||||
try:
|
||||
if target == _CLAUDE_TARGET:
|
||||
preflight_claude_settings(claude_settings_path(os.environ))
|
||||
else:
|
||||
preflight_codex_settings(codex_config_path(os.environ))
|
||||
except (ClaudeSettingsError, CodexSettingsError) as e:
|
||||
raise click.ClickException(str(e)) from e
|
||||
|
||||
|
||||
def _start(
|
||||
ctx: click.Context, base_url: str, api_key: str | None, target: str = _CLAUDE_TARGET
|
||||
) -> tuple[StaticToken, _Listing]:
|
||||
_preflight(target)
|
||||
try:
|
||||
credential: Final = resolve_credential(ctx, api_key)
|
||||
except ClaudeSettingsError as e:
|
||||
raise click.ClickException(str(e))
|
||||
return credential, _listed_models(base_url, credential.token, target)
|
||||
|
||||
|
||||
def _listing_error(base_url: str, error: PiSyncError, target: str) -> str:
|
||||
"""The hint that fits how the listing failed: only an unreachable proxy gets the "is it running" question."""
|
||||
if error.kind is ListingFailure.REJECTED:
|
||||
return f"LiteLLM rejected your key (HTTP {error.status}). Pass a valid --api-key."
|
||||
if error.kind is ListingFailure.UNREACHABLE:
|
||||
return (
|
||||
f"Could not connect. Is the proxy at {base_url} running, and is --base-url (or LITELLM_PROXY_URL) correct?"
|
||||
)
|
||||
if error.kind is ListingFailure.EMPTY:
|
||||
name: Final = "Claude Code" if target == _CLAUDE_TARGET else "Codex"
|
||||
return f"{error.message} {name} would have nothing to run; give the key access to at least one model."
|
||||
return f"The proxy at {base_url} answered, so check that it is a LiteLLM proxy and is healthy."
|
||||
|
||||
|
||||
def _listed_models(base_url: str, key: str, target: str = _CLAUDE_TARGET) -> _Listing:
|
||||
listed: Final = fetch_model_listing(
|
||||
base_url, key, headers=_CLAUDE_CODE_VIEW if target == _CLAUDE_TARGET else MappingProxyType({})
|
||||
)
|
||||
if isinstance(listed, PiSyncError):
|
||||
raise click.ClickException(_listing_error(base_url, listed, target))
|
||||
return _Listing(listed)
|
||||
|
||||
|
||||
def _starting_model(model: str, listing: _Listing) -> str | None:
|
||||
source: Final = next((listed.id for listed in listing.models if listed.source_model == model), None)
|
||||
return source or next((listed.id for listed in listing.models if listed.id == model), None)
|
||||
|
||||
|
||||
def _model_choice(model: str | None) -> ModelChoice:
|
||||
return StartOn(model) if model is not None else UnpinModel()
|
||||
|
||||
|
||||
def _validated_model(model: str | None, listing: _Listing, base_url: str) -> str | None:
|
||||
starting: Final = _starting_model(model, listing) if model is not None else None
|
||||
if model is not None and starting is None:
|
||||
shown: Final = ", ".join(listing.ids[:_LISTED_MODELS_SHOWN])
|
||||
raise click.ClickException(f"{model!r} is not served by {base_url} for this key. /v1/models lists: {shown}.")
|
||||
return starting
|
||||
|
||||
|
||||
def _apply_claude(base_url: str, credential: StaticToken, listing: _Listing, model: str | None) -> None:
|
||||
listed: Final = listing.ids
|
||||
starting: Final = _validated_model(model, listing, base_url)
|
||||
settings_path: Final = claude_settings_path(os.environ)
|
||||
try:
|
||||
configure_claude_settings(
|
||||
base_url,
|
||||
credential,
|
||||
_model_choice(starting),
|
||||
settings_path,
|
||||
configure_state_path(settings_path),
|
||||
settings_file_owners(settings_path),
|
||||
)
|
||||
except ClaudeSettingsError as e:
|
||||
raise click.ClickException(str(e))
|
||||
in_picker: Final = sum(1 for listed_model in listed if CLAUDE_CODE_PICKER_PATTERN.search(listed_model))
|
||||
click.echo(f"Configured Claude Code: {settings_path} now routes through {base_url}.")
|
||||
|
||||
click.echo("Credential: your virtual key, stored in the file as ANTHROPIC_AUTH_TOKEN.")
|
||||
click.echo(
|
||||
f"Starting model: {starting} ({STARTING_MODEL_ROLE}); switch any time with /model."
|
||||
if starting is not None
|
||||
else "Starting model: not pinned (Claude Code's default, or a model you set yourself); switch with /model, or "
|
||||
"pass --model to start on a proxy model. Without a pin, a resumed session re-sends the model its transcript "
|
||||
"recorded, which behind a raw-model auto-router is the tier model."
|
||||
)
|
||||
click.echo(
|
||||
f"/model will list all {len(listed)} of the proxy's models."
|
||||
if in_picker == len(listed)
|
||||
else f"/model will list {in_picker} of the proxy's {len(listed)} models: Claude Code shows only ids containing "
|
||||
"'claude' or 'anthropic', and this proxy does not list the rest under such names."
|
||||
)
|
||||
click.echo("Start `claude` from any terminal. Undo with `lite unconfigure claude`.")
|
||||
if settings_path.is_symlink():
|
||||
click.echo(
|
||||
f"Note: {settings_path} is a symlink to {settings_path.resolve()}, so your key now lives in "
|
||||
"that file; keep it out of version control.",
|
||||
err=True,
|
||||
)
|
||||
|
||||
|
||||
def _pick_targets() -> tuple[str, ...]:
|
||||
picked: Final = inquirer.checkbox(
|
||||
message="Which agents should route through LiteLLM?",
|
||||
choices=[Choice(value, name=label, enabled=True) for value, label in _TARGETS],
|
||||
validate=lambda chosen: len(chosen) > 0,
|
||||
invalid_message="Pick at least one.",
|
||||
).execute()
|
||||
return tuple(str(value) for value in picked)
|
||||
|
||||
|
||||
def _pick_model(listed: Sequence[str]) -> str | None:
|
||||
picked: Final = inquirer.fuzzy(
|
||||
message="Model Claude Code starts on (type to filter; /model switches any time):",
|
||||
choices=[_KEEP_DEFAULT_MODEL, *listed],
|
||||
default=listed[0] if listed else _KEEP_DEFAULT_MODEL,
|
||||
).execute()
|
||||
return None if picked == _KEEP_DEFAULT_MODEL else str(picked)
|
||||
|
||||
|
||||
def _pick_codex_model(listed: Sequence[str]) -> str:
|
||||
choices: Final = list(listed) # mutable-ok: InquirerPy's choices parameter requires a list
|
||||
return str(inquirer.fuzzy(message="Model Codex starts on (type to filter):", choices=choices).execute())
|
||||
|
||||
|
||||
def _apply_codex(base_url: str, credential: StaticToken, listing: _Listing, model: str) -> None:
|
||||
_validated_model(model, listing, base_url)
|
||||
settings_path: Final = codex_config_path(os.environ)
|
||||
try:
|
||||
configure_codex_settings(base_url, credential.token, model, settings_path)
|
||||
except CodexSettingsError as e:
|
||||
raise click.ClickException(str(e)) from e
|
||||
click.echo(f"Configured Codex: {settings_path} now routes through {base_url}.")
|
||||
click.echo(f"Starting model: {model}. Credential: your virtual key, stored in the private provider settings.")
|
||||
click.echo("Start `codex` from any terminal. Undo with `lite unconfigure codex`.")
|
||||
if settings_path.is_symlink():
|
||||
click.echo(f"Note: your key now lives in {settings_path.resolve()}; keep it out of version control.", err=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Setup:
|
||||
target: str
|
||||
listing: _Listing
|
||||
model: str | None
|
||||
|
||||
|
||||
def _choose_setup(
|
||||
base_url: str,
|
||||
target: str,
|
||||
credential: StaticToken,
|
||||
pick_model: Callable[[Sequence[str]], str | None],
|
||||
pick_codex_model: Callable[[Sequence[str]], str],
|
||||
) -> _Setup:
|
||||
listing: Final = _listed_models(base_url, credential.token, target)
|
||||
model: Final = (
|
||||
pick_model(tuple(item.source_model or item.id for item in listing.models))
|
||||
if target == _CLAUDE_TARGET
|
||||
else pick_codex_model(listing.ids)
|
||||
)
|
||||
_validated_model(model, listing, base_url)
|
||||
return _Setup(target, listing, model)
|
||||
|
||||
|
||||
def interactive_configure(
|
||||
ctx: click.Context,
|
||||
pick_targets: Callable[[], tuple[str, ...]] = _pick_targets,
|
||||
pick_model: Callable[[Sequence[str]], str | None] = _pick_model,
|
||||
pick_codex_model: Callable[[Sequence[str]], str] = _pick_codex_model,
|
||||
) -> None:
|
||||
"""`lite configure` with no agent named: ask which agents to wire and which model to pin."""
|
||||
targets: Final = pick_targets()
|
||||
if not targets:
|
||||
return
|
||||
for target in targets:
|
||||
_preflight(target)
|
||||
try:
|
||||
credential: Final = resolve_credential(ctx, None)
|
||||
except ClaudeSettingsError as e:
|
||||
raise click.ClickException(str(e)) from e
|
||||
base_url: Final[str] = ctx.obj["base_url"]
|
||||
setups: Final = tuple(
|
||||
_choose_setup(base_url, target, credential, pick_model, pick_codex_model) for target in targets
|
||||
)
|
||||
for setup in setups:
|
||||
if setup.target == _CLAUDE_TARGET:
|
||||
_apply_claude(base_url, credential, setup.listing, setup.model)
|
||||
elif setup.model is not None:
|
||||
_apply_codex(base_url, credential, setup.listing, setup.model)
|
||||
|
||||
|
||||
class _ConnectionOptions(BaseModel):
|
||||
api_key: str | None = None
|
||||
gateway_url: str | None = None
|
||||
|
|
@ -286,21 +45,20 @@ class _ConnectionOptions(BaseModel):
|
|||
|
||||
def _connection_settings(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> CliContextObj:
|
||||
"""The context object a subcommand runs with: its own --api-key / --gateway-url over the group's, over `lite`'s."""
|
||||
ctx_obj: Final[CliContextObj] = ctx.obj
|
||||
ctx_obj: Final = ConnectionSettings.model_validate(ctx.find_object(object))
|
||||
group: Final = (
|
||||
_ConnectionOptions.model_validate(ctx.parent.params)
|
||||
if ctx.parent is not None and ctx.parent.command.name == "configure"
|
||||
if ctx.parent is not None and ctx.parent.command.name in ("configure", "reconfigure")
|
||||
else _ConnectionOptions()
|
||||
)
|
||||
key: Final = api_key if api_key is not None else group.api_key
|
||||
url: Final = gateway_url if gateway_url is not None else group.gateway_url
|
||||
normalized: Final = normalize_base_url(url if url is not None else ctx_obj["base_url"])
|
||||
normalized: Final = normalize_base_url(url if url is not None else ctx_obj.base_url)
|
||||
connection: Final[CliContextObj] = {
|
||||
**ctx_obj,
|
||||
"base_url": normalized.removesuffix("/v1"),
|
||||
"base_url_explicit": url is not None or ctx_obj.get("base_url_explicit", False),
|
||||
"api_key": key if key is not None else ctx_obj.get("api_key"),
|
||||
"api_key_from_token_file": False if key is not None else ctx_obj.get("api_key_from_token_file", False),
|
||||
"base_url_explicit": url is not None or ctx_obj.base_url_explicit,
|
||||
"api_key": key if key is not None else ctx_obj.api_key,
|
||||
"api_key_from_token_file": False if key is not None else ctx_obj.api_key_from_token_file,
|
||||
}
|
||||
return connection
|
||||
|
||||
|
|
@ -309,108 +67,216 @@ def _connection_context(ctx: click.Context, settings: CliContextObj) -> click.Co
|
|||
return click.Context(ctx.command, parent=ctx.parent, obj=settings)
|
||||
|
||||
|
||||
@click.group(name="configure", invoke_without_command=True)
|
||||
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key to store in the selected agents.")
|
||||
@click.option(
|
||||
"--gateway-url", "--base-url", default=None, help="Gateway URL; defaults to `lite --base-url` / LITELLM_PROXY_URL."
|
||||
)
|
||||
@click.pass_context
|
||||
def configure_group(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> None:
|
||||
"""Persistently route a coding agent through your LiteLLM proxy.
|
||||
def _require_terminal(command: str) -> None:
|
||||
if sys.stdin.isatty():
|
||||
return
|
||||
raise click.ClickException(
|
||||
f"`lite {command}` asks questions, so it needs a terminal. Non-interactively, run "
|
||||
f"`lite {command} claude --api-key <key> --model <model>` or "
|
||||
f"`lite {command} codex --api-key <key> --model <model>`"
|
||||
)
|
||||
|
||||
With no agent named, asks which agents to wire and which proxy model to pin.
|
||||
"""
|
||||
|
||||
def _configure_group(ctx: click.Context, api_key: str | None, gateway_url: str | None, edit: bool) -> None:
|
||||
if ctx.invoked_subcommand is not None:
|
||||
return
|
||||
settings: Final = _connection_settings(ctx, api_key, gateway_url)
|
||||
connection: Final = _connection_context(ctx, settings)
|
||||
if not sys.stdin.isatty():
|
||||
raise click.ClickException(
|
||||
"`lite configure` asks questions, so it needs a terminal. Non-interactively, run "
|
||||
"`lite configure claude --api-key <key> --model <model>` or "
|
||||
"`lite configure codex --api-key <key> --model <model>`."
|
||||
with setup_locks(TARGETS):
|
||||
saved_targets: Final[tuple[Target, ...]] = tuple(
|
||||
target for target in TARGETS if read_saved_setup(target) is not None
|
||||
)
|
||||
if settings.get("base_url_explicit"):
|
||||
interactive_configure(connection)
|
||||
return
|
||||
prompted: Final = _connection_settings(connection, None, click.prompt("Gateway URL", default=settings["base_url"]))
|
||||
interactive_configure(_connection_context(connection, prompted))
|
||||
if saved_targets and not edit:
|
||||
configure_targets(connection, saved_targets)
|
||||
return
|
||||
_require_terminal("reconfigure" if edit else "configure")
|
||||
selected: Final = pick_targets(saved_targets or TARGETS, edit=edit)
|
||||
if not selected:
|
||||
return
|
||||
if edit:
|
||||
configure_targets(connection, selected, interactive=True, edit_connection=True)
|
||||
return
|
||||
prompted: Final = (
|
||||
settings
|
||||
if settings.get("base_url_explicit")
|
||||
else _connection_settings(connection, None, click.prompt("Gateway URL", default=settings["base_url"]))
|
||||
)
|
||||
configure_targets(_connection_context(connection, prompted), selected, interactive=True)
|
||||
|
||||
|
||||
@click.group(name="unconfigure")
|
||||
def unconfigure_group() -> None:
|
||||
"""Undo `lite configure` for a coding agent."""
|
||||
@click.group(name="configure", invoke_without_command=True)
|
||||
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key to save for the selected agents.")
|
||||
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, including any deployment path prefix.")
|
||||
@click.pass_context
|
||||
def configure_group(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> None:
|
||||
"""Apply saved setup, or choose agents and models on the first run."""
|
||||
_configure_group(ctx, api_key, gateway_url, False)
|
||||
|
||||
|
||||
@click.group(name="reconfigure", invoke_without_command=True)
|
||||
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key to save for the selected agents.")
|
||||
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, including any deployment path prefix.")
|
||||
@click.pass_context
|
||||
def reconfigure_group(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> None:
|
||||
"""Edit saved gateway, key and model choices, using current choices as defaults."""
|
||||
_configure_group(ctx, api_key, gateway_url, True)
|
||||
|
||||
|
||||
def _configure_target(
|
||||
ctx: click.Context,
|
||||
target: Target,
|
||||
api_key: str | None,
|
||||
gateway_url: str | None,
|
||||
model: str | None,
|
||||
default_model: bool = False,
|
||||
*,
|
||||
edit: bool = False,
|
||||
) -> None:
|
||||
settings: Final = _connection_settings(ctx, api_key, gateway_url)
|
||||
interactive: Final = edit and model is None and not default_model
|
||||
if interactive:
|
||||
_require_terminal("reconfigure")
|
||||
with setup_locks((target,)):
|
||||
configure_targets(
|
||||
_connection_context(ctx, settings),
|
||||
(target,),
|
||||
model=model,
|
||||
default_model=default_model,
|
||||
interactive=interactive,
|
||||
edit_connection=interactive,
|
||||
)
|
||||
|
||||
|
||||
@configure_group.command(name="claude")
|
||||
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key, or reuse the saved key.")
|
||||
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, or reuse the saved gateway.")
|
||||
@click.option("--model", default=None, help=MODEL_OPTION_HELP)
|
||||
@click.option(
|
||||
"--api-key",
|
||||
"api_key",
|
||||
default=None,
|
||||
help="Long-lived LiteLLM virtual key written into Claude Code's settings. Defaults to the `lite --api-key` / "
|
||||
"LITELLM_PROXY_API_KEY value; required, since a `lite login` credential expires within a day.",
|
||||
"--default-model", is_flag=True, help="Stop pinning a starting model; let Claude Code choose its default."
|
||||
)
|
||||
@click.option("--model", default=None, help=_MODEL_OPTION_HELP)
|
||||
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, including any deployment path prefix.")
|
||||
@click.pass_context
|
||||
def configure_claude(ctx: click.Context, api_key: str | None, model: str | None, gateway_url: str | None) -> None:
|
||||
"""Route every Claude Code session through your LiteLLM proxy until `lite unconfigure claude`.
|
||||
|
||||
Patches ~/.claude/settings.json in place: the proxy URL, your virtual key as a static token,
|
||||
and gateway model discovery so /model lists the proxy's models; --model picks the one Claude
|
||||
Code starts on and resumes with. Every other
|
||||
setting is kept, and what changed is recorded so `lite unconfigure claude` can put it back.
|
||||
Assumes the proxy is already running.
|
||||
"""
|
||||
settings: Final = _connection_settings(ctx, api_key, gateway_url)
|
||||
credential, listing = _start(_connection_context(ctx, settings), settings["base_url"], api_key)
|
||||
_apply_claude(settings["base_url"], credential, listing, model)
|
||||
def configure_claude(
|
||||
ctx: click.Context,
|
||||
api_key: str | None,
|
||||
gateway_url: str | None,
|
||||
model: str | None,
|
||||
default_model: bool,
|
||||
) -> None:
|
||||
"""Apply Claude Code's saved setup, or save the supplied settings."""
|
||||
_configure_target(ctx, "claude", api_key, gateway_url, model, default_model)
|
||||
|
||||
|
||||
@configure_group.command(name="codex")
|
||||
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key to store in Codex's user config.")
|
||||
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, including any deployment path prefix.")
|
||||
@click.option("--model", required=True, help="Gateway model Codex starts on, as listed by /v1/models for your key.")
|
||||
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key, or reuse the saved key.")
|
||||
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, or reuse the saved gateway.")
|
||||
@click.option("--model", default=None, help="Gateway model to start on; required only for first-time setup.")
|
||||
@click.pass_context
|
||||
def configure_codex(ctx: click.Context, api_key: str | None, gateway_url: str | None, model: str) -> None:
|
||||
"""Route plain `codex` through the gateway until `lite unconfigure codex`."""
|
||||
settings: Final = _connection_settings(ctx, api_key, gateway_url)
|
||||
credential, listing = _start(_connection_context(ctx, settings), settings["base_url"], api_key, _CODEX_TARGET)
|
||||
_apply_codex(settings["base_url"], credential, listing, model)
|
||||
def configure_codex(ctx: click.Context, api_key: str | None, gateway_url: str | None, model: str | None) -> None:
|
||||
"""Apply Codex's saved setup, or save the supplied settings."""
|
||||
_configure_target(ctx, "codex", api_key, gateway_url, model)
|
||||
|
||||
|
||||
@unconfigure_group.command(name="codex")
|
||||
def unconfigure_codex() -> None:
|
||||
"""Restore only Codex settings still holding what configure wrote."""
|
||||
settings_path: Final = codex_config_path(os.environ)
|
||||
@reconfigure_group.command(name="claude")
|
||||
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key, or reuse the saved key.")
|
||||
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, or reuse the saved gateway.")
|
||||
@click.option("--model", default=None, help=MODEL_OPTION_HELP)
|
||||
@click.option(
|
||||
"--default-model", is_flag=True, help="Stop pinning a starting model; let Claude Code choose its default."
|
||||
)
|
||||
@click.pass_context
|
||||
def reconfigure_claude(
|
||||
ctx: click.Context,
|
||||
api_key: str | None,
|
||||
gateway_url: str | None,
|
||||
model: str | None,
|
||||
default_model: bool,
|
||||
) -> None:
|
||||
"""Edit Claude Code setup, or supply --model / --default-model to apply directly."""
|
||||
_configure_target(ctx, "claude", api_key, gateway_url, model, default_model, edit=True)
|
||||
|
||||
|
||||
@reconfigure_group.command(name="codex")
|
||||
@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key, or reuse the saved key.")
|
||||
@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, or reuse the saved gateway.")
|
||||
@click.option("--model", default=None, help="Gateway model to start on; omit to open the setup wizard.")
|
||||
@click.pass_context
|
||||
def reconfigure_codex(ctx: click.Context, api_key: str | None, gateway_url: str | None, model: str | None) -> None:
|
||||
"""Edit Codex setup, or supply --model to apply directly."""
|
||||
_configure_target(ctx, "codex", api_key, gateway_url, model, edit=True)
|
||||
|
||||
|
||||
def _disconnect(target: Target, forget: bool) -> None:
|
||||
settings_path: Final = settings_path_for(target)
|
||||
state_path: Final = receipt_path_for(target, settings_path)
|
||||
profile: Final = setup_profile_path(target, settings_path)
|
||||
try:
|
||||
outcome: Final = unconfigure_codex_settings(settings_path)
|
||||
except CodexSettingsError as e:
|
||||
raise click.ClickException(str(e)) from e
|
||||
if outcome.file_removed:
|
||||
click.echo(f"Removed {settings_path}; it held only settings created by `lite configure codex`.")
|
||||
elif outcome.restored:
|
||||
click.echo(f"Restored in {settings_path}: {', '.join(outcome.restored)}.")
|
||||
else:
|
||||
click.echo(f"Nothing in {settings_path} was still ours to restore.")
|
||||
if outcome.kept:
|
||||
click.echo(f"Left as you changed them since: {', '.join(outcome.kept)}.")
|
||||
if not state_path.exists():
|
||||
click.echo(f"No {target} undo receipt at {state_path}; nothing to undo. Agent settings were not changed.")
|
||||
if settings_path.exists():
|
||||
click.echo(
|
||||
f"Cannot confirm disconnection. Check {settings_path} and remove any remaining gateway "
|
||||
"connection and key manually.",
|
||||
err=True,
|
||||
)
|
||||
elif target == "claude":
|
||||
preflight_claude_settings(settings_path)
|
||||
outcome: Final = unconfigure_claude_settings(settings_path, state_path, settings_file_owners(settings_path))
|
||||
_report_unconfigure(settings_path, state_path, outcome)
|
||||
else:
|
||||
codex_outcome: Final = unconfigure_codex_settings(settings_path)
|
||||
if codex_outcome.file_removed:
|
||||
click.echo(f"Removed {settings_path}; it held only settings created by `lite configure codex`.")
|
||||
elif codex_outcome.restored:
|
||||
click.echo(f"Restored in {settings_path}: {', '.join(codex_outcome.restored)}.")
|
||||
else:
|
||||
click.echo(f"Nothing in {settings_path} was still ours to restore.")
|
||||
if codex_outcome.kept:
|
||||
click.echo(f"Left as you changed them since: {', '.join(codex_outcome.kept)}.")
|
||||
except (ClaudeSettingsError, CodexSettingsError) as error:
|
||||
raise click.ClickException(str(error)) from error
|
||||
if forget:
|
||||
forget_saved_setup(target)
|
||||
click.echo(f"Forgot saved {target} setup, including its saved key.")
|
||||
elif profile.exists():
|
||||
click.echo(f"Saved setup retained. Run `lite configure {target}` to apply it again.")
|
||||
|
||||
|
||||
@click.group(name="unconfigure", invoke_without_command=True)
|
||||
@click.option("--forget", is_flag=True, help="Also delete saved setups and their keys.")
|
||||
@click.pass_context
|
||||
def unconfigure_group(ctx: click.Context, forget: bool) -> None:
|
||||
"""Disconnect agents while retaining saved setup for `lite configure`."""
|
||||
if ctx.invoked_subcommand is not None:
|
||||
return
|
||||
with setup_locks(TARGETS):
|
||||
for target in TARGETS:
|
||||
_disconnect(target, forget)
|
||||
|
||||
|
||||
class _UnconfigureOptions(BaseModel):
|
||||
forget: bool = False
|
||||
|
||||
|
||||
def _unconfigure_target(ctx: click.Context, target: Target, forget: bool) -> None:
|
||||
parent: Final = _UnconfigureOptions.model_validate(ctx.parent.params) if ctx.parent else _UnconfigureOptions()
|
||||
with setup_locks((target,)):
|
||||
_disconnect(target, forget or parent.forget)
|
||||
|
||||
|
||||
@unconfigure_group.command(name="claude")
|
||||
def unconfigure_claude() -> None:
|
||||
"""Return Claude Code's settings to what they were before `lite configure claude`.
|
||||
@click.option("--forget", is_flag=True, help="Also delete the saved Claude Code setup and key.")
|
||||
@click.pass_context
|
||||
def unconfigure_claude(ctx: click.Context, forget: bool) -> None:
|
||||
"""Restore Claude Code settings, including those applied by `lite login --config-claude`."""
|
||||
_unconfigure_target(ctx, "claude", forget)
|
||||
|
||||
Also undoes `lite login --config-claude`. Only keys still holding what configure wrote are
|
||||
put back; anything you changed since is left as it is and named in the output.
|
||||
"""
|
||||
settings_path: Final = claude_settings_path(os.environ)
|
||||
state_path: Final = configure_state_path(settings_path)
|
||||
try:
|
||||
outcome: Final = unconfigure_claude_settings(settings_path, state_path, settings_file_owners(settings_path))
|
||||
except ClaudeSettingsError as e:
|
||||
raise click.ClickException(str(e))
|
||||
_report_unconfigure(settings_path, state_path, outcome)
|
||||
|
||||
@unconfigure_group.command(name="codex")
|
||||
@click.option("--forget", is_flag=True, help="Also delete the saved Codex setup and key.")
|
||||
@click.pass_context
|
||||
def unconfigure_codex(ctx: click.Context, forget: bool) -> None:
|
||||
"""Restore Codex settings still holding what configure wrote."""
|
||||
_unconfigure_target(ctx, "codex", forget)
|
||||
|
||||
|
||||
def _report_unconfigure(settings_path: Path, state_path: Path, outcome: UnconfigureOutcome) -> None:
|
||||
|
|
@ -434,4 +300,11 @@ def _report_unconfigure(settings_path: Path, state_path: Path, outcome: Unconfig
|
|||
)
|
||||
|
||||
|
||||
__all__ = ("configure_group", "interactive_configure", "resolve_credential", "unconfigure_group")
|
||||
__all__ = (
|
||||
"configure_group",
|
||||
"inquirer",
|
||||
"interactive_configure",
|
||||
"reconfigure_group",
|
||||
"resolve_credential",
|
||||
"unconfigure_group",
|
||||
)
|
||||
|
|
|
|||
158
litellm/proxy/client/cli/commands/configure_profiles.py
Normal file
158
litellm/proxy/client/cli/commands/configure_profiles.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"""Reusable agent setup, separate from the settings writers' undo receipts."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from collections.abc import Generator, Sequence
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
import click
|
||||
from filelock import FileLock, Timeout
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
||||
|
||||
from litellm.litellm_core_utils.private_json import (
|
||||
commit_staged_json,
|
||||
discard_staged_json,
|
||||
ensure_private_dir,
|
||||
stage_private_json,
|
||||
)
|
||||
|
||||
from .agents import codex_config_path
|
||||
from .claude_settings import claude_settings_path, configure_state_path
|
||||
from .codex_settings import codex_configure_state_path
|
||||
from .config import normalize_base_url
|
||||
|
||||
Target: TypeAlias = Literal["claude", "codex"]
|
||||
TARGETS: Final[tuple[Target, ...]] = ("claude", "codex")
|
||||
|
||||
|
||||
class SavedSetup(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
|
||||
|
||||
version: Literal[1] = 1
|
||||
target: Target
|
||||
settings_path: str
|
||||
base_url: str
|
||||
api_key: str = Field(repr=False)
|
||||
model: str | None
|
||||
|
||||
@field_validator("base_url")
|
||||
@classmethod
|
||||
def normalized_gateway(cls, value: str) -> str:
|
||||
try:
|
||||
if normalize_base_url(value).removesuffix("/v1") != value:
|
||||
raise ValueError("Gateway must be normalized")
|
||||
except click.UsageError as error:
|
||||
raise ValueError("Invalid gateway URL") from error
|
||||
return value
|
||||
|
||||
@field_validator("api_key")
|
||||
@classmethod
|
||||
def valid_key(cls, value: str) -> str:
|
||||
if not value or any(ord(char) <= 32 or ord(char) == 127 for char in value):
|
||||
raise ValueError("Invalid virtual key")
|
||||
return value
|
||||
|
||||
@field_validator("model")
|
||||
@classmethod
|
||||
def valid_model(cls, value: str | None) -> str | None:
|
||||
if value is not None and (not value or any(ord(char) < 32 or ord(char) == 127 for char in value)):
|
||||
raise ValueError("Invalid model choice")
|
||||
return value
|
||||
|
||||
|
||||
def settings_path_for(target: Target) -> Path:
|
||||
return claude_settings_path(os.environ) if target == "claude" else codex_config_path(os.environ)
|
||||
|
||||
|
||||
def receipt_path_for(target: Target, settings_path: Path) -> Path:
|
||||
return configure_state_path(settings_path) if target == "claude" else codex_configure_state_path(settings_path)
|
||||
|
||||
|
||||
def setup_profile_path(target: Target, settings_path: Path) -> Path:
|
||||
receipt: Final = receipt_path_for(target, settings_path)
|
||||
return receipt.with_name(f"{receipt.stem}_profile.json")
|
||||
|
||||
|
||||
def read_saved_setup(target: Target) -> SavedSetup | None:
|
||||
settings_path: Final = settings_path_for(target)
|
||||
path: Final = setup_profile_path(target, settings_path)
|
||||
try:
|
||||
payload: Final = path.read_bytes()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise click.ClickException(
|
||||
f"Could not read saved {target} setup at {path}; no settings were changed"
|
||||
) from error
|
||||
try:
|
||||
saved: Final = SavedSetup.model_validate_json(payload)
|
||||
if (
|
||||
saved.target != target
|
||||
or saved.settings_path != str(settings_path.resolve())
|
||||
or (target == "codex" and saved.model is None)
|
||||
):
|
||||
raise ValueError("Invalid saved setup")
|
||||
return saved
|
||||
except (ValidationError, ValueError, click.UsageError) as error:
|
||||
raise click.ClickException(
|
||||
f"Saved {target} setup at {path} is invalid or unsupported. "
|
||||
f"Run `lite unconfigure {target} --forget` to discard it; no settings were changed"
|
||||
) from error
|
||||
|
||||
|
||||
def _lock_path(target: Target) -> Path:
|
||||
digest: Final = hashlib.sha256(f"{target}:{settings_path_for(target).resolve()}".encode()).hexdigest()
|
||||
return Path.home() / ".litellm" / "setup-locks" / f"{digest}.lock"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def setup_locks(targets: Sequence[Target]) -> Generator[None, None, None]:
|
||||
with ExitStack() as stack:
|
||||
try:
|
||||
for path in tuple(_lock_path(target) for target in sorted(frozenset(targets))):
|
||||
ensure_private_dir(path.parent)
|
||||
stack.enter_context(FileLock(str(path), timeout=10, mode=0o600))
|
||||
except (OSError, Timeout) as error:
|
||||
raise click.ClickException(
|
||||
"Could not lock agent setup; retry when other configure commands finish"
|
||||
) from error
|
||||
yield
|
||||
|
||||
|
||||
def save_setup(saved: SavedSetup) -> None:
|
||||
path: Final = setup_profile_path(saved.target, settings_path_for(saved.target))
|
||||
try:
|
||||
ensure_private_dir(path.parent)
|
||||
staged: Final = stage_private_json(
|
||||
str(path),
|
||||
{ # mutable-ok: private_json serializes with json.dump, which requires a dict
|
||||
"version": saved.version,
|
||||
"target": saved.target,
|
||||
"settings_path": saved.settings_path,
|
||||
"base_url": saved.base_url,
|
||||
"api_key": saved.api_key,
|
||||
"model": saved.model,
|
||||
},
|
||||
)
|
||||
except OSError as error:
|
||||
raise click.ClickException(
|
||||
f"Could not save {saved.target} setup; no {saved.target} settings were changed"
|
||||
) from error
|
||||
try:
|
||||
commit_staged_json(staged, str(path))
|
||||
except OSError as error:
|
||||
raise click.ClickException(
|
||||
f"Could not save {saved.target} setup; no {saved.target} settings were changed"
|
||||
) from error
|
||||
finally:
|
||||
discard_staged_json(staged)
|
||||
|
||||
|
||||
def forget_saved_setup(target: Target) -> None:
|
||||
path: Final = setup_profile_path(target, settings_path_for(target))
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
raise click.ClickException(f"Could not remove saved {target} setup at {path}") from error
|
||||
439
litellm/proxy/client/cli/commands/configure_setup.py
Normal file
439
litellm/proxy/client/cli/commands/configure_setup.py
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
"""Persistent Claude Code and Codex gateway configuration."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import click
|
||||
import requests
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
|
||||
from litellm.proxy.common_utils.model_listing_utils import (
|
||||
CLAUDE_CODE_CLIENT,
|
||||
CLAUDE_CODE_PICKER_PATTERN,
|
||||
GATEWAY_CLIENT_HEADER,
|
||||
)
|
||||
|
||||
from .agents import codex_config_path
|
||||
from .claude_settings import (
|
||||
STARTING_MODEL_ROLE,
|
||||
ClaudeSettingsError,
|
||||
ModelChoice,
|
||||
StartOn,
|
||||
StaticToken,
|
||||
UnpinModel,
|
||||
claude_settings_path,
|
||||
configure_claude_settings,
|
||||
configure_state_path,
|
||||
preflight_claude_settings,
|
||||
settings_file_owners,
|
||||
)
|
||||
from .codex_settings import (
|
||||
CodexSettingsError,
|
||||
configure_codex_settings,
|
||||
preflight_codex_settings,
|
||||
)
|
||||
from .config import normalize_base_url
|
||||
from .configure_profiles import (
|
||||
TARGETS,
|
||||
SavedSetup,
|
||||
Target,
|
||||
read_saved_setup,
|
||||
save_setup,
|
||||
settings_path_for,
|
||||
setup_locks,
|
||||
)
|
||||
from .pi import ListedModel, ListingFailure, PiSyncError, fetch_model_listing
|
||||
|
||||
_LISTED_MODELS_SHOWN: Final = 20
|
||||
_CLAUDE_TARGET: Final = "claude"
|
||||
_CODEX_TARGET: Final = "codex"
|
||||
_TARGETS: Final = ((_CLAUDE_TARGET, "Claude Code (CLI)"), (_CODEX_TARGET, "Codex (CLI)"))
|
||||
_KEEP_DEFAULT_MODEL: Final = "Keep Claude Code's own default"
|
||||
_CLAUDE_CODE_VIEW: Final = MappingProxyType(
|
||||
{"anthropic-version": "2023-06-01", GATEWAY_CLIENT_HEADER: CLAUDE_CODE_CLIENT}
|
||||
)
|
||||
MODEL_OPTION_HELP: Final = (
|
||||
f"Proxy model to set as {STARTING_MODEL_ROLE}. Must be listed on /v1/models for the key; omission keeps "
|
||||
"the saved choice. Use --default-model to stop pinning a model. Nothing pins Claude "
|
||||
"Code's sub-agent or background tiers; `lite autoroute start` is the mode that does."
|
||||
)
|
||||
_TARGET_SELECTION: Final = TypeAdapter(tuple[Target, ...])
|
||||
_MODEL_SELECTION: Final = TypeAdapter(str)
|
||||
|
||||
|
||||
class ConnectionSettings(BaseModel):
|
||||
base_url: str
|
||||
base_url_explicit: bool = False
|
||||
api_key: str | None = None
|
||||
api_key_from_token_file: bool = False
|
||||
|
||||
|
||||
def resolve_credential(ctx: click.Context, api_key: str | None) -> StaticToken:
|
||||
"""The long-lived key written into settings.json: --api-key, `lite --api-key` or LITELLM_PROXY_API_KEY.
|
||||
|
||||
A `lite login` credential is never written: it expires within a day, and keeping it fresh would mean
|
||||
Claude Code running `lite` through `apiKeyHelper` on every credential refresh.
|
||||
"""
|
||||
ctx_obj: Final = ConnectionSettings.model_validate(ctx.find_object(object))
|
||||
explicit: Final = api_key if api_key is not None else (None if ctx_obj.api_key_from_token_file else ctx_obj.api_key)
|
||||
if explicit is None:
|
||||
raise ClaudeSettingsError(
|
||||
"`lite configure` needs a long-lived virtual key: pass --api-key, `lite --api-key`, or set "
|
||||
"LITELLM_PROXY_API_KEY. Your `lite login` credential expires within a day, so it is not written "
|
||||
"into agent settings."
|
||||
)
|
||||
if not explicit.strip() or any(ord(char) <= 32 or ord(char) == 127 for char in explicit):
|
||||
raise ClaudeSettingsError("The virtual key must not be blank or contain whitespace or control characters.")
|
||||
return StaticToken(explicit)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Listing:
|
||||
models: tuple[ListedModel, ...]
|
||||
|
||||
@property
|
||||
def ids(self) -> tuple[str, ...]:
|
||||
return tuple(model.id for model in self.models)
|
||||
|
||||
|
||||
def _preflight(target: Target) -> None:
|
||||
try:
|
||||
if target == _CLAUDE_TARGET:
|
||||
preflight_claude_settings(claude_settings_path(os.environ))
|
||||
else:
|
||||
preflight_codex_settings(codex_config_path(os.environ))
|
||||
except (ClaudeSettingsError, CodexSettingsError) as e:
|
||||
raise click.ClickException(str(e)) from e
|
||||
|
||||
|
||||
def _listing_error(base_url: str, error: PiSyncError, target: str) -> str:
|
||||
"""The hint that fits how the listing failed: only an unreachable proxy gets the "is it running" question."""
|
||||
if error.kind is ListingFailure.REJECTED:
|
||||
return f"LiteLLM rejected your key (HTTP {error.status}). Pass a valid --api-key."
|
||||
if error.kind is ListingFailure.UNREACHABLE:
|
||||
return (
|
||||
f"Could not connect. Is the proxy at {base_url} running, and is --base-url (or LITELLM_PROXY_URL) correct?"
|
||||
)
|
||||
if error.kind is ListingFailure.EMPTY:
|
||||
name: Final = "Claude Code" if target == _CLAUDE_TARGET else "Codex"
|
||||
return f"{error.message} {name} would have nothing to run; give the key access to at least one model."
|
||||
return f"The proxy at {base_url} answered, so check that it is a LiteLLM proxy and is healthy."
|
||||
|
||||
|
||||
def _fetch_models(base_url: str, key: str, target: Target) -> tuple[ListedModel, ...] | PiSyncError:
|
||||
return fetch_model_listing(
|
||||
base_url,
|
||||
key,
|
||||
get=partial(requests.get, allow_redirects=False),
|
||||
headers=_CLAUDE_CODE_VIEW if target == _CLAUDE_TARGET else MappingProxyType({}),
|
||||
)
|
||||
|
||||
|
||||
def _connection_listing(
|
||||
ctx: click.Context,
|
||||
base_url: str,
|
||||
credential: StaticToken,
|
||||
target: Target,
|
||||
repair: bool,
|
||||
) -> tuple[StaticToken, _Listing]:
|
||||
listed: Final = _fetch_models(base_url, credential.token, target)
|
||||
if not isinstance(listed, PiSyncError):
|
||||
return credential, _Listing(listed)
|
||||
if not repair or listed.kind is not ListingFailure.REJECTED:
|
||||
raise click.ClickException(_listing_error(base_url, listed, target))
|
||||
replacement: Final = click.prompt("Replacement virtual key", hide_input=True, show_default=False)
|
||||
try:
|
||||
refreshed: Final = resolve_credential(ctx, replacement)
|
||||
except ClaudeSettingsError as error:
|
||||
raise click.ClickException(str(error)) from error
|
||||
retried: Final = _fetch_models(base_url, refreshed.token, target)
|
||||
if isinstance(retried, PiSyncError):
|
||||
raise click.ClickException(_listing_error(base_url, retried, target))
|
||||
return refreshed, _Listing(retried)
|
||||
|
||||
|
||||
def _starting_model(model: str, listing: _Listing) -> str | None:
|
||||
source: Final = next((listed.id for listed in listing.models if listed.source_model == model), None)
|
||||
return source or next((listed.id for listed in listing.models if listed.id == model), None)
|
||||
|
||||
|
||||
def _model_choice(model: str | None) -> ModelChoice:
|
||||
return StartOn(model) if model is not None else UnpinModel()
|
||||
|
||||
|
||||
def _validated_model(model: str | None, listing: _Listing, base_url: str) -> str | None:
|
||||
starting: Final = _starting_model(model, listing) if model is not None else None
|
||||
if model is not None and starting is None:
|
||||
shown: Final = ", ".join(listing.ids[:_LISTED_MODELS_SHOWN])
|
||||
raise click.ClickException(f"{model!r} is not served by {base_url} for this key. /v1/models lists: {shown}.")
|
||||
return starting
|
||||
|
||||
|
||||
def _apply_claude(base_url: str, credential: StaticToken, listing: _Listing, model: str | None) -> None:
|
||||
listed: Final = listing.ids
|
||||
starting: Final = _validated_model(model, listing, base_url)
|
||||
settings_path: Final = claude_settings_path(os.environ)
|
||||
try:
|
||||
configure_claude_settings(
|
||||
base_url,
|
||||
credential,
|
||||
_model_choice(starting),
|
||||
settings_path,
|
||||
configure_state_path(settings_path),
|
||||
settings_file_owners(settings_path),
|
||||
)
|
||||
except ClaudeSettingsError as e:
|
||||
raise click.ClickException(str(e))
|
||||
in_picker: Final = sum(1 for listed_model in listed if CLAUDE_CODE_PICKER_PATTERN.search(listed_model))
|
||||
click.echo(f"Configured Claude Code: {settings_path} now routes through {base_url}.")
|
||||
|
||||
click.echo("Credential: your virtual key, stored in the file as ANTHROPIC_AUTH_TOKEN.")
|
||||
click.echo(
|
||||
f"Starting model: {starting} ({STARTING_MODEL_ROLE}); switch any time with /model."
|
||||
if starting is not None
|
||||
else "Starting model: not pinned (Claude Code's default, or a model you set yourself); switch with /model, or "
|
||||
"pass --model to start on a proxy model. Without a pin, a resumed session re-sends the model its transcript "
|
||||
"recorded, which behind a raw-model auto-router is the tier model."
|
||||
)
|
||||
click.echo(
|
||||
f"/model will list all {len(listed)} of the proxy's models."
|
||||
if in_picker == len(listed)
|
||||
else f"/model will list {in_picker} of the proxy's {len(listed)} models: Claude Code shows only ids containing "
|
||||
"'claude' or 'anthropic', and this proxy does not list the rest under such names."
|
||||
)
|
||||
click.echo("Start `claude` from any terminal. Undo with `lite unconfigure claude`.")
|
||||
if settings_path.is_symlink():
|
||||
click.echo(
|
||||
f"Note: {settings_path} is a symlink to {settings_path.resolve()}, so your key now lives in "
|
||||
"that file; keep it out of version control.",
|
||||
err=True,
|
||||
)
|
||||
|
||||
|
||||
def _has_targets(chosen: Sequence[object]) -> bool:
|
||||
return bool(chosen)
|
||||
|
||||
|
||||
def pick_targets(defaults: tuple[Target, ...] = ("claude", "codex"), *, edit: bool = False) -> tuple[Target, ...]:
|
||||
choices: Final = [ # mutable-ok: InquirerPy requires a list
|
||||
Choice(value, name=label, enabled=value in defaults) for value, label in _TARGETS
|
||||
]
|
||||
picked: Final = _TARGET_SELECTION.validate_python(
|
||||
inquirer.checkbox(
|
||||
message="Which agents should be edited? Unselected agents keep their current setup"
|
||||
if edit
|
||||
else "Which agents should route through LiteLLM?",
|
||||
choices=choices,
|
||||
validate=_has_targets,
|
||||
invalid_message="Pick at least one.",
|
||||
).execute()
|
||||
)
|
||||
return tuple(target for target in TARGETS if target in picked)
|
||||
|
||||
|
||||
def _pick_model(listed: Sequence[str], default: str | None = None) -> str | None:
|
||||
choices: Final = [_KEEP_DEFAULT_MODEL, *listed] # mutable-ok: InquirerPy requires a list
|
||||
picked: Final = _MODEL_SELECTION.validate_python(
|
||||
inquirer.fuzzy(
|
||||
message="Model Claude Code starts on (type to filter; /model switches any time):",
|
||||
choices=choices,
|
||||
default=default if default in listed else _KEEP_DEFAULT_MODEL,
|
||||
).execute()
|
||||
)
|
||||
return None if picked == _KEEP_DEFAULT_MODEL else picked
|
||||
|
||||
|
||||
def _pick_codex_model(listed: Sequence[str], default: str | None = None) -> str:
|
||||
choices: Final = list(listed) # mutable-ok: InquirerPy's choices parameter requires a list
|
||||
return _MODEL_SELECTION.validate_python(
|
||||
inquirer.fuzzy(
|
||||
message="Model Codex starts on (type to filter):",
|
||||
choices=choices,
|
||||
default=default if default in listed else listed[0],
|
||||
).execute()
|
||||
)
|
||||
|
||||
|
||||
def _apply_codex(base_url: str, credential: StaticToken, listing: _Listing, model: str) -> None:
|
||||
_validated_model(model, listing, base_url)
|
||||
settings_path: Final = codex_config_path(os.environ)
|
||||
try:
|
||||
configure_codex_settings(base_url, credential.token, model, settings_path)
|
||||
except CodexSettingsError as e:
|
||||
raise click.ClickException(str(e)) from e
|
||||
click.echo(f"Configured Codex: {settings_path} now routes through {base_url}.")
|
||||
click.echo(f"Starting model: {model}. Credential: your virtual key, stored in the private provider settings.")
|
||||
click.echo("Start `codex` from any terminal. Undo with `lite unconfigure codex`.")
|
||||
if settings_path.is_symlink():
|
||||
click.echo(f"Note: your key now lives in {settings_path.resolve()}; keep it out of version control.", err=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PreparedSetup:
|
||||
saved: SavedSetup
|
||||
listing: _Listing
|
||||
|
||||
|
||||
def _connection(ctx: click.Context, saved: SavedSetup | None) -> tuple[str, StaticToken]:
|
||||
settings: Final = ConnectionSettings.model_validate(ctx.find_object(object))
|
||||
base_url: Final = settings.base_url if saved is None or settings.base_url_explicit else saved.base_url
|
||||
supplied_key: Final = None if settings.api_key_from_token_file else settings.api_key
|
||||
reusable_key: Final = saved.api_key if saved is not None and saved.base_url == base_url else None
|
||||
try:
|
||||
credential: Final = resolve_credential(ctx, supplied_key if supplied_key is not None else reusable_key)
|
||||
except ClaudeSettingsError as error:
|
||||
if saved is not None and saved.base_url != base_url and supplied_key is None:
|
||||
raise click.ClickException(
|
||||
"The gateway changed. Pass --api-key for the new gateway; the saved key was not used"
|
||||
) from error
|
||||
raise click.ClickException(str(error)) from error
|
||||
return base_url, credential
|
||||
|
||||
|
||||
def _prompt_connection(ctx: click.Context, target: Target, saved: SavedSetup | None) -> tuple[str, StaticToken]:
|
||||
settings: Final = ConnectionSettings.model_validate(ctx.find_object(object))
|
||||
default_url: Final = settings.base_url if saved is None or settings.base_url_explicit else saved.base_url
|
||||
base_url: Final = normalize_base_url(
|
||||
click.prompt(f"{target.capitalize()} gateway URL", default=default_url)
|
||||
).removesuffix("/v1")
|
||||
supplied_key: Final = None if settings.api_key_from_token_file else settings.api_key
|
||||
kept_key: Final = (
|
||||
supplied_key
|
||||
if supplied_key is not None
|
||||
else (saved.api_key if saved is not None and saved.base_url == base_url else None)
|
||||
)
|
||||
entered: Final = click.prompt(
|
||||
"Virtual key (press Enter to keep the current key)" if kept_key is not None else "Virtual key",
|
||||
default="" if kept_key is not None else None,
|
||||
show_default=False,
|
||||
hide_input=True,
|
||||
)
|
||||
key: Final[str | None] = entered or kept_key
|
||||
try:
|
||||
return base_url, resolve_credential(ctx, key)
|
||||
except ClaudeSettingsError as error:
|
||||
raise click.ClickException(str(error)) from error
|
||||
|
||||
|
||||
def _prepare(
|
||||
ctx: click.Context,
|
||||
target: Target,
|
||||
saved: SavedSetup | None,
|
||||
model: str | None,
|
||||
default_model: bool,
|
||||
*,
|
||||
interactive: bool = False,
|
||||
edit_connection: bool = False,
|
||||
pick_model: Callable[[Sequence[str]], str | None] | None = None,
|
||||
pick_codex_model: Callable[[Sequence[str]], str] | None = None,
|
||||
) -> PreparedSetup:
|
||||
default: Final = None if default_model else (model if model is not None else (saved.model if saved else None))
|
||||
if target == "codex" and default is None and not interactive:
|
||||
raise click.UsageError("Missing option '--model'. First-time Codex setup needs a starting model")
|
||||
base_url, credential = _prompt_connection(ctx, target, saved) if edit_connection else _connection(ctx, saved)
|
||||
repair: Final = saved is not None and not interactive and sys.stdin.isatty()
|
||||
active_credential, listing = _connection_listing(ctx, base_url, credential, target, repair)
|
||||
repair_model: Final = repair and default is not None and _starting_model(default, listing) is None
|
||||
source_names: Final = tuple(item.source_model or item.id for item in listing.models)
|
||||
chosen: Final = (
|
||||
(pick_model(source_names) if pick_model is not None else _pick_model(source_names, default))
|
||||
if (interactive or repair_model) and target == "claude"
|
||||
else (
|
||||
pick_codex_model(listing.ids) if pick_codex_model is not None else _pick_codex_model(listing.ids, default)
|
||||
)
|
||||
if interactive or repair_model
|
||||
else default
|
||||
)
|
||||
if target == "codex" and chosen is None:
|
||||
raise click.ClickException("First-time Codex setup needs --model. Run `lite configure` for the model picker")
|
||||
validated: Final = _validated_model(chosen, listing, base_url)
|
||||
saved_model: Final = (
|
||||
next(item.source_model or item.id for item in listing.models if item.id == validated)
|
||||
if target == "claude" and validated is not None
|
||||
else chosen
|
||||
)
|
||||
try:
|
||||
profile: Final = SavedSetup(
|
||||
target=target,
|
||||
settings_path=str(settings_path_for(target).resolve()),
|
||||
base_url=base_url,
|
||||
api_key=active_credential.token,
|
||||
model=saved_model,
|
||||
)
|
||||
except ValidationError as error:
|
||||
raise click.ClickException("Invalid gateway setup; no settings were changed") from error
|
||||
return PreparedSetup(profile, listing)
|
||||
|
||||
|
||||
def _apply(setup: PreparedSetup) -> None:
|
||||
saved: Final = setup.saved
|
||||
save_setup(saved)
|
||||
try:
|
||||
if saved.target == "claude":
|
||||
_apply_claude(saved.base_url, StaticToken(saved.api_key), setup.listing, saved.model)
|
||||
elif saved.model is not None:
|
||||
_apply_codex(saved.base_url, StaticToken(saved.api_key), setup.listing, saved.model)
|
||||
except click.ClickException as error:
|
||||
raise click.ClickException(
|
||||
f"{error.format_message()} {saved.target.capitalize()} setup was saved. "
|
||||
f"Run `lite configure {saved.target}` to retry applying it"
|
||||
) from error
|
||||
click.echo(
|
||||
f"Setup saved. Edit with `lite reconfigure {saved.target}`; "
|
||||
f"remove saved settings and key with `lite unconfigure {saved.target} --forget`."
|
||||
)
|
||||
|
||||
|
||||
def configure_targets(
|
||||
ctx: click.Context,
|
||||
targets: tuple[Target, ...],
|
||||
*,
|
||||
model: str | None = None,
|
||||
default_model: bool = False,
|
||||
interactive: bool = False,
|
||||
edit_connection: bool = False,
|
||||
pick_model: Callable[[Sequence[str]], str | None] | None = None,
|
||||
pick_codex_model: Callable[[Sequence[str]], str] | None = None,
|
||||
) -> None:
|
||||
if model is not None and default_model:
|
||||
raise click.UsageError("--model and --default-model cannot be used together")
|
||||
for target in targets:
|
||||
_preflight(target)
|
||||
setups: Final = tuple(
|
||||
_prepare(
|
||||
ctx,
|
||||
target,
|
||||
read_saved_setup(target),
|
||||
model,
|
||||
default_model,
|
||||
interactive=interactive,
|
||||
edit_connection=edit_connection,
|
||||
pick_model=pick_model,
|
||||
pick_codex_model=pick_codex_model,
|
||||
)
|
||||
for target in targets
|
||||
)
|
||||
for setup in setups:
|
||||
_apply(setup)
|
||||
|
||||
|
||||
def interactive_configure(
|
||||
ctx: click.Context,
|
||||
pick_targets: Callable[[], tuple[str, ...]] = pick_targets,
|
||||
pick_model: Callable[[Sequence[str]], str | None] | None = None,
|
||||
pick_codex_model: Callable[[Sequence[str]], str] | None = None,
|
||||
) -> None:
|
||||
"""Configure selected agents, retaining injectable pickers for embedders."""
|
||||
selected: Final = pick_targets()
|
||||
targets: Final[tuple[Target, ...]] = tuple(target for target in TARGETS if target in selected)
|
||||
if not targets:
|
||||
return
|
||||
with setup_locks(targets):
|
||||
configure_targets(ctx, targets, interactive=True, pick_model=pick_model, pick_codex_model=pick_codex_model)
|
||||
|
|
@ -21,7 +21,7 @@ from .commands.auth import (
|
|||
from .commands.autoroute.commands import autoroute_group
|
||||
from .commands.chat import chat
|
||||
from .commands.config import config_commands, get_config_value, hidden_command_names
|
||||
from .commands.configure import configure_group, unconfigure_group
|
||||
from .commands.configure import configure_group, reconfigure_group, unconfigure_group
|
||||
from .commands.credentials import credentials
|
||||
from .commands.debug import debug
|
||||
from .commands.encryption import encryption
|
||||
|
|
@ -103,7 +103,8 @@ def cli(ctx: click.Context, show_version: bool, base_url: str | None, api_key: s
|
|||
|
||||
# If no API key provided via flag or environment variable, try to load from saved token.
|
||||
# Pass base_url so we only use the stored key when it was issued for this server.
|
||||
api_key_from_token_file: Final = api_key is None and ctx.invoked_subcommand not in ("configure", "unconfigure")
|
||||
setup_command: Final = ctx.invoked_subcommand in ("configure", "reconfigure", "unconfigure")
|
||||
api_key_from_token_file: Final = api_key is None and not setup_command
|
||||
resolved_api_key: Final = (
|
||||
get_stored_api_key(expected_base_url=base_url, vault=context_secret_vault(ctx))
|
||||
if api_key_from_token_file
|
||||
|
|
@ -119,7 +120,7 @@ def cli(ctx: click.Context, show_version: bool, base_url: str | None, api_key: s
|
|||
# "user said localhost:4000 on purpose" so they can fall back to
|
||||
# whatever server the stored token was actually issued for. A base_url
|
||||
# saved via `lite config set` counts as the user saying it.
|
||||
ctx.obj["base_url_explicit"] = base_url_provided or bool(stored_base_url)
|
||||
ctx.obj["base_url_explicit"] = base_url_provided or (bool(stored_base_url) and not setup_command)
|
||||
|
||||
if show_version:
|
||||
print_version(base_url, resolved_api_key)
|
||||
|
|
@ -174,6 +175,7 @@ cli.add_command(autoroute_group, name="autoroute")
|
|||
cli.add_command(config_commands)
|
||||
# Add configure/unconfigure (persistently wire a coding agent to the proxy with a virtual key)
|
||||
cli.add_command(configure_group)
|
||||
cli.add_command(reconfigure_group)
|
||||
cli.add_command(unconfigure_group)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import stat
|
|||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Final, Literal
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
|
@ -12,6 +13,8 @@ import requests
|
|||
import responses
|
||||
import tomlkit
|
||||
from click.testing import CliRunner
|
||||
from InquirerPy.base.control import Choice
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
from litellm.proxy.client.cli import cli
|
||||
from litellm.proxy.client.cli.commands import claude_settings as claude_settings_module
|
||||
|
|
@ -393,7 +396,7 @@ class TestConfigureAgents:
|
|||
)
|
||||
assert (settings_path.read_bytes(), codex_path.read_bytes()) == before
|
||||
assert not state_path.exists()
|
||||
assert not (codex_path.parent / ".litellm").exists()
|
||||
assert not tuple((codex_path.parent / ".litellm").glob("*.json"))
|
||||
|
||||
@responses.activate
|
||||
def test_both_configs_are_preflighted_before_fetching_models_or_writing(
|
||||
|
|
@ -433,7 +436,7 @@ class TestConfigureAgents:
|
|||
assert VALID_KEY not in str(caught.value)
|
||||
assert len(responses.calls) == 0
|
||||
assert not paths[0].exists() and not paths[1].exists()
|
||||
assert not codex_path.exists() and not (codex_path.parent / ".litellm").exists()
|
||||
assert not codex_path.exists() and not tuple((codex_path.parent / ".litellm").glob("*.json"))
|
||||
|
||||
@responses.activate
|
||||
def test_claude_only_configuration_does_not_require_codex(
|
||||
|
|
@ -509,7 +512,7 @@ class TestConfigureAgents:
|
|||
assert not paths[0].exists() and not codex_path.exists()
|
||||
|
||||
@responses.activate
|
||||
def test_configure_and_unconfigure_do_not_read_a_stored_login(
|
||||
def test_configure_reconfigure_and_unconfigure_do_not_read_a_stored_login(
|
||||
self, runner, paths, codex_path, tmp_path, secret_vault_factory, fake_codex_version
|
||||
):
|
||||
_mock_agent_models()
|
||||
|
|
@ -528,12 +531,17 @@ class TestConfigureAgents:
|
|||
obj={"secret_vault": vault},
|
||||
)
|
||||
assert configured.exit_code == 0, configured.output
|
||||
reconfigured: Final = runner.invoke(
|
||||
cli, ["reconfigure", "codex", "--model", "auto"], obj={"secret_vault": vault}
|
||||
)
|
||||
assert reconfigured.exit_code == 0, reconfigured.output
|
||||
fake_codex_version(None, 0)
|
||||
undone = runner.invoke(cli, ["unconfigure", "codex"], obj={"secret_vault": vault})
|
||||
assert undone.exit_code == 0, undone.output
|
||||
assert vault.reads == 0 and vault.writes == [] and vault.erases == 0
|
||||
assert not codex_path.exists() and not paths[0].exists()
|
||||
assert "Removed" in undone.output and "sk-login" not in missing.output + configured.output + undone.output
|
||||
assert "Removed" in undone.output
|
||||
assert "sk-login" not in missing.output + configured.output + reconfigured.output + undone.output
|
||||
|
||||
|
||||
class TestUnconfigureClaude:
|
||||
|
|
@ -599,9 +607,14 @@ class TestUnconfigureClaude:
|
|||
assert str(state_path) in result.output and state_path.exists()
|
||||
assert "sk-ant" not in result.output
|
||||
|
||||
def test_refuses_while_lite_up_holds_a_backup(self, runner, paths, lite_up_backup):
|
||||
result = runner.invoke(cli, ["unconfigure", "claude"])
|
||||
assert result.exit_code != 0 and "lite down" in result.output
|
||||
def test_disconnected_unconfigure_does_not_touch_lite_up_backup(
|
||||
self, runner: CliRunner, paths: tuple[Path, Path], lite_up_backup: Path
|
||||
) -> None:
|
||||
result: Final = runner.invoke(cli, ["unconfigure", "claude"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "nothing to undo" in result.output
|
||||
assert "Agent settings were not changed" in result.output
|
||||
assert lite_up_backup.read_text() == "{}"
|
||||
|
||||
@responses.activate
|
||||
def test_a_config_dir_is_configured_and_undone_apart_from_the_default_file(
|
||||
|
|
@ -625,12 +638,12 @@ class TestUnconfigureClaude:
|
|||
assert undone.exit_code == 0, undone.output
|
||||
assert json.loads((work_dir / "settings.json").read_text()) == original
|
||||
assert not default_settings.exists() and not default_state.exists()
|
||||
assert runner.invoke(cli, ["unconfigure", "claude"]).exit_code != 0, "the receipt is gone with the undo"
|
||||
assert runner.invoke(cli, ["unconfigure", "claude"]).exit_code == 0
|
||||
|
||||
def test_without_a_receipt_it_fails_loudly(self, runner, paths):
|
||||
def test_without_a_receipt_it_reports_nothing_to_undo(self, runner, paths):
|
||||
result = runner.invoke(cli, ["unconfigure", "claude"])
|
||||
assert result.exit_code != 0
|
||||
assert "nothing to undo" in result.output
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "nothing to undo" in result.output.lower()
|
||||
|
||||
|
||||
class TestClaudeCodeView:
|
||||
|
|
@ -702,3 +715,534 @@ class TestClaudeCodeView:
|
|||
result = _configure(runner, "--api-key", VALID_KEY)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "/model will list 1 of the proxy's 2 models: Claude Code shows only ids containing" in result.output
|
||||
|
||||
|
||||
def _saved_profile_path(target: Literal["claude", "codex"], settings_path: Path) -> Path:
|
||||
from litellm.proxy.client.cli.commands.configure_profiles import setup_profile_path
|
||||
|
||||
return setup_profile_path(target, settings_path)
|
||||
|
||||
|
||||
def _configure_saved_agent(runner: CliRunner, target: Literal["claude", "codex"]) -> None:
|
||||
result: Final = runner.invoke(
|
||||
cli,
|
||||
["configure", "--gateway-url", PROXY, "--api-key", VALID_KEY, target, "--model", "auto"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
|
||||
def _agent_document(settings_path: Path) -> dict[str, JsonValue]:
|
||||
adapter: Final = TypeAdapter(dict[str, JsonValue])
|
||||
if settings_path.suffix == ".json":
|
||||
return adapter.validate_json(settings_path.read_text())
|
||||
return adapter.validate_python(tomlkit.parse(settings_path.read_text()).unwrap())
|
||||
|
||||
|
||||
def _prompt_answer(answer: str | tuple[str, ...]) -> SimpleNamespace:
|
||||
def execute() -> str | tuple[str, ...]:
|
||||
return answer
|
||||
|
||||
return SimpleNamespace(execute=execute)
|
||||
|
||||
|
||||
class TestSavedAgentSetup:
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize("target", ["claude", "codex"])
|
||||
@pytest.mark.parametrize("resume", [("configure",), None], ids=["all", "target"])
|
||||
def test_disconnect_then_configure_reuses_connection_and_model_without_prompts(
|
||||
self,
|
||||
runner: CliRunner,
|
||||
paths: tuple[Path, Path],
|
||||
codex_path: Path,
|
||||
target: Literal["claude", "codex"],
|
||||
resume: tuple[str, ...] | None,
|
||||
) -> None:
|
||||
_mock_agent_models()
|
||||
settings_path: Final = paths[0] if target == "claude" else codex_path
|
||||
_configure_saved_agent(runner, target)
|
||||
configured: Final = _agent_document(settings_path)
|
||||
undone: Final = runner.invoke(cli, ["unconfigure", target])
|
||||
assert undone.exit_code == 0, undone.output
|
||||
assert not settings_path.exists()
|
||||
|
||||
resumed: Final = runner.invoke(cli, list(resume or ("configure", target)))
|
||||
assert resumed.exit_code == 0, resumed.output
|
||||
assert _agent_document(settings_path) == configured
|
||||
assert "lite configure" in undone.output and "saved" in undone.output.lower()
|
||||
repeated: Final = runner.invoke(cli, ["configure", target])
|
||||
assert repeated.exit_code == 0, repeated.output
|
||||
assert _agent_document(settings_path) == configured
|
||||
restored: Final = runner.invoke(cli, ["unconfigure", target])
|
||||
assert restored.exit_code == 0, restored.output
|
||||
assert not settings_path.exists()
|
||||
assert VALID_KEY not in resumed.output + repeated.output + restored.output
|
||||
|
||||
@responses.activate
|
||||
def test_resume_both_agents_captures_the_settings_changed_while_disconnected(
|
||||
self, runner: CliRunner, paths: tuple[Path, Path], codex_path: Path
|
||||
) -> None:
|
||||
_mock_agent_models()
|
||||
_configure_saved_agent(runner, "claude")
|
||||
codex_url: Final = "https://codex-gateway.test/prefix"
|
||||
responses.get(
|
||||
f"{codex_url}/v1/models",
|
||||
json={"data": [{"id": "auto"}]},
|
||||
match=[responses.matchers.header_matcher({"Authorization": "Bearer sk-codex"})],
|
||||
)
|
||||
codex_setup: Final = runner.invoke(
|
||||
cli,
|
||||
["configure", "codex", "--gateway-url", codex_url, "--api-key", "sk-codex", "--model", "auto"],
|
||||
)
|
||||
assert codex_setup.exit_code == 0, codex_setup.output
|
||||
undone: Final = runner.invoke(cli, ["unconfigure"])
|
||||
assert undone.exit_code == 0, undone.output
|
||||
paths[0].write_text('{"theme": "light", "model": "personal-claude"}')
|
||||
codex_path.write_text('model = "personal-codex"\napproval_policy = "on-request"\n')
|
||||
|
||||
resumed: Final = runner.invoke(cli, ["configure"])
|
||||
assert resumed.exit_code == 0, resumed.output
|
||||
assert json.loads(paths[0].read_text())["model"] == "claude-router-6175746f"
|
||||
assert tomlkit.parse(codex_path.read_text())["model"] == "auto"
|
||||
assert responses.calls[-1].request.url == f"{codex_url}/v1/models"
|
||||
restored: Final = runner.invoke(cli, ["unconfigure"])
|
||||
assert restored.exit_code == 0, restored.output
|
||||
assert json.loads(paths[0].read_text()) == {"theme": "light", "model": "personal-claude"}
|
||||
assert tomlkit.parse(codex_path.read_text()) == {
|
||||
"model": "personal-codex", "approval_policy": "on-request"
|
||||
}
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize("disconnected", [False, True], ids=["active", "disconnected"])
|
||||
@pytest.mark.parametrize(
|
||||
"forget, forgotten",
|
||||
[
|
||||
(("unconfigure", "--forget", "claude"), ("claude",)),
|
||||
(("unconfigure", "codex", "--forget"), ("codex",)),
|
||||
(("unconfigure", "--forget"), ("claude", "codex")),
|
||||
],
|
||||
ids=["group-option-target", "leaf-option", "all"],
|
||||
)
|
||||
def test_forget_removes_only_selected_saved_setups_even_after_disconnect(
|
||||
self,
|
||||
runner: CliRunner,
|
||||
paths: tuple[Path, Path],
|
||||
codex_path: Path,
|
||||
disconnected: bool,
|
||||
forget: tuple[str, ...],
|
||||
forgotten: tuple[str, ...],
|
||||
) -> None:
|
||||
_mock_agent_models()
|
||||
_configure_saved_agent(runner, "claude")
|
||||
_configure_saved_agent(runner, "codex")
|
||||
if disconnected:
|
||||
undone: Final = runner.invoke(cli, ["unconfigure"])
|
||||
assert undone.exit_code == 0, undone.output
|
||||
result: Final = runner.invoke(cli, list(forget))
|
||||
assert result.exit_code == 0, result.output
|
||||
for target, settings_path in (("claude", paths[0]), ("codex", codex_path)):
|
||||
assert _saved_profile_path(target, settings_path).exists() == (target not in forgotten)
|
||||
resumed: Final = runner.invoke(cli, ["configure", target])
|
||||
assert (resumed.exit_code == 0) == (target not in forgotten), resumed.output
|
||||
assert settings_path.exists() == (target not in forgotten)
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize("target", ["claude", "codex"])
|
||||
@pytest.mark.parametrize("source", ["leaf", "global", "environment"])
|
||||
def test_saved_key_never_follows_a_gateway_override_without_a_replacement(
|
||||
self,
|
||||
runner: CliRunner,
|
||||
paths: tuple[Path, Path],
|
||||
codex_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
target: Literal["claude", "codex"],
|
||||
source: str,
|
||||
) -> None:
|
||||
_mock_agent_models()
|
||||
_configure_saved_agent(runner, target)
|
||||
undone: Final = runner.invoke(cli, ["unconfigure", target])
|
||||
assert undone.exit_code == 0, undone.output
|
||||
replacement_url: Final = "https://replacement.test/gateway"
|
||||
if source == "environment":
|
||||
monkeypatch.setenv("LITELLM_PROXY_URL", replacement_url)
|
||||
args: Final = (
|
||||
["--base-url", replacement_url, "configure", target]
|
||||
if source == "global"
|
||||
else ["configure", target, "--gateway-url", replacement_url]
|
||||
if source == "leaf"
|
||||
else ["configure", target]
|
||||
)
|
||||
refused: Final = runner.invoke(cli, args)
|
||||
assert refused.exit_code != 0, refused.output
|
||||
assert "--api-key" in refused.output and VALID_KEY not in refused.output
|
||||
assert len(responses.calls) == 1
|
||||
assert not paths[0].exists() and not codex_path.exists()
|
||||
|
||||
responses.get(
|
||||
f"{replacement_url}/v1/models",
|
||||
json={"data": [{"id": "auto"}]},
|
||||
match=[responses.matchers.header_matcher({"Authorization": "Bearer sk-replacement"})],
|
||||
)
|
||||
replaced: Final = runner.invoke(cli, [*args, "--api-key", "sk-replacement"])
|
||||
assert replaced.exit_code == 0, replaced.output
|
||||
assert len(responses.calls) == 2
|
||||
assert responses.calls[-1].request.url == f"{replacement_url}/v1/models"
|
||||
assert VALID_KEY not in replaced.output and "sk-replacement" not in replaced.output
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize("target", ["claude", "codex"])
|
||||
def test_saved_setup_is_private_and_scoped_to_the_resolved_agent_home(
|
||||
self,
|
||||
runner: CliRunner,
|
||||
paths: tuple[Path, Path],
|
||||
codex_path: Path,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
target: Literal["claude", "codex"],
|
||||
) -> None:
|
||||
_mock_agent_models()
|
||||
_configure_saved_agent(runner, target)
|
||||
settings_path: Final = paths[0] if target == "claude" else codex_path
|
||||
profile_path: Final = _saved_profile_path(target, settings_path)
|
||||
assert stat.S_IMODE(profile_path.stat().st_mode) == 0o600
|
||||
assert stat.S_IMODE(profile_path.parent.stat().st_mode) & 0o077 == 0
|
||||
undone: Final = runner.invoke(cli, ["unconfigure", target])
|
||||
assert undone.exit_code == 0, undone.output
|
||||
alternate_home: Final = tmp_path / f"other-{target}"
|
||||
alternate_settings: Final = alternate_home / settings_path.name
|
||||
environment: Final = "CLAUDE_CONFIG_DIR" if target == "claude" else "CODEX_HOME"
|
||||
monkeypatch.setenv(environment, str(alternate_home))
|
||||
missing: Final = runner.invoke(cli, ["configure", target])
|
||||
assert missing.exit_code != 0, missing.output
|
||||
assert not alternate_settings.exists() and len(responses.calls) == 1
|
||||
assert profile_path.exists()
|
||||
stored_url: Final = runner.invoke(cli, ["config", "set", "base_url", "https://other-default.test"])
|
||||
assert stored_url.exit_code == 0, stored_url.output
|
||||
monkeypatch.setenv(environment, str(settings_path.parent))
|
||||
resumed: Final = runner.invoke(cli, ["configure", target])
|
||||
assert resumed.exit_code == 0, resumed.output
|
||||
assert settings_path.exists() and not alternate_settings.exists()
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize("target", ["claude", "codex"])
|
||||
@pytest.mark.parametrize("fault", ["json", "version", "target", "path"])
|
||||
def test_invalid_saved_setup_fails_without_network_or_secret_output_and_can_be_forgotten(
|
||||
self,
|
||||
runner: CliRunner,
|
||||
paths: tuple[Path, Path],
|
||||
codex_path: Path,
|
||||
target: Literal["claude", "codex"],
|
||||
fault: str,
|
||||
) -> None:
|
||||
_mock_agent_models()
|
||||
_configure_saved_agent(runner, target)
|
||||
settings_path: Final = paths[0] if target == "claude" else codex_path
|
||||
profile_path: Final = _saved_profile_path(target, settings_path)
|
||||
profile: Final = TypeAdapter(dict[str, JsonValue]).validate_json(profile_path.read_text())
|
||||
corrupted: Final = (
|
||||
"{ " + VALID_KEY
|
||||
if fault == "json"
|
||||
else json.dumps({**profile, "version": 999})
|
||||
if fault == "version"
|
||||
else json.dumps({**profile, "target": "codex" if target == "claude" else "claude"})
|
||||
if fault == "target"
|
||||
else json.dumps({**profile, "settings_path": str(settings_path.parent / "another-file")})
|
||||
)
|
||||
undone: Final = runner.invoke(cli, ["unconfigure", target])
|
||||
assert undone.exit_code == 0, undone.output
|
||||
profile_path.write_text(corrupted)
|
||||
failed: Final = runner.invoke(cli, ["configure", target])
|
||||
assert failed.exit_code != 0, failed.output
|
||||
assert "saved" in failed.output.lower() and "--forget" in failed.output
|
||||
assert VALID_KEY not in failed.output
|
||||
assert not settings_path.exists() and len(responses.calls) == 1
|
||||
forgotten: Final = runner.invoke(cli, ["unconfigure", "--forget", target])
|
||||
assert forgotten.exit_code == 0, forgotten.output
|
||||
assert not profile_path.exists() and not settings_path.exists()
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize("target", ["claude", "codex"])
|
||||
def test_reconfigure_prefills_saved_choices_and_changes_only_the_selected_agent(
|
||||
self,
|
||||
runner: CliRunner,
|
||||
paths: tuple[Path, Path],
|
||||
codex_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
target: Literal["claude", "codex"],
|
||||
) -> None:
|
||||
_mock_agent_models()
|
||||
_configure_saved_agent(runner, "claude")
|
||||
_configure_saved_agent(runner, "codex")
|
||||
untouched: Final = codex_path if target == "claude" else paths[0]
|
||||
before: Final = untouched.read_bytes()
|
||||
responses.replace(
|
||||
responses.GET, f"{PROXY}/v1/models", json={"data": [{"id": "auto"}, {"id": "replacement"}]}
|
||||
)
|
||||
|
||||
def checkbox(**kwargs: object) -> SimpleNamespace:
|
||||
choices: Final = kwargs["choices"]
|
||||
assert isinstance(choices, list) and len(choices) == 2
|
||||
for choice in choices:
|
||||
assert isinstance(choice, Choice) and choice.enabled
|
||||
return _prompt_answer((target,))
|
||||
|
||||
def fuzzy(**kwargs: object) -> SimpleNamespace:
|
||||
assert kwargs["default"] == "auto"
|
||||
return _prompt_answer("replacement")
|
||||
|
||||
monkeypatch.setattr(configure_module.inquirer, "checkbox", checkbox)
|
||||
monkeypatch.setattr(configure_module.inquirer, "fuzzy", fuzzy)
|
||||
changed: Final = runner.invoke(cli, ["reconfigure"], input=_TerminalInput(b"\n\n"))
|
||||
assert changed.exit_code == 0, changed.output
|
||||
assert PROXY in changed.output and VALID_KEY not in changed.output
|
||||
assert untouched.read_bytes() == before
|
||||
undone: Final = runner.invoke(cli, ["unconfigure", target])
|
||||
assert undone.exit_code == 0, undone.output
|
||||
resumed: Final = runner.invoke(cli, ["configure", target])
|
||||
assert resumed.exit_code == 0, resumed.output
|
||||
if target == "claude":
|
||||
assert json.loads(paths[0].read_text())["model"] == "replacement"
|
||||
else:
|
||||
assert tomlkit.parse(codex_path.read_text())["model"] == "replacement"
|
||||
assert untouched.read_bytes() == before
|
||||
|
||||
@responses.activate
|
||||
def test_reconfigure_cancel_preserves_every_agents_settings_and_saved_choices(
|
||||
self,
|
||||
runner: CliRunner,
|
||||
paths: tuple[Path, Path],
|
||||
codex_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_mock_agent_models()
|
||||
_configure_saved_agent(runner, "claude")
|
||||
_configure_saved_agent(runner, "codex")
|
||||
files: Final = (
|
||||
paths[0], codex_path, _saved_profile_path("claude", paths[0]), _saved_profile_path("codex", codex_path)
|
||||
)
|
||||
before: Final = tuple(path.read_bytes() for path in files)
|
||||
|
||||
def checkbox(**kwargs: object) -> SimpleNamespace:
|
||||
return _prompt_answer(("claude", "codex"))
|
||||
|
||||
def fuzzy(**kwargs: object) -> SimpleNamespace:
|
||||
assert tuple(path.read_bytes() for path in files) == before
|
||||
if "Codex" in str(kwargs["message"]):
|
||||
raise KeyboardInterrupt()
|
||||
return _prompt_answer("Keep Claude Code's own default")
|
||||
|
||||
monkeypatch.setattr(configure_module.inquirer, "checkbox", checkbox)
|
||||
monkeypatch.setattr(configure_module.inquirer, "fuzzy", fuzzy)
|
||||
cancelled: Final = runner.invoke(cli, ["reconfigure"], input=_TerminalInput(b"\n\n\n\n"))
|
||||
assert cancelled.exit_code != 0, cancelled.output
|
||||
assert "Aborted" in cancelled.output
|
||||
assert tuple(path.read_bytes() for path in files) == before
|
||||
|
||||
@responses.activate
|
||||
def test_explicit_default_model_unpins_claude_and_remains_the_saved_choice(
|
||||
self, runner: CliRunner, paths: tuple[Path, Path]
|
||||
) -> None:
|
||||
_mock_agent_models()
|
||||
_configure_saved_agent(runner, "claude")
|
||||
changed: Final = runner.invoke(cli, ["reconfigure", "claude", "--default-model"])
|
||||
assert changed.exit_code == 0, changed.output
|
||||
assert "model" not in json.loads(paths[0].read_text())
|
||||
undone: Final = runner.invoke(cli, ["unconfigure", "claude"])
|
||||
assert undone.exit_code == 0, undone.output
|
||||
resumed: Final = runner.invoke(cli, ["configure", "claude"])
|
||||
assert resumed.exit_code == 0, resumed.output
|
||||
assert "model" not in json.loads(paths[0].read_text())
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize("target", ["claude", "codex"])
|
||||
@pytest.mark.parametrize("fault", ["key", "model"])
|
||||
def test_terminal_resume_repairs_only_the_rejected_saved_choice(
|
||||
self,
|
||||
runner: CliRunner,
|
||||
paths: tuple[Path, Path],
|
||||
codex_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
target: Literal["claude", "codex"],
|
||||
fault: str,
|
||||
) -> None:
|
||||
_mock_agent_models()
|
||||
_configure_saved_agent(runner, target)
|
||||
settings_path: Final = paths[0] if target == "claude" else codex_path
|
||||
profile_path: Final = _saved_profile_path(target, settings_path)
|
||||
before: Final = profile_path.read_bytes()
|
||||
undone: Final = runner.invoke(cli, ["unconfigure", target])
|
||||
assert undone.exit_code == 0, undone.output
|
||||
responses.reset()
|
||||
if fault == "key":
|
||||
responses.get(
|
||||
f"{PROXY}/v1/models", status=401,
|
||||
match=[responses.matchers.header_matcher({"Authorization": f"Bearer {VALID_KEY}"})],
|
||||
)
|
||||
responses.get(
|
||||
f"{PROXY}/v1/models",
|
||||
json={"data": [{"id": "auto" if fault == "key" else "replacement"}]},
|
||||
match=[responses.matchers.header_matcher({
|
||||
"Authorization": "Bearer sk-repaired" if fault == "key" else f"Bearer {VALID_KEY}"
|
||||
})],
|
||||
)
|
||||
failed: Final = runner.invoke(cli, ["configure", target])
|
||||
assert failed.exit_code != 0, failed.output
|
||||
assert not settings_path.exists() and profile_path.read_bytes() == before
|
||||
assert len(responses.calls) == 1
|
||||
|
||||
def checkbox(**kwargs: object) -> SimpleNamespace:
|
||||
raise AssertionError("Saved resume must not ask which agents to configure")
|
||||
|
||||
def fuzzy(**kwargs: object) -> SimpleNamespace:
|
||||
assert fault == "model", "A rejected key must not discard the saved model"
|
||||
return _prompt_answer("replacement")
|
||||
|
||||
monkeypatch.setattr(configure_module.inquirer, "checkbox", checkbox)
|
||||
monkeypatch.setattr(configure_module.inquirer, "fuzzy", fuzzy)
|
||||
resumed: Final = runner.invoke(
|
||||
cli, ["configure"], input=_TerminalInput(b"sk-repaired\n" if fault == "key" else b"")
|
||||
)
|
||||
assert resumed.exit_code == 0, resumed.output
|
||||
assert "gateway URL" not in resumed.output
|
||||
assert VALID_KEY not in resumed.output and "sk-repaired" not in resumed.output
|
||||
assert settings_path.exists()
|
||||
saved: Final = TypeAdapter(dict[str, JsonValue]).validate_json(profile_path.read_text())
|
||||
assert saved["api_key"] == ("sk-repaired" if fault == "key" else VALID_KEY)
|
||||
assert saved["model"] == ("auto" if fault == "key" else "replacement")
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize("target", ["claude", "codex"])
|
||||
@pytest.mark.parametrize("lost_receipt", [False, True], ids=["malformed-settings", "lost-receipt"])
|
||||
def test_forget_without_receipt_preserves_agent_settings_and_reports_unknown_connection(
|
||||
self,
|
||||
runner: CliRunner,
|
||||
paths: tuple[Path, Path],
|
||||
codex_path: Path,
|
||||
target: Literal["claude", "codex"],
|
||||
lost_receipt: bool,
|
||||
) -> None:
|
||||
from litellm.proxy.client.cli.commands.configure_profiles import receipt_path_for
|
||||
|
||||
_mock_agent_models()
|
||||
_configure_saved_agent(runner, target)
|
||||
settings_path: Final = paths[0] if target == "claude" else codex_path
|
||||
profile_path: Final = _saved_profile_path(target, settings_path)
|
||||
if lost_receipt:
|
||||
receipt_path_for(target, settings_path).unlink()
|
||||
else:
|
||||
undone: Final = runner.invoke(cli, ["unconfigure", target])
|
||||
assert undone.exit_code == 0, undone.output
|
||||
settings_path.write_text("[invalid")
|
||||
before: Final = settings_path.read_bytes()
|
||||
forgotten: Final = runner.invoke(cli, ["unconfigure", target, "--forget"])
|
||||
assert forgotten.exit_code == 0, forgotten.output
|
||||
assert not profile_path.exists()
|
||||
assert settings_path.read_bytes() == before
|
||||
assert "Cannot confirm disconnection" in forgotten.output
|
||||
assert "gateway connection and key manually" in forgotten.output
|
||||
assert str(settings_path) in forgotten.output
|
||||
assert "already disconnected" not in forgotten.output and VALID_KEY not in forgotten.output
|
||||
assert len(responses.calls) == 1
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize("target", ["claude", "codex"])
|
||||
@pytest.mark.parametrize("failure", ["stage_private_json", "commit_staged_json", "apply"])
|
||||
def test_failed_setup_write_preserves_saved_intent_and_plain_configure_retries_it(
|
||||
self,
|
||||
runner: CliRunner,
|
||||
paths: tuple[Path, Path],
|
||||
codex_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
target: Literal["claude", "codex"],
|
||||
failure: str,
|
||||
) -> None:
|
||||
from litellm.proxy.client.cli.commands import configure_profiles, configure_setup
|
||||
|
||||
_mock_agent_models()
|
||||
_configure_saved_agent(runner, target)
|
||||
settings_path: Final = paths[0] if target == "claude" else codex_path
|
||||
profile_path: Final = _saved_profile_path(target, settings_path)
|
||||
receipt_path: Final = configure_profiles.receipt_path_for(target, settings_path)
|
||||
before: Final = (settings_path.read_bytes(), profile_path.read_bytes(), receipt_path.read_bytes())
|
||||
original_settings: Final = _agent_document(settings_path)
|
||||
original_profile: Final = TypeAdapter(dict[str, JsonValue]).validate_json(profile_path.read_text())
|
||||
replacement_url: Final = "https://replacement.test/gateway"
|
||||
replacement_key: Final = "sk-replacement"
|
||||
responses.get(
|
||||
f"{replacement_url}/v1/models",
|
||||
json={"data": [{"id": "replacement"}]},
|
||||
match=[responses.matchers.header_matcher({"Authorization": f"Bearer {replacement_key}"})],
|
||||
)
|
||||
|
||||
def fail_write(*args: object, **kwargs: object) -> str:
|
||||
raise OSError(f"simulated disk error {VALID_KEY}")
|
||||
|
||||
def fail_apply(*args: object, **kwargs: object) -> None:
|
||||
error: Final = (
|
||||
configure_setup.ClaudeSettingsError if target == "claude" else configure_setup.CodexSettingsError
|
||||
)
|
||||
raise error("simulated agent settings write failure")
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
if failure == "apply":
|
||||
patch.setattr(configure_setup, f"configure_{target}_settings", fail_apply)
|
||||
else:
|
||||
patch.setattr(configure_profiles, failure, fail_write)
|
||||
failed: Final = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"reconfigure", target, "--gateway-url", replacement_url,
|
||||
"--api-key", replacement_key, "--model", "replacement",
|
||||
],
|
||||
)
|
||||
assert failed.exit_code != 0, failed.output
|
||||
assert VALID_KEY not in failed.output and replacement_key not in failed.output
|
||||
assert (settings_path.read_bytes(), receipt_path.read_bytes()) == (before[0], before[2])
|
||||
saved: Final = TypeAdapter(dict[str, JsonValue]).validate_json(profile_path.read_text())
|
||||
if failure == "apply":
|
||||
assert saved == {
|
||||
**original_profile, "base_url": replacement_url, "api_key": replacement_key, "model": "replacement"
|
||||
}
|
||||
assert "simulated agent settings write failure" in failed.output
|
||||
assert "setup was saved" in failed.output and f"lite configure {target}" in failed.output
|
||||
else:
|
||||
assert "could not save" in failed.output.lower()
|
||||
assert profile_path.read_bytes() == before[1]
|
||||
retried: Final = runner.invoke(cli, ["configure", target])
|
||||
assert retried.exit_code == 0, retried.output
|
||||
written: Final = _agent_document(settings_path)
|
||||
if failure != "apply":
|
||||
assert written == original_settings
|
||||
elif target == "claude":
|
||||
environment: Final = written["env"]
|
||||
assert isinstance(environment, dict)
|
||||
assert (environment["ANTHROPIC_BASE_URL"], environment["ANTHROPIC_AUTH_TOKEN"], written["model"]) == (
|
||||
replacement_url, replacement_key, "replacement"
|
||||
)
|
||||
else:
|
||||
providers: Final = written["model_providers"]
|
||||
assert isinstance(providers, dict)
|
||||
provider: Final = providers["litellm"]
|
||||
assert isinstance(provider, dict)
|
||||
headers: Final = provider["http_headers"]
|
||||
assert isinstance(headers, dict)
|
||||
assert (provider["base_url"], headers["Authorization"], written["model"]) == (
|
||||
f"{replacement_url}/v1", f"Bearer {replacement_key}", "replacement"
|
||||
)
|
||||
|
||||
@responses.activate
|
||||
def test_contended_setup_lock_blocks_requests_and_agent_writes(
|
||||
self, runner: CliRunner, paths: tuple[Path, Path]
|
||||
) -> None:
|
||||
from litellm.proxy.client.cli.commands.configure_profiles import setup_locks
|
||||
|
||||
_mock_agent_models()
|
||||
with setup_locks(("claude",)):
|
||||
blocked: Final = runner.invoke(
|
||||
cli,
|
||||
["configure", "claude", "--gateway-url", PROXY, "--api-key", VALID_KEY, "--model", "auto"],
|
||||
)
|
||||
assert blocked.exit_code != 0, blocked.output
|
||||
assert "Could not lock agent setup" in blocked.output
|
||||
assert len(responses.calls) == 0
|
||||
assert not paths[0].exists() and not paths[1].exists()
|
||||
assert not _saved_profile_path("claude", paths[0]).exists()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue