diff --git a/litellm/proxy/analytics_endpoints/analytics_endpoints.py b/litellm/proxy/analytics_endpoints/analytics_endpoints.py index 3af88182d7d..3eb93a3e970 100644 --- a/litellm/proxy/analytics_endpoints/analytics_endpoints.py +++ b/litellm/proxy/analytics_endpoints/analytics_endpoints.py @@ -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 [], diff --git a/litellm/proxy/analytics_endpoints/cache_activity.py b/litellm/proxy/analytics_endpoints/cache_activity.py index c860f3c8a88..8a3f71e656e 100644 --- a/litellm/proxy/analytics_endpoints/cache_activity.py +++ b/litellm/proxy/analytics_endpoints/cache_activity.py @@ -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( diff --git a/litellm/proxy/auth/authorization.py b/litellm/proxy/auth/authorization.py index bb498c33763..2ca007a5739 100644 --- a/litellm/proxy/auth/authorization.py +++ b/litellm/proxy/auth/authorization.py @@ -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]]], diff --git a/tests/integration/spend/test_cache_activity.py b/tests/integration/spend/test_cache_activity.py index c381d51c402..aebba663694 100644 --- a/tests/integration/spend/test_cache_activity.py +++ b/tests/integration/spend/test_cache_activity.py @@ -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 diff --git a/tests/unit/proxy/analytics_endpoints/test_analytics_endpoints.py b/tests/unit/proxy/analytics_endpoints/test_analytics_endpoints.py index 69188ebd5a2..1c706978fed 100644 --- a/tests/unit/proxy/analytics_endpoints/test_analytics_endpoints.py +++ b/tests/unit/proxy/analytics_endpoints/test_analytics_endpoints.py @@ -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() diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 356684ee937..2cc91879f7e 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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;