litellm/tests/integration/spend/test_team_member_spend.py
yuneng-jiang 44d99475a5
test(integration): stop GET /__observations from draining the upstream log (#47256)
* test(integration): stop GET /__observations from draining the upstream log

GET /__observations used to empty the queue, so a second read in a test
saw nothing and a "never reached upstream" check could pass whether or not
the request was forwarded. GET now returns every request since the last
reset and DELETE /__observations is the reset. An autouse fixture resets
the worker upstream before each test, which also covers tests that read
without the gateway fixture.

Discarded reads used as resets become DELETEs, helpers that accumulated
drained reads now read one snapshot, and tests with several phases reset
between them. The three vacuous negative checks (rag allowlist, guardrail
opt-out chaos, xAI batch output) now assert the absence on the same read
that shows a request which did reach the upstream.

* style(integration): space the observations unit test like its neighbours
2026-10-18 09:58:05 +00:00

1026 lines
49 KiB
Python

import os
import uuid
from collections.abc import Callable, Iterator
from datetime import datetime, timedelta, timezone
from typing import Final
import httpx
import pytest
from integration._support.client import Gateway, Scenario, eventually, object_value, string_value
from integration._support.database import read_rows
from pydantic import BaseModel, JsonValue
from redis import Redis
def _python_isoformat(value: datetime) -> str:
return value.isoformat()
def _javascript_iso_string(value: datetime) -> str:
return value.strftime("%Y-%m-%dT%H:%M:%S.000Z")
class _ObservedRequest(BaseModel):
body: dict[str, JsonValue]
class _Observations(BaseModel):
requests: list[_ObservedRequest]
class _ProxyError(BaseModel):
type: str
class _ErrorResponse(BaseModel):
error: _ProxyError
class _BudgetRecord(BaseModel):
budget_id: str
max_budget: float | None = None
tpm_limit: int | None = None
rpm_limit: int | None = None
budget_duration: str | None = None
allowed_models: list[str] | None = None
temp_budget_increase: float | None = None
temp_budget_expiry: datetime | None = None
class _MemberBudgetState(BaseModel):
user_id: str
team_id: str
budget_id: str | None = None
spend: float | None = None
max_budget: float | None = None
tpm_limit: int | None = None
rpm_limit: int | None = None
budget_duration: str | None = None
allowed_models: list[str] | None = None
temp_budget_increase: float | None = None
temp_budget_expiry: datetime | None = None
class _TeamRosterMember(BaseModel):
role: str
user_id: str | None = None
user_email: str | None = None
class _TeamState(BaseModel):
team_id: str
models: list[str] | None = None
members_with_roles: tuple[_TeamRosterMember, ...] = ()
metadata: dict[str, JsonValue] | None = None
class _TeamMembershipInfo(BaseModel):
user_id: str
team_id: str
budget_id: str | None = None
spend: float | None = None
budget_source: str | None = None
litellm_budget_table: _BudgetRecord | None = None
class _TeamInfo(BaseModel):
team_info: _TeamState
team_memberships: list[_TeamMembershipInfo]
class _MemberMe(BaseModel):
user_id: str
team_id: str
budget_id: str | None = None
role: str | None = None
litellm_budget_table: _BudgetRecord | None = None
class _MemberUpdateResponse(BaseModel):
team_id: str
user_id: str
user_email: str | None = None
max_budget_in_team: float | None = None
tpm_limit: int | None = None
rpm_limit: int | None = None
budget_duration: str | None = None
allowed_models: list[str] | None = None
temp_budget_increase: float | None = None
temp_budget_expiry: datetime | None = None
class _BulkBudgetResult(BaseModel):
user_id: str | None = None
user_email: str | None = None
success: bool
error: str | None = None
budget_id: str | None = None
max_budget: float | None = None
max_budget_source: str | None = None
tpm_limit: int | None = None
rpm_limit: int | None = None
budget_duration: str | None = None
allowed_models: tuple[str, ...] | None = None
class _BulkBudgetUpdateResponse(BaseModel):
data: tuple[_BulkBudgetResult, ...]
@pytest.fixture
def upstream(gateway: Gateway) -> Iterator[httpx.Client]:
with httpx.Client(base_url=gateway.upstream_url, timeout=15, trust_env=False) as client:
yield client
def _priced_model(scenario: Scenario) -> tuple[str, str]:
provider_model: Final = f"team-member-spend-{uuid.uuid4().hex}"
model: Final = scenario.model(
model=f"openai/{provider_model}",
input_cost_per_token=0.001,
output_cost_per_token=0.002,
)
return model, provider_model
def _free_model(scenario: Scenario) -> tuple[str, str]:
provider_model: Final = f"team-member-free-{uuid.uuid4().hex}"
model: Final = scenario.model(
model=f"openai/{provider_model}",
input_cost_per_token=0,
output_cost_per_token=0,
)
return model, provider_model
def _observed_models(upstream: httpx.Client) -> tuple[str, ...]:
response: Final = upstream.get("/__observations")
assert response.status_code == 200, response.text
observations: Final = _Observations.model_validate_json(response.text)
return tuple(string_value(request.body["model"]) for request in observations.requests)
def _chat(gateway: Gateway, model: str, key: str, text: str) -> httpx.Response:
return gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": text}]},
key=key,
)
def _team_info(gateway: Gateway, team_id: str) -> _TeamInfo:
response: Final = gateway.request("GET", "/team/info", params={"team_id": team_id})
assert response.status_code == 200, response.text
return _TeamInfo.model_validate_json(response.text)
def _member_me(gateway: Gateway, team_id: str, key: str) -> _MemberMe:
response: Final = gateway.request("GET", f"/team/{team_id}/members/me", key=key)
assert response.status_code == 200, response.text
return _MemberMe.model_validate_json(response.text)
def _budget_state(team_id: str, user_id: str) -> tuple[_MemberBudgetState, ...]:
return tuple(
_MemberBudgetState.model_validate(row)
for row in read_rows(
"SELECT tm.user_id, tm.team_id, tm.budget_id, tm.spend, b.max_budget, b.tpm_limit, b.rpm_limit, "
"b.budget_duration, b.allowed_models, b.temp_budget_increase, "
"b.temp_budget_expiry::text AS temp_budget_expiry "
'FROM "LiteLLM_TeamMembership" tm LEFT JOIN "LiteLLM_BudgetTable" b ON b.budget_id = tm.budget_id '
"WHERE tm.team_id = %s AND tm.user_id = %s",
(team_id, user_id),
)
)
def _budget_record(budget_id: str) -> tuple[_BudgetRecord, ...]:
return tuple(
_BudgetRecord.model_validate(row)
for row in read_rows(
"SELECT budget_id, max_budget, tpm_limit, rpm_limit, budget_duration, allowed_models, "
'temp_budget_increase, temp_budget_expiry FROM "LiteLLM_BudgetTable" WHERE budget_id = %s',
(budget_id,),
)
)
def _team_default_budget_id(team_id: str) -> str:
rows: Final = read_rows('SELECT metadata FROM "LiteLLM_TeamTable" WHERE team_id = %s', (team_id,))
assert len(rows) == 1, rows
metadata: Final = object_value(rows[0]["metadata"])
return string_value(metadata["team_member_budget_id"])
def _member_update(gateway: Gateway, body: dict[str, JsonValue]) -> _MemberUpdateResponse:
response: Final = gateway.request("POST", "/team/member_update", body)
assert response.status_code == 200, response.text
return _MemberUpdateResponse.model_validate_json(response.text)
def _budget_refused(response: httpx.Response) -> None:
assert response.status_code == 422, response.text
assert _ErrorResponse.model_validate_json(response.text).error.type == "budget_exceeded", response.text
@pytest.mark.covers("spend.team_member.member_without_budget_gets_membership_row_and_spend")
def test_member_added_without_any_budget_is_charged_on_its_membership_row(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002)
team: Final = scenario.team(models=[model])
user: Final = scenario.user()
added: Final = gateway.request(
"POST", "/team/member_add", {"team_id": team, "member": {"user_id": user, "role": "user"}}
)
assert added.status_code == 200, added.text
memberships: Final = added.json()["updated_team_memberships"]
assert [
{"user_id": row["user_id"], "team_id": row["team_id"], "budget_id": row["budget_id"], "spend": row["spend"]}
for row in memberships
] == [{"user_id": user, "team_id": team, "budget_id": None, "spend": 0}], added.text
assert read_rows(
'SELECT budget_id, spend, total_spend FROM "LiteLLM_TeamMembership" WHERE team_id=%s AND user_id=%s',
(team, user),
) == [{"budget_id": None, "spend": 0.0, "total_spend": 0.0}]
key: Final = scenario.key(team_id=team, user_id=user, models=[model])
assert gateway.chat(model, key=key, text=f"member spend {uuid.uuid4().hex}")["usage"]["total_tokens"] == 40
charged: Final = eventually(
lambda: read_rows(
'SELECT spend, total_spend FROM "LiteLLM_TeamMembership" WHERE team_id=%s AND user_id=%s',
(team, user),
),
lambda values: len(values) == 1 and float(values[0]["spend"]) >= 0.06,
seconds=70,
)
assert float(charged[0]["spend"]) == pytest.approx(0.06)
assert float(charged[0]["total_spend"]) == pytest.approx(0.06)
team_rows: Final = eventually(
lambda: read_rows('SELECT spend FROM "LiteLLM_TeamTable" WHERE team_id=%s', (team,)),
lambda values: len(values) == 1 and float(values[0]["spend"]) >= 0.06,
seconds=70,
)
assert float(team_rows[0]["spend"]) == pytest.approx(0.06)
info: Final = gateway.get("/team/info", {"team_id": team})
listed: Final = info["team_memberships"]
assert isinstance(listed, list)
exposed: Final = [
(object_value(row)["user_id"], object_value(row)["spend"])
for row in listed
if object_value(row)["user_id"] == user
]
assert len(exposed) == 1 and exposed[0][1] == pytest.approx(0.06), info
@pytest.mark.covers("spend.team_member.stale_low_redis_counter_still_blocks_member_over_budget")
def test_member_over_budget_is_blocked_when_redis_counter_reads_stale_low(gateway: Gateway) -> None:
with (
gateway.scenario() as scenario,
httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream,
Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"])) as cache,
):
model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002)
team: Final = scenario.team(models=[model])
user: Final = scenario.user()
added: Final = gateway.request(
"POST",
"/team/member_add",
{"team_id": team, "member": {"user_id": user, "role": "user"}, "max_budget_in_team": 0.05},
)
assert added.status_code == 200, added.text
key: Final = scenario.key(team_id=team, user_id=user, models=[model])
assert gateway.chat(model, key=key, text=f"member budget {uuid.uuid4().hex}")["usage"]["total_tokens"] == 40
charged: Final = eventually(
lambda: read_rows(
'SELECT spend FROM "LiteLLM_TeamMembership" WHERE team_id=%s AND user_id=%s', (team, user)
),
lambda values: len(values) == 1 and float(values[0]["spend"]) >= 0.06,
seconds=70,
)
assert float(charged[0]["spend"]) == pytest.approx(0.06)
counter_key: Final = f"spend:team_member:{user}:{team}"
counted: Final = eventually(lambda: cache.get(counter_key), lambda value: value is not None, seconds=10)
assert float(counted) == pytest.approx(0.06), counted
cache.set(counter_key, "0.01")
upstream.delete("/__observations").raise_for_status()
denied: Final = gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": f"stale counter {uuid.uuid4().hex}"}]},
key=key,
)
assert denied.status_code == 422 and denied.json()["error"]["type"] == "budget_exceeded", denied.text
assert upstream.get("/__observations").json()["requests"] == []
assert float(cache.get(counter_key)) == pytest.approx(0.06), denied.text
def test_member_update_lowering_the_budget_below_spend_blocks_the_next_call(
gateway: Gateway, peer: Gateway, upstream: httpx.Client
) -> None:
with gateway.scenario() as scenario:
model, provider_model = _priced_model(scenario)
team: Final = scenario.team(models=[model])
user: Final = scenario.user()
added: Final = gateway.request(
"POST", "/team/member_add", {"team_id": team, "member": {"user_id": user, "role": "user"}}
)
assert added.status_code == 200, added.text
initial_budget: Final = _member_update(
gateway,
{"team_id": team, "user_id": user, "max_budget_in_team": 1},
)
assert initial_budget == _MemberUpdateResponse(team_id=team, user_id=user, max_budget_in_team=1), initial_budget
key: Final = scenario.key(team_id=team, user_id=user)
personal_key: Final = scenario.key(user_id=user)
first: Final = _chat(gateway, model, key, f"accrued member spend {uuid.uuid4().hex}")
assert first.status_code == 200, first.text
eventually(
lambda: _budget_state(team, user),
lambda values: len(values) == 1 and values[0].spend is not None and values[0].spend >= 0.06,
seconds=70,
)
peer_first: Final = _chat(peer, model, key, f"accrued member spend on peer {uuid.uuid4().hex}")
assert peer_first.status_code == 200, peer_first.text
observed_before_update: Final = _observed_models(upstream)
assert observed_before_update == (provider_model, provider_model), repr(observed_before_update)
upstream.delete("/__observations").raise_for_status()
accrued: Final = eventually(
lambda: _budget_state(team, user),
lambda values: len(values) == 1 and values[0].spend is not None and values[0].spend >= 0.12,
seconds=70,
)
assert accrued[0].spend == pytest.approx(0.12), accrued
cached_membership: Final = _member_me(gateway, team, key)
assert cached_membership.litellm_budget_table is not None, repr(cached_membership)
assert cached_membership.litellm_budget_table.max_budget == 1, repr(cached_membership)
updated: Final = _member_update(
gateway,
{
"team_id": team,
"user_id": user,
"max_budget_in_team": 0.0001,
"budget_duration": "30d",
"tpm_limit": 1000,
"rpm_limit": 100,
},
)
assert updated == _MemberUpdateResponse(
team_id=team,
user_id=user,
max_budget_in_team=0.0001,
budget_duration="30d",
tpm_limit=1000,
rpm_limit=100,
), repr(updated)
denied: Final = _chat(gateway, model, key, f"lowered member budget {uuid.uuid4().hex}")
_budget_refused(denied)
peer_denied: Final = _chat(peer, model, key, f"lowered member budget on peer {uuid.uuid4().hex}")
_budget_refused(peer_denied)
observed_after_denial: Final = _observed_models(upstream)
assert observed_after_denial == (), repr(observed_after_denial)
assert observed_before_update + observed_after_denial == (provider_model, provider_model), observed_after_denial
state: Final = _budget_state(team, user)
assert len(state) == 1, state
assert state[0].user_id == user, state
assert state[0].team_id == team, state
assert state[0].spend == pytest.approx(0.12), state
assert state[0].max_budget == 0.0001, state
assert state[0].tpm_limit == 1000, state
assert state[0].rpm_limit == 100, state
assert state[0].budget_duration == "30d", state
team_info: Final = _team_info(gateway, team)
listed: Final = next(member for member in team_info.team_memberships if member.user_id == user)
assert listed.budget_id == state[0].budget_id, repr(listed)
assert listed.litellm_budget_table is not None, repr(listed)
assert listed.litellm_budget_table.max_budget == 0.0001, repr(listed)
assert listed.litellm_budget_table.tpm_limit == 1000, repr(listed)
assert listed.litellm_budget_table.rpm_limit == 100, repr(listed)
assert listed.litellm_budget_table.budget_duration == "30d", repr(listed)
member_info: Final = _member_me(gateway, team, personal_key)
assert member_info.budget_id == state[0].budget_id, repr(member_info)
assert member_info.litellm_budget_table is not None, repr(member_info)
assert member_info.litellm_budget_table.max_budget == 0.0001, repr(member_info)
assert member_info.litellm_budget_table.tpm_limit == 1000, repr(member_info)
assert member_info.litellm_budget_table.rpm_limit == 100, repr(member_info)
assert member_info.litellm_budget_table.budget_duration == "30d", repr(member_info)
@pytest.mark.parametrize(
"spell_expiry",
[
pytest.param(_python_isoformat, id="python-isoformat"),
pytest.param(_javascript_iso_string, id="javascript-toisostring"),
],
)
@pytest.mark.parametrize(
"budget_source",
[
pytest.param("own-budget", id="own-budget"),
pytest.param("team-default", id="team-default"),
],
)
def test_member_update_temp_budget_increase_readmits_a_blocked_member(
gateway: Gateway,
upstream: httpx.Client,
spell_expiry: Callable[[datetime], str],
budget_source: str,
) -> None:
with gateway.scenario() as scenario:
model, provider_model = _priced_model(scenario)
team: Final = (
scenario.team(models=[model], team_member_budget=0.05)
if budget_source == "team-default"
else scenario.team(models=[model])
)
user: Final = scenario.user()
member_add_body: Final = {"team_id": team, "member": {"user_id": user, "role": "user"}}
added: Final = gateway.request(
"POST",
"/team/member_add",
member_add_body | ({"max_budget_in_team": 0.05} if budget_source == "own-budget" else {}),
)
assert added.status_code == 200, added.text
default_budget_id: Final = _team_default_budget_id(team) if budget_source == "team-default" else None
default_budget_before: Final = (
_budget_record(default_budget_id) if default_budget_id is not None else ()
)
if default_budget_id is not None:
assert len(default_budget_before) == 1, repr(default_budget_before)
assert default_budget_before[0].max_budget == 0.05, default_budget_before
assert default_budget_before[0].temp_budget_increase is None, default_budget_before
assert default_budget_before[0].temp_budget_expiry is None, default_budget_before
initial_state: Final = _budget_state(team, user)
assert len(initial_state) == 1, initial_state
if budget_source == "own-budget":
assert initial_state[0].max_budget == 0.05, initial_state
else:
assert default_budget_id is not None
assert initial_state[0].budget_id == default_budget_id, initial_state
key: Final = scenario.key(team_id=team, user_id=user)
first: Final = _chat(gateway, model, key, f"initial member budget {uuid.uuid4().hex}")
assert first.status_code == 200, first.text
observed_before_block: Final = _observed_models(upstream)
assert observed_before_block == (provider_model,), repr(observed_before_block)
upstream.delete("/__observations").raise_for_status()
accrued: Final = eventually(
lambda: _budget_state(team, user),
lambda values: len(values) == 1 and values[0].spend is not None and values[0].spend >= 0.06,
seconds=70,
)
assert accrued[0].spend == pytest.approx(0.06), accrued
blocked: Final = _chat(gateway, model, key, f"exceeded member budget {uuid.uuid4().hex}")
_budget_refused(blocked)
observed_after_block: Final = _observed_models(upstream)
assert observed_after_block == (), repr(observed_after_block)
expiry: Final = (datetime.now(timezone.utc) + timedelta(days=1)).replace(microsecond=0)
updated: Final = _member_update(
gateway,
{
"team_id": team,
"user_id": user,
"temp_budget_increase": 1.0,
"temp_budget_expiry": spell_expiry(expiry),
},
)
assert updated.temp_budget_expiry is not None, repr(updated)
assert updated.model_copy(
update={"temp_budget_expiry": updated.temp_budget_expiry.replace(tzinfo=timezone.utc)}
) == _MemberUpdateResponse(
team_id=team,
user_id=user,
temp_budget_increase=1.0,
temp_budget_expiry=expiry,
), repr(updated)
state: Final = _budget_state(team, user)
assert len(state) == 1, state
if budget_source == "own-budget":
assert state[0].max_budget == 0.05, state
else:
assert default_budget_id is not None
assert state[0].budget_id != default_budget_id, state
assert state[0].max_budget is None, state
default_budget_after: Final = _budget_record(default_budget_id)
assert default_budget_after == default_budget_before, repr(default_budget_after)
assert default_budget_after[0].max_budget == 0.05, default_budget_after
assert default_budget_after[0].temp_budget_increase is None, default_budget_after
assert default_budget_after[0].temp_budget_expiry is None, default_budget_after
assert state[0].temp_budget_increase == 1.0, state
assert state[0].temp_budget_expiry is not None, state
assert state[0].temp_budget_expiry.replace(tzinfo=timezone.utc) == expiry, state
team_info: Final = _team_info(gateway, team)
listed: Final = next(member for member in team_info.team_memberships if member.user_id == user)
assert listed.budget_id == state[0].budget_id, repr(listed)
assert listed.litellm_budget_table is not None, repr(listed)
assert listed.litellm_budget_table.budget_id == state[0].budget_id, repr(listed)
assert listed.litellm_budget_table.temp_budget_increase == 1.0, repr(listed)
assert listed.litellm_budget_table.temp_budget_expiry is not None, repr(listed)
assert listed.litellm_budget_table.temp_budget_expiry.replace(tzinfo=timezone.utc) == expiry, repr(listed)
member_info: Final = _member_me(gateway, team, key)
assert member_info.budget_id == state[0].budget_id, repr(member_info)
assert member_info.litellm_budget_table is not None, repr(member_info)
assert member_info.litellm_budget_table.budget_id == state[0].budget_id, repr(member_info)
assert member_info.litellm_budget_table.temp_budget_increase == 1.0, repr(member_info)
assert member_info.litellm_budget_table.temp_budget_expiry is not None, repr(member_info)
assert member_info.litellm_budget_table.temp_budget_expiry.replace(tzinfo=timezone.utc) == expiry, repr(
member_info
)
readmitted: Final = _chat(gateway, model, key, f"temporary member budget {uuid.uuid4().hex}")
assert readmitted.status_code == 200, readmitted.text
observed_after_readmission: Final = _observed_models(upstream)
assert observed_after_readmission == (provider_model,), repr(observed_after_readmission)
assert observed_before_block + observed_after_readmission == (provider_model, provider_model), repr(
(observed_before_block, observed_after_readmission)
)
def test_member_rpm_limit_update_changes_runtime_admission(gateway: Gateway, upstream: httpx.Client) -> None:
pytest.skip("BUG: member_update leaves a warmed key using the old RPM limit")
with gateway.scenario() as scenario:
model, provider_model = _free_model(scenario)
team: Final = scenario.team(models=[model])
user: Final = scenario.user()
added: Final = gateway.request(
"POST",
"/team/member_add",
{"team_id": team, "member": {"user_id": user, "role": "user"}},
)
assert added.status_code == 200, added.text
key: Final = scenario.key(team_id=team, user_id=user)
warm: Final = _chat(gateway, model, key, f"warm member key before rpm limit {uuid.uuid4().hex}")
assert warm.status_code == 200, warm.text
updated: Final = _member_update(gateway, {"team_id": team, "user_id": user, "rpm_limit": 1})
assert updated.rpm_limit == 1, repr(updated)
admitted: Final = _chat(gateway, model, key, f"member rpm admitted request {uuid.uuid4().hex}")
assert admitted.status_code == 200, admitted.text
limited: Final = _chat(gateway, model, key, f"member rpm limited request {uuid.uuid4().hex}")
assert limited.status_code == 429, limited.text
assert _ErrorResponse.model_validate_json(limited.text).error.type == "throttling_error", limited.text
observed: Final = _observed_models(upstream)
assert observed == (provider_model, provider_model), repr(observed)
def test_member_rpm_limit_update_changes_runtime_admission_for_cold_key(
gateway: Gateway, upstream: httpx.Client
) -> None:
with gateway.scenario() as scenario:
model, provider_model = _free_model(scenario)
team: Final = scenario.team(models=[model])
user: Final = scenario.user()
added: Final = gateway.request(
"POST",
"/team/member_add",
{"team_id": team, "member": {"user_id": user, "role": "user"}},
)
assert added.status_code == 200, added.text
key: Final = scenario.key(team_id=team, user_id=user)
updated: Final = _member_update(gateway, {"team_id": team, "user_id": user, "rpm_limit": 1})
assert updated.rpm_limit == 1, repr(updated)
admitted: Final = _chat(gateway, model, key, f"cold member rpm admitted request {uuid.uuid4().hex}")
assert admitted.status_code == 200, admitted.text
limited: Final = _chat(gateway, model, key, f"cold member rpm limited request {uuid.uuid4().hex}")
assert limited.status_code == 429, limited.text
assert _ErrorResponse.model_validate_json(limited.text).error.type == "throttling_error", limited.text
observed: Final = _observed_models(upstream)
assert observed == (provider_model,), repr(observed)
def test_member_tpm_limit_update_changes_runtime_admission(gateway: Gateway, upstream: httpx.Client) -> None:
pytest.skip("BUG: member_update leaves a warmed key using the old TPM limit")
with gateway.scenario() as scenario:
model, provider_model = _free_model(scenario)
team: Final = scenario.team(models=[model])
user: Final = scenario.user()
added: Final = gateway.request(
"POST",
"/team/member_add",
{"team_id": team, "member": {"user_id": user, "role": "user"}},
)
assert added.status_code == 200, added.text
key: Final = scenario.key(team_id=team, user_id=user)
warm: Final = _chat(gateway, model, key, f"warm member key before tpm limit {uuid.uuid4().hex}")
assert warm.status_code == 200, warm.text
updated: Final = _member_update(gateway, {"team_id": team, "user_id": user, "tpm_limit": 30})
assert updated.tpm_limit == 30, repr(updated)
admitted: Final = _chat(gateway, model, key, f"first member tpm-limited request {uuid.uuid4().hex}")
assert admitted.status_code == 200, admitted.text
limited: Final = _chat(gateway, model, key, f"second member tpm-limited request {uuid.uuid4().hex}")
assert limited.status_code == 429, limited.text
assert _ErrorResponse.model_validate_json(limited.text).error.type == "throttling_error", limited.text
observed: Final = _observed_models(upstream)
assert observed == (provider_model, provider_model), repr(observed)
def test_member_tpm_limit_update_changes_runtime_admission_for_cold_key(
gateway: Gateway, upstream: httpx.Client
) -> None:
with gateway.scenario() as scenario:
model, provider_model = _free_model(scenario)
team: Final = scenario.team(models=[model])
user: Final = scenario.user()
added: Final = gateway.request(
"POST",
"/team/member_add",
{"team_id": team, "member": {"user_id": user, "role": "user"}},
)
assert added.status_code == 200, added.text
key: Final = scenario.key(team_id=team, user_id=user)
updated: Final = _member_update(gateway, {"team_id": team, "user_id": user, "tpm_limit": 30})
assert updated.tpm_limit == 30, repr(updated)
admitted: Final = _chat(gateway, model, key, f"cold member tpm admitted request {uuid.uuid4().hex}")
assert admitted.status_code == 200, admitted.text
limited: Final = _chat(gateway, model, key, f"cold member tpm limited request {uuid.uuid4().hex}")
assert limited.status_code == 429, limited.text
assert _ErrorResponse.model_validate_json(limited.text).error.type == "throttling_error", limited.text
observed: Final = _observed_models(upstream)
assert observed == (provider_model,), repr(observed)
def test_member_update_by_email_sets_models_role_and_forks_the_team_default_budget(
gateway: Gateway, upstream: httpx.Client
) -> None:
with gateway.scenario() as scenario:
model_one, provider_one = _free_model(scenario)
model_two, provider_two = _free_model(scenario)
team: Final = scenario.team(models=[model_one, model_two], team_member_budget=25)
email_one: Final = f"member-{uuid.uuid4().hex}@example.com"
user_one: Final = scenario.user(user_email=email_one, user_role="internal_user")
user_two: Final = scenario.user(user_role="internal_user")
added: Final = gateway.request(
"POST",
"/team/member_add",
{
"team_id": team,
"member": [
{"user_email": email_one, "role": "user"},
{"user_id": user_two, "role": "user"},
],
},
)
assert added.status_code == 200, added.text
default_budget_id: Final = _team_default_budget_id(team)
default_budget_before: Final = _budget_record(default_budget_id)
assert len(default_budget_before) == 1, repr(default_budget_before)
initial_one: Final = _budget_state(team, user_one)
initial_two: Final = _budget_state(team, user_two)
assert len(initial_one) == 1 and initial_one[0].budget_id == default_budget_id, initial_one
assert len(initial_two) == 1 and initial_two[0].budget_id == default_budget_id, initial_two
user_one_key: Final = scenario.key(team_id=team, user_id=user_one)
model_scope: Final = _member_update(
gateway,
{"team_id": team, "user_email": email_one, "allowed_models": [model_one]},
)
assert model_scope.user_id == user_one, repr(model_scope)
assert model_scope.allowed_models == [model_one], repr(model_scope)
scoped_state: Final = _budget_state(team, user_one)
assert len(scoped_state) == 1, scoped_state
assert scoped_state[0].allowed_models == [model_one], scoped_state
assert scoped_state[0].budget_id != default_budget_id, scoped_state
other_member_state: Final = _budget_state(team, user_two)
assert other_member_state[0].budget_id == default_budget_id, other_member_state
assert _budget_record(default_budget_id) == default_budget_before, repr(default_budget_before)
allowed: Final = _chat(gateway, model_one, user_one_key, f"email allowed model {uuid.uuid4().hex}")
assert allowed.status_code == 200, allowed.text
denied: Final = _chat(gateway, model_two, user_one_key, f"email restricted model {uuid.uuid4().hex}")
assert denied.status_code == 403, denied.text
assert _ErrorResponse.model_validate_json(denied.text).error.type == "team_model_access_denied", denied.text
observed_before_clear: Final = _observed_models(upstream)
assert observed_before_clear == (provider_one,), repr(observed_before_clear)
upstream.delete("/__observations").raise_for_status()
cleared_models: Final = _member_update(
gateway,
{"team_id": team, "user_email": email_one, "allowed_models": []},
)
assert cleared_models.user_id == user_one, repr(cleared_models)
assert cleared_models.allowed_models == [], repr(cleared_models)
cleared_state: Final = _budget_state(team, user_one)
assert len(cleared_state) == 1, cleared_state
assert cleared_state[0].allowed_models == [], cleared_state
unrestricted: Final = _chat(gateway, model_two, user_one_key, f"email cleared model scope {uuid.uuid4().hex}")
assert unrestricted.status_code == 200, unrestricted.text
observed_after_clear: Final = _observed_models(upstream)
assert observed_after_clear == (provider_two,), repr(observed_after_clear)
budget_update: Final = _member_update(
gateway,
{"team_id": team, "user_email": email_one, "max_budget_in_team": 12},
)
assert budget_update.user_id == user_one, repr(budget_update)
assert budget_update.max_budget_in_team == 12, repr(budget_update)
user_one_state: Final = _budget_state(team, user_one)
user_two_state: Final = _budget_state(team, user_two)
assert len(user_one_state) == 1 and user_one_state[0].budget_id != default_budget_id, user_one_state
assert user_one_state[0].max_budget == 12, user_one_state
assert user_two_state == initial_two, user_two_state
assert _budget_record(default_budget_id) == default_budget_before, repr(default_budget_before)
team_info: Final = _team_info(gateway, team)
listed_one: Final = next(member for member in team_info.team_memberships if member.user_id == user_one)
assert listed_one.litellm_budget_table is not None, repr(listed_one)
assert listed_one.litellm_budget_table.max_budget == 12, repr(listed_one)
assert listed_one.litellm_budget_table.allowed_models == [], repr(listed_one)
member_info: Final = _member_me(gateway, team, user_one_key)
assert member_info.litellm_budget_table is not None, repr(member_info)
assert member_info.litellm_budget_table.max_budget == 12, repr(member_info)
assert member_info.litellm_budget_table.allowed_models == [], repr(member_info)
roster_member: Final = next(
member for member in team_info.team_info.members_with_roles if member.user_id == user_one
)
assert roster_member.role == "user", repr(roster_member)
new_member: Final = scenario.user()
user_key: Final = scenario.key(user_id=user_one)
denied_admin_action: Final = gateway.request(
"POST",
"/team/member_add",
{"team_id": team, "member": {"user_id": new_member, "role": "user"}},
key=user_key,
)
assert denied_admin_action.status_code == 403, denied_admin_action.text
role_update: Final = _member_update(
gateway,
{"team_id": team, "user_email": email_one, "role": "admin"},
)
assert role_update.user_id == user_one, repr(role_update)
role_info: Final = _team_info(gateway, team)
admin_roster: Final = next(
member for member in role_info.team_info.members_with_roles if member.user_id == user_one
)
assert admin_roster.role == "admin", repr(admin_roster)
roster_rows: Final = read_rows(
'SELECT members_with_roles FROM "LiteLLM_TeamTable" WHERE team_id = %s',
(team,),
)
assert len(roster_rows) == 1, roster_rows
database_roster: Final = _TeamState.model_validate(
{"team_id": team, "members_with_roles": roster_rows[0]["members_with_roles"]}
).members_with_roles
assert next(member for member in database_roster if member.user_id == user_one).role == "admin", repr(
database_roster
)
allowed_admin_action: Final = gateway.request(
"POST",
"/team/member_add",
{"team_id": team, "member": {"user_id": new_member, "role": "user"}},
key=user_key,
)
assert allowed_admin_action.status_code == 200, allowed_admin_action.text
assert observed_before_clear + observed_after_clear == (provider_one, provider_two), repr(
(observed_before_clear, observed_after_clear)
)
def test_bulk_update_is_a_merge_patch_per_member(gateway: Gateway, upstream: httpx.Client) -> None:
with gateway.scenario() as scenario:
model_one, provider_one = _free_model(scenario)
model_two, _ = _free_model(scenario)
spend_model, provider_spend = _priced_model(scenario)
team: Final = scenario.team(models=[model_one, model_two, spend_model], team_member_budget=25)
user_one: Final = scenario.user()
email_two: Final = f"bulk-member-{uuid.uuid4().hex}@example.com"
user_two: Final = scenario.user(user_email=email_two, user_role="internal_user")
added: Final = gateway.request(
"POST",
"/team/member_add",
{
"team_id": team,
"member": [
{"user_id": user_one, "role": "user"},
{"user_email": email_two, "role": "user"},
],
},
)
assert added.status_code == 200, added.text
default_budget_id: Final = _team_default_budget_id(team)
default_budget_before: Final = _budget_record(default_budget_id)
key_one: Final = scenario.key(team_id=team, user_id=user_one)
key_two: Final = scenario.key(team_id=team, user_id=user_two)
personal_key_one: Final = scenario.key(user_id=user_one)
first_chat: Final = _chat(gateway, spend_model, key_one, f"bulk member spend {uuid.uuid4().hex}")
assert first_chat.status_code == 200, first_chat.text
observed_before_update: Final = _observed_models(upstream)
assert observed_before_update == (provider_spend,), repr(observed_before_update)
upstream.delete("/__observations").raise_for_status()
accrued: Final = eventually(
lambda: _budget_state(team, user_one),
lambda values: len(values) == 1 and values[0].spend is not None and values[0].spend >= 0.06,
seconds=70,
)
assert accrued[0].spend == pytest.approx(0.06), accrued
update: Final = gateway.request(
"POST",
f"/management/v1/teams/{team}/members/bulk_update",
{
"members": [
{"user_id": user_one, "max_budget_in_team": 0.0001, "rpm_limit": 7},
{"user_email": email_two, "rpm_limit": 5, "allowed_models": [model_one]},
]
},
)
assert update.status_code == 200, update.text
update_response: Final = _BulkBudgetUpdateResponse.model_validate_json(update.text)
state_one: Final = _budget_state(team, user_one)
state_two: Final = _budget_state(team, user_two)
assert len(state_one) == 1 and len(state_two) == 1, (state_one, state_two)
assert state_one[0].budget_id is not None, state_one
assert state_two[0].budget_id is not None, state_two
assert state_one[0].budget_id != default_budget_id, state_one
assert state_two[0].budget_id != default_budget_id, state_two
assert update_response.data == (
_BulkBudgetResult(
user_id=user_one,
success=True,
budget_id=state_one[0].budget_id,
max_budget=0.0001,
max_budget_source="member",
rpm_limit=7,
allowed_models=tuple(state_one[0].allowed_models or ()),
),
_BulkBudgetResult(
user_id=user_two,
user_email=email_two,
success=True,
budget_id=state_two[0].budget_id,
max_budget=25,
max_budget_source="member",
rpm_limit=5,
allowed_models=(model_one,),
),
), update.text
assert state_one[0].max_budget == 0.0001 and state_one[0].rpm_limit == 7, state_one
assert state_two[0].max_budget == 25 and state_two[0].rpm_limit == 5, state_two
assert state_two[0].allowed_models == [model_one], state_two
assert _budget_record(default_budget_id) == default_budget_before, repr(default_budget_before)
info: Final = _team_info(gateway, team)
info_one: Final = next(member for member in info.team_memberships if member.user_id == user_one)
assert info_one.litellm_budget_table is not None, repr(info_one)
assert info_one.litellm_budget_table.max_budget == 0.0001, repr(info_one)
assert info_one.litellm_budget_table.rpm_limit == 7, repr(info_one)
me_one: Final = _member_me(gateway, team, personal_key_one)
assert me_one.litellm_budget_table is not None, repr(me_one)
assert me_one.litellm_budget_table.max_budget == 0.0001, repr(me_one)
assert me_one.litellm_budget_table.rpm_limit == 7, repr(me_one)
over_budget: Final = _chat(gateway, spend_model, key_one, f"bulk member blocked {uuid.uuid4().hex}")
_budget_refused(over_budget)
observed_after_over_budget: Final = _observed_models(upstream)
assert observed_after_over_budget == (), repr(observed_after_over_budget)
allowed_two: Final = _chat(gateway, model_one, key_two, f"bulk member scoped allowed {uuid.uuid4().hex}")
assert allowed_two.status_code == 200, allowed_two.text
denied_two: Final = _chat(gateway, model_two, key_two, f"bulk member scoped denied {uuid.uuid4().hex}")
assert denied_two.status_code == 403, denied_two.text
assert _ErrorResponse.model_validate_json(denied_two.text).error.type == "team_model_access_denied", (
denied_two.text
)
observed_after_first_patch: Final = _observed_models(upstream)
assert observed_after_first_patch == (provider_one,), repr(observed_after_first_patch)
upstream.delete("/__observations").raise_for_status()
cleared_max: Final = gateway.request(
"POST",
f"/management/v1/teams/{team}/members/bulk_update",
{"members": [{"user_id": user_one, "max_budget_in_team": None}]},
)
assert cleared_max.status_code == 200, cleared_max.text
cleared_max_response: Final = _BulkBudgetUpdateResponse.model_validate_json(cleared_max.text)
cleared_max_state: Final = _budget_state(team, user_one)
assert len(cleared_max_state) == 1, cleared_max_state
assert cleared_max_response.data == (
_BulkBudgetResult(
user_id=user_one,
success=True,
budget_id=cleared_max_state[0].budget_id,
max_budget=25,
max_budget_source="team_default",
rpm_limit=7,
allowed_models=tuple(cleared_max_state[0].allowed_models or ()),
),
), cleared_max.text
assert cleared_max_state[0].budget_id == state_one[0].budget_id, cleared_max_state
assert cleared_max_state[0].max_budget is None, cleared_max_state
assert cleared_max_state[0].rpm_limit == 7, cleared_max_state
assert _budget_record(default_budget_id) == default_budget_before, repr(default_budget_before)
me_after_max_clear: Final = _member_me(gateway, team, personal_key_one)
assert me_after_max_clear.litellm_budget_table is not None, repr(me_after_max_clear)
assert me_after_max_clear.litellm_budget_table.max_budget is None, repr(me_after_max_clear)
assert me_after_max_clear.litellm_budget_table.rpm_limit == 7, repr(me_after_max_clear)
admitted_again: Final = _chat(gateway, spend_model, key_one, f"bulk member readmitted {uuid.uuid4().hex}")
assert admitted_again.status_code == 200, admitted_again.text
observed_after_max_clear: Final = _observed_models(upstream)
assert observed_after_max_clear == (provider_spend,), repr(observed_after_max_clear)
assert observed_before_update + observed_after_first_patch + observed_after_max_clear == (
provider_spend,
provider_one,
provider_spend,
), repr((observed_before_update, observed_after_first_patch, observed_after_max_clear))
def test_bulk_update_restores_team_default_when_last_member_limit_is_cleared(
gateway: Gateway, upstream: httpx.Client
) -> None:
with gateway.scenario() as scenario:
model, provider_model = _priced_model(scenario)
team: Final = scenario.team(models=[model], team_member_budget=0.0001)
user: Final = scenario.user()
added: Final = gateway.request(
"POST",
"/team/member_add",
{"team_id": team, "member": {"user_id": user, "role": "user"}},
)
assert added.status_code == 200, added.text
default_budget_id: Final = _team_default_budget_id(team)
default_budget_before: Final = _budget_record(default_budget_id)
initial_state: Final = _budget_state(team, user)
assert len(initial_state) == 1 and initial_state[0].budget_id == default_budget_id, initial_state
key: Final = scenario.key(team_id=team, user_id=user, models=[model])
increased: Final = gateway.request(
"POST",
f"/management/v1/teams/{team}/members/bulk_update",
{"members": [{"user_id": user, "max_budget_in_team": 5}]},
)
assert increased.status_code == 200, increased.text
increased_response: Final = _BulkBudgetUpdateResponse.model_validate_json(increased.text)
increased_state: Final = _budget_state(team, user)
assert len(increased_state) == 1, increased_state
assert increased_state[0].budget_id is not None and increased_state[0].budget_id != default_budget_id, (
increased_state
)
assert increased_response.data == (
_BulkBudgetResult(
user_id=user,
success=True,
budget_id=increased_state[0].budget_id,
max_budget=5,
max_budget_source="member",
allowed_models=tuple(increased_state[0].allowed_models or ()),
),
), increased.text
first_chat: Final = _chat(gateway, model, key, f"member raised limit {uuid.uuid4().hex}")
assert first_chat.status_code == 200, first_chat.text
first_observations: Final = _observed_models(upstream)
assert first_observations == (provider_model,), repr(first_observations)
upstream.delete("/__observations").raise_for_status()
accrued: Final = eventually(
lambda: _budget_state(team, user),
lambda values: len(values) == 1 and values[0].spend is not None and values[0].spend >= 0.06,
seconds=70,
)
assert accrued[0].spend == pytest.approx(0.06), accrued
second_chat: Final = _chat(gateway, model, key, f"member still under limit {uuid.uuid4().hex}")
assert second_chat.status_code == 200, second_chat.text
second_observations: Final = _observed_models(upstream)
assert second_observations == (provider_model,), repr(second_observations)
upstream.delete("/__observations").raise_for_status()
cleared: Final = gateway.request(
"POST",
f"/management/v1/teams/{team}/members/bulk_update",
{"members": [{"user_id": user, "max_budget_in_team": None}]},
)
assert cleared.status_code == 200, cleared.text
cleared_response: Final = _BulkBudgetUpdateResponse.model_validate_json(cleared.text)
final_state: Final = _budget_state(team, user)
assert len(final_state) == 1, final_state
assert final_state[0].budget_id is None, final_state
assert cleared_response.data == (
_BulkBudgetResult(
user_id=user,
success=True,
budget_id=None,
max_budget=0.0001,
max_budget_source="team_default",
tpm_limit=None,
rpm_limit=None,
budget_duration=None,
allowed_models=None,
),
), cleared.text
assert read_rows(
'SELECT budget_id FROM "LiteLLM_TeamMembership" WHERE team_id=%s AND user_id=%s',
(team, user),
) == [{"budget_id": None}], cleared.text
assert _budget_record(default_budget_id) == default_budget_before, repr(default_budget_before)
refused: Final = _chat(gateway, model, key, f"member default cap reached {uuid.uuid4().hex}")
_budget_refused(refused)
observed_after_refusal: Final = _observed_models(upstream)
assert observed_after_refusal == (), repr(observed_after_refusal)
assert first_observations + second_observations == (provider_model, provider_model), repr(
(first_observations, second_observations)
)