mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
commit
8672cd4df4
25 changed files with 2862 additions and 520 deletions
8
Makefile
8
Makefile
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
231
litellm/litellm_core_utils/cli_keyring.py
Normal file
231
litellm/litellm_core_utils/cli_keyring.py
Normal 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()
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
70
litellm/litellm_core_utils/private_json.py
Normal file
70
litellm/litellm_core_utils/private_json.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,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))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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`.")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
37
tests/test_litellm/litellm_core_utils/test_private_json.py
Normal file
37
tests/test_litellm/litellm_core_utils/test_private_json.py
Normal 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
|
||||
|
|
@ -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
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"}, {})
|
||||
|
|
|
|||
|
|
@ -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
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