diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 67d935e34e3..7e8d08e7cad 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -26762,6 +26762,113 @@ "title": "ToolPolicyUpdateResponse", "type": "object" }, + "ToolSpendDailyEntry": { + "description": "Spend attributed to one tool on one UTC day.", + "properties": { + "call_count": { + "default": 0, + "title": "Call Count", + "type": "integer" + }, + "date": { + "title": "Date", + "type": "string" + }, + "spend": { + "default": 0.0, + "title": "Spend", + "type": "number" + }, + "tool_name": { + "title": "Tool Name", + "type": "string" + } + }, + "required": [ + "date", + "tool_name" + ], + "title": "ToolSpendDailyEntry", + "type": "object" + }, + "ToolSpendEntry": { + "description": "Total spend attributed to one tool over the requested window.", + "properties": { + "call_count": { + "default": 0, + "title": "Call Count", + "type": "integer" + }, + "spend": { + "default": 0.0, + "description": "Attributed spend: a request that used several tools counts its full spend toward each of them", + "title": "Spend", + "type": "number" + }, + "tool_name": { + "title": "Tool Name", + "type": "string" + }, + "total_tokens": { + "default": 0, + "title": "Total Tokens", + "type": "integer" + } + }, + "required": [ + "tool_name" + ], + "title": "ToolSpendEntry", + "type": "object" + }, + "ToolSpendResponse": { + "properties": { + "by_tool": { + "items": { + "$ref": "#/components/schemas/ToolSpendEntry" + }, + "title": "By Tool", + "type": "array" + }, + "daily": { + "items": { + "$ref": "#/components/schemas/ToolSpendDailyEntry" + }, + "title": "Daily", + "type": "array" + }, + "end_date": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Date" + }, + "start_date": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start Date" + }, + "total_spend": { + "default": 0.0, + "description": "Deduplicated spend of every request that called at least one tool in the window; less than the sum of per-tool attributed spend whenever multi-tool requests exist", + "title": "Total Spend", + "type": "number" + } + }, + "title": "ToolSpendResponse", + "type": "object" + }, "ToolUsageLogEntry": { "description": "One spend log row for a tool call (for UI \"recent logs\" table).", "properties": { @@ -26858,6 +26965,13 @@ }, "ValidationError": { "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, "loc": { "items": { "anyOf": [ @@ -27301,6 +27415,81 @@ ] } }, + "/v1/tool/spend": { + "get": { + "description": "Spend attributed to each tool over a date range, for the Cost Optimization dashboard.\n\nJoins ``LiteLLM_SpendLogToolIndex`` (which tool names ran on which request) to\n``LiteLLM_SpendLogs`` (what the request cost). A request that used multiple tools\ncounts its full spend toward each of those tools, so per-tool numbers are\nattributions. ``total_spend`` is the deduplicated spend of every request that\ncalled at least one tool in the window, so it never double counts.", + "operationId": "get_tool_spend_v1_tool_spend_get", + "parameters": [ + { + "description": "YYYY-MM-DD (defaults to 30 days ago)", + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "YYYY-MM-DD (defaults to 30 days ago)", + "title": "Start Date" + } + }, + { + "description": "YYYY-MM-DD (defaults to today)", + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "YYYY-MM-DD (defaults to today)", + "title": "End Date" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolSpendResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Tool Spend", + "tags": [ + "tools" + ] + } + }, "/v1/tool/{tool_name}": { "get": { "description": "Get details for a single tool.", diff --git a/litellm/proxy/management_endpoints/tool_management_endpoints.py b/litellm/proxy/management_endpoints/tool_management_endpoints.py index 9d71761f115..ca606e07cee 100644 --- a/litellm/proxy/management_endpoints/tool_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tool_management_endpoints.py @@ -10,16 +10,18 @@ POST /v1/tool/policy - Update the input_policy / output_policy for a """ import uuid -from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, List, Optional +from datetime import datetime, timedelta, timezone +from itertools import groupby +from typing import TYPE_CHECKING, Annotated, Any, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, TypeAdapter if TYPE_CHECKING: from litellm.proxy.utils import PrismaClient from litellm._logging import verbose_proxy_logger -from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth +from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.repositories.object_permission_repository import ObjectPermissionRepository from litellm.repositories.table_repositories import ( @@ -39,6 +41,9 @@ from litellm.types.tool_management import ( ToolPolicyOptionsResponse, ToolPolicyUpdateRequest, ToolPolicyUpdateResponse, + ToolSpendDailyEntry, + ToolSpendEntry, + ToolSpendResponse, ToolUsageLogEntry, ToolUsageLogsResponse, ) @@ -124,6 +129,147 @@ async def list_tools( raise HTTPException(status_code=500, detail=str(e)) +def _parse_day_start(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.strptime(value.strip(), "%Y-%m-%d").replace(tzinfo=timezone.utc) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Invalid date format: {value}. Expected: 'YYYY-MM-DD'", + ) + + +class _ToolSpendRow(BaseModel): + date: str + tool_name: str + call_count: int + spend: float + total_tokens: int + + +class _RequestTotalRow(BaseModel): + total_spend: float + + +_TOOL_SPEND_ROWS = TypeAdapter(list[_ToolSpendRow]) +_REQUEST_TOTAL_ROWS = TypeAdapter(list[_RequestTotalRow]) + + +def _summarize_tool(name: str, grp: tuple[_ToolSpendRow, ...]) -> ToolSpendEntry: + return ToolSpendEntry( + tool_name=name, + spend=sum(r.spend for r in grp), + call_count=sum(r.call_count for r in grp), + total_tokens=sum(r.total_tokens for r in grp), + ) + + +def _build_tool_spend_response( + rows: list[_ToolSpendRow], + total_spend: float, + start_date: str, + end_date: str, +) -> ToolSpendResponse: + daily = [ + ToolSpendDailyEntry(date=r.date, tool_name=r.tool_name, spend=r.spend, call_count=r.call_count) for r in rows + ] + grouped = groupby(sorted(rows, key=lambda r: r.tool_name), key=lambda r: r.tool_name) + by_tool = sorted( + (_summarize_tool(name, tuple(grp)) for name, grp in grouped), + key=lambda e: e.spend, + reverse=True, + ) + return ToolSpendResponse( + by_tool=by_tool, + daily=daily, + total_spend=total_spend, + start_date=start_date, + end_date=end_date, + ) + + +@router.get( + "/v1/tool/spend", + tags=["tool management"], + dependencies=[Depends(user_api_key_auth)], + response_model=ToolSpendResponse, +) +async def get_tool_spend( + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + start_date: Annotated[str | None, Query(description="YYYY-MM-DD (defaults to 30 days ago)")] = None, + end_date: Annotated[str | None, Query(description="YYYY-MM-DD (defaults to today)")] = None, +): + """ + Spend attributed to each tool over a date range, for the Cost Optimization dashboard. + + Joins ``LiteLLM_SpendLogToolIndex`` (which tool names ran on which request) to + ``LiteLLM_SpendLogs`` (what the request cost). A request that used multiple tools + counts its full spend toward each of those tools, so per-tool numbers are + attributions. ``total_spend`` is the deduplicated spend of every request that + called at least one tool in the window, so it never double counts. + """ + from litellm.proxy.proxy_server import prisma_client + + if user_api_key_dict.user_role not in ( + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ): + raise HTTPException( + status_code=403, + detail="Only proxy admin roles can view tool spend across the deployment", + ) + + if prisma_client is None: + raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value) + + now = datetime.now(timezone.utc) + end_day = _parse_day_start(end_date) + start_dt = _parse_day_start(start_date) or ((end_day or now) - timedelta(days=30)) + end_exclusive = (end_day + timedelta(days=1)) if end_day else now + + rows = await prisma_client.db.query_raw( + """ + SELECT to_char(ti.start_time, 'YYYY-MM-DD') AS date, + ti.tool_name AS tool_name, + COUNT(*)::int AS call_count, + COALESCE(SUM(sl.spend), 0)::double precision AS spend, + COALESCE(SUM(sl.total_tokens), 0)::bigint AS total_tokens + FROM "LiteLLM_SpendLogToolIndex" ti + JOIN "LiteLLM_SpendLogs" sl ON sl.request_id = ti.request_id + WHERE ti.start_time >= ($1::timestamptz AT TIME ZONE 'UTC') + AND ti.start_time < ($2::timestamptz AT TIME ZONE 'UTC') + GROUP BY date, ti.tool_name + ORDER BY date ASC, spend DESC + """, + start_dt.isoformat(), + end_exclusive.isoformat(), + ) + totals = await prisma_client.db.query_raw( + """ + SELECT COALESCE(SUM(sl.spend), 0)::double precision AS total_spend + FROM "LiteLLM_SpendLogs" sl + WHERE EXISTS ( + SELECT 1 + FROM "LiteLLM_SpendLogToolIndex" ti + WHERE ti.request_id = sl.request_id + AND ti.start_time >= ($1::timestamptz AT TIME ZONE 'UTC') + AND ti.start_time < ($2::timestamptz AT TIME ZONE 'UTC') + ) + """, + start_dt.isoformat(), + end_exclusive.isoformat(), + ) + total_rows = _REQUEST_TOTAL_ROWS.validate_python(totals or []) + return _build_tool_spend_response( + rows=_TOOL_SPEND_ROWS.validate_python(rows or []), + total_spend=total_rows[0].total_spend if total_rows else 0.0, + start_date=start_dt.strftime("%Y-%m-%d"), + end_date=(end_day or now).strftime("%Y-%m-%d"), + ) + + @router.get( "/v1/tool/{tool_name:path}/detail", tags=["tool management"], diff --git a/litellm/types/tool_management.py b/litellm/types/tool_management.py index 1c5e1df9e9a..71ec412e8ef 100644 --- a/litellm/types/tool_management.py +++ b/litellm/types/tool_management.py @@ -98,3 +98,38 @@ class ToolUsageLogsResponse(BaseModel): total: int page: int page_size: int + + +class ToolSpendEntry(BaseModel): + """Total spend attributed to one tool over the requested window.""" + + tool_name: str + spend: float = Field( + 0.0, + description="Attributed spend: a request that used several tools counts its full spend toward each of them", + ) + call_count: int = 0 + total_tokens: int = 0 + + +class ToolSpendDailyEntry(BaseModel): + """Spend attributed to one tool on one UTC day.""" + + date: str + tool_name: str + spend: float = 0.0 + call_count: int = 0 + + +class ToolSpendResponse(BaseModel): + by_tool: List[ToolSpendEntry] = Field(default_factory=list) + daily: List[ToolSpendDailyEntry] = Field(default_factory=list) + total_spend: float = Field( + 0.0, + description=( + "Deduplicated spend of every request that called at least one tool in the window; " + "less than the sum of per-tool attributed spend whenever multi-tool requests exist" + ), + ) + start_date: str | None = None + end_date: str | None = None diff --git a/tests/test_litellm/proxy/management_endpoints/test_tool_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_tool_management_endpoints.py index cf80ee5dee5..351f125052d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_tool_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_tool_management_endpoints.py @@ -13,12 +13,17 @@ from datetime import datetime, timezone from typing import Optional from unittest.mock import AsyncMock, MagicMock, patch +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient sys.path.insert(0, os.path.abspath("../../..")) -from litellm.proxy.management_endpoints.tool_management_endpoints import router +from litellm.proxy.management_endpoints.tool_management_endpoints import ( + _build_tool_spend_response, + _ToolSpendRow, + router, +) from litellm.types.tool_management import LiteLLM_ToolTableRow # --- helpers --- @@ -50,9 +55,9 @@ def _make_app() -> FastAPI: # Stub the auth dependency so we don't need a real proxy running. def _override_auth(): - from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth - return UserAPIKeyAuth(api_key="sk-test", user_id="admin") + return UserAPIKeyAuth(api_key="sk-test", user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) # A real (non-None) prisma stub for truthiness checks. @@ -147,3 +152,117 @@ class TestToolManagementEndpoints: json={"tool_name": "my_tool", "input_policy": "invalid_value"}, ) assert resp.status_code == 422 + + def test_tool_spend_route_not_shadowed_by_get_tool(self): + prisma = MagicMock() + prisma.db.query_raw = AsyncMock(return_value=[]) + with patch("litellm.proxy.proxy_server.prisma_client", prisma): + resp = self.client.get("/v1/tool/spend") + assert resp.status_code == 200 + assert resp.json()["by_tool"] == [] + + def test_tool_spend_aggregates_and_sorts(self): + rows = [ + {"date": "2026-07-01", "tool_name": "search", "call_count": 2, "spend": 1.0, "total_tokens": 100}, + {"date": "2026-07-02", "tool_name": "search", "call_count": 1, "spend": 4.0, "total_tokens": 50}, + {"date": "2026-07-01", "tool_name": "read_file", "call_count": 3, "spend": 2.0, "total_tokens": 300}, + ] + prisma = MagicMock() + prisma.db.query_raw = AsyncMock(side_effect=[rows, [{"total_spend": 5.5}]]) + with patch("litellm.proxy.proxy_server.prisma_client", prisma): + resp = self.client.get("/v1/tool/spend?start_date=2026-07-01&end_date=2026-07-02") + assert resp.status_code == 200 + body = resp.json() + assert [t["tool_name"] for t in body["by_tool"]] == ["search", "read_file"] + search = body["by_tool"][0] + assert search["spend"] == 5.0 + assert search["call_count"] == 3 + assert search["total_tokens"] == 150 + assert len(body["daily"]) == 3 + assert body["start_date"] == "2026-07-01" + assert body["end_date"] == "2026-07-02" + assert body["total_spend"] == 5.5 + + @patch("litellm.proxy.proxy_server.prisma_client", None) + def test_tool_spend_no_db_returns_500(self): + resp = self.client.get("/v1/tool/spend") + assert resp.status_code == 500 + + def test_tool_spend_end_date_is_inclusive_via_exclusive_next_day_bound(self): + prisma = MagicMock() + prisma.db.query_raw = AsyncMock(return_value=[]) + with patch("litellm.proxy.proxy_server.prisma_client", prisma): + resp = self.client.get("/v1/tool/spend?start_date=2026-07-01&end_date=2026-07-02") + assert resp.status_code == 200 + expected_binds = ( + datetime(2026, 7, 1, tzinfo=timezone.utc).isoformat(), + datetime(2026, 7, 3, tzinfo=timezone.utc).isoformat(), + ) + assert prisma.db.query_raw.await_count == 2 + for call in prisma.db.query_raw.await_args_list: + assert tuple(call.args[1:]) == expected_binds + assert resp.json()["end_date"] == "2026-07-02" + + @pytest.mark.parametrize( + "query", + [ + "start_date=not-a-date", + "start_date=2026-02-30", + "start_date=07/01/2026", + "end_date=2026-13-01", + "end_date=20260701", + ], + ) + def test_tool_spend_malformed_date_returns_400(self, query: str): + prisma = MagicMock() + prisma.db.query_raw = AsyncMock(return_value=[]) + with patch("litellm.proxy.proxy_server.prisma_client", prisma): + resp = self.client.get(f"/v1/tool/spend?{query}") + assert resp.status_code == 400 + assert "Invalid date format" in resp.json()["detail"] + prisma.db.query_raw.assert_not_awaited() + + def test_tool_spend_non_admin_returns_403(self): + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + app = _make_app() + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="sk-user", user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER + ) + client = TestClient(app, raise_server_exceptions=True) + prisma = MagicMock() + prisma.db.query_raw = AsyncMock(return_value=[]) + with patch("litellm.proxy.proxy_server.prisma_client", prisma): + resp = client.get("/v1/tool/spend") + assert resp.status_code == 403 + prisma.db.query_raw.assert_not_awaited() + + +def _spend_row(date: str, tool_name: str, spend: float, call_count: int = 1, total_tokens: int = 10) -> _ToolSpendRow: + return _ToolSpendRow(date=date, tool_name=tool_name, call_count=call_count, spend=spend, total_tokens=total_tokens) + + +class TestBuildToolSpendResponse: + def test_multi_tool_attribution_double_counts_per_tool_but_not_total(self): + rows = [ + _spend_row("2026-07-01", "a", spend=3.0), + _spend_row("2026-07-01", "b", spend=3.0), + ] + resp = _build_tool_spend_response(rows, total_spend=3.0, start_date="2026-07-01", end_date="2026-07-01") + by_tool = {t.tool_name: t.spend for t in resp.by_tool} + assert by_tool == {"a": 3.0, "b": 3.0} + assert resp.total_spend == 3.0 + + def test_groups_across_days_and_sorts_by_spend(self): + rows = [ + _spend_row("2026-07-01", "b", spend=1.0, call_count=2, total_tokens=100), + _spend_row("2026-07-02", "b", spend=4.0, call_count=1, total_tokens=50), + _spend_row("2026-07-01", "a", spend=2.0, call_count=3, total_tokens=300), + ] + resp = _build_tool_spend_response(rows, total_spend=7.0, start_date="2026-07-01", end_date="2026-07-02") + assert [(t.tool_name, t.spend, t.call_count, t.total_tokens) for t in resp.by_tool] == [ + ("b", 5.0, 3, 150), + ("a", 2.0, 3, 300), + ] + assert len(resp.daily) == 3 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx new file mode 100644 index 00000000000..1d82fbb48ea --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx @@ -0,0 +1,83 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { DailyData, KeyMetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types"; + +vi.mock("@/components/shared/advanced_date_picker", () => ({ + __esModule: true, + default: () =>
, +})); + +import CacheLeakageCard from "./CacheLeakageCard"; + +const baseMetrics = (overrides: Partial): SpendMetrics => ({ + spend: 0, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + api_requests: 0, + successful_requests: 0, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + ...overrides, +}); + +const key = (alias: string, metrics: Partial): KeyMetricWithMetadata => ({ + metrics: baseMetrics(metrics), + metadata: { key_alias: alias, team_id: null }, +}); + +const dayWithKeys = (date: string, apiKeys: Record): DailyData => ({ + date, + metrics: baseMetrics({}), + breakdown: { + models: {}, + model_groups: {}, + mcp_servers: {}, + providers: {}, + api_keys: apiKeys, + entities: {}, + }, +}); + +const renderWith = (results: DailyData[]) => + render( + , + ); + +describe("CacheLeakageCard", () => { + it("ranks leaking keys by uncached prompt tokens and shows cache hit ratio", () => { + const { getByText, getByLabelText } = renderWith([ + dayWithKeys("2026-07-12", { + "hash-caching": key("caching-key", { prompt_tokens: 1000, cache_read_input_tokens: 900 }), + "hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }), + }), + ]); + + expect(getByText("leaky-key")).toBeInTheDocument(); + expect(getByText("0.0%")).toBeInTheDocument(); + expect(getByText("90.0%")).toBeInTheDocument(); + [ + "Input tokens in the selected range that were neither read from nor written to the prompt cache", + "Share of this key's total input tokens that were served from the prompt cache", + "Dollars this key actually saved because cached input was billed at the discounted cache-read rate", + "Approximate dollars this key could still save if its uncached input had hit the cache at the portfolio's realized discount", + ].forEach((info) => expect(getByLabelText(info)).toBeInTheDocument()); + }); + + it("shows an empty state when no key used tokens in the range", () => { + const { getByText, queryByRole } = renderWith([dayWithKeys("2026-07-12", {})]); + + expect(getByText("No key usage in this range.")).toBeInTheDocument(); + expect(queryByRole("table")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx new file mode 100644 index 00000000000..359ce502b07 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx @@ -0,0 +1,112 @@ +"use client"; + +import React, { useMemo } from "react"; +import { Info } from "lucide-react"; + +import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { computeCacheLeakage, pct, usd } from "./costOptimizationUtils"; +import { DailyActivityRange } from "./useDailyActivityRange"; + +interface CacheLeakageCardProps { + activity: DailyActivityRange; +} + +const HeadWithInfo = ({ label, info }: { label: string; info: string }) => ( + + {label} + + + + + + + {info} + + +); + +const CacheLeakageCard: React.FC = ({ activity }) => { + const { dateValue, onDateChange, results, loading, isFetchingMore } = activity; + const leakage = useMemo(() => computeCacheLeakage(results), [results]); + + return ( + + + +
+
+ Cache leakage by virtual key +

+ Keys sending large volumes of uncached prompt tokens with a low cache-hit ratio are likely missing + prompt caching. Estimated savings left is approximate: uncached prompt tokens priced at the + portfolio's realized cache-read discount. +

+
+ +
+
+ + {leakage.rows.length === 0 ? ( +

+ {loading || isFetchingMore ? "Loading..." : "No key usage in this range."} +

+ ) : ( + + + + Key + + + + + + + + + + + + + + + + {leakage.rows.map((row) => ( + + + {row.keyAlias || `${row.apiKey.slice(0, 8)}...`} + {row.teamId && ({row.teamId})} + + {formatNumberWithCommas(row.uncachedPromptTokens)} + {pct(row.cacheHitRatio)} + {usd(row.realizedCachingSavings)} + + {row.estSavingsLeft == null ? "—" : usd(row.estSavingsLeft)} + + + ))} + +
+ )} +
+
+
+ ); +}; + +export default CacheLeakageCard; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx new file mode 100644 index 00000000000..97289a7ca46 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const mockUserDailyActivityCall = vi.fn(); + +vi.mock("@/components/networking", () => ({ + userDailyActivityCall: (...args: unknown[]) => mockUserDailyActivityCall(...args), + getToolSpend: vi.fn().mockResolvedValue({ by_tool: [], daily: [], total_spend: 0, start_date: null, end_date: null }), + getGeneralSettingsCall: vi.fn().mockResolvedValue([]), +})); + +vi.mock("@/components/shared/advanced_date_picker", () => ({ + __esModule: true, + default: () =>
, +})); + +vi.mock("@/components/shared/charts", () => ({ + AreaChart: () =>
, + DonutChart: () =>
, + BarChart: () =>
, + DEFAULT_COLOR_CYCLE: ["emerald"], +})); + +vi.mock("@/app/(dashboard)/router-settings/_components/general_settings", () => ({ + PromptCachingPanel: () =>
, +})); + +vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () =>
})); +vi.mock("./AutorouterTab", () => ({ __esModule: true, default: () =>
})); + +import CostOptimizationView from "./CostOptimizationView"; + +const singlePage = { + results: [], + metadata: { total_pages: 1, has_more: false, page: 1 }, +}; + +describe("CostOptimizationView daily activity", () => { + it("fetches daily activity once for the page and shares it with every tab that needs it", async () => { + mockUserDailyActivityCall.mockResolvedValue(singlePage); + + const { getByRole, getByTestId } = render( + , + ); + + await waitFor(() => expect(mockUserDailyActivityCall).toHaveBeenCalledTimes(1)); + + fireEvent.click(getByRole("tab", { name: "Prompt Caching" })); + await waitFor(() => expect(getByTestId("caching-settings")).toBeInTheDocument()); + + expect(mockUserDailyActivityCall).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx index 6e6830b8451..3bab6afee57 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx @@ -8,6 +8,7 @@ import UsageTab from "./UsageTab"; import PromptCompressionTab from "./PromptCompressionTab"; import AutorouterTab from "./AutorouterTab"; import PromptCachingTab from "./PromptCachingTab"; +import { useDailyActivityRange } from "./useDailyActivityRange"; interface CostOptimizationViewProps { accessToken: string | null; @@ -16,11 +17,13 @@ interface CostOptimizationViewProps { } const CostOptimizationView: React.FC = ({ accessToken, userId, userRole }) => { + const activity = useDailyActivityRange(accessToken, userId, userRole); + const items = [ { key: "usage", label: "Usage", - children: , + children: , }, { key: "compression", @@ -35,7 +38,7 @@ const CostOptimizationView: React.FC = ({ accessToken { key: "caching", label: "Prompt Caching", - children: , + children: , }, ]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx new file mode 100644 index 00000000000..a18109e8133 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx @@ -0,0 +1,43 @@ +import { render, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const mockGetGeneralSettingsCall = vi.fn(); + +vi.mock("@/components/networking", () => ({ + getGeneralSettingsCall: (...args: unknown[]) => mockGetGeneralSettingsCall(...args), +})); + +vi.mock("@/app/(dashboard)/router-settings/_components/general_settings", () => ({ + PromptCachingPanel: () =>
, +})); + +const mockCacheLeakageCard = vi.fn(); + +vi.mock("./CacheLeakageCard", () => ({ + __esModule: true, + default: (props: unknown) => { + mockCacheLeakageCard(props); + return
; + }, +})); + +import PromptCachingTab from "./PromptCachingTab"; + +describe("PromptCachingTab", () => { + it("renders the cache leakage table alongside the caching settings", async () => { + mockGetGeneralSettingsCall.mockResolvedValue([]); + + const activity = { + dateValue: {}, + onDateChange: vi.fn(), + results: [], + loading: false, + isFetchingMore: false, + }; + const { getByTestId } = render(); + + expect(getByTestId("caching-settings")).toBeInTheDocument(); + expect(getByTestId("cache-leakage-card")).toBeInTheDocument(); + await waitFor(() => expect(mockCacheLeakageCard).toHaveBeenCalledWith(expect.objectContaining({ activity }))); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.tsx index e6f73088824..952e9f653ac 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.tsx @@ -8,12 +8,15 @@ import { PromptCachingPanel, generalSettingsItem, } from "@/app/(dashboard)/router-settings/_components/general_settings"; +import CacheLeakageCard from "./CacheLeakageCard"; +import { DailyActivityRange } from "./useDailyActivityRange"; interface PromptCachingTabProps { accessToken: string | null; + activity: DailyActivityRange; } -const PromptCachingTab: React.FC = ({ accessToken }) => { +const PromptCachingTab: React.FC = ({ accessToken, activity }) => { const [settings, setSettings] = useState([]); const loadSettings = useCallback(() => { @@ -43,8 +46,9 @@ const PromptCachingTab: React.FC = ({ accessToken }) => { } return ( -
+
+
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx index 048d9a34d31..0e2f16c5d93 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx @@ -1,16 +1,13 @@ import { render } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import type { ToolSpendResponse } from "@/components/networking"; import type { DailyData, SpendMetrics } from "@/components/UsagePage/types"; -const mockUsePaginatedDailyActivity = vi.fn(); - -vi.mock("@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity", () => ({ - usePaginatedDailyActivity: (args: unknown) => mockUsePaginatedDailyActivity(args), -})); +const mockGetToolSpend = vi.fn(); vi.mock("@/components/networking", () => ({ - userDailyActivityCall: vi.fn(), + getToolSpend: (...args: unknown[]) => mockGetToolSpend(...args), })); vi.mock("@/components/shared/advanced_date_picker", () => ({ @@ -25,10 +22,16 @@ vi.mock("@/components/shared/charts", () => ({ DonutChart: ({ data, label }: { data: unknown; label: string }) => (
), + BarChart: ({ data, categories }: { data: unknown; categories: string[] }) => ( +
+ ), + DEFAULT_COLOR_CYCLE: ["emerald", "blue", "violet", "amber"], })); import UsageTab from "./UsageTab"; +const emptyToolSpend: ToolSpendResponse = { by_tool: [], daily: [], total_spend: 0, start_date: null, end_date: null }; + const baseMetrics = (overrides: Partial): SpendMetrics => ({ spend: 0, prompt_tokens: 0, @@ -55,9 +58,20 @@ const day = (date: string, metrics: Partial): DailyData => ({ }, }); -const renderWith = (results: DailyData[]) => { - mockUsePaginatedDailyActivity.mockReturnValue({ data: { results }, loading: false, isFetchingMore: false }); - return render(); +const renderWith = (results: DailyData[], toolSpend = emptyToolSpend) => { + mockGetToolSpend.mockResolvedValue(toolSpend); + return render( + , + ); }; describe("UsageTab", () => { @@ -105,4 +119,22 @@ describe("UsageTab", () => { const slices = JSON.parse(getByTestId("donut-chart").getAttribute("data-slices") ?? "[]"); expect(slices).toEqual([{ driver: "Compression", usd: expect.closeTo(0.04, 5) }]); }); + + it("renders spend-by-tool bars from the tool spend endpoint", async () => { + const toolSpend = { + by_tool: [ + { tool_name: "search", spend: 4.0, call_count: 3, total_tokens: 150 }, + { tool_name: "read_file", spend: 1.0, call_count: 2, total_tokens: 50 }, + ], + daily: [{ date: "2026-07-12", tool_name: "search", spend: 4.0, call_count: 3 }], + total_spend: 5.0, + start_date: "2026-07-12", + end_date: "2026-07-12", + }; + const { findAllByTestId } = renderWith([day("2026-07-12", {})], toolSpend); + + const bars = await findAllByTestId("bar-chart"); + const series = JSON.parse(bars[0].getAttribute("data-series") ?? "[]"); + expect(series[0]).toMatchObject({ tool_name: "search", spend: 4.0 }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx index 8e6fc40b5ad..9216dbfaad2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx @@ -1,35 +1,35 @@ "use client"; -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Collapse } from "antd"; -import { AreaChart, DonutChart } from "@/components/shared/charts"; +import { AreaChart, BarChart, DonutChart, DEFAULT_COLOR_CYCLE } from "@/components/shared/charts"; import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { userDailyActivityCall } from "@/components/networking"; -import { DailyData, SpendMetrics } from "@/components/UsagePage/types"; +import { getToolSpend, ToolSpendResponse } from "@/components/networking"; +import { SpendMetrics } from "@/components/UsagePage/types"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { all_admin_roles } from "@/utils/roles"; -import { usePaginatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity"; +import { buildDailyToolSeries, topToolsBySpend, usd } from "./costOptimizationUtils"; +import { DailyActivityRange } from "./useDailyActivityRange"; interface UsageTabProps { accessToken: string | null; - userId: string | null; - userRole: string; + activity: DailyActivityRange; } -type DateRange = { from?: Date; to?: Date }; - -const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; - -const usd = (value: number): string => { - const decimals = value > 0 && value < 1 ? 4 : 2; - return `$${formatNumberWithCommas(value, decimals)}`; +const EMPTY_TOOL_SPEND: ToolSpendResponse = { + by_tool: [], + daily: [], + total_spend: 0, + start_date: null, + end_date: null, }; const shortDate = (iso: string): string => new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" }); +const isoDay = (d: Date): string => d.toISOString().slice(0, 10); + const compressionOf = (m: SpendMetrics): number => m.compression_savings_spend ?? 0; const cachingOf = (m: SpendMetrics): number => m.prompt_caching_savings_spend ?? 0; const savedTokensOf = (m: SpendMetrics): number => m.compression_saved_tokens ?? 0; @@ -81,23 +81,33 @@ const SummaryCard = ({ label, value, hint }: { label: string; value: string; hin ); -const UsageTab: React.FC = ({ accessToken, userId, userRole }) => { - const initialFrom = useMemo(() => new Date(new Date().getTime() - THIRTY_DAYS_MS), []); - const initialTo = useMemo(() => new Date(), []); - const [dateValue, setDateValue] = useState({ from: initialFrom, to: initialTo }); +const UsageTab: React.FC = ({ accessToken, activity }) => { + const { dateValue, onDateChange, results, loading, isFetchingMore } = activity; const startTime = dateValue.from ?? null; const endTime = dateValue.to ?? null; - const isAdmin = all_admin_roles.includes(userRole); - const effectiveUserId = isAdmin ? null : userId; - const { data, loading, isFetchingMore } = usePaginatedDailyActivity({ - fetchFn: userDailyActivityCall, - args: [accessToken, startTime, endTime, effectiveUserId], - enabled: !!accessToken && !!startTime && !!endTime, - }); + const toolSpendEnabled = !!accessToken && !!startTime && !!endTime; + const rangeKey = startTime && endTime ? `${isoDay(startTime)}|${isoDay(endTime)}` : ""; + const [toolSpendState, setToolSpendState] = useState<{ key: string; data: ToolSpendResponse } | null>(null); - const results = data.results as DailyData[]; + useEffect(() => { + if (!accessToken || !startTime || !endTime) return; + let cancelled = false; + getToolSpend(accessToken, isoDay(startTime), isoDay(endTime)) + .then((res) => { + if (!cancelled) setToolSpendState({ key: rangeKey, data: res }); + }) + .catch(() => { + if (!cancelled) setToolSpendState({ key: rangeKey, data: EMPTY_TOOL_SPEND }); + }); + return () => { + cancelled = true; + }; + }, [accessToken, startTime, endTime, rangeKey]); + + const toolSpend = toolSpendState?.key === rangeKey ? toolSpendState.data : null; + const toolSpendLoading = toolSpendEnabled && toolSpend === null; const compressionTotal = useMemo(() => results.reduce((sum, d) => sum + compressionOf(d.metrics), 0), [results]); const cachingTotal = useMemo(() => results.reduce((sum, d) => sum + cachingOf(d.metrics), 0), [results]); @@ -123,11 +133,27 @@ const UsageTab: React.FC = ({ accessToken, userId, userRole }) => [compressionTotal, cachingTotal], ); + const topTools = useMemo(() => topToolsBySpend(toolSpend?.by_tool ?? []), [toolSpend]); + const topToolNames = useMemo(() => topTools.map((t) => t.tool_name), [topTools]); + const topToolsChart = useMemo[]>( + () => topTools.map((t) => ({ tool_name: t.tool_name, spend: t.spend })), + [topTools], + ); + const dailyToolSeries = useMemo( + () => + buildDailyToolSeries(toolSpend?.daily ?? [], topToolNames).map((point) => ({ + ...point, + date: shortDate(String(point.date)), + })), + [toolSpend, topToolNames], + ); + const toolColors = useMemo(() => DEFAULT_COLOR_CYCLE.slice(0, Math.max(topToolNames.length, 1)), [topToolNames]); + return (
- setDateValue(v)} /> +
@@ -177,6 +203,50 @@ const UsageTab: React.FC = ({ accessToken, userId, userRole }) =>
+ + + + Spend by tool +

+ Spend on requests that called each tool (MCP and client-side tools). A request that used multiple tools + counts its full spend toward each, so this attributes rather than partitions spend. +

+
+ + {topTools.length === 0 ? ( +

+ {toolSpendLoading ? "Loading..." : "No tool usage in this range."} +

+ ) : ( +
+
+

Total by tool

+ +
+
+

Daily spend by tool

+ +
+
+ )} +
+
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts new file mode 100644 index 00000000000..562552ffb50 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; + +import type { DailyData, SpendMetrics } from "@/components/UsagePage/types"; +import type { ToolSpendDailyEntry, ToolSpendEntry } from "@/components/networking"; +import { buildDailyToolSeries, computeCacheLeakage, topToolsBySpend } from "./costOptimizationUtils"; + +const metrics = (overrides: Partial): SpendMetrics => ({ + spend: 0, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + api_requests: 0, + successful_requests: 0, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + ...overrides, +}); + +const day = ( + date: string, + keys: Record }>, +): DailyData => ({ + date, + metrics: metrics({}), + breakdown: { + models: {}, + model_groups: {}, + mcp_servers: {}, + providers: {}, + entities: {}, + api_keys: Object.fromEntries( + Object.entries(keys).map(([hash, v]) => [ + hash, + { metrics: metrics(v.metrics), metadata: { key_alias: v.alias, team_id: null } }, + ]), + ), + }, +}); + +describe("computeCacheLeakage", () => { + it("aggregates a key's tokens and savings across multiple days", () => { + const results = [ + day("2026-07-01", { h1: { alias: "svc-a", metrics: { prompt_tokens: 1000, cache_read_input_tokens: 0 } } }), + day("2026-07-02", { h1: { alias: "svc-a", metrics: { prompt_tokens: 500, cache_read_input_tokens: 0 } } }), + ]; + const { rows } = computeCacheLeakage(results); + expect(rows).toHaveLength(1); + expect(rows[0].uncachedPromptTokens).toBe(1500); + }); + + it("subtracts cache reads and writes from prompt tokens instead of double-counting them", () => { + const results = [ + day("2026-07-01", { + h1: { + alias: "svc-a", + metrics: { prompt_tokens: 1000, cache_read_input_tokens: 400, cache_creation_input_tokens: 100 }, + }, + }), + ]; + const { rows } = computeCacheLeakage(results); + expect(rows).toHaveLength(1); + expect(rows[0].uncachedPromptTokens).toBe(500); + expect(rows[0].cacheHitRatio).toBeCloseTo(0.4, 6); + }); + + it("prices leakage at the portfolio's realized cache-read discount and drops fully cached keys", () => { + const results = [ + day("2026-07-01", { + cacher: { + alias: "cacher", + metrics: { prompt_tokens: 1000, cache_read_input_tokens: 1000, prompt_caching_savings_spend: 2.0 }, + }, + leaker: { alias: "leaker", metrics: { prompt_tokens: 500 } }, + }), + ]; + const { rows, discountPerToken } = computeCacheLeakage(results); + expect(discountPerToken).toBeCloseTo(0.002, 6); + expect(rows.map((r) => r.keyAlias)).toEqual(["leaker"]); + expect(rows[0].estSavingsLeft).toBeCloseTo(1.0, 6); + }); + + it("returns null estimate and ranks by uncached tokens when nobody used caching", () => { + const results = [ + day("2026-07-01", { + big: { alias: "big", metrics: { prompt_tokens: 9000 } }, + small: { alias: "small", metrics: { prompt_tokens: 100 } }, + }), + ]; + const { rows, discountPerToken } = computeCacheLeakage(results); + expect(discountPerToken).toBeNull(); + expect(rows.map((r) => r.keyAlias)).toEqual(["big", "small"]); + expect(rows.every((r) => r.estSavingsLeft === null)).toBe(true); + }); + + it("computes cache hit ratio against total prompt tokens and clamps inconsistent data at zero", () => { + const results = [ + day("2026-07-01", { + onlycache: { alias: "onlycache", metrics: { cache_read_input_tokens: 100 } }, + mixed: { alias: "mixed", metrics: { prompt_tokens: 1000, cache_read_input_tokens: 750 } }, + }), + ]; + const { rows } = computeCacheLeakage(results); + expect(rows.map((r) => r.keyAlias)).toEqual(["mixed"]); + expect(rows[0].cacheHitRatio).toBeCloseTo(0.75, 6); + expect(rows[0].uncachedPromptTokens).toBe(250); + }); + + it("respects the row limit", () => { + const keys = Object.fromEntries( + Array.from({ length: 15 }, (_, i) => [`h${i}`, { alias: `k${i}`, metrics: { prompt_tokens: i + 1 } }]), + ); + const { rows } = computeCacheLeakage([day("2026-07-01", keys)], 5); + expect(rows).toHaveLength(5); + }); +}); + +describe("buildDailyToolSeries", () => { + const daily: ToolSpendDailyEntry[] = [ + { date: "2026-07-01", tool_name: "search", spend: 1.0, call_count: 1 }, + { date: "2026-07-01", tool_name: "read", spend: 0.5, call_count: 1 }, + { date: "2026-07-02", tool_name: "search", spend: 2.0, call_count: 1 }, + { date: "2026-07-01", tool_name: "excluded", spend: 9.0, call_count: 1 }, + ]; + + it("pivots to per-date points keyed by the selected tools, dropping others", () => { + const series = buildDailyToolSeries(daily, ["search", "read"]); + expect(series).toEqual([ + { date: "2026-07-01", search: 1.0, read: 0.5 }, + { date: "2026-07-02", search: 2.0, read: 0 }, + ]); + }); + + it("sums repeated (date, tool) rows", () => { + const series = buildDailyToolSeries( + [ + { date: "2026-07-01", tool_name: "search", spend: 1.0, call_count: 1 }, + { date: "2026-07-01", tool_name: "search", spend: 2.5, call_count: 1 }, + ], + ["search"], + ); + expect(series[0].search).toBe(3.5); + }); +}); + +describe("topToolsBySpend", () => { + const byTool: ToolSpendEntry[] = [ + { tool_name: "a", spend: 1, call_count: 1, total_tokens: 1 }, + { tool_name: "b", spend: 5, call_count: 1, total_tokens: 1 }, + { tool_name: "c", spend: 3, call_count: 1, total_tokens: 1 }, + ]; + + it("sorts by spend descending and truncates to the limit", () => { + expect(topToolsBySpend(byTool, 2).map((t) => t.tool_name)).toEqual(["b", "c"]); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts new file mode 100644 index 00000000000..2868bdac880 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts @@ -0,0 +1,123 @@ +import { DailyData } from "@/components/UsagePage/types"; +import { ToolSpendDailyEntry, ToolSpendEntry } from "@/components/networking"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; + +export const usd = (value: number): string => { + const decimals = value > 0 && value < 1 ? 4 : 2; + return `$${formatNumberWithCommas(value, decimals)}`; +}; + +export const pct = (ratio: number): string => `${formatNumberWithCommas(ratio * 100, 1)}%`; + +export interface CacheLeakageRow { + apiKey: string; + keyAlias: string | null; + teamId: string | null; + uncachedPromptTokens: number; + cacheReadTokens: number; + cacheHitRatio: number; + realizedCachingSavings: number; + estSavingsLeft: number | null; +} + +export interface CacheLeakageResult { + rows: CacheLeakageRow[]; + discountPerToken: number | null; +} + +interface KeyAccumulator { + keyAlias: string | null; + teamId: string | null; + promptTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + realizedCachingSavings: number; +} + +const emptyAccumulator = (): KeyAccumulator => ({ + keyAlias: null, + teamId: null, + promptTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + realizedCachingSavings: 0, +}); + +export const computeCacheLeakage = (results: readonly DailyData[], limit = 10): CacheLeakageResult => { + const byKey = new Map(); + for (const day of results) { + const apiKeys = day.breakdown?.api_keys ?? {}; + for (const [apiKey, entry] of Object.entries(apiKeys)) { + const acc = byKey.get(apiKey) ?? emptyAccumulator(); + const m = entry.metrics; + const next: KeyAccumulator = { + keyAlias: acc.keyAlias ?? entry.metadata?.key_alias ?? null, + teamId: acc.teamId ?? entry.metadata?.team_id ?? null, + promptTokens: acc.promptTokens + (m.prompt_tokens ?? 0), + cacheReadTokens: acc.cacheReadTokens + (m.cache_read_input_tokens ?? 0), + cacheCreationTokens: acc.cacheCreationTokens + (m.cache_creation_input_tokens ?? 0), + realizedCachingSavings: acc.realizedCachingSavings + (m.prompt_caching_savings_spend ?? 0), + }; + byKey.set(apiKey, next); + } + } + + const totals = [...byKey.values()].reduce( + (agg, a) => ({ + cacheReadTokens: agg.cacheReadTokens + a.cacheReadTokens, + realizedCachingSavings: agg.realizedCachingSavings + a.realizedCachingSavings, + }), + { cacheReadTokens: 0, realizedCachingSavings: 0 }, + ); + const discountPerToken = totals.cacheReadTokens > 0 ? totals.realizedCachingSavings / totals.cacheReadTokens : null; + + const rows: CacheLeakageRow[] = [...byKey.entries()] + .map(([apiKey, a]) => { + const uncachedPromptTokens = Math.max(0, a.promptTokens - a.cacheReadTokens - a.cacheCreationTokens); + return { + apiKey, + keyAlias: a.keyAlias, + teamId: a.teamId, + uncachedPromptTokens, + cacheReadTokens: a.cacheReadTokens, + cacheHitRatio: a.promptTokens > 0 ? a.cacheReadTokens / a.promptTokens : 0, + realizedCachingSavings: a.realizedCachingSavings, + estSavingsLeft: discountPerToken != null ? uncachedPromptTokens * discountPerToken : null, + }; + }) + .filter((row) => row.uncachedPromptTokens > 0); + + const sorted = rows.sort((x, y) => + discountPerToken != null + ? (y.estSavingsLeft ?? 0) - (x.estSavingsLeft ?? 0) + : y.uncachedPromptTokens - x.uncachedPromptTokens, + ); + + return { rows: sorted.slice(0, limit), discountPerToken }; +}; + +export interface DailyToolSpendPoint { + date: string; + [toolName: string]: string | number; +} + +export const buildDailyToolSeries = ( + daily: readonly ToolSpendDailyEntry[], + topToolNames: readonly string[], +): DailyToolSpendPoint[] => { + const top = new Set(topToolNames); + const byDate = new Map(); + for (const d of daily) { + if (!top.has(d.tool_name)) continue; + const point = byDate.get(d.date) ?? seedPoint(d.date, topToolNames); + point[d.tool_name] = (Number(point[d.tool_name]) || 0) + d.spend; + byDate.set(d.date, point); + } + return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)); +}; + +const seedPoint = (date: string, toolNames: readonly string[]): DailyToolSpendPoint => + toolNames.reduce((p, name) => ({ ...p, [name]: 0 }), { date }); + +export const topToolsBySpend = (byTool: readonly ToolSpendEntry[], limit = 8): ToolSpendEntry[] => + [...byTool].sort((a, b) => b.spend - a.spend).slice(0, limit); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx new file mode 100644 index 00000000000..9fd27d80c37 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx @@ -0,0 +1,39 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const mockUsePaginatedDailyActivity = vi.fn(); + +vi.mock("@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity", () => ({ + usePaginatedDailyActivity: (args: unknown) => { + mockUsePaginatedDailyActivity(args); + return { data: { results: [] }, loading: false, isFetchingMore: false }; + }, +})); + +vi.mock("@/components/networking", () => ({ + userDailyActivityCall: vi.fn(), +})); + +import { useDailyActivityRange } from "./useDailyActivityRange"; + +const argsOfLastCall = () => mockUsePaginatedDailyActivity.mock.calls.at(-1)?.[0].args as unknown[]; + +describe("useDailyActivityRange", () => { + it("queries every user's activity for an admin", () => { + renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin")); + + expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), null]); + }); + + it("scopes the query to the caller for a non-admin", () => { + renderHook(() => useDailyActivityRange("test-token", "u1", "internal_user")); + + expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1"]); + }); + + it("stays disabled until an access token is available", () => { + renderHook(() => useDailyActivityRange(null, "u1", "proxy_admin")); + + expect(mockUsePaginatedDailyActivity).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: false })); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts new file mode 100644 index 00000000000..1c3f706726e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts @@ -0,0 +1,49 @@ +import { useMemo, useState } from "react"; + +import { userDailyActivityCall } from "@/components/networking"; +import { DailyData } from "@/components/UsagePage/types"; +import { all_admin_roles } from "@/utils/roles"; +import { usePaginatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity"; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +export interface DateRange { + from?: Date; + to?: Date; +} + +export interface DailyActivityRange { + dateValue: DateRange; + onDateChange: (value: DateRange) => void; + results: DailyData[]; + loading: boolean; + isFetchingMore: boolean; +} + +export const useDailyActivityRange = ( + accessToken: string | null, + userId: string | null, + userRole: string, +): DailyActivityRange => { + const initialFrom = useMemo(() => new Date(new Date().getTime() - THIRTY_DAYS_MS), []); + const initialTo = useMemo(() => new Date(), []); + const [dateValue, setDateValue] = useState({ from: initialFrom, to: initialTo }); + + const startTime = dateValue.from ?? null; + const endTime = dateValue.to ?? null; + const effectiveUserId = all_admin_roles.includes(userRole) ? null : userId; + + const { data, loading, isFetchingMore } = usePaginatedDailyActivity({ + fetchFn: userDailyActivityCall, + args: [accessToken, startTime, endTime, effectiveUserId], + enabled: !!accessToken && !!startTime && !!endTime, + }); + + return { + dateValue, + onDateChange: setDateValue, + results: data.results as DailyData[], + loading, + isFetchingMore, + }; +}; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index f5fe3990ba7..9dcb4321445 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -7582,6 +7582,38 @@ export const fetchToolsList = async (accessToken: string): Promise => return data.tools ?? []; }; +export interface ToolSpendEntry { + tool_name: string; + spend: number; + call_count: number; + total_tokens: number; +} + +export interface ToolSpendDailyEntry { + date: string; + tool_name: string; + spend: number; + call_count: number; +} + +export interface ToolSpendResponse { + by_tool: ToolSpendEntry[]; + daily: ToolSpendDailyEntry[]; + total_spend: number; + start_date: string | null; + end_date: string | null; +} + +export const getToolSpend = async ( + accessToken: string, + startDate?: string, + endDate?: string, +): Promise => + apiClient.get(`/v1/tool/spend`, { + accessToken, + query: { start_date: startDate, end_date: endDate }, + }); + export interface ToolPolicyOverrideRow { override_id: string; tool_name: string; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index e0223aa4af6..825d06d6a38 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -17900,6 +17900,32 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/tool/spend": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Tool Spend + * @description Spend attributed to each tool over a date range, for the Cost Optimization dashboard. + * + * Joins ``LiteLLM_SpendLogToolIndex`` (which tool names ran on which request) to + * ``LiteLLM_SpendLogs`` (what the request cost). A request that used multiple tools + * counts its full spend toward each of those tools, so per-tool numbers are + * attributions. ``total_spend`` is the deduplicated spend of every request that + * called at least one tool in the window, so it never double counts. + */ + get: operations["get_tool_spend_v1_tool_spend_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/tool/{tool_name}": { parameters: { query?: never; @@ -31978,6 +32004,67 @@ export interface components { /** Updated */ updated: boolean; }; + /** + * ToolSpendDailyEntry + * @description Spend attributed to one tool on one UTC day. + */ + ToolSpendDailyEntry: { + /** + * Call Count + * @default 0 + */ + call_count: number; + /** Date */ + date: string; + /** + * Spend + * @default 0 + */ + spend: number; + /** Tool Name */ + tool_name: string; + }; + /** + * ToolSpendEntry + * @description Total spend attributed to one tool over the requested window. + */ + ToolSpendEntry: { + /** + * Call Count + * @default 0 + */ + call_count: number; + /** + * Spend + * @description Attributed spend: a request that used several tools counts its full spend toward each of them + * @default 0 + */ + spend: number; + /** Tool Name */ + tool_name: string; + /** + * Total Tokens + * @default 0 + */ + total_tokens: number; + }; + /** ToolSpendResponse */ + ToolSpendResponse: { + /** By Tool */ + by_tool?: components["schemas"]["ToolSpendEntry"][]; + /** Daily */ + daily?: components["schemas"]["ToolSpendDailyEntry"][]; + /** End Date */ + end_date?: string | null; + /** Start Date */ + start_date?: string | null; + /** + * Total Spend + * @description Deduplicated spend of every request that called at least one tool in the window; less than the sum of per-tool attributed spend whenever multi-tool requests exist + * @default 0 + */ + total_spend: number; + }; /** * ToolUsageLogEntry * @description One spend log row for a tool call (for UI "recent logs" table). @@ -56176,6 +56263,40 @@ export interface operations { }; }; }; + get_tool_spend_v1_tool_spend_get: { + parameters: { + query?: { + /** @description YYYY-MM-DD (defaults to 30 days ago) */ + start_date?: string | null; + /** @description YYYY-MM-DD (defaults to today) */ + 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"]["ToolSpendResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_tool_v1_tool__tool_name__get: { parameters: { query?: never;