From 5b573c552de13b6d3e33bc81c48c87c55908b721 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 19 Aug 2026 15:00:12 -0700 Subject: [PATCH] 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. --- .../(dashboard)/hooks/keys/useKeyBudgets.ts | 16 + .../components/shared/InheritedBudgetHint.tsx | 5 +- .../components/templates/KeyBudgetsTable.tsx | 48 +++ .../templates/KeyBudgetsTableColumns.tsx | 161 +++++++++ .../key_info_view.budgets_tab.test.tsx | 325 ++++++++++++++++++ .../components/templates/key_info_view.tsx | 10 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 253 +++++++++++++- 7 files changed, 816 insertions(+), 2 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyBudgets.ts create mode 100644 ui/litellm-dashboard/src/components/templates/KeyBudgetsTable.tsx create mode 100644 ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx create mode 100644 ui/litellm-dashboard/src/components/templates/key_info_view.budgets_tab.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyBudgets.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyBudgets.ts new file mode 100644 index 00000000000..8620fead52e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyBudgets.ts @@ -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) }, + ); +}; diff --git a/ui/litellm-dashboard/src/components/shared/InheritedBudgetHint.tsx b/ui/litellm-dashboard/src/components/shared/InheritedBudgetHint.tsx index 5fc80758cfd..4040524899a 100644 --- a/ui/litellm-dashboard/src/components/shared/InheritedBudgetHint.tsx +++ b/ui/litellm-dashboard/src/components/shared/InheritedBudgetHint.tsx @@ -56,10 +56,13 @@ export function InheritedBudgetHint({ gates }: InheritedBudgetHintProps) { - This key has no budget of its own, but its spend still counts toward: + This key has no budget of its own. Its spend also counts toward: {gates.map((gate) => ( {formatGate(gate)} ))} + + Not the full list. The key’s Budgets tab shows every budget that can block it, with live spend. + } /> diff --git a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTable.tsx b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTable.tsx new file mode 100644 index 00000000000..f031f5d90e5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTable.tsx @@ -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 ( + `${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 ( +
+

+ Every budget that can block this key, with the live spend each one is measured against. +

+ {isError ? ( + + Could not load budgets + {parseErrorMessage(error)} + + ) : ( + + )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx new file mode 100644 index 00000000000..e52d3c6d01f --- /dev/null +++ b/ui/litellm-dashboard/src/components/templates/KeyBudgetsTableColumns.tsx @@ -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 = { + 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 = { 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 ( +
+ + {SCOPE_LABELS[entry.scope] ?? entry.scope} + + } + /> + {entity && {entity}} + {entry.note && ( + + {entry.note} + + )} +
+ ); +} + +function EnforcementCell({ entry }: { entry: KeyBudgetEntry }) { + return isAlertOnly(entry) ? ( + + ) : ( + + ); +} + +function RemainingCell({ entry }: { entry: KeyBudgetEntry }) { + const unlimited = entry.max_budget == null; + return ( + + ); +} + +export const getKeyBudgetsTableColumns = (): ColumnDef[] => [ + { + id: "scope", + meta: { title: "Scope" }, + header: "Scope", + size: 240, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "enforcement", + meta: { title: "Enforcement" }, + header: "Enforcement", + size: 150, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "spend", + meta: { title: "Spend / Limit" }, + header: "Spend / Limit", + size: 200, + enableSorting: false, + cell: ({ row }) => ( + + ), + }, + { + id: "remaining", + meta: { title: "Remaining", className: "text-right", headerClassName: "text-right" }, + header: "Remaining", + size: 120, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "status", + meta: { title: "Status" }, + header: "Status", + size: 170, + enableSorting: false, + cell: ({ row }) => { + const { tone, label } = statusPresentation(row.original); + return ( + + ); + }, + }, + { + id: "resets", + meta: { title: "Resets" }, + header: "Resets", + size: 150, + enableSorting: false, + cell: ({ row }) => ( +
+ + {row.original.budget_duration && ( + Every {row.original.budget_duration} + )} +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.budgets_tab.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.budgets_tab.test.tsx new file mode 100644 index 00000000000..91d812bbf24 --- /dev/null +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.budgets_tab.test.tsx @@ -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: () =>
, +})); + +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( + {}} + 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, 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index e33ae8323f5..e0c74dcf6c4 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -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({ Overview + + Budgets + Settings @@ -738,6 +742,12 @@ export default function KeyInfoView({
+ {/* 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. */} + + + + {/* Settings Panel */} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 4c63586d4d6..85ee07b8dd5 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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:` + * - 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:` + * - 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;