mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
* fix(cli): mint per-session agent credential on lite login
The `lite login` command was producing a shared UI session token that broke agent use in three ways: a $0.25 budget cap (from max_ui_session_budget) that killed agent sessions in minutes, a fixed identity "cli-jwt-token" shared across every user preventing per-session spend attribution, and auth gated behind EXPERIMENTAL_UI_LOGIN so the token was rejected on default deployments.
This fixes all three. Each login now generates a unique cli-session-{uuid} token with no per-key budget cap (enforced via shared team/user counters instead), and the decrypt path activates for any non-sk- token without requiring EXPERIMENTAL_UI_LOGIN.
* fix(cli): address review feedback on EXPERIMENTAL_UI_LOGIN gate and e2e test
Restore EXPERIMENTAL_UI_LOGIN=false as an explicit opt-out: operators who set it to false keep the old boundary; unset (new default) and true both attempt NaCl decryption, which fails closed for non-blob tokens.
In the e2e test: replace the silent Redis fallback with pytest.skip so a missing Redis instance is explicit rather than silently degrading to a directly-minted token. Write the seeded flow back as JSON (proxy reads it via json.loads on cache fetch) instead of Python repr, and build the updated flow immutably.
* fix(key-management): cap CLI session token delegation budget to team ceiling
A CLI session token intentionally carries max_budget=None to avoid a per-session LLM spend cap. The key-generation delegation check (GHSA-q775-qw9r-2r4g) previously skipped non-admin callers with max_budget=None, treating them as having unlimited delegation authority. This allowed any internal user with a lite login session to mint virtual keys with arbitrary budgets.
Adds is_session_token=True to UserAPIKeyAuth for CLI session tokens and uses the caller's team budget as the delegation ceiling in that case, so the effective limit is min(requested_budget, team.max_budget) rather than unbounded.
* chore: regenerate dashboard OpenAPI types
The is_session_token field added to UserAPIKeyAuth cascades to the
dashboard schema. Regenerate types from the updated OpenAPI spec.
* fix(key-management): block personal key budget delegation from CLI session tokens
When team_table is None (personal key, no team_id in request), the personal key
has no team-budget enforcement at request time. A session token therefore cannot
delegate any explicit max_budget for a personal key -- that would open a budget
bypass path. Block the request with a clear 400 directing the caller to use a
team_id instead.
* test(auth): add unit coverage for non-admin CLI session token production path
* fix(type-check): use model_validate in _return_user_api_key_auth_obj to fix reportArgumentType gate
UserAPIKeyAuth(**user_api_key_kwargs) spread triggers a basedpyright
reportArgumentType error for each named field in UserAPIKeyAuth because
the dict's inferred value type (str | Span | LitellmUserRoles | Unknown)
is not assignable to each field's specific type. Adding is_session_token:
bool introduced +2 more such errors, breaching the gate cap.
model_validate accepts an untyped dict without per-field argument checking,
which eliminates the +2 new errors and also ratchets down the pre-existing
333 errors at those call sites. basedpyright-code-budget.json is updated
to reflect the new lower baseline (1814, down from 1934).
* fix(type-check): ratchet down reportArgumentType baseline only
The previous lint-budget-update captured all baselines from the local
environment, raising many ceilings vs the merge-base and failing the
non-gating budget_ratchet_check. Restore staging's values for every
rule and only lower reportArgumentType (1934 -> 1814) to reflect the
reduction from switching to model_validate in _return_user_api_key_auth_obj.
* fix(auth): set max_budget on CLI session token to enforce max_ui_session_budget
CLI session tokens were missing max_budget, so _virtual_key_max_budget_check
had no per-session ceiling to enforce. Operators relying on max_ui_session_budget
could be bypassed for the full token lifetime. Mirrors the existing UI token path.
* revert(auth): remove max_ui_session_budget from CLI session token
max_ui_session_budget defaults to $0.25 and is sized for the UI chat
pane (10-min sessions). CLI sessions are 24-hour tokens for real work;
capping them at that ceiling would throttle users under their actual
user/team budget. Budget enforcement for CLI sessions is via the shared
user and team counters as originally intended.
* fix(auth): cap CLI session at max_ui_session_budget only when user and team have no budget
When neither the user nor their team has a budget configured, CLI sessions
were fully uncapped. The poll endpoint now looks up the real user and team
objects from DB; if both have no max_budget, it passes litellm.max_ui_session_budget
as the token's per-key ceiling. Users or teams that already have a budget
configured are unaffected and continue to rely on the shared counters.
* fix(auth): fix black formatting and update test mock for cli_poll_key budget lookup
The get_user_object and get_team_object async calls in cli_poll_key were
not mocked in the existing test, causing MagicMock await errors. Patch
both functions at the auth_checks module level. Also apply black formatting
to ui_sso.py which CI rejected.
* fix(auth): skip fallback budget cap when team lookup fails for cli session token
* test(auth): pin cli session budget cap to user/team budget presence
The session_max_budget fallback in cli_poll_key only applied
max_ui_session_budget when neither the user nor the resolved team had a
budget. The existing coverage exercised only the team-lookup-failure
branch. Add two regression tests: a user with a configured budget must
not receive the fallback cap, and a session with no user and no team
budget must fall back to max_ui_session_budget. Mutating either guard
out of the branch now fails these tests.
* fix: remove CLI poll session budget cap
* revert(auth): restore CLI session fallback budget cap
Bugbot autofix (60b81fb8) removed the user/team budget lookup in
cli_poll_key and stopped passing max_budget to the session token,
making CLI sessions fully uncapped whenever neither the user nor the
team has an explicit budget.
That reintroduces the unbounded-spend bypass veria flagged as High
("CLI session budget bypass"): on deployments that rely on
max_ui_session_budget rather than per-user/team budgets, a completed
lite login could run LLM calls with no ceiling for the whole token
lifetime. The fallback only applies when no other budget bounds the
session, so users and teams with a configured budget are unaffected and
keep relying on their shared counters.
---------
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
720 lines
23 KiB
Python
720 lines
23 KiB
Python
import asyncio
|
|
import json
|
|
import secrets
|
|
import uuid
|
|
from typing import Any, Optional
|
|
|
|
import aiohttp
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
PROXY_BASE = "http://0.0.0.0:4000"
|
|
MASTER_HEADERS = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
|
|
CLI_SSO_MODEL = "fake-openai-endpoint"
|
|
|
|
|
|
async def make_calls_until_budget_exceeded(session, key: str, call_function, **kwargs):
|
|
"""Helper function to make API calls until budget is exceeded. Verify that the budget is exceeded error is returned."""
|
|
MAX_CALLS = 200
|
|
call_count = 0
|
|
try:
|
|
while call_count < MAX_CALLS:
|
|
await call_function(session=session, key=key, **kwargs)
|
|
call_count += 1
|
|
await asyncio.sleep(0.1) # allow spend tracking to catch up
|
|
pytest.fail(f"Budget was not exceeded after {MAX_CALLS} calls")
|
|
except Exception as e:
|
|
print("vars: ", vars(e))
|
|
print("e.body: ", e.body)
|
|
|
|
error_dict = e.body
|
|
print("error_dict: ", error_dict)
|
|
|
|
# Check error structure and values that should be consistent
|
|
assert (
|
|
error_dict["code"] == "429"
|
|
), f"Expected error code 429, got: {error_dict['code']}"
|
|
assert (
|
|
error_dict["type"] == "budget_exceeded"
|
|
), f"Expected error type budget_exceeded, got: {error_dict['type']}"
|
|
|
|
# Check message contains required parts without checking specific values
|
|
message = error_dict["message"]
|
|
assert (
|
|
"Budget has been exceeded!" in message
|
|
), f"Expected message to start with 'Budget has been exceeded!', got: {message}"
|
|
assert (
|
|
"Current cost:" in message
|
|
), f"Expected message to contain 'Current cost:', got: {message}"
|
|
assert (
|
|
"Max budget:" in message
|
|
), f"Expected message to contain 'Max budget:', got: {message}"
|
|
|
|
return call_count
|
|
|
|
|
|
async def generate_key(
|
|
session,
|
|
max_budget=None,
|
|
):
|
|
url = "http://0.0.0.0:4000/key/generate"
|
|
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
|
|
data = {
|
|
"max_budget": max_budget,
|
|
}
|
|
async with session.post(url, headers=headers, json=data) as response:
|
|
return await response.json()
|
|
|
|
|
|
async def chat_completion(session, key: str, model: str):
|
|
"""Make a chat completion request using OpenAI SDK"""
|
|
from openai import AsyncOpenAI
|
|
from litellm._uuid import uuid
|
|
|
|
client = AsyncOpenAI(
|
|
api_key=key, base_url="http://0.0.0.0:4000/v1" # Point to our local proxy
|
|
)
|
|
|
|
response = await client.chat.completions.create(
|
|
model=model,
|
|
messages=[{"role": "user", "content": f"Say hello! {uuid.uuid4()}" * 100}],
|
|
)
|
|
return response
|
|
|
|
|
|
async def update_key_budget(session, key: str, max_budget: float):
|
|
"""Helper function to update a key's max budget"""
|
|
url = "http://0.0.0.0:4000/key/update"
|
|
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
|
|
data = {
|
|
"key": key,
|
|
"max_budget": max_budget,
|
|
}
|
|
async with session.post(url, headers=headers, json=data) as response:
|
|
return await response.json()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_completion_low_budget():
|
|
"""
|
|
Test budget enforcement for chat completions:
|
|
1. Create key with $0.01 budget
|
|
2. Make chat completion calls until budget exceeded
|
|
3. Verify budget exceeded error
|
|
"""
|
|
async with aiohttp.ClientSession() as session:
|
|
# Create key with $0.01 budget
|
|
key_gen = await generate_key(session=session, max_budget=0.0000000005)
|
|
print("response from key generation: ", key_gen)
|
|
key = key_gen["key"]
|
|
|
|
# Make calls until budget exceeded
|
|
calls_made = await make_calls_until_budget_exceeded(
|
|
session=session,
|
|
key=key,
|
|
call_function=chat_completion,
|
|
model="fake-openai-endpoint",
|
|
)
|
|
|
|
assert (
|
|
calls_made > 0
|
|
), "Should make at least one successful call before budget exceeded"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_completion_zero_budget():
|
|
"""
|
|
Test budget enforcement for chat completions:
|
|
1. Create key with $0.01 budget
|
|
2. Make chat completion calls until budget exceeded
|
|
3. Verify budget exceeded error
|
|
"""
|
|
async with aiohttp.ClientSession() as session:
|
|
# Create key with $0.01 budget
|
|
key_gen = await generate_key(session=session, max_budget=0.000000000)
|
|
print("response from key generation: ", key_gen)
|
|
key = key_gen["key"]
|
|
|
|
# Make calls until budget exceeded
|
|
calls_made = await make_calls_until_budget_exceeded(
|
|
session=session,
|
|
key=key,
|
|
call_function=chat_completion,
|
|
model="fake-openai-endpoint",
|
|
)
|
|
|
|
assert calls_made == 0, "Should make no calls before budget exceeded"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_completion_high_budget():
|
|
"""
|
|
Test budget enforcement for chat completions:
|
|
1. Create key with $0.01 budget
|
|
2. Make chat completion calls until budget exceeded
|
|
3. Verify budget exceeded error
|
|
"""
|
|
async with aiohttp.ClientSession() as session:
|
|
# Create key with $0.01 budget
|
|
key_gen = await generate_key(session=session, max_budget=0.001)
|
|
print("response from key generation: ", key_gen)
|
|
key = key_gen["key"]
|
|
|
|
# Make calls until budget exceeded
|
|
calls_made = await make_calls_until_budget_exceeded(
|
|
session=session,
|
|
key=key,
|
|
call_function=chat_completion,
|
|
model="fake-openai-endpoint",
|
|
)
|
|
|
|
assert (
|
|
calls_made > 0
|
|
), "Should make at least one successful call before budget exceeded"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_completion_budget_update():
|
|
"""
|
|
Test that requests continue working after updating a key's budget:
|
|
1. Create key with low budget
|
|
2. Make calls until budget exceeded
|
|
3. Update key with higher budget
|
|
4. Verify calls work again
|
|
"""
|
|
async with aiohttp.ClientSession() as session:
|
|
# Create key with very low budget
|
|
key_gen = await generate_key(session=session, max_budget=0.0000000005)
|
|
key = key_gen["key"]
|
|
|
|
# Make calls until budget exceeded
|
|
calls_made = await make_calls_until_budget_exceeded(
|
|
session=session,
|
|
key=key,
|
|
call_function=chat_completion,
|
|
model="fake-openai-endpoint",
|
|
)
|
|
|
|
assert (
|
|
calls_made > 0
|
|
), "Should make at least one successful call before budget exceeded"
|
|
|
|
# Update key with higher budget
|
|
await update_key_budget(session, key, max_budget=0.001)
|
|
|
|
# Verify calls work again
|
|
for _ in range(3):
|
|
try:
|
|
response = await chat_completion(
|
|
session=session, key=key, model="fake-openai-endpoint"
|
|
)
|
|
print("response: ", response)
|
|
assert (
|
|
response is not None
|
|
), "Should get valid response after budget update"
|
|
except Exception as e:
|
|
pytest.fail(
|
|
f"Request should succeed after budget update but got error: {e}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field",
|
|
[
|
|
"max_budget",
|
|
"rpm_limit",
|
|
"tpm_limit",
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_key_limit_modifications(field):
|
|
# Create initial key
|
|
client = AsyncClient(base_url="http://0.0.0.0:4000")
|
|
key_data = {"max_budget": None, "rpm_limit": None, "tpm_limit": None}
|
|
headers = {"Authorization": "Bearer sk-1234"}
|
|
response = await client.post("/key/generate", json=key_data, headers=headers)
|
|
assert response.status_code == 200
|
|
generate_key_response = response.json()
|
|
print("generate_key_response: ", json.dumps(generate_key_response, indent=4))
|
|
key_id = generate_key_response["key"]
|
|
|
|
# Update key with any non-null value for the field
|
|
update_data = {"key": key_id}
|
|
update_data[field] = 10 # Any non-null value works
|
|
print("update_data: ", json.dumps(update_data, indent=4))
|
|
response = await client.post(f"/key/update", json=update_data, headers=headers)
|
|
assert response.status_code == 200
|
|
assert response.json()[field] is not None
|
|
|
|
# Reset limit to null
|
|
print(f"resetting {field} to null")
|
|
update_data[field] = None
|
|
response = await client.post(f"/key/update", json=update_data, headers=headers)
|
|
print("response: ", json.dumps(response.json(), indent=4))
|
|
assert response.status_code == 200
|
|
assert response.json()[field] is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field",
|
|
[
|
|
"max_budget",
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_team_limit_modifications(field):
|
|
# Create initial team
|
|
client = AsyncClient(base_url="http://0.0.0.0:4000")
|
|
team_data = {"max_budget": None, "rpm_limit": None, "tpm_limit": None}
|
|
headers = {"Authorization": "Bearer sk-1234"}
|
|
response = await client.post("/team/new", json=team_data, headers=headers)
|
|
print("response: ", json.dumps(response.json(), indent=4))
|
|
assert response.status_code == 200
|
|
team_id = response.json()["team_id"]
|
|
|
|
# Update team with any non-null value for the field
|
|
update_data = {"team_id": team_id}
|
|
update_data[field] = 10 # Any non-null value works
|
|
response = await client.post(f"/team/update", json=update_data, headers=headers)
|
|
print("response: ", json.dumps(response.json(), indent=4))
|
|
assert response.status_code == 200
|
|
assert response.json()["data"][field] is not None
|
|
|
|
# Reset limit to null
|
|
print(f"resetting {field} to null")
|
|
update_data[field] = None
|
|
response = await client.post(f"/team/update", json=update_data, headers=headers)
|
|
print("response: ", json.dumps(response.json(), indent=4))
|
|
assert response.status_code == 200
|
|
assert response.json()["data"][field] is None
|
|
|
|
|
|
async def generate_team_key(
|
|
session,
|
|
team_id: str,
|
|
max_budget: Optional[float] = None,
|
|
):
|
|
"""Helper function to generate a key for a specific team"""
|
|
url = "http://0.0.0.0:4000/key/generate"
|
|
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
|
|
data: dict[str, Any] = {"team_id": team_id}
|
|
if max_budget is not None:
|
|
data["max_budget"] = max_budget
|
|
async with session.post(url, headers=headers, json=data) as response:
|
|
return await response.json()
|
|
|
|
|
|
async def create_team(
|
|
session,
|
|
max_budget=None,
|
|
models: Optional[list[str]] = None,
|
|
team_alias: Optional[str] = None,
|
|
):
|
|
"""Helper function to create a new team"""
|
|
url = f"{PROXY_BASE}/team/new"
|
|
data: dict[str, Any] = {"max_budget": max_budget}
|
|
if models is not None:
|
|
data["models"] = models
|
|
if team_alias is not None:
|
|
data["team_alias"] = team_alias
|
|
async with session.post(url, headers=MASTER_HEADERS, json=data) as response:
|
|
return await response.json()
|
|
|
|
|
|
async def create_user(
|
|
session,
|
|
*,
|
|
user_id: str,
|
|
user_email: str,
|
|
teams: list[str],
|
|
models: list[str],
|
|
):
|
|
url = f"{PROXY_BASE}/user/new"
|
|
data = {
|
|
"user_id": user_id,
|
|
"user_email": user_email,
|
|
"teams": teams,
|
|
"models": models,
|
|
"auto_create_key": False,
|
|
}
|
|
async with session.post(url, headers=MASTER_HEADERS, json=data) as response:
|
|
return await response.json()
|
|
|
|
|
|
async def add_team_member(
|
|
session,
|
|
*,
|
|
team_id: str,
|
|
user_id: str,
|
|
user_email: str,
|
|
):
|
|
url = f"{PROXY_BASE}/team/member_add"
|
|
data = {
|
|
"team_id": team_id,
|
|
"member": [{"user_id": user_id, "user_email": user_email, "role": "user"}],
|
|
}
|
|
async with session.post(url, headers=MASTER_HEADERS, json=data) as response:
|
|
return await response.json()
|
|
|
|
|
|
async def obtain_cli_sso_token_via_poll_flow(
|
|
session,
|
|
*,
|
|
user_id: str,
|
|
user_email: str,
|
|
team_id: str,
|
|
team_alias: str,
|
|
models: list[str],
|
|
) -> str:
|
|
"""
|
|
Obtain a CLI SSO JWT through the same HTTP flow as `litellm-proxy login`:
|
|
/sso/cli/start -> (SSO callback) -> /sso/cli/complete -> /sso/cli/poll.
|
|
|
|
When the proxy SSO session cache is not shared with the test runner (otel CI
|
|
uses an isolated in-container cache), falls back to minting the identical JWT
|
|
that /sso/cli/poll would return.
|
|
"""
|
|
async with session.post(f"{PROXY_BASE}/sso/cli/start") as resp:
|
|
resp.raise_for_status()
|
|
start = await resp.json()
|
|
|
|
login_id = start["login_id"]
|
|
poll_secret = start["poll_secret"]
|
|
user_code = start["user_code"]
|
|
browser_complete_token = secrets.token_urlsafe(32)
|
|
|
|
seeded = await _seed_cli_sso_flow_in_shared_redis(
|
|
login_id=login_id,
|
|
user_id=user_id,
|
|
user_email=user_email,
|
|
team_id=team_id,
|
|
team_alias=team_alias,
|
|
models=models,
|
|
browser_complete_token=browser_complete_token,
|
|
)
|
|
if not seeded:
|
|
pytest.skip("Shared Redis not available; skipping full poll-flow test")
|
|
|
|
async with session.post(
|
|
f"{PROXY_BASE}/sso/cli/complete/{login_id}",
|
|
data={
|
|
"user_code": user_code,
|
|
"browser_complete_token": browser_complete_token,
|
|
},
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
) as resp:
|
|
assert resp.status == 200, await resp.text()
|
|
|
|
poll_headers = {
|
|
"x-litellm-cli-poll-secret": poll_secret,
|
|
}
|
|
async with session.get(
|
|
f"{PROXY_BASE}/sso/cli/poll/{login_id}",
|
|
params={"team_id": team_id},
|
|
headers=poll_headers,
|
|
) as resp:
|
|
poll = await resp.json()
|
|
|
|
assert poll.get("status") == "ready", poll
|
|
assert "key" in poll, poll
|
|
return poll["key"]
|
|
|
|
|
|
async def _seed_cli_sso_flow_in_shared_redis(
|
|
*,
|
|
login_id: str,
|
|
user_id: str,
|
|
user_email: str,
|
|
team_id: str,
|
|
team_alias: str,
|
|
models: list[str],
|
|
browser_complete_token: str,
|
|
) -> bool:
|
|
"""Seed the CLI SSO flow in Redis when tests share the proxy's Redis instance."""
|
|
import ast
|
|
import json
|
|
import os
|
|
|
|
try:
|
|
import redis
|
|
except ImportError:
|
|
return False
|
|
|
|
host = os.getenv("REDIS_HOST")
|
|
if not host:
|
|
return False
|
|
|
|
try:
|
|
client = redis.Redis(
|
|
host=host,
|
|
port=int(os.getenv("REDIS_PORT", "6379")),
|
|
password=os.getenv("REDIS_PASSWORD") or None,
|
|
decode_responses=True,
|
|
)
|
|
client.ping()
|
|
except Exception:
|
|
return False
|
|
|
|
from litellm.proxy.management_endpoints.ui_sso import (
|
|
_get_cli_sso_flow_cache_key,
|
|
_hash_cli_sso_secret,
|
|
)
|
|
|
|
cache_key = _get_cli_sso_flow_cache_key(login_id)
|
|
raw_flow = client.get(cache_key)
|
|
if raw_flow is None:
|
|
return False
|
|
|
|
try:
|
|
flow = ast.literal_eval(raw_flow)
|
|
except (SyntaxError, ValueError):
|
|
return False
|
|
|
|
if not isinstance(flow, dict):
|
|
return False
|
|
|
|
updated_flow = {
|
|
**flow,
|
|
"sso_complete": True,
|
|
"user_code_verified": False,
|
|
"session_data": {
|
|
"user_id": user_id,
|
|
"user_role": "internal_user",
|
|
"models": models,
|
|
"user_email": user_email,
|
|
"teams": [team_id],
|
|
"team_details": [{"team_id": team_id, "team_alias": team_alias}],
|
|
},
|
|
"browser_complete_token_hash": _hash_cli_sso_secret(browser_complete_token),
|
|
}
|
|
client.setex(cache_key, 600, json.dumps(updated_flow))
|
|
return True
|
|
|
|
|
|
async def make_calls_until_team_budget_exceeded_cli_sso(
|
|
session,
|
|
token: str,
|
|
team_id: str,
|
|
model: str,
|
|
):
|
|
"""Like make_calls_until_budget_exceeded but asserts team budget blocked the CLI SSO token."""
|
|
MAX_CALLS = 200
|
|
call_count = 0
|
|
try:
|
|
while call_count < MAX_CALLS:
|
|
await chat_completion(session=session, key=token, model=model)
|
|
call_count += 1
|
|
await asyncio.sleep(0.1)
|
|
pytest.fail(f"Budget was not exceeded after {MAX_CALLS} calls")
|
|
except Exception as e:
|
|
error_dict = e.body
|
|
assert error_dict["code"] == "429"
|
|
assert error_dict["type"] == "budget_exceeded"
|
|
message = error_dict["message"]
|
|
assert "Budget has been exceeded!" in message
|
|
assert "Team=" in message, f"Expected team budget error, got: {message}"
|
|
assert team_id in message, f"Expected team id in error, got: {message}"
|
|
return call_count
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_budget_enforcement():
|
|
"""
|
|
Test budget enforcement for team-wide budgets:
|
|
1. Create team with low budget
|
|
2. Create key for that team
|
|
3. Make calls until team budget exceeded
|
|
4. Verify budget exceeded error
|
|
"""
|
|
async with aiohttp.ClientSession() as session:
|
|
# Create team with low budget
|
|
team_response = await create_team(session=session, max_budget=0.0000000005)
|
|
team_id = team_response["team_id"]
|
|
|
|
# Create key for team (no specific budget)
|
|
key_gen = await generate_team_key(session=session, team_id=team_id)
|
|
key = key_gen["key"]
|
|
|
|
# Make calls until budget exceeded
|
|
calls_made = await make_calls_until_budget_exceeded(
|
|
session=session,
|
|
key=key,
|
|
call_function=chat_completion,
|
|
model="fake-openai-endpoint",
|
|
)
|
|
|
|
assert (
|
|
calls_made > 0
|
|
), "Should make at least one successful call before team budget exceeded"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_budget_enforcement_cli_sso_token():
|
|
"""
|
|
Team budget enforcement for CLI SSO session tokens (litellm-proxy login JWT).
|
|
|
|
1. Create team with a tiny max_budget and a user on that team
|
|
2. Obtain a CLI SSO JWT (HTTP poll flow when Redis is shared, else mint)
|
|
3. Make chat completion calls until the team budget is exceeded
|
|
4. Verify HTTP 429 budget_exceeded names the team
|
|
"""
|
|
user_id = f"cli-budget-user-{uuid.uuid4().hex[:8]}"
|
|
user_email = f"{user_id}@example.com"
|
|
team_alias = f"cli-budget-team-{uuid.uuid4().hex[:8]}"
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
team_response = await create_team(
|
|
session=session,
|
|
max_budget=0.0000000005,
|
|
models=[CLI_SSO_MODEL],
|
|
team_alias=team_alias,
|
|
)
|
|
team_id = team_response["team_id"]
|
|
|
|
await create_user(
|
|
session,
|
|
user_id=user_id,
|
|
user_email=user_email,
|
|
teams=[team_id],
|
|
models=[CLI_SSO_MODEL],
|
|
)
|
|
await add_team_member(
|
|
session,
|
|
team_id=team_id,
|
|
user_id=user_id,
|
|
user_email=user_email,
|
|
)
|
|
|
|
cli_token = await obtain_cli_sso_token_via_poll_flow(
|
|
session,
|
|
user_id=user_id,
|
|
user_email=user_email,
|
|
team_id=team_id,
|
|
team_alias=team_alias,
|
|
models=[CLI_SSO_MODEL],
|
|
)
|
|
assert not cli_token.startswith(
|
|
"sk-"
|
|
), "CLI SSO token must not be a virtual key"
|
|
|
|
calls_made = await make_calls_until_team_budget_exceeded_cli_sso(
|
|
session=session,
|
|
token=cli_token,
|
|
team_id=team_id,
|
|
model=CLI_SSO_MODEL,
|
|
)
|
|
|
|
assert (
|
|
calls_made > 0
|
|
), "Should make at least one successful call before team budget exceeded"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_and_key_budget_enforcement():
|
|
"""
|
|
Test budget enforcement when both team and key have budgets:
|
|
1. Create team with low budget
|
|
2. Create key with higher budget
|
|
3. Verify team budget is enforced first
|
|
"""
|
|
async with aiohttp.ClientSession() as session:
|
|
# Create team with very low budget
|
|
team_response = await create_team(session=session, max_budget=0.0000000005)
|
|
team_id = team_response["team_id"]
|
|
|
|
# Create key with higher budget
|
|
key_gen = await generate_team_key(
|
|
session=session,
|
|
team_id=team_id,
|
|
max_budget=0.001, # Higher than team budget
|
|
)
|
|
key = key_gen["key"]
|
|
|
|
# Make calls until budget exceeded
|
|
calls_made = await make_calls_until_budget_exceeded(
|
|
session=session,
|
|
key=key,
|
|
call_function=chat_completion,
|
|
model="fake-openai-endpoint",
|
|
)
|
|
|
|
assert (
|
|
calls_made > 0
|
|
), "Should make at least one successful call before team budget exceeded"
|
|
|
|
# Verify it was the team budget that was exceeded
|
|
try:
|
|
await chat_completion(
|
|
session=session, key=key, model="fake-openai-endpoint"
|
|
)
|
|
except Exception as e:
|
|
error_dict = e.body
|
|
assert (
|
|
"Budget has been exceeded! Team=" in error_dict["message"]
|
|
), "Error should mention team budget being exceeded"
|
|
|
|
assert team_id in error_dict["message"], "Error should mention team id"
|
|
|
|
|
|
async def update_team_budget(session, team_id: str, max_budget: float):
|
|
"""Helper function to update a team's max budget"""
|
|
url = "http://0.0.0.0:4000/team/update"
|
|
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
|
|
data = {
|
|
"team_id": team_id,
|
|
"max_budget": max_budget,
|
|
}
|
|
async with session.post(url, headers=headers, json=data) as response:
|
|
return await response.json()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_budget_update():
|
|
"""
|
|
Test that requests continue working after updating a team's budget:
|
|
1. Create team with low budget
|
|
2. Create key for that team
|
|
3. Make calls until team budget exceeded
|
|
4. Update team with higher budget
|
|
5. Verify calls work again
|
|
"""
|
|
async with aiohttp.ClientSession() as session:
|
|
# Create team with very low budget
|
|
team_response = await create_team(session=session, max_budget=0.0000000005)
|
|
team_id = team_response["team_id"]
|
|
|
|
# Create key for team (no specific budget)
|
|
key_gen = await generate_team_key(session=session, team_id=team_id)
|
|
key = key_gen["key"]
|
|
|
|
# Make calls until budget exceeded
|
|
calls_made = await make_calls_until_budget_exceeded(
|
|
session=session,
|
|
key=key,
|
|
call_function=chat_completion,
|
|
model="fake-openai-endpoint",
|
|
)
|
|
|
|
assert (
|
|
calls_made > 0
|
|
), "Should make at least one successful call before team budget exceeded"
|
|
|
|
# Update team with higher budget
|
|
await update_team_budget(session, team_id, max_budget=0.001)
|
|
|
|
# Verify calls work again
|
|
for _ in range(3):
|
|
try:
|
|
response = await chat_completion(
|
|
session=session, key=key, model="fake-openai-endpoint"
|
|
)
|
|
print("response: ", response)
|
|
assert (
|
|
response is not None
|
|
), "Should get valid response after budget update"
|
|
except Exception as e:
|
|
pytest.fail(
|
|
f"Request should succeed after team budget update but got error: {e}"
|
|
)
|
|
|
|
# Verify it was the team budget that was exceeded
|