feat(ui): add a Budgets tab to the virtual key detail page

Renders GET /key/{key_id}/budgets as a table of every budget that can gate the
key, so a 429 that names no entity can be traced to a row without reading auth
source. Rows sort blocking-first, and a soft budget that is over reads as
"Exceeded (alert only)" against "Blocks requests", so an alert can never be
mistaken for the thing that rejected the request. Scopes with nothing configured
still get a row, rendered "Unlimited" rather than $0, which is what lets someone
rule out the org and the team without clicking into them.

The panel is deliberately not keepMounted, so the request is lazy and its cells
cannot collide with the keepMounted Overview panel.

InheritedBudgetHint keeps its client-side team/org guess on the Overview card and
the keys list, where no tab is in reach, but its tooltip now says it is not the
full list and points at this tab, so the two cannot read as competing answers.
This commit is contained in:
ryan-crabbe-berri 2026-08-19 15:00:12 -07:00
parent 59c7e7a17d
commit 5b573c552d
7 changed files with 816 additions and 2 deletions

View file

@ -0,0 +1,16 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { $api } from "@/lib/http/api";
import type { components } from "@/lib/http/schema";
export type KeyBudgetsResponse = components["schemas"]["KeyBudgetsResponse"];
export type KeyBudgetEntry = KeyBudgetsResponse["budgets"][number];
export const useKeyBudgets = (keyId: string | undefined) => {
const { accessToken } = useAuthorized();
return $api.useQuery(
"get",
"/key/{key_id}/budgets",
{ params: { path: { key_id: keyId ?? "" } } },
{ enabled: Boolean(accessToken) && Boolean(keyId) },
);
};

View file

@ -56,10 +56,13 @@ export function InheritedBudgetHint({ gates }: InheritedBudgetHintProps) {
<SimpleTooltip
content={
<div data-testid="inherited-budget-hint" className="flex flex-col gap-1">
<span>This key has no budget of its own, but its spend still counts toward:</span>
<span>This key has no budget of its own. Its spend also counts toward:</span>
{gates.map((gate) => (
<span key={gate.scope}>{formatGate(gate)}</span>
))}
<span className="opacity-80">
Not the full list. The key&rsquo;s Budgets tab shows every budget that can block it, with live spend.
</span>
</div>
}
/>

View file

@ -0,0 +1,48 @@
"use client";
import { useMemo } from "react";
import { useKeyBudgets, type KeyBudgetEntry } from "@/app/(dashboard)/hooks/keys/useKeyBudgets";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { DataTable } from "@/components/shared/DataTable";
import { parseErrorMessage } from "../shared/errorUtils";
import { getKeyBudgetsTableColumns, isBlockingRow, severityRank } from "./KeyBudgetsTableColumns";
function BudgetRows({ budgets, isLoading }: { budgets: readonly KeyBudgetEntry[]; isLoading: boolean }) {
const columns = useMemo(() => getKeyBudgetsTableColumns(), []);
const rows = useMemo(() => [...budgets].sort((a, b) => severityRank(a) - severityRank(b)), [budgets]);
return (
<DataTable
data={rows}
columns={columns}
getRowId={(entry, index) => `${entry.scope}:${entry.entity_id ?? ""}:${index}`}
isLoading={isLoading}
loadingMessage="Loading budgets…"
noDataMessage="No budgets apply to this key."
rowClassName={(row) => (isBlockingRow(row.original) ? "bg-red-50 hover:bg-red-50" : "")}
size="compact"
/>
);
}
export function KeyBudgetsTable({ keyId }: { keyId: string }) {
const { data, isLoading, isError, error } = useKeyBudgets(keyId);
return (
<div data-testid="key-budgets-panel" className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Every budget that can block this key, with the live spend each one is measured against.
</p>
{isError ? (
<Alert variant="error">
<AlertTitle>Could not load budgets</AlertTitle>
<AlertDescription>{parseErrorMessage(error)}</AlertDescription>
</Alert>
) : (
<BudgetRows budgets={data?.budgets ?? []} isLoading={isLoading} />
)}
</div>
);
}

View file

@ -0,0 +1,161 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import type { KeyBudgetEntry } from "@/app/(dashboard)/hooks/keys/useKeyBudgets";
import {
CellTooltip,
DateCell,
MoneyCell,
SpendBudgetCell,
StatusBadge,
type StatusTone,
} from "@/components/shared/table_cells";
const SCOPE_LABELS: Record<string, string> = {
proxy: "Proxy",
key: "Key",
key_window: "Key window",
key_model: "Key per-model",
team: "Team",
team_window: "Team window",
team_member: "Team member",
user: "User",
organization: "Organization",
project: "Project",
tag: "Tag",
end_user: "End user",
end_user_model: "End user per-model",
};
const isAlertOnly = (entry: KeyBudgetEntry): boolean => entry.enforcement === "soft";
export const isBlockingRow = (entry: KeyBudgetEntry): boolean => entry.status === "exceeded" && !isAlertOnly(entry);
const STATUS_ORDER: Record<string, number> = { exceeded: 0, ok: 1, unlimited: 2 };
export const severityRank = (entry: KeyBudgetEntry): number =>
(STATUS_ORDER[entry.status] ?? STATUS_ORDER.unlimited) * 2 + (isAlertOnly(entry) ? 1 : 0);
const statusPresentation = (entry: KeyBudgetEntry): { tone: StatusTone; label: string } => {
if (entry.status === "unlimited") return { tone: "neutral", label: "Unlimited" };
if (entry.status !== "exceeded") return { tone: "success", label: "Within budget" };
return isAlertOnly(entry)
? { tone: "warning", label: "Exceeded (alert only)" }
: { tone: "error", label: "Exceeded" };
};
function ScopeCell({ entry }: { entry: KeyBudgetEntry }) {
const entity = entry.entity_label || entry.entity_id;
return (
<div className="flex min-w-0 flex-col gap-0.5">
<CellTooltip
content={`Limit source: ${entry.source}`}
trigger={
<span className="w-fit truncate text-sm font-medium text-foreground">
{SCOPE_LABELS[entry.scope] ?? entry.scope}
</span>
}
/>
{entity && <span className="truncate font-mono text-xs text-muted-foreground">{entity}</span>}
{entry.note && (
<span className="truncate text-xs text-muted-foreground italic" title={entry.note}>
{entry.note}
</span>
)}
</div>
);
}
function EnforcementCell({ entry }: { entry: KeyBudgetEntry }) {
return isAlertOnly(entry) ? (
<StatusBadge
tone="neutral"
label="Alert only"
tooltip="Soft budget. Going over raises an alert and never rejects a request."
/>
) : (
<StatusBadge tone="info" label="Blocks requests" tooltip="Going over this budget rejects requests on this key." />
);
}
function RemainingCell({ entry }: { entry: KeyBudgetEntry }) {
const unlimited = entry.max_budget == null;
return (
<MoneyCell
value={unlimited ? null : entry.remaining}
decimals={2}
emptyText={unlimited ? "Unlimited" : "-"}
showZero
/>
);
}
export const getKeyBudgetsTableColumns = (): ColumnDef<KeyBudgetEntry>[] => [
{
id: "scope",
meta: { title: "Scope" },
header: "Scope",
size: 240,
enableSorting: false,
cell: ({ row }) => <ScopeCell entry={row.original} />,
},
{
id: "enforcement",
meta: { title: "Enforcement" },
header: "Enforcement",
size: 150,
enableSorting: false,
cell: ({ row }) => <EnforcementCell entry={row.original} />,
},
{
id: "spend",
meta: { title: "Spend / Limit" },
header: "Spend / Limit",
size: 200,
enableSorting: false,
cell: ({ row }) => (
<SpendBudgetCell spend={row.original.spend} maxBudget={row.original.max_budget} budgetDecimals={2} />
),
},
{
id: "remaining",
meta: { title: "Remaining", className: "text-right", headerClassName: "text-right" },
header: "Remaining",
size: 120,
enableSorting: false,
cell: ({ row }) => <RemainingCell entry={row.original} />,
},
{
id: "status",
meta: { title: "Status" },
header: "Status",
size: 170,
enableSorting: false,
cell: ({ row }) => {
const { tone, label } = statusPresentation(row.original);
return (
<StatusBadge
tone={tone}
label={label}
dataTestId={isBlockingRow(row.original) ? "key-budget-blocking" : undefined}
/>
);
},
},
{
id: "resets",
meta: { title: "Resets" },
header: "Resets",
size: 150,
enableSorting: false,
cell: ({ row }) => (
<div className="flex flex-col gap-0.5">
<DateCell value={row.original.budget_reset_at} precision="date" fallback="Never" />
{row.original.budget_duration && (
<span className="text-xs text-muted-foreground">Every {row.original.budget_duration}</span>
)}
</div>
),
},
];

View file

@ -0,0 +1,325 @@
import { renderWithProviders } from "../../../tests/test-utils";
import { fireEvent, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { KeyBudgetEntry } from "@/app/(dashboard)/hooks/keys/useKeyBudgets";
import type { KeyResponse } from "../key_team_helpers/key_list";
import KeyInfoView from "./key_info_view";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
// The Budgets tab is the whole point of the endpoint: the key, its team and its user were all
// unlimited and the request still 429'd, because the gate was the team-member budget. These tests
// pin that a user can pick that row out of the table without reading auth source.
const apiMocks = vi.hoisted(() => ({ useQuery: vi.fn() }));
vi.mock("@/lib/http/api", () => ({
$api: { useQuery: apiMocks.useQuery },
fetchClient: { GET: vi.fn(), POST: vi.fn() },
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("./key_edit_view", () => ({
KeyEditView: () => <div data-testid="key-edit-view-stub" />,
}));
vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ default: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: vi.fn().mockReturnValue({ data: [] }),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
useProjects: vi.fn().mockReturnValue({ data: [], isLoading: false }),
}));
vi.mock("@/app/(dashboard)/hooks/keys/useResetKeySpend", () => ({
useResetKeySpend: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
}));
vi.mock("../networking", () => ({
serverRootPath: "",
keyDeleteCall: vi.fn().mockResolvedValue({}),
keyUpdateCall: vi.fn().mockResolvedValue({}),
getPolicyInfoWithGuardrails: vi.fn().mockResolvedValue({ resolved_guardrails: [] }),
}));
const MOCK_KEY_DATA = {
token: "test-token-123",
token_id: "test-token-123",
key_name: "sk-...abcd",
key_alias: "ci-runner",
spend: 1000.2,
max_budget: null,
expires: "null",
models: [],
aliases: {},
config: {},
user_id: "default_user_id",
team_id: "team-123",
max_parallel_requests: null,
metadata: {},
tpm_limit: null,
rpm_limit: null,
budget_duration: null,
budget_reset_at: null,
allowed_cache_controls: [],
permissions: {},
model_spend: {},
model_max_budget: {},
soft_budget_cooldown: false,
blocked: false,
litellm_budget_table: {},
organization_id: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
team_spend: 0,
team_alias: "",
team_max_budget: null,
team_models: [],
team_blocked: false,
soft_budget: null,
team_model_aliases: {},
team_member_spend: 0,
team_metadata: {},
end_user_id: null,
last_refreshed_at: 0,
api_key: "sk-...abcd",
user_role: "user",
rpm_limit_per_model: {},
tpm_limit_per_model: {},
user_email: "alice@example.com",
object_permission: {
object_permission_id: "perm-1",
mcp_servers: [],
mcp_access_groups: [],
mcp_tool_permissions: {},
vector_stores: [],
},
auto_rotate: false,
} as unknown as KeyResponse;
const baseAuthorized = {
accessToken: "test-token",
userId: "test-user",
userRole: "admin",
userRoleLabel: "Admin",
isViewOnly: false,
premiumUser: true,
token: "test-token",
userEmail: null,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
isLoading: false,
isAuthorized: true,
};
const UNCONFIGURED_BUDGET = {
scope: "key",
entity_type: "key",
entity_id: null,
entity_label: null,
enforcement: "hard",
max_budget: null,
spend: 0,
remaining: null,
comparison: ">=",
budget_duration: null,
budget_reset_at: null,
window_start: null,
source: "key.max_budget",
status: "unlimited",
note: null,
} as KeyBudgetEntry;
const KEY_UNLIMITED: KeyBudgetEntry = {
...UNCONFIGURED_BUDGET,
entity_id: "test-token-123",
entity_label: "ci-runner",
spend: 1000.2,
};
const USER_WITHIN_BUDGET: KeyBudgetEntry = {
...UNCONFIGURED_BUDGET,
scope: "user",
entity_type: "user",
entity_id: "default_user_id",
entity_label: "alice@example.com",
max_budget: 250,
spend: 10,
remaining: 240,
source: "user.max_budget",
status: "ok",
};
const TEAM_SOFT_OVER: KeyBudgetEntry = {
...UNCONFIGURED_BUDGET,
scope: "team",
entity_type: "team",
entity_id: "team-123",
entity_label: "Platform",
enforcement: "soft",
max_budget: 500,
spend: 900,
remaining: -400,
source: "budget_table:b-soft",
status: "exceeded",
note: "alert only",
};
const TEAM_MEMBER_BLOCKING: KeyBudgetEntry = {
...UNCONFIGURED_BUDGET,
scope: "team_member",
entity_type: "team_member",
entity_id: "default_user_id:team-123",
entity_label: "alice @ Platform",
max_budget: 1000,
spend: 1000.2,
remaining: -0.2,
budget_duration: "30d",
budget_reset_at: "2026-09-01T12:00:00+00:00",
source: "team.metadata.team_member_budget_id",
status: "exceeded",
};
const ORG_UNCONFIGURED: KeyBudgetEntry = {
...UNCONFIGURED_BUDGET,
scope: "organization",
entity_type: "organization",
entity_id: "org-1",
entity_label: "Acme Org",
spend: 12.5,
source: "organization.budget_id",
};
const ALL_BUDGETS = [KEY_UNLIMITED, USER_WITHIN_BUDGET, TEAM_SOFT_OVER, TEAM_MEMBER_BLOCKING, ORG_UNCONFIGURED];
const mockBudgets = (budgets: KeyBudgetEntry[]) => {
const loaded = { data: { key: "test-token-123", budgets }, isLoading: false, isError: false, error: null };
apiMocks.useQuery.mockReturnValue(loaded);
};
const renderKeyInfo = () =>
renderWithProviders(
<KeyInfoView
keyData={MOCK_KEY_DATA}
onClose={() => {}}
keyId="test-key-id"
onKeyDataUpdate={() => {}}
teams={[]}
/>,
);
const openBudgetsTab = async () => {
fireEvent.click(await screen.findByRole("tab", { name: "Budgets" }));
return within(await screen.findByTestId("key-budgets-panel"));
};
const renderAndOpenBudgetsTab = async () => {
renderKeyInfo();
return openBudgetsTab();
};
const rowFor = (panel: ReturnType<typeof within>, entityLabel: string): HTMLElement => {
const row = panel.getByText(entityLabel).closest("tr");
if (row === null) throw new Error(`no table row rendered for ${entityLabel}`);
return row;
};
describe("KeyInfoView Budgets tab", () => {
beforeEach(() => {
apiMocks.useQuery.mockReset();
mockBudgets(ALL_BUDGETS);
vi.mocked(useTeams).mockReturnValue({ teams: [], setTeams: vi.fn() });
vi.mocked(useAuthorized).mockReturnValue(baseAuthorized);
});
it("does not fetch budgets until the tab is opened, then asks for this key's id", async () => {
renderKeyInfo();
expect(await screen.findByRole("tab", { name: "Budgets" })).toBeInTheDocument();
expect(apiMocks.useQuery).not.toHaveBeenCalled();
await openBudgetsTab();
expect(apiMocks.useQuery).toHaveBeenCalledWith(
"get",
"/key/{key_id}/budgets",
{ params: { path: { key_id: "test-token-123" } } },
{ enabled: true },
);
});
it("identifies the team-member budget as the single one blocking the key", async () => {
const panel = await renderAndOpenBudgetsTab();
const blocking = panel.getAllByTestId("key-budget-blocking");
expect(blocking).toHaveLength(1);
expect(blocking[0]).toHaveTextContent("Exceeded");
const blockedRow = rowFor(panel, "alice @ Platform");
expect(within(blockedRow).getByText("Team member")).toBeInTheDocument();
expect(within(blockedRow).getByText("Blocks requests")).toBeInTheDocument();
expect(blockedRow).toHaveTextContent("$1,000.2000 of $1,000.00");
});
it("shows an over-budget soft limit as alert-only, never as a blocker", async () => {
const panel = await renderAndOpenBudgetsTab();
const softRow = rowFor(panel, "Platform");
expect(within(softRow).getByText("Alert only")).toBeInTheDocument();
expect(within(softRow).getByText("Exceeded (alert only)")).toBeInTheDocument();
expect(within(softRow).queryByTestId("key-budget-blocking")).not.toBeInTheDocument();
expect(within(softRow).queryByText("Blocks requests")).not.toBeInTheDocument();
expect(softRow).toHaveTextContent("alert only");
});
it("renders a scope with nothing configured as Unlimited rather than $0", async () => {
const panel = await renderAndOpenBudgetsTab();
const orgRow = rowFor(panel, "Acme Org");
expect(orgRow).toHaveTextContent("· Unlimited");
expect(within(orgRow).getAllByText("Unlimited")).toHaveLength(2);
expect(orgRow).not.toHaveTextContent("$0.00");
expect(within(orgRow).queryByRole("meter")).not.toBeInTheDocument();
});
it("puts the blocking budget above the alert-only, healthy and unlimited ones", async () => {
const panel = await renderAndOpenBudgetsTab();
const [, ...dataRows] = panel.getAllByRole("row");
expect(dataRows[0]).toHaveTextContent("alice @ Platform");
expect(dataRows[1]).toHaveTextContent("Exceeded (alert only)");
expect(dataRows[2]).toHaveTextContent("Within budget");
expect(dataRows.slice(3).every((row) => row.textContent?.includes("Unlimited"))).toBe(true);
});
it("shows when the blocking budget next resets", async () => {
const panel = await renderAndOpenBudgetsTab();
const blockedRow = rowFor(panel, "alice @ Platform");
expect(blockedRow).toHaveTextContent("Sep 1, 2026");
expect(blockedRow).toHaveTextContent("Every 30d");
});
it("says a scope never resets when no reset is scheduled", async () => {
const panel = await renderAndOpenBudgetsTab();
expect(rowFor(panel, "alice@example.com")).toHaveTextContent("Never");
});
it("surfaces a failed lookup instead of an empty budget table", async () => {
const failed = { data: undefined, isLoading: false, isError: true, error: new Error("Admin-only endpoint") };
apiMocks.useQuery.mockReturnValue(failed);
const panel = await renderAndOpenBudgetsTab();
expect(panel.getByText("Could not load budgets")).toBeInTheDocument();
expect(panel.getByText("Admin-only endpoint")).toBeInTheDocument();
expect(panel.queryByRole("table")).not.toBeInTheDocument();
});
it("tells the user nothing applies when the server returns no budgets", async () => {
mockBudgets([]);
const panel = await renderAndOpenBudgetsTab();
expect(panel.getByText("No budgets apply to this key.")).toBeInTheDocument();
expect(panel.queryByTestId("key-budget-blocking")).not.toBeInTheDocument();
});
});

View file

@ -37,6 +37,7 @@ import ObjectPermissionsView from "../object_permissions_view";
import { RegenerateKeyModal } from "../organisms/RegenerateKeyModal";
import { parseErrorMessage } from "../shared/errorUtils";
import { InheritedBudgetHint, inheritedBudgetGates } from "../shared/InheritedBudgetHint";
import { KeyBudgetsTable } from "./KeyBudgetsTable";
import { KeyEditView } from "./key_edit_view";
interface KeyInfoViewProps {
@ -603,6 +604,9 @@ export default function KeyInfoView({
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
Overview
</TabsTrigger>
<TabsTrigger value="budgets" className="flex-none rounded-none px-4 py-2">
Budgets
</TabsTrigger>
<TabsTrigger value="settings" className="flex-none rounded-none px-4 py-2">
Settings
</TabsTrigger>
@ -738,6 +742,12 @@ export default function KeyInfoView({
</div>
</TabsContent>
{/* Budgets Panel — deliberately NOT keepMounted: it must stay unmounted until opened so the
budgets request is lazy and its cells cannot collide with the keepMounted Overview panel. */}
<TabsContent value="budgets">
<KeyBudgetsTable keyId={currentKeyData.token_id || currentKeyData.token} />
</TabsContent>
{/* Settings Panel */}
<TabsContent value="settings" keepMounted>
<Card className="block p-6">

View file

@ -6800,6 +6800,69 @@ export interface paths {
patch?: never;
trace?: never;
};
"/key/budgets": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Key Budgets Fn
* @description List every budget that can block requests made with a key, with its live spend.
*
* A `BudgetExceededError` names one entity, but finding out which of the key, its windows, its
* per-model caps, its team, the caller's membership in that team, the owning user, org, project,
* the key's tags, the end user or the proxy-wide limit produced it means reading auth source.
* This returns all of them at once, including the scopes that are left unconfigured, so a scope
* can be ruled out without opening every object.
*
* Parameters:
* - key_id: str | None (path parameter) - The key to inspect. Accepts the plaintext key or its
* hash. Defaults to the key in the Authorization header when omitted (`GET /key/budgets`).
* - end_user_id: str | None (query parameter) - Also report the budgets that would apply to this
* end user. Omitted end users produce no `end_user` rows, because nothing binds an end user to
* a key outside a request.
*
* Returns:
* - key: str - The key that was looked up, echoed back as it was passed in
* - budgets: list - One entry per applicable budget
* - scope: str - `proxy`, `key`, `key_window`, `key_model`, `team`, `team_window`,
* `team_member`, `user`, `organization`, `project`, `tag`, `end_user` or `end_user_model`
* - entity_type: str - The `Litellm_EntityType` a `BudgetExceededError` from this scope
* carries, so a denial message maps back to a row here
* - entity_id / entity_label: str | None - Which entity is limited, and its human-facing alias
* - enforcement: str - `hard` blocks the request, `soft` only raises an alert
* - max_budget: float | None - The limit in effect. `null` means this scope applies to the key
* but places no limit on it
* - spend: float | None - Spend as the enforcing check reads it, from the same cross-pod
* counter, not the periodically-synced database column
* - remaining: float | None - `max_budget - spend`, when both are known
* - comparison: str - The operator the enforcing check uses, which differs per scope
* - budget_duration / budget_reset_at / window_start: When spend next resets to zero
* - source: str - Where the limit is configured, e.g. `key.max_budget`, `budget_table:<id>`
* - status: str - `unlimited`, `ok` or `exceeded`
* - note: str | None - A caveat worth knowing before trusting the row
*
* Example Curl:
* ```
* curl -X GET "http://0.0.0.0:4000/key/sk-test-example-key-123/budgets" -H "Authorization: Bearer sk-1234"
* ```
*
* Example Curl - the budgets on the calling key itself
* ```
* curl -X GET "http://0.0.0.0:4000/key/budgets" -H "Authorization: Bearer sk-test-example-key-123"
* ```
*/
get: operations["key_budgets_fn_key_budgets_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/key/bulk_update": {
parameters: {
query?: never;
@ -7427,6 +7490,69 @@ export interface paths {
patch?: never;
trace?: never;
};
"/key/{key_id}/budgets": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Key Budgets Fn
* @description List every budget that can block requests made with a key, with its live spend.
*
* A `BudgetExceededError` names one entity, but finding out which of the key, its windows, its
* per-model caps, its team, the caller's membership in that team, the owning user, org, project,
* the key's tags, the end user or the proxy-wide limit produced it means reading auth source.
* This returns all of them at once, including the scopes that are left unconfigured, so a scope
* can be ruled out without opening every object.
*
* Parameters:
* - key_id: str | None (path parameter) - The key to inspect. Accepts the plaintext key or its
* hash. Defaults to the key in the Authorization header when omitted (`GET /key/budgets`).
* - end_user_id: str | None (query parameter) - Also report the budgets that would apply to this
* end user. Omitted end users produce no `end_user` rows, because nothing binds an end user to
* a key outside a request.
*
* Returns:
* - key: str - The key that was looked up, echoed back as it was passed in
* - budgets: list - One entry per applicable budget
* - scope: str - `proxy`, `key`, `key_window`, `key_model`, `team`, `team_window`,
* `team_member`, `user`, `organization`, `project`, `tag`, `end_user` or `end_user_model`
* - entity_type: str - The `Litellm_EntityType` a `BudgetExceededError` from this scope
* carries, so a denial message maps back to a row here
* - entity_id / entity_label: str | None - Which entity is limited, and its human-facing alias
* - enforcement: str - `hard` blocks the request, `soft` only raises an alert
* - max_budget: float | None - The limit in effect. `null` means this scope applies to the key
* but places no limit on it
* - spend: float | None - Spend as the enforcing check reads it, from the same cross-pod
* counter, not the periodically-synced database column
* - remaining: float | None - `max_budget - spend`, when both are known
* - comparison: str - The operator the enforcing check uses, which differs per scope
* - budget_duration / budget_reset_at / window_start: When spend next resets to zero
* - source: str - Where the limit is configured, e.g. `key.max_budget`, `budget_table:<id>`
* - status: str - `unlimited`, `ok` or `exceeded`
* - note: str | None - A caveat worth knowing before trusting the row
*
* Example Curl:
* ```
* curl -X GET "http://0.0.0.0:4000/key/sk-test-example-key-123/budgets" -H "Authorization: Bearer sk-1234"
* ```
*
* Example Curl - the budgets on the calling key itself
* ```
* curl -X GET "http://0.0.0.0:4000/key/budgets" -H "Authorization: Bearer sk-test-example-key-123"
* ```
*/
get: operations["key_budgets_fn_key__key_id__budgets_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/key/{key}/regenerate": {
parameters: {
query?: never;
@ -26201,6 +26327,64 @@ export interface components {
/** Updated By */
updated_by?: string | null;
};
/**
* KeyBudgetEntry
* @description One budget that can gate requests made with a key, with its live spend.
*/
KeyBudgetEntry: {
/** Budget Duration */
budget_duration?: string | null;
/** Budget Reset At */
budget_reset_at?: string | null;
/**
* Comparison
* @enum {string}
*/
comparison: ">=" | ">";
/**
* Enforcement
* @enum {string}
*/
enforcement: "hard" | "soft";
/** Entity Id */
entity_id?: string | null;
/** Entity Label */
entity_label?: string | null;
/** Entity Type */
entity_type: string;
/** Max Budget */
max_budget?: number | null;
/** Note */
note?: string | null;
/** Remaining */
remaining?: number | null;
/**
* Scope
* @enum {string}
*/
scope: "proxy" | "key" | "key_window" | "key_model" | "team" | "team_window" | "team_member" | "user" | "organization" | "project" | "tag" | "end_user" | "end_user_model";
/** Source */
source: string;
/** Spend */
spend?: number | null;
/**
* Status
* @enum {string}
*/
status: "unlimited" | "ok" | "exceeded";
/** Window Start */
window_start?: string | null;
};
/**
* KeyBudgetsResponse
* @description Every budget that applies to one key, including the ones left unconfigured.
*/
KeyBudgetsResponse: {
/** Budgets */
budgets: components["schemas"]["KeyBudgetEntry"][];
/** Key */
key?: string | null;
};
/** KeyHealthResponse */
KeyHealthResponse: {
/**
@ -26226,7 +26410,7 @@ export interface components {
* @description Enum for key management routes
* @enum {string}
*/
KeyManagementRoutes: "/key/generate" | "/key/update" | "/key/delete" | "/key/regenerate" | "/key/service-account/generate" | "/key/{key_id}/regenerate" | "/key/block" | "/key/unblock" | "/key/bulk_update" | "/team/key/bulk_update" | "/key/{key_id}/reset_spend" | "/key/access_group_assignment" | "/key/info" | "/key/health" | "/key/list" | "/key/aliases" | "/team/daily/activity" | "/team/daily/activity/aggregated" | "/spend/logs" | "/spend/logs/v2";
KeyManagementRoutes: "/key/generate" | "/key/update" | "/key/delete" | "/key/regenerate" | "/key/service-account/generate" | "/key/{key_id}/regenerate" | "/key/block" | "/key/unblock" | "/key/bulk_update" | "/team/key/bulk_update" | "/key/{key_id}/reset_spend" | "/key/access_group_assignment" | "/key/info" | "/key/{key_id}/budgets" | "/key/budgets" | "/key/health" | "/key/list" | "/key/aliases" | "/team/daily/activity" | "/team/daily/activity/aggregated" | "/spend/logs" | "/spend/logs/v2";
/**
* KeyManagementSystem
* @enum {string}
@ -45777,6 +45961,39 @@ export interface operations {
};
};
};
key_budgets_fn_key_budgets_get: {
parameters: {
query?: {
key_id?: string | null;
/** @description Resolve the budgets that apply to this end user as well. End-user budgets are request-scoped, so they can only be reported for a named end user. */
end_user_id?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KeyBudgetsResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
bulk_update_keys_key_bulk_update_post: {
parameters: {
query?: never;
@ -46189,6 +46406,40 @@ export interface operations {
};
};
};
key_budgets_fn_key__key_id__budgets_get: {
parameters: {
query?: {
/** @description Resolve the budgets that apply to this end user as well. End-user budgets are request-scoped, so they can only be reported for a named end user. */
end_user_id?: string | null;
};
header?: never;
path: {
key_id: string | null;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KeyBudgetsResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
regenerate_key_fn_key__key__regenerate_post: {
parameters: {
query?: never;