feat: Filter by project on logs and usage screens (BerriAI/litellm/issues/40386)

This commit is contained in:
Jay 2026-09-09 14:54:50 +05:30
parent 47b15ffb67
commit 2ea7d86469
11 changed files with 671 additions and 2 deletions

View file

@ -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,196 @@ 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: {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)

View file

@ -2349,6 +2349,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",
@ -2763,6 +2767,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

View file

@ -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, ...]

View file

@ -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,189 @@ 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",
start_date="2026-09-01",
end_date="2026-09-02",
)
assert exc_info.value.status_code == 404
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()

View file

@ -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(
{
@ -4226,6 +4234,74 @@ 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):
"""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"
)
try:
with patch.object(
ps,
"prisma_client",
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_project_id),
):
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"""

View file

@ -2075,6 +2075,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;

View file

@ -24,10 +24,15 @@ vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({
useInfiniteSpendLogEndUsers: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
useProjects: 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";
const emptyInfiniteQuery = {
data: { pages: [], pageParams: [] },
@ -77,6 +82,9 @@ 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
>);
});
it("renders every backend-supported filter field", async () => {
@ -84,6 +92,7 @@ describe("RequestLogsFilters", () => {
for (const label of [
"Team ID",
"Project",
"Status",
"Cache",
"Key Alias",
@ -130,6 +139,19 @@ 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("pushes the User ID picker query to the paginated user lookup", async () => {
const user = userEvent.setup();
renderFilters();

View file

@ -6,6 +6,7 @@ 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 { DataTableFilterField } from "@/components/shared/DataTable";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
@ -76,6 +77,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,
@ -328,6 +355,8 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF
teams={teams}
/>
<ProjectFilterField value={valueOf(LOG_FILTER_IDS.PROJECT_ID)} onChange={setter(LOG_FILTER_IDS.PROJECT_ID)} />
<DataTableFilterField label="Status">
<Select
items={STATUS_FILTER_ITEMS}

View file

@ -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" },

View file

@ -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),

View file

@ -11530,6 +11530,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;
@ -34168,6 +34201,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"];
@ -54910,6 +54996,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;
@ -57514,6 +57633,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 */
@ -57632,6 +57753,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 */