mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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:
parent
0a4333580f
commit
bd73ca8c64
18 changed files with 1456 additions and 47 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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'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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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} />,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })));
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 }));
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
121
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
121
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue