mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
eecb226762
commit
0de829d3e4
22 changed files with 1295 additions and 511 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
125
litellm/litellm_core_utils/cli_keyring.py
Normal file
125
litellm/litellm_core_utils/cli_keyring.py
Normal 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()
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)"""
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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`.")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
100
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue