@acarmisc/backstage-plugin-litellm 0.23.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
@@ -45,6 +45,28 @@ export interface BudgetSummary {
45
45
  * team falls back to the owner's personal budget.
46
46
  */
47
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[];
48
70
  /**
49
71
  * One-line summary of a `BudgetSummary`, for a collapsed/compact header:
50
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.
@@ -2835,6 +2931,22 @@ function buildBudgetSummary(user, teams, keys, maxKeys = 3) {
2835
2931
  teams: teamLimits
2836
2932
  };
2837
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
+ }
2838
2950
  function budgetHeadline(summary) {
2839
2951
  const all = [
2840
2952
  ...summary.keys,
@@ -2861,9 +2973,11 @@ function budgetTone2(pct) {
2861
2973
  if (pct >= 80) return "warning";
2862
2974
  return "accent";
2863
2975
  }
2976
+ var GAUGE_KINDS;
2864
2977
  var init_budget = __esm({
2865
2978
  "src/budget.ts"() {
2866
2979
  "use strict";
2980
+ GAUGE_KINDS = ["key", "user", "team"];
2867
2981
  }
2868
2982
  });
2869
2983
 
@@ -4379,6 +4493,7 @@ __export(index_exports, {
4379
4493
  DashboardHeader: () => DashboardHeader,
4380
4494
  GenerateKeyDialog: () => GenerateKeyDialog,
4381
4495
  KeysTable: () => KeysTable,
4496
+ LiteLLMBudgetGauges: () => LiteLLMBudgetGauges,
4382
4497
  LiteLLMBudgetWidget: () => LiteLLMBudgetWidget,
4383
4498
  LiteLLMHomeWidget: () => LiteLLMHomeWidget,
4384
4499
  LiteLLMPage: () => LiteLLMPage,
@@ -4533,6 +4648,201 @@ var LiteLLMHomeWidget = ({
4533
4648
 
4534
4649
  // src/index.ts
4535
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
4536
4846
  init_GenerateKeyDialog();
4537
4847
  init_ManageTeamDialog();
4538
4848
  init_api();