mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-15 04:19:08 +00:00
fix(proxy): scope /global/activity/cache_hits analytics to the caller's own and permitted-team logs (#46210)
Non-admin callers previously received deployment-wide cache activity, error breakdowns, every key alias on the proxy, and could filter the aggregates by any other tenant's key alias. All four queries now take the caller's read scope as bind parameters, which also shrinks the key-alias and model filter options to the caller's own visibility. The three-way scope resolution (admin / internal user / fallback) now lives in resolve_spend_log_read_scope, shared with /spend/tags and /spend/logs/session/ui instead of being duplicated per endpoint.
This commit is contained in:
parent
46a389c60b
commit
14e820ef7c
6 changed files with 202 additions and 30 deletions
|
|
@ -7,6 +7,8 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.analytics_endpoints.cache_activity import CacheActivityResponse, get_cache_activity
|
||||
from litellm.proxy.auth.authorization import resolve_spend_log_read_scope
|
||||
from litellm.proxy.auth.authorization_dependencies import LogTeamLookupDependency
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
router: Final = APIRouter()
|
||||
|
|
@ -30,6 +32,8 @@ def _parse_date(value: str, param_name: str) -> datetime:
|
|||
include_in_schema=False,
|
||||
)
|
||||
async def get_global_activity(
|
||||
log_team_lookup: LogTeamLookupDependency,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
start_date: Annotated[str, fastapi.Query(description="Time from which to start viewing spend")],
|
||||
end_date: Annotated[str, fastapi.Query(description="Time till which to view spend")],
|
||||
key_aliases: Annotated[
|
||||
|
|
@ -41,6 +45,9 @@ async def get_global_activity(
|
|||
Cache activity for the Admin UI cache dashboard, aggregated per call_type:
|
||||
cache hits vs successful LLM API requests vs failed requests, plus totals
|
||||
for the stat cards and the available key-alias/model filter options.
|
||||
|
||||
Admin callers see proxy-wide activity; all other callers see only their own
|
||||
spend logs and those of teams they may view, exactly like ``/spend/logs/ui``.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
|
|
@ -54,6 +61,7 @@ async def get_global_activity(
|
|||
|
||||
return await get_cache_activity(
|
||||
prisma_client=prisma_client,
|
||||
read_scope=await resolve_spend_log_read_scope(user_api_key_dict, log_team_lookup),
|
||||
start_date=_parse_date(start_date, "start_date"),
|
||||
end_date=_parse_date(end_date, "end_date"),
|
||||
key_aliases=key_aliases or [],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from typing import Final, Protocol
|
|||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm.proxy._types import LiteLLMRoutes
|
||||
from litellm.proxy.auth.authorization import ReadScope, read_scope_bind_params
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
|
||||
UNKNOWN_CALL_TYPE: Final = "Unknown"
|
||||
|
|
@ -84,6 +85,9 @@ GROUPS_SQL: Final = """
|
|||
AND ($4::jsonb = '[]'::jsonb
|
||||
OR sl."model" IN (SELECT jsonb_array_elements_text($4::jsonb)))
|
||||
AND sl."call_type" NOT IN (SELECT jsonb_array_elements_text($5::jsonb))
|
||||
AND ($6::boolean
|
||||
OR sl."user" = $7
|
||||
OR sl."team_id" IN (SELECT jsonb_array_elements_text($8::jsonb)))
|
||||
GROUP BY 1
|
||||
ORDER BY (COUNT(*)) DESC
|
||||
"""
|
||||
|
|
@ -105,6 +109,9 @@ ERROR_BREAKDOWN_SQL: Final = """
|
|||
AND ($4::jsonb = '[]'::jsonb
|
||||
OR sl."model" IN (SELECT jsonb_array_elements_text($4::jsonb)))
|
||||
AND sl."call_type" NOT IN (SELECT jsonb_array_elements_text($5::jsonb))
|
||||
AND ($6::boolean
|
||||
OR sl."user" = $7
|
||||
OR sl."team_id" IN (SELECT jsonb_array_elements_text($8::jsonb)))
|
||||
GROUP BY 1, 2, 3
|
||||
ORDER BY (COUNT(*)) DESC
|
||||
"""
|
||||
|
|
@ -117,6 +124,9 @@ KEY_ALIAS_OPTIONS_SQL: Final = """
|
|||
sl."startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
|
||||
AND sl."startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
|
||||
AND sl."call_type" NOT IN (SELECT jsonb_array_elements_text($3::jsonb))
|
||||
AND ($4::boolean
|
||||
OR sl."user" = $5
|
||||
OR sl."team_id" IN (SELECT jsonb_array_elements_text($6::jsonb)))
|
||||
ORDER BY 1
|
||||
"""
|
||||
|
||||
|
|
@ -128,6 +138,9 @@ MODEL_OPTIONS_SQL: Final = """
|
|||
AND sl."startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
|
||||
AND sl."model" != ''
|
||||
AND sl."call_type" NOT IN (SELECT jsonb_array_elements_text($3::jsonb))
|
||||
AND ($4::boolean
|
||||
OR sl."user" = $5
|
||||
OR sl."team_id" IN (SELECT jsonb_array_elements_text($6::jsonb)))
|
||||
ORDER BY 1
|
||||
"""
|
||||
|
||||
|
|
@ -162,6 +175,7 @@ def compute_totals(groups: Sequence[CacheActivityGroup]) -> CacheActivityTotals:
|
|||
|
||||
async def get_cache_activity(
|
||||
prisma_client: _SupportsRawQueryDb,
|
||||
read_scope: ReadScope,
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
key_aliases: Sequence[str],
|
||||
|
|
@ -169,13 +183,16 @@ async def get_cache_activity(
|
|||
) -> CacheActivityResponse:
|
||||
key_aliases_json: Final = json.dumps(list(key_aliases))
|
||||
models_json: Final = json.dumps(list(models))
|
||||
scope_params: Final = read_scope_bind_params(read_scope)
|
||||
group_rows, error_rows, key_alias_rows, model_rows = await asyncio.gather(
|
||||
prisma_client.db.query_raw(GROUPS_SQL, start_date, end_date, key_aliases_json, models_json, INFO_ROUTES_JSON),
|
||||
prisma_client.db.query_raw(
|
||||
ERROR_BREAKDOWN_SQL, start_date, end_date, key_aliases_json, models_json, INFO_ROUTES_JSON
|
||||
GROUPS_SQL, start_date, end_date, key_aliases_json, models_json, INFO_ROUTES_JSON, *scope_params
|
||||
),
|
||||
prisma_client.db.query_raw(KEY_ALIAS_OPTIONS_SQL, start_date, end_date, INFO_ROUTES_JSON),
|
||||
prisma_client.db.query_raw(MODEL_OPTIONS_SQL, start_date, end_date, INFO_ROUTES_JSON),
|
||||
prisma_client.db.query_raw(
|
||||
ERROR_BREAKDOWN_SQL, start_date, end_date, key_aliases_json, models_json, INFO_ROUTES_JSON, *scope_params
|
||||
),
|
||||
prisma_client.db.query_raw(KEY_ALIAS_OPTIONS_SQL, start_date, end_date, INFO_ROUTES_JSON, *scope_params),
|
||||
prisma_client.db.query_raw(MODEL_OPTIONS_SQL, start_date, end_date, INFO_ROUTES_JSON, *scope_params),
|
||||
)
|
||||
groups: Final = _groups_adapter.validate_python(group_rows or [])
|
||||
return CacheActivityResponse(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import json
|
||||
from collections.abc import Awaitable, Callable, Iterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import Final, TypeAlias
|
||||
|
||||
from litellm.proxy._types import KeyManagementRoutes, LiteLLM_TeamTable, LitellmUserRoles, UserAPIKeyAuth
|
||||
|
|
@ -66,6 +68,33 @@ async def can_read_log_owner(
|
|||
return False
|
||||
|
||||
|
||||
async def resolve_spend_log_read_scope(
|
||||
auth: UserAPIKeyAuth,
|
||||
permitted_team_lookup: Callable[[UserAPIKeyAuth], Awaitable[Sequence[str]]],
|
||||
) -> ReadScope:
|
||||
"""Admin roles read every spend log; internal users read their own rows and
|
||||
those of teams they may view; any other caller reads only their own rows,
|
||||
which is no rows at all when the key carries no user_id."""
|
||||
if auth.user_role in (LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY):
|
||||
return AllRows()
|
||||
is_internal_user: Final = auth.user_role in (
|
||||
LitellmUserRoles.INTERNAL_USER,
|
||||
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
|
||||
)
|
||||
if is_internal_user and auth.user_id is not None:
|
||||
return await resolve_owned_read_scope(auth.user_id, partial(permitted_team_lookup, auth))
|
||||
return OwnedRows(auth.user_id)
|
||||
|
||||
|
||||
def read_scope_bind_params(scope: ReadScope) -> tuple[bool, str | None, str]:
|
||||
"""Encode a scope as (grant_all, user_id, team_ids_json) bind parameters, so a
|
||||
static SQL clause like ($N::boolean OR "user" = $N+1 OR team_id IN (...$N+2))
|
||||
can enforce it without interpolating SQL text."""
|
||||
if isinstance(scope, AllRows):
|
||||
return (True, None, "[]")
|
||||
return (False, scope.user_id, json.dumps(list(scope.team_ids)))
|
||||
|
||||
|
||||
async def resolve_trace_read_scope(
|
||||
auth: UserAPIKeyAuth,
|
||||
permitted_team_lookup: Callable[[], Awaitable[Sequence[str]]],
|
||||
|
|
|
|||
|
|
@ -31,11 +31,18 @@ AT_NOON: Final = DAY.replace(hour=12)
|
|||
RUN: Final = uuid.uuid4()
|
||||
INFERENCE_KEY: Final = f"ca-inference-{RUN}"
|
||||
INFO_ONLY_KEY: Final = f"ca-info-only-{RUN}"
|
||||
OUTSIDER_KEY: Final = f"ca-outsider-{RUN}"
|
||||
INFERENCE_ALIAS: Final = f"alias-inference-{RUN}"
|
||||
INFO_ONLY_ALIAS: Final = f"alias-info-only-{RUN}"
|
||||
OUTSIDER_ALIAS: Final = f"alias-outsider-{RUN}"
|
||||
INFERENCE_MODEL: Final = f"gpt-5.4-mini-{RUN}"
|
||||
INFO_ONLY_MODEL: Final = f"ghost-model-{RUN}"
|
||||
NO_FILTER: Final = "[]"
|
||||
OWNER: Final = f"owner-{RUN}"
|
||||
OWNER_TEAM: Final = f"team-{RUN}"
|
||||
OUTSIDER: Final = f"outsider-{RUN}"
|
||||
GRANT_ALL: Final = (True, None, "[]")
|
||||
NO_GRANTS: Final = (False, None, "[]")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="module", loop_scope="session")
|
||||
|
|
@ -44,11 +51,21 @@ async def db() -> AsyncIterator[Prisma]:
|
|||
yield client
|
||||
|
||||
|
||||
async def _spend_log(db, api_key: str, call_type: str, status: str, model: str = "", error_code: str = "") -> None:
|
||||
async def _spend_log(
|
||||
db,
|
||||
api_key: str,
|
||||
call_type: str,
|
||||
status: str,
|
||||
model: str = "",
|
||||
error_code: str = "",
|
||||
user: str = "",
|
||||
team_id: str = "",
|
||||
) -> None:
|
||||
metadata: Final = {"error_information": {"error_code": error_code, "error_class": "ProxyException"}}
|
||||
await db.execute_raw(
|
||||
'INSERT INTO "LiteLLM_SpendLogs" ("request_id", "call_type", "api_key", "startTime", "endTime", "model", '
|
||||
'"status", "metadata") VALUES ($1, $2, $3, $4::timestamp, $4::timestamp, $5, $6, $7::jsonb)',
|
||||
'"status", "metadata", "user", "team_id") '
|
||||
"VALUES ($1, $2, $3, $4::timestamp, $4::timestamp, $5, $6, $7::jsonb, $8, $9)",
|
||||
str(uuid.uuid4()),
|
||||
call_type,
|
||||
api_key,
|
||||
|
|
@ -56,29 +73,38 @@ async def _spend_log(db, api_key: str, call_type: str, status: str, model: str =
|
|||
model,
|
||||
status,
|
||||
json.dumps(metadata if status == "failure" else {}),
|
||||
user,
|
||||
team_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
async def seeded(db):
|
||||
for token, alias in ((INFERENCE_KEY, INFERENCE_ALIAS), (INFO_ONLY_KEY, INFO_ONLY_ALIAS)):
|
||||
for token, alias in (
|
||||
(INFERENCE_KEY, INFERENCE_ALIAS),
|
||||
(INFO_ONLY_KEY, INFO_ONLY_ALIAS),
|
||||
(OUTSIDER_KEY, OUTSIDER_ALIAS),
|
||||
):
|
||||
await db.execute_raw(
|
||||
'INSERT INTO "LiteLLM_VerificationToken" ("token", "key_alias") VALUES ($1, $2)', token, alias
|
||||
)
|
||||
await _spend_log(db, INFERENCE_KEY, "acompletion", "success", model=INFERENCE_MODEL)
|
||||
await _spend_log(db, INFERENCE_KEY, "acompletion", "failure", model=INFERENCE_MODEL, error_code="429")
|
||||
await _spend_log(db, INFERENCE_KEY, "", "failure", error_code="401")
|
||||
await _spend_log(db, INFERENCE_KEY, "/model/info", "failure", error_code="401")
|
||||
await _spend_log(db, INFO_ONLY_KEY, "/v1/models", "failure", model=INFO_ONLY_MODEL, error_code="401")
|
||||
await _spend_log(db, INFO_ONLY_KEY, "/key/info", "success")
|
||||
await _spend_log(db, INFERENCE_KEY, "acompletion", "success", model=INFERENCE_MODEL, user=OWNER)
|
||||
await _spend_log(
|
||||
db, INFERENCE_KEY, "acompletion", "failure", model=INFERENCE_MODEL, error_code="429", team_id=OWNER_TEAM
|
||||
)
|
||||
await _spend_log(db, INFERENCE_KEY, "", "failure", error_code="401", user=OUTSIDER)
|
||||
await _spend_log(db, INFERENCE_KEY, "/model/info", "failure", error_code="401", user=OWNER)
|
||||
await _spend_log(db, INFO_ONLY_KEY, "/v1/models", "failure", model=INFO_ONLY_MODEL, error_code="401", user=OUTSIDER)
|
||||
await _spend_log(db, INFO_ONLY_KEY, "/key/info", "success", user=OUTSIDER)
|
||||
await _spend_log(db, OUTSIDER_KEY, "acompletion", "success", model=INFERENCE_MODEL, user=OUTSIDER)
|
||||
yield
|
||||
keys: Final = [INFERENCE_KEY, INFO_ONLY_KEY]
|
||||
keys: Final = [INFERENCE_KEY, INFO_ONLY_KEY, OUTSIDER_KEY]
|
||||
await db.execute_raw('DELETE FROM "LiteLLM_SpendLogs" WHERE "api_key" = ANY($1::text[])', keys)
|
||||
await db.execute_raw('DELETE FROM "LiteLLM_VerificationToken" WHERE "token" = ANY($1::text[])', keys)
|
||||
|
||||
|
||||
async def _groups(db, key_aliases: list[str]) -> dict[str, dict]:
|
||||
rows: Final = await db.query_raw(GROUPS_SQL, DAY, DAY, json.dumps(key_aliases), NO_FILTER, INFO_ROUTES_JSON)
|
||||
async def _groups(db, key_aliases: list[str], scope: tuple = GRANT_ALL) -> dict[str, dict]:
|
||||
rows: Final = await db.query_raw(GROUPS_SQL, DAY, DAY, json.dumps(key_aliases), NO_FILTER, INFO_ROUTES_JSON, *scope)
|
||||
return {row["call_type"]: row for row in rows}
|
||||
|
||||
|
||||
|
|
@ -95,7 +121,13 @@ async def test_key_with_only_info_route_traffic_has_no_groups(db):
|
|||
|
||||
async def test_error_breakdown_drops_info_routes(db):
|
||||
rows: Final = await db.query_raw(
|
||||
ERROR_BREAKDOWN_SQL, DAY, DAY, json.dumps([INFERENCE_ALIAS, INFO_ONLY_ALIAS]), NO_FILTER, INFO_ROUTES_JSON
|
||||
ERROR_BREAKDOWN_SQL,
|
||||
DAY,
|
||||
DAY,
|
||||
json.dumps([INFERENCE_ALIAS, INFO_ONLY_ALIAS]),
|
||||
NO_FILTER,
|
||||
INFO_ROUTES_JSON,
|
||||
*GRANT_ALL,
|
||||
)
|
||||
assert {(row["call_type"], row["error_code"], row["count"]) for row in rows} == {
|
||||
("acompletion", "429", 1),
|
||||
|
|
@ -104,9 +136,50 @@ async def test_error_breakdown_drops_info_routes(db):
|
|||
|
||||
|
||||
async def test_filter_options_only_offer_values_that_return_analytics(db):
|
||||
key_alias_rows: Final = await db.query_raw(KEY_ALIAS_OPTIONS_SQL, DAY, DAY, INFO_ROUTES_JSON)
|
||||
model_rows: Final = await db.query_raw(MODEL_OPTIONS_SQL, DAY, DAY, INFO_ROUTES_JSON)
|
||||
key_alias_rows: Final = await db.query_raw(KEY_ALIAS_OPTIONS_SQL, DAY, DAY, INFO_ROUTES_JSON, *GRANT_ALL)
|
||||
model_rows: Final = await db.query_raw(MODEL_OPTIONS_SQL, DAY, DAY, INFO_ROUTES_JSON, *GRANT_ALL)
|
||||
key_aliases: Final = {row["key_alias"] for row in key_alias_rows}
|
||||
models: Final = {row["model"] for row in model_rows}
|
||||
assert INFERENCE_ALIAS in key_aliases and INFO_ONLY_ALIAS not in key_aliases
|
||||
assert INFERENCE_MODEL in models and INFO_ONLY_MODEL not in models
|
||||
|
||||
|
||||
def _owner_scope(team_ids: list[str]) -> tuple:
|
||||
return (False, OWNER, json.dumps(team_ids))
|
||||
|
||||
|
||||
async def test_scoped_groups_cover_own_rows_and_permitted_team_rows_only(db):
|
||||
own_only: Final = await _groups(db, [INFERENCE_ALIAS], scope=_owner_scope([]))
|
||||
assert {(g["api_requests"], g["failed_requests"]) for g in own_only.values()} == {(1, 0)}
|
||||
|
||||
with_team: Final = await _groups(db, [INFERENCE_ALIAS], scope=_owner_scope([OWNER_TEAM]))
|
||||
assert (with_team["acompletion"]["api_requests"], with_team["acompletion"]["failed_requests"]) == (1, 1)
|
||||
assert "Unknown" not in with_team, "the outsider's row must stay invisible"
|
||||
|
||||
|
||||
async def test_scope_without_grants_returns_nothing(db):
|
||||
assert await _groups(db, [INFERENCE_ALIAS], scope=NO_GRANTS) == {}
|
||||
|
||||
|
||||
async def test_scoped_error_breakdown_excludes_other_users_failures(db):
|
||||
rows: Final = await db.query_raw(
|
||||
ERROR_BREAKDOWN_SQL,
|
||||
DAY,
|
||||
DAY,
|
||||
json.dumps([INFERENCE_ALIAS, INFO_ONLY_ALIAS]),
|
||||
NO_FILTER,
|
||||
INFO_ROUTES_JSON,
|
||||
*_owner_scope([OWNER_TEAM]),
|
||||
)
|
||||
assert {(row["call_type"], row["error_code"], row["count"]) for row in rows} == {("acompletion", "429", 1)}
|
||||
|
||||
|
||||
async def test_scoped_filter_options_hide_other_users_key_aliases(db):
|
||||
unscoped_rows: Final = await db.query_raw(KEY_ALIAS_OPTIONS_SQL, DAY, DAY, INFO_ROUTES_JSON, *GRANT_ALL)
|
||||
assert OUTSIDER_ALIAS in {row["key_alias"] for row in unscoped_rows}, (
|
||||
"seed must make the outsider visible to admins"
|
||||
)
|
||||
|
||||
scoped_rows: Final = await db.query_raw(KEY_ALIAS_OPTIONS_SQL, DAY, DAY, INFO_ROUTES_JSON, *_owner_scope([]))
|
||||
scoped_aliases: Final = {row["key_alias"] for row in scoped_rows}
|
||||
assert INFERENCE_ALIAS in scoped_aliases and OUTSIDER_ALIAS not in scoped_aliases
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ totals for the stat cards, and the filter options for the UI dropdowns.
|
|||
"""
|
||||
|
||||
import json
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._types import LiteLLMRoutes
|
||||
from litellm.proxy._types import LiteLLMRoutes, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.analytics_endpoints.analytics_endpoints import get_global_activity
|
||||
from litellm.proxy.analytics_endpoints.cache_activity import (
|
||||
ERROR_BREAKDOWN_SQL,
|
||||
|
|
@ -23,6 +24,23 @@ from litellm.proxy.analytics_endpoints.cache_activity import (
|
|||
compute_totals,
|
||||
)
|
||||
|
||||
ADMIN: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user")
|
||||
INTERNAL_USER: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="user-1")
|
||||
|
||||
|
||||
async def _no_teams(auth: UserAPIKeyAuth) -> tuple[str, ...]:
|
||||
return ()
|
||||
|
||||
|
||||
async def _team_nine(auth: UserAPIKeyAuth) -> tuple[str, ...]:
|
||||
return ("team-9",)
|
||||
|
||||
|
||||
async def _activity(auth: UserAPIKeyAuth, team_lookup=_no_teams, **overrides):
|
||||
params = {"start_date": "2026-07-01", "end_date": "2026-07-27", "key_aliases": [], "models": [], **overrides}
|
||||
return await get_global_activity(log_team_lookup=team_lookup, user_api_key_dict=auth, **params)
|
||||
|
||||
|
||||
GROUP_ROWS = [
|
||||
{
|
||||
"call_type": "acompletion",
|
||||
|
|
@ -78,7 +96,7 @@ def mock_prisma(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_groups_totals_and_filter_options(mock_prisma: MagicMock):
|
||||
response = await get_global_activity(start_date="2026-07-01", end_date="2026-07-27", key_aliases=[], models=[])
|
||||
response = await _activity(ADMIN)
|
||||
|
||||
assert [group.call_type for group in response.groups] == ["acompletion", "Unknown"]
|
||||
assert response.groups[0].api_requests == 1000
|
||||
|
|
@ -99,12 +117,7 @@ async def test_returns_groups_totals_and_filter_options(mock_prisma: MagicMock):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filters_are_passed_to_sql_as_json_arrays(mock_prisma: MagicMock):
|
||||
await get_global_activity(
|
||||
start_date="2026-07-01",
|
||||
end_date="2026-07-27",
|
||||
key_aliases=["my-key"],
|
||||
models=["gpt-5.1", "claude-opus-4-8"],
|
||||
)
|
||||
await _activity(ADMIN, key_aliases=["my-key"], models=["gpt-5.1", "claude-opus-4-8"])
|
||||
|
||||
filtered_calls = [
|
||||
call for call in mock_prisma.db.query_raw.call_args_list if call.args[0] in (GROUPS_SQL, ERROR_BREAKDOWN_SQL)
|
||||
|
|
@ -120,9 +133,9 @@ async def test_every_query_excludes_the_same_info_routes(mock_prisma: MagicMock)
|
|||
"""Regression for LIT-5884: failed info-route calls are spend-logged but are not inference traffic, so
|
||||
the groups, error breakdown and both filter-option queries all receive the same exclusion list. What
|
||||
the SQL does with it is covered against Postgres in tests/integration/spend/test_cache_activity.py."""
|
||||
await get_global_activity(start_date="2026-07-01", end_date="2026-07-27", key_aliases=[], models=[])
|
||||
await _activity(ADMIN)
|
||||
|
||||
exclusions_by_query = {call.args[0]: json.loads(call.args[-1]) for call in mock_prisma.db.query_raw.call_args_list}
|
||||
exclusions_by_query = {call.args[0]: json.loads(call.args[-4]) for call in mock_prisma.db.query_raw.call_args_list}
|
||||
assert set(exclusions_by_query) == {GROUPS_SQL, ERROR_BREAKDOWN_SQL, KEY_ALIAS_OPTIONS_SQL, MODEL_OPTIONS_SQL}
|
||||
for excluded_call_types in exclusions_by_query.values():
|
||||
assert excluded_call_types == LiteLLMRoutes.info_routes.value
|
||||
|
|
@ -130,10 +143,39 @@ async def test_every_query_excludes_the_same_info_routes(mock_prisma: MagicMock)
|
|||
assert "" not in excluded_call_types
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_queries_grant_all_rows(mock_prisma: MagicMock):
|
||||
await _activity(ADMIN)
|
||||
|
||||
scope_by_query = {call.args[0]: call.args[-3:] for call in mock_prisma.db.query_raw.call_args_list}
|
||||
assert set(scope_by_query) == {GROUPS_SQL, ERROR_BREAKDOWN_SQL, KEY_ALIAS_OPTIONS_SQL, MODEL_OPTIONS_SQL}
|
||||
for scope_args in scope_by_query.values():
|
||||
assert scope_args == (True, None, "[]")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_user_queries_are_scoped_to_own_and_team_rows(mock_prisma: MagicMock):
|
||||
await _activity(INTERNAL_USER, team_lookup=_team_nine)
|
||||
|
||||
scope_by_query = {call.args[0]: call.args[-3:] for call in mock_prisma.db.query_raw.call_args_list}
|
||||
assert set(scope_by_query) == {GROUPS_SQL, ERROR_BREAKDOWN_SQL, KEY_ALIAS_OPTIONS_SQL, MODEL_OPTIONS_SQL}
|
||||
for scope_args in scope_by_query.values():
|
||||
assert scope_args == (False, "user-1", json.dumps(["team-9"]))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_without_user_id_grants_no_rows(mock_prisma: MagicMock):
|
||||
no_user = UserAPIKeyAuth(user_role=LitellmUserRoles.TEAM, user_id=None)
|
||||
await _activity(no_user)
|
||||
|
||||
for call in mock_prisma.db.query_raw.call_args_list:
|
||||
assert call.args[-3:] == (False, None, "[]")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_malformed_dates_with_400(mock_prisma: MagicMock):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_global_activity(start_date="07/01/2026", end_date="2026-07-27", key_aliases=[], models=[])
|
||||
await _activity(ADMIN, start_date="07/01/2026")
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
mock_prisma.db.query_raw.assert_not_called()
|
||||
|
|
|
|||
3
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
3
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -6147,6 +6147,9 @@ export interface paths {
|
|||
* @description Cache activity for the Admin UI cache dashboard, aggregated per call_type:
|
||||
* cache hits vs successful LLM API requests vs failed requests, plus totals
|
||||
* for the stat cards and the available key-alias/model filter options.
|
||||
*
|
||||
* Admin callers see proxy-wide activity; all other callers see only their own
|
||||
* spend logs and those of teams they may view, exactly like ``/spend/logs/ui``.
|
||||
*/
|
||||
get: operations["get_global_activity_global_activity_cache_hits_get"];
|
||||
put?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue