feat(cost-optimization): add spend-by-tool and cache leakage views

Adds GET /v1/tool/spend returning per-tool and daily tool spend with a
deduplicated request total, and a cache leakage breakdown on the Prompt
Caching tab of the Cost Optimization page. Tool-spend rows are validated at
the boundary with pydantic, the endpoint is scoped to proxy admins, date
params are cast to timestamptz for real-Postgres query_raw, and the leakage
math treats litellm-normalized prompt_tokens as cache-inclusive
(uncached = max(0, prompt - cache_read - cache_creation)).
This commit is contained in:
Tin Chi Lo 2026-07-23 10:37:21 -07:00
parent 0a4333580f
commit bd73ca8c64
18 changed files with 1456 additions and 47 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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: () => <div data-testid="date-picker" />,
}));
import CacheLeakageCard from "./CacheLeakageCard";
const baseMetrics = (overrides: Partial<SpendMetrics>): 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<SpendMetrics>): KeyMetricWithMetadata => ({
metrics: baseMetrics(metrics),
metadata: { key_alias: alias, team_id: null },
});
const dayWithKeys = (date: string, apiKeys: Record<string, KeyMetricWithMetadata>): DailyData => ({
date,
metrics: baseMetrics({}),
breakdown: {
models: {},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: apiKeys,
entities: {},
},
});
const renderWith = (results: DailyData[]) =>
render(
<CacheLeakageCard
activity={{
dateValue: {},
onDateChange: vi.fn(),
results,
loading: false,
isFetchingMore: false,
}}
/>,
);
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();
});
});

View file

@ -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 }) => (
<span className="inline-flex items-center gap-1">
{label}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex" aria-label={info}>
<Info className="h-3 w-3 text-gray-400" />
</span>
</TooltipTrigger>
<TooltipContent>{info}</TooltipContent>
</Tooltip>
</span>
);
const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
const { dateValue, onDateChange, results, loading, isFetchingMore } = activity;
const leakage = useMemo(() => computeCacheLeakage(results), [results]);
return (
<TooltipProvider delay={300}>
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<CardTitle>Cache leakage by virtual key</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
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&apos;s realized cache-read discount.
</p>
</div>
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
</div>
</CardHeader>
<CardContent>
{leakage.rows.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{loading || isFetchingMore ? "Loading..." : "No key usage in this range."}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Key</TableHead>
<TableHead className="text-right">
<HeadWithInfo
label="Uncached prompt tokens"
info="Input tokens in the selected range that were neither read from nor written to the prompt cache"
/>
</TableHead>
<TableHead className="text-right">
<HeadWithInfo
label="Cache hit ratio"
info="Share of this key's total input tokens that were served from the prompt cache"
/>
</TableHead>
<TableHead className="text-right">
<HeadWithInfo
label="Realized caching savings"
info="Dollars this key actually saved because cached input was billed at the discounted cache-read rate"
/>
</TableHead>
<TableHead className="text-right">
<HeadWithInfo
label="Est. savings left"
info="Approximate dollars this key could still save if its uncached input had hit the cache at the portfolio's realized discount"
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{leakage.rows.map((row) => (
<TableRow key={row.apiKey}>
<TableCell className="font-medium">
{row.keyAlias || `${row.apiKey.slice(0, 8)}...`}
{row.teamId && <span className="ml-1 text-xs text-muted-foreground">({row.teamId})</span>}
</TableCell>
<TableCell className="text-right">{formatNumberWithCommas(row.uncachedPromptTokens)}</TableCell>
<TableCell className="text-right">{pct(row.cacheHitRatio)}</TableCell>
<TableCell className="text-right">{usd(row.realizedCachingSavings)}</TableCell>
<TableCell className="text-right">
{row.estSavingsLeft == null ? "—" : usd(row.estSavingsLeft)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TooltipProvider>
);
};
export default CacheLeakageCard;

View file

@ -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: () => <div data-testid="date-picker" />,
}));
vi.mock("@/components/shared/charts", () => ({
AreaChart: () => <div />,
DonutChart: () => <div />,
BarChart: () => <div />,
DEFAULT_COLOR_CYCLE: ["emerald"],
}));
vi.mock("@/app/(dashboard)/router-settings/_components/general_settings", () => ({
PromptCachingPanel: () => <div data-testid="caching-settings" />,
}));
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () => <div /> }));
vi.mock("./AutorouterTab", () => ({ __esModule: true, default: () => <div /> }));
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(
<CostOptimizationView accessToken="test-token" userId="u1" userRole="proxy_admin" />,
);
await waitFor(() => expect(mockUserDailyActivityCall).toHaveBeenCalledTimes(1));
fireEvent.click(getByRole("tab", { name: "Prompt Caching" }));
await waitFor(() => expect(getByTestId("caching-settings")).toBeInTheDocument());
expect(mockUserDailyActivityCall).toHaveBeenCalledTimes(1);
});
});

View file

@ -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<CostOptimizationViewProps> = ({ accessToken, userId, userRole }) => {
const activity = useDailyActivityRange(accessToken, userId, userRole);
const items = [
{
key: "usage",
label: "Usage",
children: <UsageTab accessToken={accessToken} userId={userId} userRole={userRole} />,
children: <UsageTab accessToken={accessToken} activity={activity} />,
},
{
key: "compression",
@ -35,7 +38,7 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
{
key: "caching",
label: "Prompt Caching",
children: <PromptCachingTab accessToken={accessToken} />,
children: <PromptCachingTab accessToken={accessToken} activity={activity} />,
},
];

View file

@ -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: () => <div data-testid="caching-settings" />,
}));
const mockCacheLeakageCard = vi.fn();
vi.mock("./CacheLeakageCard", () => ({
__esModule: true,
default: (props: unknown) => {
mockCacheLeakageCard(props);
return <div data-testid="cache-leakage-card" />;
},
}));
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(<PromptCachingTab accessToken="test-token" activity={activity} />);
expect(getByTestId("caching-settings")).toBeInTheDocument();
expect(getByTestId("cache-leakage-card")).toBeInTheDocument();
await waitFor(() => expect(mockCacheLeakageCard).toHaveBeenCalledWith(expect.objectContaining({ activity })));
});
});

View file

@ -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<PromptCachingTabProps> = ({ accessToken }) => {
const PromptCachingTab: React.FC<PromptCachingTabProps> = ({ accessToken, activity }) => {
const [settings, setSettings] = useState<generalSettingsItem[]>([]);
const loadSettings = useCallback(() => {
@ -43,8 +46,9 @@ const PromptCachingTab: React.FC<PromptCachingTabProps> = ({ accessToken }) => {
}
return (
<div className="w-full">
<div className="w-full space-y-6">
<PromptCachingPanel accessToken={accessToken} settings={settings} onChange={handleChange} />
<CacheLeakageCard activity={activity} />
</div>
);
};

View file

@ -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 }) => (
<div data-testid="donut-chart" data-label={label} data-slices={JSON.stringify(data)} />
),
BarChart: ({ data, categories }: { data: unknown; categories: string[] }) => (
<div data-testid="bar-chart" data-categories={categories.join(",")} data-series={JSON.stringify(data)} />
),
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>): SpendMetrics => ({
spend: 0,
prompt_tokens: 0,
@ -55,9 +58,20 @@ const day = (date: string, metrics: Partial<SpendMetrics>): DailyData => ({
},
});
const renderWith = (results: DailyData[]) => {
mockUsePaginatedDailyActivity.mockReturnValue({ data: { results }, loading: false, isFetchingMore: false });
return render(<UsageTab accessToken="test-token" userId="u1" userRole="proxy_admin" />);
const renderWith = (results: DailyData[], toolSpend = emptyToolSpend) => {
mockGetToolSpend.mockResolvedValue(toolSpend);
return render(
<UsageTab
accessToken="test-token"
activity={{
dateValue: { from: new Date("2026-07-01"), to: new Date("2026-07-14") },
onDateChange: vi.fn(),
results,
loading: false,
isFetchingMore: false,
}}
/>,
);
};
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 });
});
});

View file

@ -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
</Card>
);
const UsageTab: React.FC<UsageTabProps> = ({ accessToken, userId, userRole }) => {
const initialFrom = useMemo(() => new Date(new Date().getTime() - THIRTY_DAYS_MS), []);
const initialTo = useMemo(() => new Date(), []);
const [dateValue, setDateValue] = useState<DateRange>({ from: initialFrom, to: initialTo });
const UsageTab: React.FC<UsageTabProps> = ({ 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<UsageTabProps> = ({ 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<Record<string, string | number>[]>(
() => 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 (
<div className="w-full space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<MethodologyNote />
<AdvancedDatePicker value={dateValue} onValueChange={(v) => setDateValue(v)} />
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
@ -177,6 +203,50 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, userId, userRole }) =>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Spend by tool</CardTitle>
<p className="text-sm text-muted-foreground">
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.
</p>
</CardHeader>
<CardContent>
{topTools.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{toolSpendLoading ? "Loading..." : "No tool usage in this range."}
</p>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<p className="mb-2 text-sm font-medium text-muted-foreground">Total by tool</p>
<BarChart
data={topToolsChart}
index="tool_name"
categories={["spend"]}
colors={["emerald"]}
layout="vertical"
yAxisWidth={140}
showLegend={false}
valueFormatter={usd}
/>
</div>
<div>
<p className="mb-2 text-sm font-medium text-muted-foreground">Daily spend by tool</p>
<BarChart
data={dailyToolSeries}
index="date"
categories={topToolNames}
colors={toolColors}
stack
valueFormatter={usd}
/>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View file

@ -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>): 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<string, { alias: string | null; metrics: Partial<SpendMetrics> }>,
): 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"]);
});
});

View file

@ -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<string, KeyAccumulator>();
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<string, DailyToolSpendPoint>();
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<DailyToolSpendPoint>((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);

View file

@ -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 }));
});
});

View file

@ -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<DateRange>({ 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,
};
};

View file

@ -7582,6 +7582,38 @@ export const fetchToolsList = async (accessToken: string): Promise<ToolRow[]> =>
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<ToolSpendResponse> =>
apiClient.get<ToolSpendResponse>(`/v1/tool/spend`, {
accessToken,
query: { start_date: startDate, end_date: endDate },
});
export interface ToolPolicyOverrideRow {
override_id: string;
tool_name: string;

View file

@ -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;