mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge pull request #39853 from BerriAI/litellm_guardrail_usage_cost_ui
feat(ui): show guardrail usage units and cost on the Guardrails Monitor
This commit is contained in:
commit
1745d74293
22 changed files with 1354 additions and 250 deletions
|
|
@ -13218,6 +13218,26 @@
|
|||
"title": "Untracked Usage Units",
|
||||
"type": "object"
|
||||
},
|
||||
"untracked_usage_units_by_key": {
|
||||
"additionalProperties": {
|
||||
"additionalProperties": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"title": "Untracked Usage Units By Key",
|
||||
"type": "object"
|
||||
},
|
||||
"untracked_usage_units_by_team": {
|
||||
"additionalProperties": {
|
||||
"additionalProperties": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"title": "Untracked Usage Units By Team",
|
||||
"type": "object"
|
||||
},
|
||||
"usage_units": {
|
||||
"additionalProperties": {
|
||||
"type": "integer"
|
||||
|
|
@ -13274,7 +13294,9 @@
|
|||
"cost_by_unit",
|
||||
"cost_by_team",
|
||||
"cost_by_key",
|
||||
"untracked_usage_units"
|
||||
"untracked_usage_units",
|
||||
"untracked_usage_units_by_team",
|
||||
"untracked_usage_units_by_key"
|
||||
],
|
||||
"title": "UsageDetailResponse",
|
||||
"type": "object"
|
||||
|
|
|
|||
|
|
@ -158,6 +158,14 @@ def _counter_name(row: "prisma_models.LiteLLM_DailyGuardrailUsageUnits") -> str:
|
|||
return row.usage_unit
|
||||
|
||||
|
||||
def _team_of(row: "prisma_models.LiteLLM_DailyGuardrailUsageUnits") -> str:
|
||||
return row.team_id
|
||||
|
||||
|
||||
def _key_of(row: "prisma_models.LiteLLM_DailyGuardrailUsageUnits") -> str:
|
||||
return row.api_key
|
||||
|
||||
|
||||
def _row_untracked_units(row: "prisma_models.LiteLLM_DailyGuardrailUsageUnits") -> int:
|
||||
"""A row written before the cost column carries NULL cost and is untracked in full."""
|
||||
return int(row.units) if row.cost is None else int(row.untracked_units)
|
||||
|
|
@ -310,6 +318,8 @@ class UsageDetailResponse(BaseModel):
|
|||
cost_by_team: Mapping[str, float | None]
|
||||
cost_by_key: Mapping[str, float | None]
|
||||
untracked_usage_units: Mapping[str, int]
|
||||
untracked_usage_units_by_team: Mapping[str, Mapping[str, int]]
|
||||
untracked_usage_units_by_key: Mapping[str, Mapping[str, int]]
|
||||
|
||||
|
||||
class UsageLogEntry(BaseModel):
|
||||
|
|
@ -712,13 +722,15 @@ async def guardrails_usage_detail(
|
|||
time_series=time_series,
|
||||
usage_units=_sum_counter_units(units_rows),
|
||||
usage_units_daily=units_daily,
|
||||
usage_units_by_team=_by(units_rows, lambda r: r.team_id, _sum_counter_units),
|
||||
usage_units_by_key=_by(units_rows, lambda r: r.api_key, _sum_counter_units),
|
||||
usage_units_by_team=_by(units_rows, _team_of, _sum_counter_units),
|
||||
usage_units_by_key=_by(units_rows, _key_of, _sum_counter_units),
|
||||
cost=_sum_tracked_cost(units_rows),
|
||||
cost_by_unit=_by(units_rows, _counter_name, _sum_tracked_cost),
|
||||
cost_by_team=_by(units_rows, lambda r: r.team_id, _sum_tracked_cost),
|
||||
cost_by_key=_by(units_rows, lambda r: r.api_key, _sum_tracked_cost),
|
||||
cost_by_team=_by(units_rows, _team_of, _sum_tracked_cost),
|
||||
cost_by_key=_by(units_rows, _key_of, _sum_tracked_cost),
|
||||
untracked_usage_units=_sum_untracked_units(units_rows),
|
||||
untracked_usage_units_by_team=_by(units_rows, _team_of, _sum_untracked_units),
|
||||
untracked_usage_units_by_key=_by(units_rows, _key_of, _sum_untracked_units),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -430,6 +430,13 @@ async def test_detail_breaks_cost_down_by_unit_day_team_and_key():
|
|||
assert resp.cost_by_team.keys() == resp.usage_units_by_team.keys()
|
||||
assert resp.cost_by_key.keys() == resp.usage_units_by_key.keys()
|
||||
assert resp.untracked_usage_units == {"contentPolicyUnits": 50, "topicPolicyUnits": 10}
|
||||
assert resp.untracked_usage_units_by_team == {"team-a": {"topicPolicyUnits": 10}, "": {"contentPolicyUnits": 50}}
|
||||
assert resp.untracked_usage_units_by_key == {
|
||||
"hash-1": {"topicPolicyUnits": 10},
|
||||
"hash-2": {"contentPolicyUnits": 50},
|
||||
}
|
||||
assert resp.untracked_usage_units_by_team.keys() == resp.usage_units_by_team.keys()
|
||||
assert resp.untracked_usage_units_by_key.keys() == resp.usage_units_by_key.keys()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -451,6 +458,7 @@ async def test_detail_degrades_units_to_empty_when_units_table_is_missing():
|
|||
)
|
||||
assert (resp.cost, resp.cost_by_unit, resp.cost_by_team, resp.cost_by_key) == (None, {}, {}, {})
|
||||
assert resp.untracked_usage_units == {}
|
||||
assert (resp.untracked_usage_units_by_team, resp.untracked_usage_units_by_key) == ({}, {})
|
||||
|
||||
|
||||
# ---- logs -------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { GuardrailUsageDetail } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage";
|
||||
import { GuardrailDetail } from "./GuardrailDetail";
|
||||
|
||||
const mockGetGuardrailsUsageDetail = vi.fn();
|
||||
const mockUseGuardrailsUsageDetail = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage", () => ({
|
||||
useGuardrailsUsageDetail: (...args: unknown[]) => mockUseGuardrailsUsageDetail(...args),
|
||||
}));
|
||||
|
||||
const mockGetGuardrailsUsageLogs = vi.fn();
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getGuardrailsUsageDetail: (...args: unknown[]) => mockGetGuardrailsUsageDetail(...args),
|
||||
getGuardrailsUsageLogs: (...args: unknown[]) => mockGetGuardrailsUsageLogs(...args),
|
||||
}));
|
||||
|
||||
|
|
@ -19,7 +23,8 @@ vi.mock("./EvaluationSettingsModal", () => ({
|
|||
EvaluationSettingsModal: ({ open }: { open: boolean }) => (open ? <div data-testid="evaluation-modal" /> : null),
|
||||
}));
|
||||
|
||||
const detail = {
|
||||
const detail: GuardrailUsageDetail = {
|
||||
guardrail_id: "pii-detector",
|
||||
guardrail_name: "pii-detector",
|
||||
description: "Blocks personally identifiable information",
|
||||
status: "warning",
|
||||
|
|
@ -29,12 +34,27 @@ const detail = {
|
|||
failRate: 20,
|
||||
avgScore: 0.4,
|
||||
avgLatency: 180,
|
||||
trend: "stable",
|
||||
time_series: [],
|
||||
usage_units: { sensitiveInformationPolicyUnits: 4 },
|
||||
usage_units_daily: [],
|
||||
usage_units_by_team: { "": { sensitiveInformationPolicyUnits: 4 } },
|
||||
usage_units_by_key: { "hash-1": { sensitiveInformationPolicyUnits: 4 } },
|
||||
cost: 0.0004,
|
||||
cost_by_unit: { sensitiveInformationPolicyUnits: 0.0004 },
|
||||
cost_by_team: { "": 0.0004 },
|
||||
cost_by_key: { "hash-1": 0.0004 },
|
||||
untracked_usage_units: {},
|
||||
untracked_usage_units_by_team: {},
|
||||
untracked_usage_units_by_key: {},
|
||||
};
|
||||
|
||||
const loaded = (data: GuardrailUsageDetail | undefined) => ({ data, isLoading: false, error: null });
|
||||
|
||||
const defaultProps = {
|
||||
guardrailId: "pii-detector",
|
||||
onBack: vi.fn(),
|
||||
accessToken: "test-token",
|
||||
accessToken: "test-token" as string | null,
|
||||
startDate: "2026-07-01",
|
||||
endDate: "2026-07-24",
|
||||
};
|
||||
|
|
@ -49,19 +69,19 @@ function renderDetail(props: Partial<typeof defaultProps> = {}) {
|
|||
describe("GuardrailDetail", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetGuardrailsUsageDetail.mockResolvedValue(detail);
|
||||
mockUseGuardrailsUsageDetail.mockReturnValue(loaded(detail));
|
||||
mockGetGuardrailsUsageLogs.mockResolvedValue({ logs: [], total: 0 });
|
||||
});
|
||||
|
||||
it("should show a busy indicator while the detail request is in flight", () => {
|
||||
mockGetGuardrailsUsageDetail.mockReturnValue(new Promise(() => {}));
|
||||
mockUseGuardrailsUsageDetail.mockReturnValue({ data: undefined, isLoading: true, error: null });
|
||||
renderDetail();
|
||||
expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument();
|
||||
expect(screen.queryByText("pii-detector")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show an error message and a way back when the detail request fails", async () => {
|
||||
mockGetGuardrailsUsageDetail.mockRejectedValue(new Error("boom"));
|
||||
mockUseGuardrailsUsageDetail.mockReturnValue({ data: undefined, isLoading: false, error: new Error("boom") });
|
||||
renderDetail();
|
||||
expect(await screen.findByText("Failed to load guardrail details.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /back to overview/i })).toBeInTheDocument();
|
||||
|
|
@ -69,14 +89,12 @@ describe("GuardrailDetail", () => {
|
|||
|
||||
it("should request the detail and the logs for the guardrail and date range", async () => {
|
||||
renderDetail();
|
||||
await waitFor(() =>
|
||||
expect(mockGetGuardrailsUsageDetail).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
"pii-detector",
|
||||
"2026-07-01",
|
||||
"2026-07-24",
|
||||
),
|
||||
);
|
||||
expect(mockUseGuardrailsUsageDetail).toHaveBeenCalledWith("pii-detector", {
|
||||
accessToken: "test-token",
|
||||
startDate: "2026-07-01",
|
||||
endDate: "2026-07-24",
|
||||
});
|
||||
await waitFor(() => expect(mockGetGuardrailsUsageLogs).toHaveBeenCalled());
|
||||
expect(mockGetGuardrailsUsageLogs).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.objectContaining({ guardrailId: "pii-detector", startDate: "2026-07-01", endDate: "2026-07-24" }),
|
||||
|
|
@ -100,11 +118,18 @@ describe("GuardrailDetail", () => {
|
|||
});
|
||||
|
||||
it("should show a placeholder when no latency has been recorded", async () => {
|
||||
mockGetGuardrailsUsageDetail.mockResolvedValue({ ...detail, avgLatency: null });
|
||||
mockUseGuardrailsUsageDetail.mockReturnValue(loaded({ ...detail, avgLatency: null }));
|
||||
renderDetail();
|
||||
expect(await screen.findByText("No data")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the usage and cost breakdown for the guardrail on the overview tab", async () => {
|
||||
renderDetail();
|
||||
const section = await screen.findByRole("region", { name: "Usage and cost" });
|
||||
expect(section).toHaveTextContent("$0.0004");
|
||||
expect(section).toHaveTextContent("Sensitive Information Policy");
|
||||
});
|
||||
|
||||
it("should call onBack when 'Back to Overview' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
|
|
@ -138,8 +163,12 @@ describe("GuardrailDetail", () => {
|
|||
});
|
||||
|
||||
it("should not request anything without an access token", () => {
|
||||
mockUseGuardrailsUsageDetail.mockReturnValue(loaded(undefined));
|
||||
renderDetail({ accessToken: null });
|
||||
expect(mockGetGuardrailsUsageDetail).not.toHaveBeenCalled();
|
||||
expect(mockUseGuardrailsUsageDetail).toHaveBeenCalledWith(
|
||||
"pii-detector",
|
||||
expect.objectContaining({ accessToken: null }),
|
||||
);
|
||||
expect(mockGetGuardrailsUsageLogs).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Settings, Shield, TriangleAlert } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { getGuardrailsUsageDetail, getGuardrailsUsageLogs } from "@/components/networking";
|
||||
import { getGuardrailsUsageLogs } from "@/components/networking";
|
||||
import { useGuardrailsUsageDetail } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage";
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { EvaluationSettingsModal } from "./EvaluationSettingsModal";
|
||||
import { GuardrailUsageBreakdown } from "./GuardrailUsageBreakdown";
|
||||
import { LogViewer } from "@/components/GuardrailsMonitor/LogViewer";
|
||||
import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard";
|
||||
import type { LogEntry } from "@/components/GuardrailsMonitor/mockData";
|
||||
|
|
@ -36,11 +38,7 @@ export function GuardrailDetail({ guardrailId, onBack, accessToken = null, start
|
|||
data: detailData,
|
||||
isLoading: detailLoading,
|
||||
error: detailError,
|
||||
} = useQuery({
|
||||
queryKey: ["guardrails-usage-detail", guardrailId, startDate, endDate],
|
||||
queryFn: () => getGuardrailsUsageDetail(accessToken!, guardrailId, startDate, endDate),
|
||||
enabled: !!accessToken && !!guardrailId,
|
||||
});
|
||||
} = useGuardrailsUsageDetail(guardrailId, { accessToken, startDate, endDate });
|
||||
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||
queryKey: ["guardrails-usage-logs", guardrailId, logsPage, logsPageSize],
|
||||
queryFn: () =>
|
||||
|
|
@ -194,6 +192,8 @@ export function GuardrailDetail({ guardrailId, onBack, accessToken = null, start
|
|||
/>
|
||||
</div>
|
||||
|
||||
{detailData && <GuardrailUsageBreakdown detail={detailData} />}
|
||||
|
||||
{logViewer("all")}
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GuardrailUsageDetail } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage";
|
||||
import { GuardrailUsageBreakdown } from "./GuardrailUsageBreakdown";
|
||||
|
||||
const detail: GuardrailUsageDetail = {
|
||||
guardrail_id: "bedrock-pii-mask",
|
||||
guardrail_name: "bedrock-pii-mask",
|
||||
type: "pii",
|
||||
provider: "Bedrock",
|
||||
requestsEvaluated: 5,
|
||||
failRate: 0,
|
||||
avgScore: null,
|
||||
avgLatency: 120,
|
||||
status: "healthy",
|
||||
trend: "stable",
|
||||
description: null,
|
||||
time_series: [],
|
||||
usage_units: { contentPolicyUnits: 1000, sensitiveInformationPolicyUnits: 300, someFutureCounter: 7 },
|
||||
usage_units_daily: [],
|
||||
usage_units_by_team: {
|
||||
"team-a": { contentPolicyUnits: 900, sensitiveInformationPolicyUnits: 300 },
|
||||
"": { contentPolicyUnits: 100, someFutureCounter: 7 },
|
||||
},
|
||||
usage_units_by_key: {
|
||||
"hash-1": { contentPolicyUnits: 1000, sensitiveInformationPolicyUnits: 300 },
|
||||
"hash-2": { someFutureCounter: 7 },
|
||||
},
|
||||
cost: 0.18,
|
||||
cost_by_unit: { contentPolicyUnits: 0.15, sensitiveInformationPolicyUnits: 0.03, someFutureCounter: null },
|
||||
cost_by_team: { "team-a": 0.165, "": 0.015 },
|
||||
cost_by_key: { "hash-1": 0.18, "hash-2": null },
|
||||
untracked_usage_units: { someFutureCounter: 7 },
|
||||
untracked_usage_units_by_team: { "team-a": {}, "": { someFutureCounter: 7 } },
|
||||
untracked_usage_units_by_key: { "hash-1": {}, "hash-2": { someFutureCounter: 7 } },
|
||||
};
|
||||
|
||||
const rowNamed = (name: string) => screen.getByRole("row", { name: new RegExp(name) });
|
||||
|
||||
describe("GuardrailUsageBreakdown", () => {
|
||||
it("totals the units and the cost, and says how many units the cost leaves out", () => {
|
||||
render(<GuardrailUsageBreakdown detail={detail} />);
|
||||
|
||||
const cost = screen.getByRole("group", { name: "Cost" });
|
||||
expect(cost).toHaveTextContent("$0.1800");
|
||||
expect(cost).toHaveTextContent("7 units unpriced");
|
||||
|
||||
const units = screen.getByRole("group", { name: "Usage Units" });
|
||||
expect(units).toHaveTextContent("1,307");
|
||||
expect(units).toHaveTextContent("3 counters");
|
||||
});
|
||||
|
||||
it("lists each counter with its units, cost and unpriced share", () => {
|
||||
render(<GuardrailUsageBreakdown detail={detail} />);
|
||||
|
||||
const content = rowNamed("Content Policy");
|
||||
expect(within(content).getByText("1,000")).toBeInTheDocument();
|
||||
expect(within(content).getByText("$0.1500")).toBeInTheDocument();
|
||||
expect(within(content).getByText("—")).toBeInTheDocument();
|
||||
|
||||
const future = rowNamed("Some Future Counter");
|
||||
expect(within(future).getByText("7", { selector: ".text-warning" })).toBeInTheDocument();
|
||||
expect(within(future).getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("breaks units and cost down by team and by key, flagging the unpriced share of each row", () => {
|
||||
render(<GuardrailUsageBreakdown detail={detail} />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "By team" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "By key" })).toBeInTheDocument();
|
||||
const teamA = rowNamed("team-a");
|
||||
expect(within(teamA).getByText("1,200")).toBeInTheDocument();
|
||||
expect(within(teamA).getByText("$0.1650")).toBeInTheDocument();
|
||||
expect(within(teamA).getByText("—")).toBeInTheDocument();
|
||||
expect(within(teamA).queryByText("7")).not.toBeInTheDocument();
|
||||
|
||||
const noTeam = rowNamed("No team");
|
||||
expect(within(noTeam).getByText("107")).toBeInTheDocument();
|
||||
expect(within(noTeam).getByText("$0.0150")).toBeInTheDocument();
|
||||
expect(within(noTeam).getByText("7", { selector: ".text-warning" })).toBeInTheDocument();
|
||||
|
||||
const unpricedKey = rowNamed("hash-2");
|
||||
expect(within(unpricedKey).getByText("—")).toBeInTheDocument();
|
||||
expect(within(unpricedKey).getByText("7", { selector: ".text-warning" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cellsOf = (dialog: HTMLElement): string[][] =>
|
||||
within(dialog)
|
||||
.getAllByRole("row")
|
||||
.map((row) =>
|
||||
within(row)
|
||||
.getAllByRole("cell")
|
||||
.map((cell) => cell.textContent ?? ""),
|
||||
);
|
||||
|
||||
it("lays the cost math out per counter as units × price = cost", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GuardrailUsageBreakdown detail={detail} />);
|
||||
|
||||
await user.click(
|
||||
within(screen.getByRole("group", { name: "Cost" })).getByRole("button", { name: /How is this calculated/ }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "How this cost is calculated" });
|
||||
expect(cellsOf(dialog)).toEqual([
|
||||
["Content Policy", "1,000", "× $0.00015", "= $0.1500"],
|
||||
["Sensitive Information Policy", "300", "× $0.0001", "= $0.0300"],
|
||||
["Some Future Counter", "7", "× —", "= —"],
|
||||
["no known price, left out"],
|
||||
["Total", "$0.1800"],
|
||||
]);
|
||||
expect(within(dialog).getByText(/7 units with no known price are left out of the cost/)).toBeInTheDocument();
|
||||
const issueLink = within(dialog).getByRole("link", { name: "Request pricing on GitHub" });
|
||||
expect(issueLink).toHaveAttribute("target", "_blank");
|
||||
const issueUrl = new URL(issueLink.getAttribute("href") ?? "");
|
||||
expect(issueUrl.searchParams.get("title")).toBe("[Feature]: add Bedrock guardrail pricing to the cost map");
|
||||
expect(issueUrl.searchParams.get("the-feature")).toContain("someFutureCounter");
|
||||
});
|
||||
|
||||
it("does not ask for pricing when every unit was priced", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<GuardrailUsageBreakdown
|
||||
detail={{
|
||||
...detail,
|
||||
cost: 0.15,
|
||||
usage_units: { contentPolicyUnits: 1000 },
|
||||
cost_by_unit: { contentPolicyUnits: 0.15 },
|
||||
untracked_usage_units: {},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
within(screen.getByRole("group", { name: "Cost" })).getByRole("button", { name: /How is this calculated/ }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "How this cost is calculated" });
|
||||
expect(cellsOf(dialog)).toEqual([
|
||||
["Content Policy", "1,000", "× $0.00015", "= $0.1500"],
|
||||
["Total", "$0.1500"],
|
||||
]);
|
||||
expect(within(dialog).queryByRole("link", { name: "Request pricing on GitHub" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lays the units sum out per counter", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GuardrailUsageBreakdown detail={detail} />);
|
||||
|
||||
await user.click(
|
||||
within(screen.getByRole("group", { name: "Usage Units" })).getByRole("button", {
|
||||
name: /How is this calculated/,
|
||||
}),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "How usage units add up" });
|
||||
expect(cellsOf(dialog)).toEqual([
|
||||
["Content Policy", "1,000"],
|
||||
["Sensitive Information Policy", "300"],
|
||||
["Some Future Counter", "7"],
|
||||
["Total", "1,307"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders teams and keys by units, largest first", () => {
|
||||
render(<GuardrailUsageBreakdown detail={detail} />);
|
||||
|
||||
const rows = screen.getAllByRole("row").map((row) => row.textContent ?? "");
|
||||
expect(rows.findIndex((text) => text.includes("team-a"))).toBeLessThan(
|
||||
rows.findIndex((text) => text.includes("No team")),
|
||||
);
|
||||
expect(rows.findIndex((text) => text.includes("hash-1"))).toBeLessThan(
|
||||
rows.findIndex((text) => text.includes("hash-2")),
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when the window has no billable units instead of rendering empty tables", () => {
|
||||
render(
|
||||
<GuardrailUsageBreakdown
|
||||
detail={{
|
||||
...detail,
|
||||
usage_units: {},
|
||||
usage_units_by_team: {},
|
||||
usage_units_by_key: {},
|
||||
cost: null,
|
||||
cost_by_unit: {},
|
||||
cost_by_team: {},
|
||||
cost_by_key: {},
|
||||
untracked_usage_units: {},
|
||||
untracked_usage_units_by_team: {},
|
||||
untracked_usage_units_by_key: {},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No billable usage units were recorded in this period.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { CircleDollarSign } from "lucide-react";
|
||||
import React from "react";
|
||||
import type { GuardrailUsageDetail } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage";
|
||||
import { CalcPopover, MathTable } from "@/components/GuardrailsMonitor/CalcPopover";
|
||||
import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard";
|
||||
import { UnpricedNote } from "@/components/GuardrailsMonitor/UnpricedNote";
|
||||
import {
|
||||
counterLabel,
|
||||
counterMathRow,
|
||||
formatCost,
|
||||
totalUnits,
|
||||
unitsMathRows,
|
||||
unpricedSummary,
|
||||
} from "@/components/GuardrailsMonitor/usageUnits";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { IdCell } from "@/components/shared/table_cells/id_cell";
|
||||
import { MoneyCell } from "@/components/shared/table_cells/money_cell";
|
||||
|
||||
interface CounterRow {
|
||||
counter: string;
|
||||
units: number;
|
||||
cost: number | null;
|
||||
unpriced: number;
|
||||
}
|
||||
|
||||
interface GroupRow {
|
||||
id: string;
|
||||
units: number;
|
||||
cost: number | null;
|
||||
unpriced: number;
|
||||
}
|
||||
|
||||
const counterRows = (detail: GuardrailUsageDetail): CounterRow[] =>
|
||||
Object.entries(detail.usage_units).map(([counter, units]) => ({
|
||||
counter,
|
||||
units,
|
||||
cost: detail.cost_by_unit[counter] ?? null,
|
||||
unpriced: detail.untracked_usage_units[counter] ?? 0,
|
||||
}));
|
||||
|
||||
const groupRows = (
|
||||
unitsByGroup: GuardrailUsageDetail["usage_units_by_team"],
|
||||
costByGroup: GuardrailUsageDetail["cost_by_team"],
|
||||
untrackedByGroup: GuardrailUsageDetail["untracked_usage_units_by_team"],
|
||||
): GroupRow[] =>
|
||||
Object.entries(unitsByGroup)
|
||||
.map(([id, units]) => ({
|
||||
id,
|
||||
units: totalUnits(units),
|
||||
cost: costByGroup[id] ?? null,
|
||||
unpriced: totalUnits(untrackedByGroup[id] ?? {}),
|
||||
}))
|
||||
.sort((a, b) => b.units - a.units);
|
||||
|
||||
const UnpricedUnitsCell = ({ unpriced }: { unpriced: number }) =>
|
||||
unpriced > 0 ? (
|
||||
<span className="text-warning">{unpriced.toLocaleString()}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
);
|
||||
|
||||
const unpricedColumn = <TRow extends { unpriced: number }>(): ColumnDef<TRow> => ({
|
||||
header: "Unpriced Units",
|
||||
accessorKey: "unpriced",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <UnpricedUnitsCell unpriced={row.original.unpriced} />,
|
||||
});
|
||||
|
||||
const counterColumns: ColumnDef<CounterRow>[] = [
|
||||
{ header: "Counter", accessorKey: "counter", cell: ({ row }) => counterLabel(row.original.counter) },
|
||||
{
|
||||
header: "Units",
|
||||
accessorKey: "units",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => row.original.units.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Cost",
|
||||
accessorKey: "cost",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <MoneyCell value={row.original.cost} emptyText="—" showZero />,
|
||||
},
|
||||
unpricedColumn<CounterRow>(),
|
||||
];
|
||||
|
||||
const groupColumns = (label: string, emptyLabel: string): ColumnDef<GroupRow>[] => [
|
||||
{
|
||||
header: label,
|
||||
accessorKey: "id",
|
||||
cell: ({ row }) =>
|
||||
row.original.id ? (
|
||||
<IdCell value={row.original.id} variant="plain" copyable />
|
||||
) : (
|
||||
<span className="text-muted-foreground">{emptyLabel}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Units",
|
||||
accessorKey: "units",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => row.original.units.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Cost",
|
||||
accessorKey: "cost",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <MoneyCell value={row.original.cost} emptyText="—" showZero />,
|
||||
},
|
||||
unpricedColumn<GroupRow>(),
|
||||
];
|
||||
|
||||
const teamColumns = groupColumns("Team", "No team");
|
||||
const keyColumns = groupColumns("Key", "No key");
|
||||
|
||||
const CostMath = ({ counters, detail }: { counters: CounterRow[]; detail: GuardrailUsageDetail }) => (
|
||||
<CalcPopover title="How this cost is calculated" formula="priced units × price per unit = cost, per counter">
|
||||
<MathTable rows={counters.map(counterMathRow)} total={formatCost(detail.cost)} />
|
||||
<p className="text-xs text-muted-foreground">Per-unit prices come from the cost map LiteLLM ships with.</p>
|
||||
<UnpricedNote unpriced={detail.untracked_usage_units} provider={detail.provider} />
|
||||
</CalcPopover>
|
||||
);
|
||||
|
||||
const UnitsMath = ({ units }: { units: GuardrailUsageDetail["usage_units"] }) => (
|
||||
<CalcPopover title="How usage units add up" formula="counter + counter + … = usage units">
|
||||
<MathTable rows={unitsMathRows(units)} total={totalUnits(units).toLocaleString()} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Units are the billable counters the provider reported for this guardrail, added up over every call.
|
||||
</p>
|
||||
</CalcPopover>
|
||||
);
|
||||
|
||||
const TableHeading = ({ title }: { title: string }) => (
|
||||
<h6 className="text-sm font-semibold text-foreground">{title}</h6>
|
||||
);
|
||||
|
||||
export function GuardrailUsageBreakdown({ detail }: { detail: GuardrailUsageDetail }) {
|
||||
const counters = counterRows(detail);
|
||||
const unpriced = unpricedSummary(detail.untracked_usage_units);
|
||||
|
||||
return (
|
||||
<section className="space-y-4" aria-label="Usage and cost">
|
||||
<div>
|
||||
<h5 className="mb-0 text-base font-semibold text-foreground">Usage & Cost</h5>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Billable units the provider reported for this guardrail and what LiteLLM priced them at
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{counters.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No billable usage units were recorded in this period.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
<MetricCard
|
||||
label="Cost"
|
||||
value={formatCost(detail.cost)}
|
||||
valueColor={detail.cost != null ? "text-foreground" : "text-muted-foreground"}
|
||||
icon={<CircleDollarSign className="size-4" />}
|
||||
subtitle={unpriced ?? undefined}
|
||||
hint={<CostMath counters={counters} detail={detail} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Usage Units"
|
||||
value={totalUnits(detail.usage_units).toLocaleString()}
|
||||
subtitle={`${counters.length} ${counters.length === 1 ? "counter" : "counters"}`}
|
||||
hint={<UnitsMath units={detail.usage_units} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={counterColumns}
|
||||
data={counters}
|
||||
getRowId={(row) => row.counter}
|
||||
size="compact"
|
||||
toolbar={() => <TableHeading title="By counter" />}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<DataTable
|
||||
columns={teamColumns}
|
||||
data={groupRows(detail.usage_units_by_team, detail.cost_by_team, detail.untracked_usage_units_by_team)}
|
||||
getRowId={(row) => row.id || "no-team"}
|
||||
size="compact"
|
||||
toolbar={() => <TableHeading title="By team" />}
|
||||
/>
|
||||
<DataTable
|
||||
columns={keyColumns}
|
||||
data={groupRows(detail.usage_units_by_key, detail.cost_by_key, detail.untracked_usage_units_by_key)}
|
||||
getRowId={(row) => row.id || "no-key"}
|
||||
size="compact"
|
||||
toolbar={() => <TableHeading title="By key" />}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,21 +6,33 @@ import * as networking from "@/components/networking";
|
|||
import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getGuardrailsUsageOverview: vi.fn(),
|
||||
getGuardrailsUsageDetail: vi.fn(),
|
||||
getGuardrailsUsageLogs: vi.fn(),
|
||||
formatDate: vi.fn((d: Date) => d.toISOString().slice(0, 10)),
|
||||
}));
|
||||
|
||||
const mockUseGuardrailsUsageOverview = vi.fn();
|
||||
const mockUseGuardrailsUsageDetail = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage", () => ({
|
||||
useGuardrailsUsageOverview: (...args: unknown[]) => mockUseGuardrailsUsageOverview(...args),
|
||||
useGuardrailsUsageDetail: (...args: unknown[]) => mockUseGuardrailsUsageDetail(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/GuardrailsMonitor/LogViewer", () => ({
|
||||
LogViewer: ({ guardrailName }: { guardrailName: string }) => <div data-testid="log-viewer">{guardrailName}</div>,
|
||||
}));
|
||||
|
||||
const mockGetGuardrailsUsageOverview = vi.mocked(networking.getGuardrailsUsageOverview);
|
||||
const mockGetGuardrailsUsageDetail = vi.mocked(networking.getGuardrailsUsageDetail);
|
||||
const mockGetGuardrailsUsageLogs = vi.mocked(networking.getGuardrailsUsageLogs);
|
||||
|
||||
const emptyOverview = { rows: [], chart: [], totalRequests: 0, totalBlocked: 0, passRate: 100 };
|
||||
const emptyOverview = {
|
||||
rows: [],
|
||||
chart: [],
|
||||
totalRequests: 0,
|
||||
totalBlocked: 0,
|
||||
passRate: 100,
|
||||
totalUsageUnits: {},
|
||||
totalCost: null,
|
||||
totalUntrackedUsageUnits: {},
|
||||
};
|
||||
|
||||
const piiRow = {
|
||||
id: "gr-pii",
|
||||
|
|
@ -29,11 +41,17 @@ const piiRow = {
|
|||
provider: "LiteLLM",
|
||||
requestsEvaluated: 10,
|
||||
failRate: 10,
|
||||
avgScore: null,
|
||||
avgLatency: null,
|
||||
status: "healthy" as const,
|
||||
trend: "stable" as const,
|
||||
usageUnits: {},
|
||||
cost: null,
|
||||
untrackedUsageUnits: {},
|
||||
};
|
||||
|
||||
const piiDetail = {
|
||||
guardrail_id: "gr-pii",
|
||||
guardrail_name: "PII Guard",
|
||||
description: "",
|
||||
status: "healthy",
|
||||
|
|
@ -43,14 +61,27 @@ const piiDetail = {
|
|||
failRate: 10,
|
||||
avgScore: 0.5,
|
||||
avgLatency: 20,
|
||||
trend: "stable",
|
||||
time_series: [],
|
||||
usage_units: {},
|
||||
usage_units_daily: [],
|
||||
usage_units_by_team: {},
|
||||
usage_units_by_key: {},
|
||||
cost: null,
|
||||
cost_by_unit: {},
|
||||
cost_by_team: {},
|
||||
cost_by_key: {},
|
||||
untracked_usage_units: {},
|
||||
untracked_usage_units_by_team: {},
|
||||
untracked_usage_units_by_key: {},
|
||||
};
|
||||
|
||||
describe("GuardrailsMonitorView", () => {
|
||||
beforeEach(() => {
|
||||
testQueryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
mockGetGuardrailsUsageOverview.mockResolvedValue(emptyOverview);
|
||||
mockGetGuardrailsUsageDetail.mockResolvedValue(piiDetail);
|
||||
mockUseGuardrailsUsageOverview.mockReturnValue({ data: emptyOverview, isLoading: false, error: null });
|
||||
mockUseGuardrailsUsageDetail.mockReturnValue({ data: piiDetail, isLoading: false, error: null });
|
||||
mockGetGuardrailsUsageLogs.mockResolvedValue({ logs: [], total: 0 });
|
||||
});
|
||||
|
||||
|
|
@ -59,7 +90,9 @@ describe("GuardrailsMonitorView", () => {
|
|||
|
||||
expect(await screen.findByRole("heading", { name: /Guardrails Monitor/i })).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(mockGetGuardrailsUsageOverview).toHaveBeenCalled();
|
||||
expect(mockUseGuardrailsUsageOverview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ accessToken: "test-token", startDate: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -73,11 +106,9 @@ describe("GuardrailsMonitorView", () => {
|
|||
renderWithProviders(<GuardrailsMonitorView accessToken="test-token" />, { searchParams: "?guardrail=gr-pii" });
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "PII Guard" })).toBeInTheDocument();
|
||||
expect(mockGetGuardrailsUsageDetail).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect(mockUseGuardrailsUsageDetail).toHaveBeenCalledWith(
|
||||
"gr-pii",
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({ accessToken: "test-token", startDate: expect.any(String) }),
|
||||
);
|
||||
expect(screen.queryByRole("heading", { name: /Guardrails Monitor/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -85,7 +116,11 @@ describe("GuardrailsMonitorView", () => {
|
|||
it("should push ?guardrail= as a new history entry when a guardrail is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
|
||||
mockGetGuardrailsUsageOverview.mockResolvedValue({ ...emptyOverview, rows: [piiRow] });
|
||||
mockUseGuardrailsUsageOverview.mockReturnValue({
|
||||
data: { ...emptyOverview, rows: [piiRow] },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
renderWithProviders(<GuardrailsMonitorView accessToken="test-token" />, { onUrlUpdate });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "PII Guard" }));
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as networking from "@/components/networking";
|
||||
import type {
|
||||
GuardrailUsageOverview,
|
||||
GuardrailUsageOverviewRow,
|
||||
} from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage";
|
||||
import { GuardrailsOverview } from "./GuardrailsOverview";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getGuardrailsUsageOverview: vi.fn(),
|
||||
const useGuardrailsUsageOverviewMock = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage", () => ({
|
||||
useGuardrailsUsageOverview: (...args: unknown[]) => useGuardrailsUsageOverviewMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./ScoreChart", () => ({
|
||||
|
|
@ -17,16 +20,65 @@ vi.mock("./EvaluationSettingsModal", () => ({
|
|||
EvaluationSettingsModal: ({ open }: { open: boolean }) => (open ? <div>Evaluation settings modal</div> : null),
|
||||
}));
|
||||
|
||||
const mockGetGuardrailsUsageOverview = vi.mocked(networking.getGuardrailsUsageOverview);
|
||||
const baseRow: GuardrailUsageOverviewRow = {
|
||||
id: "guardrail",
|
||||
name: "Guardrail",
|
||||
type: "content_filter",
|
||||
provider: "LiteLLM",
|
||||
requestsEvaluated: 0,
|
||||
failRate: 0,
|
||||
avgScore: null,
|
||||
avgLatency: null,
|
||||
status: "healthy",
|
||||
trend: "stable",
|
||||
usageUnits: {},
|
||||
cost: null,
|
||||
untrackedUsageUnits: {},
|
||||
};
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
const overview: GuardrailUsageOverview = {
|
||||
rows: [
|
||||
{
|
||||
...baseRow,
|
||||
id: "guardrail-low",
|
||||
name: "Low Failure Guardrail",
|
||||
requestsEvaluated: 1200,
|
||||
failRate: 2.5,
|
||||
avgLatency: 45,
|
||||
trend: "down",
|
||||
},
|
||||
});
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
{
|
||||
...baseRow,
|
||||
id: "guardrail-high",
|
||||
name: "High Failure Guardrail",
|
||||
provider: "Bedrock",
|
||||
requestsEvaluated: 300,
|
||||
failRate: 18,
|
||||
status: "warning",
|
||||
trend: "up",
|
||||
usageUnits: { contentPolicyUnits: 1000, sensitiveInformationPolicyUnits: 250 },
|
||||
cost: 0.15,
|
||||
untrackedUsageUnits: { sensitiveInformationPolicyUnits: 250 },
|
||||
},
|
||||
{
|
||||
...baseRow,
|
||||
id: "guardrail-free",
|
||||
name: "Free Bedrock Guardrail",
|
||||
provider: "Bedrock",
|
||||
requestsEvaluated: 10,
|
||||
failRate: 0,
|
||||
usageUnits: { contentPolicyUnits: 40 },
|
||||
cost: 0,
|
||||
},
|
||||
],
|
||||
chart: [],
|
||||
totalRequests: 1510,
|
||||
totalBlocked: 84,
|
||||
passRate: 94.4,
|
||||
totalUsageUnits: { contentPolicyUnits: 1040, sensitiveInformationPolicyUnits: 250 },
|
||||
totalCost: 0.15,
|
||||
totalUntrackedUsageUnits: { sensitiveInformationPolicyUnits: 250 },
|
||||
};
|
||||
|
||||
function renderOverview(onSelectGuardrail = vi.fn()) {
|
||||
return render(
|
||||
|
|
@ -36,41 +88,24 @@ function renderOverview(onSelectGuardrail = vi.fn()) {
|
|||
endDate="2026-08-12"
|
||||
onSelectGuardrail={onSelectGuardrail}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
}
|
||||
|
||||
const rowNamed = (name: string) => screen.getByRole("row", { name: new RegExp(name) });
|
||||
|
||||
describe("GuardrailsOverview", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetGuardrailsUsageOverview.mockResolvedValue({
|
||||
rows: [
|
||||
{
|
||||
id: "guardrail-low",
|
||||
name: "Low Failure Guardrail",
|
||||
type: "content_filter",
|
||||
provider: "LiteLLM",
|
||||
requestsEvaluated: 1200,
|
||||
failRate: 2.5,
|
||||
avgLatency: 45,
|
||||
status: "healthy",
|
||||
trend: "down",
|
||||
},
|
||||
{
|
||||
id: "guardrail-high",
|
||||
name: "High Failure Guardrail",
|
||||
type: "content_filter",
|
||||
provider: "Bedrock",
|
||||
requestsEvaluated: 300,
|
||||
failRate: 18,
|
||||
status: "warning",
|
||||
trend: "up",
|
||||
},
|
||||
],
|
||||
chart: [],
|
||||
totalRequests: 1500,
|
||||
totalBlocked: 84,
|
||||
passRate: 94.4,
|
||||
useGuardrailsUsageOverviewMock.mockReturnValue({ data: overview, isLoading: false, error: null });
|
||||
});
|
||||
|
||||
it("asks for the usage overview of the selected window", () => {
|
||||
renderOverview();
|
||||
|
||||
expect(useGuardrailsUsageOverviewMock).toHaveBeenCalledWith({
|
||||
accessToken: "test-token",
|
||||
startDate: "2026-08-01",
|
||||
endDate: "2026-08-12",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -78,15 +113,7 @@ describe("GuardrailsOverview", () => {
|
|||
const onSelectGuardrail = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<GuardrailsOverview
|
||||
accessToken="test-token"
|
||||
startDate="2026-08-01"
|
||||
endDate="2026-08-12"
|
||||
onSelectGuardrail={onSelectGuardrail}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
renderOverview(onSelectGuardrail);
|
||||
|
||||
expect(await screen.findByRole("columnheader", { name: "Guardrail" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: /Requests/ })).toBeInTheDocument();
|
||||
|
|
@ -105,6 +132,56 @@ describe("GuardrailsOverview", () => {
|
|||
expect(onSelectGuardrail).toHaveBeenCalledWith("guardrail-low");
|
||||
});
|
||||
|
||||
it("shows each guardrail's usage units and cost, marking the units cost leaves out", async () => {
|
||||
renderOverview();
|
||||
|
||||
expect(await screen.findByRole("columnheader", { name: "Usage Units" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: /Cost/ })).toBeInTheDocument();
|
||||
|
||||
const priced = rowNamed("High Failure Guardrail");
|
||||
expect(within(priced).getByText("1,250")).toBeInTheDocument();
|
||||
expect(within(priced).getByText("$0.1500")).toBeInTheDocument();
|
||||
expect(within(priced).getByLabelText("250 units unpriced")).toBeInTheDocument();
|
||||
|
||||
const free = rowNamed("Free Bedrock Guardrail");
|
||||
expect(within(free).getByText("40")).toBeInTheDocument();
|
||||
expect(within(free).getByText("$0.0000")).toBeInTheDocument();
|
||||
expect(within(free).queryByLabelText(/unpriced/)).not.toBeInTheDocument();
|
||||
|
||||
const unmetered = rowNamed("Low Failure Guardrail");
|
||||
expect(within(unmetered).getAllByText("—")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("breaks the usage units down per counter on hover", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOverview();
|
||||
|
||||
await user.hover(within(rowNamed("High Failure Guardrail")).getByText("1,250"));
|
||||
|
||||
expect(await screen.findByText("Content Policy: 1,000")).toBeInTheDocument();
|
||||
expect(screen.getByText("Sensitive Information Policy: 250")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sorts by cost when its header is clicked, keeping guardrails with no known cost last either way", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOverview();
|
||||
const rowNames = () =>
|
||||
screen
|
||||
.getAllByRole("row")
|
||||
.slice(1)
|
||||
.map((r) => r.textContent ?? "");
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /Cost/ }));
|
||||
await waitFor(() => expect(rowNames()[0]).toContain("Free Bedrock Guardrail"));
|
||||
expect(rowNames()[1]).toContain("High Failure Guardrail");
|
||||
expect(rowNames()[2]).toContain("Low Failure Guardrail");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Cost/ }));
|
||||
await waitFor(() => expect(rowNames()[0]).toContain("High Failure Guardrail"));
|
||||
expect(rowNames()[1]).toContain("Free Bedrock Guardrail");
|
||||
expect(rowNames()[2]).toContain("Low Failure Guardrail");
|
||||
});
|
||||
|
||||
it("renders the page header and the export action", async () => {
|
||||
renderOverview();
|
||||
|
||||
|
|
@ -117,15 +194,63 @@ describe("GuardrailsOverview", () => {
|
|||
it("renders every summary metric card", async () => {
|
||||
renderOverview();
|
||||
|
||||
expect(await screen.findByText("1,500")).toBeInTheDocument();
|
||||
expect(await screen.findByText("1,510")).toBeInTheDocument();
|
||||
expect(screen.getByText("Total Evaluations")).toBeInTheDocument();
|
||||
expect(screen.getByText("Blocked Requests")).toBeInTheDocument();
|
||||
expect(screen.getByText("84")).toBeInTheDocument();
|
||||
expect(screen.getByText("Pass Rate")).toBeInTheDocument();
|
||||
expect(screen.getByText("94.4%")).toBeInTheDocument();
|
||||
expect(screen.getByText("23ms")).toBeInTheDocument();
|
||||
expect(screen.getByText("15ms")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active Guardrails")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("totals guardrail cost across the window and says how many units it leaves out", async () => {
|
||||
renderOverview();
|
||||
|
||||
const card = await screen.findByRole("group", { name: "Guardrail Cost" });
|
||||
expect(card).toHaveTextContent("$0.1500");
|
||||
expect(card).toHaveTextContent("250 units unpriced");
|
||||
});
|
||||
|
||||
it("lays the guardrail cost total out per guardrail", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOverview();
|
||||
|
||||
const card = await screen.findByRole("group", { name: "Guardrail Cost" });
|
||||
await user.click(within(card).getByRole("button", { name: /How is this calculated/ }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "How this cost is calculated" });
|
||||
const cells = within(dialog)
|
||||
.getAllByRole("row")
|
||||
.map((row) =>
|
||||
within(row)
|
||||
.getAllByRole("cell")
|
||||
.map((cell) => cell.textContent ?? ""),
|
||||
);
|
||||
expect(cells).toEqual([
|
||||
["High Failure Guardrail", "$0.1500"],
|
||||
["Free Bedrock Guardrail", "$0.0000"],
|
||||
["Total", "$0.1500"],
|
||||
]);
|
||||
expect(within(dialog).getByText(/250 units with no known price are left out of the cost/)).toBeInTheDocument();
|
||||
const issueLink = within(dialog).getByRole("link", { name: "Request pricing on GitHub" });
|
||||
const issueUrl = new URL(issueLink.getAttribute("href") ?? "");
|
||||
expect(issueUrl.searchParams.get("template")).toBe("feature_request.yml");
|
||||
expect(issueUrl.searchParams.get("the-feature")).toContain("sensitiveInformationPolicyUnits");
|
||||
});
|
||||
|
||||
it("shows a dash for guardrail cost when nothing in the window was priced", async () => {
|
||||
useGuardrailsUsageOverviewMock.mockReturnValue({
|
||||
data: { ...overview, totalCost: null, totalUntrackedUsageUnits: {} },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
renderOverview();
|
||||
|
||||
const card = await screen.findByRole("group", { name: "Guardrail Cost" });
|
||||
expect(card).toHaveTextContent("—");
|
||||
expect(card).not.toHaveTextContent("unpriced");
|
||||
});
|
||||
|
||||
it("renders the table toolbar heading and its description", async () => {
|
||||
|
|
@ -147,14 +272,18 @@ describe("GuardrailsOverview", () => {
|
|||
});
|
||||
|
||||
it("marks the overview busy while the usage request is in flight", async () => {
|
||||
mockGetGuardrailsUsageOverview.mockReturnValue(new Promise(() => {}));
|
||||
useGuardrailsUsageOverviewMock.mockReturnValue({ data: undefined, isLoading: true, error: null });
|
||||
renderOverview();
|
||||
|
||||
await waitFor(() => expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("shows a failure message when the usage request rejects", async () => {
|
||||
mockGetGuardrailsUsageOverview.mockRejectedValue(new Error("network down"));
|
||||
useGuardrailsUsageOverviewMock.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: new Error("network down"),
|
||||
});
|
||||
renderOverview();
|
||||
|
||||
expect(await screen.findByText("Failed to load data. Try again.")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,22 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ColumnDef, OnChangeFn, SortingState } from "@tanstack/react-table";
|
||||
import { Download, HeartPulse, Settings, TrendingUp, TriangleAlert } from "lucide-react";
|
||||
import { CircleDollarSign, Download, HeartPulse, Settings, TrendingUp, TriangleAlert } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { DataTable, DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { getGuardrailsUsageOverview } from "@/components/networking";
|
||||
import { type PerformanceRow } from "@/components/GuardrailsMonitor/mockData";
|
||||
import { MoneyCell } from "@/components/shared/table_cells/money_cell";
|
||||
import { CellTooltip } from "@/components/shared/table_cells/cell_tooltip";
|
||||
import {
|
||||
type GuardrailUsageOverviewRow,
|
||||
useGuardrailsUsageOverview,
|
||||
} from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage";
|
||||
import { CalcPopover, MathTable } from "@/components/GuardrailsMonitor/CalcPopover";
|
||||
import { UnpricedNote } from "@/components/GuardrailsMonitor/UnpricedNote";
|
||||
import {
|
||||
counterLabel,
|
||||
formatCost,
|
||||
totalUnits,
|
||||
unpricedSummary,
|
||||
type UsageUnits,
|
||||
} from "@/components/GuardrailsMonitor/usageUnits";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/shared/PageHeader";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
|
|
@ -20,7 +32,7 @@ interface GuardrailsOverviewProps {
|
|||
dateRangeControl?: React.ReactNode;
|
||||
}
|
||||
|
||||
type SortKey = "failRate" | "requestsEvaluated" | "avgLatency" | "falsePositiveRate" | "falseNegativeRate";
|
||||
type SortKey = "failRate" | "requestsEvaluated" | "avgLatency" | "cost";
|
||||
|
||||
const providerColors: Record<string, string> = {
|
||||
Bedrock: "bg-warning/15 text-warning border-warning/20",
|
||||
|
|
@ -30,14 +42,73 @@ const providerColors: Record<string, string> = {
|
|||
Custom: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
function computeMetricsFromRows(data: PerformanceRow[]) {
|
||||
const totalRequests = data.reduce((sum, r) => sum + r.requestsEvaluated, 0);
|
||||
const totalBlocked = data.reduce((sum, r) => sum + Math.round((r.requestsEvaluated * r.failRate) / 100), 0);
|
||||
const passRate = totalRequests > 0 ? ((1 - totalBlocked / totalRequests) * 100).toFixed(1) : "0";
|
||||
const withLat = data.filter((r) => r.avgLatency != null);
|
||||
const avgLatency =
|
||||
withLat.length > 0 ? Math.round(withLat.reduce((sum, r) => sum + (r.avgLatency ?? 0), 0) / withLat.length) : 0;
|
||||
return { totalRequests, totalBlocked, passRate, avgLatency, count: data.length };
|
||||
const EMPTY_METRICS = {
|
||||
totalRequests: 0,
|
||||
totalBlocked: 0,
|
||||
passRate: "0",
|
||||
avgLatency: 0,
|
||||
count: 0,
|
||||
totalCost: null as number | null,
|
||||
untracked: {} as UsageUnits,
|
||||
};
|
||||
|
||||
function UsageUnitsCell({ units }: { units: GuardrailUsageOverviewRow["usageUnits"] }) {
|
||||
const counters = Object.entries(units);
|
||||
if (counters.length === 0) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<CellTooltip
|
||||
content={
|
||||
<ul className="space-y-0.5">
|
||||
{counters.map(([counter, n]) => (
|
||||
<li key={counter}>
|
||||
{counterLabel(counter)}: {n.toLocaleString()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
trigger={<span className="tabular-nums">{totalUnits(units).toLocaleString()}</span>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalCostMath({
|
||||
rows,
|
||||
total,
|
||||
untracked,
|
||||
}: {
|
||||
rows: GuardrailUsageOverviewRow[];
|
||||
total: number | null;
|
||||
untracked: UsageUnits;
|
||||
}) {
|
||||
return (
|
||||
<CalcPopover title="How this cost is calculated" formula="guardrail + guardrail + … = guardrail cost">
|
||||
<MathTable
|
||||
rows={rows
|
||||
.filter((row) => row.cost != null)
|
||||
.map((row) => ({ label: row.name, parts: [formatCost(row.cost)], note: null }))}
|
||||
total={formatCost(total)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{`Each guardrail's cost is its units per counter × that counter's per-unit price from the cost map. Open a guardrail for its per-counter math.`}
|
||||
</p>
|
||||
<UnpricedNote unpriced={untracked} />
|
||||
</CalcPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function CostCell({ row }: { row: GuardrailUsageOverviewRow }) {
|
||||
const unpriced = unpricedSummary(row.untrackedUsageUnits);
|
||||
return (
|
||||
<span className="inline-flex w-full items-center justify-end gap-1">
|
||||
{unpriced && (
|
||||
<CellTooltip
|
||||
content={`${unpriced}: these units have no known price and are left out of the cost`}
|
||||
trigger={<TriangleAlert aria-label={unpriced} className="size-3.5 shrink-0 text-warning" />}
|
||||
/>
|
||||
)}
|
||||
<MoneyCell value={row.cost} emptyText="—" showZero />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function GuardrailsOverview({
|
||||
|
|
@ -55,40 +126,56 @@ export function GuardrailsOverview({
|
|||
data: guardrailsData,
|
||||
isLoading: guardrailsLoading,
|
||||
error: guardrailsError,
|
||||
} = useQuery({
|
||||
queryKey: ["guardrails-usage-overview", startDate, endDate],
|
||||
queryFn: () => getGuardrailsUsageOverview(accessToken!, startDate, endDate),
|
||||
enabled: !!accessToken,
|
||||
});
|
||||
} = useGuardrailsUsageOverview({ accessToken, startDate, endDate });
|
||||
|
||||
const activeData: PerformanceRow[] = guardrailsData?.rows ?? [];
|
||||
const activeData: GuardrailUsageOverviewRow[] = useMemo(() => guardrailsData?.rows ?? [], [guardrailsData]);
|
||||
const metrics = useMemo(() => {
|
||||
if (guardrailsData) {
|
||||
return {
|
||||
totalRequests: guardrailsData.totalRequests ?? 0,
|
||||
totalBlocked: guardrailsData.totalBlocked ?? 0,
|
||||
passRate: String(guardrailsData.passRate ?? 0),
|
||||
avgLatency: activeData.length
|
||||
? Math.round(activeData.reduce((s, r) => s + (r.avgLatency ?? 0), 0) / activeData.length)
|
||||
: 0,
|
||||
count: activeData.length,
|
||||
};
|
||||
}
|
||||
return computeMetricsFromRows(activeData);
|
||||
if (!guardrailsData) return EMPTY_METRICS;
|
||||
return {
|
||||
totalRequests: guardrailsData.totalRequests,
|
||||
totalBlocked: guardrailsData.totalBlocked,
|
||||
passRate: String(guardrailsData.passRate),
|
||||
avgLatency: activeData.length
|
||||
? Math.round(activeData.reduce((s, r) => s + (r.avgLatency ?? 0), 0) / activeData.length)
|
||||
: 0,
|
||||
count: activeData.length,
|
||||
totalCost: guardrailsData.totalCost,
|
||||
untracked: guardrailsData.totalUntrackedUsageUnits,
|
||||
};
|
||||
}, [guardrailsData, activeData]);
|
||||
const chartData = guardrailsData?.chart;
|
||||
const sorted = useMemo(() => {
|
||||
const mult = sortDir === "desc" ? -1 : 1;
|
||||
return [...activeData].sort((a, b) => {
|
||||
const mult = sortDir === "desc" ? -1 : 1;
|
||||
const aVal = a[sortBy] ?? 0;
|
||||
const bVal = b[sortBy] ?? 0;
|
||||
return (Number(aVal) - Number(bVal)) * mult;
|
||||
const aVal = a[sortBy];
|
||||
const bVal = b[sortBy];
|
||||
if (aVal == null || bVal == null) return Number(aVal == null) - Number(bVal == null);
|
||||
return (aVal - bVal) * mult;
|
||||
});
|
||||
}, [activeData, sortBy, sortDir]);
|
||||
const isLoading = guardrailsLoading;
|
||||
const error = guardrailsError;
|
||||
|
||||
const columns: ColumnDef<PerformanceRow>[] = [
|
||||
const columns: ColumnDef<GuardrailUsageOverviewRow>[] = [
|
||||
{
|
||||
header: "Status",
|
||||
accessorKey: "status",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
row.original.status === "healthy"
|
||||
? "bg-success"
|
||||
: row.original.status === "warning"
|
||||
? "bg-warning"
|
||||
: "bg-destructive"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground capitalize">{row.original.status}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Guardrail",
|
||||
accessorKey: "name",
|
||||
|
|
@ -167,27 +254,22 @@ export function GuardrailsOverview({
|
|||
),
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
accessorKey: "status",
|
||||
header: "Usage Units",
|
||||
accessorKey: "usageUnits",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
row.original.status === "healthy"
|
||||
? "bg-success"
|
||||
: row.original.status === "warning"
|
||||
? "bg-warning"
|
||||
: "bg-destructive"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground capitalize">{row.original.status}</span>
|
||||
</span>
|
||||
),
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <UsageUnitsCell units={row.original.usageUnits} />,
|
||||
},
|
||||
{
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Cost" />,
|
||||
accessorKey: "cost",
|
||||
meta: { numeric: true },
|
||||
sortDescFirst: false,
|
||||
cell: ({ row }) => <CostCell row={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
const sortableKeys: SortKey[] = ["failRate", "requestsEvaluated", "avgLatency"];
|
||||
const sortableKeys: SortKey[] = ["failRate", "requestsEvaluated", "avgLatency", "cost"];
|
||||
const sorting = useMemo<SortingState>(() => [{ id: sortBy, desc: sortDir === "desc" }], [sortBy, sortDir]);
|
||||
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
|
||||
const nextSorting = typeof updater === "function" ? updater(sorting) : updater;
|
||||
|
|
@ -236,6 +318,14 @@ export function GuardrailsOverview({
|
|||
metrics.avgLatency > 150 ? "text-destructive" : metrics.avgLatency > 50 ? "text-warning" : "text-success"
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Guardrail Cost"
|
||||
value={formatCost(metrics.totalCost)}
|
||||
valueColor={metrics.totalCost != null ? "text-foreground" : "text-muted-foreground"}
|
||||
icon={<CircleDollarSign className="size-4" />}
|
||||
subtitle={unpricedSummary(metrics.untracked) ?? undefined}
|
||||
hint={<TotalCostMath rows={activeData} total={metrics.totalCost} untracked={metrics.untracked} />}
|
||||
/>
|
||||
<MetricCard label="Active Guardrails" value={metrics.count} />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,23 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
|||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));
|
||||
const requestUrl = (input: RequestInfo | URL) => (input instanceof Request ? input.url : String(input));
|
||||
|
||||
const requestedUrls = () => fetchMock.mock.calls.map(([input]) => requestUrl(input));
|
||||
|
||||
const emptyOverview = {
|
||||
rows: [],
|
||||
chart: [],
|
||||
totalRequests: 0,
|
||||
totalBlocked: 0,
|
||||
passRate: 100,
|
||||
totalUsageUnits: {},
|
||||
totalCost: null,
|
||||
totalUntrackedUsageUnits: {},
|
||||
};
|
||||
|
||||
const jsonResponse = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
|
||||
const renderAs = (userRole: string) => {
|
||||
useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole });
|
||||
|
|
@ -25,12 +41,9 @@ describe("Guardrails Monitor page access by role", () => {
|
|||
beforeEach(() => {
|
||||
testQueryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ rows: [], chart: [], totalRequests: 0, totalBlocked: 0, passRate: 100 }),
|
||||
});
|
||||
fetchMock.mockImplementation(async (input: RequestInfo | URL) =>
|
||||
jsonResponse(requestUrl(input).includes("/guardrails/usage/overview") ? emptyOverview : []),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useGuardrailsUsageDetail, useGuardrailsUsageOverview } from "./useGuardrailsUsage";
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
vi.mock("@/lib/http/api", () => ({
|
||||
$api: { useQuery: (...args: unknown[]) => useQueryMock(...args) },
|
||||
}));
|
||||
|
||||
const lastCall = () => {
|
||||
const calls = useQueryMock.mock.calls;
|
||||
return calls[calls.length - 1] as [string, string, unknown, { enabled: boolean }];
|
||||
};
|
||||
|
||||
describe("useGuardrailsUsageOverview", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useQueryMock.mockReturnValue({ data: undefined });
|
||||
});
|
||||
|
||||
it("queries GET /guardrails/usage/overview with the window as query params", () => {
|
||||
renderHook(() => useGuardrailsUsageOverview({ accessToken: "sk", startDate: "2026-09-01", endDate: "2026-09-04" }));
|
||||
|
||||
expect(lastCall().slice(0, 3)).toEqual([
|
||||
"get",
|
||||
"/guardrails/usage/overview",
|
||||
{ params: { query: { start_date: "2026-09-01", end_date: "2026-09-04" } } },
|
||||
]);
|
||||
expect(lastCall()[3].enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("omits blank dates so the proxy applies its default window", () => {
|
||||
renderHook(() => useGuardrailsUsageOverview({ accessToken: "sk", startDate: "", endDate: "" }));
|
||||
|
||||
expect(lastCall()[2]).toEqual({ params: { query: { start_date: undefined, end_date: undefined } } });
|
||||
});
|
||||
|
||||
it("stays disabled without an access token", () => {
|
||||
renderHook(() => useGuardrailsUsageOverview({ accessToken: null, startDate: "2026-09-01", endDate: "2026-09-04" }));
|
||||
|
||||
expect(lastCall()[3].enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGuardrailsUsageDetail", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useQueryMock.mockReturnValue({ data: undefined });
|
||||
});
|
||||
|
||||
it("queries GET /guardrails/usage/detail/{guardrail_id} with the id as a path param", () => {
|
||||
renderHook(() =>
|
||||
useGuardrailsUsageDetail("bedrock-pii-mask", {
|
||||
accessToken: "sk",
|
||||
startDate: "2026-09-01",
|
||||
endDate: "2026-09-04",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastCall().slice(0, 3)).toEqual([
|
||||
"get",
|
||||
"/guardrails/usage/detail/{guardrail_id}",
|
||||
{
|
||||
params: {
|
||||
path: { guardrail_id: "bedrock-pii-mask" },
|
||||
query: { start_date: "2026-09-01", end_date: "2026-09-04" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(lastCall()[3].enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("stays disabled without a guardrail id", () => {
|
||||
renderHook(() =>
|
||||
useGuardrailsUsageDetail("", { accessToken: "sk", startDate: "2026-09-01", endDate: "2026-09-04" }),
|
||||
);
|
||||
|
||||
expect(lastCall()[3].enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { $api } from "@/lib/http/api";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type GuardrailUsageOverview = components["schemas"]["UsageOverviewResponse"];
|
||||
export type GuardrailUsageOverviewRow = components["schemas"]["UsageOverviewRow"];
|
||||
export type GuardrailUsageDetail = components["schemas"]["UsageDetailResponse"];
|
||||
|
||||
export interface GuardrailsUsageWindow {
|
||||
accessToken: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
const dateQuery = (startDate: string, endDate: string) => ({
|
||||
start_date: startDate || undefined,
|
||||
end_date: endDate || undefined,
|
||||
});
|
||||
|
||||
export const useGuardrailsUsageOverview = ({ accessToken, startDate, endDate }: GuardrailsUsageWindow) =>
|
||||
$api.useQuery(
|
||||
"get",
|
||||
"/guardrails/usage/overview",
|
||||
{ params: { query: dateQuery(startDate, endDate) } },
|
||||
{ enabled: Boolean(accessToken) },
|
||||
);
|
||||
|
||||
export const useGuardrailsUsageDetail = (
|
||||
guardrailId: string,
|
||||
{ accessToken, startDate, endDate }: GuardrailsUsageWindow,
|
||||
) =>
|
||||
$api.useQuery(
|
||||
"get",
|
||||
"/guardrails/usage/detail/{guardrail_id}",
|
||||
{ params: { path: { guardrail_id: guardrailId }, query: dateQuery(startDate, endDate) } },
|
||||
{ enabled: Boolean(accessToken && guardrailId) },
|
||||
);
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { CircleHelp } from "lucide-react";
|
||||
import React, { type ReactNode } from "react";
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import type { MathRow } from "./usageUnits";
|
||||
|
||||
export function CalcPopover({ title, formula, children }: { title: string; formula: string; children: ReactNode }) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={200}
|
||||
closeDelay={150}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 inline-flex w-fit cursor-help items-start gap-1 text-left text-xs text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CircleHelp className="mt-px size-3.5 shrink-0" />
|
||||
How is this calculated?
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="start" className="w-auto min-w-72 max-w-md gap-3">
|
||||
<PopoverTitle>{title}</PopoverTitle>
|
||||
<code className="w-fit rounded bg-muted px-2 py-1 text-[11px] text-muted-foreground">{formula}</code>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function MathTable({ rows, total }: { rows: readonly MathRow[]; total: string }) {
|
||||
const width = 1 + Math.max(...rows.map((row) => row.parts.length), 1);
|
||||
return (
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<React.Fragment key={row.label}>
|
||||
<tr>
|
||||
<td className="py-0.5 pr-3">{row.label}</td>
|
||||
{row.parts.map((part, i) => (
|
||||
<td key={i} className="py-0.5 pl-3 text-right whitespace-nowrap tabular-nums">
|
||||
{part}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
{row.note && (
|
||||
<tr>
|
||||
<td colSpan={width} className="pb-1 text-[11px] text-warning">
|
||||
{row.note}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-border font-medium">
|
||||
<td className="pt-1.5 pr-3" colSpan={width - 1}>
|
||||
Total
|
||||
</td>
|
||||
<td className="pt-1.5 pl-3 text-right whitespace-nowrap tabular-nums">{total}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,17 +6,19 @@ interface MetricCardProps {
|
|||
valueColor?: string;
|
||||
icon?: ReactNode;
|
||||
subtitle?: string;
|
||||
hint?: ReactNode;
|
||||
}
|
||||
|
||||
export function MetricCard({ label, value, valueColor = "text-foreground", icon, subtitle }: MetricCardProps) {
|
||||
export function MetricCard({ label, value, valueColor = "text-foreground", icon, subtitle, hint }: MetricCardProps) {
|
||||
return (
|
||||
<div className="h-full bg-card border border-border rounded-lg p-5 flex flex-col">
|
||||
<div role="group" aria-label={label} className="h-full bg-card border border-border rounded-lg p-5 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-muted-foreground">{label}</span>
|
||||
{icon && <span className="text-muted-foreground">{icon}</span>}
|
||||
</div>
|
||||
<div className={`text-3xl font-semibold ${valueColor} tracking-tight`}>{value}</div>
|
||||
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
|
||||
{hint}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import React from "react";
|
||||
import { pricingIssueUrl, totalUnits, type UsageUnits } from "./usageUnits";
|
||||
|
||||
export function UnpricedNote({ unpriced, provider }: { unpriced: UsageUnits; provider?: string }) {
|
||||
const total = totalUnits(unpriced);
|
||||
if (total === 0) return null;
|
||||
const [noun, verb] = total === 1 ? ["unit", "is"] : ["units", "are"];
|
||||
return (
|
||||
<p className="text-xs text-warning">
|
||||
{`${total.toLocaleString()} ${noun} with no known price ${verb} left out of the cost. `}
|
||||
<a
|
||||
href={pricingIssueUrl(unpriced, provider)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
Request pricing on GitHub
|
||||
</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,39 +2,6 @@
|
|||
* Types for Guardrails Monitor dashboard (data from usage API).
|
||||
*/
|
||||
|
||||
export interface PerformanceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
provider: string;
|
||||
requestsEvaluated: number;
|
||||
failRate: number;
|
||||
avgScore?: number;
|
||||
avgLatency?: number;
|
||||
p95Latency?: number;
|
||||
falsePositiveRate?: number;
|
||||
falseNegativeRate?: number;
|
||||
status: "healthy" | "warning" | "critical";
|
||||
trend: "up" | "down" | "stable";
|
||||
}
|
||||
|
||||
export interface GuardrailDetailRecord {
|
||||
name: string;
|
||||
type: string;
|
||||
provider: string;
|
||||
requestsEvaluated: number;
|
||||
failRate: number;
|
||||
avgScore?: number;
|
||||
avgLatency?: number;
|
||||
p95Latency?: number;
|
||||
falsePositiveRate?: number;
|
||||
falsePositiveCount?: number;
|
||||
falseNegativeRate?: number;
|
||||
falseNegativeCount?: number;
|
||||
status: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
counterLabel,
|
||||
counterMathRow,
|
||||
formatCost,
|
||||
formatUnitPrice,
|
||||
pricingIssueUrl,
|
||||
totalUnits,
|
||||
unitPrice,
|
||||
unitsMathRows,
|
||||
unpricedSummary,
|
||||
type CounterMath,
|
||||
} from "./usageUnits";
|
||||
|
||||
const counterOf = (counter: string, units: number, unpriced: number, cost: number | null): CounterMath => ({
|
||||
counter,
|
||||
units,
|
||||
unpriced,
|
||||
cost,
|
||||
});
|
||||
|
||||
describe("formatCost", () => {
|
||||
it("renders a dash when nothing was priced", () => {
|
||||
expect(formatCost(null)).toBe("—");
|
||||
expect(formatCost(undefined)).toBe("—");
|
||||
});
|
||||
|
||||
it("keeps an explicit zero as a real price rather than a dash", () => {
|
||||
expect(formatCost(0)).toBe("$0.0000");
|
||||
});
|
||||
|
||||
it("shows four decimals for the sub-cent amounts guardrail units cost", () => {
|
||||
expect(formatCost(0.0003)).toBe("$0.0003");
|
||||
expect(formatCost(12.5)).toBe("$12.5000");
|
||||
});
|
||||
|
||||
it("flags amounts below the displayed precision instead of rounding them to zero", () => {
|
||||
expect(formatCost(0.00001)).toBe("< $0.0001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("totalUnits", () => {
|
||||
it("sums every counter", () => {
|
||||
expect(totalUnits({ contentPolicyUnits: 3, sensitiveInformationPolicyUnits: 4 })).toBe(7);
|
||||
});
|
||||
|
||||
it("is zero for no counters", () => {
|
||||
expect(totalUnits({})).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("counterLabel", () => {
|
||||
it("turns a Bedrock counter name into words without the Units suffix", () => {
|
||||
expect(counterLabel("sensitiveInformationPolicyUnits")).toBe("Sensitive Information Policy");
|
||||
expect(counterLabel("contentPolicyUnits")).toBe("Content Policy");
|
||||
});
|
||||
|
||||
it("leaves a name it cannot split alone apart from capitalising it", () => {
|
||||
expect(counterLabel("units")).toBe("Units");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unpricedSummary", () => {
|
||||
it("is null when every unit was priced", () => {
|
||||
expect(unpricedSummary({})).toBeNull();
|
||||
expect(unpricedSummary({ contentPolicyUnits: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it("counts unpriced units across counters with a pluralised label", () => {
|
||||
expect(unpricedSummary({ contentPolicyUnits: 1200, someFutureCounter: 34 })).toBe("1,234 units unpriced");
|
||||
expect(unpricedSummary({ someFutureCounter: 1 })).toBe("1 unit unpriced");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unitPrice", () => {
|
||||
it("backs the per-unit price out of the priced share only", () => {
|
||||
expect(unitPrice(counterOf("contentPolicyUnits", 1200, 200, 0.15))).toBeCloseTo(0.00015, 10);
|
||||
});
|
||||
|
||||
it("is null when nothing was priced", () => {
|
||||
expect(unitPrice(counterOf("someFutureCounter", 7, 7, null))).toBeNull();
|
||||
expect(unitPrice(counterOf("someFutureCounter", 7, 7, 0))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUnitPrice", () => {
|
||||
it("keeps the significant decimals and drops trailing zeros", () => {
|
||||
expect(formatUnitPrice(0.0001)).toBe("$0.0001");
|
||||
expect(formatUnitPrice(0.00015)).toBe("$0.00015");
|
||||
expect(formatUnitPrice(0)).toBe("$0");
|
||||
expect(formatUnitPrice(1)).toBe("$1");
|
||||
});
|
||||
|
||||
it("never shows a positive price as free", () => {
|
||||
expect(formatUnitPrice(0.0000002)).toBe("< $0.000001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("counterMathRow", () => {
|
||||
it("shows units × price = cost for a fully priced counter", () => {
|
||||
expect(counterMathRow(counterOf("contentPolicyUnits", 1000, 0, 0.15))).toEqual({
|
||||
label: "Content Policy",
|
||||
parts: ["1,000", "× $0.00015", "= $0.1500"],
|
||||
note: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("prices only the priced share and calls out the rest", () => {
|
||||
expect(counterMathRow(counterOf("sensitiveInformationPolicyUnits", 8, 2, 0.0006))).toEqual({
|
||||
label: "Sensitive Information Policy",
|
||||
parts: ["6", "× $0.0001", "= $0.0006"],
|
||||
note: "2 unpriced units left out",
|
||||
});
|
||||
expect(counterMathRow(counterOf("sensitiveInformationPolicyUnits", 8, 1, 0.0007)).note).toBe(
|
||||
"1 unpriced unit left out",
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when a counter has no known price at all", () => {
|
||||
expect(counterMathRow(counterOf("someFutureCounter", 7, 7, null))).toEqual({
|
||||
label: "Some Future Counter",
|
||||
parts: ["7", "× —", "= —"],
|
||||
note: "no known price, left out",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a free counter as × $0", () => {
|
||||
expect(counterMathRow(counterOf("wordPolicyUnits", 2, 0, 0)).parts).toEqual(["2", "× $0", "= $0.0000"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unitsMathRows", () => {
|
||||
it("lists the counters in order with their counts", () => {
|
||||
expect(unitsMathRows({ contentPolicyUnits: 2, wordPolicyUnits: 1200 })).toEqual([
|
||||
{ label: "Content Policy", parts: ["2"], note: null },
|
||||
{ label: "Word Policy", parts: ["1,200"], note: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pricingIssueUrl", () => {
|
||||
it("prefills the feature request with the provider and the unpriced counters", () => {
|
||||
const url = new URL(pricingIssueUrl({ text_records: 5, someFutureCounter: 7 }, "azure/prompt_shield"));
|
||||
|
||||
expect(url.origin + url.pathname).toBe("https://github.com/BerriAI/litellm/issues/new");
|
||||
expect(url.searchParams.get("template")).toBe("feature_request.yml");
|
||||
expect(url.searchParams.get("title")).toBe("[Feature]: add azure/prompt_shield guardrail pricing to the cost map");
|
||||
expect(url.searchParams.get("the-feature")).toContain("text_records, someFutureCounter");
|
||||
});
|
||||
|
||||
it("stays generic when no provider is known", () => {
|
||||
const url = new URL(pricingIssueUrl({ text_records: 5 }));
|
||||
|
||||
expect(url.searchParams.get("title")).toBe("[Feature]: add guardrail pricing to the cost map");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { formatNumberWithCommas, getSpendString } from "@/utils/dataUtils";
|
||||
|
||||
export type UsageUnits = Readonly<Record<string, number>>;
|
||||
|
||||
export const formatCost = (cost: number | null | undefined): string => {
|
||||
if (cost == null) return "—";
|
||||
return cost === 0 ? `$${formatNumberWithCommas(0, 4)}` : getSpendString(cost, 4);
|
||||
};
|
||||
|
||||
export const totalUnits = (units: UsageUnits): number => Object.values(units).reduce((sum, n) => sum + n, 0);
|
||||
|
||||
export const counterLabel = (counter: string): string =>
|
||||
counter
|
||||
.replace(/Units$/, "")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/^./, (c) => c.toUpperCase());
|
||||
|
||||
export const unpricedSummary = (untracked: UsageUnits): string | null => {
|
||||
const total = totalUnits(untracked);
|
||||
return total > 0 ? `${total.toLocaleString()} ${total === 1 ? "unit" : "units"} unpriced` : null;
|
||||
};
|
||||
|
||||
export interface CounterMath {
|
||||
readonly counter: string;
|
||||
readonly units: number;
|
||||
readonly unpriced: number;
|
||||
readonly cost: number | null;
|
||||
}
|
||||
|
||||
export const pricedUnits = ({ units, unpriced }: Pick<CounterMath, "units" | "unpriced">): number =>
|
||||
Math.max(units - unpriced, 0);
|
||||
|
||||
export const unitPrice = (row: CounterMath): number | null => {
|
||||
const priced = pricedUnits(row);
|
||||
return row.cost != null && priced > 0 ? row.cost / priced : null;
|
||||
};
|
||||
|
||||
export const formatUnitPrice = (price: number): string => {
|
||||
const fixed = price.toFixed(6).replace(/\.?0+$/, "");
|
||||
return price > 0 && Number(fixed) === 0 ? "< $0.000001" : `$${fixed}`;
|
||||
};
|
||||
|
||||
export interface MathRow {
|
||||
readonly label: string;
|
||||
readonly parts: readonly string[];
|
||||
readonly note: string | null;
|
||||
}
|
||||
|
||||
export const counterMathRow = (row: CounterMath): MathRow => {
|
||||
const label = counterLabel(row.counter);
|
||||
const price = unitPrice(row);
|
||||
if (price == null) {
|
||||
return { label, parts: [row.units.toLocaleString(), "× —", "= —"], note: "no known price, left out" };
|
||||
}
|
||||
return {
|
||||
label,
|
||||
parts: [pricedUnits(row).toLocaleString(), `× ${formatUnitPrice(price)}`, `= ${formatCost(row.cost)}`],
|
||||
note:
|
||||
row.unpriced > 0
|
||||
? `${row.unpriced.toLocaleString()} unpriced ${row.unpriced === 1 ? "unit" : "units"} left out`
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const unitsMathRows = (units: UsageUnits): readonly MathRow[] =>
|
||||
Object.entries(units).map(([counter, n]) => ({
|
||||
label: counterLabel(counter),
|
||||
parts: [n.toLocaleString()],
|
||||
note: null,
|
||||
}));
|
||||
|
||||
export const pricingIssueUrl = (unpriced: UsageUnits, provider?: string): string => {
|
||||
const subject = provider ? `${provider} guardrail` : "guardrail";
|
||||
const params = new URLSearchParams({
|
||||
template: "feature_request.yml",
|
||||
title: `[Feature]: add ${subject} pricing to the cost map`,
|
||||
"the-feature": `LiteLLM has no price for these ${subject} usage units, so the Guardrails Monitor leaves them out of the cost: ${Object.keys(unpriced).join(", ")}`,
|
||||
});
|
||||
return `https://github.com/BerriAI/litellm/issues/new?${params.toString()}`;
|
||||
};
|
||||
|
|
@ -3964,63 +3964,6 @@ export const rejectGuardrailSubmission = async (
|
|||
};
|
||||
|
||||
// Guardrails / Policies usage (dashboard)
|
||||
export const getGuardrailsUsageOverview = async (accessToken: string, startDate?: string, endDate?: string) => {
|
||||
try {
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails/usage/overview` : `/guardrails/usage/overview`;
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append("start_date", startDate);
|
||||
if (endDate) params.append("end_date", endDate);
|
||||
if (params.toString()) url += `?${params.toString()}`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(deriveErrorMessage(errorData));
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to get guardrails usage overview:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getGuardrailsUsageDetail = async (
|
||||
accessToken: string,
|
||||
guardrailId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
) => {
|
||||
try {
|
||||
let url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/guardrails/usage/detail/${encodeURIComponent(guardrailId)}`
|
||||
: `/guardrails/usage/detail/${encodeURIComponent(guardrailId)}`;
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append("start_date", startDate);
|
||||
if (endDate) params.append("end_date", endDate);
|
||||
if (params.toString()) url += `?${params.toString()}`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(deriveErrorMessage(errorData));
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to get guardrails usage detail:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getGuardrailsUsageLogs = async (
|
||||
accessToken: string,
|
||||
options: {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@ const middleware: Middleware = {
|
|||
* auth header and maps non-2xx responses to ApiError so query functions can just
|
||||
* read `.data`.
|
||||
*/
|
||||
export const fetchClient = createFetchClient<paths>({ Request: BaseAwareRequest });
|
||||
export const fetchClient = createFetchClient<paths>({
|
||||
Request: BaseAwareRequest,
|
||||
fetch: (request) => globalThis.fetch(request),
|
||||
});
|
||||
fetchClient.use(middleware);
|
||||
|
||||
/**
|
||||
|
|
|
|||
12
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
12
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -38487,6 +38487,18 @@ export interface components {
|
|||
untracked_usage_units: {
|
||||
[key: string]: number;
|
||||
};
|
||||
/** Untracked Usage Units By Key */
|
||||
untracked_usage_units_by_key: {
|
||||
[key: string]: {
|
||||
[key: string]: number;
|
||||
};
|
||||
};
|
||||
/** Untracked Usage Units By Team */
|
||||
untracked_usage_units_by_team: {
|
||||
[key: string]: {
|
||||
[key: string]: number;
|
||||
};
|
||||
};
|
||||
/** Usage Units */
|
||||
usage_units: {
|
||||
[key: string]: number;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue