feat(cli): store the lite login credential in the OS keychain

lite login used to write the minted cli-session key in cleartext to
~/.litellm/token.json. The secret material (key plus any JWT) now goes
to the OS keychain through the optional keyring package, with the 0600
file kept for non-secret metadata and as the fallback on headless boxes.
Legacy plaintext files keep authenticating and are migrated into the
keychain, then scrubbed, on first read. A secret still on disk always
outranks the keychain entry, so a failed keychain write can never
resurrect a stale key. LITELLM_PROXY_API_KEY and --api-key precedence
is unchanged, lite logout clears both stores and warns when the
keychain will not release the entry, and ~/.litellm is created 0700
(tightened from 0755 where an older CLI left it broader).
LITELLM_CLI_DISABLE_KEYRING=1 forces the file fallback.
This commit is contained in:
mateo-berri 2026-08-19 18:57:35 -07:00
parent eecb226762
commit 0de829d3e4
22 changed files with 1295 additions and 511 deletions

View file

@ -57,7 +57,7 @@
"limit": 5663
},
"reportMissingTypeArgument": {
"limit": 15557
"limit": 15556
},
"reportMissingTypeStubs": {
"limit": 40
@ -105,13 +105,13 @@
"limit": 109
},
"reportUnknownMemberType": {
"limit": 39043
"limit": 39042
},
"reportUnknownParameterType": {
"limit": 19887
"limit": 19886
},
"reportUnknownVariableType": {
"limit": 30574
"limit": 30571
},
"reportUnnecessaryCast": {
"limit": 117

View file

@ -60,4 +60,4 @@ if __name__ == "__main__":
print("\n💡 Tips:")
print("1. Run 'litellm-proxy login' to authenticate first")
print("2. Replace 'https://your-proxy.com' with your actual proxy URL")
print("3. The token is stored locally at ~/.litellm/token.json")
print("3. The token is stored in your OS keychain, or in ~/.litellm/token.json when there is none")

View file

@ -0,0 +1,125 @@
"""
CLI Keyring Access
SDK-level access to the OS keychain (macOS Keychain, Windows Credential Manager,
Linux Secret Service) that holds the credential minted by `lite login`.
The `keyring` package is optional and imported lazily, so importing this module
never pulls it in. Every failure is returned as a value: a machine with no
keychain, or one whose keychain is locked, must degrade to the token file rather
than break `lite` or the SDK.
"""
import os
from dataclasses import dataclass
from typing import Final, Protocol, TypeAlias
KEYRING_SERVICE: Final = "litellm-cli"
KEYRING_ACCOUNT: Final = "credential"
DISABLE_KEYRING_ENV_VAR: Final = "LITELLM_CLI_DISABLE_KEYRING"
_DISABLED_VALUES: Final = frozenset(("1", "true", "yes", "on"))
@dataclass(frozen=True, slots=True)
class SecretFound:
blob: str
@dataclass(frozen=True, slots=True)
class SecretMissing:
pass
@dataclass(frozen=True, slots=True)
class SecretUnavailable:
pass
SecretRead: TypeAlias = SecretFound | SecretMissing | SecretUnavailable
class SecretVault(Protocol):
"""The single slot holding the CLI credential's secret material."""
def read(self) -> SecretRead: ...
def write(self, blob: str) -> bool: ...
def erase(self) -> bool: ...
class KeyringApi(Protocol):
def get_password(self, service_name: str, username: str) -> str | None: ...
def set_password(self, service_name: str, username: str, password: str) -> None: ...
def delete_password(self, service_name: str, username: str) -> None: ...
def _keyring_disabled() -> bool:
return os.getenv(DISABLE_KEYRING_ENV_VAR, "").strip().lower() in _DISABLED_VALUES
def _import_keyring() -> KeyringApi | None:
try:
import keyring
except ImportError:
return None
return keyring
def _keyring_api() -> KeyringApi | None:
return None if _keyring_disabled() else _import_keyring()
@dataclass(frozen=True, slots=True)
class KeyringVault:
"""The OS keychain, reached through the optional `keyring` package."""
def read(self) -> SecretRead:
api: Final = _keyring_api()
if api is None:
return SecretUnavailable()
try:
blob: Final = api.get_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
except Exception: # noqa: BLE001 # backends raise outside keyring.errors; never break the SDK
return SecretUnavailable()
return SecretMissing() if blob is None else SecretFound(blob)
def write(self, blob: str) -> bool:
api: Final = _keyring_api()
if api is None:
return False
try:
api.set_password(KEYRING_SERVICE, KEYRING_ACCOUNT, blob)
except Exception: # noqa: BLE001 # a keychain that refuses the write falls back to the token file
return False
return True
def erase(self) -> bool:
if _import_keyring() is None:
return True
if _keyring_disabled():
# a credential stored before the kill switch was set may still be in the keychain
return False
match self.read():
case SecretUnavailable():
return False
case SecretMissing():
return True
case SecretFound():
return self._delete()
def _delete(self) -> bool:
api: Final = _keyring_api()
if api is None:
return False
try:
api.delete_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
except Exception: # noqa: BLE001 # report the failure as a value so `lite logout` can warn
return False
return True
SYSTEM_KEYRING: Final[SecretVault] = KeyringVault()

View file

@ -1,17 +1,68 @@
"""
CLI Token Utilities
SDK-level utilities for reading CLI authentication tokens.
SDK-level utilities for reading the credential minted by `lite login`.
Non-secret metadata lives in ~/.litellm/token.json. The secret material (the
bearer key, plus a JWT when one is issued) lives in the OS keychain when the
machine has one, and in that same 0600 file otherwise. This module hides the
split from callers, and migrates a legacy plaintext file into the keychain the
first time it reads one.
This module has no dependencies on proxy code and can be safely imported at the SDK level.
"""
import json
import os
import contextlib
import time
from collections.abc import Mapping
from pathlib import Path
from types import MappingProxyType
from typing import Final
from pydantic import BaseModel, ConfigDict, ValidationError
from litellm.litellm_core_utils.cli_keyring import (
SYSTEM_KEYRING,
SecretFound,
SecretMissing,
SecretUnavailable,
SecretVault,
)
from litellm.litellm_core_utils.private_json import ensure_private_dir, write_private_json
class CliTokenRecord(BaseModel):
"""A stored CLI credential.
`key is None` means the metadata was found but the secret could not be
produced: the keychain holds nothing for us, or we could not reach it.
"""
model_config = ConfigDict(frozen=True, extra="allow")
base_url: str = ""
key: str | None = None
user_id: str = ""
user_email: str = ""
user_role: str = ""
auth_header_name: str = "Authorization"
jwt_token: str = ""
timestamp: float = 0.0
class CliTokenSecret(BaseModel):
"""The secret material as stored in the OS keychain.
`base_url` is duplicated from the metadata file purely as a pairing tag: a
secret minted for one server is never handed to another, even if the
metadata file is edited underneath us.
"""
model_config = ConfigDict(frozen=True)
base_url: str
key: str
jwt_token: str = ""
def get_cli_token_file_path() -> str:
"""Get the path to the CLI token file"""
@ -20,26 +71,39 @@ def get_cli_token_file_path() -> str:
return str(config_dir / "token.json")
def load_cli_token() -> dict | None:
"""Load CLI token data from file"""
token_file: Final = get_cli_token_file_path()
if not os.path.exists(token_file):
def load_cli_token(*, vault: SecretVault = SYSTEM_KEYRING) -> CliTokenRecord | None:
"""Load the stored CLI credential, or None when this machine has none"""
record: Final = _read_token_file()
if record is None:
return None
return _resolve_secret(record, vault)
try:
with open(token_file, "r") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
def save_cli_token(record: CliTokenRecord, *, vault: SecretVault = SYSTEM_KEYRING) -> bool:
"""Store a freshly minted credential. Returns whether the keychain took the secret"""
if record.key is None or not vault.write(_encode_secret(record.base_url, record.key, record.jwt_token)):
_write_token_file(record)
return False
_write_token_file(_without_secret(record))
return True
def clear_cli_token(*, vault: SecretVault = SYSTEM_KEYRING) -> bool:
"""Remove the credential from both stores. Returns whether the keychain is now free of it"""
erased: Final = vault.erase()
Path(get_cli_token_file_path()).unlink(missing_ok=True)
return erased
def get_litellm_gateway_api_key(
expected_base_url: str | None = None,
*,
vault: SecretVault = SYSTEM_KEYRING,
) -> str | None:
"""
Get the stored CLI API key for use with LiteLLM SDK.
This function reads the token file created by `lite login`
This function reads the credential created by `lite login`
and returns the API key for use in Python scripts.
Args:
@ -47,6 +111,7 @@ def get_litellm_gateway_api_key(
originally issued for this URL. Pass the target server URL to
prevent credential leakage when the client is pointed at a
different (possibly malicious) server.
vault: Where the secret material is stored. Defaults to the OS keychain.
Returns:
str: The API key if found (and origin matches), None otherwise
@ -62,25 +127,84 @@ def get_litellm_gateway_api_key(
>>> base_url="https://your-proxy.com/v1"
>>> )
"""
token_data: Final = load_cli_token()
if not token_data or "key" not in token_data:
record: Final = _read_token_file()
if record is None:
return None
if expected_base_url is not None:
stored_url: Final = token_data.get("base_url")
if stored_url != expected_base_url.rstrip("/"):
return None
return token_data["key"]
if expected_base_url is not None and record.base_url != expected_base_url.rstrip("/"):
return None
resolved: Final = _resolve_secret(record, vault)
return None if resolved is None else resolved.key
def is_cli_token_fresh(token_data: Mapping[str, object], buffer_hours: float = 0.1) -> bool:
"""Check whether a cached CLI token (as stored in token.json) is still
within its expiration window. Used by `lite auth print-token` to fail
fast, without a network round trip, once the cached token is past
`LITELLM_CLI_JWT_EXPIRATION_HOURS`."""
def is_cli_token_fresh(token_data: CliTokenRecord, buffer_hours: float = 0.1) -> bool:
"""Check whether a cached CLI token is still within its expiration window.
Used by `lite auth print-token` to fail fast, without a network round trip,
once the cached token is past `LITELLM_CLI_JWT_EXPIRATION_HOURS`."""
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
timestamp: Final = token_data.get("timestamp")
if not isinstance(timestamp, (int, float)):
return False
age_hours: Final = (time.time() - timestamp) / 3600
age_hours: Final = (time.time() - token_data.timestamp) / 3600
return age_hours < (CLI_JWT_EXPIRATION_HOURS - buffer_hours)
def _read_token_file() -> CliTokenRecord | None:
try:
raw: Final = Path(get_cli_token_file_path()).read_text()
except OSError:
return None
try:
return CliTokenRecord.model_validate_json(raw)
except ValidationError:
return None
def _resolve_secret(record: CliTokenRecord, vault: SecretVault) -> CliTokenRecord | None:
match vault.read():
case SecretFound(blob=blob):
return _apply_vault_secret(record, blob, vault)
case SecretMissing():
return _migrate_file_secret(record, vault)
case SecretUnavailable():
return record
def _apply_vault_secret(record: CliTokenRecord, blob: str, vault: SecretVault) -> CliTokenRecord | None:
if record.key is not None:
# a secret still on disk means the last keychain write failed: the file outranks the vault
return _migrate_file_secret(record, vault)
try:
secret: Final = CliTokenSecret.model_validate_json(blob)
except ValidationError:
return _migrate_file_secret(record, vault)
if secret.base_url != record.base_url:
return _migrate_file_secret(record, vault)
_scrub_file_secret(record)
return record.model_copy(update=MappingProxyType({"key": secret.key, "jwt_token": secret.jwt_token}))
def _migrate_file_secret(record: CliTokenRecord, vault: SecretVault) -> CliTokenRecord | None:
if record.key is None:
return None
if vault.write(_encode_secret(record.base_url, record.key, record.jwt_token)):
_scrub_file_secret(record)
return record
def _scrub_file_secret(record: CliTokenRecord) -> None:
if record.key is None and not record.jwt_token:
return
with contextlib.suppress(OSError):
_write_token_file(_without_secret(record))
def _without_secret(record: CliTokenRecord) -> CliTokenRecord:
return record.model_copy(update=MappingProxyType({"key": None, "jwt_token": ""}))
def _encode_secret(base_url: str, key: str, jwt_token: str) -> str:
return CliTokenSecret(base_url=base_url, key=key, jwt_token=jwt_token).model_dump_json()
def _write_token_file(record: CliTokenRecord) -> None:
path: Final = Path(get_cli_token_file_path())
ensure_private_dir(path.parent)
write_private_json(str(path), record.model_dump(exclude_none=True))

View file

@ -1,10 +1,20 @@
import json
import os
import stat
import tempfile
from collections.abc import Mapping
from pathlib import Path
from typing import Final
PRIVATE_DIR_MODE: Final = 0o700
def ensure_private_dir(directory: Path) -> None:
"""Create directory (and parents) owner-only, tightening it if it already exists group/world readable"""
directory.mkdir(mode=PRIVATE_DIR_MODE, parents=True, exist_ok=True)
if stat.S_IMODE(directory.stat().st_mode) & 0o077:
directory.chmod(PRIVATE_DIR_MODE)
def write_private_json(path: str, data: Mapping[str, object]) -> None:
"""Atomically write JSON to path with owner-only permissions (0600)"""

View file

@ -331,7 +331,7 @@ sequenceDiagram
CLI->>Proxy: Poll /sso/cli/poll/login_id with poll_secret header
Proxy->>CLI: Return {"status": "ready", "key": "jwt"}
CLI->>CLI: Save key to ~/.litellm/token.json
CLI->>CLI: Save key to the OS keychain (metadata to ~/.litellm/token.json)
```
### Authentication Commands
@ -352,7 +352,7 @@ The CLI provides these authentication commands:
5. **Callback Processing**: SSO provider redirects back to proxy with state parameter
6. **User Code Verification**: Browser confirms the verification code shown in the CLI
7. **Polling**: CLI polls `/sso/cli/poll/{login_id}` with the polling secret header until the JWT is ready. When `CLI_SSO_CLAIM_MAP` is configured on the proxy, the poll response may include `attribution_metadata` (allowlisted scalar OIDC claims for client attribution).
8. **Token Storage**: CLI saves the authentication token to `~/.litellm/token.json`
8. **Token Storage**: CLI saves the key to the OS keychain and the non-secret session metadata to `~/.litellm/token.json`
### Benefits of This Approach
@ -364,11 +364,11 @@ The CLI provides these authentication commands:
### Token Storage
Authentication tokens are stored in `~/.litellm/token.json` with restricted file permissions (600). The stored token includes:
The key itself goes into the OS keychain (macOS Keychain, Windows Credential Manager, or the Linux Secret Service) under service `litellm-cli`, account `credential`. Only the non-secret session metadata is written to `~/.litellm/token.json`, in a `0700` directory with `0600` file permissions:
```json
{
"key": "sk-...",
"base_url": "https://your-proxy.com",
"user_id": "cli-user",
"user_email": "user@example.com",
"user_role": "cli",
@ -377,6 +377,10 @@ Authentication tokens are stored in `~/.litellm/token.json` with restricted file
}
```
Headless boxes and CI runners usually have no keychain. There the key stays in the same `0600` file alongside the metadata, exactly as it did before, and `lite login` tells you which of the two happened. Set `LITELLM_CLI_DISABLE_KEYRING=1` to force the file even where a keychain exists. A `token.json` written by an older `lite` keeps working and is moved into the keychain, and scrubbed from the file, the first time a keychain-capable `lite` reads it.
`lite logout` clears both stores. If the keychain is locked at that moment it says so, and re-running it once the keychain is unlocked finishes the job.
The stored credential is a short-lived, per-session agent token, not a managed virtual key. It is scoped to the user and team you logged in as and inherits their models and budgets; spend is tracked against the shared team and user budgets rather than a separate per-session cap, so multiple logins or several concurrent agents all draw down the same allowance. It is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); re-run `lite login` to refresh it and pick up your latest team and user settings. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while fresh and fails once it expires -- there is no silent renewal. It is accepted on a default deployment without `EXPERIMENTAL_UI_LOGIN`, does not appear in the Keys UI, and cannot be rotated or revoked mid-session. For a long-lived, rotatable, Keys-UI-visible credential, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY`.
### Usage

View file

@ -8,7 +8,7 @@ from typing import Final
import click
import requests
from .auth import get_stored_api_key, login
from .auth import context_secret_vault, get_stored_api_key, login
ANTHROPIC_BASE_URL_ENV: Final = "ANTHROPIC_BASE_URL"
ANTHROPIC_AUTH_TOKEN_ENV: Final = "ANTHROPIC_AUTH_TOKEN"
@ -316,7 +316,7 @@ def resolve_api_key(ctx: click.Context) -> str:
click.echo("No LiteLLM credentials found; starting login...")
ctx.invoke(login)
api_key = get_stored_api_key(expected_base_url=base_url)
api_key = get_stored_api_key(expected_base_url=base_url, vault=context_secret_vault(ctx))
if not api_key:
raise click.ClickException("Login did not produce an API key; cannot start the agent.")
return api_key

View file

@ -1,9 +1,6 @@
import json
import os
import sys
import time
import webbrowser
from pathlib import Path
from typing import Any, Final
from urllib.parse import urlencode
@ -11,10 +8,19 @@ import click
import requests
from rich.console import Console
from rich.table import Table
from typing_extensions import NotRequired, TypedDict
from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
from litellm.litellm_core_utils.cli_token_utils import is_cli_token_fresh
from litellm.litellm_core_utils.cli_keyring import SYSTEM_KEYRING, SecretVault
from litellm.litellm_core_utils.cli_token_utils import (
CliTokenRecord,
clear_cli_token,
get_cli_token_file_path,
get_litellm_gateway_api_key,
is_cli_token_fresh,
load_cli_token,
save_cli_token,
)
from .claude_settings import (
CLAUDE_SETTINGS_PATH,
@ -22,18 +28,6 @@ from .claude_settings import (
ClaudeSettingsError,
write_claude_settings,
)
from .private_json import write_private_json
class CliTokenData(TypedDict):
base_url: str
key: str
user_id: str
user_email: str
user_role: str
auth_header_name: str
jwt_token: str
timestamp: float
class CliTeam(TypedDict, total=False):
@ -46,6 +40,7 @@ class CliTeam(TypedDict, total=False):
class CliContextObj(TypedDict):
base_url: str
base_url_explicit: NotRequired[bool]
secret_vault: NotRequired[ReadOnly[SecretVault]]
class CliPollData(TypedDict, total=False):
@ -76,50 +71,32 @@ class CliAuthResult(TypedDict):
team_id: str | None
KEYCHAIN_UNREACHABLE_MESSAGE: Final = (
"Your credential is stored in your OS keychain, which could not be read. Unlock it and retry, or run 'lite login'."
)
# Token storage utilities
def get_token_file_path() -> str:
"""Get the path to store the authentication token"""
home_dir: Final = Path.home()
config_dir: Final = home_dir / ".litellm"
config_dir.mkdir(exist_ok=True)
return str(config_dir / "token.json")
def context_secret_vault(ctx: click.Context) -> SecretVault:
"""Where this invocation reads and writes secret material; injectable through ctx.obj for tests"""
ctx_obj: Final[CliContextObj | None] = ctx.obj
if ctx_obj is None:
return SYSTEM_KEYRING
return ctx_obj.get("secret_vault") or SYSTEM_KEYRING
def save_token(token_data: CliTokenData) -> None:
"""Save token data to file"""
write_private_json(get_token_file_path(), token_data)
def load_token() -> CliTokenData | None:
"""Load token data from file"""
token_file: Final = get_token_file_path()
if not os.path.exists(token_file):
return None
try:
with open(token_file, "r") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
def clear_token() -> None:
"""Clear stored token"""
token_file: Final = get_token_file_path()
if os.path.exists(token_file):
os.remove(token_file)
def get_stored_api_key(expected_base_url: str | None = None) -> str | None:
"""Get the stored API key from token file.
def get_stored_api_key(
expected_base_url: str | None = None,
*,
vault: SecretVault = SYSTEM_KEYRING,
) -> str | None:
"""Get the stored API key.
If expected_base_url is provided, the key is only returned when it was
originally issued for that URL. This prevents credential leakage when the
CLI is pointed at a different (possibly malicious) server.
"""
from litellm.litellm_core_utils.cli_token_utils import get_litellm_gateway_api_key
return get_litellm_gateway_api_key(expected_base_url=expected_base_url)
return get_litellm_gateway_api_key(expected_base_url=expected_base_url, vault=vault)
# Team selection utilities
@ -689,23 +666,27 @@ def login(ctx: click.Context, config_claude: bool):
api_key: Final = auth_result["api_key"]
user_id: Final = auth_result["user_id"]
# Save token data. base_url is stored so we can verify origin
# before reusing the key on a subsequent CLI invocation.
save_token(
{
"base_url": base_url.rstrip("/"),
"key": api_key,
"user_id": user_id or "cli-user",
"user_email": "unknown",
"user_role": "cli",
"auth_header_name": "Authorization",
"jwt_token": "",
"timestamp": time.time(),
}
# base_url is stored so we can verify origin before reusing the
# key on a subsequent CLI invocation.
record: Final = CliTokenRecord(
base_url=base_url.rstrip("/"),
key=api_key,
user_id=user_id or "cli-user",
user_email="unknown",
user_role="cli",
auth_header_name="Authorization",
jwt_token="",
timestamp=time.time(),
)
in_keychain: Final = save_cli_token(record, vault=context_secret_vault(ctx))
click.echo("\nLogin successful!")
click.echo(f"JWT Token: {api_key[:20]}...")
click.echo(
"Credential stored in your OS keychain."
if in_keychain
else f"No OS keychain available; credential stored in {get_cli_token_file_path()} (owner-only)."
)
click.echo("You can now use the CLI without specifying --api-key")
if config_claude:
@ -736,10 +717,14 @@ def login(ctx: click.Context, config_claude: bool):
@click.command(name="logout")
def logout():
@click.pass_context
def logout(ctx: click.Context):
"""Logout and clear stored authentication"""
clear_token()
click.echo("Logged out successfully. Authentication token cleared.")
if clear_cli_token(vault=context_secret_vault(ctx)):
click.echo("Logged out successfully. Authentication token cleared.")
return
click.echo("Logged out. The local token file is gone, but the OS keychain entry could not be removed.")
click.echo("Unlock your keychain and run 'lite logout' again to clear it.")
@click.command(name="print-token")
@ -753,7 +738,7 @@ def print_token(ctx: click.Context):
expires after `LITELLM_CLI_JWT_EXPIRATION_HOURS` (default 24h); once
expired, run `lite login` again.
"""
token_data: Final = load_token()
token_data: Final = load_cli_token(vault=context_secret_vault(ctx))
if not token_data:
click.echo("Not authenticated. Run 'lite login'.", err=True)
sys.exit(1)
@ -765,7 +750,7 @@ def print_token(ctx: click.Context):
ctx_obj: Final[CliContextObj] = ctx.obj
if ctx_obj.get("base_url_explicit"):
base_url: Final = ctx_obj["base_url"]
if token_data.get("base_url") != base_url.rstrip("/"):
if token_data.base_url != base_url.rstrip("/"):
click.echo("Not authenticated for this server. Run 'lite login'.", err=True)
sys.exit(1)
@ -773,33 +758,36 @@ def print_token(ctx: click.Context):
click.echo("Token expired. Run 'lite login' again.", err=True)
sys.exit(1)
api_key: Final = token_data.get("key")
api_key: Final = token_data.key
if not api_key:
click.echo("No token available. Run 'lite login'.", err=True)
click.echo(KEYCHAIN_UNREACHABLE_MESSAGE, err=True)
sys.exit(1)
click.echo(api_key)
@click.command(name="whoami")
def whoami():
@click.pass_context
def whoami(ctx: click.Context):
"""Show current authentication status"""
token_data: Final = load_token()
token_data: Final = load_cli_token(vault=context_secret_vault(ctx))
if not token_data:
click.echo("Not authenticated. Run 'lite login' to authenticate.")
return
click.echo("Authenticated")
click.echo(f"User Email: {token_data.get('user_email', 'Unknown')}")
click.echo(f"User ID: {token_data.get('user_id', 'Unknown')}")
click.echo(f"User Role: {token_data.get('user_role', 'Unknown')}")
click.echo(f"User Email: {token_data.user_email or 'Unknown'}")
click.echo(f"User ID: {token_data.user_id or 'Unknown'}")
click.echo(f"User Role: {token_data.user_role or 'Unknown'}")
# Check if token is still valid (basic timestamp check)
timestamp: Final = token_data.get("timestamp", 0)
age_hours: Final = (time.time() - timestamp) / 3600
age_hours: Final = (time.time() - token_data.timestamp) / 3600
click.echo(f"Token age: {age_hours:.1f} hours")
if token_data.key is None:
click.echo(KEYCHAIN_UNREACHABLE_MESSAGE)
if age_hours > CLI_JWT_EXPIRATION_HOURS:
click.echo(f"Warning: Token is more than {CLI_JWT_EXPIRATION_HOURS} hours old and may have expired.")

View file

@ -15,7 +15,7 @@ from typing import Final
from pydantic import JsonValue, TypeAdapter, ValidationError
from .private_json import write_private_json
from litellm.litellm_core_utils.private_json import write_private_json
ENV_KEY: Final = "env"
API_KEY_HELPER_KEY: Final = "apiKeyHelper"

View file

@ -10,7 +10,7 @@ from urllib.parse import urlparse
import click
from pydantic import TypeAdapter
from .private_json import write_private_json
from litellm.litellm_core_utils.private_json import ensure_private_dir, write_private_json
HIDDEN_COMMANDS_KEY: Final = "hidden_commands"
@ -42,7 +42,9 @@ def load_config() -> Mapping[str, str]:
def save_config(config: Mapping[str, str]) -> None:
"""Save CLI config to file"""
write_private_json(get_config_file_path(), config)
config_file: Final = Path(get_config_file_path())
ensure_private_dir(config_file.parent)
write_private_json(str(config_file), config)
def get_config_value(key: str) -> str | None:

View file

@ -14,10 +14,12 @@ from typing import IO, Final
import click
from pydantic import JsonValue, TypeAdapter, ValidationError
from litellm.litellm_core_utils.cli_token_utils import is_cli_token_fresh
from litellm.litellm_core_utils.cli_keyring import SecretVault
from litellm.litellm_core_utils.cli_token_utils import is_cli_token_fresh, load_cli_token
from litellm.litellm_core_utils.private_json import ensure_private_dir
from .agents import AgentRunError, resolve_api_key, verify_proxy_key
from .auth import load_token, login
from .auth import context_secret_vault, login
from .claude_settings import (
BACKUP_PATH,
CLAUDE_SETTINGS_PATH,
@ -66,7 +68,7 @@ def secure_create(path: Path) -> Iterator[IO[str]]:
def write_backup(record: BackupRecord, backup_path: Path | None = None) -> None:
path: Final = backup_path if backup_path is not None else BACKUP_PATH
path.parent.mkdir(exist_ok=True)
ensure_private_dir(path.parent)
with secure_create(path) as f:
json.dump({"existed": record.existed, "content": record.content}, f, indent=2)
@ -103,10 +105,17 @@ def restore_claude_settings(settings_path: Path | None = None, backup_path: Path
return record
def _has_fresh_login(base_url: str, vault: SecretVault) -> bool:
token_data: Final = load_cli_token(vault=vault)
if token_data is None or token_data.key is None or token_data.base_url != base_url:
return False
return is_cli_token_fresh(token_data)
def _ensure_fresh_login(ctx: click.Context) -> None:
base_url: Final = ctx.obj["base_url"].rstrip("/")
token_data = load_token()
if token_data and token_data.get("base_url") == base_url and is_cli_token_fresh(token_data):
vault: Final = context_secret_vault(ctx)
if _has_fresh_login(base_url, vault):
return
if not sys.stdin.isatty():
@ -117,8 +126,7 @@ def _ensure_fresh_login(ctx: click.Context) -> None:
click.echo("No fresh LiteLLM login found for this proxy; starting login...")
ctx.invoke(login)
token_data = load_token()
if not token_data or token_data.get("base_url") != base_url or not is_cli_token_fresh(token_data):
if not _has_fresh_login(base_url, vault):
raise UpError("Login did not produce a usable token; cannot start `lite up`.")

View file

@ -9,7 +9,7 @@ from litellm._version import version as litellm_version
from litellm.proxy.client.health import HealthManagementClient
from .commands.agents import agent_commands
from .commands.auth import auth_group, get_stored_api_key, login, logout, whoami
from .commands.auth import auth_group, context_secret_vault, get_stored_api_key, login, logout, whoami
from .commands.autoroute.commands import autoroute_group
from .commands.chat import chat
from .commands.config import config_commands, get_config_value, hidden_command_names
@ -94,7 +94,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.
if api_key is None:
api_key = get_stored_api_key(expected_base_url=base_url)
api_key = get_stored_api_key(expected_base_url=base_url, vault=context_secret_vault(ctx))
ctx.obj["base_url"] = base_url
ctx.obj["api_key"] = api_key

View file

@ -85,6 +85,7 @@ cli = [
"pyyaml>=6.0.3,<7.0",
"requests>=2.32.0,<3.0",
"InquirerPy>=0.3.4,<1.0",
"keyring>=25.6.0,<26.0",
]
extra_proxy = [
"prisma>=0.11.0,<1.0",
@ -166,6 +167,7 @@ litellm-proxy = "litellm.proxy.client.cli:cli"
dev = [
"diff-cover==9.7.2",
"basedpyright==1.39.7",
"keyring==25.7.0",
"pytest==9.0.3",
"pytest-mock==3.15.1",
"pytest-asyncio==1.3.0",

View file

@ -22,6 +22,12 @@ import litellm
from litellm import router as litellm_router_module
from litellm import utils as litellm_utils_module
from litellm._logging import ALL_LOGGERS
from litellm.litellm_core_utils.cli_keyring import (
SecretFound,
SecretMissing,
SecretRead,
SecretUnavailable,
)
from litellm.litellm_core_utils.prompt_templates import (
image_handling as image_handling_module,
)
@ -106,6 +112,65 @@ def isolate_host_proxy_base_url(monkeypatch):
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
@pytest.fixture(scope="function", autouse=True)
def isolate_host_os_keychain(monkeypatch):
"""Keep any code path that resolves a CLI credential out of the developer's real OS keychain.
Tests that exercise keychain behaviour inject their own vault instead.
"""
monkeypatch.setenv("LITELLM_CLI_DISABLE_KEYRING", "1")
class FakeSecretVault:
"""In-memory stand-in for the OS keychain, injected wherever CLI credential storage is exercised.
`available=False` models a keychain that is locked or has no backend, `writable=False` one that
refuses to store, and `erasable=False` one that will not release what it already holds.
"""
def __init__(
self,
blob: str | None = None,
*,
available: bool = True,
writable: bool = True,
erasable: bool = True,
) -> None:
self.blob: str | None = blob
self.available: bool = available
self.writable: bool = writable
self.erasable: bool = erasable
self.reads: int = 0
self.writes: list[str] = []
self.erases: int = 0
def read(self) -> SecretRead:
self.reads += 1
if not self.available:
return SecretUnavailable()
return SecretMissing() if self.blob is None else SecretFound(self.blob)
def write(self, blob: str) -> bool:
self.writes.append(blob)
if not (self.available and self.writable):
return False
self.blob = blob
return True
def erase(self) -> bool:
self.erases += 1
if not (self.available and self.erasable):
return False
self.blob = None
return True
@pytest.fixture
def secret_vault_factory():
"""Build FakeSecretVault instances; see its docstring for the failure modes it can model."""
return FakeSecretVault
def _run_coroutine_if_needed(result):
if not asyncio.iscoroutine(result):
return

View file

@ -1,89 +1,429 @@
"""
Unit tests for CLI token utilities
"""
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import mock_open, patch
import stat
import sys
import time
import pytest
from litellm.litellm_core_utils.cli_token_utils import get_litellm_gateway_api_key
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
from litellm.litellm_core_utils.cli_keyring import (
DISABLE_KEYRING_ENV_VAR,
KEYRING_ACCOUNT,
KEYRING_SERVICE,
KeyringVault,
SecretFound,
SecretMissing,
SecretUnavailable,
)
from litellm.litellm_core_utils.cli_token_utils import (
CliTokenRecord,
clear_cli_token,
get_cli_token_file_path,
get_litellm_gateway_api_key,
is_cli_token_fresh,
load_cli_token,
save_cli_token,
)
SERVER = "https://proxy.example.com"
OTHER_SERVER = "https://other-proxy.example.com"
class TestCLITokenUtils:
"""Test CLI token utility functions"""
@pytest.fixture
def isolated_home(monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
return tmp_path
def test_get_litellm_gateway_api_key_success(self):
"""Test getting CLI API key when token file exists and is valid"""
token_data = {
"key": "sk-test-cli-key-123",
"user_id": "test-user",
"user_email": "test@example.com",
"timestamp": 1234567890,
}
with (
patch("os.path.exists", return_value=True),
patch("builtins.open", mock_open(read_data=json.dumps(token_data))),
patch(
"litellm.litellm_core_utils.cli_token_utils.get_cli_token_file_path",
return_value="/test/.litellm/token.json",
),
):
def _token_file(home):
return home / ".litellm" / "token.json"
result = get_litellm_gateway_api_key()
assert result == "sk-test-cli-key-123"
def _write_legacy_file(home, **overrides):
payload = {
"base_url": SERVER,
"key": "sk-legacy",
"user_id": "u-1",
"user_email": "user@example.com",
"user_role": "cli",
"timestamp": time.time(),
**overrides,
}
path = _token_file(home)
path.parent.mkdir(exist_ok=True)
path.write_text(json.dumps(payload))
path.chmod(0o600)
return path
def test_get_litellm_gateway_api_key_no_file(self):
"""Test getting CLI API key when token file doesn't exist"""
with (
patch("os.path.exists", return_value=False),
patch(
"litellm.litellm_core_utils.cli_token_utils.get_cli_token_file_path",
return_value="/test/.litellm/token.json",
),
):
result = get_litellm_gateway_api_key()
def _write_metadata_only_file(home):
"""What a post-migration token.json looks like: everything except the secret material."""
path = _token_file(home)
path.parent.mkdir(exist_ok=True)
path.write_text(json.dumps({"base_url": SERVER, "user_id": "u-1", "timestamp": time.time()}))
path.chmod(0o600)
return path
assert result is None
def test_get_litellm_gateway_api_key_invalid_json(self):
"""Test getting CLI API key when token file has invalid JSON"""
with (
patch("os.path.exists", return_value=True),
patch("builtins.open", mock_open(read_data="invalid json")),
patch(
"litellm.litellm_core_utils.cli_token_utils.get_cli_token_file_path",
return_value="/test/.litellm/token.json",
),
):
def _blob(base_url=SERVER, key="sk-vault", jwt_token=""):
return json.dumps({"base_url": base_url, "key": key, "jwt_token": jwt_token})
result = get_litellm_gateway_api_key()
assert result is None
class TestGetCliTokenFilePath:
def test_points_at_the_home_config_file(self, isolated_home):
assert get_cli_token_file_path() == str(isolated_home / ".litellm" / "token.json")
def test_get_litellm_gateway_api_key_no_key_field(self):
"""Test getting CLI API key when token file exists but has no key field"""
token_data = {
"user_id": "test-user",
"user_email": "test@example.com",
# Missing 'key' field
}
def test_does_not_create_the_directory(self, isolated_home):
"""Merely asking for the path must not leave a directory behind, so an SDK import that
never logs in cannot create a ~/.litellm on someone's machine."""
get_cli_token_file_path()
with (
patch("os.path.exists", return_value=True),
patch("builtins.open", mock_open(read_data=json.dumps(token_data))),
patch(
"litellm.litellm_core_utils.cli_token_utils.get_cli_token_file_path",
return_value="/test/.litellm/token.json",
),
):
assert not (isolated_home / ".litellm").exists()
result = get_litellm_gateway_api_key()
assert result is None
class TestLoadCliToken:
def test_no_token_file_never_touches_the_keychain(self, isolated_home, secret_vault_factory):
"""The SDK calls this on machines that never ran `lite login`; it must not prompt for
keychain access there."""
vault = secret_vault_factory(blob=_blob())
assert load_cli_token(vault=vault) is None
assert vault.reads == 0
def test_secret_comes_from_the_vault_when_the_file_holds_only_metadata(self, isolated_home, secret_vault_factory):
_write_metadata_only_file(isolated_home)
vault = secret_vault_factory(blob=_blob(key="sk-from-keychain"))
record = load_cli_token(vault=vault)
assert record.key == "sk-from-keychain"
assert "sk-from-keychain" not in _token_file(isolated_home).read_text()
def test_jwt_token_round_trips_through_the_vault(self, isolated_home, secret_vault_factory):
_write_metadata_only_file(isolated_home)
vault = secret_vault_factory(blob=_blob(key="sk-a", jwt_token="jwt-a"))
record = load_cli_token(vault=vault)
assert (record.key, record.jwt_token) == ("sk-a", "jwt-a")
def test_legacy_plaintext_file_still_authenticates_and_is_migrated(self, isolated_home, secret_vault_factory):
"""A token.json written by an older `lite` keeps working, and reading it moves the secret
into the keychain and scrubs it from disk."""
path = _write_legacy_file(isolated_home)
vault = secret_vault_factory()
record = load_cli_token(vault=vault)
assert record.key == "sk-legacy"
assert json.loads(vault.blob)["key"] == "sk-legacy"
on_disk = json.loads(path.read_text())
assert "key" not in on_disk
assert on_disk["user_email"] == "user@example.com"
assert stat.S_IMODE(path.stat().st_mode) == 0o600
def test_legacy_file_survives_a_vault_that_refuses_to_store(self, isolated_home, secret_vault_factory):
"""Scrubbing the only copy of the secret after a failed keychain write would log the user
out for good."""
path = _write_legacy_file(isolated_home)
before = path.read_text()
record = load_cli_token(vault=secret_vault_factory(writable=False))
assert record.key == "sk-legacy"
assert path.read_text() == before
def test_a_secret_left_on_disk_outranks_a_stale_keychain_entry(self, isolated_home, secret_vault_factory):
"""A failed keychain write leaves the fresh secret on disk while the vault still holds the
previous one; the next read must serve the file's secret and move it into the vault, never
resurrect the stale key or scrub the only copy of the fresh one."""
path = _write_legacy_file(isolated_home, key="sk-fresh")
vault = secret_vault_factory(blob=_blob(key="sk-stale"))
record = load_cli_token(vault=vault)
assert record.key == "sk-fresh"
assert json.loads(vault.blob)["key"] == "sk-fresh"
assert "key" not in json.loads(path.read_text())
def test_a_disk_secret_survives_when_the_stale_vault_refuses_the_rewrite(
self, isolated_home, secret_vault_factory
):
path = _write_legacy_file(isolated_home, key="sk-fresh")
before = path.read_text()
record = load_cli_token(vault=secret_vault_factory(blob=_blob(key="sk-stale"), writable=False))
assert record.key == "sk-fresh"
assert path.read_text() == before
def test_legacy_file_survives_an_unreachable_vault_without_write_attempts(
self, isolated_home, secret_vault_factory
):
path = _write_legacy_file(isolated_home)
before = path.read_text()
vault = secret_vault_factory(available=False)
record = load_cli_token(vault=vault)
assert record.key == "sk-legacy"
assert vault.writes == []
assert path.read_text() == before
def test_metadata_only_file_with_an_empty_vault_is_not_a_login(self, isolated_home, secret_vault_factory):
_write_metadata_only_file(isolated_home)
assert load_cli_token(vault=secret_vault_factory()) is None
def test_metadata_only_file_with_an_unreachable_vault_reports_a_missing_secret(
self, isolated_home, secret_vault_factory
):
"""The caller needs to tell "never logged in" apart from "locked keychain", so the record
comes back with no key rather than as None."""
_write_metadata_only_file(isolated_home)
record = load_cli_token(vault=secret_vault_factory(available=False))
assert record.key is None
assert record.user_id == "u-1"
def test_a_secret_minted_for_another_server_is_never_handed_out(self, isolated_home, secret_vault_factory):
_write_metadata_only_file(isolated_home)
assert load_cli_token(vault=secret_vault_factory(blob=_blob(base_url=OTHER_SERVER))) is None
def test_a_secret_minted_for_another_server_loses_to_the_file(self, isolated_home, secret_vault_factory):
_write_legacy_file(isolated_home)
vault = secret_vault_factory(blob=_blob(base_url=OTHER_SERVER, key="sk-elsewhere"))
record = load_cli_token(vault=vault)
assert record.key == "sk-legacy"
assert json.loads(vault.blob)["key"] == "sk-legacy"
def test_unreadable_vault_blob_falls_back_to_the_file_secret(self, isolated_home, secret_vault_factory):
_write_legacy_file(isolated_home)
record = load_cli_token(vault=secret_vault_factory(blob="not json at all {{{"))
assert record.key == "sk-legacy"
def test_corrupt_token_file_is_not_a_login(self, isolated_home, secret_vault_factory):
_token_file(isolated_home).parent.mkdir()
_token_file(isolated_home).write_text("not json at all {{{")
assert load_cli_token(vault=secret_vault_factory(blob=_blob())) is None
class TestGetLitellmGatewayApiKey:
def test_returns_the_vault_secret_when_the_origin_matches(self, isolated_home, secret_vault_factory):
_write_metadata_only_file(isolated_home)
key = get_litellm_gateway_api_key(expected_base_url=SERVER, vault=secret_vault_factory(blob=_blob()))
assert key == "sk-vault"
def test_trailing_slash_on_the_expected_url_is_normalised(self, isolated_home, secret_vault_factory):
_write_metadata_only_file(isolated_home)
key = get_litellm_gateway_api_key(expected_base_url=SERVER + "/", vault=secret_vault_factory(blob=_blob()))
assert key == "sk-vault"
def test_origin_mismatch_returns_nothing_without_reading_the_keychain(self, isolated_home, secret_vault_factory):
"""Pointing the SDK at a different server must fail before the keychain is even consulted,
so a hostile base_url cannot provoke an unlock prompt."""
_write_legacy_file(isolated_home)
vault = secret_vault_factory(blob=_blob())
assert get_litellm_gateway_api_key(expected_base_url=OTHER_SERVER, vault=vault) is None
assert vault.reads == 0
def test_no_token_file_returns_nothing(self, isolated_home, secret_vault_factory):
assert get_litellm_gateway_api_key(vault=secret_vault_factory(blob=_blob())) is None
class TestSaveCliToken:
def test_secret_goes_to_the_keychain_and_never_to_the_file(self, isolated_home, secret_vault_factory):
vault = secret_vault_factory()
stored = save_cli_token(
CliTokenRecord(base_url=SERVER, key="sk-new", user_id="u-1", timestamp=time.time()),
vault=vault,
)
assert stored is True
assert "sk-new" not in _token_file(isolated_home).read_text()
assert json.loads(vault.blob)["key"] == "sk-new"
assert load_cli_token(vault=vault).key == "sk-new"
def test_falls_back_to_the_owner_only_file_when_there_is_no_keychain(self, isolated_home, secret_vault_factory):
stored = save_cli_token(
CliTokenRecord(base_url=SERVER, key="sk-new", timestamp=time.time()),
vault=secret_vault_factory(available=False),
)
path = _token_file(isolated_home)
assert stored is False
assert json.loads(path.read_text())["key"] == "sk-new"
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert list(path.parent.glob(".tmp-*")) == []
def test_creates_the_config_directory_owner_only(self, isolated_home, secret_vault_factory):
"""A 0755 ~/.litellm lets any local process list, and in the fallback case read, the
credential's directory."""
save_cli_token(CliTokenRecord(base_url=SERVER, key="sk-new"), vault=secret_vault_factory())
assert stat.S_IMODE((isolated_home / ".litellm").stat().st_mode) == 0o700
def test_tightens_a_directory_left_group_readable_by_an_older_cli(self, isolated_home, secret_vault_factory):
config_dir = isolated_home / ".litellm"
config_dir.mkdir(mode=0o755)
save_cli_token(CliTokenRecord(base_url=SERVER, key="sk-new"), vault=secret_vault_factory())
assert stat.S_IMODE(config_dir.stat().st_mode) == 0o700
def test_a_failed_write_leaves_the_previous_credential_intact(self, isolated_home, secret_vault_factory, monkeypatch):
path = _write_legacy_file(isolated_home)
before = path.read_text()
def _explode(*args, **kwargs):
raise TypeError("not serialisable")
monkeypatch.setattr("litellm.litellm_core_utils.private_json.json.dump", _explode)
with pytest.raises(TypeError):
save_cli_token(CliTokenRecord(base_url=SERVER, key="sk-new"), vault=secret_vault_factory(available=False))
assert path.read_text() == before
assert list(path.parent.glob(".tmp-*")) == []
class TestClearCliToken:
def test_removes_the_credential_from_both_stores(self, isolated_home, secret_vault_factory):
vault = secret_vault_factory()
save_cli_token(CliTokenRecord(base_url=SERVER, key="sk-new"), vault=vault)
assert clear_cli_token(vault=vault) is True
assert vault.blob is None
assert not _token_file(isolated_home).exists()
assert load_cli_token(vault=vault) is None
def test_reports_a_keychain_that_will_not_release_the_secret(self, isolated_home, secret_vault_factory):
_write_legacy_file(isolated_home)
vault = secret_vault_factory(blob=_blob(), erasable=False)
assert clear_cli_token(vault=vault) is False
assert not _token_file(isolated_home).exists()
def test_is_safe_when_nothing_was_ever_stored(self, isolated_home, secret_vault_factory):
assert clear_cli_token(vault=secret_vault_factory()) is True
class TestIsCliTokenFresh:
def test_a_just_issued_token_is_fresh(self):
assert is_cli_token_fresh(CliTokenRecord(timestamp=time.time())) is True
def test_a_token_past_its_expiry_is_stale(self):
stale = CliTokenRecord(timestamp=time.time() - (CLI_JWT_EXPIRATION_HOURS + 1) * 3600)
assert is_cli_token_fresh(stale) is False
def test_the_buffer_retires_a_token_just_before_it_expires(self):
almost = CliTokenRecord(timestamp=time.time() - (CLI_JWT_EXPIRATION_HOURS * 3600 - 60))
assert is_cli_token_fresh(almost, buffer_hours=0.1) is False
class _FakeKeyringModule:
def __init__(self, stored=None, *, get_error=None, set_error=None, delete_error=None):
self.stored = stored
self.get_error = get_error
self.set_error = set_error
self.delete_error = delete_error
self.calls = []
def get_password(self, service_name, username):
self.calls.append(("get", service_name, username))
if self.get_error is not None:
raise self.get_error
return self.stored
def set_password(self, service_name, username, password):
self.calls.append(("set", service_name, username))
if self.set_error is not None:
raise self.set_error
self.stored = password
def delete_password(self, service_name, username):
self.calls.append(("delete", service_name, username))
if self.delete_error is not None:
raise self.delete_error
self.stored = None
@pytest.fixture
def install_fake_keyring(monkeypatch):
def _install(fake):
monkeypatch.delenv(DISABLE_KEYRING_ENV_VAR, raising=False)
monkeypatch.setitem(sys.modules, "keyring", fake)
return fake
return _install
class TestKeyringVault:
def test_round_trips_through_the_installed_keyring(self, install_fake_keyring):
fake = install_fake_keyring(_FakeKeyringModule())
vault = KeyringVault()
assert vault.write("blob-1") is True
assert vault.read() == SecretFound("blob-1")
assert vault.erase() is True
assert vault.read() == SecretMissing()
assert {call[1:] for call in fake.calls} == {(KEYRING_SERVICE, KEYRING_ACCOUNT)}
def test_the_kill_switch_reports_no_keychain(self, monkeypatch):
"""`LITELLM_CLI_DISABLE_KEYRING` has to work without importing keyring, because keyring
caches its backend on first use and cannot be reconfigured later. Erase still fails: a
credential stored before the switch was set may be in the keychain, and with reads
disabled `lite logout` cannot verify it is gone, so it must warn instead."""
monkeypatch.setenv(DISABLE_KEYRING_ENV_VAR, "1")
vault = KeyringVault()
assert vault.read() == SecretUnavailable()
assert vault.write("blob-1") is False
assert vault.erase() is False
def test_an_uninstalled_keyring_library_degrades_to_the_file(self, monkeypatch):
"""keyring is an optional extra, so the SDK must survive its absence rather than raise on
the hot path."""
monkeypatch.delenv(DISABLE_KEYRING_ENV_VAR, raising=False)
monkeypatch.setitem(sys.modules, "keyring", None)
vault = KeyringVault()
assert vault.read() == SecretUnavailable()
assert vault.write("blob-1") is False
assert vault.erase() is True
def test_a_locked_keychain_is_reported_not_raised(self, install_fake_keyring):
install_fake_keyring(_FakeKeyringModule(get_error=RuntimeError("keyring is locked")))
assert KeyringVault().read() == SecretUnavailable()
def test_a_refused_write_is_reported_not_raised(self, install_fake_keyring):
install_fake_keyring(_FakeKeyringModule(set_error=RuntimeError("no backend")))
assert KeyringVault().write("blob-1") is False
def test_a_refused_delete_is_reported_so_logout_can_warn(self, install_fake_keyring):
install_fake_keyring(_FakeKeyringModule(stored="blob-1", delete_error=RuntimeError("locked")))
assert KeyringVault().erase() is False
def test_erasing_a_locked_keychain_is_a_failure(self, install_fake_keyring):
install_fake_keyring(_FakeKeyringModule(get_error=RuntimeError("locked")))
assert KeyringVault().erase() is False

View file

@ -672,8 +672,9 @@ class TestAgentCommands:
assert "LITELLM_PROXY_API_KEY" in result.output
mock_run.assert_not_called()
def test_interactive_without_key_logs_in_then_launches(self):
def test_interactive_without_key_logs_in_then_launches(self, secret_vault_factory):
captured = {}
vault = secret_vault_factory()
@click.command()
def fake_login():
@ -695,12 +696,12 @@ class TestAgentCommands:
result = self.runner.invoke(
_agent_command("claude"),
[],
obj={"base_url": "http://localhost:4000", "api_key": None},
obj={"base_url": "http://localhost:4000", "api_key": None, "secret_vault": vault},
)
assert result.exit_code == 0, result.output
assert captured["api_key"] == "sk-after-login"
mock_get.assert_called_once_with(expected_base_url="http://localhost:4000")
mock_get.assert_called_once_with(expected_base_url="http://localhost:4000", vault=vault)
def test_child_exit_code_reaches_the_shell(self):
with patch(f"{AGENTS_MODULE}.run_agent", side_effect=SystemExit(42)):

View file

@ -4,7 +4,7 @@ import stat
import sys
import time
from pathlib import Path
from unittest.mock import Mock, mock_open, patch
from unittest.mock import Mock, patch
sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system path
@ -13,21 +13,38 @@ import pytest
from click.testing import CliRunner
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
from litellm.litellm_core_utils.cli_token_utils import CliTokenRecord, save_cli_token
from litellm.proxy.client.cli import cli
from litellm.proxy.client.cli.commands.auth import (
clear_token,
KEYCHAIN_UNREACHABLE_MESSAGE,
get_stored_api_key,
get_token_file_path,
load_token,
login,
logout,
print_token,
save_token,
whoami,
)
from litellm.proxy.client.cli.commands.claude_settings import SettingsFileOwner
@pytest.fixture
def isolated_home(monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("LITELLM_PROXY_URL", raising=False)
monkeypatch.delenv("LITELLM_PROXY_API_KEY", raising=False)
return tmp_path
def _write_home_json(home: Path, filename: str, payload: dict[str, object]) -> None:
litellm_dir = home / ".litellm"
litellm_dir.mkdir(exist_ok=True)
(litellm_dir / filename).write_text(json.dumps(payload))
def _secret_blob(base_url: str, key: str) -> str:
return json.dumps({"base_url": base_url, "key": key, "jwt_token": ""})
def _mock_cli_sso_start_response(
login_id: str = "cli-session-uuid-456",
poll_secret: str = "poll-secret",
@ -176,200 +193,50 @@ class TestStartCliSsoFlowErrors:
assert "https://unreachable.example.com/sso/cli/start" in message
class TestTokenUtilities:
"""Test token file utility functions"""
class TestStoredApiKeyLookup:
"""`get_stored_api_key` is what every other `lite` subcommand authenticates with, so the
keychain split and the origin check both have to be invisible to it."""
def test_get_token_file_path(self):
"""Test getting token file path"""
with (
patch("pathlib.Path.home") as mock_home,
patch("pathlib.Path.mkdir") as mock_mkdir,
):
mock_home.return_value = Path("/home/user")
def test_returns_the_secret_the_keychain_holds(self, isolated_home, secret_vault_factory):
_write_home_json(isolated_home, "token.json", {"base_url": "https://real-proxy.com", "user_id": "u-1"})
vault = secret_vault_factory(blob=_secret_blob("https://real-proxy.com", "sk-from-keychain"))
result = get_token_file_path()
assert get_stored_api_key(vault=vault) == "sk-from-keychain"
assert result == "/home/user/.litellm/token.json"
mock_mkdir.assert_called_once_with(exist_ok=True)
def test_returns_a_legacy_plaintext_key(self, isolated_home, secret_vault_factory):
_write_home_json(isolated_home, "token.json", {"base_url": "https://real-proxy.com", "key": "sk-legacy"})
def test_get_token_file_path_creates_directory(self):
"""Test that get_token_file_path creates the config directory"""
with (
patch("pathlib.Path.home") as mock_home,
patch("pathlib.Path.mkdir") as mock_mkdir,
):
mock_home.return_value = Path("/home/user")
assert get_stored_api_key(vault=secret_vault_factory()) == "sk-legacy"
get_token_file_path()
def test_no_token_at_all_returns_nothing(self, isolated_home, secret_vault_factory):
assert get_stored_api_key(vault=secret_vault_factory()) is None
mock_mkdir.assert_called_once_with(exist_ok=True)
def test_metadata_without_a_secret_returns_nothing(self, isolated_home, secret_vault_factory):
_write_home_json(isolated_home, "token.json", {"base_url": "https://real-proxy.com", "user_id": "u-1"})
def test_save_token(self, tmp_path):
"""Test saving token data to file"""
token_data = {
"key": "test-key",
"user_id": "test-user",
"timestamp": 1234567890,
}
token_file = tmp_path / "token.json"
assert get_stored_api_key(vault=secret_vault_factory()) is None
with patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path:
mock_path.return_value = str(token_file)
def test_matching_base_url_returns_the_key(self, isolated_home, secret_vault_factory):
_write_home_json(isolated_home, "token.json", {"base_url": "https://real-proxy.com", "key": "sk-prod"})
save_token(token_data)
assert get_stored_api_key("https://real-proxy.com", vault=secret_vault_factory()) == "sk-prod"
assert json.loads(token_file.read_text()) == token_data
assert stat.S_IMODE(token_file.stat().st_mode) == 0o600
def test_trailing_slash_on_the_expected_url_is_normalised(self, isolated_home, secret_vault_factory):
_write_home_json(isolated_home, "token.json", {"base_url": "https://real-proxy.com", "key": "sk-prod"})
def test_load_token_success(self):
"""Test loading token data from file successfully"""
token_data = {
"key": "test-key",
"user_id": "test-user",
"timestamp": 1234567890,
}
assert get_stored_api_key("https://real-proxy.com/", vault=secret_vault_factory()) == "sk-prod"
with (
patch("builtins.open", mock_open(read_data=json.dumps(token_data))),
patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path,
patch("os.path.exists", return_value=True),
):
mock_path.return_value = "/test/path/token.json"
def test_mismatched_base_url_withholds_the_key(self, isolated_home, secret_vault_factory):
_write_home_json(isolated_home, "token.json", {"base_url": "https://real-proxy.com", "key": "sk-prod"})
result = load_token()
assert get_stored_api_key("https://evil.com", vault=secret_vault_factory()) is None
assert result == token_data
def test_old_tokens_without_a_base_url_are_rejected_when_an_origin_is_expected(
self, isolated_home, secret_vault_factory
):
_write_home_json(isolated_home, "token.json", {"key": "sk-old-token"})
def test_load_token_file_not_exists(self):
"""Test loading token when file doesn't exist"""
with (
patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path,
patch("os.path.exists", return_value=False),
):
mock_path.return_value = "/test/path/token.json"
result = load_token()
assert result is None
def test_load_token_json_decode_error(self):
"""Test loading token with invalid JSON"""
with (
patch("builtins.open", mock_open(read_data="invalid json")),
patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path,
patch("os.path.exists", return_value=True),
):
mock_path.return_value = "/test/path/token.json"
result = load_token()
assert result is None
def test_load_token_io_error(self):
"""Test loading token with IO error"""
with (
patch("builtins.open", side_effect=OSError("Permission denied")),
patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path,
patch("os.path.exists", return_value=True),
):
mock_path.return_value = "/test/path/token.json"
result = load_token()
assert result is None
def test_clear_token_file_exists(self):
"""Test clearing token when file exists"""
with (
patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path,
patch("os.path.exists", return_value=True),
patch("os.remove") as mock_remove,
):
mock_path.return_value = "/test/path/token.json"
clear_token()
mock_remove.assert_called_once_with("/test/path/token.json")
def test_clear_token_file_not_exists(self):
"""Test clearing token when file doesn't exist"""
with (
patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path,
patch("os.path.exists", return_value=False),
patch("os.remove") as mock_remove,
):
mock_path.return_value = "/test/path/token.json"
clear_token()
mock_remove.assert_not_called()
def test_get_stored_api_key_success(self):
"""Test getting stored API key successfully"""
token_data = {"key": "test-api-key-123", "user_id": "test-user"}
with patch(
"litellm.litellm_core_utils.cli_token_utils.load_cli_token",
return_value=token_data,
):
result = get_stored_api_key()
assert result == "test-api-key-123"
def test_get_stored_api_key_no_token(self):
"""Test getting stored API key when no token exists"""
with patch(
"litellm.litellm_core_utils.cli_token_utils.load_cli_token",
return_value=None,
):
result = get_stored_api_key()
assert result is None
def test_get_stored_api_key_no_key_field(self):
"""Test getting stored API key when token has no key field"""
token_data = {"user_id": "test-user"}
with patch(
"litellm.litellm_core_utils.cli_token_utils.load_cli_token",
return_value=token_data,
):
result = get_stored_api_key()
assert result is None
def test_get_stored_api_key_base_url_match(self):
"""Stored key is returned when expected_base_url matches stored origin"""
token_data = {"key": "sk-prod", "base_url": "https://real-proxy.com"}
with patch(
"litellm.litellm_core_utils.cli_token_utils.load_cli_token",
return_value=token_data,
):
assert get_stored_api_key(expected_base_url="https://real-proxy.com") == "sk-prod"
def test_get_stored_api_key_base_url_match_trailing_slash(self):
"""Trailing slash on expected_base_url is normalised before comparison"""
token_data = {"key": "sk-prod", "base_url": "https://real-proxy.com"}
with patch(
"litellm.litellm_core_utils.cli_token_utils.load_cli_token",
return_value=token_data,
):
assert get_stored_api_key(expected_base_url="https://real-proxy.com/") == "sk-prod"
def test_get_stored_api_key_base_url_mismatch(self):
"""Stored key is NOT returned when expected_base_url differs from stored origin"""
token_data = {"key": "sk-prod", "base_url": "https://real-proxy.com"}
with patch(
"litellm.litellm_core_utils.cli_token_utils.load_cli_token",
return_value=token_data,
):
assert get_stored_api_key(expected_base_url="https://evil.com") is None
def test_get_stored_api_key_old_token_no_base_url(self):
"""Old tokens without a base_url field are rejected when origin check is requested"""
token_data = {"key": "sk-old-token"}
with patch(
"litellm.litellm_core_utils.cli_token_utils.load_cli_token",
return_value=token_data,
):
assert get_stored_api_key(expected_base_url="https://real-proxy.com") is None
assert get_stored_api_key("https://real-proxy.com", vault=secret_vault_factory()) is None
class TestLoginCommand:
@ -402,7 +269,7 @@ class TestLoginCommand:
return_value=_mock_cli_sso_start_response(login_id="cli-test-uuid-123"),
) as mock_post,
patch("requests.get", return_value=mock_response) as mock_get,
patch("litellm.proxy.client.cli.commands.auth.save_token") as mock_save,
patch("litellm.proxy.client.cli.commands.auth.save_cli_token") as mock_save,
patch("litellm.proxy.client.cli.interface.show_commands") as mock_show_commands,
):
result = self.runner.invoke(login, obj=mock_context.obj)
@ -424,8 +291,8 @@ class TestLoginCommand:
# Verify JWT was saved
mock_save.assert_called_once()
saved_data = mock_save.call_args[0][0]
assert saved_data["key"] == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt"
assert saved_data["user_id"] == "test-user-123"
assert saved_data.key == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt"
assert saved_data.user_id == "test-user-123"
# Verify commands were shown
mock_show_commands.assert_called_once()
@ -557,7 +424,7 @@ class TestLogoutCommand:
def test_logout_success(self):
"""Test successful logout"""
with patch("litellm.proxy.client.cli.commands.auth.clear_token") as mock_clear:
with patch("litellm.proxy.client.cli.commands.auth.clear_cli_token") as mock_clear:
result = self.runner.invoke(logout)
assert result.exit_code == 0
@ -574,14 +441,15 @@ class TestWhoamiCommand:
def test_whoami_authenticated(self):
"""Test whoami when user is authenticated"""
token_data = {
"user_email": "test@example.com",
"user_id": "test-user-123",
"user_role": "admin",
"timestamp": time.time() - 3600, # 1 hour ago
}
token_data = CliTokenRecord(
user_email="test@example.com",
user_id="test-user-123",
user_role="admin",
key="sk-live",
timestamp=time.time() - 3600,
)
with patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data):
with patch("litellm.proxy.client.cli.commands.auth.load_cli_token", return_value=token_data):
result = self.runner.invoke(whoami)
assert result.exit_code == 0
@ -593,7 +461,7 @@ class TestWhoamiCommand:
def test_whoami_not_authenticated(self):
"""Test whoami when user is not authenticated"""
with patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=None):
with patch("litellm.proxy.client.cli.commands.auth.load_cli_token", return_value=None):
result = self.runner.invoke(whoami)
assert result.exit_code == 0
@ -602,14 +470,15 @@ class TestWhoamiCommand:
def test_whoami_old_token(self):
"""Test whoami with old token showing warning"""
token_data = {
"user_email": "test@example.com",
"user_id": "test-user-123",
"user_role": "admin",
"timestamp": time.time() - (25 * 3600), # 25 hours ago
}
token_data = CliTokenRecord(
user_email="test@example.com",
user_id="test-user-123",
user_role="admin",
key="sk-live",
timestamp=time.time() - (25 * 3600),
)
with patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data):
with patch("litellm.proxy.client.cli.commands.auth.load_cli_token", return_value=token_data):
result = self.runner.invoke(whoami)
assert result.exit_code == 0
@ -618,12 +487,9 @@ class TestWhoamiCommand:
def test_whoami_missing_fields(self):
"""Test whoami with token missing some fields"""
token_data = {
"timestamp": time.time() - 3600
# Missing user_email, user_id, user_role
}
token_data = CliTokenRecord(key="sk-live", timestamp=time.time() - 3600)
with patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data):
with patch("litellm.proxy.client.cli.commands.auth.load_cli_token", return_value=token_data):
result = self.runner.invoke(whoami)
assert result.exit_code == 0
@ -632,16 +498,16 @@ class TestWhoamiCommand:
def test_whoami_no_timestamp(self):
"""Test whoami with token missing timestamp"""
token_data = {
"user_email": "test@example.com",
"user_id": "test-user-123",
"user_role": "admin",
# Missing timestamp
}
token_data = CliTokenRecord(
user_email="test@example.com",
user_id="test-user-123",
user_role="admin",
key="sk-live",
)
with (
patch(
"litellm.proxy.client.cli.commands.auth.load_token",
"litellm.proxy.client.cli.commands.auth.load_cli_token",
return_value=token_data,
),
patch("time.time", return_value=1000),
@ -701,7 +567,7 @@ class TestCLIKeyRegenerationFlow:
return_value=_mock_cli_sso_start_response(login_id="cli-session-uuid-456"),
),
patch("requests.get", side_effect=[mock_first_response, mock_second_response]) as mock_get,
patch("litellm.proxy.client.cli.commands.auth.save_token") as mock_save,
patch("litellm.proxy.client.cli.commands.auth.save_cli_token") as mock_save,
patch("litellm.proxy.client.cli.interface.show_commands") as mock_show_commands,
patch("click.prompt", return_value="2"),
): # User selects index 2
@ -734,8 +600,8 @@ class TestCLIKeyRegenerationFlow:
# Verify JWT was saved
mock_save.assert_called_once()
saved_data = mock_save.call_args[0][0]
assert saved_data["key"] == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.team-beta.jwt"
assert saved_data["user_id"] == "test-user-456"
assert saved_data.key == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.team-beta.jwt"
assert saved_data.user_id == "test-user-456"
mock_show_commands.assert_called_once()
@ -762,7 +628,7 @@ class TestCLIKeyRegenerationFlow:
return_value=_mock_cli_sso_start_response(login_id="cli-session-uuid-solo"),
),
patch("requests.get", return_value=mock_response),
patch("litellm.proxy.client.cli.commands.auth.save_token") as mock_save,
patch("litellm.proxy.client.cli.commands.auth.save_cli_token") as mock_save,
patch("litellm.proxy.client.cli.interface.show_commands"),
):
result = self.runner.invoke(login, obj=mock_context.obj)
@ -780,8 +646,8 @@ class TestCLIKeyRegenerationFlow:
# Verify JWT was saved
mock_save.assert_called_once()
saved_data = mock_save.call_args[0][0]
assert saved_data["key"] == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.no-team.jwt"
assert saved_data["user_id"] == "test-user-solo"
assert saved_data.key == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.no-team.jwt"
assert saved_data.user_id == "test-user-solo"
class TestPrintTokenCommand:
@ -810,7 +676,7 @@ class TestPrintTokenCommand:
self.runner = CliRunner()
def test_no_stored_token_fails_cleanly(self):
with patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=None):
with patch("litellm.proxy.client.cli.commands.auth.load_cli_token", return_value=None):
result = self.runner.invoke(print_token, obj={})
assert result.exit_code != 0
@ -822,12 +688,12 @@ class TestPrintTokenCommand:
one). Must use token.json's own base_url, not a hardcoded default."""
with (
patch(
"litellm.proxy.client.cli.commands.auth.load_token",
return_value={
"base_url": "https://litellm-proxy.corp.com",
"key": "sk-prod-fresh",
"timestamp": time.time(),
},
"litellm.proxy.client.cli.commands.auth.load_cli_token",
return_value=CliTokenRecord(
base_url="https://litellm-proxy.corp.com",
key="sk-prod-fresh",
timestamp=time.time(),
),
),
patch("requests.post") as mock_post,
):
@ -844,12 +710,12 @@ class TestPrintTokenCommand:
token minted for proxy A must not reach a helper invocation aimed
at proxy B, even though the token itself is otherwise fresh."""
with patch(
"litellm.proxy.client.cli.commands.auth.load_token",
return_value={
"base_url": "https://other-server.com",
"key": "sk-should-not-print",
"timestamp": time.time(),
},
"litellm.proxy.client.cli.commands.auth.load_cli_token",
return_value=CliTokenRecord(
base_url="https://other-server.com",
key="sk-should-not-print",
timestamp=time.time(),
),
):
result = self.runner.invoke(
print_token,
@ -863,12 +729,12 @@ class TestPrintTokenCommand:
"""`lite up`'s own bound invocation shape: --base-url matching the token's origin
must succeed exactly like the bare/legacy invocation does."""
with patch(
"litellm.proxy.client.cli.commands.auth.load_token",
return_value={
"base_url": "http://localhost:4000",
"key": "sk-matches",
"timestamp": time.time(),
},
"litellm.proxy.client.cli.commands.auth.load_cli_token",
return_value=CliTokenRecord(
base_url="http://localhost:4000",
key="sk-matches",
timestamp=time.time(),
),
):
result = self.runner.invoke(
print_token,
@ -884,12 +750,12 @@ class TestPrintTokenCommand:
frequently)."""
with (
patch(
"litellm.proxy.client.cli.commands.auth.load_token",
return_value={
"base_url": "http://localhost:4000",
"key": "sk-cached-fresh",
"timestamp": time.time(),
},
"litellm.proxy.client.cli.commands.auth.load_cli_token",
return_value=CliTokenRecord(
base_url="http://localhost:4000",
key="sk-cached-fresh",
timestamp=time.time(),
),
),
patch("requests.post") as mock_post,
):
@ -908,12 +774,12 @@ class TestPrintTokenCommand:
with (
patch(
"litellm.proxy.client.cli.commands.auth.load_token",
return_value={
"base_url": "http://localhost:4000",
"key": "sk-stale-key",
"timestamp": old_timestamp,
},
"litellm.proxy.client.cli.commands.auth.load_cli_token",
return_value=CliTokenRecord(
base_url="http://localhost:4000",
key="sk-stale-key",
timestamp=old_timestamp,
),
),
patch("requests.post") as mock_post,
):
@ -925,25 +791,11 @@ class TestPrintTokenCommand:
mock_post.assert_not_called()
def _write_home_json(home: Path, filename: str, payload: dict[str, object]) -> None:
litellm_dir = home / ".litellm"
litellm_dir.mkdir(exist_ok=True)
(litellm_dir / filename).write_text(json.dumps(payload))
class TestPrintTokenWithConfigFile:
"""A config-file base_url is a drop-in replacement for exporting
LITELLM_PROXY_URL, so print-token must treat it as an explicit server
choice: a token minted for a different proxy is never handed out."""
@pytest.fixture
def isolated_home(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("LITELLM_PROXY_URL", raising=False)
monkeypatch.delenv("LITELLM_PROXY_API_KEY", raising=False)
return tmp_path
def test_config_base_url_mismatch_fails_closed(self, isolated_home):
_write_home_json(
isolated_home,
@ -1001,37 +853,196 @@ class TestPrintTokenWithConfigFile:
assert result.stdout.strip() == "sk-issued-for-a"
class TestSaveTokenPrivateWrite:
"""token.json holds the real API key: it must never be world-readable at any
instant, and a failed write must not destroy the previously stored token."""
class TestFileFallbackStorage:
"""On a headless box with no keychain the token file is still the only store, so it has to
stay owner-only and survive a failed write."""
@pytest.fixture
def isolated_home(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("LITELLM_PROXY_URL", raising=False)
monkeypatch.delenv("LITELLM_PROXY_API_KEY", raising=False)
return tmp_path
def test_save_token_owner_only_permissions_and_no_temp_leftovers(self, isolated_home):
save_token({"key": "sk-secret", "user_id": "u-1", "timestamp": 1234567890})
def test_owner_only_file_and_directory_with_no_temp_leftovers(self, isolated_home, secret_vault_factory):
save_cli_token(
CliTokenRecord(base_url="https://proxy.example.com", key="sk-secret", user_id="u-1", timestamp=1234567890),
vault=secret_vault_factory(available=False),
)
token_file = isolated_home / ".litellm" / "token.json"
assert json.loads(token_file.read_text()) == {"key": "sk-secret", "user_id": "u-1", "timestamp": 1234567890}
assert json.loads(token_file.read_text())["key"] == "sk-secret"
assert stat.S_IMODE(token_file.stat().st_mode) == 0o600
assert stat.S_IMODE(token_file.parent.stat().st_mode) == 0o700
assert list(token_file.parent.glob(".tmp-*")) == []
def test_save_token_failure_mid_write_preserves_existing_token(self, isolated_home):
def test_a_failed_write_preserves_the_existing_token(self, isolated_home, secret_vault_factory, monkeypatch):
_write_home_json(isolated_home, "token.json", {"key": "sk-original", "timestamp": 1234567890})
token_file = isolated_home / ".litellm" / "token.json"
def _explode(*args, **kwargs):
raise TypeError("not serialisable")
monkeypatch.setattr("litellm.litellm_core_utils.private_json.json.dump", _explode)
with pytest.raises(TypeError):
save_token({"key": object()})
save_cli_token(CliTokenRecord(key="sk-new"), vault=secret_vault_factory(available=False))
assert json.loads(token_file.read_text()) == {"key": "sk-original", "timestamp": 1234567890}
assert list(token_file.parent.glob(".tmp-*")) == []
class TestKeychainBackedCommands:
"""End-to-end through the `lite` commands: the secret lives in the keychain, the file keeps
only metadata, and every command still reads and writes through that split."""
def setup_method(self):
self.runner = CliRunner()
def _login(self, vault, base_url="https://test.example.com"):
poll_response = Mock()
poll_response.status_code = 200
poll_response.json.return_value = {
"status": "ready",
"key": "sk-minted",
"user_id": "test-user-123",
"team_id": "team-1",
"teams": ["team-1"],
}
with (
patch("webbrowser.open"),
patch("requests.post", return_value=_mock_cli_sso_start_response()),
patch("requests.get", return_value=poll_response),
patch("litellm.proxy.client.cli.interface.show_commands"),
):
return self.runner.invoke(login, obj={"base_url": base_url, "secret_vault": vault})
def test_login_puts_the_secret_in_the_keychain_and_not_in_the_file(self, isolated_home, secret_vault_factory):
vault = secret_vault_factory()
result = self._login(vault)
token_file = isolated_home / ".litellm" / "token.json"
assert result.exit_code == 0
assert "Credential stored in your OS keychain." in result.output
assert json.loads(vault.blob)["key"] == "sk-minted"
assert "sk-minted" not in token_file.read_text()
assert json.loads(token_file.read_text())["user_id"] == "test-user-123"
def test_login_without_a_keychain_says_where_the_credential_went(self, isolated_home, secret_vault_factory):
result = self._login(secret_vault_factory(available=False))
token_file = isolated_home / ".litellm" / "token.json"
assert result.exit_code == 0
assert "No OS keychain available" in result.output
assert str(token_file) in result.output
assert json.loads(token_file.read_text())["key"] == "sk-minted"
def test_whoami_and_print_token_read_through_the_keychain(self, isolated_home, secret_vault_factory):
vault = secret_vault_factory()
self._login(vault)
obj = {"base_url": "https://test.example.com", "secret_vault": vault}
whoami_result = self.runner.invoke(whoami, obj=obj)
print_result = self.runner.invoke(print_token, obj=obj)
assert "Authenticated" in whoami_result.output
assert "test-user-123" in whoami_result.output
assert print_result.exit_code == 0
assert print_result.stdout.strip() == "sk-minted"
def test_logout_clears_the_keychain_as_well_as_the_file(self, isolated_home, secret_vault_factory):
vault = secret_vault_factory()
self._login(vault)
result = self.runner.invoke(logout, obj={"base_url": "https://test.example.com", "secret_vault": vault})
assert result.exit_code == 0
assert "Logged out successfully" in result.output
assert vault.blob is None
assert not (isolated_home / ".litellm" / "token.json").exists()
def test_logout_warns_when_the_keychain_will_not_release_the_secret(self, isolated_home, secret_vault_factory):
"""Silently reporting success would leave a live credential in the keychain."""
vault = secret_vault_factory(erasable=False)
self._login(vault)
result = self.runner.invoke(logout, obj={"base_url": "https://test.example.com", "secret_vault": vault})
assert result.exit_code == 0
assert "could not be removed" in result.output
assert not (isolated_home / ".litellm" / "token.json").exists()
def test_print_token_explains_a_locked_keychain_instead_of_printing_nothing(
self, isolated_home, secret_vault_factory
):
_write_home_json(
isolated_home,
"token.json",
{"base_url": "https://test.example.com", "user_id": "u-1", "timestamp": time.time()},
)
obj = {"base_url": "https://test.example.com", "secret_vault": secret_vault_factory(available=False)}
result = self.runner.invoke(print_token, obj=obj)
assert result.exit_code == 1
assert KEYCHAIN_UNREACHABLE_MESSAGE in result.output
def test_whoami_flags_a_locked_keychain(self, isolated_home, secret_vault_factory):
_write_home_json(
isolated_home,
"token.json",
{"base_url": "https://test.example.com", "user_id": "u-1", "timestamp": time.time()},
)
obj = {"base_url": "https://test.example.com", "secret_vault": secret_vault_factory(available=False)}
result = self.runner.invoke(whoami, obj=obj)
assert "Authenticated" in result.output
assert KEYCHAIN_UNREACHABLE_MESSAGE in result.output
class TestApiKeyPrecedence:
"""`LITELLM_PROXY_API_KEY` and `--api-key` outrank the stored credential; moving the secret
into the keychain must not disturb that order."""
def _resolved_key(self, args, obj=None):
with patch("litellm.proxy.client.cli.main.print_version") as mock_print_version:
result = CliRunner().invoke(cli, [*args, "version"], obj=obj)
assert result.exit_code == 0, result.output
return mock_print_version.call_args[0][1]
def test_the_stored_credential_is_the_fallback(self, isolated_home):
_write_home_json(
isolated_home,
"token.json",
{"base_url": "http://localhost:4000", "key": "sk-stored", "timestamp": time.time()},
)
assert self._resolved_key([]) == "sk-stored"
def test_the_stored_credential_is_read_through_the_injected_keychain(self, isolated_home, secret_vault_factory):
"""The vault handed to the CLI through ctx.obj must be the one the group callback reads,
so a keychain-held secret resolves without ever touching the host OS keychain."""
_write_home_json(isolated_home, "token.json", {"base_url": "http://localhost:4000", "timestamp": time.time()})
vault = secret_vault_factory(_secret_blob("http://localhost:4000", "sk-keychain"))
assert self._resolved_key([], obj={"secret_vault": vault}) == "sk-keychain"
def test_env_var_beats_the_stored_credential(self, isolated_home, monkeypatch):
_write_home_json(
isolated_home,
"token.json",
{"base_url": "http://localhost:4000", "key": "sk-stored", "timestamp": time.time()},
)
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-from-env")
assert self._resolved_key([]) == "sk-from-env"
def test_explicit_api_key_beats_both(self, isolated_home, monkeypatch):
_write_home_json(
isolated_home,
"token.json",
{"base_url": "http://localhost:4000", "key": "sk-stored", "timestamp": time.time()},
)
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-from-env")
assert self._resolved_key(["--api-key", "sk-explicit"]) == "sk-explicit"
class TestLoginConfigClaude:
"""`lite login --config-claude` wiring into ~/.claude/settings.json"""
@ -1054,7 +1065,7 @@ class TestLoginConfigClaude:
patch("webbrowser.open"),
patch("requests.post", return_value=_mock_cli_sso_start_response()),
patch("requests.get", return_value=poll_response),
patch("litellm.proxy.client.cli.commands.auth.save_token"),
patch("litellm.proxy.client.cli.commands.auth.save_cli_token"),
patch("litellm.proxy.client.cli.interface.show_commands"),
patch("litellm.proxy.client.cli.commands.auth.CLAUDE_SETTINGS_PATH", settings_path),
patch(

View file

@ -7,6 +7,7 @@ from unittest.mock import patch
import pytest
from click.testing import CliRunner
from litellm.litellm_core_utils.cli_token_utils import CliTokenRecord
from litellm.proxy.client.cli import cli
from litellm.proxy.client.cli.commands.claude_settings import (
AUTOROUTE_BACKUP_PATH,
@ -181,18 +182,18 @@ class TestApiKeyHelperIsActuallyInvocable:
assert result.exit_code != 2
def test_the_generated_command_reaches_print_token(self):
with patch(f"{AUTH_MODULE}.load_token", return_value=None):
with patch(f"{AUTH_MODULE}.load_cli_token", return_value=None):
result = CliRunner().invoke(cli, self._helper_args("http://localhost:4000"))
assert "Not authenticated" in result.output
def test_the_generated_command_carries_the_base_url_through(self):
stale = {
"base_url": "http://other-proxy.example.com",
"key": "sk-stale",
"timestamp": time.time(),
}
with patch(f"{AUTH_MODULE}.load_token", return_value=stale):
stale = CliTokenRecord(
base_url="http://other-proxy.example.com",
key="sk-stale",
timestamp=time.time(),
)
with patch(f"{AUTH_MODULE}.load_cli_token", return_value=stale):
result = CliRunner().invoke(cli, self._helper_args("http://localhost:4000"))
assert "Not authenticated for this server" in result.output

View file

@ -18,7 +18,7 @@ from litellm.proxy.client.cli.commands.config import (
load_config,
save_config,
)
from litellm.proxy.client.cli.commands.private_json import write_private_json
from litellm.litellm_core_utils.private_json import write_private_json
from litellm.proxy.client.cli.interface import show_commands
@ -355,7 +355,7 @@ class TestWritePrivateJson:
def _interrupt(*args: object, **kwargs: object) -> None:
raise KeyboardInterrupt()
monkeypatch.setattr("litellm.proxy.client.cli.commands.private_json.json.dump", _interrupt)
monkeypatch.setattr("litellm.litellm_core_utils.private_json.json.dump", _interrupt)
target = tmp_path / "config.json"
with pytest.raises(KeyboardInterrupt):

View file

@ -8,6 +8,7 @@ import click
import pytest
from click.testing import CliRunner
from litellm.litellm_core_utils.cli_token_utils import CliTokenRecord
from litellm.proxy.client.cli.commands import up as up_module
from litellm.proxy.client.cli.commands.agents import AgentRunError
from litellm.proxy.client.cli.commands.claude_settings import ClaudeSettingsError
@ -220,13 +221,17 @@ def _make_ctx(base_url):
return click.Context(click.Command("test"), obj={"base_url": base_url})
def _token(key, base_url):
return CliTokenRecord(key=key, base_url=base_url)
class TestEnsureFreshLogin:
"""A token that is fresh but was issued for a *different* proxy must not be trusted: without
this check, a user logged into proxy A who runs `up --base-url proxy-b` would silently get an
apiKeyHelper wired up around proxy A's real token, which print-token would then hand to proxy B."""
def test_reuses_a_fresh_token_issued_for_the_same_proxy(self, monkeypatch):
monkeypatch.setattr(up_module, "load_token", lambda: {"key": "sk-a", "base_url": "http://proxy-a:4000"})
monkeypatch.setattr(up_module, "load_cli_token", lambda **_: _token("sk-a", "http://proxy-a:4000"))
monkeypatch.setattr(up_module, "is_cli_token_fresh", lambda token_data: True)
login_calls = []
monkeypatch.setattr(up_module, "login", lambda ctx: login_calls.append(ctx))
@ -239,11 +244,11 @@ class TestEnsureFreshLogin:
monkeypatch.setattr(up_module.sys.stdin, "isatty", lambda: True)
tokens = iter(
[
{"key": "sk-a", "base_url": "http://proxy-a:4000"},
{"key": "sk-b", "base_url": "http://proxy-b:4000"},
_token("sk-a", "http://proxy-a:4000"),
_token("sk-b", "http://proxy-b:4000"),
]
)
monkeypatch.setattr(up_module, "load_token", lambda: next(tokens))
monkeypatch.setattr(up_module, "load_cli_token", lambda **_: next(tokens))
monkeypatch.setattr(up_module, "is_cli_token_fresh", lambda token_data: True)
login_calls = []
@ -259,7 +264,7 @@ class TestEnsureFreshLogin:
def test_fails_cleanly_non_interactively_when_only_a_different_proxys_token_is_cached(self, monkeypatch):
monkeypatch.setattr(up_module.sys.stdin, "isatty", lambda: False)
monkeypatch.setattr(up_module, "load_token", lambda: {"key": "sk-a", "base_url": "http://proxy-a:4000"})
monkeypatch.setattr(up_module, "load_cli_token", lambda **_: _token("sk-a", "http://proxy-a:4000"))
monkeypatch.setattr(up_module, "is_cli_token_fresh", lambda token_data: True)
with pytest.raises(UpError, match="lite login"):
@ -276,7 +281,7 @@ class TestUpCommand:
backup_path.write_text(json.dumps(existing_backup))
with (
patch(f"{UP_MODULE}.load_token", return_value={"key": "sk-fresh", "base_url": "http://localhost:4000"}),
patch(f"{UP_MODULE}.load_cli_token", return_value=_token("sk-fresh", "http://localhost:4000")),
patch(f"{UP_MODULE}.is_cli_token_fresh", return_value=True),
patch(f"{UP_MODULE}.resolve_api_key", return_value="sk-fresh"),
patch(f"{UP_MODULE}.verify_proxy_key"),
@ -293,7 +298,7 @@ class TestUpCommand:
_patch_paths(monkeypatch, tmp_path)
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
with patch(f"{UP_MODULE}.load_token", return_value=None):
with patch(f"{UP_MODULE}.load_cli_token", return_value=None):
result = self.runner.invoke(up, obj={"base_url": "http://localhost:4000"})
assert result.exit_code != 0
@ -303,7 +308,7 @@ class TestUpCommand:
_patch_paths(monkeypatch, tmp_path)
with (
patch(f"{UP_MODULE}.load_token", return_value={"key": "sk-fresh", "base_url": "http://localhost:4000"}),
patch(f"{UP_MODULE}.load_cli_token", return_value=_token("sk-fresh", "http://localhost:4000")),
patch(f"{UP_MODULE}.is_cli_token_fresh", return_value=True),
patch(f"{UP_MODULE}.resolve_api_key", return_value="sk-fresh"),
patch(
@ -329,7 +334,7 @@ class TestUpCommand:
return True
with (
patch(f"{UP_MODULE}.load_token", return_value={"key": "sk-fresh", "base_url": "http://localhost:4000"}),
patch(f"{UP_MODULE}.load_cli_token", return_value=_token("sk-fresh", "http://localhost:4000")),
patch(f"{UP_MODULE}.is_cli_token_fresh", return_value=True),
patch(f"{UP_MODULE}.resolve_api_key", return_value="sk-fresh"),
patch(f"{UP_MODULE}.verify_proxy_key"),

View file

@ -1,9 +1,9 @@
{
"LIT001": {
"limit": 22806
"limit": 22805
},
"LIT002": {
"limit": 26878
"limit": 26877
},
"LIT003": {
"limit": 269
@ -27,12 +27,12 @@
"limit": 0
},
"LIT010": {
"limit": 16695
"limit": 16693
},
"LIT011": {
"limit": 5588
},
"LIT012": {
"limit": 4519
"limit": 4511
}
}

100
uv.lock generated
View file

@ -10,7 +10,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-08-16T00:41:08.185444Z"
exclude-newer = "2026-08-17T01:06:38.502388Z"
exclude-newer-span = "P3D"
[manifest]
@ -710,6 +710,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]]
name = "backports-tarfile"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" },
]
[[package]]
name = "basedpyright"
version = "1.39.7"
@ -3538,6 +3547,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
]
[[package]]
name = "jaraco-context"
version = "6.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-tarfile", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" },
]
[[package]]
name = "jaraco-functools"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" },
]
[[package]]
name = "jeepney"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
@ -3764,6 +3818,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "keyring"
version = "25.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata", marker = "python_full_version < '3.12'" },
{ name = "jaraco-classes" },
{ name = "jaraco-context" },
{ name = "jaraco-functools" },
{ name = "jeepney", marker = "sys_platform == 'linux'" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
[[package]]
name = "kiwisolver"
version = "1.5.0"
@ -4222,6 +4294,7 @@ caching = [
]
cli = [
{ name = "inquirerpy" },
{ name = "keyring" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "rich" },
@ -4352,6 +4425,7 @@ dev = [
{ name = "diff-cover" },
{ name = "fakeredis" },
{ name = "fastapi-offline" },
{ name = "keyring" },
{ name = "langfuse" },
{ name = "openapi-core" },
{ name = "opentelemetry-api" },
@ -4446,6 +4520,7 @@ requires-dist = [
{ name = "inquirerpy", marker = "extra == 'proxy'", specifier = ">=0.3.4,<1.0" },
{ name = "jinja2", specifier = ">=3.1.6,<4.0" },
{ name = "jsonschema", specifier = ">=4.0.0,<5.0" },
{ name = "keyring", marker = "extra == 'cli'", specifier = ">=25.6.0,<26.0" },
{ name = "langfuse", marker = "extra == 'proxy-runtime'", specifier = ">=2.59.7,<3.0" },
{ name = "litellm-enterprise", marker = "extra == 'proxy'", editable = "enterprise" },
{ name = "litellm-proxy-extras", marker = "extra == 'proxy'", editable = "litellm-proxy-extras" },
@ -4532,6 +4607,7 @@ dev = [
{ name = "diff-cover", specifier = "==9.7.2" },
{ name = "fakeredis", specifier = "==2.34.1" },
{ name = "fastapi-offline", specifier = "==1.7.6" },
{ name = "keyring", specifier = "==25.7.0" },
{ name = "langfuse", specifier = "==2.59.7" },
{ name = "openapi-core", specifier = "==0.22.0" },
{ name = "opentelemetry-api", specifier = "==1.28.0" },
@ -7790,6 +7866,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@ -8632,6 +8717,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
]
[[package]]
name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
[[package]]
name = "semantic-router"
version = "0.1.15"