Merge pull request #37566 from BerriAI/litellm_cli_refresh_tokens

feat(cli): store the lite login credential in the OS keychain
This commit is contained in:
Mateo Wang 2026-08-20 11:10:28 -07:00 • committed by GitHub
commit 8672cd4df4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2862 additions and 520 deletions

View file

@ -144,11 +144,13 @@ lint-install:
$(UV) sync --inexact --frozen --group proxy-dev --group e2e-dev
$(UV_RUN) python scripts/prisma_generate_if_needed.py
# Diff-scoped format check, identical to test-linting.yml's "Check ruff format" step:
# Diff-scoped format check, mirroring test-linting.yml's "Check ruff format" step:
# only the litellm Python files changed vs the base are checked, so a pre-existing
# format issue elsewhere doesn't block an unrelated commit.
# format issue elsewhere doesn't block an unrelated commit. Git pathspecs match
# recursively, so 'litellm/*.py' covers nested modules and the top-level files that
# CI's 'litellm/**/*.py' skips, which makes this target a superset of the CI step.
lint-format-check-changed: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
@files=$$(git diff --name-only origin/litellm_internal_staging...HEAD -- 'litellm/**/*.py' | grep -v '^litellm/enterprise/' || true); \
@files=$$(git diff --name-only --diff-filter=ACMR origin/litellm_internal_staging...HEAD -- 'litellm/*.py' | grep -v '^litellm/enterprise/' || true); \
if [ -z "$$files" ]; then \
echo "No changed litellm Python files to format-check."; \
else \

View file

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

View file

@ -0,0 +1,231 @@
"""
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, naming which of the
ways the keychain can be out of reach applies, so callers can degrade to the
token file and tell the user what to do about it.
A write is only reported as stored once it has been read back, because keyring's
null backend, which `keyring --disable` and headless CI images both select,
accepts every write and keeps nothing. Writes are also pre-flighted with a
throwaway value, because a keychain can answer neither way and block forever.
"""
import os
import threading
from contextlib import suppress
from dataclasses import dataclass, field
from typing import Final, Protocol, TypeAlias
KEYRING_SERVICE: Final = "litellm-cli"
KEYRING_ACCOUNT: Final = "credential"
KEYRING_PREFLIGHT_ACCOUNT: Final = "credential-preflight"
DISABLE_KEYRING_ENV_VAR: Final = "LITELLM_CLI_DISABLE_KEYRING"
_DISABLED_VALUES: Final = frozenset(("1", "true", "yes", "on"))
_PREFLIGHT_VALUE: Final = "preflight"
_PREFLIGHT_TIMEOUT_SECONDS: Final = 5.0
@dataclass(frozen=True, slots=True)
class SecretFound:
blob: str
@dataclass(frozen=True, slots=True)
class SecretMissing:
pass
@dataclass(frozen=True, slots=True)
class SecretStored:
pass
@dataclass(frozen=True, slots=True)
class SecretErased:
pass
@dataclass(frozen=True, slots=True)
class SecretStranded:
pass
@dataclass(frozen=True, slots=True)
class KeyringNotInstalled:
pass
@dataclass(frozen=True, slots=True)
class KeyringDisabled:
pass
@dataclass(frozen=True, slots=True)
class KeyringUnreachable:
pass
@dataclass(frozen=True, slots=True)
class KeyringDiscardsWrites:
pass
KeyringUnusable: TypeAlias = KeyringNotInstalled | KeyringDisabled | KeyringUnreachable
SecretRead: TypeAlias = SecretFound | SecretMissing | KeyringUnusable
SecretWrite: TypeAlias = SecretStored | KeyringUnusable | KeyringDiscardsWrites
SecretErase: TypeAlias = SecretErased | SecretStranded | KeyringUnusable
class SecretVault(Protocol):
"""The single slot holding the CLI credential's secret material."""
def read(self) -> SecretRead: ...
def write(self, blob: str) -> SecretWrite: ...
def erase(self) -> SecretErase: ...
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 | KeyringNotInstalled | KeyringDisabled:
if _keyring_disabled():
return KeyringDisabled()
api: Final = _import_keyring()
return KeyringNotInstalled() if api is None else api
def _answers_a_write(api: KeyringApi, timeout_seconds: float) -> bool:
"""Whether the keychain answers a write at all, asked with a value worth nothing.
macOS derives the login keychain from `$HOME`, and `set_password` against a HOME with no usable
one blocks forever with no timeout of its own. Containers, CI images, `sudo -H`, and service
accounts all run there, and reads answer normally, so nothing cheaper tells them apart. Asking
with a throwaway value keeps a keychain that never answers from taking `lite login` down with
it, and keeps the real credential out of a store that might accept it long after we gave up.
A keychain that refuses the probe outright still answered it, so only silence counts against it.
"""
answered: Final = threading.Event()
def ask() -> None:
with suppress(Exception):
api.set_password(KEYRING_SERVICE, KEYRING_PREFLIGHT_ACCOUNT, _PREFLIGHT_VALUE)
answered.set()
threading.Thread(target=ask, daemon=True, name="litellm-cli-keyring-preflight").start()
return answered.wait(timeout_seconds)
def _forget_the_preflight(api: KeyringApi) -> None:
"""Take the throwaway probe back out.
A backend that kept nothing has nothing to remove, and the probe is worth nothing either way,
so a keychain that refuses to give it up costs the caller nothing.
"""
with suppress(Exception):
api.delete_password(KEYRING_SERVICE, KEYRING_PREFLIGHT_ACCOUNT)
@dataclass(frozen=True, slots=True)
class KeyringVault:
"""The OS keychain, reached through the optional `keyring` package.
A keychain that let the pre-flight time out is not asked anything else for the rest of the
process. The probe that timed out is still sitting in the keychain on a thread of its own, and
it holds the keychain against every later call, so the read after it would block on the main
thread with no timeout to save it. One silence is answer enough.
"""
preflight_timeout_seconds: float = _PREFLIGHT_TIMEOUT_SECONDS
stopped_answering: threading.Event = field(default_factory=threading.Event, compare=False, repr=False)
def read(self) -> SecretRead:
if self.stopped_answering.is_set():
return KeyringUnreachable()
api: Final = _keyring_api()
if isinstance(api, (KeyringNotInstalled, KeyringDisabled)):
return api
try:
blob: Final = api.get_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
except Exception: # noqa: BLE001 # backends raise outside keyring.errors; never break the SDK
return KeyringUnreachable()
return SecretMissing() if blob is None else SecretFound(blob)
def write(self, blob: str) -> SecretWrite:
"""Store the secret, reporting stored only once the keychain hands the same bytes back.
A backend that accepts writes and keeps nothing, which is exactly what `keyring --disable`
and `PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring` select, raises nothing to
distinguish itself. Reading the value back is the only way to tell it apart from a keychain
that really stored the credential, and the caller is about to drop its own copy on our word.
The keychain is pre-flighted first, because one that blocks rather than answering would
otherwise hang `lite login` outright.
"""
if self.stopped_answering.is_set():
return KeyringUnreachable()
api: Final = _keyring_api()
if isinstance(api, (KeyringNotInstalled, KeyringDisabled)):
return api
if not _answers_a_write(api, self.preflight_timeout_seconds):
self.stopped_answering.set()
return KeyringUnreachable()
_forget_the_preflight(api)
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 KeyringUnreachable()
return SecretStored() if self.read() == SecretFound(blob) else KeyringDiscardsWrites()
def erase(self) -> SecretErase:
"""Remove our entry, reporting whether the keychain is guaranteed to be free of it.
A keychain out of reach is never an erasure: the entry belongs to the OS, not to this
install, so it outlives an uninstalled `keyring` package and a kill switch set after login.
Those cases are reported apart from a confirmed entry that would not delete, because only
the caller knows whether this machine ever put a secret in a keychain.
"""
match self.read():
case KeyringNotInstalled() | KeyringDisabled() | KeyringUnreachable() as unusable:
return unusable
case SecretMissing():
return SecretErased()
case SecretFound():
return self._delete()
def _delete(self) -> SecretErase:
api: Final = _keyring_api()
if isinstance(api, (KeyringNotInstalled, KeyringDisabled)):
return api
try:
api.delete_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
except Exception: # noqa: BLE001 # report the failure as a value so `lite logout` can warn
return SecretStranded()
return SecretErased()
SYSTEM_KEYRING: Final[SecretVault] = KeyringVault()

View file

@ -1,16 +1,125 @@
"""
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 math
import time
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Final
from types import MappingProxyType
from typing import Final, TypeAlias
from pydantic import BaseModel, ConfigDict, ValidationError
from litellm.litellm_core_utils.cli_keyring import (
SYSTEM_KEYRING,
KeyringDisabled,
KeyringNotInstalled,
KeyringUnreachable,
SecretErase,
SecretErased,
SecretFound,
SecretMissing,
SecretStored,
SecretStranded,
SecretVault,
SecretWrite,
)
from litellm.litellm_core_utils.private_json import (
commit_staged_json,
discard_staged_json,
ensure_private_dir,
overwrite_private_json,
stage_private_json,
write_private_json,
)
@dataclass(frozen=True, slots=True)
class CredentialNotSaved:
"""The credential was minted but no store would keep it, so this machine has none.
Nothing was touched on the way to this, so a login that already worked still does.
"""
detail: str
@dataclass(frozen=True, slots=True)
class CredentialNotRecorded:
"""The keychain took the credential, but the file that names it could not be replaced.
The keychain holds one entry, so the secret that was there is already gone and no rollback
brings it back. Removing the new one as well would only turn a login this machine may still
be able to use into no login at all, so it stays, and the user is told what is where.
"""
@dataclass(frozen=True, slots=True)
class CredentialNotCleared:
"""The token file still holds the secret, because it could not be removed or rewritten.
Logging out of the keychain is only half of it. A `~/.litellm` that refuses both the scrubbed
rewrite and the removal leaves the credential readable on disk, which is the one thing a logout
is for, so it is reported instead of being counted as a clean sweep.
"""
detail: str
SecretSave: TypeAlias = SecretWrite | CredentialNotSaved | CredentialNotRecorded
SecretClear: TypeAlias = SecretErase | CredentialNotCleared
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
expires_at: float | None = None
refresh_token: str | None = None
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. `timestamp` is the sign-in this
secret came from, which is what decides it against a secret still on disk.
"""
model_config = ConfigDict(frozen=True)
base_url: str
key: str
jwt_token: str = ""
timestamp: float = 0.0
CLI_TOKEN_FRESHNESS_BUFFER_SECONDS: Final = 360
@ -22,26 +131,183 @@ 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)
def save_cli_token(record: CliTokenRecord, *, vault: SecretVault = SYSTEM_KEYRING) -> SecretSave:
"""Store a freshly minted credential. Reports where its secret material ended up, and why.
The token file is what makes a keychain-backed credential findable again, and it is also the
half that a read-only or full directory refuses, so it is staged before the keychain is handed
anything. A save that cannot land then leaves both stores exactly as it found them, which
matters most when the login it failed to replace is still perfectly good.
Staging can still succeed and the replacement fail afterwards. That is the one case where the
keychain has already taken the new secret, and it reports itself as such rather than claiming
the previous login survived.
"""
stamped: Final = _stamped_past_every_stored_login(record, vault)
staged: Final = _stage_token_file(_without_secret(stamped))
if isinstance(staged, CredentialNotSaved):
return staged
outcome: Final = SecretStored() if stamped.key is None else vault.write(_encode_secret(stamped, stamped.key))
if isinstance(outcome, SecretStored):
return outcome if _commit_token_file(staged) else CredentialNotRecorded()
discard_staged_json(staged)
return _keep_the_secret_in_the_file(stamped, outcome)
def _stamped_past_every_stored_login(record: CliTokenRecord, vault: SecretVault) -> CliTokenRecord:
"""Keep a sign-in's stamp ahead of every login already stored, whatever the clock did in between.
The stamp is what decides a keychain secret against one still on disk, so a clock that stepped
backwards between two logins would hand the older of them the win and put a superseded
credential back in use. Pinning the new stamp just past the highest one either store holds costs
one read each and changes nothing on a clock that only moves forwards.
"""
highest: Final = _highest_stamp_already_stored(record.base_url, vault)
if highest < record.timestamp:
return record
return record.model_copy(update=MappingProxyType({"timestamp": math.nextafter(highest, math.inf)}))
def _highest_stamp_already_stored(base_url: str, vault: SecretVault) -> float:
"""When the latest login either store still holds was made, or minus infinity when neither has one.
Both are asked because the file names the login being replaced only while the two agree. A login
the keychain took but the file could not record afterwards leaves the keychain holding the later
of the two, and reading only the file would stamp the next sign-in below it.
"""
previous: Final = _read_token_file()
secret: Final = _stored_secret(base_url, vault)
return max(
-math.inf if previous is None else previous.timestamp,
-math.inf if secret is None else secret.timestamp,
)
def _stored_secret(base_url: str, vault: SecretVault) -> CliTokenSecret | None:
"""The keychain's secret for this server, when it holds one this login may be compared against"""
match vault.read():
case SecretFound(blob=blob):
return _decode_secret(blob, base_url)
case SecretMissing() | KeyringNotInstalled() | KeyringDisabled() | KeyringUnreachable():
return None
def _keep_the_secret_in_the_file(record: CliTokenRecord, outcome: SecretWrite) -> SecretSave:
"""Fall back to the owner-only file, which is all that is left when no keychain took the secret"""
try:
with open(token_file, "r") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
_write_token_file(record)
except OSError as error:
return CredentialNotSaved(str(error))
return outcome
def clear_cli_token(*, vault: SecretVault = SYSTEM_KEYRING) -> SecretClear:
"""Remove the credential from both stores. Reports whether the keychain is now free of it.
A logout the keychain never answered keeps the token file, with its secret taken out, because
that file is the only remaining record that something may still be in there to remove. It is
what lets a later run tell a machine with a credential it cannot reach apart from one that never
had a login at all, and taking it away would leave the next logout answering the warning this
one just issued with a false all-clear. The secret goes either way, and a file that will give up
neither its copy nor itself is removed rather than kept, with the note written again afterwards
so the warning still outlives this run.
"""
outcome: Final = vault.erase()
record: Final = _read_token_file()
settled: Final = _nothing_left_behind(outcome, record)
if not settled and _keep_the_unchecked_keychain_on_record(outcome, record):
return outcome
removal: Final = _remove_token_file()
if removal is not None and record is not None and not _scrub_file_secret(record):
return removal
if removal is None and record is not None and _the_keychain_went_unchecked(outcome):
_write_the_note_the_removal_took_with_it(record)
return SecretErased() if settled else outcome
def _remove_token_file() -> CredentialNotCleared | None:
try:
Path(get_cli_token_file_path()).unlink(missing_ok=True)
except OSError as error:
return CredentialNotCleared(str(error))
return None
def _write_the_note_the_removal_took_with_it(record: CliTokenRecord) -> None:
"""Put the secret-free note back after the file carrying it had to go to get the secret off disk.
Reaching here means neither rewrite would take, so the file went instead, and its absence is
what the next logout would read as a keychain already known to be clean. Removing it is also
what frees the room the rewrite was refused for, so the note usually lands on this second try.
When it does not, the warning this logout printed is the only one the user gets.
"""
staged: Final = _stage_scrubbed_file(record)
if staged is not None:
_commit_token_file(staged)
def _keep_the_unchecked_keychain_on_record(outcome: SecretErase, record: CliTokenRecord | None) -> bool:
"""Whether the token file, stripped of its secret, is worth keeping as the note that says so.
Only a keychain that could not be reached leaves the question open. One that answered for itself
is remembered without any help from the file, and a file it can still pair a live entry with
would leave the machine signed in to the login that was just ended. A copy that will give up
its secret neither to a staged replacement nor to an overwrite is not kept either, because the
secret goes first.
"""
if record is None or not _the_keychain_went_unchecked(outcome):
return False
return _scrub_file_secret(record)
def _the_keychain_went_unchecked(outcome: SecretErase) -> bool:
"""Whether the keychain neither confirmed the erase nor answered that it still holds the secret"""
match outcome:
case SecretErased() | SecretStranded():
return False
case KeyringDisabled() | KeyringNotInstalled() | KeyringUnreachable():
return True
def _nothing_left_behind(outcome: SecretErase, record: CliTokenRecord | None) -> bool:
"""Whether the keychain can be trusted to hold no credential of ours once the file is gone.
A machine with no token file has no stored login to end, and `clear_cli_token` keeps one behind
whenever the keychain is left unconfirmed, taking the secret out in place when it cannot stage a
replacement and writing the note again when the file holding it had to go, so a missing file is
real evidence rather than the absence of it. Past that, a
keychain that could not be reached is never trusted, whatever the file looks like. Even a file
holding its own secret says only that the login which wrote it had no keychain to write to, and
the login before it may well have had one: the entry that login left outlives both the
uninstalled package and the file that replaced it. `SecretStranded` is
the keychain answering for itself and outranks the file.
"""
match outcome:
case SecretErased():
return True
case SecretStranded():
return False
case KeyringDisabled() | KeyringNotInstalled() | KeyringUnreachable():
return record is None
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:
@ -49,6 +315,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
@ -64,30 +331,182 @@ 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 = CLI_TOKEN_FRESHNESS_BUFFER_SECONDS / 3600
token_data: CliTokenRecord | Mapping[str, object],
buffer_hours: float = CLI_TOKEN_FRESHNESS_BUFFER_SECONDS / 3600,
) -> 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`."""
"""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`. A `--pkce`
credential carries its own `expires_at`, which is authoritative when present."""
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
expires_at: Final = token_data.get("expires_at")
expires_at: Final = (
token_data.expires_at if isinstance(token_data, CliTokenRecord) else token_data.get("expires_at")
)
if isinstance(expires_at, (int, float)):
return time.time() < expires_at - buffer_hours * 3600
timestamp: Final = token_data.get("timestamp")
timestamp: Final = token_data.timestamp if isinstance(token_data, CliTokenRecord) else token_data.get("timestamp")
if not isinstance(timestamp, (int, float)):
return False
age_hours: Final = (time.time() - 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, ValueError):
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 KeyringNotInstalled() | KeyringDisabled() | KeyringUnreachable():
return record
def _apply_vault_secret(record: CliTokenRecord, blob: str, vault: SecretVault) -> CliTokenRecord | None:
"""Resolve the credential when both stores hold one.
The sign-in each secret came from decides it, because either store can be the stale one. A
secret is usually left on disk by a keychain that would not take it, which makes the file the
fresher of the two. It is the older one when a login the keychain did take could not replace
the file afterwards, and serving that one would put a superseded credential back in use. Equal
stamps are one login sitting in both stores, left by a migration whose scrub was refused, so
that branch retries the migration rather than trading one credential for another.
A scrub the file refuses leaves that superseded secret where it lies, which is the state the
login already named when it could not replace the file, and which `lite logout` reports rather
than counting as a clean sweep. Rolling the vault back the way a migration does is not the
answer here, because the two stores hold different credentials and the rollback would hand the
superseded one back out.
"""
secret: Final = _decode_secret(blob, record.base_url)
if secret is None or (record.key is not None and secret.timestamp <= record.timestamp):
return _migrate_file_secret(record, vault)
_scrub_file_secret(record)
return record.model_copy(
update=MappingProxyType(
{
"key": secret.key,
"jwt_token": secret.jwt_token,
"timestamp": max(secret.timestamp, record.timestamp),
}
)
)
def _migrate_file_secret(record: CliTokenRecord, vault: SecretVault) -> CliTokenRecord | None:
"""Move a file-held secret into the vault, but only once the file's copy can be taken away.
The scrubbed file is staged first so a directory that will not accept it stops the migration
before the keychain is handed anything. Copying the credential into a second store and only
then discovering the first one cannot be cleaned would widen exposure instead of narrowing it,
which is the opposite of what moving it into the keychain is for.
A staged file that will not go into place is overwritten where it lies before the keychain is
asked to take the new entry back, so the migration finishes on a directory that would only ever
have refused it. Rolling back is the last resort, and a rollback the keychain also refuses
leaves the secret in both stores until the next read, which retries this same migration.
"""
if record.key is None:
return None
staged: Final = _stage_scrubbed_file(record)
if staged is None:
return record
if not isinstance(vault.write(_encode_secret(record, record.key)), SecretStored):
discard_staged_json(staged)
return record
if not _commit_token_file(staged) and not _overwrite_file_secret(record):
vault.erase()
return record
def _scrub_file_secret(record: CliTokenRecord) -> bool:
"""Leave no secret material in the token file once the vault holds it"""
if record.key is None and not record.jwt_token:
return True
staged: Final = _stage_scrubbed_file(record)
if staged is not None and _commit_token_file(staged):
return True
return _overwrite_file_secret(record)
def _overwrite_file_secret(record: CliTokenRecord) -> bool:
"""Take the secret out of the token file where it lies, when no replacement can be put in place.
The atomic rewrite wants room for a second file and a directory that will accept it. A full disk
refuses the first and a read-only `~/.litellm` the second, and neither stands in the way of
shortening the file that is already there. It is worth the loss of atomicity because a partial
write reads as no login at all, which is where the refused rewrite left the next run anyway.
"""
try:
overwrite_private_json(get_cli_token_file_path(), _without_secret(record).model_dump(exclude_none=True))
except OSError:
return False
return True
def _stage_scrubbed_file(record: CliTokenRecord) -> str | None:
staged: Final = _stage_token_file(_without_secret(record))
return None if isinstance(staged, CredentialNotSaved) else staged
def _stage_token_file(record: CliTokenRecord) -> str | CredentialNotSaved:
path: Final = Path(get_cli_token_file_path())
try:
ensure_private_dir(path.parent)
return stage_private_json(str(path), record.model_dump(exclude_none=True))
except OSError as error:
return CredentialNotSaved(str(error))
def _commit_token_file(staged: str) -> bool:
try:
commit_staged_json(staged, get_cli_token_file_path())
except OSError:
return False
return True
def _without_secret(record: CliTokenRecord) -> CliTokenRecord:
return record.model_copy(update=MappingProxyType({"key": None, "jwt_token": ""}))
def _encode_secret(record: CliTokenRecord, key: str) -> str:
return CliTokenSecret(
base_url=record.base_url, key=key, jwt_token=record.jwt_token, timestamp=record.timestamp
).model_dump_json()
def _decode_secret(blob: str, base_url: str) -> CliTokenSecret | None:
"""The keychain entry, when it is one this metadata file may be paired with"""
try:
secret: Final = CliTokenSecret.model_validate_json(blob)
except ValidationError:
return None
return secret if secret.base_url == base_url else None
def _write_token_file(record: CliTokenRecord) -> None:
path: Final = Path(get_cli_token_file_path())
ensure_private_dir(path.parent)
write_private_json(str(path), record.model_dump(exclude_none=True))

View file

@ -0,0 +1,70 @@
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 stage_private_json(path: str, data: Mapping[str, object]) -> str:
"""Write JSON to a private temp file beside `path`, ready for `commit_staged_json`.
Staging is the half that can fail on a read-only or full directory, so callers with something
to lose can find that out before they act on the assumption that the rewrite will land.
"""
parent: Final = Path(path).parent
parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=str(parent), prefix=".tmp-", suffix=".json")
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2)
f.flush()
os.fsync(f.fileno())
except BaseException:
Path(tmp_path).unlink(missing_ok=True)
raise
return tmp_path
def commit_staged_json(staged: str, path: str) -> None:
"""Move a staged file into place, replacing whatever is there in one step"""
try:
os.replace(staged, path)
except OSError:
Path(staged).unlink(missing_ok=True)
raise
def overwrite_private_json(path: str, data: Mapping[str, object]) -> None:
"""Rewrite a file that is already there, in place, keeping the mode it was created with.
`write_private_json` needs room for a second file and a directory that will accept it, which is
what a full disk and a read-only `~/.litellm` respectively refuse. Shortening the file already
in place needs neither. It is not atomic, so an interrupted write leaves a partial file, and it
never creates one, so it cannot put a world-readable file where a private one was.
"""
fd: Final = os.open(path, os.O_WRONLY | os.O_TRUNC)
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2)
f.flush()
os.fsync(f.fileno())
def discard_staged_json(staged: str) -> None:
"""Throw a staged file away when the change it was part of is abandoned"""
Path(staged).unlink(missing_ok=True)
def write_private_json(path: str, data: Mapping[str, object]) -> None:
"""Atomically write JSON to path with owner-only permissions (0600)"""
commit_staged_json(stage_private_json(path, data), path)

View file

@ -331,7 +331,7 @@ sequenceDiagram
CLI->>Proxy: Poll /sso/cli/poll/login_id with poll_secret header
Proxy->>CLI: Return {"status": "ready", "key": "jwt"}
CLI->>CLI: Save key to ~/.litellm/token.json
CLI->>CLI: Save key to the OS keychain (metadata to ~/.litellm/token.json)
```
### Authentication Commands
@ -353,7 +353,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
@ -365,11 +365,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",
@ -378,6 +378,10 @@ Authentication tokens are stored in `~/.litellm/token.json` with restricted file
}
```
Keychain storage needs the `keyring` package, which ships with `pip install 'litellm[cli]'`. Headless boxes and CI runners usually have no keychain either. In all of those cases the key stays in the same `0600` file alongside the metadata, exactly as it did before, and `lite login` names which one applies: the package is missing, the machine has no keychain, or you 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. A credential from `lite login --pkce` is the exception: it carries a refresh token, so the CLI renews the key shortly before it expires and `lite logout` revokes the refresh token on the proxy (see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). Only the holder can end a `--pkce` session early, with `lite logout`; an admin has no button for it, but every renewal re-reads the user on the proxy, so deactivating the user or removing them from the team makes the next renewal fail and the key runs out within `LITELLM_CLI_JWT_EXPIRATION_HOURS`. On a proxy with more than one worker or replica, configure Redis (`litellm_settings.cache` with Redis `cache_params`, or `general_settings.coordination_redis`) so a refresh token stays single-use and `lite logout` holds on every worker; without Redis each worker keeps its own record. For a long-lived, rotatable, Keys-UI-visible credential, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY`.
### Usage

View file

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

View file

@ -1,9 +1,7 @@
import json
import os
import sys
import time
import webbrowser
from pathlib import Path
from collections.abc import Callable, Mapping
from typing import Any, Final
from urllib.parse import urlencode
@ -14,7 +12,32 @@ from rich.table import Table
from typing_extensions import NotRequired, ReadOnly, TypedDict, assert_never
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 (
DISABLE_KEYRING_ENV_VAR,
SYSTEM_KEYRING,
KeyringDisabled,
KeyringDiscardsWrites,
KeyringNotInstalled,
KeyringUnreachable,
SecretErased,
SecretFound,
SecretMissing,
SecretStored,
SecretStranded,
SecretVault,
)
from litellm.litellm_core_utils.cli_token_utils import (
CliTokenRecord,
CredentialNotCleared,
CredentialNotRecorded,
CredentialNotSaved,
SecretSave,
clear_cli_token,
get_cli_token_file_path,
is_cli_token_fresh,
load_cli_token,
save_cli_token,
)
from .claude_settings import (
CLAUDE_SETTINGS_PATH,
@ -31,7 +54,6 @@ from .pkce_login import (
revoke_stored_credential,
run_pkce_login,
)
from .private_json import write_private_json
class CliTokenData(TypedDict):
@ -62,6 +84,7 @@ class CliTeam(TypedDict, total=False):
class CliContextObj(TypedDict):
base_url: str
base_url_explicit: NotRequired[bool]
secret_vault: NotRequired[ReadOnly[SecretVault]]
api_key: ReadOnly[NotRequired[str | None]]
api_key_from_token_file: ReadOnly[NotRequired[bool]]
@ -94,51 +117,148 @@ class CliAuthResult(TypedDict):
team_id: str | None
# Token storage utilities
def get_token_file_path() -> str:
"""Get the path to store the authentication token"""
return str(Path.home() / ".litellm" / "token.json")
KEYRING_INSTALL_HINT: Final = "pip install 'litellm[cli]'"
KEYRING_ENABLE_HINT: Final = "keyring --enable (or unset PYTHON_KEYRING_BACKEND)"
STRANDED_CREDENTIAL_MESSAGE: Final = (
"Logged out locally, but your credential is still in the OS keychain and could not be removed."
)
UNCHECKED_KEYCHAIN_MESSAGE: Final = (
"Logged out locally, but your OS keychain could not be checked, so a credential stored there by "
"an earlier login may still be usable."
)
def save_token(token_data: CliTokenData) -> None:
"""Save token data to file"""
write_private_json(get_token_file_path(), token_data)
def storage_notice(outcome: SecretSave) -> str:
"""Tell the user where the credential ended up, and how to get keychain storage if it did not."""
path: Final = get_cli_token_file_path()
match outcome:
case SecretStored():
return "Credential stored in your OS keychain."
case KeyringNotInstalled():
return (
f"Credential stored in {path} (owner-only). "
f"For OS keychain storage, install the keyring package with: {KEYRING_INSTALL_HINT}"
)
case KeyringDisabled():
return f"Keychain storage is off ({DISABLE_KEYRING_ENV_VAR}). Credential stored in {path} (owner-only)."
case KeyringUnreachable():
return f"No OS keychain available. Credential stored in {path} (owner-only)."
case KeyringDiscardsWrites():
return (
f"Your keyring backend keeps nothing it is given, so the credential was stored in {path} "
f"(owner-only) instead. For OS keychain storage, run: {KEYRING_ENABLE_HINT}"
)
case CredentialNotSaved(detail=detail):
return (
f"Signed in, but the credential could not be saved to {path}: {detail}. "
"Any login you already had is untouched. Run 'lite login' again once that path is "
"writable, or 'lite logout' to clear whatever is stored now."
)
case CredentialNotRecorded():
return (
f"Signed in, and the credential is in your OS keychain, but {path} could not be "
"replaced, so it still describes your previous login and may still hold its "
"credential. Run 'lite login' again once that path is writable, or 'lite logout' "
"to clear both."
)
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 keychain_unreadable_notice(vault: SecretVault) -> str:
"""Explain why the secret half of a stored login cannot be produced, and what fixes it"""
match vault.read():
case KeyringNotInstalled():
return (
"Your credential is in your OS keychain, which this install cannot read without the "
f"keyring package. Install it with: {KEYRING_INSTALL_HINT}, or run 'lite login' to start over."
)
case KeyringDisabled():
return (
f"Your credential is in your OS keychain, which {DISABLE_KEYRING_ENV_VAR} is blocking. "
"Unset it, or run 'lite login' to start over."
)
case KeyringUnreachable():
return (
"Your credential is in your OS keychain, which could not be read. Unlock it, or run "
"'lite login' to start over."
)
case SecretFound() | SecretMissing():
return "Your credential could not be read from your OS keychain. Run 'lite login' to start over."
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 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 get_stored_api_key(expected_base_url: str | None = None) -> str | None:
"""Get the stored API key from token file.
def load_token(*, vault: SecretVault = SYSTEM_KEYRING) -> Mapping[str, object] | None:
"""The stored credential as a plain mapping, with the secret resolved out of the vault.
The PKCE renewal and revocation helpers read records by field name, so this is the
shape they get; the keychain split lives underneath, in `load_cli_token`.
"""
record: Final = load_cli_token(vault=vault)
return None if record is None else record.model_dump(exclude_none=True)
def save_token(record: CliTokenData, *, vault: SecretVault = SYSTEM_KEYRING) -> SecretSave:
"""Store a credential the PKCE layer produced, secret in the vault and the rest on disk"""
return save_cli_token(CliTokenRecord(**record), vault=vault)
def _renewal_saver(vault: SecretVault) -> Callable[[CliTokenData], None]:
"""Persist a silently renewed credential, and say on stderr when no store would keep it.
A renewal rotates the refresh token, so a rotation that is never stored logs this
machine out on the next command; the user hears about it rather than guessing.
"""
def save(record: CliTokenData) -> None:
outcome: Final = save_token(record, vault=vault)
if isinstance(outcome, (CredentialNotSaved, CredentialNotRecorded)):
_warn(storage_notice(outcome))
return save
def _renewal_reader(vault: SecretVault) -> Callable[[], Mapping[str, object] | None]:
"""Re-read the record mid-renewal, so a rotation a sibling `lite` process saved is seen"""
def reload() -> Mapping[str, object] | None:
return load_token(vault=vault)
return reload
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. A key obtained by
``lite login --pkce`` is refreshed here once it nears expiry.
"""
token_data: Final = load_token()
token_data: Final = load_token(vault=vault)
if token_data is None:
return None
if expected_base_url is not None and token_data.get("base_url") != expected_base_url.rstrip("/"):
return None
return fresh_api_key(token_data, save_token, requests.Session(), reload=load_token, warn=_warn)
return fresh_api_key(
token_data,
_renewal_saver(vault),
requests.Session(),
reload=_renewal_reader(vault),
warn=_warn,
)
def _warn(message: str) -> None:
@ -672,11 +792,14 @@ def _configure_claude_code(base_url: str) -> None:
click.echo("Your other Claude Code settings were left untouched. Restart Claude Code to pick this up.")
def _finish_login(base_url: str, api_key: str, config_claude: bool) -> None:
def _finish_login(base_url: str, api_key: str, config_claude: bool, stored: SecretSave) -> None:
from litellm.proxy.client.cli.interface import show_commands
click.echo("\nLogin successful!")
click.echo(f"JWT Token: {api_key[:20]}...")
click.echo(storage_notice(stored))
if isinstance(stored, (CredentialNotSaved, CredentialNotRecorded)):
return
click.echo("You can now use the CLI without specifying --api-key")
if config_claude:
_configure_claude_code(base_url)
@ -684,27 +807,28 @@ def _finish_login(base_url: str, api_key: str, config_claude: bool) -> None:
show_commands()
def _replace_stored_token(record: CliTokenData, http: Http) -> None:
previous: Final = load_token()
save_token(record)
if previous is None:
return
def _replace_stored_token(record: CliTokenData, http: Http, vault: SecretVault) -> SecretSave:
previous: Final = load_token(vault=vault)
stored: Final = save_token(record, vault=vault)
if previous is None or isinstance(stored, CredentialNotSaved):
return stored
revocation: Final = revoke_stored_credential(previous, http)
if revocation is not None:
click.echo(
f"Could not revoke the previous login's refresh token on the proxy ({revocation.reason}); "
"it expires on its own."
)
return stored
def _pkce_login(base_url: str, config_claude: bool) -> None:
def _pkce_login(base_url: str, config_claude: bool, vault: SecretVault) -> None:
http: Final = requests.Session()
credential: Final = run_pkce_login(base_url, http, echo=click.echo)
if isinstance(credential, PkceFailure):
click.echo(f"Authentication failed: {credential.reason}")
return
_replace_stored_token(pkce_token_record(base_url, credential), http)
_finish_login(base_url, credential.access_token, config_claude)
stored: Final = _replace_stored_token(pkce_token_record(base_url, credential), http, vault)
_finish_login(base_url, credential.access_token, config_claude, stored)
@click.command(name="login")
@ -737,7 +861,7 @@ def login(ctx: click.Context, config_claude: bool, pkce: bool) -> None:
try:
if pkce:
_pkce_login(base_url, config_claude)
_pkce_login(base_url, config_claude, context_secret_vault(ctx))
return
cli_sso_flow: Final = _start_cli_sso_flow(base_url=base_url)
key_id: Final = cli_sso_flow["login_id"]
@ -765,7 +889,7 @@ def login(ctx: click.Context, config_claude: bool, pkce: bool) -> None:
# Save token data. base_url is stored so we can verify origin
# before reusing the key on a subsequent CLI invocation.
_replace_stored_token(
stored: Final = _replace_stored_token(
{
"base_url": base_url.rstrip("/"),
"key": api_key,
@ -777,9 +901,10 @@ def login(ctx: click.Context, config_claude: bool, pkce: bool) -> None:
"timestamp": time.time(),
},
requests.Session(),
context_secret_vault(ctx),
)
_finish_login(base_url, api_key, config_claude)
_finish_login(base_url, api_key, config_claude, stored)
return
else:
click.echo("Authentication timed out. Please try again.")
@ -802,23 +927,44 @@ def login(ctx: click.Context, config_claude: bool, pkce: bool) -> None:
@click.command(name="logout")
def logout():
@click.pass_context
def logout(ctx: click.Context):
"""Logout and clear stored authentication"""
token_data: Final = load_token()
vault: Final = context_secret_vault(ctx)
token_data: Final = load_token(vault=vault)
revocation: Final = revoke_stored_credential(token_data, requests.Session()) if token_data is not None else None
match revocation:
case RevocationUnavailable(reason=reason):
raise click.ClickException(
f"The proxy could not record the revocation ({reason}). Nothing was cleared; run `lite logout` again shortly."
f"The proxy could not record the revocation ({reason}). Nothing was cleared; "
"run `lite logout` again shortly."
)
case PkceFailure(reason=reason):
clear_token()
click.echo(f"Could not revoke the refresh token on the proxy ({reason}); it expires on its own.")
case None:
clear_token()
pass
case _:
assert_never(revocation)
click.echo("Logged out successfully. Authentication token cleared.")
path: Final = get_cli_token_file_path()
match clear_cli_token(vault=vault):
case SecretErased():
click.echo("Logged out successfully. Authentication token cleared.")
case CredentialNotCleared(detail=detail):
click.echo(f"Your credential is still in {path}, which could not be removed: {detail}.")
click.echo("Delete that file, or make the directory writable and run 'lite logout' again.")
case SecretStranded():
click.echo(STRANDED_CREDENTIAL_MESSAGE)
click.echo("Unlock your keychain and run 'lite logout' again to clear it.")
case KeyringNotInstalled():
click.echo(UNCHECKED_KEYCHAIN_MESSAGE)
click.echo(f"Install the keyring package with: {KEYRING_INSTALL_HINT}, then run 'lite logout' again.")
case KeyringDisabled():
click.echo(UNCHECKED_KEYCHAIN_MESSAGE)
click.echo(f"Unset {DISABLE_KEYRING_ENV_VAR} and run 'lite logout' again to clear it.")
case KeyringUnreachable():
click.echo(UNCHECKED_KEYCHAIN_MESSAGE)
click.echo("Unlock your keychain and run 'lite logout' again to clear it.")
@click.command(name="print-token")
@ -833,7 +979,8 @@ def print_token(ctx: click.Context):
`lite login --pkce` token renews itself here first, and once a token
has expired for good, run the same `lite login` command again.
"""
token_data: Final = load_token()
vault: Final = context_secret_vault(ctx)
token_data: Final = load_token(vault=vault)
if not token_data:
click.echo("Not authenticated. Run 'lite login'.", err=True)
sys.exit(1)
@ -853,10 +1000,20 @@ def print_token(ctx: click.Context):
click.echo("Token expired. Run 'lite login' again.", err=True)
sys.exit(1)
if token_data.get("key") is None:
click.echo(keychain_unreadable_notice(vault), err=True)
sys.exit(1)
api_key: Final = (
ctx_obj.get("api_key")
if issued_for_this_server and ctx_obj.get("api_key_from_token_file")
else fresh_api_key(token_data, save_token, requests.Session(), reload=load_token, warn=_warn)
else fresh_api_key(
token_data,
_renewal_saver(vault),
requests.Session(),
reload=_renewal_reader(vault),
warn=_warn,
)
)
if not api_key:
click.echo(f"Key expired. Run '{_login_command(renews)}' again.", err=True)
@ -866,26 +1023,32 @@ def print_token(ctx: click.Context):
@click.command(name="whoami")
def whoami():
@click.pass_context
def whoami(ctx: click.Context):
"""Show current authentication status"""
token_data: Final = load_token()
vault: Final = context_secret_vault(ctx)
token_data: Final = load_token(vault=vault)
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')}")
key_readable: Final = token_data.get("key") is not None
click.echo("Authenticated" if key_readable else "Signed in, but the credential cannot be read")
click.echo(f"User Email: {token_data.get('user_email') or 'Unknown'}")
click.echo(f"User ID: {token_data.get('user_id') or 'Unknown'}")
click.echo(f"User Role: {token_data.get('user_role') or 'Unknown'}")
team_id: Final = token_data.get("team_id")
if team_id:
click.echo(f"Team ID: {team_id}")
timestamp: Final = token_data.get("timestamp", 0)
age_hours: Final = (time.time() - timestamp) / 3600
stamped: Final = token_data.get("timestamp")
age_hours: Final = (time.time() - (stamped if isinstance(stamped, (int, float)) else 0.0)) / 3600
click.echo(f"Token age: {age_hours:.1f} hours")
if not key_readable:
click.echo(keychain_unreadable_notice(vault))
expires_at: Final = token_data.get("expires_at")
if isinstance(expires_at, (int, float)):
click.echo(_key_expiry_line(expires_at, renews="refresh_token" in token_data))

View file

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

View file

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

View file

@ -1,21 +0,0 @@
import json
import os
import tempfile
from collections.abc import Mapping
from pathlib import Path
from typing import Final
def write_private_json(path: str, data: Mapping[str, object]) -> None:
"""Atomically write JSON to path with owner-only permissions (0600)"""
parent: Final = Path(path).parent
parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=str(parent), prefix=".tmp-", suffix=".json")
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
finally:
Path(tmp_path).unlink(missing_ok=True)

View file

@ -14,10 +14,12 @@ from typing import IO, Final
import click
from pydantic import JsonValue, TypeAdapter, ValidationError
from litellm.litellm_core_utils.cli_keyring import SecretVault
from litellm.litellm_core_utils.cli_token_utils import is_cli_token_fresh
from litellm.litellm_core_utils.private_json import ensure_private_dir
from .agents import AgentRunError, resolve_api_key, verify_proxy_key
from .auth import CliContextObj, get_stored_api_key, load_token, login
from .auth import CliContextObj, context_secret_vault, get_stored_api_key, load_token, 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,31 +105,32 @@ def restore_claude_settings(settings_path: Path | None = None, backup_path: Path
return record
def _usable_login(api_key: str | None) -> bool:
def _usable_login(api_key: str | None, vault: SecretVault) -> bool:
if api_key is None:
return False
token_data: Final = load_token()
token_data: Final = load_token(vault=vault)
return token_data is not None and is_cli_token_fresh(token_data)
def _key_resolved_on_the_way_in(ctx_obj: CliContextObj, base_url: str) -> str | None:
def _key_resolved_on_the_way_in(ctx_obj: CliContextObj, base_url: str, vault: SecretVault) -> str | None:
if ctx_obj.get("api_key_from_token_file"):
return ctx_obj.get("api_key")
return get_stored_api_key(expected_base_url=base_url)
return get_stored_api_key(expected_base_url=base_url, vault=vault)
def _stored_login_is_pkce() -> bool:
token_data: Final = load_token()
return token_data is not None and "refresh_token" in token_data
def _stored_login_is_pkce(vault: SecretVault) -> bool:
token_data: Final = load_token(vault=vault)
return token_data is not None and token_data.get("refresh_token") is not None
def _ensure_fresh_login(ctx: click.Context) -> None:
ctx_obj: Final[CliContextObj] = ctx.obj
base_url: Final = ctx_obj["base_url"].rstrip("/")
if _usable_login(_key_resolved_on_the_way_in(ctx_obj, base_url)):
vault: Final = context_secret_vault(ctx)
if _usable_login(_key_resolved_on_the_way_in(ctx_obj, base_url, vault), vault):
return
pkce: Final = _stored_login_is_pkce()
pkce: Final = _stored_login_is_pkce(vault)
login_command: Final = "lite login --pkce" if pkce else "lite login"
if not sys.stdin.isatty():
raise UpError(
@ -137,7 +140,7 @@ def _ensure_fresh_login(ctx: click.Context) -> None:
click.echo("No fresh LiteLLM login found for this proxy; starting login...")
ctx.invoke(login, pkce=pkce)
if not _usable_login(get_stored_api_key(expected_base_url=base_url)):
if not _usable_login(get_stored_api_key(expected_base_url=base_url, vault=vault), vault):
raise UpError("Login did not produce a usable token; cannot start `lite up`.")

View file

@ -9,7 +9,7 @@ from litellm._version import version as litellm_version
from litellm.proxy.client.health import HealthManagementClient
from .commands.agents import agent_commands
from .commands.auth import auth_group, get_stored_api_key, login, logout, whoami
from .commands.auth import auth_group, context_secret_vault, get_stored_api_key, login, logout, whoami
from .commands.autoroute.commands import autoroute_group
from .commands.chat import chat
from .commands.config import config_commands, get_config_value, hidden_command_names
@ -94,7 +94,11 @@ def cli(ctx: click.Context, show_version: bool, base_url: str | None, api_key: s
# If no API key provided via flag or environment variable, try to load from saved token.
# Pass base_url so we only use the stored key when it was issued for this server.
api_key_from_token_file: Final = api_key is None
resolved_api_key: Final = get_stored_api_key(expected_base_url=base_url) if api_key_from_token_file else api_key
resolved_api_key: Final = (
get_stored_api_key(expected_base_url=base_url, vault=context_secret_vault(ctx))
if api_key_from_token_file
else api_key
)
ctx.obj["base_url"] = base_url
ctx.obj["api_key"] = resolved_api_key

View file

@ -78,13 +78,16 @@ proxy = [
"expression>=5.6.0,<6.0",
]
# Thin client install for the `lite` CLI on developer laptops. The CLI's heavy
# imports (fastapi, cryptography, ...) are all guarded, so it runs on the base
# SDK plus just these four; none of the server runtime in `proxy` is pulled in.
# imports are all guarded, so it runs on the base SDK plus just these five, and
# none of the server runtime in `proxy` is pulled in. On Linux,
# keyring reaches the Secret Service through secretstorage, which brings
# cryptography with it.
cli = [
"rich>=13.9.4,<14.0",
"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 +169,7 @@ litellm-proxy = "litellm.proxy.client.cli:cli"
dev = [
"diff-cover==9.7.2",
"basedpyright==1.39.7",
"keyring==25.7.0",
"pytest==9.0.3",
"pytest-mock==3.15.1",
"pytest-asyncio==1.3.0",

View file

@ -11,7 +11,7 @@ import sys
import traceback
from collections.abc import Callable
EXTRAS_ONLY_MODULES = ("fastapi", "uvicorn")
EXTRAS_ONLY_MODULES = ("fastapi", "uvicorn", "keyring")
def _require(condition: bool, message: str) -> None:

View file

@ -22,6 +22,19 @@ 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 (
KeyringDiscardsWrites,
KeyringUnreachable,
KeyringUnusable,
SecretErase,
SecretErased,
SecretFound,
SecretMissing,
SecretRead,
SecretStored,
SecretStranded,
SecretWrite,
)
from litellm.litellm_core_utils.prompt_templates import (
image_handling as image_handling_module,
)
@ -106,6 +119,75 @@ 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, `erasable=False` one that will not release what it already holds, and `failure`
picks which unusable state those report. `discards=True` is keyring's null backend, which answers
reads and erases like any other yet keeps nothing it is given, so only writes report it.
"""
def __init__(
self,
blob: str | None = None,
*,
available: bool = True,
writable: bool = True,
erasable: bool = True,
discards: bool = False,
failure: KeyringUnusable = KeyringUnreachable(),
) -> None:
self.blob: str | None = blob
self.available: bool = available
self.writable: bool = writable
self.erasable: bool = erasable
self.discards: bool = discards
self.failure: KeyringUnusable = failure
self.reads: int = 0
self.writes: list[str] = []
self.erases: int = 0
def read(self) -> SecretRead:
self.reads += 1
if not self.available:
return self.failure
return SecretMissing() if self.blob is None else SecretFound(self.blob)
def write(self, blob: str) -> SecretWrite:
self.writes.append(blob)
if not (self.available and self.writable):
return self.failure
if self.discards:
return KeyringDiscardsWrites()
self.blob = blob
return SecretStored()
def erase(self) -> SecretErase:
self.erases += 1
if not self.available:
return self.failure
if not self.erasable:
return SecretStranded() if self.blob is not None else SecretErased()
self.blob = None
return SecretErased()
@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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,37 @@
import json
import os
import stat
import pytest
from litellm.litellm_core_utils.private_json import overwrite_private_json, write_private_json
class TestOverwritePrivateJson:
def test_replaces_the_contents_of_the_file_already_there(self, tmp_path):
path = tmp_path / "token.json"
write_private_json(str(path), {"key": "sk-" + "a" * 700})
overwrite_private_json(str(path), {"user_id": "u-1"})
assert json.loads(path.read_text()) == {"user_id": "u-1"}
def test_refuses_to_create_the_file_it_was_asked_to_rewrite(self, tmp_path):
"""This is the one writer that does not go through a private temp file, so a path it creates
would land with whatever the umask allows. Refusing keeps it unable to put a world-readable
file where the caller believed a private one already was."""
path = tmp_path / "token.json"
with pytest.raises(FileNotFoundError):
overwrite_private_json(str(path), {"user_id": "u-1"})
assert not path.exists()
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores file permissions")
def test_keeps_the_owner_only_mode_the_file_was_created_with(self, tmp_path):
path = tmp_path / "token.json"
write_private_json(str(path), {"key": "sk-live"})
overwrite_private_json(str(path), {"user_id": "u-1"})
assert stat.S_IMODE(path.stat().st_mode) == 0o600

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -246,10 +246,10 @@ class _FakeTokenStore:
self.keys_by_base_url = keys_by_base_url
self.rotated_to = rotated_to
self.key_requests = []
monkeypatch.setattr(up_module, "load_token", lambda: self.record)
monkeypatch.setattr(up_module, "load_token", lambda **_: self.record)
monkeypatch.setattr(up_module, "get_stored_api_key", self.get_stored_api_key)
def get_stored_api_key(self, expected_base_url=None):
def get_stored_api_key(self, expected_base_url=None, **_):
self.key_requests.append(expected_base_url)
key = self.keys_by_base_url.get(expected_base_url)
if key is not None and self.rotated_to is not None:
@ -302,6 +302,19 @@ class TestEnsureFreshLogin:
assert login_calls == [("http://proxy-b:4000", False)]
assert store.key_requests == ["http://proxy-b:4000", "http://proxy-b:4000"]
def test_forces_a_fresh_login_when_the_cached_token_has_no_readable_key(self, monkeypatch):
monkeypatch.setattr(up_module.sys.stdin, "isatty", lambda: True)
store = _FakeTokenStore(monkeypatch, {"base_url": "http://proxy-a:4000"}, {})
monkeypatch.setattr(up_module, "is_cli_token_fresh", lambda token_data: True)
login_calls = _capture_login(
monkeypatch,
on_login=lambda: store.log_in({"key": "sk-a", "base_url": "http://proxy-a:4000"}, "sk-a"),
)
_ensure_fresh_login(_make_ctx("http://proxy-a:4000"))
assert login_calls == [("http://proxy-a:4000", False)]
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)
_FakeTokenStore(monkeypatch, {"key": "sk-a", "base_url": "http://proxy-a:4000"}, {})

View file

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

100
uv.lock generated
View file

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