mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge 55eef53d7d into f2e0a5db1e
This commit is contained in:
commit
e42b796b49
31 changed files with 1800 additions and 122 deletions
|
|
@ -11,11 +11,13 @@ Endpoints for /project operations
|
|||
#### PROJECT MANAGEMENT ####
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Final, NamedTuple, TypedDict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import ReadOnly
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -34,6 +36,10 @@ from litellm.repositories.project_repository import ProjectRepository
|
|||
from litellm.repositories.team_repository import TeamRepository
|
||||
from litellm.repositories.user_repository import UserRepository
|
||||
from litellm.repositories.verification_token_repository import VerificationTokenRepository
|
||||
from litellm.types.proxy.management_endpoints.project_endpoints import (
|
||||
ProjectDailySpendResponse,
|
||||
ProjectDailySpendRow,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma import models as prisma_models
|
||||
|
|
@ -1083,3 +1089,198 @@ async def list_projects(
|
|||
)
|
||||
)
|
||||
raise handle_exception_on_proxy(e)
|
||||
|
||||
|
||||
def _project_daily_activity_error(*, status_code: int, message: str) -> HTTPException:
|
||||
"""Mirrors _daily_activity_error() from team_endpoints.py."""
|
||||
return HTTPException(status_code=status_code, detail={"error": message}) # mutable-ok: FastAPI JSON detail
|
||||
|
||||
|
||||
_MAX_PROJECT_DAILY_ACTIVITY_RANGE_DAYS: Final = 400
|
||||
|
||||
|
||||
def _project_daily_activity_date_range_error(start_date: str | None, end_date: str | None) -> str | None:
|
||||
"""Mirrors _aggregated_date_range_error() from team_endpoints.py.
|
||||
|
||||
There is no daily-aggregated project spend table, so this endpoint scans
|
||||
LiteLLM_SpendLogs directly and needs the same guard against an unbounded range.
|
||||
"""
|
||||
if start_date is None or end_date is None:
|
||||
return "Please provide start_date and end_date"
|
||||
try:
|
||||
parsed_start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
parsed_end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return "start_date and end_date must be valid YYYY-MM-DD dates"
|
||||
if parsed_end < parsed_start:
|
||||
return "end_date must be on or after start_date"
|
||||
if (parsed_end - parsed_start).days > _MAX_PROJECT_DAILY_ACTIVITY_RANGE_DAYS:
|
||||
return f"Date range must be at most {_MAX_PROJECT_DAILY_ACTIVITY_RANGE_DAYS} days"
|
||||
return None
|
||||
|
||||
|
||||
def _project_daily_spend_sql(*, project_count: int) -> str:
|
||||
project_placeholders: Final = ", ".join(f"${i}" for i in range(3, 3 + project_count))
|
||||
return f"""
|
||||
SELECT
|
||||
(DATE_TRUNC('day', sl."startTime" AT TIME ZONE 'UTC'))::date::text AS spend_date,
|
||||
sl.metadata->>'user_api_key_project_id' AS project_id,
|
||||
SUM(sl.spend)::float AS spend,
|
||||
SUM(sl.prompt_tokens)::bigint AS prompt_tokens,
|
||||
SUM(sl.completion_tokens)::bigint AS completion_tokens,
|
||||
SUM(sl.total_tokens)::bigint AS total_tokens,
|
||||
COUNT(*)::bigint AS api_requests,
|
||||
COUNT(*) FILTER (WHERE sl.status IS DISTINCT FROM 'failure')::bigint AS successful_requests,
|
||||
COUNT(*) FILTER (WHERE sl.status = 'failure')::bigint AS failed_requests
|
||||
FROM "LiteLLM_SpendLogs" sl
|
||||
WHERE sl."startTime" >= $1::timestamp
|
||||
AND sl."startTime" < $2::timestamp + INTERVAL '1 day'
|
||||
AND sl.metadata->>'user_api_key_project_id' IN ({project_placeholders})
|
||||
GROUP BY spend_date, project_id
|
||||
ORDER BY spend_date, project_id
|
||||
"""
|
||||
|
||||
|
||||
class _ProjectDailySpendDbRow(TypedDict):
|
||||
spend_date: ReadOnly[str]
|
||||
project_id: ReadOnly[str | None]
|
||||
spend: ReadOnly[float]
|
||||
prompt_tokens: ReadOnly[int]
|
||||
completion_tokens: ReadOnly[int]
|
||||
total_tokens: ReadOnly[int]
|
||||
api_requests: ReadOnly[int]
|
||||
successful_requests: ReadOnly[int]
|
||||
failed_requests: ReadOnly[int]
|
||||
|
||||
|
||||
class _ProjectDailyActivityScope(NamedTuple):
|
||||
project_ids: tuple[str, ...]
|
||||
project_alias_by_id: Mapping[str, str | None]
|
||||
|
||||
|
||||
async def _resolve_project_daily_activity_scope(
|
||||
*,
|
||||
project_ids: str,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
prisma_client: PrismaClient,
|
||||
) -> _ProjectDailyActivityScope:
|
||||
"""
|
||||
Resolve which of the requested projects the caller may view.
|
||||
|
||||
Proxy admins may query any project. Everyone else must be an admin of the
|
||||
project's team, the same permission /project/update enforces.
|
||||
"""
|
||||
requested: Final = tuple(pid.strip() for pid in project_ids.split(",") if pid.strip())
|
||||
if not requested:
|
||||
return _ProjectDailyActivityScope(project_ids=(), project_alias_by_id={})
|
||||
|
||||
projects: Final = await _project_table(prisma_client).find_many(where={"project_id": {"in": list(requested)}})
|
||||
found_by_id: Final = {p.project_id: p for p in projects}
|
||||
missing: Final = [pid for pid in requested if pid not in found_by_id]
|
||||
if missing:
|
||||
raise _project_daily_activity_error(
|
||||
status_code=404, message=f"Project(s) not found: {', '.join(sorted(missing))}"
|
||||
)
|
||||
|
||||
if not user_api_key_has_admin_view(user_api_key_dict):
|
||||
for project_id in requested:
|
||||
has_permission = await _check_user_permission_for_project(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
team_id=found_by_id[project_id].team_id,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
if not has_permission:
|
||||
raise _project_daily_activity_error(
|
||||
status_code=403,
|
||||
message=f"Not authorized to view daily activity for project_id={project_id}",
|
||||
)
|
||||
|
||||
return _ProjectDailyActivityScope(
|
||||
project_ids=requested,
|
||||
project_alias_by_id={pid: found_by_id[pid].project_alias for pid in requested},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/project/daily/activity",
|
||||
tags=["project management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=ProjectDailySpendResponse,
|
||||
)
|
||||
async def get_project_daily_activity(
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
project_ids: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
) -> ProjectDailySpendResponse:
|
||||
"""
|
||||
Daily spend per project, attributed per request from spend logs.
|
||||
|
||||
Scans LiteLLM_SpendLogs directly and groups by day and the project_id
|
||||
stored in each request's metadata: there is no daily-aggregated project
|
||||
spend table, unlike /team/daily/activity.
|
||||
|
||||
Proxy admins may query any project. Team admins may query projects
|
||||
belonging to teams they administer.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
curl --location 'http://0.0.0.0:4000/project/daily/activity?project_ids=project-123&start_date=2026-09-01&end_date=2026-09-04' \\
|
||||
--header 'Authorization: Bearer sk-1234'
|
||||
```
|
||||
"""
|
||||
from litellm.proxy.proxy_server import premium_user, prisma_client
|
||||
|
||||
if not premium_user:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "Project management is an enterprise feature. " + CommonProxyErrors.not_premium_user.value
|
||||
},
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": CommonProxyErrors.db_not_connected_error.value},
|
||||
)
|
||||
|
||||
range_error: Final = _project_daily_activity_date_range_error(start_date, end_date)
|
||||
if range_error is not None or start_date is None or end_date is None:
|
||||
raise _project_daily_activity_error(
|
||||
status_code=400, message=range_error or "Please provide start_date and end_date"
|
||||
)
|
||||
|
||||
if not project_ids:
|
||||
raise _project_daily_activity_error(status_code=400, message="Please provide project_ids")
|
||||
|
||||
scope: Final = await _resolve_project_daily_activity_scope(
|
||||
project_ids=project_ids,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
if not scope.project_ids:
|
||||
return ProjectDailySpendResponse(start_date=start_date, end_date=end_date, results=())
|
||||
|
||||
rows: Final[Sequence[_ProjectDailySpendDbRow]] = await prisma_client.db.query_raw(
|
||||
_project_daily_spend_sql(project_count=len(scope.project_ids)),
|
||||
start_date,
|
||||
end_date,
|
||||
*scope.project_ids,
|
||||
)
|
||||
results: Final = tuple(
|
||||
ProjectDailySpendRow(
|
||||
date=row["spend_date"],
|
||||
project_id=row["project_id"] or "",
|
||||
project_alias=scope.project_alias_by_id.get(row["project_id"] or ""),
|
||||
spend=row["spend"],
|
||||
prompt_tokens=row["prompt_tokens"],
|
||||
completion_tokens=row["completion_tokens"],
|
||||
total_tokens=row["total_tokens"],
|
||||
api_requests=row["api_requests"],
|
||||
successful_requests=row["successful_requests"],
|
||||
failed_requests=row["failed_requests"],
|
||||
)
|
||||
for row in rows
|
||||
)
|
||||
return ProjectDailySpendResponse(start_date=start_date, end_date=end_date, results=results)
|
||||
|
|
|
|||
|
|
@ -2354,6 +2354,10 @@ async def ui_view_spend_logs(
|
|||
default=None,
|
||||
description="Filter spend logs by team_id",
|
||||
),
|
||||
project_id: str | None = fastapi.Query(
|
||||
default=None,
|
||||
description="Filter spend logs by project_id",
|
||||
),
|
||||
min_spend: float | None = fastapi.Query(
|
||||
default=None,
|
||||
description="Filter logs with spend greater than or equal to this value",
|
||||
|
|
@ -2768,6 +2772,10 @@ async def ui_view_spend_logs(
|
|||
sql_conditions.append(f"metadata->'error_information'->>'error_message' LIKE ${p}")
|
||||
sql_params.append(f"%{error_message}%")
|
||||
p += 1
|
||||
if project_id is not None:
|
||||
sql_conditions.append(f"metadata->>'user_api_key_project_id' = ${p}")
|
||||
sql_params.append(project_id)
|
||||
p += 1
|
||||
|
||||
if (
|
||||
group_by_session is True
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProjectDailySpendRow(BaseModel):
|
||||
date: str
|
||||
project_id: str
|
||||
project_alias: str | None = None
|
||||
spend: float = 0.0
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
api_requests: int = 0
|
||||
successful_requests: int = 0
|
||||
failed_requests: int = 0
|
||||
|
||||
|
||||
class ProjectDailySpendResponse(BaseModel):
|
||||
start_date: str
|
||||
end_date: str
|
||||
results: tuple[ProjectDailySpendRow, ...]
|
||||
|
|
@ -19,6 +19,7 @@ GET /guardrails/usage/overview
|
|||
GET /key/spend/report
|
||||
GET /organization/daily/activity
|
||||
GET /organization/spend/report
|
||||
GET /project/daily/activity
|
||||
GET /tag/daily/activity
|
||||
GET /tag/dau
|
||||
GET /tag/distinct
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from litellm_enterprise.proxy.management_endpoints.project_endpoints import (
|
|||
update_project,
|
||||
delete_project,
|
||||
project_info,
|
||||
get_project_daily_activity,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
LitellmUserRoles,
|
||||
|
|
@ -1368,3 +1369,190 @@ async def test_new_project_flag_on_access_group_model_returns_400(monkeypatch):
|
|||
|
||||
assert "prod-models" in str(exc_info.value)
|
||||
assert "expand to multiple models at request time" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_daily_activity_requires_project_ids(monkeypatch):
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.query_raw = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True)
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_project_daily_activity(
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"),
|
||||
project_ids=None,
|
||||
start_date="2026-09-01",
|
||||
end_date="2026-09-04",
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "project_ids" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_daily_activity_admin_groups_by_day_and_project(monkeypatch):
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
project_alpha = MagicMock(project_id="project-alpha", project_alias="Alpha", team_id="team-1")
|
||||
project_beta = MagicMock(project_id="project-beta", project_alias="Beta", team_id="team-1")
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_projecttable.find_many = AsyncMock(return_value=[project_alpha, project_beta])
|
||||
mock_prisma.db.query_raw = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"spend_date": "2026-09-01",
|
||||
"project_id": "project-alpha",
|
||||
"spend": 0.5,
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 5,
|
||||
"total_tokens": 15,
|
||||
"api_requests": 3,
|
||||
"successful_requests": 2,
|
||||
"failed_requests": 1,
|
||||
},
|
||||
{
|
||||
"spend_date": "2026-09-02",
|
||||
"project_id": "project-beta",
|
||||
"spend": 0.25,
|
||||
"prompt_tokens": 4,
|
||||
"completion_tokens": 2,
|
||||
"total_tokens": 6,
|
||||
"api_requests": 1,
|
||||
"successful_requests": 1,
|
||||
"failed_requests": 0,
|
||||
},
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True)
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma)
|
||||
|
||||
admin = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin")
|
||||
response = await get_project_daily_activity(
|
||||
user_api_key_dict=admin,
|
||||
project_ids="project-alpha,project-beta",
|
||||
start_date="2026-09-01",
|
||||
end_date="2026-09-02",
|
||||
)
|
||||
|
||||
sql, *params = mock_prisma.db.query_raw.call_args.args
|
||||
assert params == ["2026-09-01", "2026-09-02", "project-alpha", "project-beta"]
|
||||
assert 'FROM "LiteLLM_SpendLogs" sl' in sql
|
||||
assert "sl.metadata->>'user_api_key_project_id' IN ($3, $4)" in sql
|
||||
assert "GROUP BY spend_date, project_id" in sql
|
||||
|
||||
assert response.start_date == "2026-09-01"
|
||||
assert response.end_date == "2026-09-02"
|
||||
assert [(r.date, r.project_id, r.project_alias, r.spend, r.api_requests) for r in response.results] == [
|
||||
("2026-09-01", "project-alpha", "Alpha", 0.5, 3),
|
||||
("2026-09-02", "project-beta", "Beta", 0.25, 1),
|
||||
]
|
||||
assert (response.results[0].successful_requests, response.results[0].failed_requests) == (2, 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_daily_activity_team_admin_can_view_own_project(monkeypatch):
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
project = MagicMock(project_id="project-alpha", project_alias="Alpha", team_id="team-1")
|
||||
team = MagicMock(admins=["alice"])
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_projecttable.find_many = AsyncMock(return_value=[project])
|
||||
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team)
|
||||
mock_prisma.db.query_raw = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True)
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma)
|
||||
|
||||
caller = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="alice")
|
||||
response = await get_project_daily_activity(
|
||||
user_api_key_dict=caller,
|
||||
project_ids="project-alpha",
|
||||
start_date="2026-09-01",
|
||||
end_date="2026-09-02",
|
||||
)
|
||||
assert response.results == ()
|
||||
mock_prisma.db.query_raw.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_daily_activity_non_team_admin_forbidden(monkeypatch):
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
project = MagicMock(project_id="project-alpha", project_alias="Alpha", team_id="team-1")
|
||||
team = MagicMock(admins=["someone-else"])
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_projecttable.find_many = AsyncMock(return_value=[project])
|
||||
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team)
|
||||
mock_prisma.db.query_raw = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True)
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma)
|
||||
|
||||
caller = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="bob")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_project_daily_activity(
|
||||
user_api_key_dict=caller,
|
||||
project_ids="project-alpha",
|
||||
start_date="2026-09-01",
|
||||
end_date="2026-09-02",
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
mock_prisma.db.query_raw.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_daily_activity_unknown_project_404(monkeypatch):
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_projecttable.find_many = AsyncMock(return_value=[])
|
||||
mock_prisma.db.query_raw = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True)
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma)
|
||||
|
||||
admin = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_project_daily_activity(
|
||||
user_api_key_dict=admin,
|
||||
project_ids="does-not-exist,also-missing",
|
||||
start_date="2026-09-01",
|
||||
end_date="2026-09-02",
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.detail == {"error": "Project(s) not found: also-missing, does-not-exist"}
|
||||
mock_prisma.db.query_raw.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"project_ids,start_date,end_date,expected_error",
|
||||
[
|
||||
(None, "2026-09-01", "2026-09-04", "project_ids"),
|
||||
("", "2026-09-01", "2026-09-04", "project_ids"),
|
||||
("project-alpha", None, "2026-09-04", "start_date and end_date"),
|
||||
("project-alpha", "2026-09-04", "2026-09-01", "on or after"),
|
||||
("project-alpha", "2020-01-01", "2026-12-31", "at most 400 days"),
|
||||
("project-alpha", "nope", "2026-09-04", "valid YYYY-MM-DD"),
|
||||
],
|
||||
)
|
||||
async def test_get_project_daily_activity_rejects_bad_input(
|
||||
monkeypatch, project_ids, start_date, end_date, expected_error
|
||||
):
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.query_raw = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True)
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma)
|
||||
|
||||
admin = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_project_daily_activity(
|
||||
user_api_key_dict=admin, project_ids=project_ids, start_date=start_date, end_date=end_date
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert expected_error in str(exc_info.value.detail)
|
||||
mock_prisma.db.query_raw.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
|
|||
gte = re.search(r'"startTime" >= \(\$(\d+)', cond)
|
||||
lte = re.search(r'"startTime" <= \(\$(\d+)', cond)
|
||||
alias = re.search(r"user_api_key_alias' LIKE \$(\d+)", cond)
|
||||
project = re.search(r"user_api_key_project_id' = \$(\d+)", cond)
|
||||
code = re.search(r"error_code' = \$(\d+)", cond)
|
||||
msg = re.search(r"error_message' LIKE \$(\d+)", cond)
|
||||
sess = re.fullmatch(r"session_id LIKE \$(\d+)", cond)
|
||||
|
|
@ -152,6 +153,13 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
|
|||
"string_contains": str(params[int(alias.group(1)) - 1]).strip("%"),
|
||||
}
|
||||
)
|
||||
elif project:
|
||||
metadata_conds.append(
|
||||
{
|
||||
"path": ["user_api_key_project_id"],
|
||||
"equals": params[int(project.group(1)) - 1],
|
||||
}
|
||||
)
|
||||
elif code:
|
||||
metadata_conds.append(
|
||||
{
|
||||
|
|
@ -4254,6 +4262,71 @@ async def test_ui_view_spend_logs_with_error_message(client):
|
|||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_with_project_id(client, monkeypatch):
|
||||
"""Test filtering spend logs by project_id"""
|
||||
mock_spend_logs = [
|
||||
{
|
||||
"id": "log1",
|
||||
"request_id": "req1",
|
||||
"api_key": "sk-test-key",
|
||||
"user": "test_user_1",
|
||||
"team_id": "team1",
|
||||
"spend": 0.05,
|
||||
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
|
||||
"model": "gpt-3.5-turbo",
|
||||
"metadata": '{"user_api_key_project_id": "project-1"}',
|
||||
},
|
||||
{
|
||||
"id": "log2",
|
||||
"request_id": "req2",
|
||||
"api_key": "sk-test-key",
|
||||
"user": "test_user_2",
|
||||
"team_id": "team1",
|
||||
"spend": 0.10,
|
||||
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
|
||||
"model": "gpt-4",
|
||||
"metadata": '{"user_api_key_project_id": "project-2"}',
|
||||
},
|
||||
]
|
||||
|
||||
def filter_by_project_id(where):
|
||||
if "metadata" in where:
|
||||
mf = where["metadata"]
|
||||
if mf.get("path") == ["user_api_key_project_id"]:
|
||||
project_id = mf.get("equals")
|
||||
return [log for log in mock_spend_logs if json.loads(log["metadata"])["user_api_key_project_id"] == project_id]
|
||||
return mock_spend_logs
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(ps, "prisma_client", make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_project_id))
|
||||
|
||||
try:
|
||||
start_date, end_date = _default_date_range()
|
||||
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={
|
||||
"project_id": "project-1",
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert len(data["data"]) == 1
|
||||
assert data["data"][0]["id"] == "log1"
|
||||
assert data["data"][0]["metadata"]["user_api_key_project_id"] == "project-1"
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_with_error_code_and_key_alias(client):
|
||||
"""Test merging error_code and key_alias filters with AND logic"""
|
||||
|
|
|
|||
|
|
@ -4,6 +4,16 @@ import { createQueryKeys } from "../common/queryKeysFactory";
|
|||
|
||||
const uiSettingsKeys = createQueryKeys("uiSettings");
|
||||
|
||||
export interface UISettingsFieldSchema {
|
||||
description?: string;
|
||||
properties?: Record<string, { description?: string; type?: string }>;
|
||||
}
|
||||
|
||||
export interface UISettingsData {
|
||||
field_schema: UISettingsFieldSchema;
|
||||
values: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI settings, cached for an hour by default because they rarely change.
|
||||
*
|
||||
|
|
@ -14,11 +24,12 @@ const uiSettingsKeys = createQueryKeys("uiSettings");
|
|||
* so a caller that needs to notice a change also has to poll.
|
||||
*/
|
||||
export const useUISettings = (options?: { staleTime?: number; refetchInterval?: number }) => {
|
||||
return useQuery<Record<string, any>>({
|
||||
const queryOptions = {
|
||||
queryKey: uiSettingsKeys.list({}),
|
||||
queryFn: async () => await getUiSettings(),
|
||||
staleTime: options?.staleTime ?? 60 * 60 * 1000, // 1 hour - data rarely changes
|
||||
staleTime: options?.staleTime ?? 60 * 60 * 1000,
|
||||
gcTime: 60 * 60 * 1000, // 1 hour - keep in cache for 1 hour
|
||||
refetchInterval: options?.refetchInterval,
|
||||
});
|
||||
};
|
||||
return useQuery<UISettingsData>(queryOptions);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,16 +10,15 @@ import {
|
|||
type ProviderSpendRow,
|
||||
} from "./entityUsageAggregations";
|
||||
import { buildCostBreakdownTiles, buildSummaryTiles, hasFlatCost, type SummaryTile } from "./entityUsageSummary";
|
||||
import { SummaryTileCard } from "./SummaryTileCard";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { hasCapability, type Capability } from "@/utils/capabilities";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
|
||||
import { ChevronDown, ChevronRight, Info } from "lucide-react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import React, { type ReactNode, useMemo, useState } from "react";
|
||||
import TeamMultiSelect from "@/components/common_components/team_multi_select";
|
||||
import UserDropdown from "@/components/common_components/UserDropdown";
|
||||
|
|
@ -36,7 +35,7 @@ import {
|
|||
userDailyActivityCall,
|
||||
} from "@/components/networking";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { usePaginatedDailyActivity } from "../../hooks/usePaginatedDailyActivity";
|
||||
import { usePaginatedDailyActivity, type FetchPageFn } from "../../hooks/usePaginatedDailyActivity";
|
||||
import { EntityMetricWithMetadata } from "@/components/UsagePage/types";
|
||||
import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters";
|
||||
import EndpointUsage from "../EndpointUsage/EndpointUsage";
|
||||
|
|
@ -45,6 +44,15 @@ import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView
|
|||
import TopModelView from "./TopModelView";
|
||||
import TeamUserSpendCard from "./TeamUserSpendCard";
|
||||
|
||||
/** The entity metadata shape actually probed by getEntityLabel: whichever of these
|
||||
* fields the backend populated for a given entity type (team, user, ...). */
|
||||
interface EntityBreakdownMetadata {
|
||||
team_alias?: string;
|
||||
user_email?: string;
|
||||
user_alias?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface EntityMetrics {
|
||||
metrics: {
|
||||
spend: number;
|
||||
|
|
@ -57,7 +65,7 @@ interface EntityMetrics {
|
|||
failed_requests: number;
|
||||
api_requests: number;
|
||||
};
|
||||
metadata: Record<string, any>;
|
||||
metadata: EntityBreakdownMetadata;
|
||||
}
|
||||
|
||||
interface EntitySpendData {
|
||||
|
|
@ -89,7 +97,7 @@ interface EntityUsageProps {
|
|||
isOrgAdmin?: boolean;
|
||||
}
|
||||
|
||||
const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
|
||||
const ENTITY_FETCH_FNS: Record<EntityType, FetchPageFn> = {
|
||||
tag: tagDailyActivityCall,
|
||||
team: teamDailyActivityCall,
|
||||
organization: organizationDailyActivityCall,
|
||||
|
|
@ -100,7 +108,7 @@ const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
|
|||
|
||||
// Single-shot endpoints returning the whole range in one response; entity types
|
||||
// without one fall back to page-draining the paginated endpoint.
|
||||
const ENTITY_AGGREGATED_FETCH_FNS: Partial<Record<EntityType, (...args: any[]) => Promise<any>>> = {
|
||||
const ENTITY_AGGREGATED_FETCH_FNS: Partial<Record<EntityType, FetchPageFn>> = {
|
||||
team: teamDailyActivityAggregatedCall,
|
||||
};
|
||||
|
||||
|
|
@ -142,18 +150,19 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
const hasRequestWindow = !!accessToken && !!startTime && !!endTime;
|
||||
const enabled = hasRequestWindow && canViewEntity;
|
||||
|
||||
const entityPaginatedActivityOptions = {
|
||||
fetchFn,
|
||||
args: [accessToken, startTime, endTime, entityFilterArg],
|
||||
enabled,
|
||||
aggregatedFetchFn,
|
||||
};
|
||||
const {
|
||||
data: spendDataRaw,
|
||||
isFetchingMore,
|
||||
progress,
|
||||
cancelled,
|
||||
cancel,
|
||||
} = usePaginatedDailyActivity({
|
||||
fetchFn,
|
||||
args: [accessToken, startTime, endTime, entityFilterArg],
|
||||
enabled,
|
||||
aggregatedFetchFn,
|
||||
});
|
||||
} = usePaginatedDailyActivity(entityPaginatedActivityOptions);
|
||||
|
||||
const spendData = spendDataRaw as unknown as EntitySpendData;
|
||||
|
||||
|
|
@ -182,7 +191,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const getEntityLabel = (entity: string, metadata?: Record<string, any>): string => {
|
||||
const getEntityLabel = (entity: string, metadata?: EntityBreakdownMetadata): string => {
|
||||
if (entityList) {
|
||||
const entityItem = entityList.find((item) => item.value === entity);
|
||||
if (entityItem) {
|
||||
|
|
@ -227,7 +236,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
metadata: {
|
||||
alias: getEntityLabel(entity, data.metadata as any),
|
||||
alias: getEntityLabel(entity, data.metadata as EntityBreakdownMetadata),
|
||||
id: entity,
|
||||
},
|
||||
};
|
||||
|
|
@ -358,29 +367,13 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
[],
|
||||
);
|
||||
|
||||
const chev = "size-3 text-muted-foreground";
|
||||
const expandIcon = showCostBreakdown ? <ChevronDown className={chev} /> : <ChevronRight className={chev} />;
|
||||
|
||||
const renderSummaryTile = ({ title, value, className, tooltip, expandable }: SummaryTile) => (
|
||||
<ShadcnCard
|
||||
key={title}
|
||||
className={expandable ? "cursor-pointer hover:bg-accent transition-colors" : undefined}
|
||||
onClick={expandable ? () => setShowCostBreakdown(!showCostBreakdown) : undefined}
|
||||
>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-foreground">{title}</h3>
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{expandable ? expandIcon : null}
|
||||
</div>
|
||||
<p className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
const renderSummaryTile = (tile: SummaryTile) => (
|
||||
<SummaryTileCard
|
||||
key={tile.title}
|
||||
tile={tile}
|
||||
expanded={showCostBreakdown}
|
||||
onToggleExpand={() => setShowCostBreakdown(!showCostBreakdown)}
|
||||
/>
|
||||
);
|
||||
|
||||
const breakdownTiles = showFlatCost && showCostBreakdown ? buildCostBreakdownTiles(spendData.metadata) : [];
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
import { DonutChart } from "@/components/shared/charts";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { Info } from "lucide-react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import React, { useState } from "react";
|
||||
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
||||
import { ChartLoader } from "@/components/shared/chart_loader";
|
||||
|
||||
import { SpendByCategoryPanel } from "../SpendByCategoryPanel";
|
||||
|
||||
type ProviderSpendData = {
|
||||
provider: string;
|
||||
|
|
@ -70,13 +67,10 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
|
|||
const filteredProviderSpend = providerSpend.filter((provider) => {
|
||||
const isUnknown = provider.provider?.toLowerCase() === "unknown";
|
||||
|
||||
// If includeUnknown is true, always include unknown provider
|
||||
if (isUnknown) {
|
||||
return includeUnknown;
|
||||
}
|
||||
|
||||
// If includeZeroSpend is true, include all providers (including those with 0 spend)
|
||||
// Otherwise, only include providers with spend > 0
|
||||
if (includeZeroSpend) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -84,54 +78,37 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
|
|||
return provider.spend > 0;
|
||||
});
|
||||
|
||||
const headerAction = (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-foreground">Show Zero Spend</label>
|
||||
<Switch checked={includeZeroSpend} onCheckedChange={setIncludeZeroSpend} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="text-sm text-foreground">Show Unknown</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
|
||||
<TooltipContent>Requests that failed to route to a provider</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch checked={includeUnknown} onCheckedChange={setIncludeUnknown} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Spend by Provider</CardTitle>
|
||||
<CardAction className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-foreground">Show Zero Spend</label>
|
||||
<Switch checked={includeZeroSpend} onCheckedChange={setIncludeZeroSpend} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="text-sm text-foreground">Show Unknown</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
|
||||
<TooltipContent>Requests that failed to route to a provider</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch checked={includeUnknown} onCheckedChange={setIncludeUnknown} />
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2">
|
||||
<DonutChart
|
||||
className="mt-4 h-40"
|
||||
data={filteredProviderSpend}
|
||||
index="provider"
|
||||
category="spend"
|
||||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
colors={["cyan"]}
|
||||
showLabel
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredProviderSpend}
|
||||
getRowId={(row) => row.provider}
|
||||
noDataMessage="No provider usage data"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Provider"
|
||||
headerAction={headerAction}
|
||||
loading={loading}
|
||||
isDateChanging={isDateChanging}
|
||||
data={filteredProviderSpend}
|
||||
indexKey="provider"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.provider}
|
||||
noDataMessage="No provider usage data"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { ChevronDown, ChevronRight, Info } from "lucide-react";
|
||||
|
||||
import { Card as ShadcnCard, CardContent } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
import type { SummaryTile } from "./entityUsageSummary";
|
||||
|
||||
interface SummaryTileCardProps {
|
||||
tile: SummaryTile;
|
||||
expanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
}
|
||||
|
||||
export function SummaryTileCard({ tile, expanded = false, onToggleExpand }: SummaryTileCardProps) {
|
||||
const { title, value, className, tooltip, expandable } = tile;
|
||||
const chev = "size-3 text-muted-foreground";
|
||||
const expandIcon = expanded ? <ChevronDown className={chev} /> : <ChevronRight className={chev} />;
|
||||
|
||||
return (
|
||||
<ShadcnCard
|
||||
className={expandable ? "cursor-pointer hover:bg-accent transition-colors" : undefined}
|
||||
onClick={expandable ? onToggleExpand : undefined}
|
||||
>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-foreground">{title}</h3>
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{expandable ? expandIcon : null}
|
||||
</div>
|
||||
<p className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default SummaryTileCard;
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import ProjectSpendBreakdown from "./ProjectSpendBreakdown";
|
||||
import type { ProjectSpendRow } from "./projectUsageAggregations";
|
||||
|
||||
vi.mock("@/components/shared/chart_loader", () => ({
|
||||
ChartLoader: ({ isDateChanging }: { isDateChanging: boolean }) => (
|
||||
<div data-testid="chart-loader">{isDateChanging ? "Processing date selection..." : "Loading chart data..."}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockProjectSpend: ProjectSpendRow[] = [
|
||||
{
|
||||
project_id: "project-alpha",
|
||||
project_alias: "Project Alpha",
|
||||
spend: 150.5,
|
||||
requests: 100,
|
||||
successful_requests: 95,
|
||||
failed_requests: 5,
|
||||
tokens: 50000,
|
||||
},
|
||||
{
|
||||
project_id: "project-beta",
|
||||
project_alias: "Project Beta",
|
||||
spend: 200.75,
|
||||
requests: 120,
|
||||
successful_requests: 115,
|
||||
failed_requests: 5,
|
||||
tokens: 75000,
|
||||
},
|
||||
];
|
||||
|
||||
describe("ProjectSpendBreakdown", () => {
|
||||
it("displays the title", () => {
|
||||
render(<ProjectSpendBreakdown loading={false} isDateChanging={false} projectSpend={[]} />);
|
||||
expect(screen.getByText("Spend by Project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the loader instead of chart content while loading", () => {
|
||||
render(<ProjectSpendBreakdown loading isDateChanging={false} projectSpend={[]} />);
|
||||
expect(screen.getByTestId("chart-loader")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No project usage data")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays table headers", () => {
|
||||
render(<ProjectSpendBreakdown loading={false} isDateChanging={false} projectSpend={mockProjectSpend} />);
|
||||
expect(screen.getByText("Project")).toBeInTheDocument();
|
||||
expect(screen.getByText("Spend")).toBeInTheDocument();
|
||||
expect(screen.getByText("Successful")).toBeInTheDocument();
|
||||
expect(screen.getByText("Failed")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tokens")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays each project's alias and formatted spend in the table", () => {
|
||||
render(<ProjectSpendBreakdown loading={false} isDateChanging={false} projectSpend={mockProjectSpend} />);
|
||||
expect(screen.getAllByText("Project Alpha").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Project Beta").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("$150.50")).toBeInTheDocument();
|
||||
expect(screen.getByText("$200.75")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty message when no project has usage", () => {
|
||||
render(<ProjectSpendBreakdown loading={false} isDateChanging={false} projectSpend={[]} />);
|
||||
expect(screen.getByText("No project usage data")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import React from "react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
|
||||
import { SpendByCategoryPanel } from "../SpendByCategoryPanel";
|
||||
import type { ProjectSpendRow } from "./projectUsageAggregations";
|
||||
|
||||
interface ProjectSpendBreakdownProps {
|
||||
loading: boolean;
|
||||
isDateChanging: boolean;
|
||||
projectSpend: ProjectSpendRow[];
|
||||
}
|
||||
|
||||
const columns: ColumnDef<ProjectSpendRow>[] = [
|
||||
{
|
||||
header: "Project",
|
||||
accessorKey: "project_alias",
|
||||
},
|
||||
{
|
||||
header: "Spend",
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
|
||||
},
|
||||
{
|
||||
header: "Successful",
|
||||
accessorKey: "successful_requests",
|
||||
meta: { numeric: true, className: "text-success" },
|
||||
cell: ({ row }) => row.original.successful_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Failed",
|
||||
accessorKey: "failed_requests",
|
||||
meta: { numeric: true, className: "text-destructive" },
|
||||
cell: ({ row }) => row.original.failed_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Tokens",
|
||||
accessorKey: "tokens",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => row.original.tokens.toLocaleString(),
|
||||
},
|
||||
];
|
||||
|
||||
const ProjectSpendBreakdown: React.FC<ProjectSpendBreakdownProps> = ({ loading, isDateChanging, projectSpend }) => (
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Project"
|
||||
loading={loading}
|
||||
isDateChanging={isDateChanging}
|
||||
data={projectSpend}
|
||||
indexKey="project_alias"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.project_id}
|
||||
noDataMessage="No project usage data"
|
||||
/>
|
||||
);
|
||||
|
||||
export default ProjectSpendBreakdown;
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils";
|
||||
import * as networking from "@/components/networking";
|
||||
|
||||
import ProjectUsage from "./ProjectUsage";
|
||||
import type { EntityList } from "../EntityUsage/EntityUsage";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
projectDailyActivityCall: vi.fn(),
|
||||
}));
|
||||
|
||||
const PROJECT_LIST: EntityList[] = [
|
||||
{ label: "Project Alpha", value: "project-alpha" },
|
||||
{ label: "Project Beta", value: "project-beta" },
|
||||
];
|
||||
|
||||
const DATE_VALUE = { from: new Date("2026-09-01"), to: new Date("2026-09-08") };
|
||||
|
||||
describe("ProjectUsage", () => {
|
||||
const mockProjectDailyActivityCall = vi.mocked(networking.projectDailyActivityCall);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testQueryClient.clear();
|
||||
});
|
||||
|
||||
it("shows an enterprise upsell and never calls the API when the caller is not a premium user", () => {
|
||||
renderWithProviders(
|
||||
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={false} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Project Usage is an Enterprise feature")).toBeInTheDocument();
|
||||
expect(mockProjectDailyActivityCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prompts for a project before fetching anything", () => {
|
||||
renderWithProviders(
|
||||
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Select at least one project above to view its usage.")).toBeInTheDocument();
|
||||
expect(mockProjectDailyActivityCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches and renders the picked project's daily spend", async () => {
|
||||
mockProjectDailyActivityCall.mockResolvedValue({
|
||||
start_date: "2026-09-01",
|
||||
end_date: "2026-09-08",
|
||||
results: [
|
||||
{
|
||||
date: "2026-09-01",
|
||||
project_id: "project-alpha",
|
||||
project_alias: "Project Alpha",
|
||||
spend: 12.5,
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 150,
|
||||
api_requests: 4,
|
||||
successful_requests: 3,
|
||||
failed_requests: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByRole("option", { name: "Project Alpha" }));
|
||||
|
||||
await waitFor(() => expect(mockProjectDailyActivityCall).toHaveBeenCalledTimes(1));
|
||||
expect(mockProjectDailyActivityCall).toHaveBeenCalledWith("test-token", DATE_VALUE.from, DATE_VALUE.to, [
|
||||
"project-alpha",
|
||||
]);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText("$12.50").length).toBeGreaterThan(0));
|
||||
expect(screen.getAllByText("4").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Project Alpha").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows an error instead of silently rendering zeros when the request fails", async () => {
|
||||
mockProjectDailyActivityCall.mockRejectedValue(new Error("Project management is an enterprise feature"));
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByRole("option", { name: "Project Alpha" }));
|
||||
|
||||
expect(await screen.findByText("Could not load project usage")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project management is an enterprise feature")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No project usage data")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a backend not-found message verbatim, without mangling the comma-joined list", async () => {
|
||||
mockProjectDailyActivityCall.mockRejectedValue(new Error("Project(s) not found: also-missing, does-not-exist"));
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByRole("option", { name: "Project Alpha" }));
|
||||
|
||||
expect(await screen.findByText("Project(s) not found: also-missing, does-not-exist")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a loading indicator while fetching data for a newly-added project", async () => {
|
||||
let resolveSecondCall: (value: unknown) => void = () => {};
|
||||
mockProjectDailyActivityCall
|
||||
.mockResolvedValueOnce({
|
||||
start_date: "2026-09-01",
|
||||
end_date: "2026-09-08",
|
||||
results: [
|
||||
{
|
||||
date: "2026-09-01",
|
||||
project_id: "project-alpha",
|
||||
project_alias: "Project Alpha",
|
||||
spend: 12.5,
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 150,
|
||||
api_requests: 4,
|
||||
successful_requests: 3,
|
||||
failed_requests: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSecondCall = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByRole("option", { name: "Project Alpha" }));
|
||||
await waitFor(() => expect(screen.getAllByText("$12.50").length).toBeGreaterThan(0));
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByRole("option", { name: "Project Beta" }));
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText("Loading chart data...").length).toBeGreaterThan(0));
|
||||
|
||||
resolveSecondCall({
|
||||
start_date: "2026-09-01",
|
||||
end_date: "2026-09-08",
|
||||
results: [],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryAllByText("Loading chart data...").length).toBe(0));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { BarChart } from "@/components/shared/charts";
|
||||
import { ChartLoader } from "@/components/shared/chart_loader";
|
||||
import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect";
|
||||
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
|
||||
import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { projectDailyActivityCall } from "@/components/networking";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters";
|
||||
|
||||
import { buildSummaryTiles } from "../EntityUsage/entityUsageSummary";
|
||||
import { SummaryTileCard } from "../EntityUsage/SummaryTileCard";
|
||||
import type { EntityList } from "../EntityUsage/EntityUsage";
|
||||
import ProjectSpendBreakdown from "./ProjectSpendBreakdown";
|
||||
import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations";
|
||||
|
||||
interface ProjectUsageProps {
|
||||
accessToken: string | null;
|
||||
projectList: EntityList[] | null;
|
||||
dateValue: DateRangePickerValue;
|
||||
premiumUser: boolean;
|
||||
}
|
||||
|
||||
const ProjectUsage: React.FC<ProjectUsageProps> = ({ accessToken, projectList, dateValue, premiumUser }) => {
|
||||
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
|
||||
|
||||
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
|
||||
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
|
||||
|
||||
const projectOptions = useMemo<MultiSelectOption[]>(
|
||||
() => (projectList ?? []).map((project) => ({ label: project.label || project.value, value: project.value })),
|
||||
[projectList],
|
||||
);
|
||||
|
||||
const hasSelection = selectedProjectIds.length > 0;
|
||||
const hasDateRange = !!startTime && !!endTime;
|
||||
const hasRequestWindow = premiumUser && !!accessToken && hasDateRange;
|
||||
const enabled = hasRequestWindow && hasSelection;
|
||||
|
||||
const queryOptions = {
|
||||
queryKey: ["project-daily-activity", selectedProjectIds, startTime?.toISOString(), endTime?.toISOString()],
|
||||
queryFn: () =>
|
||||
projectDailyActivityCall(accessToken as string, startTime as Date, endTime as Date, selectedProjectIds),
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
};
|
||||
const { data, isPending, isFetching, isPlaceholderData, isError, error } = useQuery(queryOptions);
|
||||
|
||||
const rows = useMemo(() => data?.results ?? [], [data]);
|
||||
const summary = useMemo(() => summarizeProjectUsage(rows), [rows]);
|
||||
const dailySpend = useMemo(() => buildDailySpendSeries(rows), [rows]);
|
||||
const projectBreakdown = useMemo(() => buildProjectSpendBreakdown(rows), [rows]);
|
||||
|
||||
if (!premiumUser) {
|
||||
return (
|
||||
<Alert variant="info">
|
||||
<AlertTitle>Project Usage is an Enterprise feature</AlertTitle>
|
||||
<AlertDescription>
|
||||
Filtering usage by project requires a LiteLLM Enterprise license. Get a 7 day trial at{" "}
|
||||
<a href="https://www.litellm.ai/enterprise#trial" target="_blank" rel="noreferrer" className="underline">
|
||||
litellm.ai/enterprise
|
||||
</a>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const isLoadingRows = isPending || (isFetching && isPlaceholderData);
|
||||
|
||||
const renderResultsPanel = () => {
|
||||
if (!hasSelection) {
|
||||
return (
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
Select at least one project above to view its usage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="col-span-2">
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Could not load project usage</AlertTitle>
|
||||
<AlertDescription>{extractProxyErrorMessage(error)}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Project Spend Overview</h3>
|
||||
{isLoadingRows ? (
|
||||
<ChartLoader isDateChanging={false} />
|
||||
) : (
|
||||
<div className="grid grid-cols-5 gap-4 mt-4">
|
||||
{buildSummaryTiles(summary, false).map((tile) => (
|
||||
<SummaryTileCard key={tile.title} tile={tile} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Daily Spend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingRows ? (
|
||||
<ChartLoader isDateChanging={false} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={dailySpend}
|
||||
index="date"
|
||||
categories={["spend"]}
|
||||
colors={["cyan"]}
|
||||
valueFormatter={valueFormatterSpend}
|
||||
yAxisWidth={100}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<ProjectSpendBreakdown loading={isLoadingRows} isDateChanging={false} projectSpend={projectBreakdown} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 w-full">
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-sm font-medium text-foreground">Projects</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
|
||||
<TooltipContent>Select one or more projects to compare their usage</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<MultiSelect
|
||||
options={projectOptions}
|
||||
value={selectedProjectIds}
|
||||
onValueChange={setSelectedProjectIds}
|
||||
placeholder="Search or select projects..."
|
||||
emptyText="No projects found"
|
||||
/>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
{renderResultsPanel()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectUsage;
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ProjectDailySpendRow } from "@/components/networking";
|
||||
|
||||
import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations";
|
||||
|
||||
const row = (overrides: Partial<ProjectDailySpendRow> = {}): ProjectDailySpendRow => ({
|
||||
date: "2026-09-01",
|
||||
project_id: "project-alpha",
|
||||
project_alias: "Project Alpha",
|
||||
spend: 1,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
api_requests: 3,
|
||||
successful_requests: 2,
|
||||
failed_requests: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("summarizeProjectUsage", () => {
|
||||
it("returns all-zero totals for no rows", () => {
|
||||
const zeroTotals = {
|
||||
total_spend: 0,
|
||||
total_api_requests: 0,
|
||||
total_successful_requests: 0,
|
||||
total_failed_requests: 0,
|
||||
total_tokens: 0,
|
||||
};
|
||||
|
||||
expect(summarizeProjectUsage([])).toEqual(zeroTotals);
|
||||
});
|
||||
|
||||
it("sums spend, requests, and tokens across every row regardless of project or date", () => {
|
||||
const projectBetaRow: Partial<ProjectDailySpendRow> = {
|
||||
project_id: "project-beta",
|
||||
date: "2026-09-02",
|
||||
spend: 2.25,
|
||||
api_requests: 4,
|
||||
successful_requests: 4,
|
||||
failed_requests: 0,
|
||||
total_tokens: 40,
|
||||
};
|
||||
const rows = [row({ spend: 1.5 }), row(projectBetaRow)];
|
||||
const expectedTotals = {
|
||||
total_spend: 3.75,
|
||||
total_api_requests: 7,
|
||||
total_successful_requests: 6,
|
||||
total_failed_requests: 1,
|
||||
total_tokens: 55,
|
||||
};
|
||||
|
||||
expect(summarizeProjectUsage(rows)).toEqual(expectedTotals);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDailySpendSeries", () => {
|
||||
it("sums same-day spend across projects into a single point", () => {
|
||||
const rows = [row(), row({ project_id: "project-beta", spend: 2 })];
|
||||
|
||||
expect(buildDailySpendSeries(rows)).toEqual([{ date: "2026-09-01", spend: 3 }]);
|
||||
});
|
||||
|
||||
it("sorts distinct dates oldest first regardless of input order", () => {
|
||||
const rows = [row({ date: "2026-09-03", spend: 3 }), row(), row({ date: "2026-09-02", spend: 2 })];
|
||||
|
||||
expect(buildDailySpendSeries(rows).map((point) => point.date)).toEqual(["2026-09-01", "2026-09-02", "2026-09-03"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProjectSpendBreakdown", () => {
|
||||
it("sums a project's spend and tokens across every day into a single row", () => {
|
||||
const secondDayRow: Partial<ProjectDailySpendRow> = {
|
||||
date: "2026-09-02",
|
||||
spend: 2,
|
||||
total_tokens: 20,
|
||||
successful_requests: 1,
|
||||
failed_requests: 2,
|
||||
};
|
||||
const rows = [row({ total_tokens: 10, api_requests: 2, failed_requests: 0 }), row(secondDayRow)];
|
||||
|
||||
expect(buildProjectSpendBreakdown(rows)).toEqual([
|
||||
{
|
||||
project_id: "project-alpha",
|
||||
project_alias: "Project Alpha",
|
||||
spend: 3,
|
||||
requests: 5,
|
||||
successful_requests: 3,
|
||||
failed_requests: 2,
|
||||
tokens: 30,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts projects by spend descending", () => {
|
||||
const rows = [
|
||||
row({ project_id: "project-cheap", project_alias: "Cheap" }),
|
||||
row({ project_id: "project-costly", project_alias: "Costly", spend: 9 }),
|
||||
];
|
||||
|
||||
expect(buildProjectSpendBreakdown(rows).map((r) => r.project_id)).toEqual(["project-costly", "project-cheap"]);
|
||||
});
|
||||
|
||||
it("falls back to the project id when the project has no alias", () => {
|
||||
const rows = [row({ project_id: "project-untitled", project_alias: null })];
|
||||
|
||||
expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("project-untitled");
|
||||
});
|
||||
|
||||
it("disambiguates two projects that share the same human-set alias", () => {
|
||||
const rows = [
|
||||
row({ project_id: "project-one", project_alias: "Production" }),
|
||||
row({ project_id: "project-two", project_alias: "Production" }),
|
||||
];
|
||||
|
||||
const aliases = buildProjectSpendBreakdown(rows).map((r) => r.project_alias);
|
||||
expect(new Set(aliases).size).toBe(2);
|
||||
expect(aliases.every((alias) => alias.includes("Production"))).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a unique alias untouched", () => {
|
||||
const rows = [row({ project_id: "project-alpha", project_alias: "Project Alpha" })];
|
||||
|
||||
expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("Project Alpha");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import type { ProjectDailySpendRow } from "@/components/networking";
|
||||
|
||||
export interface ProjectSpendRow extends Record<string, unknown> {
|
||||
project_id: string;
|
||||
project_alias: string;
|
||||
spend: number;
|
||||
requests: number;
|
||||
successful_requests: number;
|
||||
failed_requests: number;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
export interface DailyProjectSpendPoint extends Record<string, unknown> {
|
||||
date: string;
|
||||
spend: number;
|
||||
}
|
||||
|
||||
export interface ProjectUsageSummary {
|
||||
total_spend: number;
|
||||
total_api_requests: number;
|
||||
total_successful_requests: number;
|
||||
total_failed_requests: number;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
const EMPTY_SUMMARY: ProjectUsageSummary = {
|
||||
total_spend: 0,
|
||||
total_api_requests: 0,
|
||||
total_successful_requests: 0,
|
||||
total_failed_requests: 0,
|
||||
total_tokens: 0,
|
||||
};
|
||||
|
||||
export const summarizeProjectUsage = (rows: ProjectDailySpendRow[]): ProjectUsageSummary =>
|
||||
rows.reduce(
|
||||
(totals, row) => ({
|
||||
total_spend: totals.total_spend + row.spend,
|
||||
total_api_requests: totals.total_api_requests + row.api_requests,
|
||||
total_successful_requests: totals.total_successful_requests + row.successful_requests,
|
||||
total_failed_requests: totals.total_failed_requests + row.failed_requests,
|
||||
total_tokens: totals.total_tokens + row.total_tokens,
|
||||
}),
|
||||
EMPTY_SUMMARY,
|
||||
);
|
||||
|
||||
export const buildDailySpendSeries = (rows: ProjectDailySpendRow[]): DailyProjectSpendPoint[] => {
|
||||
const spendByDate = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
spendByDate.set(row.date, (spendByDate.get(row.date) ?? 0) + row.spend);
|
||||
}
|
||||
return [...spendByDate.entries()]
|
||||
.map(([date, spend]) => ({ date, spend }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
};
|
||||
|
||||
const groupByProjectId = (rows: ProjectDailySpendRow[]): ProjectDailySpendRow[][] => {
|
||||
const groups = new Map<string, ProjectDailySpendRow[]>();
|
||||
for (const row of rows) {
|
||||
const existing = groups.get(row.project_id);
|
||||
if (existing) {
|
||||
existing.push(row);
|
||||
} else {
|
||||
groups.set(row.project_id, [row]);
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
};
|
||||
|
||||
const EMPTY_PROJECT_GROUP_TOTALS = {
|
||||
spend: 0,
|
||||
requests: 0,
|
||||
successful_requests: 0,
|
||||
failed_requests: 0,
|
||||
tokens: 0,
|
||||
};
|
||||
|
||||
const summarizeProjectGroup = (rows: ProjectDailySpendRow[]): ProjectSpendRow => {
|
||||
const [{ project_id, project_alias }] = rows;
|
||||
const totals = rows.reduce(
|
||||
(acc, row) => ({
|
||||
spend: acc.spend + row.spend,
|
||||
requests: acc.requests + row.api_requests,
|
||||
successful_requests: acc.successful_requests + row.successful_requests,
|
||||
failed_requests: acc.failed_requests + row.failed_requests,
|
||||
tokens: acc.tokens + row.total_tokens,
|
||||
}),
|
||||
EMPTY_PROJECT_GROUP_TOTALS,
|
||||
);
|
||||
return { project_id, project_alias: project_alias || project_id, ...totals };
|
||||
};
|
||||
|
||||
const disambiguateAliases = (rows: ProjectSpendRow[]): ProjectSpendRow[] => {
|
||||
const aliasCounts = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
aliasCounts.set(row.project_alias, (aliasCounts.get(row.project_alias) ?? 0) + 1);
|
||||
}
|
||||
return rows.map((row) =>
|
||||
(aliasCounts.get(row.project_alias) ?? 0) > 1
|
||||
? { ...row, project_alias: `${row.project_alias} (${row.project_id})` }
|
||||
: row,
|
||||
);
|
||||
};
|
||||
|
||||
export const buildProjectSpendBreakdown = (rows: ProjectDailySpendRow[]): ProjectSpendRow[] => {
|
||||
const summarized = groupByProjectId(rows).map(summarizeProjectGroup);
|
||||
return disambiguateAliases(summarized).sort((a, b) => b.spend - a.spend);
|
||||
};
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import { SpendByCategoryPanel } from "./SpendByCategoryPanel";
|
||||
|
||||
vi.mock("@/components/shared/chart_loader", () => ({
|
||||
ChartLoader: ({ isDateChanging }: { isDateChanging: boolean }) => (
|
||||
<div data-testid="chart-loader">{isDateChanging ? "Processing date selection..." : "Loading chart data..."}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
interface TestRow extends Record<string, unknown> {
|
||||
key: string;
|
||||
spend: number;
|
||||
}
|
||||
|
||||
const columns: ColumnDef<TestRow>[] = [{ header: "Key", accessorKey: "key" }];
|
||||
|
||||
const rows: TestRow[] = [{ key: "row-1", spend: 5 }];
|
||||
|
||||
describe("SpendByCategoryPanel", () => {
|
||||
it("displays the given title", () => {
|
||||
render(
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Widget"
|
||||
loading={false}
|
||||
isDateChanging={false}
|
||||
data={[]}
|
||||
indexKey="key"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.key}
|
||||
noDataMessage="No data"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Spend by Widget")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the loader instead of chart content while loading", () => {
|
||||
render(
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Widget"
|
||||
loading
|
||||
isDateChanging={false}
|
||||
data={rows}
|
||||
indexKey="key"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.key}
|
||||
noDataMessage="No data"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("chart-loader")).toBeInTheDocument();
|
||||
expect(screen.queryByText("row-1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the table rows once loaded", () => {
|
||||
render(
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Widget"
|
||||
loading={false}
|
||||
isDateChanging={false}
|
||||
data={rows}
|
||||
indexKey="key"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.key}
|
||||
noDataMessage="No data"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText("row-1").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows the empty message when there is no data", () => {
|
||||
render(
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Widget"
|
||||
loading={false}
|
||||
isDateChanging={false}
|
||||
data={[]}
|
||||
indexKey="key"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.key}
|
||||
noDataMessage="No data for this range"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("No data for this range")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a header action only when one is given", () => {
|
||||
const { rerender } = render(
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Widget"
|
||||
loading={false}
|
||||
isDateChanging={false}
|
||||
data={[]}
|
||||
indexKey="key"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.key}
|
||||
noDataMessage="No data"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Toggle")).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Widget"
|
||||
headerAction={<button type="button">Toggle</button>}
|
||||
loading={false}
|
||||
isDateChanging={false}
|
||||
data={[]}
|
||||
indexKey="key"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.key}
|
||||
noDataMessage="No data"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Toggle")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import React from "react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import { DonutChart } from "@/components/shared/charts";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { ChartLoader } from "@/components/shared/chart_loader";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
interface SpendByCategoryPanelProps<TRow extends Record<string, unknown>> {
|
||||
title: string;
|
||||
headerAction?: React.ReactNode;
|
||||
loading: boolean;
|
||||
isDateChanging: boolean;
|
||||
data: TRow[];
|
||||
indexKey: keyof TRow & string;
|
||||
columns: ColumnDef<TRow>[];
|
||||
getRowId: (row: TRow) => string;
|
||||
noDataMessage: string;
|
||||
}
|
||||
|
||||
export function SpendByCategoryPanel<TRow extends Record<string, unknown>>({
|
||||
title,
|
||||
headerAction,
|
||||
loading,
|
||||
isDateChanging,
|
||||
data,
|
||||
indexKey,
|
||||
columns,
|
||||
getRowId,
|
||||
noDataMessage,
|
||||
}: SpendByCategoryPanelProps<TRow>) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{headerAction && <CardAction className="flex items-center gap-4">{headerAction}</CardAction>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2">
|
||||
<DonutChart
|
||||
className="mt-4 h-40"
|
||||
data={data}
|
||||
index={indexKey}
|
||||
category="spend"
|
||||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
colors={["cyan"]}
|
||||
showLabel
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
/>
|
||||
<DataTable columns={columns} data={data} getRowId={getRowId} noDataMessage={noDataMessage} size="compact" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpendByCategoryPanel;
|
||||
|
|
@ -20,9 +20,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||
|
||||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
|
||||
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { hasCapability } from "@/utils/capabilities";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
|
||||
|
|
@ -59,6 +61,7 @@ import {
|
|||
import EndpointUsage from "./EndpointUsage/EndpointUsage";
|
||||
import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
|
||||
import ModelViewToggle, { ModelViewType } from "./ModelViewToggle";
|
||||
import ProjectUsage from "./ProjectUsage/ProjectUsage";
|
||||
import SpendByProvider from "./EntityUsage/SpendByProvider";
|
||||
import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView";
|
||||
import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView";
|
||||
|
|
@ -102,12 +105,16 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
// filter reads as loading rather than as a range with no customers.
|
||||
const { data: customers } = useCustomers();
|
||||
const { data: agentsResponse } = useAgents();
|
||||
const { data: projectsResponse } = useProjects();
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
const isAdmin = all_admin_roles.includes(userRole || "");
|
||||
const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || "");
|
||||
const isOrgAdmin = useIsOrgAdmin();
|
||||
const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin);
|
||||
const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage");
|
||||
const { data: uiSettingsData } = useUISettings();
|
||||
const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui);
|
||||
const canViewProjectUsage = hasCapability(userRole, "viewProjectUsage") && enableProjectsUI;
|
||||
|
||||
// For admins: null means global view (all users), a string means filter by that user
|
||||
// For non-admins: always set to their own user ID
|
||||
|
|
@ -117,12 +124,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
|
||||
const [isAiChatOpen, setIsAiChatOpen] = useState(false);
|
||||
const [selectedUsageView, setUsageView] = useState<UsageOption>("global");
|
||||
// Org-admin membership is read from the server, so unlike the other usage
|
||||
// views this one can be revoked while the page is open. Derive the view in
|
||||
// render rather than storing it, so the fallback lands on the same paint and
|
||||
// the selector never holds a value it no longer offers.
|
||||
const usageView: UsageOption =
|
||||
selectedUsageView === "organization" && !canViewOrganizationUsage ? "global" : selectedUsageView;
|
||||
const hasOrganizationAccessIfSelected = selectedUsageView !== "organization" || canViewOrganizationUsage;
|
||||
const hasProjectAccessIfSelected = selectedUsageView !== "project" || canViewProjectUsage;
|
||||
const stillHasAccessToSelectedView = hasOrganizationAccessIfSelected && hasProjectAccessIfSelected;
|
||||
const usageView: UsageOption = stillHasAccessToSelectedView ? selectedUsageView : "global";
|
||||
|
||||
const [showCredentialBanner, setShowCredentialBanner] = useState(true);
|
||||
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
|
||||
|
|
@ -233,10 +238,12 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const aggregatedFailed = selectForRange(aggregatedFailure, currentAggregatedRangeKey) === true;
|
||||
|
||||
// Paginated fallback — only enabled when aggregated endpoint fails
|
||||
const hasRequestWindow = !!accessToken && !!startTime && !!endTime;
|
||||
const hasPaginatedFallbackRequestWindow = aggregatedFailed && hasRequestWindow;
|
||||
const paginatedResult = usePaginatedDailyActivity({
|
||||
fetchFn: userDailyActivityCall,
|
||||
args: [accessToken, startTime, endTime, effectiveUserId],
|
||||
enabled: aggregatedFailed && !!accessToken && !!startTime && !!endTime,
|
||||
enabled: hasPaginatedFallbackRequestWindow,
|
||||
});
|
||||
|
||||
// Derive userSpendData from whichever source is active
|
||||
|
|
@ -482,6 +489,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
userRole={userRole}
|
||||
canViewTagUsage={canViewTagUsage}
|
||||
isOrgAdmin={isOrgAdmin}
|
||||
enableProjectsUI={enableProjectsUI}
|
||||
/>
|
||||
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
|
||||
</div>
|
||||
|
|
@ -935,6 +943,20 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{usageView === "project" && canViewProjectUsage && (
|
||||
<ProjectUsage
|
||||
accessToken={accessToken}
|
||||
projectList={
|
||||
projectsResponse?.map((project) => ({
|
||||
label: project.project_alias || project.project_id,
|
||||
value: project.project_id,
|
||||
})) || null
|
||||
}
|
||||
dateValue={dateValue}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Customer Usage Panel */}
|
||||
{usageView === "customer" && (
|
||||
<EntityUsage
|
||||
|
|
|
|||
|
|
@ -60,23 +60,29 @@ describe("UsageViewSelect", () => {
|
|||
expect(offers(container, "Tag Usage")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", async (optionName) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
|
||||
it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])(
|
||||
"should show %s to an admin",
|
||||
async (optionName) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
|
||||
|
||||
await openMenu(user);
|
||||
expect(offers(container, optionName)).toBe(true);
|
||||
});
|
||||
await openMenu(user);
|
||||
expect(offers(container, optionName)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", async (optionName) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
|
||||
);
|
||||
it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])(
|
||||
"should hide %s from an internal user",
|
||||
async (optionName) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
|
||||
);
|
||||
|
||||
await openMenu(user);
|
||||
expect(offers(container, optionName)).toBe(false);
|
||||
});
|
||||
await openMenu(user);
|
||||
expect(offers(container, optionName)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
// An org admin's session role is "Internal User" — org-admin-ness lives in the
|
||||
// membership table — so the two rows above cannot tell them apart from a plain
|
||||
|
|
@ -86,6 +92,7 @@ describe("UsageViewSelect", () => {
|
|||
it.each([
|
||||
["Organization Usage", true],
|
||||
["Agent Usage (A2A)", false],
|
||||
["Project Usage", false],
|
||||
] as const)("should offer %s to an org admin: %s", async (optionName, expected) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
|
|
@ -96,6 +103,16 @@ describe("UsageViewSelect", () => {
|
|||
expect(offers(container, optionName)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should hide Project Usage from an admin when enableProjectsUI is false", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" enableProjectsUI={false} />,
|
||||
);
|
||||
|
||||
await openMenu(user);
|
||||
expect(offers(container, "Project Usage")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", async (optionName) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BarChart3, Bot, Building2, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react";
|
||||
import { BarChart3, Bot, Building2, Folder, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -9,6 +9,7 @@ export type UsageOption =
|
|||
| "my-usage"
|
||||
| "organization"
|
||||
| "team"
|
||||
| "project"
|
||||
| "customer"
|
||||
| "tag"
|
||||
| "agent"
|
||||
|
|
@ -20,6 +21,7 @@ export interface UsageViewSelectProps {
|
|||
userRole: string | null;
|
||||
canViewTagUsage?: boolean;
|
||||
isOrgAdmin?: boolean;
|
||||
enableProjectsUI?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
"data-id"?: string;
|
||||
|
|
@ -68,6 +70,13 @@ const OPTIONS: OptionConfig[] = [
|
|||
description: "View usage by team",
|
||||
icon: <Users className="size-4" />,
|
||||
},
|
||||
{
|
||||
value: "project",
|
||||
label: "Project Usage",
|
||||
description: "View usage by project",
|
||||
icon: <Folder className="size-4" />,
|
||||
capability: "viewProjectUsage",
|
||||
},
|
||||
{
|
||||
value: "customer",
|
||||
label: "Customer Usage",
|
||||
|
|
@ -110,6 +119,7 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
|
|||
userRole,
|
||||
canViewTagUsage = false,
|
||||
isOrgAdmin = false,
|
||||
enableProjectsUI = true,
|
||||
title = "Usage View",
|
||||
description = "Select the usage data you want to view",
|
||||
"data-id": dataId,
|
||||
|
|
@ -117,6 +127,9 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
|
|||
const isAdmin = all_admin_roles.includes(userRole ?? "");
|
||||
const getFilteredOptions = () => {
|
||||
return OPTIONS.filter((option) => {
|
||||
if (option.value === "project" && !enableProjectsUI) {
|
||||
return false;
|
||||
}
|
||||
if (option.capability) {
|
||||
return hasCapability(userRole, option.capability, isOrgAdmin);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,12 +32,12 @@ const SUMMABLE_METADATA_KEYS = [
|
|||
"total_flat_cost",
|
||||
] as const;
|
||||
|
||||
interface DailyActivityResponse {
|
||||
export interface DailyActivityResponse {
|
||||
results: DailyData[];
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
type FetchPageFn = (...args: any[]) => Promise<DailyActivityResponse>;
|
||||
export type FetchPageFn = (...args: any[]) => Promise<DailyActivityResponse>;
|
||||
|
||||
interface UsePaginatedDailyActivityParams {
|
||||
/** The API call function (e.g., userDailyActivityCall). */
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ function SettingRow({
|
|||
);
|
||||
}
|
||||
|
||||
function toStringArrayOrNull(value: unknown): string[] | null {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : null;
|
||||
}
|
||||
|
||||
export default function UISettings() {
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data, isLoading, isError, error } = useUISettings();
|
||||
|
|
@ -434,7 +438,7 @@ export default function UISettings() {
|
|||
|
||||
<Separator />
|
||||
<PageVisibilitySettings
|
||||
enabledPagesInternalUsers={values.enabled_ui_pages_internal_users}
|
||||
enabledPagesInternalUsers={toStringArrayOrNull(values.enabled_ui_pages_internal_users)}
|
||||
enabledPagesPropertyDescription={enabledPagesProperty?.description}
|
||||
isUpdating={isUpdating}
|
||||
onUpdate={handleUpdatePageVisibility}
|
||||
|
|
|
|||
|
|
@ -1623,6 +1623,30 @@ export const agentDailyActivityCall = async (
|
|||
});
|
||||
};
|
||||
|
||||
export type ProjectDailySpendResponse = components["schemas"]["ProjectDailySpendResponse"];
|
||||
export type ProjectDailySpendRow = components["schemas"]["ProjectDailySpendRow"];
|
||||
|
||||
export const projectDailyActivityCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
projectIds: string[],
|
||||
): Promise<ProjectDailySpendResponse> => {
|
||||
try {
|
||||
return await apiClient.get<ProjectDailySpendResponse>(`/project/daily/activity`, {
|
||||
accessToken,
|
||||
query: {
|
||||
project_ids: projectIds.join(","),
|
||||
start_date: formatDate(startTime),
|
||||
end_date: formatDate(endTime),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch project daily activity:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getOnboardingCredentials = async (inviteUUID: string) => {
|
||||
/**
|
||||
* Get all models on proxy
|
||||
|
|
@ -2075,6 +2099,7 @@ export const userFilterUICall = async (accessToken: string, params: URLSearchPar
|
|||
interface UiSpendLogsParams {
|
||||
api_key?: string;
|
||||
team_id?: string;
|
||||
project_id?: string;
|
||||
request_id?: string;
|
||||
session_id?: string;
|
||||
user_id?: string;
|
||||
|
|
|
|||
|
|
@ -24,10 +24,20 @@ vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({
|
|||
useInfiniteSpendLogEndUsers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
|
||||
useProjects: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
||||
useUISettings: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers";
|
||||
import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers";
|
||||
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
|
||||
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
|
||||
const emptyInfiniteQuery = {
|
||||
data: { pages: [], pageParams: [] },
|
||||
|
|
@ -77,6 +87,10 @@ describe("RequestLogsFilters", () => {
|
|||
vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue(
|
||||
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteSpendLogEndUsers>,
|
||||
);
|
||||
vi.mocked(useProjects).mockReturnValue({ data: [], isLoading: false } as unknown as ReturnType<typeof useProjects>);
|
||||
vi.mocked(useUISettings).mockReturnValue({
|
||||
data: { values: { enable_projects_ui: true } },
|
||||
} as unknown as ReturnType<typeof useUISettings>);
|
||||
});
|
||||
|
||||
it("renders every backend-supported filter field", async () => {
|
||||
|
|
@ -84,6 +98,7 @@ describe("RequestLogsFilters", () => {
|
|||
|
||||
for (const label of [
|
||||
"Team ID",
|
||||
"Project",
|
||||
"Status",
|
||||
"Cache",
|
||||
"Key Alias",
|
||||
|
|
@ -130,6 +145,30 @@ describe("RequestLogsFilters", () => {
|
|||
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.USER_ID, "alice@example.com");
|
||||
});
|
||||
|
||||
it("selects a project value from the caller's visible projects", async () => {
|
||||
vi.mocked(useProjects).mockReturnValue({
|
||||
data: [{ project_id: "project-1", project_alias: "Alpha" }],
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useProjects>);
|
||||
const user = userEvent.setup();
|
||||
const { set } = renderFilters();
|
||||
|
||||
await chooseSelectOption(user, await screen.findByPlaceholderText("Search or select a project"), /Alpha/);
|
||||
|
||||
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.PROJECT_ID, "project-1");
|
||||
});
|
||||
|
||||
it("hides the Project filter when the projects UI setting is disabled", async () => {
|
||||
vi.mocked(useUISettings).mockReturnValue({
|
||||
data: { values: { enable_projects_ui: false } },
|
||||
} as unknown as ReturnType<typeof useUISettings>);
|
||||
|
||||
renderFilters();
|
||||
|
||||
expect(await screen.findByText("Team ID")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Project")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pushes the User ID picker query to the paginated user lookup", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderFilters();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/u
|
|||
import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers";
|
||||
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
|
||||
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { DataTableFilterField } from "@/components/shared/DataTable";
|
||||
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
|
||||
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
|
||||
|
|
@ -76,6 +78,32 @@ function TeamFilterField({
|
|||
);
|
||||
}
|
||||
|
||||
function ProjectFilterField({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) {
|
||||
const { data: projects, isLoading } = useProjects();
|
||||
|
||||
const options = useMemo<SearchSelectOption[]>(
|
||||
() =>
|
||||
(projects ?? []).map((project) => ({
|
||||
label: project.project_alias || project.project_id,
|
||||
value: project.project_id,
|
||||
sublabel: project.project_id,
|
||||
})),
|
||||
[projects],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTableFilterField label="Project">
|
||||
<SearchSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={(next) => onChange(emptyToUndefined(next))}
|
||||
placeholder="Search or select a project"
|
||||
emptyText={isLoading ? "Loading projects…" : "No projects found"}
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyAliasFilterField({
|
||||
value,
|
||||
onChange,
|
||||
|
|
@ -319,6 +347,8 @@ interface RequestLogsFiltersProps {
|
|||
export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsFiltersProps) {
|
||||
const valueOf = (id: string): string => asString(get(id));
|
||||
const setter = (id: string) => (next: string | undefined) => set(id, next);
|
||||
const { data: uiSettingsData } = useUISettings();
|
||||
const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -328,6 +358,10 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF
|
|||
teams={teams}
|
||||
/>
|
||||
|
||||
{enableProjectsUI && (
|
||||
<ProjectFilterField value={valueOf(LOG_FILTER_IDS.PROJECT_ID)} onChange={setter(LOG_FILTER_IDS.PROJECT_ID)} />
|
||||
)}
|
||||
|
||||
<DataTableFilterField label="Status">
|
||||
<Select
|
||||
items={STATUS_FILTER_ITEMS}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ describe("useLogFilterLogic", () => {
|
|||
const cases: ReadonlyArray<{ id: string; value: string; param: string }> = [
|
||||
{ id: LOG_FILTER_IDS.KEY_HASH, value: "sk-hash-1", param: "api_key" },
|
||||
{ id: LOG_FILTER_IDS.TEAM_ID, value: "team-1", param: "team_id" },
|
||||
{ id: LOG_FILTER_IDS.PROJECT_ID, value: "project-1", param: "project_id" },
|
||||
{ id: LOG_FILTER_IDS.REQUEST_ID, value: "req-1", param: "request_id" },
|
||||
{ id: LOG_FILTER_IDS.SESSION_ID, value: "sess-1", param: "session_id" },
|
||||
{ id: LOG_FILTER_IDS.END_USER, value: "end-user-1", param: "end_user" },
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface PaginatedResponse {
|
|||
|
||||
export const LOG_FILTER_IDS = {
|
||||
TEAM_ID: "team_id",
|
||||
PROJECT_ID: "project_id",
|
||||
STATUS: "status",
|
||||
CACHE_STATUS: "cache_hit",
|
||||
KEY_ALIAS: "key_alias",
|
||||
|
|
@ -38,6 +39,7 @@ export const LOG_FILTER_IDS = {
|
|||
|
||||
export const LOG_FILTER_LABELS: Record<string, string> = {
|
||||
[LOG_FILTER_IDS.TEAM_ID]: "Team ID",
|
||||
[LOG_FILTER_IDS.PROJECT_ID]: "Project",
|
||||
[LOG_FILTER_IDS.STATUS]: "Status",
|
||||
[LOG_FILTER_IDS.CACHE_STATUS]: "Cache",
|
||||
[LOG_FILTER_IDS.KEY_ALIAS]: "Key Alias",
|
||||
|
|
@ -176,6 +178,7 @@ export function useLogFilterLogic({
|
|||
params: {
|
||||
api_key: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_HASH),
|
||||
team_id: getFilterValue(columnFilters, LOG_FILTER_IDS.TEAM_ID),
|
||||
project_id: getFilterValue(columnFilters, LOG_FILTER_IDS.PROJECT_ID),
|
||||
request_id: getFilterValue(columnFilters, LOG_FILTER_IDS.REQUEST_ID),
|
||||
search: getFilterValue(columnFilters, LOG_FILTER_IDS.SEARCH),
|
||||
session_id: getFilterValue(columnFilters, LOG_FILTER_IDS.SESSION_ID),
|
||||
|
|
|
|||
123
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
123
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -11474,6 +11474,39 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/project/daily/activity": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Project Daily Activity
|
||||
* @description Daily spend per project, attributed per request from spend logs.
|
||||
*
|
||||
* Scans LiteLLM_SpendLogs directly and groups by day and the project_id
|
||||
* stored in each request's metadata: there is no daily-aggregated project
|
||||
* spend table, unlike /team/daily/activity.
|
||||
*
|
||||
* Proxy admins may query any project. Team admins may query projects
|
||||
* belonging to teams they administer.
|
||||
*
|
||||
* Example:
|
||||
* ```bash
|
||||
* curl --location 'http://0.0.0.0:4000/project/daily/activity?project_ids=project-123&start_date=2026-09-01&end_date=2026-09-04' \
|
||||
* --header 'Authorization: Bearer sk-1234'
|
||||
* ```
|
||||
*/
|
||||
get: operations["get_project_daily_activity_project_daily_activity_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/project/delete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -34286,6 +34319,59 @@ export interface components {
|
|||
*/
|
||||
version_status: string;
|
||||
};
|
||||
/** ProjectDailySpendResponse */
|
||||
ProjectDailySpendResponse: {
|
||||
/** End Date */
|
||||
end_date: string;
|
||||
/** Results */
|
||||
results: components["schemas"]["ProjectDailySpendRow"][];
|
||||
/** Start Date */
|
||||
start_date: string;
|
||||
};
|
||||
/** ProjectDailySpendRow */
|
||||
ProjectDailySpendRow: {
|
||||
/**
|
||||
* Api Requests
|
||||
* @default 0
|
||||
*/
|
||||
api_requests: number;
|
||||
/**
|
||||
* Completion Tokens
|
||||
* @default 0
|
||||
*/
|
||||
completion_tokens: number;
|
||||
/** Date */
|
||||
date: string;
|
||||
/**
|
||||
* Failed Requests
|
||||
* @default 0
|
||||
*/
|
||||
failed_requests: number;
|
||||
/** Project Alias */
|
||||
project_alias?: string | null;
|
||||
/** Project Id */
|
||||
project_id: string;
|
||||
/**
|
||||
* Prompt Tokens
|
||||
* @default 0
|
||||
*/
|
||||
prompt_tokens: number;
|
||||
/**
|
||||
* Spend
|
||||
* @default 0
|
||||
*/
|
||||
spend: number;
|
||||
/**
|
||||
* Successful Requests
|
||||
* @default 0
|
||||
*/
|
||||
successful_requests: number;
|
||||
/**
|
||||
* Total Tokens
|
||||
* @default 0
|
||||
*/
|
||||
total_tokens: number;
|
||||
};
|
||||
/** Prompt */
|
||||
Prompt: {
|
||||
litellm_params: components["schemas"]["PromptLiteLLMParams"];
|
||||
|
|
@ -55069,6 +55155,39 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
get_project_daily_activity_project_daily_activity_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
project_ids?: string | null;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectDailySpendResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_project_project_delete_delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -57673,6 +57792,8 @@ export interface operations {
|
|||
session_id?: string | null;
|
||||
/** @description Filter spend logs by team_id */
|
||||
team_id?: string | null;
|
||||
/** @description Filter spend logs by project_id */
|
||||
project_id?: string | null;
|
||||
/** @description Filter logs with spend greater than or equal to this value */
|
||||
min_spend?: number | null;
|
||||
/** @description Filter logs with spend less than or equal to this value */
|
||||
|
|
@ -57791,6 +57912,8 @@ export interface operations {
|
|||
session_id?: string | null;
|
||||
/** @description Filter spend logs by team_id */
|
||||
team_id?: string | null;
|
||||
/** @description Filter spend logs by project_id */
|
||||
project_id?: string | null;
|
||||
/** @description Filter logs with spend greater than or equal to this value */
|
||||
min_spend?: number | null;
|
||||
/** @description Filter logs with spend less than or equal to this value */
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const ADMIN_ONLY_CAPABILITIES: Capability[] = [
|
|||
"viewPrompts",
|
||||
"viewOrganizationUsage",
|
||||
"viewAgentUsage",
|
||||
"viewProjectUsage",
|
||||
];
|
||||
|
||||
const PROXY_ADMIN_ONLY_PAGE_CAPABILITIES: Capability[] = [
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const CAPABILITY_ROLES = {
|
|||
viewPrompts: all_admin_roles,
|
||||
viewOrganizationUsage: all_admin_roles,
|
||||
viewAgentUsage: all_admin_roles,
|
||||
viewProjectUsage: all_admin_roles,
|
||||
viewGlobalSpend: proxyAdminOnlyRoles,
|
||||
viewWorkflowRuns: proxyAdminOnlyRoles,
|
||||
viewMemory: proxyAdminOnlyRoles,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue