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;