@acarmisc/backstage-plugin-litellm 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/budget.d.ts CHANGED
@@ -17,6 +17,12 @@ export interface BudgetLimit {
17
17
  softLimit?: number;
18
18
  /** Share of the cap spent, 0..100+ (unclamped). */
19
19
  pct: number;
20
+ /**
21
+ * True when the backend redacted the dollar amounts (team budget hiding).
22
+ * `budget`/`spend` are then 0 and only `pct` carries the signal — render
23
+ * the meter without `$` figures.
24
+ */
25
+ hidden?: boolean;
20
26
  }
21
27
  /** The budgets that apply to the current user, grouped by enforcement level. */
22
28
  export interface BudgetSummary {
@@ -39,6 +45,28 @@ export interface BudgetSummary {
39
45
  * team falls back to the owner's personal budget.
40
46
  */
41
47
  export declare function buildBudgetSummary(user: UserInfo | null, teams: TeamInfo[], keys: VirtualKey[], maxKeys?: number): BudgetSummary;
48
+ /** One gauge in the condensed homepage card — a single enforcement level. */
49
+ export interface BudgetGauge {
50
+ kind: 'key' | 'user' | 'team';
51
+ /**
52
+ * The limit of this level nearest its cap, or undefined when the user has
53
+ * no cap at this level (render an empty gauge).
54
+ */
55
+ limit?: BudgetLimit;
56
+ /** How many limits apply at this level (1 for a personal budget). */
57
+ count: number;
58
+ }
59
+ /**
60
+ * Collapses a `BudgetSummary` to exactly one gauge per enforcement level —
61
+ * key, user, team — carrying the limit nearest its cap and how many limits
62
+ * that level holds. Every level is always returned, with `limit` undefined
63
+ * when the user has no cap there, so the caller can render a stable row.
64
+ *
65
+ * Pass a summary built with a large `maxKeys` (e.g. `keys.length`) so the
66
+ * nearest key is present even when it falls outside the display cap; a key
67
+ * count hidden by that cap is still reflected in `count`.
68
+ */
69
+ export declare function buildBudgetGauges(summary: BudgetSummary): BudgetGauge[];
42
70
  /**
43
71
  * One-line summary of a `BudgetSummary`, for a collapsed/compact header:
44
72
  * how many concrete limits the user is subject to, and the one closest to
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Condensed budget card for a homepage column: one ring gauge per
3
+ * enforcement level (key → personal → team), each showing the limit nearest
4
+ * its cap. Where the full `LiteLLMBudgetWidget` lists every limit as a
5
+ * spend-vs-cap meter, this trades detail for density — a single row of rings
6
+ * with a label and spend figure under each.
7
+ *
8
+ * Gauges are always rendered in the same order, with an empty ring when the
9
+ * user has no cap at that level, so the card keeps a stable shape as data
10
+ * loads and across users. When a level holds several limits, the ring shows
11
+ * the one closest to its cap and a "+N more" link counts the rest.
12
+ */
13
+ import React from 'react';
14
+ export interface LiteLLMBudgetGaugesProps {
15
+ /** Optional title override. Defaults to 'Budget'. */
16
+ title?: string;
17
+ /** Ring diameter in px. Defaults to 72. */
18
+ size?: number;
19
+ /** Where the "+N more" key link points. Defaults to the Keys tab. */
20
+ keysHref?: string;
21
+ /**
22
+ * Node rendered at the card bottom, below a divider — e.g. a "create key"
23
+ * button.
24
+ */
25
+ action?: React.ReactNode;
26
+ }
27
+ export declare const LiteLLMBudgetGauges: React.FC<LiteLLMBudgetGaugesProps>;
@@ -156,6 +156,26 @@ export declare const Meter: React.FC<{
156
156
  tone?: Tone;
157
157
  height?: number;
158
158
  }>;
159
+ /**
160
+ * Ring gauge for at-a-glance ratios. Hand-drawn SVG rather than MUI's
161
+ * `CircularProgress`: the track has to stay a neutral hairline (MUI tints it
162
+ * from the bar colour, so an empty ring reads as full), and the value may
163
+ * legitimately exceed 100% — the arc clamps at a full turn while the centre
164
+ * label keeps the real number.
165
+ */
166
+ export declare const Gauge: React.FC<{
167
+ /** Share consumed, 0..100+ (unclamped for the label). */
168
+ value: number;
169
+ tone?: Tone;
170
+ /** Outer diameter in px. */
171
+ size?: number;
172
+ /** Ring thickness in px. */
173
+ thickness?: number;
174
+ /** Centre content; defaults to the rounded percentage. */
175
+ label?: React.ReactNode;
176
+ /** Small caption under the value, inside the ring. */
177
+ caption?: React.ReactNode;
178
+ }>;
159
179
  /**
160
180
  * Secondary navigation inside a card. Hand-rolled rather than a second set of
161
181
  * `Tabs` so it reads as subordinate to the page tabs instead of competing
package/dist/index.cjs.js CHANGED
@@ -335,7 +335,7 @@ function SegmentedControl({
335
335
  })
336
336
  );
337
337
  }
338
- var import_react, import_Box, import_ButtonBase, import_Paper, import_Typography, import_styles, MONTHS, fmtDateShort, fmtCompact, fmtUsdCompact, CHART_COLORS, SERIES, OTHER_COLOR, ChartTooltip, SeriesLegend, StatusPill, TagChip, SectionCard, ChartCard, MetricStrip, Stat, Meter, dataTableSx, quietIconButtonSx, EmptyState;
338
+ var import_react, import_Box, import_ButtonBase, import_Paper, import_Typography, import_styles, MONTHS, fmtDateShort, fmtCompact, fmtUsdCompact, CHART_COLORS, SERIES, OTHER_COLOR, ChartTooltip, SeriesLegend, StatusPill, TagChip, SectionCard, ChartCard, MetricStrip, Stat, Meter, Gauge, dataTableSx, quietIconButtonSx, EmptyState;
339
339
  var init_ui = __esm({
340
340
  "src/components/ui.tsx"() {
341
341
  "use strict";
@@ -705,6 +705,102 @@ var init_ui = __esm({
705
705
  }
706
706
  )
707
707
  );
708
+ Gauge = ({ value, tone = "accent", size = 72, thickness = 6, label, caption }) => {
709
+ const clamped = Math.max(0, Math.min(100, value ?? 0));
710
+ const r = (size - thickness) / 2;
711
+ const c = 2 * Math.PI * r;
712
+ const dash = clamped / 100 * c;
713
+ return /* @__PURE__ */ import_react.default.createElement(
714
+ import_Box.default,
715
+ {
716
+ sx: (theme) => ({
717
+ position: "relative",
718
+ width: size,
719
+ height: size,
720
+ flexShrink: 0,
721
+ color: toneColor(theme, tone)
722
+ })
723
+ },
724
+ /* @__PURE__ */ import_react.default.createElement(
725
+ import_Box.default,
726
+ {
727
+ component: "svg",
728
+ viewBox: `0 0 ${size} ${size}`,
729
+ width: size,
730
+ height: size,
731
+ sx: { display: "block", transform: "rotate(-90deg)" }
732
+ },
733
+ /* @__PURE__ */ import_react.default.createElement(
734
+ import_Box.default,
735
+ {
736
+ component: "circle",
737
+ cx: size / 2,
738
+ cy: size / 2,
739
+ r,
740
+ fill: "none",
741
+ strokeWidth: thickness,
742
+ sx: (theme) => ({
743
+ stroke: (0, import_styles.alpha)(
744
+ theme.palette.text.primary,
745
+ theme.palette.mode === "dark" ? 0.14 : 0.09
746
+ )
747
+ })
748
+ }
749
+ ),
750
+ /* @__PURE__ */ import_react.default.createElement(
751
+ import_Box.default,
752
+ {
753
+ component: "circle",
754
+ cx: size / 2,
755
+ cy: size / 2,
756
+ r,
757
+ fill: "none",
758
+ strokeWidth: thickness,
759
+ strokeLinecap: "round",
760
+ strokeDasharray: `${dash} ${c - dash}`,
761
+ sx: (theme) => ({
762
+ stroke: "currentColor",
763
+ transition: theme.transitions.create("stroke-dasharray")
764
+ })
765
+ }
766
+ )
767
+ ),
768
+ /* @__PURE__ */ import_react.default.createElement(
769
+ import_Box.default,
770
+ {
771
+ sx: {
772
+ position: "absolute",
773
+ inset: 0,
774
+ display: "flex",
775
+ flexDirection: "column",
776
+ alignItems: "center",
777
+ justifyContent: "center",
778
+ lineHeight: 1
779
+ }
780
+ },
781
+ /* @__PURE__ */ import_react.default.createElement(
782
+ import_Typography.default,
783
+ {
784
+ sx: {
785
+ fontSize: size <= 64 ? 15 : 18,
786
+ fontWeight: 700,
787
+ fontVariantNumeric: "tabular-nums",
788
+ color: "text.primary"
789
+ }
790
+ },
791
+ label ?? `${Math.round(value)}%`
792
+ ),
793
+ caption && /* @__PURE__ */ import_react.default.createElement(
794
+ import_Typography.default,
795
+ {
796
+ variant: "caption",
797
+ sx: { fontSize: 9.5, color: "text.secondary", mt: 0.25 }
798
+ },
799
+ caption
800
+ )
801
+ )
802
+ );
803
+ };
708
804
  dataTableSx = (theme) => ({
709
805
  // Element selectors, not `.MuiTableCell-head`: host apps may set a MUI
710
806
  // classname prefix (Backstage ships `v5-`), which breaks global class targeting.
@@ -2023,6 +2119,8 @@ var init_ManageTeamDialog = __esm({
2023
2119
  const [mcpError, setMcpError] = (0, import_react5.useState)(null);
2024
2120
  const allowUnlimitedBudget = config?.teamManagement?.allowUnlimitedBudget ?? false;
2025
2121
  const maxBudgetCeiling = config?.teamManagement?.maxBudgetCeiling ?? FALLBACK_BUDGET_CEILING;
2122
+ const hideBudgetForManagers = config?.display?.hideTeamBudgetForManagers ?? false;
2123
+ const budgetWriteOnly = hideBudgetForManagers && mode === "edit";
2026
2124
  const members = (0, import_react5.useMemo)(() => team?.members_with_roles ?? [], [team]);
2027
2125
  const showMembers = mode === "edit" && !!canManageMembers;
2028
2126
  const catalogApi = (0, import_core_plugin_api2.useApi)(import_plugin_catalog_react.catalogApiRef);
@@ -2058,9 +2156,13 @@ var init_ManageTeamDialog = __esm({
2058
2156
  if (mode === "edit" && team) {
2059
2157
  setAlias(team.team_alias ?? "");
2060
2158
  setModels(team.models ?? []);
2061
- setMaxBudget(
2062
- team.max_budget ? String(team.max_budget) : String(maxBudgetCeiling)
2063
- );
2159
+ if (hideBudgetForManagers) {
2160
+ setMaxBudget("");
2161
+ } else {
2162
+ setMaxBudget(
2163
+ team.max_budget ? String(team.max_budget) : String(maxBudgetCeiling)
2164
+ );
2165
+ }
2064
2166
  setUnlimited(allowUnlimitedBudget && !team.max_budget);
2065
2167
  setBudgetDuration(team.budget_duration ?? DEFAULT_BUDGET_DURATION);
2066
2168
  } else {
@@ -2080,7 +2182,7 @@ var init_ManageTeamDialog = __esm({
2080
2182
  setMcpIds(team?.object_permission?.mcp_servers ?? []);
2081
2183
  setMcpError(null);
2082
2184
  }
2083
- }, [open, mode, team, allowUnlimitedBudget, maxBudgetCeiling]);
2185
+ }, [open, mode, team, allowUnlimitedBudget, maxBudgetCeiling, hideBudgetForManagers]);
2084
2186
  const handleSubmit = async () => {
2085
2187
  setError(null);
2086
2188
  if (!alias.trim()) {
@@ -2092,18 +2194,21 @@ var init_ManageTeamDialog = __esm({
2092
2194
  return;
2093
2195
  }
2094
2196
  if (!unlimited) {
2095
- if (!maxBudget) {
2096
- setError("Budget is required when unlimited budgets are disabled");
2097
- return;
2098
- }
2099
- const budget = parseFloat(maxBudget);
2100
- if (isNaN(budget) || budget <= 0) {
2101
- setError("Budget must be a positive number");
2102
- return;
2103
- }
2104
- if (budget > maxBudgetCeiling) {
2105
- setError(`Budget cannot exceed $${maxBudgetCeiling}`);
2106
- return;
2197
+ const budgetLeftBlank = budgetWriteOnly && !maxBudget.trim();
2198
+ if (!budgetLeftBlank) {
2199
+ if (!maxBudget) {
2200
+ setError("Budget is required when unlimited budgets are disabled");
2201
+ return;
2202
+ }
2203
+ const budget = parseFloat(maxBudget);
2204
+ if (isNaN(budget) || budget <= 0) {
2205
+ setError("Budget must be a positive number");
2206
+ return;
2207
+ }
2208
+ if (budget > maxBudgetCeiling) {
2209
+ setError(`Budget cannot exceed $${maxBudgetCeiling}`);
2210
+ return;
2211
+ }
2107
2212
  }
2108
2213
  }
2109
2214
  try {
@@ -2117,10 +2222,16 @@ var init_ManageTeamDialog = __esm({
2117
2222
  ...budgetDuration && { budget_duration: budgetDuration }
2118
2223
  };
2119
2224
  } else {
2225
+ let budgetField = {};
2226
+ if (unlimited) {
2227
+ budgetField = { max_budget: null };
2228
+ } else if (maxBudget.trim()) {
2229
+ budgetField = { max_budget: parseFloat(maxBudget) };
2230
+ }
2120
2231
  payload = {
2121
2232
  team_alias: alias.trim(),
2122
2233
  models,
2123
- ...unlimited ? { max_budget: null } : { max_budget: parseFloat(maxBudget) },
2234
+ ...budgetField,
2124
2235
  ...budgetDuration && { budget_duration: budgetDuration }
2125
2236
  };
2126
2237
  }
@@ -2226,13 +2337,13 @@ var init_ManageTeamDialog = __esm({
2226
2337
  ), /* @__PURE__ */ import_react5.default.createElement(
2227
2338
  import_TextField3.default,
2228
2339
  {
2229
- label: "Max Budget ($)",
2340
+ label: budgetWriteOnly ? "New Max Budget ($)" : "Max Budget ($)",
2230
2341
  type: "number",
2231
2342
  value: maxBudget,
2232
2343
  onChange: (e) => setMaxBudget(e.target.value),
2233
2344
  disabled: submitting || unlimited,
2234
2345
  inputProps: { min: 0, max: maxBudgetCeiling },
2235
- helperText: `Maximum: $${maxBudgetCeiling}`,
2346
+ helperText: budgetWriteOnly ? "Current budget is hidden \u2014 leave blank to keep it, or enter a new value" : `Maximum: $${maxBudgetCeiling}`,
2236
2347
  fullWidth: true
2237
2348
  }
2238
2349
  ), allowUnlimitedBudget && /* @__PURE__ */ import_react5.default.createElement(
@@ -2788,7 +2899,19 @@ function buildBudgetSummary(user, teams, keys, maxKeys = 3) {
2788
2899
  pct: limitPct(spend, budget)
2789
2900
  };
2790
2901
  })() : void 0;
2791
- const teamLimits = teams.filter((t) => (t.max_budget ?? 0) > 0).map((t) => {
2902
+ const teamLimits = teams.filter((t) => (t.max_budget ?? 0) > 0 || t.budget_hidden === true).map((t) => {
2903
+ if (t.budget_hidden === true) {
2904
+ return {
2905
+ kind: "team",
2906
+ label: t.team_alias || "Untitled team",
2907
+ sublabel: t.team_id,
2908
+ budget: 0,
2909
+ spend: 0,
2910
+ budgetDuration: t.budget_duration,
2911
+ pct: Math.min(100, Math.max(0, t.budget_pct ?? 0)),
2912
+ hidden: true
2913
+ };
2914
+ }
2792
2915
  const spend = t.spend ?? 0;
2793
2916
  return {
2794
2917
  kind: "team",
@@ -2808,6 +2931,22 @@ function buildBudgetSummary(user, teams, keys, maxKeys = 3) {
2808
2931
  teams: teamLimits
2809
2932
  };
2810
2933
  }
2934
+ function buildBudgetGauges(summary) {
2935
+ const byLevel = {
2936
+ key: summary.keys,
2937
+ user: summary.user ? [summary.user] : [],
2938
+ team: summary.teams
2939
+ };
2940
+ return GAUGE_KINDS.map((kind) => {
2941
+ const limits = byLevel[kind];
2942
+ const count = kind === "key" ? limits.length + summary.hiddenBudgetedKeys : limits.length;
2943
+ return {
2944
+ kind,
2945
+ count,
2946
+ limit: limits.length ? limits.reduce((a, b) => b.pct > a.pct ? b : a) : void 0
2947
+ };
2948
+ });
2949
+ }
2811
2950
  function budgetHeadline(summary) {
2812
2951
  const all = [
2813
2952
  ...summary.keys,
@@ -2834,9 +2973,11 @@ function budgetTone2(pct) {
2834
2973
  if (pct >= 80) return "warning";
2835
2974
  return "accent";
2836
2975
  }
2976
+ var GAUGE_KINDS;
2837
2977
  var init_budget = __esm({
2838
2978
  "src/budget.ts"() {
2839
2979
  "use strict";
2980
+ GAUGE_KINDS = ["key", "user", "team"];
2840
2981
  }
2841
2982
  });
2842
2983
 
@@ -2964,7 +3105,7 @@ var init_LiteLLMBudgetWidget = __esm({
2964
3105
  }
2965
3106
  },
2966
3107
  limit.sublabel
2967
- )), /* @__PURE__ */ import_react7.default.createElement(import_Typography7.default, { variant: "body2", sx: { fontWeight: 600, fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" } }, fmtUsd(limit.spend), /* @__PURE__ */ import_react7.default.createElement(import_Box7.default, { component: "span", color: "text.secondary", sx: { fontWeight: 400 } }, " / ", fmtUsd(limit.budget)))), /* @__PURE__ */ import_react7.default.createElement(import_Box7.default, { sx: { mt: 1 } }, /* @__PURE__ */ import_react7.default.createElement(Meter, { value: limit.pct, tone, height: 6 })), /* @__PURE__ */ import_react7.default.createElement(
3108
+ )), limit.hidden ? /* @__PURE__ */ import_react7.default.createElement(import_Typography7.default, { variant: "body2", sx: { fontWeight: 600, fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" } }, pct, "% used") : /* @__PURE__ */ import_react7.default.createElement(import_Typography7.default, { variant: "body2", sx: { fontWeight: 600, fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" } }, fmtUsd(limit.spend), /* @__PURE__ */ import_react7.default.createElement(import_Box7.default, { component: "span", color: "text.secondary", sx: { fontWeight: 400 } }, " / ", fmtUsd(limit.budget)))), /* @__PURE__ */ import_react7.default.createElement(import_Box7.default, { sx: { mt: 1 } }, /* @__PURE__ */ import_react7.default.createElement(Meter, { value: limit.pct, tone, height: 6 })), /* @__PURE__ */ import_react7.default.createElement(
2968
3109
  import_Box7.default,
2969
3110
  {
2970
3111
  sx: {
@@ -2975,7 +3116,7 @@ var init_LiteLLMBudgetWidget = __esm({
2975
3116
  mt: 0.75
2976
3117
  }
2977
3118
  },
2978
- /* @__PURE__ */ import_react7.default.createElement(import_Typography7.default, { variant: "caption", color: "text.secondary", sx: { fontVariantNumeric: "tabular-nums" } }, pct, "% of budget", closeTo && ` \xB7 soft-limit warning`),
3119
+ /* @__PURE__ */ import_react7.default.createElement(import_Typography7.default, { variant: "caption", color: "text.secondary", sx: { fontVariantNumeric: "tabular-nums" } }, limit.hidden ? /* @__PURE__ */ import_react7.default.createElement(import_react7.default.Fragment, null, "Hidden by admin") : /* @__PURE__ */ import_react7.default.createElement(import_react7.default.Fragment, null, pct, "% of budget", closeTo && ` \xB7 soft-limit warning`)),
2979
3120
  /* @__PURE__ */ import_react7.default.createElement(import_Typography7.default, { variant: "caption", color: "text.secondary" }, fmtBudgetDuration(limit.budgetDuration) ? `resets ${fmtBudgetDuration(limit.budgetDuration)}` : "never resets")
2980
3121
  ));
2981
3122
  };
@@ -3156,6 +3297,15 @@ function budgetTone3(isOver, isNear) {
3156
3297
  if (isNear) return "warning";
3157
3298
  return "accent";
3158
3299
  }
3300
+ function visibleBudgetPct(spend, budget) {
3301
+ if (budget <= 0) return 0;
3302
+ return Math.min(spend / budget * 100, 100);
3303
+ }
3304
+ function budgetStatValue(hidden, budget) {
3305
+ if (hidden) return "Hidden";
3306
+ if (budget > 0) return fmtUsd2(budget);
3307
+ return "Unlimited";
3308
+ }
3159
3309
  var import_react8, import_Paper6, import_Box8, import_Typography8, import_Skeleton4, import_Divider3, import_Table4, import_TableBody4, import_TableCell4, import_TableHead4, import_TableRow4, import_TableContainer4, import_Collapse2, import_IconButton5, import_Button5, import_CircularProgress5, import_styles5, import_icons_material6, import_recharts2, fmtUsd2, TeamCard, TeamUsage;
3160
3310
  var init_TeamUsage = __esm({
3161
3311
  "src/components/TeamUsage.tsx"() {
@@ -3184,13 +3334,15 @@ var init_TeamUsage = __esm({
3184
3334
  TeamCard = ({ team, usage, usageLoading, canManage, onEditTeam }) => {
3185
3335
  const [expanded, setExpanded] = (0, import_react8.useState)(false);
3186
3336
  const chart = useChartTheme();
3187
- const budget = team.max_budget ?? 0;
3188
- const spend = team.spend ?? 0;
3189
- const budgetPct = budget > 0 ? Math.min(spend / budget * 100, 100) : 0;
3190
- const isOver = budget > 0 && spend >= budget;
3191
- const isNear = budget > 0 && spend >= budget * 0.8 && !isOver;
3337
+ const hidden = team.budget_hidden === true;
3338
+ const budget = hidden ? 0 : team.max_budget ?? 0;
3339
+ const spend = hidden ? 0 : team.spend ?? 0;
3340
+ const budgetPct = hidden ? Math.min(100, Math.max(0, team.budget_pct ?? 0)) : visibleBudgetPct(spend, budget);
3341
+ const isOver = hidden ? team.budget_status === "over" : budget > 0 && spend >= budget;
3342
+ const isNear = hidden ? team.budget_status === "near" : budget > 0 && spend >= budget * 0.8 && !isOver;
3192
3343
  const dailyData = usage?.daily_usage?.map((d) => ({ date: d.date, spend: d.spend })) ?? [];
3193
3344
  const renderDailySpendSection = () => {
3345
+ if (hidden) return null;
3194
3346
  if (usageLoading) {
3195
3347
  return /* @__PURE__ */ import_react8.default.createElement(import_Box8.default, { display: "flex", justifyContent: "center", py: 3 }, /* @__PURE__ */ import_react8.default.createElement(import_CircularProgress5.default, { size: 22 }));
3196
3348
  }
@@ -3291,11 +3443,11 @@ var init_TeamUsage = __esm({
3291
3443
  },
3292
3444
  /* @__PURE__ */ import_react8.default.createElement(Stat, { label: "Members", value: team.members_with_roles?.length ?? "\u2014" }),
3293
3445
  /* @__PURE__ */ import_react8.default.createElement(Stat, { label: "Models", value: team.models?.length ? team.models.length : "All" }),
3294
- /* @__PURE__ */ import_react8.default.createElement(Stat, { label: "Budget", value: budget > 0 ? fmtUsd2(budget) : "Unlimited" }),
3295
- /* @__PURE__ */ import_react8.default.createElement(Stat, { label: "Spend", value: fmtUsd2(spend) }),
3446
+ /* @__PURE__ */ import_react8.default.createElement(Stat, { label: "Budget", value: budgetStatValue(hidden, budget) }),
3447
+ /* @__PURE__ */ import_react8.default.createElement(Stat, { label: "Spend", value: hidden ? "\u2014" : fmtUsd2(spend) }),
3296
3448
  /* @__PURE__ */ import_react8.default.createElement(Stat, { label: "TPM", value: team.tpm_limit && team.tpm_limit > 0 ? team.tpm_limit.toLocaleString() : "\u2014" }),
3297
3449
  /* @__PURE__ */ import_react8.default.createElement(Stat, { label: "RPM", value: team.rpm_limit && team.rpm_limit > 0 ? team.rpm_limit.toLocaleString() : "\u2014" })
3298
- ), budget > 0 && /* @__PURE__ */ import_react8.default.createElement(import_Box8.default, { mt: 2, maxWidth: 640 }, /* @__PURE__ */ import_react8.default.createElement(import_Box8.default, { display: "flex", justifyContent: "space-between", alignItems: "baseline", mb: 0.75 }, /* @__PURE__ */ import_react8.default.createElement(import_Typography8.default, { variant: "caption", color: "text.secondary", sx: { fontVariantNumeric: "tabular-nums" } }, fmtUsd2(spend), " of ", fmtUsd2(budget)), /* @__PURE__ */ import_react8.default.createElement(import_Typography8.default, { variant: "caption", color: "text.secondary", sx: { fontVariantNumeric: "tabular-nums" } }, budgetPct.toFixed(0), "%")), /* @__PURE__ */ import_react8.default.createElement(Meter, { value: budgetPct, tone: budgetTone3(isOver, isNear), height: 5 })), /* @__PURE__ */ import_react8.default.createElement(import_Collapse2.default, { in: expanded, unmountOnExit: true }, /* @__PURE__ */ import_react8.default.createElement(import_Divider3.default, { sx: { my: 2.5 } }), /* @__PURE__ */ import_react8.default.createElement(import_Box8.default, { mb: 2.5 }, sectionLabel("Models"), team.models?.length ? /* @__PURE__ */ import_react8.default.createElement(import_Box8.default, { display: "flex", gap: 0.5, flexWrap: "wrap" }, team.models.map((m) => /* @__PURE__ */ import_react8.default.createElement(TagChip, { key: m, label: m, title: m }))) : /* @__PURE__ */ import_react8.default.createElement(import_Typography8.default, { variant: "body2", color: "text.secondary" }, "All models allowed")), renderDailySpendSection(), /* @__PURE__ */ import_react8.default.createElement(import_Box8.default, null, sectionLabel("Members"), team.members_with_roles?.length ? /* @__PURE__ */ import_react8.default.createElement(
3450
+ ), hidden && /* @__PURE__ */ import_react8.default.createElement(import_Box8.default, { mt: 2, maxWidth: 640 }, /* @__PURE__ */ import_react8.default.createElement(import_Box8.default, { display: "flex", justifyContent: "space-between", alignItems: "baseline", mb: 0.75 }, /* @__PURE__ */ import_react8.default.createElement(import_Typography8.default, { variant: "caption", color: "text.secondary", sx: { fontVariantNumeric: "tabular-nums" } }, budgetPct.toFixed(0), "% used"), /* @__PURE__ */ import_react8.default.createElement(import_Typography8.default, { variant: "caption", color: "text.secondary" }, "Hidden by admin")), /* @__PURE__ */ import_react8.default.createElement(Meter, { value: budgetPct, tone: budgetTone3(isOver, isNear), height: 5 }), /* @__PURE__ */ import_react8.default.createElement(import_Typography8.default, { variant: "caption", color: "text.secondary", sx: { display: "block", mt: 0.75 } }, "Team budget is hidden \u2014 showing usage level only", team.budget_duration ? ` \xB7 resets every ${team.budget_duration}` : "", ".")), !hidden && budget > 0 && /* @__PURE__ */ import_react8.default.createElement(import_Box8.default, { mt: 2, maxWidth: 640 }, /* @__PURE__ */ import_react8.default.createElement(import_Box8.default, { display: "flex", justifyContent: "space-between", alignItems: "baseline", mb: 0.75 }, /* @__PURE__ */ import_react8.default.createElement(import_Typography8.default, { variant: "caption", color: "text.secondary", sx: { fontVariantNumeric: "tabular-nums" } }, fmtUsd2(spend), " of ", fmtUsd2(budget)), /* @__PURE__ */ import_react8.default.createElement(import_Typography8.default, { variant: "caption", color: "text.secondary", sx: { fontVariantNumeric: "tabular-nums" } }, budgetPct.toFixed(0), "%")), /* @__PURE__ */ import_react8.default.createElement(Meter, { value: budgetPct, tone: budgetTone3(isOver, isNear), height: 5 })), /* @__PURE__ */ import_react8.default.createElement(import_Collapse2.default, { in: expanded, unmountOnExit: true }, /* @__PURE__ */ import_react8.default.createElement(import_Divider3.default, { sx: { my: 2.5 } }), /* @__PURE__ */ import_react8.default.createElement(import_Box8.default, { mb: 2.5 }, sectionLabel("Models"), team.models?.length ? /* @__PURE__ */ import_react8.default.createElement(import_Box8.default, { display: "flex", gap: 0.5, flexWrap: "wrap" }, team.models.map((m) => /* @__PURE__ */ import_react8.default.createElement(TagChip, { key: m, label: m, title: m }))) : /* @__PURE__ */ import_react8.default.createElement(import_Typography8.default, { variant: "body2", color: "text.secondary" }, "All models allowed")), renderDailySpendSection(), /* @__PURE__ */ import_react8.default.createElement(import_Box8.default, null, sectionLabel("Members"), team.members_with_roles?.length ? /* @__PURE__ */ import_react8.default.createElement(
3299
3451
  import_TableContainer4.default,
3300
3452
  {
3301
3453
  component: import_Paper6.default,
@@ -4341,6 +4493,7 @@ __export(index_exports, {
4341
4493
  DashboardHeader: () => DashboardHeader,
4342
4494
  GenerateKeyDialog: () => GenerateKeyDialog,
4343
4495
  KeysTable: () => KeysTable,
4496
+ LiteLLMBudgetGauges: () => LiteLLMBudgetGauges,
4344
4497
  LiteLLMBudgetWidget: () => LiteLLMBudgetWidget,
4345
4498
  LiteLLMHomeWidget: () => LiteLLMHomeWidget,
4346
4499
  LiteLLMPage: () => LiteLLMPage,
@@ -4495,6 +4648,201 @@ var LiteLLMHomeWidget = ({
4495
4648
 
4496
4649
  // src/index.ts
4497
4650
  init_LiteLLMBudgetWidget();
4651
+
4652
+ // src/components/LiteLLMBudgetGauges.tsx
4653
+ var import_react14 = __toESM(require("react"));
4654
+ var import_Paper9 = __toESM(require("@mui/material/Paper"));
4655
+ var import_Box13 = __toESM(require("@mui/material/Box"));
4656
+ var import_Typography13 = __toESM(require("@mui/material/Typography"));
4657
+ var import_CircularProgress8 = __toESM(require("@mui/material/CircularProgress"));
4658
+ var import_Alert8 = __toESM(require("@mui/material/Alert"));
4659
+ var import_core_plugin_api6 = require("@backstage/core-plugin-api");
4660
+ var import_core_components2 = require("@backstage/core-components");
4661
+ init_api();
4662
+ init_format();
4663
+ init_ui();
4664
+ init_budget();
4665
+ var DEFAULT_KEYS_HREF = "/litellm?tab=keys";
4666
+ var LEVEL = {
4667
+ key: { name: "Key", none: "No key budget" },
4668
+ user: { name: "User", none: "No personal budget" },
4669
+ team: { name: "Team", none: "No team budget" }
4670
+ };
4671
+ function fmtUsdShort(n) {
4672
+ const v = n ?? 0;
4673
+ if (v >= 100 && Number.isInteger(v)) return `$${v}`;
4674
+ return fmtUsd(v);
4675
+ }
4676
+ function spendCaption(limit) {
4677
+ if (limit.hidden) return "hidden by admin";
4678
+ return `${fmtUsdShort(limit.spend)} / ${fmtUsdShort(limit.budget)}`;
4679
+ }
4680
+ function resetLabel(limit) {
4681
+ const window2 = fmtBudgetDuration(limit.budgetDuration);
4682
+ return window2 ? `resets ${window2}` : "never resets";
4683
+ }
4684
+ var LevelGauge = ({
4685
+ gauge,
4686
+ size,
4687
+ keysHref
4688
+ }) => {
4689
+ const { name, none } = LEVEL[gauge.kind];
4690
+ const limit = gauge.limit;
4691
+ const tone = limit ? budgetTone2(limit.pct) : "neutral";
4692
+ const extra = gauge.kind === "key" ? Math.max(0, gauge.count - 1) : 0;
4693
+ return /* @__PURE__ */ import_react14.default.createElement(
4694
+ import_Box13.default,
4695
+ {
4696
+ sx: {
4697
+ display: "flex",
4698
+ flexDirection: "column",
4699
+ alignItems: "center",
4700
+ minWidth: 0,
4701
+ flex: 1
4702
+ }
4703
+ },
4704
+ /* @__PURE__ */ import_react14.default.createElement(
4705
+ Gauge,
4706
+ {
4707
+ value: limit?.pct ?? 0,
4708
+ tone,
4709
+ size,
4710
+ label: limit ? `${Math.round(limit.pct)}%` : "\u2014"
4711
+ }
4712
+ ),
4713
+ /* @__PURE__ */ import_react14.default.createElement(
4714
+ import_Typography13.default,
4715
+ {
4716
+ sx: (theme) => ({
4717
+ mt: 0.75,
4718
+ fontSize: 10.5,
4719
+ fontWeight: 700,
4720
+ letterSpacing: "0.08em",
4721
+ textTransform: "uppercase",
4722
+ color: tone === "neutral" ? theme.palette.text.secondary : void 0
4723
+ })
4724
+ },
4725
+ name
4726
+ ),
4727
+ limit ? /* @__PURE__ */ import_react14.default.createElement(import_react14.default.Fragment, null, /* @__PURE__ */ import_react14.default.createElement(
4728
+ import_Typography13.default,
4729
+ {
4730
+ variant: "caption",
4731
+ title: limit.sublabel ? `${limit.label} \xB7 ${limit.sublabel}` : limit.label,
4732
+ sx: {
4733
+ mt: 0.25,
4734
+ fontSize: 11,
4735
+ fontWeight: 600,
4736
+ maxWidth: "100%",
4737
+ overflow: "hidden",
4738
+ textOverflow: "ellipsis",
4739
+ whiteSpace: "nowrap"
4740
+ }
4741
+ },
4742
+ limit.label
4743
+ ), /* @__PURE__ */ import_react14.default.createElement(
4744
+ import_Typography13.default,
4745
+ {
4746
+ variant: "caption",
4747
+ color: "text.secondary",
4748
+ sx: {
4749
+ fontSize: 10.5,
4750
+ fontVariantNumeric: "tabular-nums",
4751
+ textAlign: "center"
4752
+ }
4753
+ },
4754
+ spendCaption(limit)
4755
+ ), extra > 0 ? /* @__PURE__ */ import_react14.default.createElement(
4756
+ import_Typography13.default,
4757
+ {
4758
+ component: import_core_components2.Link,
4759
+ to: keysHref,
4760
+ variant: "caption",
4761
+ sx: {
4762
+ fontSize: 10.5,
4763
+ color: "primary.main",
4764
+ textDecoration: "none",
4765
+ "&:hover": { textDecoration: "underline" }
4766
+ }
4767
+ },
4768
+ "+",
4769
+ extra,
4770
+ " more"
4771
+ ) : /* @__PURE__ */ import_react14.default.createElement(import_Typography13.default, { variant: "caption", color: "text.secondary", sx: { fontSize: 10.5 } }, resetLabel(limit))) : /* @__PURE__ */ import_react14.default.createElement(
4772
+ import_Typography13.default,
4773
+ {
4774
+ variant: "caption",
4775
+ color: "text.secondary",
4776
+ sx: { mt: 0.25, fontSize: 10.5, textAlign: "center" }
4777
+ },
4778
+ none
4779
+ )
4780
+ );
4781
+ };
4782
+ var LiteLLMBudgetGauges = ({
4783
+ title = "Budget",
4784
+ size = 72,
4785
+ keysHref = DEFAULT_KEYS_HREF,
4786
+ action
4787
+ }) => {
4788
+ const api = (0, import_core_plugin_api6.useApi)(liteLlmApiRef);
4789
+ const [loading, setLoading] = (0, import_react14.useState)(true);
4790
+ const [error, setError] = (0, import_react14.useState)(null);
4791
+ const [user, setUser] = (0, import_react14.useState)(null);
4792
+ const [teams, setTeams] = (0, import_react14.useState)([]);
4793
+ const [keys, setKeys] = (0, import_react14.useState)([]);
4794
+ (0, import_react14.useEffect)(() => {
4795
+ let cancelled = false;
4796
+ setLoading(true);
4797
+ setError(null);
4798
+ Promise.allSettled([api.getUserInfo(), api.getTeams(), api.listKeys()]).then(
4799
+ ([userResult, teamsResult, keysResult]) => {
4800
+ if (cancelled) return;
4801
+ if (userResult.status === "fulfilled") {
4802
+ setUser(userResult.value);
4803
+ } else {
4804
+ setUser(null);
4805
+ setError(
4806
+ userResult.reason?.message ?? "Failed to load your LiteLLM profile"
4807
+ );
4808
+ }
4809
+ setTeams(teamsResult.status === "fulfilled" ? teamsResult.value : []);
4810
+ setKeys(keysResult.status === "fulfilled" ? keysResult.value : []);
4811
+ setLoading(false);
4812
+ }
4813
+ );
4814
+ return () => {
4815
+ cancelled = true;
4816
+ };
4817
+ }, [api]);
4818
+ const summary = (0, import_react14.useMemo)(
4819
+ () => buildBudgetSummary(user, teams, keys, keys.length),
4820
+ [user, teams, keys]
4821
+ );
4822
+ const gauges = (0, import_react14.useMemo)(() => buildBudgetGauges(summary), [summary]);
4823
+ const headline = (0, import_react14.useMemo)(() => budgetHeadline(summary), [summary]);
4824
+ const summaryText = headline.count === 0 ? "no limits apply" : `${headline.count} limit${headline.count === 1 ? "" : "s"}`;
4825
+ return /* @__PURE__ */ import_react14.default.createElement(import_Paper9.default, { sx: { p: 2 } }, /* @__PURE__ */ import_react14.default.createElement(import_Box13.default, { sx: { display: "flex", alignItems: "center", gap: 1, mb: 1.5, minWidth: 0 } }, /* @__PURE__ */ import_react14.default.createElement(import_Typography13.default, { variant: "h6", lineHeight: 1.2, sx: { minWidth: 0, flex: 1 } }, title), !loading && !error && /* @__PURE__ */ import_react14.default.createElement(
4826
+ StatusPill,
4827
+ {
4828
+ label: summaryText,
4829
+ tone: budgetTone2(headline.closest?.pct ?? 0),
4830
+ dot: headline.count > 0
4831
+ }
4832
+ )), loading && /* @__PURE__ */ import_react14.default.createElement(import_Box13.default, { display: "flex", justifyContent: "center", alignItems: "center", minHeight: 110 }, /* @__PURE__ */ import_react14.default.createElement(import_CircularProgress8.default, { size: 28 })), !loading && error && /* @__PURE__ */ import_react14.default.createElement(import_Alert8.default, { severity: "error", sx: { mt: 0.5 } }, error), !loading && !error && /* @__PURE__ */ import_react14.default.createElement(import_Box13.default, { sx: { display: "flex", alignItems: "flex-start", gap: 1 } }, gauges.map((gauge) => /* @__PURE__ */ import_react14.default.createElement(LevelGauge, { key: gauge.kind, gauge, size, keysHref }))), action && /* @__PURE__ */ import_react14.default.createElement(
4833
+ import_Box13.default,
4834
+ {
4835
+ sx: (theme) => ({
4836
+ mt: 2,
4837
+ pt: 2,
4838
+ borderTop: `1px solid ${theme.palette.divider}`
4839
+ })
4840
+ },
4841
+ action
4842
+ ));
4843
+ };
4844
+
4845
+ // src/index.ts
4498
4846
  init_GenerateKeyDialog();
4499
4847
  init_ManageTeamDialog();
4500
4848
  init_api();