@acarmisc/backstage-plugin-litellm 0.5.0 → 0.7.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/index.esm.js CHANGED
@@ -15,6 +15,14 @@ var __export = (target, all) => {
15
15
 
16
16
  // src/api.ts
17
17
  import { createApiRef } from "@backstage/core-plugin-api";
18
+ function expiryStatus(expiresAt) {
19
+ if (!expiresAt) return null;
20
+ const diff = new Date(expiresAt).getTime() - Date.now();
21
+ const days = diff / (1e3 * 60 * 60 * 24);
22
+ if (days < 0) return "expired";
23
+ if (days < 7) return "soon";
24
+ return "ok";
25
+ }
18
26
  var ApiError, liteLlmApiRef, LiteLlmApi;
19
27
  var init_api = __esm({
20
28
  "src/api.ts"() {
@@ -100,6 +108,19 @@ var init_api = __esm({
100
108
  async resetKeySpend(keyId) {
101
109
  await this.post(`/keys/${encodeURIComponent(keyId)}/reset_spend`, {});
102
110
  }
111
+ async pruneExpiredKeys() {
112
+ const keys = await this.get("/keys");
113
+ const expired = keys.filter((k) => expiryStatus(k.expires_at) === "expired");
114
+ if (expired.length === 0) return { pruned: 0 };
115
+ for (const key of expired) {
116
+ try {
117
+ await this.del(`/keys/${encodeURIComponent(key.token ?? key.key)}`);
118
+ } catch (err) {
119
+ console.warn(`Failed to delete expired key ${key.token ?? key.key}:`, err);
120
+ }
121
+ }
122
+ return { pruned: expired.length };
123
+ }
103
124
  async getAuditLogs(params) {
104
125
  const strParams = {};
105
126
  if (params.page !== void 0) strParams.page = String(params.page);
@@ -183,14 +204,6 @@ import {
183
204
  InputAdornment
184
205
  } from "@mui/material";
185
206
  import { ContentCopy, Delete, Add, Edit, Autorenew, Search, Warning, Lock, LockOpen } from "@mui/icons-material";
186
- function expiryStatus(expiresAt) {
187
- if (!expiresAt) return null;
188
- const diff = new Date(expiresAt).getTime() - Date.now();
189
- const days = diff / (1e3 * 60 * 60 * 24);
190
- if (days < 0) return "expired";
191
- if (days < 7) return "soon";
192
- return "ok";
193
- }
194
207
  function ExpiryChip({ expiresAt }) {
195
208
  const status = expiryStatus(expiresAt);
196
209
  if (!status) return /* @__PURE__ */ React2.createElement(Typography2, { variant: "body2", color: "text.secondary" }, "-");
@@ -214,6 +227,7 @@ var maskKey, shortKeyId, formatDate, emptyForm, keyToEditForm, KeysTable;
214
227
  var init_KeysTable = __esm({
215
228
  "src/components/KeysTable.tsx"() {
216
229
  "use strict";
230
+ init_api();
217
231
  maskKey = (key) => {
218
232
  if (key.length <= 8) return "***";
219
233
  return `${key.slice(0, 4)}...${key.slice(-4)}`;
@@ -258,7 +272,8 @@ var init_KeysTable = __esm({
258
272
  onBlockKey,
259
273
  onUnblockKey,
260
274
  onResetKeySpend,
261
- onDeleteKey
275
+ onDeleteKey,
276
+ onPruneExpiredKeys
262
277
  }) => {
263
278
  const [generateModalOpen, setGenerateModalOpen] = useState(false);
264
279
  const [newKeyValue, setNewKeyValue] = useState(null);
@@ -371,6 +386,23 @@ var init_KeysTable = __esm({
371
386
  const copyToClipboard = (text) => {
372
387
  navigator.clipboard.writeText(text);
373
388
  };
389
+ const [pruneConfirmCount, setPruneConfirmCount] = useState(null);
390
+ const [pruneSubmitting, setPruneSubmitting] = useState(false);
391
+ const expiredKeys = useMemo(() => {
392
+ return keys.filter((k) => expiryStatus(k.expires_at) === "expired");
393
+ }, [keys]);
394
+ const handlePruneExpired = async () => {
395
+ if (pruneConfirmCount === null || pruneConfirmCount === 0) return;
396
+ setPruneSubmitting(true);
397
+ try {
398
+ await onPruneExpiredKeys();
399
+ } catch (error) {
400
+ console.error("Failed to prune expired keys:", error);
401
+ } finally {
402
+ setPruneSubmitting(false);
403
+ setPruneConfirmCount(null);
404
+ }
405
+ };
374
406
  const modelOption = (m) => {
375
407
  const inCost = fmtCost(m.input_cost_per_token);
376
408
  const outCost = fmtCost(m.output_cost_per_token);
@@ -388,6 +420,17 @@ var init_KeysTable = __esm({
388
420
  startAdornment: /* @__PURE__ */ React2.createElement(InputAdornment, { position: "start" }, /* @__PURE__ */ React2.createElement(Search, { fontSize: "small" }))
389
421
  }
390
422
  }
423
+ ), expiredKeys.length > 0 && /* @__PURE__ */ React2.createElement(
424
+ Button,
425
+ {
426
+ variant: "outlined",
427
+ color: "error",
428
+ startIcon: /* @__PURE__ */ React2.createElement(Autorenew, null),
429
+ onClick: () => setPruneConfirmCount(expiredKeys.length)
430
+ },
431
+ "Prune expired (",
432
+ expiredKeys.length,
433
+ ")"
391
434
  ), /* @__PURE__ */ React2.createElement(
392
435
  Button,
393
436
  {
@@ -651,6 +694,15 @@ var init_KeysTable = __esm({
651
694
  disabled: deleteSubmitting
652
695
  },
653
696
  deleteSubmitting ? /* @__PURE__ */ React2.createElement(CircularProgress, { size: 20 }) : "Revoke"
697
+ ))), /* @__PURE__ */ React2.createElement(Dialog, { open: pruneConfirmCount !== null, onClose: () => setPruneConfirmCount(null), maxWidth: "xs", fullWidth: true }, /* @__PURE__ */ React2.createElement(DialogTitle, null, "Prune Expired Keys?"), /* @__PURE__ */ React2.createElement(DialogContent, null, /* @__PURE__ */ React2.createElement(Typography2, null, "This will permanently delete ", pruneConfirmCount, " expired key", pruneConfirmCount !== 1 ? "s" : "", " from LiteLLM. Any integrations using them will stop working immediately.")), /* @__PURE__ */ React2.createElement(DialogActions, null, /* @__PURE__ */ React2.createElement(Button, { onClick: () => setPruneConfirmCount(null), disabled: pruneSubmitting }, "Cancel"), /* @__PURE__ */ React2.createElement(
698
+ Button,
699
+ {
700
+ onClick: handlePruneExpired,
701
+ variant: "contained",
702
+ color: "error",
703
+ disabled: pruneSubmitting
704
+ },
705
+ pruneSubmitting ? /* @__PURE__ */ React2.createElement(CircularProgress, { size: 20 }) : "Prune"
654
706
  ))));
655
707
  };
656
708
  }
@@ -1121,7 +1173,7 @@ __export(LiteLLMPage_exports, {
1121
1173
  LiteLLMPage: () => LiteLLMPage
1122
1174
  });
1123
1175
  import React6, { useState as useState5, useCallback as useCallback2, useMemo as useMemo3 } from "react";
1124
- import { Grid as Grid2, Box as Box6, Snackbar, Alert as Alert2, CircularProgress as CircularProgress4, Typography as Typography6, Paper as Paper6, Tabs as Tabs2, Tab as Tab2 } from "@mui/material";
1176
+ import { Box as Box6, Snackbar, Alert as Alert2, CircularProgress as CircularProgress4, Typography as Typography6, Paper as Paper6, Tabs as Tabs2, Tab as Tab2 } from "@mui/material";
1125
1177
  import { useAsync as useAsync2, useAsyncRetry } from "react-use";
1126
1178
  import { useApi } from "@backstage/core-plugin-api";
1127
1179
  function initDateRange() {
@@ -1312,6 +1364,19 @@ var init_LiteLLMPage = __esm({
1312
1364
  },
1313
1365
  [api, refreshKeys]
1314
1366
  );
1367
+ const handlePruneExpiredKeys = useCallback2(
1368
+ async () => {
1369
+ try {
1370
+ const result = await api.pruneExpiredKeys();
1371
+ setSnackbar({ message: `Pruned ${result.pruned} expired key${result.pruned !== 1 ? "s" : ""}`, severity: "success" });
1372
+ refreshKeys();
1373
+ } catch (e) {
1374
+ setSnackbar({ message: `Failed to prune expired keys: ${e.message}`, severity: "error" });
1375
+ }
1376
+ return { pruned: 0 };
1377
+ },
1378
+ [api, refreshKeys]
1379
+ );
1315
1380
  const isInitialLoading = userLoading && !userInfo;
1316
1381
  if (isInitialLoading) {
1317
1382
  return /* @__PURE__ */ React6.createElement(Box6, { display: "flex", justifyContent: "center", alignItems: "center", minHeight: "50vh" }, /* @__PURE__ */ React6.createElement(CircularProgress4, null));
@@ -1321,7 +1386,7 @@ var init_LiteLLMPage = __esm({
1321
1386
  const hint = userError?.body?.hint;
1322
1387
  return /* @__PURE__ */ React6.createElement(Box6, { p: 3 }, /* @__PURE__ */ React6.createElement(Paper6, { sx: { p: 3 } }, /* @__PURE__ */ React6.createElement(Typography6, { variant: "h6", gutterBottom: true }, "Account not provisioned"), /* @__PURE__ */ React6.createElement(Typography6, { color: "text.secondary", paragraph: true }, "Your Backstage account is not linked to a LiteLLM user."), hint ? /* @__PURE__ */ React6.createElement(Typography6, { variant: "body2", color: "text.secondary" }, hint) : /* @__PURE__ */ React6.createElement(Typography6, { variant: "body2", color: "text.secondary" }, isProvisioningEnabled ? "Auto-provisioning is enabled but failed. Check the backend logs." : "Set litellm.provisioning.enabled: true in app-config.yaml to enable auto-provisioning, or ask your administrator to create the account manually.")));
1323
1388
  }
1324
- return /* @__PURE__ */ React6.createElement(Box6, { p: 3 }, userInfo.can_view_audit && /* @__PURE__ */ React6.createElement(
1389
+ return /* @__PURE__ */ React6.createElement(Box6, { p: 3 }, /* @__PURE__ */ React6.createElement(Box6, { mb: 2 }, /* @__PURE__ */ React6.createElement(DashboardHeader, { userInfo, teams: teams ?? [], loading: userLoading || teamsLoading })), /* @__PURE__ */ React6.createElement(
1325
1390
  Tabs2,
1326
1391
  {
1327
1392
  value: activeTab,
@@ -1329,20 +1394,10 @@ var init_LiteLLMPage = __esm({
1329
1394
  sx: { mb: 2, borderBottom: 1, borderColor: "divider" }
1330
1395
  },
1331
1396
  /* @__PURE__ */ React6.createElement(Tab2, { label: "Overview", value: "overview" }),
1332
- /* @__PURE__ */ React6.createElement(Tab2, { label: "Audit Log", value: "audit" })
1333
- ), activeTab === "audit" ? /* @__PURE__ */ React6.createElement(AuditLog, { api }) : /* @__PURE__ */ React6.createElement(Grid2, { container: true, spacing: 2 }, /* @__PURE__ */ React6.createElement(Grid2, { item: true, xs: 12 }, /* @__PURE__ */ React6.createElement(DashboardHeader, { userInfo, teams: teams ?? [], loading: userLoading || teamsLoading })), /* @__PURE__ */ React6.createElement(Grid2, { item: true, xs: 12 }, /* @__PURE__ */ React6.createElement(
1334
- TeamUsage,
1335
- {
1336
- teams: teams ?? [],
1337
- loading: teamsLoading,
1338
- dateRange,
1339
- getTeamUsage: (teamId) => {
1340
- if (teamUsageCache[teamId] === void 0) loadTeamUsage(teamId);
1341
- return teamUsageCache[teamId] ?? null;
1342
- },
1343
- getTeamUsageLoading: (teamId) => teamUsageLoading[teamId] ?? false
1344
- }
1345
- )), /* @__PURE__ */ React6.createElement(Grid2, { item: true, xs: 12 }, /* @__PURE__ */ React6.createElement(
1397
+ /* @__PURE__ */ React6.createElement(Tab2, { label: "Keys", value: "keys" }),
1398
+ /* @__PURE__ */ React6.createElement(Tab2, { label: "Teams", value: "teams" }),
1399
+ userInfo.can_view_audit && /* @__PURE__ */ React6.createElement(Tab2, { label: "Audit Log", value: "audit" })
1400
+ ), activeTab === "overview" && /* @__PURE__ */ React6.createElement(
1346
1401
  UsageStats,
1347
1402
  {
1348
1403
  usage: usage ?? null,
@@ -1352,7 +1407,7 @@ var init_LiteLLMPage = __esm({
1352
1407
  loading: usageLoading,
1353
1408
  userInfo
1354
1409
  }
1355
- )), /* @__PURE__ */ React6.createElement(Grid2, { item: true, xs: 12 }, /* @__PURE__ */ React6.createElement(
1410
+ ), activeTab === "keys" && /* @__PURE__ */ React6.createElement(
1356
1411
  KeysTable,
1357
1412
  {
1358
1413
  keys: keys ?? [],
@@ -1365,9 +1420,22 @@ var init_LiteLLMPage = __esm({
1365
1420
  onBlockKey: handleBlockKey,
1366
1421
  onUnblockKey: handleUnblockKey,
1367
1422
  onResetKeySpend: handleResetKeySpend,
1368
- onDeleteKey: handleDeleteKey
1423
+ onDeleteKey: handleDeleteKey,
1424
+ onPruneExpiredKeys: handlePruneExpiredKeys
1425
+ }
1426
+ ), activeTab === "teams" && /* @__PURE__ */ React6.createElement(
1427
+ TeamUsage,
1428
+ {
1429
+ teams: teams ?? [],
1430
+ loading: teamsLoading,
1431
+ dateRange,
1432
+ getTeamUsage: (teamId) => {
1433
+ if (teamUsageCache[teamId] === void 0) loadTeamUsage(teamId);
1434
+ return teamUsageCache[teamId] ?? null;
1435
+ },
1436
+ getTeamUsageLoading: (teamId) => teamUsageLoading[teamId] ?? false
1369
1437
  }
1370
- ))), /* @__PURE__ */ React6.createElement(
1438
+ ), activeTab === "audit" && userInfo.can_view_audit && /* @__PURE__ */ React6.createElement(AuditLog, { api }), /* @__PURE__ */ React6.createElement(
1371
1439
  Snackbar,
1372
1440
  {
1373
1441
  open: !!snackbar,
@@ -1431,7 +1499,7 @@ import {
1431
1499
  FormControl as FormControl2,
1432
1500
  Select as Select2,
1433
1501
  MenuItem as MenuItem4,
1434
- Grid as Grid3,
1502
+ Grid as Grid2,
1435
1503
  CircularProgress as CircularProgress5,
1436
1504
  Alert as Alert3
1437
1505
  } from "@mui/material";
@@ -1500,7 +1568,7 @@ var LiteLLMHomeWidget = ({
1500
1568
  /* @__PURE__ */ React8.createElement(MenuItem4, { value: "today" }, "Today"),
1501
1569
  /* @__PURE__ */ React8.createElement(MenuItem4, { value: "7d" }, "7d"),
1502
1570
  /* @__PURE__ */ React8.createElement(MenuItem4, { value: "30d" }, "30d")
1503
- ))), loading && /* @__PURE__ */ React8.createElement(Box7, { display: "flex", justifyContent: "center", alignItems: "center", minHeight: 120 }, /* @__PURE__ */ React8.createElement(CircularProgress5, { size: 32 })), !loading && error && /* @__PURE__ */ React8.createElement(Alert3, { severity: "error", sx: { mt: 1 } }, error), !loading && !error && /* @__PURE__ */ React8.createElement(React8.Fragment, null, /* @__PURE__ */ React8.createElement(Grid3, { container: true, spacing: 2, sx: { mb: hasSparkline ? 1.5 : 0 } }, /* @__PURE__ */ React8.createElement(Grid3, { item: true, xs: 6 }, /* @__PURE__ */ React8.createElement(Kpi, { label: "USD Spent", value: fmtUsd2(usage?.total_spend ?? 0) })), /* @__PURE__ */ React8.createElement(Grid3, { item: true, xs: 6 }, /* @__PURE__ */ React8.createElement(Kpi, { label: "Tokens In", value: fmtInt2(usage?.prompt_tokens ?? 0) })), /* @__PURE__ */ React8.createElement(Grid3, { item: true, xs: 6 }, /* @__PURE__ */ React8.createElement(Kpi, { label: "Tokens Out", value: fmtInt2(usage?.completion_tokens ?? 0) })), /* @__PURE__ */ React8.createElement(Grid3, { item: true, xs: 6 }, /* @__PURE__ */ React8.createElement(Kpi, { label: "Keys", value: fmtInt2(keys.length) }))), hasSparkline && /* @__PURE__ */ React8.createElement(Box7, { height: 120 }, /* @__PURE__ */ React8.createElement(ResponsiveContainer3, { width: "100%", height: "100%" }, /* @__PURE__ */ React8.createElement(AreaChart3, { data: dailyData, margin: { top: 4, right: 0, bottom: 0, left: 0 } }, /* @__PURE__ */ React8.createElement(
1571
+ ))), loading && /* @__PURE__ */ React8.createElement(Box7, { display: "flex", justifyContent: "center", alignItems: "center", minHeight: 120 }, /* @__PURE__ */ React8.createElement(CircularProgress5, { size: 32 })), !loading && error && /* @__PURE__ */ React8.createElement(Alert3, { severity: "error", sx: { mt: 1 } }, error), !loading && !error && /* @__PURE__ */ React8.createElement(React8.Fragment, null, /* @__PURE__ */ React8.createElement(Grid2, { container: true, spacing: 2, sx: { mb: hasSparkline ? 1.5 : 0 } }, /* @__PURE__ */ React8.createElement(Grid2, { item: true, xs: 6 }, /* @__PURE__ */ React8.createElement(Kpi, { label: "USD Spent", value: fmtUsd2(usage?.total_spend ?? 0) })), /* @__PURE__ */ React8.createElement(Grid2, { item: true, xs: 6 }, /* @__PURE__ */ React8.createElement(Kpi, { label: "Tokens In", value: fmtInt2(usage?.prompt_tokens ?? 0) })), /* @__PURE__ */ React8.createElement(Grid2, { item: true, xs: 6 }, /* @__PURE__ */ React8.createElement(Kpi, { label: "Tokens Out", value: fmtInt2(usage?.completion_tokens ?? 0) })), /* @__PURE__ */ React8.createElement(Grid2, { item: true, xs: 6 }, /* @__PURE__ */ React8.createElement(Kpi, { label: "Keys", value: fmtInt2(keys.length) }))), hasSparkline && /* @__PURE__ */ React8.createElement(Box7, { height: 120 }, /* @__PURE__ */ React8.createElement(ResponsiveContainer3, { width: "100%", height: "100%" }, /* @__PURE__ */ React8.createElement(AreaChart3, { data: dailyData, margin: { top: 4, right: 0, bottom: 0, left: 0 } }, /* @__PURE__ */ React8.createElement(
1504
1572
  Area3,
1505
1573
  {
1506
1574
  type: "monotone",