mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
Merge branch 'main' into litellm_/buildkite-241-triage-4a48cf
This commit is contained in:
commit
fa01e2d5b7
4 changed files with 237 additions and 5 deletions
|
|
@ -3,16 +3,15 @@ from __future__ import annotations
|
|||
import os
|
||||
import time
|
||||
import uuid
|
||||
from hashlib import sha256
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from typing import Final, TypeVar
|
||||
|
||||
import httpx
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
from integration._support.database import read_rows
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
T = TypeVar("T")
|
||||
|
|
@ -124,6 +123,16 @@ class Scenario:
|
|||
assert response.status_code == 200, response.text
|
||||
assert read_rows('SELECT project_id FROM "LiteLLM_ProjectTable" WHERE project_id = %s', (identity,)) == []
|
||||
|
||||
def budget(self, **fields: JsonValue) -> str:
|
||||
created: Final = self.gateway.post("/budget/new", fields)
|
||||
identity: Final = string_value(created["budget_id"])
|
||||
self.cleanups.callback(self.delete_budget, identity)
|
||||
return identity
|
||||
|
||||
def delete_budget(self, identity: str) -> None:
|
||||
self.gateway.post("/budget/delete", {"id": identity})
|
||||
assert read_rows('SELECT budget_id FROM "LiteLLM_BudgetTable" WHERE budget_id = %s', (identity,)) == []
|
||||
|
||||
def user(self, **fields: JsonValue) -> str:
|
||||
created: Final = self.gateway.post(
|
||||
"/user/new", {"user_id": f"integration-{uuid.uuid4().hex}", "auto_create_key": False, **fields}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,23 @@
|
|||
"tests/integration/spend/test_filtered_ledger.py::test_rotated_keys_users_and_model_groups_preserve_success_failure_cache_ledger": [
|
||||
"quota_management.spend_tracking.filtered_ledger_preserves_owner_identity_and_totals"
|
||||
],
|
||||
"tests/integration/management/test_partial_update_sequences.py::test_restricted_actor_cannot_detach_key_from_project": [
|
||||
"mgmt.key.update.project_detach_denied_to_restricted_actor"
|
||||
],
|
||||
"tests/integration/management/test_partial_update_sequences.py::test_cross_tenant_actor_cannot_read_update_or_detach_project_key": [
|
||||
"mgmt.key.info.cross_tenant_key_is_denied",
|
||||
"mgmt.key.update.cross_tenant_key_is_denied",
|
||||
"mgmt.key.update.cross_tenant_project_detach_is_denied"
|
||||
],
|
||||
"tests/integration/management/test_project_lifecycle.py::test_project_new_persists_real_state": [
|
||||
"mgmt.project.new.real_route_persists"
|
||||
],
|
||||
"tests/integration/management/test_project_lifecycle.py::test_project_update_persists_real_state": [
|
||||
"mgmt.project.update.real_route_persists"
|
||||
],
|
||||
"tests/integration/management/test_project_lifecycle.py::test_project_delete_with_attached_key_refuses_and_preserves_state": [
|
||||
"mgmt.project.delete.attached_key_refusal_preserves_state"
|
||||
],
|
||||
"tests/integration/sdk/test_http2_wire.py::test_async_handler_negotiates_http2_only_when_enabled": [
|
||||
"other.sdk_wire.http2.async_handler_negotiates_h2_only_when_enabled"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -5,11 +5,21 @@ from typing import Final
|
|||
import pytest
|
||||
from hypothesis import strategies as st
|
||||
from hypothesis.stateful import RuleBasedStateMachine, invariant, rule, run_state_machine_as_test
|
||||
from pydantic import JsonValue
|
||||
|
||||
from integration._support.client import Gateway, object_value
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_requests
|
||||
from pydantic import JsonValue
|
||||
|
||||
|
||||
def _key_rows(digest: str) -> list[dict[str, JsonValue]]:
|
||||
return read_rows(
|
||||
'SELECT token, key_name, key_alias, models, aliases, config, router_settings, user_id, team_id, '
|
||||
'agent_id, project_id, permissions, max_parallel_requests, metadata, blocked, tpm_limit, rpm_limit, '
|
||||
'tpd_limit, max_budget, budget_duration, allowed_cache_controls, allowed_routes, key_type, policies, '
|
||||
'access_group_ids, model_spend, model_max_budget, budget_fallbacks, budget_id, organization_id, '
|
||||
'object_permission_id, budget_limits FROM "LiteLLM_VerificationToken" WHERE token = %s',
|
||||
(digest,),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.covers("mgmt.key.update.generated_sequences_preserve_state")
|
||||
|
|
@ -198,3 +208,84 @@ def test_denied_key_update_preserves_saved_grants_and_serving(gateway: Gateway)
|
|||
)
|
||||
assert rejected.status_code == 403, rejected.text
|
||||
assert rejected.json()["error"]["type"] == "key_model_access_denied"
|
||||
|
||||
|
||||
@pytest.mark.covers("mgmt.key.update.project_detach_denied_to_restricted_actor")
|
||||
def test_restricted_actor_cannot_detach_key_from_project(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
model: Final = scenario.model()
|
||||
team: Final = scenario.team(models=[model], team_member_permissions=["/key/update"])
|
||||
project: Final = scenario.project(team, models=[model])
|
||||
member: Final = scenario.user(user_role="internal_user")
|
||||
gateway.post(
|
||||
"/team/member_add",
|
||||
{"team_id": team, "member": {"user_id": member, "role": "user"}},
|
||||
)
|
||||
target: Final = scenario.key(user_id=member, team_id=team, project_id=project, models=[model])
|
||||
caller: Final = scenario.key(
|
||||
user_id=member,
|
||||
team_id=team,
|
||||
models=[model],
|
||||
allowed_routes=["/key/update"],
|
||||
)
|
||||
digest: Final = sha256(target.encode()).hexdigest()
|
||||
before: Final = _key_rows(digest)
|
||||
assert len(before) == 1
|
||||
assert before[0]["project_id"] == project
|
||||
assert before[0]["team_id"] == team
|
||||
denied: Final = gateway.request(
|
||||
"POST", "/key/update", {"key": target, "project_id": None}, key=caller
|
||||
)
|
||||
assert denied.status_code == 403, denied.text
|
||||
assert _key_rows(digest) == before
|
||||
|
||||
|
||||
@pytest.mark.covers(
|
||||
"mgmt.key.info.cross_tenant_key_is_denied",
|
||||
"mgmt.key.update.cross_tenant_key_is_denied",
|
||||
"mgmt.key.update.cross_tenant_project_detach_is_denied",
|
||||
)
|
||||
def test_cross_tenant_actor_cannot_read_update_or_detach_project_key(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
model: Final = scenario.model()
|
||||
team: Final = scenario.team(models=[model])
|
||||
foreign_team: Final = scenario.team(models=[model])
|
||||
project: Final = scenario.project(team, models=[model])
|
||||
foreign_user: Final = scenario.user(user_role="internal_user")
|
||||
gateway.post(
|
||||
"/team/member_add",
|
||||
{"team_id": foreign_team, "member": {"user_id": foreign_user, "role": "user"}},
|
||||
)
|
||||
target: Final = scenario.key(team_id=team, project_id=project, models=[model])
|
||||
caller: Final = scenario.key(
|
||||
user_id=foreign_user,
|
||||
team_id=foreign_team,
|
||||
models=[model],
|
||||
allowed_routes=["/key/info", "/key/update"],
|
||||
)
|
||||
digest: Final = sha256(target.encode()).hexdigest()
|
||||
before: Final = _key_rows(digest)
|
||||
assert len(before) == 1
|
||||
assert before[0]["project_id"] == project
|
||||
assert before[0]["team_id"] == team
|
||||
info_denied: Final = gateway.request(
|
||||
"GET", "/key/info", params={"key": digest}, key=caller
|
||||
)
|
||||
assert info_denied.status_code == 403, info_denied.text
|
||||
assert target not in info_denied.text
|
||||
assert digest not in info_denied.text
|
||||
assert project not in info_denied.text
|
||||
assert team not in info_denied.text
|
||||
update_denied: Final = gateway.request(
|
||||
"POST", "/key/update", {"key": target, "key_alias": "foreign-update"}, key=caller
|
||||
)
|
||||
assert update_denied.status_code == 401, update_denied.text
|
||||
detach_denied: Final = gateway.request(
|
||||
"POST", "/key/update", {"key": target, "project_id": None}, key=caller
|
||||
)
|
||||
assert detach_denied.status_code == 401, detach_denied.text
|
||||
for response in (update_denied, detach_denied):
|
||||
assert target not in response.text
|
||||
assert digest not in response.text
|
||||
assert project not in response.text
|
||||
assert _key_rows(digest) == before
|
||||
|
|
|
|||
115
tests/integration/management/test_project_lifecycle.py
Normal file
115
tests/integration/management/test_project_lifecycle.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
from hashlib import sha256
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from integration._support.client import Gateway, object_value, string_value
|
||||
from integration._support.database import read_rows
|
||||
from pydantic import JsonValue
|
||||
|
||||
|
||||
def _project_rows(project_id: str) -> list[dict[str, JsonValue]]:
|
||||
return read_rows(
|
||||
'SELECT p.project_id, p.project_alias, p.description, p.team_id, p.models, p.blocked, '
|
||||
'p.budget_id, b.max_budget FROM "LiteLLM_ProjectTable" AS p '
|
||||
'LEFT JOIN "LiteLLM_BudgetTable" AS b ON b.budget_id = p.budget_id '
|
||||
'WHERE p.project_id = %s',
|
||||
(project_id,),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.covers("mgmt.project.new.real_route_persists")
|
||||
def test_project_new_persists_real_state(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
model: Final = scenario.model()
|
||||
team: Final = scenario.team(models=[model])
|
||||
budget: Final = scenario.budget(max_budget=7)
|
||||
project: Final = scenario.project(
|
||||
team, project_alias="new-project", budget_id=budget, models=[model], description="new project"
|
||||
)
|
||||
key: Final = scenario.key(team_id=team, project_id=project, models=[model])
|
||||
assert object_value(gateway.chat(model, key=key)["usage"])["total_tokens"] == 40
|
||||
rows: Final = _project_rows(project)
|
||||
assert rows != []
|
||||
assert len(rows) == 1
|
||||
row: Final = rows[0]
|
||||
assert row["project_id"] == project
|
||||
assert row["project_alias"] == "new-project"
|
||||
assert row["team_id"] == team
|
||||
assert row["description"] == "new project"
|
||||
assert row["models"] == [model]
|
||||
assert row["budget_id"] == budget
|
||||
assert row["blocked"] is False
|
||||
assert row["max_budget"] == 7.0
|
||||
|
||||
|
||||
@pytest.mark.covers("mgmt.project.update.real_route_persists")
|
||||
def test_project_update_persists_real_state(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
model: Final = scenario.model()
|
||||
team: Final = scenario.team(models=[model])
|
||||
budget: Final = scenario.budget(max_budget=3)
|
||||
project: Final = scenario.project(team, budget_id=budget, models=[model], description="before")
|
||||
key: Final = scenario.key(team_id=team, project_id=project, models=[model])
|
||||
updated: Final = gateway.post(
|
||||
"/project/update",
|
||||
{
|
||||
"project_id": project,
|
||||
"project_alias": "updated-project",
|
||||
"description": "after",
|
||||
"max_budget": 9,
|
||||
"blocked": True,
|
||||
},
|
||||
)
|
||||
assert string_value(updated["project_id"]) == project
|
||||
rows: Final = _project_rows(project)
|
||||
assert rows != []
|
||||
assert len(rows) == 1
|
||||
row: Final = rows[0]
|
||||
assert row["project_alias"] == "updated-project"
|
||||
assert row["description"] == "after"
|
||||
assert row["team_id"] == team
|
||||
assert row["models"] == [model]
|
||||
assert row["budget_id"] == budget
|
||||
assert row["blocked"] is True
|
||||
assert row["max_budget"] == 9.0
|
||||
blocked: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{"model": model, "messages": [{"role": "user", "content": "blocked project"}]},
|
||||
key=key,
|
||||
)
|
||||
assert blocked.status_code == 401, blocked.text
|
||||
assert object_value(blocked.json()["error"])["type"] == "auth_error"
|
||||
gateway.post("/project/update", {"project_id": project, "blocked": False})
|
||||
assert object_value(gateway.chat(model, key=key)["usage"])["total_tokens"] == 40
|
||||
|
||||
|
||||
@pytest.mark.covers("mgmt.project.delete.attached_key_refusal_preserves_state")
|
||||
def test_project_delete_with_attached_key_refuses_and_preserves_state(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
model: Final = scenario.model()
|
||||
team: Final = scenario.team(models=[model])
|
||||
budget: Final = scenario.budget()
|
||||
project: Final = scenario.project(
|
||||
team, budget_id=budget, project_alias="delete-project", models=[model]
|
||||
)
|
||||
key: Final = scenario.key(team_id=team, project_id=project, models=[model])
|
||||
digest: Final = sha256(key.encode()).hexdigest()
|
||||
project_before: Final = _project_rows(project)
|
||||
key_before: Final = read_rows(
|
||||
'SELECT token, key_alias, models, metadata, max_budget, team_id, project_id, budget_id '
|
||||
'FROM "LiteLLM_VerificationToken" WHERE token = %s',
|
||||
(digest,),
|
||||
)
|
||||
assert len(project_before) == 1
|
||||
assert len(key_before) == 1
|
||||
assert key_before[0]["project_id"] == project
|
||||
assert key_before[0]["team_id"] == team
|
||||
denied: Final = gateway.request("DELETE", "/project/delete", {"project_ids": [project]})
|
||||
assert denied.status_code == 400, denied.text
|
||||
assert _project_rows(project) == project_before
|
||||
assert read_rows(
|
||||
'SELECT token, key_alias, models, metadata, max_budget, team_id, project_id, budget_id '
|
||||
'FROM "LiteLLM_VerificationToken" WHERE token = %s',
|
||||
(digest,),
|
||||
) == key_before
|
||||
Loading…
Add table
Reference in a new issue