mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
feat(cli): configure Claude Code and Codex with a gateway key (#40829)
This commit is contained in:
parent
eddfb5fb20
commit
2083e2a21f
13 changed files with 1224 additions and 52 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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={{}}",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
307
litellm/proxy/client/cli/commands/codex_settings.py
Normal file
307
litellm/proxy/client/cli/commands/codex_settings.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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") == (
|
||||
|
|
|
|||
341
tests/test_litellm/proxy/client/cli/test_codex_settings.py
Normal file
341
tests/test_litellm/proxy/client/cli/test_codex_settings.py
Normal 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()
|
||||
|
|
@ -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
4
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue