mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_complexity_router_keyword_tiers
# Conflicts: # ui/litellm-dashboard/eslint-metrics.json
This commit is contained in:
commit
973a86adab
14 changed files with 226 additions and 213 deletions
2
.github/workflows/test-litellm-ui-lint.yml
vendored
2
.github/workflows/test-litellm-ui-lint.yml
vendored
|
|
@ -85,7 +85,7 @@ jobs:
|
|||
if: ${{ !cancelled() && steps.changed.outputs.has_files == 'true' }}
|
||||
run: |
|
||||
npx eslint . -f json -o "$RUNNER_TEMP/lint-report.json" || true
|
||||
node scripts/check-lint-budgets.mjs "$RUNNER_TEMP/lint-report.json" eslint-budgets.json --check eslint-metrics.json
|
||||
node scripts/check-lint-budgets.mjs "$RUNNER_TEMP/lint-report.json" eslint-budgets.json
|
||||
|
||||
- name: Check for dead code (knip)
|
||||
if: ${{ !cancelled() && steps.changed.outputs.has_files == 'true' }}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ This module has no dependencies on proxy code and can be safely imported at the
|
|||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -68,3 +69,17 @@ def get_litellm_gateway_api_key(
|
|||
if stored_url != expected_base_url.rstrip("/"):
|
||||
return None
|
||||
return token_data["key"]
|
||||
|
||||
|
||||
def is_cli_token_fresh(token_data: dict, buffer_hours: float = 0.1) -> bool:
|
||||
"""Check whether a cached CLI token (as stored in token.json) is still
|
||||
within its expiration window. Used by `lite auth print-token` to fail
|
||||
fast, without a network round trip, once the cached token is past
|
||||
`LITELLM_CLI_JWT_EXPIRATION_HOURS`."""
|
||||
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
|
||||
|
||||
timestamp = token_data.get("timestamp")
|
||||
if not isinstance(timestamp, (int, float)):
|
||||
return False
|
||||
age_hours = (time.time() - timestamp) / 3600
|
||||
return age_hours < (CLI_JWT_EXPIRATION_HOURS - buffer_hours)
|
||||
|
|
|
|||
|
|
@ -336,11 +336,12 @@ sequenceDiagram
|
|||
|
||||
### Authentication Commands
|
||||
|
||||
The CLI provides three authentication commands:
|
||||
The CLI provides these authentication commands:
|
||||
|
||||
- **`lite login`** - Start SSO authentication flow
|
||||
- **`lite logout`** - Clear stored authentication token
|
||||
- **`lite whoami`** - Show current authentication status
|
||||
- **`lite auth print-token`** - Print the cached token (used as Claude Code's `apiKeyHelper`); fails once the token has expired
|
||||
|
||||
### Authentication Flow Steps
|
||||
|
||||
|
|
@ -376,7 +377,7 @@ Authentication tokens are stored in `~/.litellm/token.json` with restricted file
|
|||
}
|
||||
```
|
||||
|
||||
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. It is accepted on a default deployment without `EXPERIMENTAL_UI_LOGIN`, does not appear in the Keys UI, and cannot be rotated or revoked mid-session. For a long-lived, rotatable, Keys-UI-visible credential, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY`.
|
||||
The stored credential is a short-lived, per-session agent token, not a managed virtual key. It is scoped to the user and team you logged in as and inherits their models and budgets; spend is tracked against the shared team and user budgets rather than a separate per-session cap, so multiple logins or several concurrent agents all draw down the same allowance. It is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); re-run `lite login` to refresh it and pick up your latest team and user settings. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while fresh and fails once it expires -- there is no silent renewal. It is accepted on a default deployment without `EXPERIMENTAL_UI_LOGIN`, does not appear in the Keys UI, and cannot be rotated or revoked mid-session. For a long-lived, rotatable, Keys-UI-visible credential, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY`.
|
||||
|
||||
### Usage
|
||||
|
||||
|
|
|
|||
|
|
@ -469,7 +469,7 @@ To pin the model, pass the agent's own model flag (for example `lite claude --mo
|
|||
|
||||
The token minted by `lite login` is a short-lived, per-session agent credential, not a managed virtual key. It is scoped to the user and team you authenticated as, inherits that user's and team's models and budgets, and is enforced on the proxy exactly like a virtual key on the same team (guardrails, routing, logging, spend). Spend is tracked against the shared team and user budgets, so running several agents (or logging in more than once) does not hand each session its own separate budget; they all draw down the same team/user allowance. There is no separate per-session cap, so sustained agent use is not capped at a small chat-session limit.
|
||||
|
||||
The credential is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); run `lite login` again to refresh it, which also re-reads your latest team and user settings. It does not appear in the Keys UI and cannot be rotated or revoked mid-session. `lite claude`, `lite codex`, and `lite opencode` work with it on a default deployment; `EXPERIMENTAL_UI_LOGIN` is not required. If you need a long-lived, rotatable key that shows up in the Keys UI, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY` instead.
|
||||
The credential is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); run `lite login` again to refresh it, which also re-reads your latest team and user settings. It does not appear in the Keys UI and cannot be rotated or revoked mid-session. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while it's still fresh and fails once it expires -- there is no silent renewal, so a long-running session needs a fresh `lite login` once a day. `lite claude`, `lite codex`, and `lite opencode` work with it on a default deployment; `EXPERIMENTAL_UI_LOGIN` is not required. If you need a long-lived, rotatable key that shows up in the Keys UI, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY` instead.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from rich.console import Console
|
|||
from rich.table import Table
|
||||
|
||||
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
|
||||
from litellm.litellm_core_utils.cli_token_utils import is_cli_token_fresh
|
||||
|
||||
|
||||
# Token storage utilities
|
||||
|
|
@ -593,6 +594,44 @@ def logout():
|
|||
click.echo("✅ Logged out successfully. Authentication token cleared.")
|
||||
|
||||
|
||||
@click.command(name="print-token")
|
||||
@click.pass_context
|
||||
def print_token(ctx: click.Context):
|
||||
"""Print a valid API token for this proxy.
|
||||
|
||||
Designed to be used as Claude Code's `apiKeyHelper`
|
||||
(https://docs.claude.com/en/docs/claude-code/settings): stdout must
|
||||
contain only the token, so all diagnostics go to stderr. The token
|
||||
expires after `LITELLM_CLI_JWT_EXPIRATION_HOURS` (default 24h); once
|
||||
expired, run `lite login` again.
|
||||
"""
|
||||
token_data = load_token()
|
||||
if not token_data:
|
||||
click.echo("Not authenticated. Run 'lite login'.", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# apiKeyHelper is invoked bare (no --base-url), so unless the caller
|
||||
# explicitly pointed us at a server, trust whichever one `lite login`
|
||||
# actually issued this token for -- that's the whole point of not
|
||||
# needing a wrapper command.
|
||||
if ctx.obj.get("base_url_explicit"):
|
||||
base_url = ctx.obj["base_url"]
|
||||
if token_data.get("base_url") != base_url.rstrip("/"):
|
||||
click.echo("Not authenticated for this server. Run 'lite login'.", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
if not is_cli_token_fresh(token_data):
|
||||
click.echo("Token expired. Run 'lite login' again.", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
api_key = token_data.get("key")
|
||||
if not api_key:
|
||||
click.echo("No token available. Run 'lite login'.", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
click.echo(api_key)
|
||||
|
||||
|
||||
@click.command(name="whoami")
|
||||
def whoami():
|
||||
"""Show current authentication status"""
|
||||
|
|
@ -616,8 +655,16 @@ def whoami():
|
|||
click.echo(f"⚠️ Warning: Token is more than {CLI_JWT_EXPIRATION_HOURS} hours old and may have expired.")
|
||||
|
||||
|
||||
@click.group(name="auth")
|
||||
def auth_group():
|
||||
"""Manage CLI authentication (apiKeyHelper support, etc.)"""
|
||||
|
||||
|
||||
auth_group.add_command(print_token)
|
||||
|
||||
|
||||
# Export functions for use by other CLI commands
|
||||
__all__ = ["login", "logout", "whoami", "prompt_team_selection"]
|
||||
__all__ = ["login", "logout", "print_token", "auth_group", "whoami", "prompt_team_selection"]
|
||||
|
||||
# Export individual commands instead of grouping them
|
||||
# login, logout, and whoami will be added as top-level commands
|
||||
|
|
|
|||
|
|
@ -8,7 +8,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 get_stored_api_key, login, logout, whoami
|
||||
from .commands.auth import auth_group, get_stored_api_key, login, logout, whoami
|
||||
from .commands.chat import chat
|
||||
from .commands.credentials import credentials
|
||||
from .commands.encryption import encryption
|
||||
|
|
@ -87,6 +87,12 @@ def cli(ctx: click.Context, base_url: str, api_key: Optional[str]) -> None:
|
|||
|
||||
ctx.obj["base_url"] = base_url
|
||||
ctx.obj["api_key"] = api_key
|
||||
# `--base-url` defaults to localhost:4000 for local dev convenience, but
|
||||
# apiKeyHelper is invoked bare (no flags) -- commands that must work
|
||||
# unattended (print-token) need to tell "user didn't say" apart from
|
||||
# "user said localhost:4000 on purpose" so they can fall back to
|
||||
# whatever server the stored token was actually issued for.
|
||||
ctx.obj["base_url_explicit"] = ctx.get_parameter_source("base_url") != click.core.ParameterSource.DEFAULT
|
||||
|
||||
# If no subcommand was invoked, start interactive mode
|
||||
if ctx.invoked_subcommand is None:
|
||||
|
|
@ -104,6 +110,8 @@ def version(ctx: click.Context):
|
|||
cli.add_command(login)
|
||||
cli.add_command(logout)
|
||||
cli.add_command(whoami)
|
||||
# Add the auth command group (e.g. `lite auth print-token`, used as Claude Code's apiKeyHelper)
|
||||
cli.add_command(auth_group, name="auth")
|
||||
# Add the models command group
|
||||
cli.add_command(models)
|
||||
# Add the credentials command group
|
||||
|
|
|
|||
|
|
@ -77,19 +77,12 @@ EOF
|
|||
npx eslint --no-warn-ignored --pass-on-unpruned-suppressions "${eslint_rel[@]}" || rc=1
|
||||
fi
|
||||
# Whole-folder lint budgets, exactly as the frontend-lint job runs them: the
|
||||
# counts and the committed metrics file are not diff-scoped, so a local pass
|
||||
# here means the budget step will pass in CI too. Unlike CI (which --checks and
|
||||
# fails), regenerate eslint-metrics.json from the same report — same as the
|
||||
# gen:api block below regenerates schema.d.ts — then flag drift so you re-stage
|
||||
# it, instead of making you run npm run lint:metrics by hand.
|
||||
# counts are not diff-scoped, so a local pass here means the budget step will
|
||||
# pass in CI too.
|
||||
report=$(mktemp)
|
||||
npx eslint . -f json -o "$report" || true
|
||||
node scripts/check-lint-budgets.mjs "$report" eslint-budgets.json --write eslint-metrics.json || rc=1
|
||||
node scripts/check-lint-budgets.mjs "$report" eslint-budgets.json || rc=1
|
||||
rm -f "$report"
|
||||
if ! git diff --quiet -- eslint-metrics.json; then
|
||||
echo "✗ eslint-metrics.json was stale; regenerated it. Stage it and re-run make pre-commit." >&2
|
||||
rc=1
|
||||
fi
|
||||
exit $rc
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@ import time
|
|||
from pathlib import Path
|
||||
from unittest.mock import Mock, mock_open, patch
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../..")
|
||||
) # Adds the parent directory to the system path
|
||||
sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system path
|
||||
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
|
||||
from litellm.proxy.client.cli.commands.auth import (
|
||||
clear_token,
|
||||
get_stored_api_key,
|
||||
|
|
@ -19,6 +18,7 @@ from litellm.proxy.client.cli.commands.auth import (
|
|||
load_token,
|
||||
login,
|
||||
logout,
|
||||
print_token,
|
||||
save_token,
|
||||
whoami,
|
||||
)
|
||||
|
|
@ -78,12 +78,9 @@ class TestTokenUtilities:
|
|||
|
||||
with (
|
||||
patch("builtins.open", mock_open()) as mock_file,
|
||||
patch(
|
||||
"litellm.proxy.client.cli.commands.auth.get_token_file_path"
|
||||
) as mock_path,
|
||||
patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path,
|
||||
patch("os.chmod") as mock_chmod,
|
||||
):
|
||||
|
||||
mock_path.return_value = "/test/path/token.json"
|
||||
|
||||
save_token(token_data)
|
||||
|
|
@ -93,9 +90,7 @@ class TestTokenUtilities:
|
|||
mock_chmod.assert_called_once_with("/test/path/token.json", 0o600)
|
||||
|
||||
# Verify JSON content was written correctly
|
||||
written_content = "".join(
|
||||
call[0][0] for call in mock_file().write.call_args_list
|
||||
)
|
||||
written_content = "".join(call[0][0] for call in mock_file().write.call_args_list)
|
||||
parsed_content = json.loads(written_content)
|
||||
assert parsed_content == token_data
|
||||
|
||||
|
|
@ -109,12 +104,9 @@ class TestTokenUtilities:
|
|||
|
||||
with (
|
||||
patch("builtins.open", mock_open(read_data=json.dumps(token_data))),
|
||||
patch(
|
||||
"litellm.proxy.client.cli.commands.auth.get_token_file_path"
|
||||
) as mock_path,
|
||||
patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path,
|
||||
patch("os.path.exists", return_value=True),
|
||||
):
|
||||
|
||||
mock_path.return_value = "/test/path/token.json"
|
||||
|
||||
result = load_token()
|
||||
|
|
@ -124,12 +116,9 @@ class TestTokenUtilities:
|
|||
def test_load_token_file_not_exists(self):
|
||||
"""Test loading token when file doesn't exist"""
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.client.cli.commands.auth.get_token_file_path"
|
||||
) as mock_path,
|
||||
patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path,
|
||||
patch("os.path.exists", return_value=False),
|
||||
):
|
||||
|
||||
mock_path.return_value = "/test/path/token.json"
|
||||
|
||||
result = load_token()
|
||||
|
|
@ -140,12 +129,9 @@ class TestTokenUtilities:
|
|||
"""Test loading token with invalid JSON"""
|
||||
with (
|
||||
patch("builtins.open", mock_open(read_data="invalid json")),
|
||||
patch(
|
||||
"litellm.proxy.client.cli.commands.auth.get_token_file_path"
|
||||
) as mock_path,
|
||||
patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path,
|
||||
patch("os.path.exists", return_value=True),
|
||||
):
|
||||
|
||||
mock_path.return_value = "/test/path/token.json"
|
||||
|
||||
result = load_token()
|
||||
|
|
@ -156,12 +142,9 @@ class TestTokenUtilities:
|
|||
"""Test loading token with IO error"""
|
||||
with (
|
||||
patch("builtins.open", side_effect=IOError("Permission denied")),
|
||||
patch(
|
||||
"litellm.proxy.client.cli.commands.auth.get_token_file_path"
|
||||
) as mock_path,
|
||||
patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path,
|
||||
patch("os.path.exists", return_value=True),
|
||||
):
|
||||
|
||||
mock_path.return_value = "/test/path/token.json"
|
||||
|
||||
result = load_token()
|
||||
|
|
@ -171,13 +154,10 @@ class TestTokenUtilities:
|
|||
def test_clear_token_file_exists(self):
|
||||
"""Test clearing token when file exists"""
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.client.cli.commands.auth.get_token_file_path"
|
||||
) as mock_path,
|
||||
patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path,
|
||||
patch("os.path.exists", return_value=True),
|
||||
patch("os.remove") as mock_remove,
|
||||
):
|
||||
|
||||
mock_path.return_value = "/test/path/token.json"
|
||||
|
||||
clear_token()
|
||||
|
|
@ -187,13 +167,10 @@ class TestTokenUtilities:
|
|||
def test_clear_token_file_not_exists(self):
|
||||
"""Test clearing token when file doesn't exist"""
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.client.cli.commands.auth.get_token_file_path"
|
||||
) as mock_path,
|
||||
patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path,
|
||||
patch("os.path.exists", return_value=False),
|
||||
patch("os.remove") as mock_remove,
|
||||
):
|
||||
|
||||
mock_path.return_value = "/test/path/token.json"
|
||||
|
||||
clear_token()
|
||||
|
|
@ -238,10 +215,7 @@ class TestTokenUtilities:
|
|||
"litellm.litellm_core_utils.cli_token_utils.load_cli_token",
|
||||
return_value=token_data,
|
||||
):
|
||||
assert (
|
||||
get_stored_api_key(expected_base_url="https://real-proxy.com")
|
||||
== "sk-prod"
|
||||
)
|
||||
assert get_stored_api_key(expected_base_url="https://real-proxy.com") == "sk-prod"
|
||||
|
||||
def test_get_stored_api_key_base_url_match_trailing_slash(self):
|
||||
"""Trailing slash on expected_base_url is normalised before comparison"""
|
||||
|
|
@ -250,10 +224,7 @@ class TestTokenUtilities:
|
|||
"litellm.litellm_core_utils.cli_token_utils.load_cli_token",
|
||||
return_value=token_data,
|
||||
):
|
||||
assert (
|
||||
get_stored_api_key(expected_base_url="https://real-proxy.com/")
|
||||
== "sk-prod"
|
||||
)
|
||||
assert get_stored_api_key(expected_base_url="https://real-proxy.com/") == "sk-prod"
|
||||
|
||||
def test_get_stored_api_key_base_url_mismatch(self):
|
||||
"""Stored key is NOT returned when expected_base_url differs from stored origin"""
|
||||
|
|
@ -271,9 +242,7 @@ class TestTokenUtilities:
|
|||
"litellm.litellm_core_utils.cli_token_utils.load_cli_token",
|
||||
return_value=token_data,
|
||||
):
|
||||
assert (
|
||||
get_stored_api_key(expected_base_url="https://real-proxy.com") is None
|
||||
)
|
||||
assert get_stored_api_key(expected_base_url="https://real-proxy.com") is None
|
||||
|
||||
|
||||
class TestLoginCommand:
|
||||
|
|
@ -307,11 +276,8 @@ class TestLoginCommand:
|
|||
) as mock_post,
|
||||
patch("requests.get", return_value=mock_response) as mock_get,
|
||||
patch("litellm.proxy.client.cli.commands.auth.save_token") as mock_save,
|
||||
patch(
|
||||
"litellm.proxy.client.cli.interface.show_commands"
|
||||
) as mock_show_commands,
|
||||
patch("litellm.proxy.client.cli.interface.show_commands") as mock_show_commands,
|
||||
):
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
|
@ -326,9 +292,7 @@ class TestLoginCommand:
|
|||
assert "Verification code: ABCD-EFGH" in result.output
|
||||
mock_post.assert_called_once()
|
||||
mock_get.assert_called()
|
||||
assert mock_get.call_args.kwargs["headers"] == {
|
||||
"x-litellm-cli-poll-secret": "poll-secret"
|
||||
}
|
||||
assert mock_get.call_args.kwargs["headers"] == {"x-litellm-cli-poll-secret": "poll-secret"}
|
||||
|
||||
# Verify JWT was saved
|
||||
mock_save.assert_called_once()
|
||||
|
|
@ -355,7 +319,6 @@ class TestLoginCommand:
|
|||
patch("requests.get", return_value=mock_response),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
|
||||
# Mock time.sleep to avoid actual delays in tests
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
|
||||
|
|
@ -377,7 +340,6 @@ class TestLoginCommand:
|
|||
patch("requests.get", return_value=mock_response),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
|
@ -399,7 +361,6 @@ class TestLoginCommand:
|
|||
),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
|
@ -415,7 +376,6 @@ class TestLoginCommand:
|
|||
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
||||
patch("requests.get", side_effect=KeyboardInterrupt),
|
||||
):
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
|
@ -440,7 +400,6 @@ class TestLoginCommand:
|
|||
patch("requests.get", return_value=mock_response),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
|
@ -456,7 +415,6 @@ class TestLoginCommand:
|
|||
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
||||
patch("requests.get", side_effect=ValueError("Invalid value")),
|
||||
):
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
|
@ -496,9 +454,7 @@ class TestWhoamiCommand:
|
|||
"timestamp": time.time() - 3600, # 1 hour ago
|
||||
}
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data
|
||||
):
|
||||
with patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data):
|
||||
result = self.runner.invoke(whoami)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
|
@ -510,9 +466,7 @@ class TestWhoamiCommand:
|
|||
|
||||
def test_whoami_not_authenticated(self):
|
||||
"""Test whoami when user is not authenticated"""
|
||||
with patch(
|
||||
"litellm.proxy.client.cli.commands.auth.load_token", return_value=None
|
||||
):
|
||||
with patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=None):
|
||||
result = self.runner.invoke(whoami)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
|
@ -528,9 +482,7 @@ class TestWhoamiCommand:
|
|||
"timestamp": time.time() - (25 * 3600), # 25 hours ago
|
||||
}
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data
|
||||
):
|
||||
with patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data):
|
||||
result = self.runner.invoke(whoami)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
|
@ -540,21 +492,16 @@ class TestWhoamiCommand:
|
|||
def test_whoami_missing_fields(self):
|
||||
"""Test whoami with token missing some fields"""
|
||||
token_data = {
|
||||
"timestamp": time.time()
|
||||
- 3600
|
||||
"timestamp": time.time() - 3600
|
||||
# Missing user_email, user_id, user_role
|
||||
}
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data
|
||||
):
|
||||
with patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data):
|
||||
result = self.runner.invoke(whoami)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Authenticated" in result.output
|
||||
assert (
|
||||
"Unknown" in result.output
|
||||
) # Should show "Unknown" for missing fields
|
||||
assert "Unknown" in result.output # Should show "Unknown" for missing fields
|
||||
|
||||
def test_whoami_no_timestamp(self):
|
||||
"""Test whoami with token missing timestamp"""
|
||||
|
|
@ -572,7 +519,6 @@ class TestWhoamiCommand:
|
|||
),
|
||||
patch("time.time", return_value=1000),
|
||||
):
|
||||
|
||||
result = self.runner.invoke(whoami)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
|
@ -625,20 +571,13 @@ class TestCLIKeyRegenerationFlow:
|
|||
patch("webbrowser.open") as mock_browser,
|
||||
patch(
|
||||
"requests.post",
|
||||
return_value=_mock_cli_sso_start_response(
|
||||
login_id="cli-session-uuid-456"
|
||||
),
|
||||
return_value=_mock_cli_sso_start_response(login_id="cli-session-uuid-456"),
|
||||
),
|
||||
patch(
|
||||
"requests.get", side_effect=[mock_first_response, mock_second_response]
|
||||
) as mock_get,
|
||||
patch("requests.get", side_effect=[mock_first_response, mock_second_response]) as mock_get,
|
||||
patch("litellm.proxy.client.cli.commands.auth.save_token") as mock_save,
|
||||
patch(
|
||||
"litellm.proxy.client.cli.interface.show_commands"
|
||||
) as mock_show_commands,
|
||||
patch("litellm.proxy.client.cli.interface.show_commands") as mock_show_commands,
|
||||
patch("click.prompt", return_value="2"),
|
||||
): # User selects index 2
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
|
@ -659,9 +598,7 @@ class TestCLIKeyRegenerationFlow:
|
|||
first_poll_url = mock_get.call_args_list[0][0][0]
|
||||
assert "cli-session-uuid-456" in first_poll_url
|
||||
assert "team_id=" not in first_poll_url
|
||||
assert mock_get.call_args_list[0].kwargs["headers"] == {
|
||||
"x-litellm-cli-poll-secret": "poll-secret"
|
||||
}
|
||||
assert mock_get.call_args_list[0].kwargs["headers"] == {"x-litellm-cli-poll-secret": "poll-secret"}
|
||||
|
||||
# Second poll should include team_id=team-beta
|
||||
second_poll_url = mock_get.call_args_list[1][0][0]
|
||||
|
|
@ -670,10 +607,7 @@ class TestCLIKeyRegenerationFlow:
|
|||
# Verify JWT was saved
|
||||
mock_save.assert_called_once()
|
||||
saved_data = mock_save.call_args[0][0]
|
||||
assert (
|
||||
saved_data["key"]
|
||||
== "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.team-beta.jwt"
|
||||
)
|
||||
assert saved_data["key"] == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.team-beta.jwt"
|
||||
assert saved_data["user_id"] == "test-user-456"
|
||||
|
||||
mock_show_commands.assert_called_once()
|
||||
|
|
@ -698,15 +632,12 @@ class TestCLIKeyRegenerationFlow:
|
|||
patch("webbrowser.open") as mock_browser,
|
||||
patch(
|
||||
"requests.post",
|
||||
return_value=_mock_cli_sso_start_response(
|
||||
login_id="cli-session-uuid-solo"
|
||||
),
|
||||
return_value=_mock_cli_sso_start_response(login_id="cli-session-uuid-solo"),
|
||||
),
|
||||
patch("requests.get", return_value=mock_response),
|
||||
patch("litellm.proxy.client.cli.commands.auth.save_token") as mock_save,
|
||||
patch("litellm.proxy.client.cli.interface.show_commands"),
|
||||
):
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
|
@ -722,7 +653,118 @@ class TestCLIKeyRegenerationFlow:
|
|||
# Verify JWT was saved
|
||||
mock_save.assert_called_once()
|
||||
saved_data = mock_save.call_args[0][0]
|
||||
assert (
|
||||
saved_data["key"] == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.no-team.jwt"
|
||||
)
|
||||
assert saved_data["key"] == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.no-team.jwt"
|
||||
assert saved_data["user_id"] == "test-user-solo"
|
||||
|
||||
|
||||
class TestPrintTokenCommand:
|
||||
"""Test `lite auth print-token`, used as Claude Code's apiKeyHelper.
|
||||
|
||||
stdout must contain *only* the token -- Claude Code treats stdout
|
||||
verbatim as the bearer token, so any diagnostic text on stdout would
|
||||
corrupt authentication.
|
||||
|
||||
apiKeyHelper is configured as a bare command (managed-settings.json sets
|
||||
just `"apiKeyHelper": "lite auth print-token"`, no --base-url flag) --
|
||||
so in the common case ctx.obj has no explicit base_url at all, and the
|
||||
command must resolve the server from whatever `lite login` stored in
|
||||
token.json, not from a CLI default. `--base-url`/`LITELLM_PROXY_URL`
|
||||
only matters when a caller explicitly overrides it (tracked via
|
||||
ctx.obj["base_url_explicit"], set by the `cli` group from
|
||||
click's ParameterSource).
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
self.runner = CliRunner()
|
||||
|
||||
def test_no_stored_token_fails_cleanly(self):
|
||||
with patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=None):
|
||||
result = self.runner.invoke(print_token, obj={})
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Not authenticated" in result.output
|
||||
|
||||
def test_bare_invocation_resolves_server_from_stored_token(self):
|
||||
"""The apiKeyHelper's real invocation shape: no --base-url given at
|
||||
all. Must use token.json's own base_url, not a hardcoded default."""
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.client.cli.commands.auth.load_token",
|
||||
return_value={
|
||||
"base_url": "https://litellm-proxy.corp.com",
|
||||
"key": "sk-prod-fresh",
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
),
|
||||
patch("requests.post") as mock_post,
|
||||
):
|
||||
result = self.runner.invoke(print_token, obj={})
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output.strip() == "sk-prod-fresh"
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_explicit_base_url_mismatch_fails_cleanly(self):
|
||||
"""When the caller *does* explicitly pass --base-url, a token issued
|
||||
for a different server must never be printed."""
|
||||
with patch(
|
||||
"litellm.proxy.client.cli.commands.auth.load_token",
|
||||
return_value={
|
||||
"base_url": "https://other-server.com",
|
||||
"key": "sk-should-not-print",
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
):
|
||||
result = self.runner.invoke(
|
||||
print_token,
|
||||
obj={"base_url": "http://localhost:4000", "base_url_explicit": True},
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "sk-should-not-print" not in result.output
|
||||
|
||||
def test_fresh_cached_key_printed_without_network_call(self):
|
||||
"""A recently-issued key should be printed straight from cache -- no
|
||||
refresh call on every single invocation (apiKeyHelper gets called
|
||||
frequently)."""
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.client.cli.commands.auth.load_token",
|
||||
return_value={
|
||||
"base_url": "http://localhost:4000",
|
||||
"key": "sk-cached-fresh",
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
),
|
||||
patch("requests.post") as mock_post,
|
||||
):
|
||||
result = self.runner.invoke(print_token, obj={})
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output.strip() == "sk-cached-fresh"
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_stale_key_fails_fast_without_network_call(self):
|
||||
"""There is no silent refresh: an expired cached key must fail
|
||||
loudly (stderr, nonzero exit) telling the user to `lite login`
|
||||
again, rather than making a network call or printing a dead key
|
||||
that will just 401 Claude Code."""
|
||||
old_timestamp = time.time() - (CLI_JWT_EXPIRATION_HOURS + 1) * 3600
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.client.cli.commands.auth.load_token",
|
||||
return_value={
|
||||
"base_url": "http://localhost:4000",
|
||||
"key": "sk-stale-key",
|
||||
"timestamp": old_timestamp,
|
||||
},
|
||||
),
|
||||
patch("requests.post") as mock_post,
|
||||
):
|
||||
result = self.runner.invoke(print_token, obj={})
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "sk-stale-key" not in result.output
|
||||
assert "lite login" in result.output
|
||||
mock_post.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"@typescript-eslint/no-explicit-any": 1969,
|
||||
"complexity": 129,
|
||||
"local/no-large-inline-object-arg": 501,
|
||||
"local/no-long-condition-chain": 234,
|
||||
"max-depth": 59,
|
||||
"no-console": 16
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"lint:metrics": "node scripts/update-lint-metrics.mjs",
|
||||
"test": "vitest",
|
||||
"test:dot": "vitest --reporter=dot",
|
||||
"test:watch": "vitest -w",
|
||||
|
|
|
|||
|
|
@ -1,20 +1,7 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { countBudgetViolations, findDrift } from "./lint-budget-lib.mjs";
|
||||
import { readFileSync } from "fs";
|
||||
import { countBudgetViolations } from "./lint-budget-lib.mjs";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const positional = [];
|
||||
const flags = {};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === "--check") {
|
||||
flags.check = argv[(i += 1)];
|
||||
} else if (argv[i] === "--write") {
|
||||
flags.write = argv[(i += 1)];
|
||||
} else {
|
||||
positional.push(argv[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const [reportPath, budgetsPath] = positional;
|
||||
const [reportPath, budgetsPath] = process.argv.slice(2);
|
||||
const report = JSON.parse(readFileSync(reportPath, "utf8"));
|
||||
const budgets = JSON.parse(readFileSync(budgetsPath, "utf8"));
|
||||
const counts = countBudgetViolations(report, budgets);
|
||||
|
|
@ -32,25 +19,4 @@ for (const [rule, { max, target }] of Object.entries(budgets)) {
|
|||
}
|
||||
}
|
||||
|
||||
if (flags.write) {
|
||||
writeFileSync(flags.write, JSON.stringify(counts, null, 2) + "\n");
|
||||
console.log(`Wrote ${flags.write}.`);
|
||||
}
|
||||
|
||||
if (flags.check) {
|
||||
const committed = JSON.parse(readFileSync(flags.check, "utf8"));
|
||||
const drift = findDrift(committed, counts);
|
||||
for (const { rule, committed: was, actual } of drift) {
|
||||
console.error(
|
||||
`::error::${flags.check} is stale for ${rule}: committed ${was ?? "missing"}, actual ${actual ?? "not a tracked rule"}.`,
|
||||
);
|
||||
}
|
||||
if (drift.length > 0) {
|
||||
console.error(`::error::Run \`npm run lint:metrics\` and commit ${flags.check}.`);
|
||||
failed = true;
|
||||
} else {
|
||||
console.log(`${flags.check} is up to date.`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
|
|
|||
|
|
@ -13,10 +13,3 @@ export function countBudgetViolations(report, budgets) {
|
|||
.map((rule) => [rule, counts[rule] || 0]),
|
||||
);
|
||||
}
|
||||
|
||||
export function findDrift(committed, actual) {
|
||||
const rules = [...new Set([...Object.keys(actual), ...Object.keys(committed)])].sort();
|
||||
return rules
|
||||
.filter((rule) => committed[rule] !== actual[rule])
|
||||
.map((rule) => ({ rule, committed: committed[rule] ?? null, actual: actual[rule] ?? null }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { countBudgetViolations } from "./lint-budget-lib.mjs";
|
||||
|
||||
const ESLINT_EXIT_LINT_ERRORS = 1;
|
||||
|
||||
const budgets = JSON.parse(readFileSync("eslint-budgets.json", "utf8"));
|
||||
const dir = mkdtempSync(join(tmpdir(), "litellm-lint-"));
|
||||
const reportPath = join(dir, "report.json");
|
||||
|
||||
try {
|
||||
execSync(`npx eslint . -f json -o "${reportPath}"`, { stdio: "inherit" });
|
||||
} catch (err) {
|
||||
if (err.status !== ESLINT_EXIT_LINT_ERRORS) throw err;
|
||||
}
|
||||
|
||||
const report = JSON.parse(readFileSync(reportPath, "utf8"));
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
|
||||
const metrics = countBudgetViolations(report, budgets);
|
||||
writeFileSync("eslint-metrics.json", JSON.stringify(metrics, null, 2) + "\n");
|
||||
console.log("Updated eslint-metrics.json");
|
||||
console.table(metrics);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { countBudgetViolations, findDrift } from "../scripts/lint-budget-lib.mjs";
|
||||
import { countBudgetViolations } from "../scripts/lint-budget-lib.mjs";
|
||||
|
||||
const budgets = {
|
||||
"@typescript-eslint/no-explicit-any": { max: 10, target: 5 },
|
||||
|
|
@ -35,21 +35,3 @@ describe("countBudgetViolations", () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDrift", () => {
|
||||
it("reports no drift when the snapshot matches the actual counts", () => {
|
||||
expect(findDrift({ complexity: 5 }, { complexity: 5 })).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects a changed count", () => {
|
||||
expect(findDrift({ complexity: 5 }, { complexity: 7 })).toEqual([{ rule: "complexity", committed: 5, actual: 7 }]);
|
||||
});
|
||||
|
||||
it("detects a rule missing from the committed snapshot", () => {
|
||||
expect(findDrift({}, { complexity: 7 })).toEqual([{ rule: "complexity", committed: null, actual: 7 }]);
|
||||
});
|
||||
|
||||
it("detects a phantom rule the committed snapshot still carries", () => {
|
||||
expect(findDrift({ "removed-rule": 3 }, {})).toEqual([{ rule: "removed-rule", committed: 3, actual: null }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue