feat(cli): configure Claude Code and Codex with a gateway key (#40829)

This commit is contained in:
tin-berri 2026-09-12 09:59:13 -07:00 committed by GitHub
parent eddfb5fb20
commit 2083e2a21f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1224 additions and 52 deletions

View file

@ -536,7 +536,30 @@ It writes the same settings `lite up` does, `env.ANTHROPIC_BASE_URL`, `env.ENABL
The key in the file is the login's own, so it expires with it (24h by default): run `lite login --config-claude` again after that, which rewrites the key in place. Earlier versions wrote an `apiKeyHelper` that ran `lite auth print-token` instead, so a later login refreshed Claude Code by itself; that meant Claude Code spawning a full `lite` start, keychain check included, on every credential refresh, so the helper is no longer written and a stale one is stripped by the next `--config-claude` or `configure claude`. Like `lite up`, the flag refuses to run while a `lite up` session holds a backup, and tells you to run `lite down` first
#### Configuring Claude Code Once, With a Virtual Key
#### Configuring Claude Code or Codex Once, With a Virtual Key
Run the setup wizard with your gateway URL and a long-lived virtual key:
```bash
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
For a scripted setup, name the agent and model:
```bash
lite configure --gateway-url https://your-proxy.example.com codex --api-key sk-... --model my-coding-model
lite unconfigure codex
```
Codex setup requires an installed stable Codex version of [0.129.0 or newer](https://github.com/openai/codex/releases/tag/rust-v0.129.0), which prevents repository settings from redirecting requests carrying your saved key. Setup checks `codex --version` before fetching models or changing either selected agent's settings. Undo remains available without Codex installed
Codex setup updates `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`) with the selected model and a LiteLLM Responses provider. The gateway key lives in that provider's static Authorization header, in a file written atomically with owner-only permissions. Other providers, hooks, MCP servers and comments are preserved. A default profile selection is removed so it cannot override the gateway settings; its contents are preserved, and undo restores the selection. Explicit Codex flags and supported project settings still follow Codex's normal precedence
The Codex undo receipt is kept in a private `.litellm` directory beside the resolved config file. `lite unconfigure codex` restores only values still holding what configure wrote, preserving later edits. The provider URL and credential are restored together. Symlinks are followed and their targets become owner-only; keep these credential-bearing files out of version control
`lite configure claude` wires Claude Code up persistently with a long-lived virtual key, a pinned model and an undo, and `lite unconfigure claude` puts things back:
@ -548,7 +571,7 @@ 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 up` 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 the same things interactively: which agents to wire (Claude Code today) and which of the proxy's models to start on, picked from `/v1/models` with a type-to-filter prompt
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
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 up` session holds a backup, and that check comes before any request

View file

@ -121,6 +121,18 @@ def build_agent_env(
return env
def codex_proxy_provider(base_url: str) -> Mapping[str, str | bool]:
return MappingProxyType(
{
"name": "LiteLLM proxy",
"base_url": base_url.rstrip("/") + "/v1",
"wire_api": "responses",
"supports_websockets": False,
"requires_openai_auth": False,
}
)
def _codex_proxy_args(base_url: str) -> list[str]:
"""Codex `-c` overrides that point it at the proxy.
@ -130,21 +142,19 @@ def _codex_proxy_args(base_url: str) -> list[str]:
because the proxy does not speak the Responses WebSocket protocol. The key is
read from OPENAI_API_KEY, which build_agent_env already exports.
"""
root: Final = base_url.rstrip("/") + "/v1"
provider: Final = f"model_providers.{CODEX_PROXY_PROVIDER}"
return [
"-c",
f'model_provider="{CODEX_PROXY_PROVIDER}"',
"-c",
f'{provider}.name="LiteLLM proxy"',
"-c",
f'{provider}.base_url="{root}"',
*(
argument
for key, value in codex_proxy_provider(base_url).items()
for argument in ("-c", f"{provider}.{key}={json.dumps(value)}")
),
"-c",
f'{provider}.env_key="{OPENAI_API_KEY_ENV}"',
"-c",
f'{provider}.wire_api="responses"',
"-c",
f"{provider}.supports_websockets=false",
f"{provider}.http_headers={{}}",
]

View file

@ -458,11 +458,17 @@ def read_configure_receipt(state_path: Path) -> ConfigureReceipt | None:
return ConfigureReceipt.model_validate_json(state_path.read_bytes())
except (OSError, ValidationError) as e:
raise ClaudeSettingsError(
f"{state_path} is not a readable `lite configure claude` receipt ({e}). "
f"{state_path} is not a readable `lite configure claude` receipt. "
"Remove it and edit Claude Code's settings by hand if they still point at the proxy."
) from e
def preflight_claude_settings(settings_path: Path) -> None:
refuse_while_owned(settings_path, settings_file_owners(settings_path))
_env_object(load_json_or_empty(settings_path), settings_path)
read_configure_receipt(configure_state_path(settings_path))
def configure_claude_settings(
base_url: str,
credential: StaticToken,

View file

@ -0,0 +1,307 @@
import hashlib
import json
import re
import subprocess
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from functools import reduce
from pathlib import Path
from types import MappingProxyType
from typing import Final, Literal, TypeAlias
import tomlkit
from pydantic import BaseModel, ConfigDict, ValidationError
from tomlkit.container import OutOfOrderTableProxy
from tomlkit.exceptions import TOMLKitError
from tomlkit.items import InlineTable, Table
from tomlkit.toml_document import TOMLDocument
from litellm.litellm_core_utils.private_json import (
commit_staged_json,
discard_staged_json,
ensure_private_dir,
stage_private_bytes,
stage_private_json,
)
from .agents import CODEX_PROXY_PROVIDER, codex_proxy_provider
_PROVIDER_PATH: Final = f"model_providers.{CODEX_PROXY_PROVIDER}"
_OWNED_PATHS: Final = ("model_provider", "model", "profile", _PROVIDER_PATH)
_Table: TypeAlias = TOMLDocument | Table | InlineTable | OutOfOrderTableProxy
_EMPTY: Final[Mapping[str, object]] = MappingProxyType({})
_MIN_CODEX_VERSION: Final = (0, 129, 0)
class CodexSettingsError(Exception):
pass
class _Receipt(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
version: Literal[1] = 1
settings_path: str
file_existed: bool
providers_existed: bool
previous: Mapping[str, str | None]
written: Mapping[str, str]
@dataclass(frozen=True, slots=True)
class CodexUnconfigureOutcome:
restored: tuple[str, ...]
kept: tuple[str, ...]
file_removed: bool
def codex_configure_state_path(settings_path: Path) -> Path:
target: Final = settings_path.resolve()
digest: Final = hashlib.sha256(str(target).encode()).hexdigest()
return target.parent / ".litellm" / f"codex_configure_{digest}.json"
def _read(settings_path: Path) -> TOMLDocument:
try:
document: Final = tomlkit.parse(settings_path.read_bytes()) if settings_path.exists() else tomlkit.document()
except (OSError, UnicodeError, TOMLKitError) as error:
raise CodexSettingsError(
f"Could not read Codex settings at {settings_path}; no settings were changed"
) from error
providers: Final = _mapping(document).get("model_providers")
parent: Final = _table(providers)
if providers is not None and parent is None:
raise CodexSettingsError("Codex model_providers must be a TOML table; no settings were changed")
entries: Final = _mapping(parent) if parent is not None else _EMPTY
configured: Final = entries.get(CODEX_PROXY_PROVIDER)
if configured is not None and _table(configured) is None:
raise CodexSettingsError("Codex model_providers.litellm must be a TOML table; no settings were changed")
return document
def _mapping(value: Mapping[str, object]) -> Mapping[str, object]:
return value
def _table(value: object) -> _Table | None:
return value if isinstance(value, (TOMLDocument, Table, InlineTable, OutOfOrderTableProxy)) else None
def _snapshot(document: TOMLDocument, path: str) -> str | None:
section, _, key = path.rpartition(".")
parent: Final = _table(_mapping(document).get(section)) if section else document
if parent is None or key not in parent:
return None
values: Final = _mapping(parent)
return tomlkit.dumps(MappingProxyType({"value": values[key]}))
def _fingerprint(value: str | None) -> str:
normalized: Final = "missing" if value is None else json.dumps(tomlkit.parse(value), sort_keys=True, default=str)
return hashlib.sha256(normalized.encode()).hexdigest()
def _with(document: TOMLDocument, path: str, snapshot: str | None) -> TOMLDocument:
section, _, key = path.rpartition(".")
if section and section not in document and snapshot is not None:
contents: Final = tomlkit.parse(tomlkit.dumps(MappingProxyType({key: tomlkit.parse(snapshot).item("value")})))
return tomlkit.parse(document.as_string() + "\n" + tomlkit.dumps(MappingProxyType({section: contents})))
# mutable-ok: TOMLKit editing requires private node mutation to preserve comments and order
updated: Final = tomlkit.parse(document.as_string())
parent: Final = _table(_mapping(updated).get(section)) if section else updated
if parent is None:
return updated
if snapshot is None:
if key in parent:
del parent[key]
else:
parent[key] = tomlkit.parse(snapshot).item("value")
return updated
def _receipt(settings_path: Path) -> _Receipt | None:
path: Final = codex_configure_state_path(settings_path)
if not path.exists():
return None
try:
receipt: Final = _Receipt.model_validate_json(path.read_bytes())
if receipt.settings_path != str(settings_path.resolve()) or frozenset(receipt.previous) != frozenset(
receipt.written
):
raise ValueError("invalid receipt scope")
if not frozenset(receipt.written) <= frozenset(_OWNED_PATHS):
raise ValueError("invalid receipt ownership")
for snapshot in receipt.previous.values():
if snapshot is not None and tuple(tomlkit.parse(snapshot)) != ("value",):
raise ValueError("invalid receipt snapshot")
except (OSError, UnicodeError, TOMLKitError, ValidationError, ValueError) as error:
raise CodexSettingsError(
f"Could not read the Codex configure receipt at {path}; no settings were changed"
) from error
return receipt
def _codex_version() -> str | None:
try:
result: Final = subprocess.run(("codex", "--version"), capture_output=True, text=True, timeout=5, check=False)
except (OSError, subprocess.SubprocessError, UnicodeError):
return None
return result.stdout if result.returncode == 0 else None
def require_safe_codex(*, version: Callable[[], str | None] = _codex_version) -> None:
output: Final = version()
matched: Final = re.fullmatch(r"codex-cli (\d+)\.(\d+)\.(\d+)", output.strip()) if output is not None else None
if matched is not None and tuple(int(part) for part in matched.groups()) >= _MIN_CODEX_VERSION:
return
raise CodexSettingsError(
"Codex 0.129.0 or newer (stable) must be installed before saving a gateway key. "
"Older versions allow repository settings to redirect authenticated requests. "
"Install or update Codex, check `codex --version`, then retry."
)
def preflight_codex_settings(settings_path: Path) -> None:
require_safe_codex()
_read(settings_path)
_receipt(settings_path)
def _ours(document: TOMLDocument, path: str, receipt: _Receipt) -> bool:
return receipt.written.get(path) == _fingerprint(_snapshot(document, path))
def _stage_settings(path: Path, document: TOMLDocument) -> str:
try:
return stage_private_bytes(str(path), document.as_string().encode())
except OSError as error:
raise CodexSettingsError(f"Could not stage Codex settings at {path}; no settings were changed") from error
def _commit(path: Path, staged: str | None, commit: Callable[[str, str], None]) -> None:
if staged is None:
path.unlink(missing_ok=True)
else:
commit(staged, str(path))
def configure_codex_settings(
base_url: str,
api_key: str,
model: str,
settings_path: Path,
*,
commit: Callable[[str, str], None] = commit_staged_json,
) -> None:
require_safe_codex()
current: Final = _read(settings_path)
earlier: Final = _receipt(settings_path)
headers: Final = tomlkit.parse(tomlkit.dumps(MappingProxyType({"Authorization": f"Bearer {api_key}"})))
provider_table: Final = tomlkit.parse(
tomlkit.dumps(MappingProxyType({**codex_proxy_provider(base_url), "http_headers": headers}))
)
provider: Final = tomlkit.dumps(MappingProxyType({"value": provider_table}))
selections: Final = tomlkit.parse(
tomlkit.dumps(MappingProxyType({"model_provider": CODEX_PROXY_PROVIDER, "model": model}))
)
merged: Final = _with(
_with(
_with(_with(current, "profile", None), "model", _snapshot(selections, "model")),
"model_provider",
_snapshot(selections, "model_provider"),
),
_PROVIDER_PATH,
provider,
)
owned: Final = tuple(
path
for path in _OWNED_PATHS
if _fingerprint(_snapshot(current, path)) != _fingerprint(_snapshot(merged, path))
or (earlier is not None and _ours(current, path, earlier))
)
receipt: Final = _Receipt(
settings_path=str(settings_path.resolve()),
file_existed=settings_path.exists() if earlier is None else earlier.file_existed,
providers_existed="model_providers" in current if earlier is None else earlier.providers_existed,
previous=MappingProxyType(
{
path: earlier.previous[path]
if earlier is not None and _ours(current, path, earlier)
else _snapshot(current, path)
for path in owned
}
),
written=MappingProxyType({path: _fingerprint(_snapshot(merged, path)) for path in owned}),
)
target: Final = settings_path.resolve()
state_path: Final = codex_configure_state_path(settings_path)
try:
ensure_private_dir(state_path.parent)
staged_receipt: Final = stage_private_json(str(state_path), receipt.model_dump(mode="json"))
except OSError as error:
raise CodexSettingsError(f"Could not stage the Codex configure receipt at {state_path}") from error
try:
staged_settings: Final = _stage_settings(target, merged)
except CodexSettingsError:
discard_staged_json(staged_receipt)
raise
try:
commit(staged_receipt, str(state_path))
except OSError as error:
discard_staged_json(staged_receipt)
discard_staged_json(staged_settings)
raise CodexSettingsError(
f"Could not write the Codex configure receipt at {state_path}; no settings were changed"
) from error
try:
commit(staged_settings, str(target))
except OSError as error:
discard_staged_json(staged_settings)
try:
_commit(
state_path,
None if earlier is None else stage_private_json(str(state_path), earlier.model_dump(mode="json")),
commit_staged_json,
)
except OSError as rollback_error:
raise CodexSettingsError(
f"Codex settings were not written and its receipt at {state_path} could not be restored"
) from rollback_error
raise CodexSettingsError(
f"Could not write Codex settings at {settings_path}; the earlier receipt was restored"
) from error
def unconfigure_codex_settings(
settings_path: Path, *, commit: Callable[[str, str], None] = commit_staged_json
) -> CodexUnconfigureOutcome:
current: Final = _read(settings_path)
receipt: Final = _receipt(settings_path)
if receipt is None:
raise CodexSettingsError("Codex is not configured by `lite configure codex`; nothing to undo")
ours: Final = tuple(path for path in receipt.written if settings_path.exists() and _ours(current, path, receipt))
restored_owned: Final = reduce(lambda document, path: _with(document, path, receipt.previous[path]), ours, current)
providers: Final = _table(_mapping(restored_owned).get("model_providers"))
restored: Final = (
_with(restored_owned, "model_providers", None)
if providers is not None and not providers and not receipt.providers_existed
else restored_owned
)
target: Final = settings_path.resolve()
file_removed: Final = not restored.as_string().strip() and not (receipt.file_existed and target.exists())
staged: Final = None if file_removed else _stage_settings(target, restored)
state_path: Final = codex_configure_state_path(settings_path)
try:
_commit(target, staged, commit)
state_path.unlink()
except OSError as error:
if staged is not None:
discard_staged_json(staged)
raise CodexSettingsError(
"Could not finish undoing Codex configuration; the receipt was kept for retry"
) from error
return CodexUnconfigureOutcome(
restored=tuple(path for path in ours if _snapshot(current, path) != _snapshot(restored, path)),
kept=tuple(path for path in receipt.written if path not in ours and _snapshot(current, path) is not None),
file_removed=file_removed,
)

View file

@ -62,12 +62,16 @@ def hidden_command_names() -> frozenset[str]:
return parse_hidden_commands(get_config_value(HIDDEN_COMMANDS_KEY))
def _normalize_base_url(value: str) -> str:
def normalize_base_url(value: str) -> str:
if any(ord(char) <= 32 or ord(char) == 127 for char in value):
raise click.UsageError("base_url must not contain whitespace or control characters")
parsed: Final = urlparse(value)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise click.UsageError("base_url must be a full http:// or https:// URL including a host")
if "?" in value or "#" in value:
raise click.UsageError("base_url must not include a query string or fragment")
if parsed.username is not None or parsed.password is not None:
raise click.UsageError("base_url must not contain credentials; pass --api-key separately")
return value.rstrip("/")
@ -86,7 +90,7 @@ def _normalize_hidden_commands(value: str) -> str:
_NORMALIZERS: Final[Mapping[str, Callable[[str], str]]] = MappingProxyType(
{
"base_url": _normalize_base_url,
"base_url": normalize_base_url,
HIDDEN_COMMANDS_KEY: _normalize_hidden_commands,
}
)

View file

@ -1,4 +1,4 @@
"""`lite configure claude` and `lite unconfigure claude`: persistent Claude Code wiring, undoable."""
"""Persistent Claude Code and Codex gateway configuration."""
import os
import sys
@ -11,6 +11,7 @@ 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,
@ -18,6 +19,7 @@ from litellm.proxy.common_utils.model_listing_utils import (
GATEWAY_CLIENT_HEADER,
)
from .agents import codex_config_path
from .auth import CliContextObj
from .claude_settings import (
STARTING_MODEL_ROLE,
@ -30,15 +32,23 @@ from .claude_settings import (
claude_settings_path,
configure_claude_settings,
configure_state_path,
refuse_while_owned,
preflight_claude_settings,
settings_file_owners,
unconfigure_claude_settings,
)
from .codex_settings import (
CodexSettingsError,
configure_codex_settings,
preflight_codex_settings,
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"
_TARGETS: Final = ((_CLAUDE_TARGET, "Claude Code (CLI)"),)
_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}
@ -60,10 +70,12 @@ def resolve_credential(ctx: click.Context, api_key: str | None) -> StaticToken:
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 claude` needs a long-lived virtual key: pass --api-key, `lite --api-key`, or set "
"`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 Claude Code's settings."
"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)
@ -76,33 +88,45 @@ class _Listing:
return tuple(model.id for model in self.models)
def _start(ctx: click.Context, api_key: str | None) -> tuple[StaticToken, _Listing]:
"""Every configure path begins the same way: the local ownership check first, so a `lite up`
session is refused before any request, then the credential, then the listing."""
settings_path: Final = claude_settings_path(os.environ)
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, api_key: str | None, target: str = _CLAUDE_TARGET) -> tuple[StaticToken, _Listing]:
_preflight(target)
try:
refuse_while_owned(settings_path, settings_file_owners(settings_path))
credential: Final = resolve_credential(ctx, api_key)
except ClaudeSettingsError as e:
raise click.ClickException(str(e))
return credential, _listed_models(ctx.obj["base_url"], credential.token)
return credential, _listed_models(ctx.obj["base_url"], credential.token, target)
def _listing_error(base_url: str, error: PiSyncError) -> str:
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"{error.message} Is the proxy at {base_url} running, and is --base-url (or LITELLM_PROXY_URL) correct?"
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:
return f"{error.message} Claude Code would have nothing to run; give the key access to at least one model."
return f"{error.message} The proxy at {base_url} answered, so check that it is a LiteLLM proxy and is healthy."
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) -> _Listing:
listed: Final = fetch_model_listing(base_url, key, headers=_CLAUDE_CODE_VIEW)
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))
raise click.ClickException(_listing_error(base_url, listed, target))
return _Listing(listed)
@ -115,17 +139,19 @@ 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(ctx: click.Context, credential: StaticToken, listing: _Listing, model: str | None) -> None:
ctx_obj: Final[CliContextObj] = ctx.obj
base_url: Final = ctx_obj["base_url"]
listed: Final = listing.ids
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(listed[:_LISTED_MODELS_SHOWN])
more: Final = f", and {len(listed) - _LISTED_MODELS_SHOWN} more" if len(listed) > _LISTED_MODELS_SHOWN else ""
raise click.ClickException(
f"{model!r} is not served by {base_url} for this key. /v1/models lists: {shown}{more}."
)
starting: Final = _validated_model(model, listing, base_url)
settings_path: Final = claude_settings_path(os.environ)
try:
configure_claude_settings(
@ -178,40 +204,131 @@ 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(ctx: click.Context, credential: StaticToken, listing: _Listing, model: str) -> None:
base_url: Final[str] = ctx.obj["base_url"]
_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(
ctx: click.Context,
target: str,
credential: StaticToken,
pick_model: Callable[[Sequence[str]], str | None],
pick_codex_model: Callable[[Sequence[str]], str],
) -> _Setup:
base_url: Final[str] = ctx.obj["base_url"]
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 _CLAUDE_TARGET not in targets:
if not targets:
return
credential, listing = _start(ctx, None)
_apply_claude(
ctx, credential, listing, pick_model(tuple(model.source_model or model.id for model in listing.models))
for target in targets:
_preflight(target)
try:
credential: Final = resolve_credential(ctx, None)
except ClaudeSettingsError as e:
raise click.ClickException(str(e)) from e
setups: Final = tuple(_choose_setup(ctx, target, credential, pick_model, pick_codex_model) for target in targets)
for setup in setups:
if setup.target == _CLAUDE_TARGET:
_apply_claude(ctx, credential, setup.listing, setup.model)
elif setup.model is not None:
_apply_codex(ctx, credential, setup.listing, setup.model)
class _ConnectionOptions(BaseModel):
api_key: str | None = None
gateway_url: str | None = None
def _connection_context(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> click.Context:
ctx_obj: Final[CliContextObj] = ctx.obj
group: Final = (
_ConnectionOptions.model_validate(ctx.parent.params)
if ctx.parent is not None and ctx.parent.command.name == "configure"
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"])
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),
}
return click.Context(ctx.command, parent=ctx.parent, obj=connection)
@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) -> None:
def configure_group(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> None:
"""Persistently route a coding agent through your LiteLLM proxy.
With no agent named, asks which agents to wire and which proxy model to pin.
"""
if ctx.invoked_subcommand is not None:
return
connection: Final = _connection_context(ctx, api_key, gateway_url)
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>`."
"`lite configure claude --api-key <key> --model <model>` or "
"`lite configure codex --api-key <key> --model <model>`."
)
interactive_configure(ctx)
prompted: Final = (
connection
if connection.obj.get("base_url_explicit")
else _connection_context(connection, None, click.prompt("Gateway URL", default=connection.obj["base_url"]))
)
interactive_configure(prompted)
@click.group(name="unconfigure")
@ -228,8 +345,9 @@ def unconfigure_group() -> None:
"LITELLM_PROXY_API_KEY value; required, since a `lite login` credential expires within a day.",
)
@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) -> None:
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,
@ -238,8 +356,39 @@ def configure_claude(ctx: click.Context, api_key: str | None, model: str | None)
setting is kept, and what changed is recorded so `lite unconfigure claude` can put it back.
Assumes the proxy is already running.
"""
credential, listing = _start(ctx, api_key)
_apply_claude(ctx, credential, listing, model)
connection: Final = _connection_context(ctx, api_key, gateway_url)
credential, listing = _start(connection, api_key)
_apply_claude(connection, credential, listing, 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.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`."""
connection: Final = _connection_context(ctx, api_key, gateway_url)
credential, listing = _start(connection, api_key, _CODEX_TARGET)
_apply_codex(connection, credential, listing, 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)
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)}.")
@unconfigure_group.command(name="claude")

View file

@ -95,7 +95,7 @@ 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
api_key_from_token_file: Final = api_key is None and ctx.invoked_subcommand not in ("configure", "unconfigure")
resolved_api_key: Final = (
get_stored_api_key(expected_base_url=base_url, vault=context_secret_vault(ctx))
if api_key_from_token_file

View file

@ -72,13 +72,14 @@ proxy = [
"RestrictedPython>=8.5,<9.0",
"rich>=13.9.4,<14.0",
"InquirerPy>=0.3.4,<1.0",
"tomlkit>=0.13.3,<1.0",
"polars>=1.38.1,<2.0",
"soundfile>=0.12.1,<1.0",
"pyroscope-io>=0.8.16,<1.0; sys_platform != 'win32'",
"expression>=5.6.0,<6.0",
]
# Thin client install for the `lite` CLI on developer laptops. The CLI's heavy
# imports are all guarded, so it runs on the base SDK plus just these five, and
# imports are all guarded, so it runs on the base SDK plus these packages, and
# none of the server runtime in `proxy` is pulled in. On Linux,
# keyring reaches the Secret Service through secretstorage, which brings
# cryptography with it.
@ -88,6 +89,7 @@ cli = [
"requests>=2.32.0,<3.0",
"InquirerPy>=0.3.4,<1.0",
"keyring>=25.6.0,<26.0",
"tomlkit>=0.13.3,<1.0",
]
extra_proxy = [
"prisma>=0.11.0,<1.0",

View file

@ -1,5 +1,6 @@
import os
from collections.abc import Iterator
import shlex
from collections.abc import Callable, Iterator
from pathlib import Path
from typing import Final
@ -19,12 +20,52 @@ def _statusline_script_under_tmp(monkeypatch: pytest.MonkeyPatch, tmp_path: Path
monkeypatch.setattr(claude_settings, "STATUSLINE_SCRIPT_PATH", tmp_path / "litellm-home" / "statusline.py")
@pytest.fixture
def fake_codex_version(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> Callable[[str | None, int], Path]:
directory: Final = tmp_path / "codex-bin"
directory.mkdir()
binary: Final = directory / ("codex.cmd" if os.name == "nt" else "codex")
version_output: Final = directory / "version-output.txt"
monkeypatch.setenv("PATH", str(directory))
def install(output: str | None, returncode: int = 0) -> Path:
if output is None:
binary.unlink(missing_ok=True)
return binary
version_output.write_text(output)
if os.name == "nt":
binary.write_text(
'@echo off\nif not "%~1"=="--version" exit /b 2\n'
'if not "%~2"=="" exit /b 2\ntype "%~dp0version-output.txt"\n'
f'exit /b {returncode}\n'
)
else:
binary.write_text(
'#!/bin/sh\nif [ "$#" -ne 1 ] || [ "$1" != "--version" ]; then\n exit 2\nfi\n'
f'/bin/cat {shlex.quote(str(version_output))}\nexit {returncode}\n'
)
binary.chmod(0o700)
return binary
install("codex-cli 0.129.0\n")
return install
@pytest.fixture(autouse=True)
def _isolated_codex_version_for_configure_tests(request: pytest.FixtureRequest) -> None:
if request.node.path.name in ("test_codex_settings.py", "test_configure_commands.py"):
request.getfixturevalue("fake_codex_version")
@pytest.fixture(autouse=True)
def isolated_claude_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
before: Final = _current_bytes()
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / ".claude"))
monkeypatch.setenv("CODEX_HOME", str(tmp_path / ".codex"))
yield tmp_path
after: Final = _current_bytes()
if after == before:

View file

@ -181,7 +181,9 @@ class TestAgentLaunchArgs:
assert 'model_providers.litellm.env_key="OPENAI_API_KEY"' in args
assert 'model_providers.litellm.wire_api="responses"' in args
assert "model_providers.litellm.supports_websockets=false" in args
assert joined.count("-c") == 6
assert "model_providers.litellm.requires_openai_auth=false" in args
assert "model_providers.litellm.http_headers={}" in args
assert joined.count("-c") == 8
def test_codex_uses_basename(self):
assert agent_launch_args("/usr/local/bin/codex", "http://localhost:4000") == (

View file

@ -0,0 +1,341 @@
import json
import stat
from collections.abc import Callable
from pathlib import Path
from typing import Final
import pytest
import tomlkit
from litellm.litellm_core_utils.private_json import commit_staged_json
from litellm.proxy.client.cli.commands import codex_settings as codex_settings_module
from litellm.proxy.client.cli.commands.agents import (
agent_launch_args,
codex_config_path,
)
from litellm.proxy.client.cli.commands.codex_settings import (
CodexSettingsError,
_snapshot,
_with,
codex_configure_state_path,
configure_codex_settings,
preflight_codex_settings,
unconfigure_codex_settings,
)
GATEWAY: Final = "https://gateway.example.com/team"
KEY: Final = "sk-test-new-gateway-key"
MODEL: Final = "gateway-codex-model"
@pytest.mark.parametrize("path,existing,first_value,second_value", [
("model", 'model = "original" # starting model\n', 'value = "first"\n', 'value = "second"\n'),
("model_providers.litellm", '', '[value]\nname = "first"\n', '[value]\nname = "second"\n'),
("model_providers.litellm", '[model_providers.litellm]\nname = "original" # provider\n',
'[value]\nname = "first"\n', '[value]\nname = "second"\n'),
])
def test_toml_transitions_leave_source_and_independent_results_unchanged(
path: str, existing: str, first_value: str, second_value: str
) -> None:
source: Final = tomlkit.parse('# user settings\n' + existing + '[profiles.work]\nmodel = "keep" # profile\n')
original_bytes: Final = source.as_string().encode()
first: Final = _with(source, path, first_value)
first_bytes: Final = first.as_string().encode()
second: Final = _with(source, path, second_value)
second_bytes: Final = second.as_string().encode()
removed: Final = _with(first, path, None)
first_snapshot: Final = _snapshot(first, path)
second_snapshot: Final = _snapshot(second, path)
assert source.as_string().encode() == original_bytes
assert first.as_string().encode() == first_bytes
assert second.as_string().encode() == second_bytes
assert first_snapshot is not None and tomlkit.parse(first_snapshot) == tomlkit.parse(first_value)
assert second_snapshot is not None and tomlkit.parse(second_snapshot) == tomlkit.parse(second_value)
assert _snapshot(removed, path) is None
for result in (first, second, removed):
assert result["profiles"] == source["profiles"]
assert '# user settings' in result.as_string()
assert '# profile' in result.as_string()
def test_persistent_provider_is_complete_and_preserves_unrelated_toml(tmp_path: Path) -> None:
path: Final = tmp_path / "config.toml"
path.write_text(
'# user settings\nmodel = "old-model" # starting model\n'
'model_provider = "openai"\nprofile = "work"\n'
'[model_providers.litellm]\nname = "old gateway"\n'
'base_url = "https://old.example.com/v1"\nenv_key = "OLD_KEY"\n'
'experimental_bearer_token = "sk-old"\nrequires_openai_auth = true\n'
'[model_providers.litellm.auth]\ncommand = "old-token-helper"\n'
'[model_providers.other]\nname = "Keep me" # other provider\n'
'[profiles.work]\nmodel = "work-model"\n'
'[[hooks.Stop]]\nhooks = [{type = "command", command = "echo done"}]\n'
)
original: Final = tomlkit.parse(path.read_text())
configure_codex_settings(GATEWAY, KEY, MODEL, path)
configured: Final = tomlkit.parse(path.read_text())
assert configured["model"] == MODEL
assert configured["model_provider"] == "litellm"
assert "profile" not in configured
assert configured["model_providers"]["litellm"] == {
"name": "LiteLLM proxy",
"base_url": GATEWAY + "/v1",
"wire_api": "responses",
"supports_websockets": False,
"requires_openai_auth": False,
"http_headers": {"Authorization": "Bearer " + KEY},
}
assert configured["model_providers"]["other"] == original["model_providers"]["other"]
assert configured["profiles"] == original["profiles"]
assert configured["hooks"] == original["hooks"]
assert "# user settings" in path.read_text()
assert "# other provider" in path.read_text()
assert KEY not in codex_configure_state_path(path).read_text()
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert stat.S_IMODE(codex_configure_state_path(path).stat().st_mode) == 0o600
assert stat.S_IMODE(codex_configure_state_path(path).parent.stat().st_mode) == 0o700
outcome: Final = unconfigure_codex_settings(path)
assert not outcome.kept and not outcome.file_removed
assert tomlkit.parse(path.read_text()) == original
assert "# starting model" in path.read_text()
assert "# other provider" in path.read_text()
assert not codex_configure_state_path(path).exists()
@pytest.mark.parametrize("original", [None, "", "# my preferences\n", '[model_providers]\n'])
def test_undo_distinguishes_missing_empty_and_existing_tables(tmp_path: Path, original: str | None) -> None:
path: Final = tmp_path / "config.toml"
if original is not None:
path.write_text(original)
configure_codex_settings(GATEWAY, KEY, MODEL, path)
outcome: Final = unconfigure_codex_settings(path)
assert outcome.file_removed == (original is None)
assert path.exists() == (original is not None)
if original is not None:
assert tomlkit.parse(path.read_text()) == tomlkit.parse(original)
assert original.strip() in path.read_text()
def test_repeat_setup_preserves_original_and_undo_keeps_user_edits(tmp_path: Path) -> None:
path: Final = tmp_path / "config.toml"
path.write_text('model = "original"\nmodel_provider = "openai"\n')
configure_codex_settings(GATEWAY, KEY, MODEL, path)
configure_codex_settings(GATEWAY + "/second", "sk-second", "second-model", path)
assert tomlkit.parse(path.read_text())["model"] == "second-model"
path.write_text(path.read_text().replace('model = "second-model"', 'model = "my-custom-model"'))
outcome: Final = unconfigure_codex_settings(path)
assert outcome.kept == ("model",)
assert tomlkit.parse(path.read_text()) == {"model": "my-custom-model", "model_provider": "openai"}
def test_repeat_setup_restores_the_user_value_it_displaced(tmp_path: Path) -> None:
path: Final = tmp_path / "config.toml"
path.write_text('model = "original"\n')
configure_codex_settings(GATEWAY, KEY, MODEL, path)
path.write_text(path.read_text().replace('model = "gateway-codex-model"', 'model = "user-edited"'))
configure_codex_settings(GATEWAY, "sk-rotated", "third-model", path)
unconfigure_codex_settings(path)
assert tomlkit.parse(path.read_text()) == {"model": "user-edited"}
def test_undo_keeps_provider_credentials_and_endpoint_together(tmp_path: Path) -> None:
path: Final = tmp_path / "config.toml"
path.write_text('[model_providers.litellm]\nbase_url = "https://old.example.com/v1"\n'
'http_headers = { Authorization = "Bearer old-key" }\n')
configure_codex_settings(GATEWAY, KEY, MODEL, path)
path.write_text(path.read_text().replace(GATEWAY, "https://user.example.com"))
outcome: Final = unconfigure_codex_settings(path)
provider: Final = tomlkit.parse(path.read_text())["model_providers"]["litellm"]
assert outcome.kept == ("model_providers.litellm",)
assert provider["base_url"] == "https://user.example.com/v1"
assert provider["http_headers"] == {"Authorization": "Bearer " + KEY}
assert "old-key" not in path.read_text()
def test_user_deleted_config_is_not_recreated_to_restore_profile(tmp_path: Path) -> None:
path: Final = tmp_path / "config.toml"
path.write_text('profile = "old-profile"\n')
configure_codex_settings(GATEWAY, KEY, MODEL, path)
path.unlink()
outcome: Final = unconfigure_codex_settings(path)
assert outcome.file_removed and outcome.restored == ()
assert not path.exists()
def test_user_comment_in_new_config_survives_undo(tmp_path: Path) -> None:
path: Final = tmp_path / "config.toml"
configure_codex_settings(GATEWAY, KEY, MODEL, path)
path.write_text("# keep my note\n" + path.read_text())
assert not unconfigure_codex_settings(path).file_removed
assert "# keep my note" in path.read_text()
def test_code_home_and_symlink_aliases_share_receipt_and_write_target(tmp_path: Path) -> None:
target: Final = tmp_path / "real-config.toml"
target.write_text('model = "old"\n')
custom_home: Final = tmp_path / "codex-home"
custom_home.mkdir()
alias: Final = codex_config_path({"CODEX_HOME": str(custom_home)})
alias.symlink_to(target)
configure_codex_settings(GATEWAY, KEY, MODEL, alias)
assert alias.is_symlink()
assert codex_configure_state_path(alias) == codex_configure_state_path(target)
assert tomlkit.parse(target.read_text())["model"] == MODEL
unconfigure_codex_settings(target)
assert alias.is_symlink()
assert tomlkit.parse(alias.read_text()) == {"model": "old"}
@pytest.mark.parametrize("invalid", [
'token = "sk-secret\n',
'model_providers = "sk-secret"\n',
'[model_providers]\nlitellm = "sk-secret"\n',
])
def test_invalid_settings_are_unchanged_and_errors_hide_content(tmp_path: Path, invalid: str) -> None:
path: Final = tmp_path / "config.toml"
path.write_text(invalid)
with pytest.raises(CodexSettingsError) as caught:
configure_codex_settings(GATEWAY, KEY, MODEL, path)
assert "sk-secret" not in str(caught.value)
assert path.read_text() == invalid
assert not codex_configure_state_path(path).exists()
def test_invalid_receipt_fails_preflight_before_settings_change(tmp_path: Path) -> None:
path: Final = tmp_path / "config.toml"
path.write_text('model = "keep"\n')
state: Final = codex_configure_state_path(path)
state.parent.mkdir()
state.write_text('{"previous": "sk-secret"}')
with pytest.raises(CodexSettingsError) as caught:
preflight_codex_settings(path)
assert "sk-secret" not in str(caught.value)
assert path.read_text() == 'model = "keep"\n'
@pytest.mark.parametrize("configured_before", [False, True])
@pytest.mark.parametrize("failed_target", ["receipt", "settings"])
def test_failed_commit_restores_receipt_and_cleans_private_staging(
tmp_path: Path, configured_before: bool, failed_target: str
) -> None:
path: Final = tmp_path / "config.toml"
path.write_text('model = "old"\n')
state: Final = codex_configure_state_path(path)
if configured_before:
configure_codex_settings(GATEWAY, KEY, MODEL, path)
before: Final = path.read_bytes()
receipt_before: Final = state.read_bytes() if state.exists() else None
def failing_commit(staged: str, destination: str) -> None:
if destination == str(state if failed_target == "receipt" else path.resolve()):
raise OSError("sk-secret OS error")
commit_staged_json(staged, destination)
with pytest.raises(CodexSettingsError) as caught:
configure_codex_settings(GATEWAY, "sk-replacement", "new-model", path, commit=failing_commit)
assert "sk-secret" not in str(caught.value)
assert path.read_bytes() == before
assert (state.read_bytes() if state.exists() else None) == receipt_before
assert not tuple(tmp_path.rglob(".tmp-*"))
if configured_before:
unconfigure_codex_settings(path)
assert tomlkit.parse(path.read_text()) == {"model": "old"}
def test_failed_undo_keeps_the_settings_and_receipt_for_retry(tmp_path: Path) -> None:
path: Final = tmp_path / "config.toml"
path.write_text('model = "old"\n')
configure_codex_settings(GATEWAY, KEY, MODEL, path)
before: Final = path.read_bytes()
def fail(staged: str, destination: str) -> None:
raise OSError("cannot replace")
with pytest.raises(CodexSettingsError):
unconfigure_codex_settings(path, commit=fail)
assert path.read_bytes() == before
assert codex_configure_state_path(path).exists()
assert not tuple(tmp_path.rglob(".tmp-*"))
unconfigure_codex_settings(path)
assert tomlkit.parse(path.read_text()) == {"model": "old"}
def test_wrapper_and_persistent_provider_agree_except_credential_source(tmp_path: Path) -> None:
path: Final = tmp_path / "config.toml"
configure_codex_settings(GATEWAY, KEY, MODEL, path)
provider: Final = tomlkit.parse(path.read_text())["model_providers"]["litellm"]
args: Final = agent_launch_args("codex", GATEWAY)
overrides: Final = dict(argument.split("=", 1) for argument in args[1::2])
for field in ("name", "base_url", "wire_api", "supports_websockets", "requires_openai_auth"):
assert json.loads(overrides[f"model_providers.litellm.{field}"]) == provider[field]
assert overrides["model_providers.litellm.http_headers"] == "{}"
assert overrides["model_providers.litellm.env_key"] == '"OPENAI_API_KEY"'
@pytest.mark.parametrize("version", [
"codex-cli 0.129.0", "codex-cli 0.129.1", "codex-cli 0.130.0", "codex-cli 1.0.0",
" \ncodex-cli 0.129.0\n",
])
def test_version_guard_accepts_the_fixed_release_and_newer_stable_versions(version: str) -> None:
assert codex_settings_module.require_safe_codex(version=lambda: version) is None
@pytest.mark.parametrize("version", [
None, "", "codex-cli 0.99.0", "codex-cli 0.128.99", "codex-cli 0.129.0-alpha.1",
"codex-cli 1.0.0-beta.1", "0.129.0", "codex-cli 0.129.0 extra", "unparseable-sk-version-secret",
])
def test_version_guard_refuses_missing_unsafe_or_unrecognized_versions(version: str | None) -> None:
with pytest.raises(CodexSettingsError) as caught:
codex_settings_module.require_safe_codex(version=lambda: version)
assert "0.129.0" in str(caught.value)
assert "sk-version-secret" not in str(caught.value)
@pytest.mark.parametrize("output,returncode", [
(None, 0),
("codex-cli 0.129.0\n", 7),
])
def test_version_probe_handles_missing_or_failed_executable(
fake_codex_version: Callable[[str | None, int], Path], output: str | None, returncode: int
) -> None:
fake_codex_version(output, returncode)
assert codex_settings_module._codex_version() is None
@pytest.mark.parametrize("output,returncode", [
(None, 0),
("codex-cli 0.128.0\n", 0),
("codex-cli 0.129.0-alpha.1\n", 0),
("unparseable-sk-version-secret\n", 0),
("codex-cli 0.129.0\n", 7),
])
def test_writer_checks_the_installed_codex_before_replacing_a_key_or_receipt(
tmp_path: Path, fake_codex_version: Callable[[str | None, int], Path],
output: str | None, returncode: int,
) -> None:
path: Final = tmp_path / "config.toml"
path.write_text('model = "original"\n')
configure_codex_settings(GATEWAY, "sk-existing-gateway", MODEL, path)
state: Final = codex_configure_state_path(path)
before: Final = (path.read_bytes(), state.read_bytes())
fake_codex_version(output, returncode)
with pytest.raises(CodexSettingsError) as caught:
configure_codex_settings(GATEWAY, KEY, "replacement-model", path)
assert "0.129.0" in str(caught.value)
assert KEY not in str(caught.value) and "sk-version-secret" not in str(caught.value)
assert (path.read_bytes(), state.read_bytes()) == before
assert not tuple(tmp_path.rglob(".tmp-*"))
def test_undo_does_not_require_codex_to_remain_installed(
tmp_path: Path, fake_codex_version: Callable[[str | None, int], Path]
) -> None:
path: Final = tmp_path / "config.toml"
original: Final = 'model = "original"\n'
path.write_text(original)
configure_codex_settings(GATEWAY, KEY, MODEL, path)
fake_codex_version(None, 0)
outcome: Final = unconfigure_codex_settings(path)
assert outcome.restored and not outcome.kept
assert tomlkit.parse(path.read_text()) == tomlkit.parse(original)
assert not codex_configure_state_path(path).exists()

View file

@ -1,11 +1,16 @@
import io
import json
import os
import stat
import time
from pathlib import Path
from types import SimpleNamespace
import click
import pytest
import requests
import responses
import tomlkit
from click.testing import CliRunner
from litellm.proxy.client.cli import cli
@ -36,6 +41,8 @@ def paths(monkeypatch, tmp_path):
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(settings_path.parent))
monkeypatch.setattr(claude_settings_module, "CLAUDE_SETTINGS_PATH", settings_path)
monkeypatch.setattr(claude_settings_module, "CONFIGURE_STATE_PATH", state_path)
monkeypatch.delenv("LITELLM_PROXY_API_KEY", raising=False)
monkeypatch.delenv("LITELLM_PROXY_URL", raising=False)
return settings_path, state_path
@ -56,6 +63,29 @@ def runner():
return CliRunner()
@pytest.fixture
def codex_path():
return Path(os.environ["CODEX_HOME"]) / "config.toml"
class _TerminalInput(io.BytesIO):
def isatty(self):
return True
def _mock_agent_models():
def listing(request):
assert request.headers["Authorization"] == f"Bearer {VALID_KEY}"
rows = (
[{"id": "claude-router-6175746f", "source_model": "auto"}]
if request.headers.get("x-gateway-client") == "claude-code"
else [{"id": "auto"}]
)
return 200, {"Content-Type": "application/json"}, json.dumps({"data": rows})
responses.add_callback(responses.GET, f"{PROXY}/v1/models", callback=listing)
@pytest.fixture
def lite_up_backup(monkeypatch, tmp_path):
"""A `lite up` session holding its backup, the local precondition every settings write refuses on."""
@ -253,6 +283,259 @@ class TestInteractiveConfigure:
assert "lite configure claude --api-key" in result.output
class TestConfigureAgents:
@responses.activate
@pytest.mark.parametrize("targets", [("claude",), ("codex",), ("claude", "codex")])
def test_group_options_drive_the_agent_picker_and_write_only_selected_agents(
self, runner, paths, codex_path, monkeypatch, targets
):
_mock_agent_models()
asked = []
def checkbox(**kwargs):
assert tuple(choice.value for choice in kwargs["choices"]) == ("claude", "codex")
return SimpleNamespace(execute=lambda: targets)
def fuzzy(**kwargs):
assert "auto" in kwargs["choices"]
assert "claude-router-6175746f" not in kwargs["choices"]
assert not paths[0].exists() and not codex_path.exists()
asked.append(kwargs["message"])
return SimpleNamespace(execute=lambda: "auto")
monkeypatch.setattr(configure_module.inquirer, "checkbox", checkbox)
monkeypatch.setattr(configure_module.inquirer, "fuzzy", fuzzy)
result = runner.invoke(
cli,
["configure", "--api-key", VALID_KEY, "--gateway-url", f"{PROXY}/v1/"],
input=_TerminalInput(),
)
assert result.exit_code == 0, result.output
assert VALID_KEY not in result.output
assert len(asked) == len(targets)
assert paths[0].exists() == ("claude" in targets)
assert codex_path.exists() == ("codex" in targets)
if "claude" in targets:
claude = json.loads(paths[0].read_text())
assert claude["model"] == "claude-router-6175746f"
assert claude["env"]["ANTHROPIC_BASE_URL"] == PROXY
assert claude["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY
if "codex" in targets:
codex = tomlkit.parse(codex_path.read_text())
assert codex["model"] == "auto"
assert codex["model_provider"] == "litellm"
provider = codex["model_providers"]["litellm"]
assert provider["base_url"] == f"{PROXY}/v1"
assert provider["http_headers"]["Authorization"] == f"Bearer {VALID_KEY}"
assert "env_key" not in provider
assert [call.request.headers.get("x-gateway-client") for call in responses.calls] == [
"claude-code" if target == "claude" else None for target in targets
]
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize("leaf_override", [False, True], ids=["inherit-group", "leaf-wins"])
def test_group_connection_options_are_inherited_and_leaf_options_take_precedence(
self, runner, paths, codex_path, target, leaf_override
):
_mock_agent_models()
group_url = "http://group.test" if leaf_override else PROXY
group_key = "sk-group" if leaf_override else VALID_KEY
args = [
"--base-url", "http://global.test", "--api-key", "sk-global", "configure",
"--gateway-url", group_url, "--api-key", group_key, target, "--model", "auto",
]
if leaf_override:
args.extend(["--base-url", f"{PROXY}/v1/", "--api-key", VALID_KEY])
result = runner.invoke(cli, args)
assert result.exit_code == 0, result.output
assert all(key not in result.output for key in (VALID_KEY, group_key, "sk-global"))
if target == "claude":
written = json.loads(paths[0].read_text())
assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY
assert written["env"]["ANTHROPIC_BASE_URL"] == PROXY
assert not codex_path.exists()
else:
provider = tomlkit.parse(codex_path.read_text())["model_providers"]["litellm"]
assert provider["http_headers"]["Authorization"] == f"Bearer {VALID_KEY}"
assert provider["base_url"] == f"{PROXY}/v1"
assert not paths[0].exists()
assert len(responses.calls) == 1
@responses.activate
@pytest.mark.parametrize("failure", ["invalid-model", "cancel"])
def test_both_model_choices_complete_before_either_configuration_changes(
self, paths, codex_path, failure
):
_mock_agent_models()
settings_path, state_path = paths
settings_path.parent.mkdir(parents=True)
settings_path.write_text('{"theme": "dark"}')
codex_path.parent.mkdir(parents=True)
codex_path.write_text('model = "original"\n')
before = (settings_path.read_bytes(), codex_path.read_bytes())
def pick_codex_model(listed):
assert listed == ("auto",)
assert (settings_path.read_bytes(), codex_path.read_bytes()) == before
if failure == "cancel":
raise KeyboardInterrupt()
return "not-listed"
ctx = click.Context(configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY})
expected = KeyboardInterrupt if failure == "cancel" else click.ClickException
with pytest.raises(expected):
interactive_configure(
ctx,
pick_targets=lambda: ("claude", "codex"),
pick_model=lambda listed: "auto",
pick_codex_model=pick_codex_model,
)
assert (settings_path.read_bytes(), codex_path.read_bytes()) == before
assert not state_path.exists()
assert not (codex_path.parent / ".litellm").exists()
@responses.activate
def test_both_configs_are_preflighted_before_fetching_models_or_writing(
self, paths, codex_path
):
_mock_agent_models()
codex_path.parent.mkdir(parents=True)
codex_path.write_text("[invalid")
ctx = click.Context(configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY})
with pytest.raises(click.ClickException, match="Could not read Codex settings"):
interactive_configure(
ctx,
pick_targets=lambda: ("claude", "codex"),
pick_model=lambda listed: "auto",
pick_codex_model=lambda listed: "auto",
)
assert not paths[0].exists() and not paths[1].exists()
assert codex_path.read_text() == "[invalid"
assert len(responses.calls) == 0
@responses.activate
@pytest.mark.parametrize("targets", [("claude", "codex"), ("codex", "claude")])
@pytest.mark.parametrize("version", [None, "codex-cli 0.128.0\n"])
def test_unsafe_codex_blocks_both_targets_before_requests_or_writes(
self, paths, codex_path, fake_codex_version, targets, version
):
_mock_agent_models()
fake_codex_version(version, 0)
ctx = click.Context(configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY})
with pytest.raises(click.ClickException, match=r"0\.129\.0") as caught:
interactive_configure(
ctx,
pick_targets=lambda: targets,
pick_model=lambda listed: "auto",
pick_codex_model=lambda listed: "auto",
)
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()
@responses.activate
def test_claude_only_configuration_does_not_require_codex(
self, runner, paths, codex_path, fake_codex_version
):
_mock_agent_models()
fake_codex_version(None, 0)
result = runner.invoke(
cli, ["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "claude", "--model", "auto"]
)
assert result.exit_code == 0, result.output
assert json.loads(paths[0].read_text())["model"] == "claude-router-6175746f"
assert not codex_path.exists()
@responses.activate
def test_codex_only_ignores_claudes_temporary_owner(
self, runner, paths, codex_path, lite_up_backup
):
_mock_agent_models()
result = runner.invoke(
cli, ["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "codex", "--model", "auto"]
)
assert result.exit_code == 0, result.output
assert tomlkit.parse(codex_path.read_text())["model"] == "auto"
assert not paths[0].exists() and not paths[1].exists()
assert lite_up_backup.exists()
def test_noninteractive_codex_requires_a_model(self, runner, paths, codex_path):
result = runner.invoke(cli, ["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "codex"])
assert result.exit_code != 0 and "Missing option '--model'" in result.output
assert not paths[0].exists() and not codex_path.exists()
@responses.activate
@pytest.mark.parametrize("target", ["claude", "codex"])
@pytest.mark.parametrize(
"option, value, expected",
[
("--api-key", "sk-secret\ninvalid", "must not be blank"),
("--gateway-url", "https://user:sk-secret@proxy.test", "must not contain credentials"),
("--gateway-url", "https://proxy.test?key=sk-secret", "must not include a query"),
("--gateway-url", "file:///sk-secret", "must be a full http:// or https:// URL"),
],
)
def test_invalid_connection_input_never_writes_requests_or_echoes_secrets(
self, runner, paths, codex_path, target, option, value, expected
):
result = runner.invoke(
cli,
[
"configure", "--api-key", VALID_KEY, "--gateway-url", PROXY,
target, "--model", "auto", option, value,
],
)
assert result.exit_code != 0 and expected in result.output
assert "sk-secret" not in result.output and VALID_KEY not in result.output
assert not paths[0].exists() and not codex_path.exists()
assert len(responses.calls) == 0
@responses.activate
@pytest.mark.parametrize("failure", ["rejected", "connection", "response-body"])
def test_gateway_failures_never_echo_the_key(self, runner, paths, codex_path, failure):
if failure == "rejected":
responses.get(f"{PROXY}/v1/models", status=401)
elif failure == "connection":
responses.get(f"{PROXY}/v1/models", body=requests.ConnectionError(VALID_KEY))
else:
responses.get(f"{PROXY}/v1/models", json={"data": VALID_KEY})
result = runner.invoke(
cli, ["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "codex", "--model", "auto"]
)
assert result.exit_code != 0 and "Error:" in result.output
assert VALID_KEY not in result.output
assert not paths[0].exists() and not codex_path.exists()
@responses.activate
def test_configure_and_unconfigure_do_not_read_a_stored_login(
self, runner, paths, codex_path, tmp_path, secret_vault_factory, fake_codex_version
):
_mock_agent_models()
token_path = tmp_path / ".litellm" / "token.json"
token_path.parent.mkdir()
token_path.write_text(json.dumps({"base_url": PROXY, "timestamp": time.time()}))
vault = secret_vault_factory(json.dumps({"base_url": PROXY, "key": "sk-login", "jwt_token": ""}))
missing = runner.invoke(
cli, ["configure", "--gateway-url", PROXY, "codex", "--model", "auto"], obj={"secret_vault": vault}
)
assert missing.exit_code != 0 and "needs a long-lived virtual key" in missing.output
assert len(responses.calls) == 0 and not codex_path.exists()
configured = runner.invoke(
cli,
["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "codex", "--model", "auto"],
obj={"secret_vault": vault},
)
assert configured.exit_code == 0, configured.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
class TestUnconfigureClaude:
@responses.activate
def test_restores_the_original_file_and_removes_the_receipt(self, runner, paths):

4
uv.lock generated
View file

@ -4390,6 +4390,7 @@ cli = [
{ name = "pyyaml" },
{ name = "requests" },
{ name = "rich" },
{ name = "tomlkit" },
]
extra-proxy = [
{ name = "a2a-sdk" },
@ -4444,6 +4445,7 @@ proxy = [
{ name = "rq" },
{ name = "soundfile" },
{ name = "starlette" },
{ name = "tomlkit" },
{ name = "uvicorn" },
{ name = "uvloop", marker = "sys_platform != 'win32'" },
{ name = "websockets" },
@ -4669,6 +4671,8 @@ requires-dist = [
{ name = "starlette", marker = "extra == 'proxy'", specifier = ">=1.0.1,<2.0" },
{ name = "tiktoken", specifier = ">=0.8.0,<1.0" },
{ name = "tokenizers", specifier = ">=0.21.0,<1.0" },
{ name = "tomlkit", marker = "extra == 'cli'", specifier = ">=0.13.3,<1.0" },
{ name = "tomlkit", marker = "extra == 'proxy'", specifier = ">=0.13.3,<1.0" },
{ name = "uvicorn", marker = "extra == 'proxy'", specifier = ">=0.33.0,<1.0" },
{ name = "uvloop", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.22.1,<1.0" },
{ name = "websockets", marker = "extra == 'proxy'", specifier = ">=15.0.1,<16.0" },