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 (
@@ -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."}
+
+ ) : (
+
+
+
+
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;