@acarmisc/backstage-plugin-litellm 0.8.0 → 0.9.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
@@ -147,6 +147,9 @@ var init_api = __esm({
147
147
  end_date: endDate
148
148
  });
149
149
  }
150
+ async getConfig() {
151
+ return this.get("/config");
152
+ }
150
153
  };
151
154
  }
152
155
  });
@@ -177,6 +180,20 @@ var init_DashboardHeader = __esm({
177
180
  }
178
181
  });
179
182
 
183
+ // src/format.ts
184
+ var fmtUsd, fmtInt, estimateTokensFromBudget;
185
+ var init_format = __esm({
186
+ "src/format.ts"() {
187
+ "use strict";
188
+ fmtUsd = (n) => `$${(n ?? 0).toFixed(n < 1 ? 4 : 2)}`;
189
+ fmtInt = (n) => (n ?? 0).toLocaleString();
190
+ estimateTokensFromBudget = (budget, pricePerToken) => {
191
+ if (!pricePerToken || pricePerToken <= 0 || !budget || budget <= 0) return null;
192
+ return Math.floor(budget / pricePerToken);
193
+ };
194
+ }
195
+ });
196
+
180
197
  // src/components/KeysTable.tsx
181
198
  import React2, { useState, useMemo } from "react";
182
199
  import {
@@ -201,9 +218,13 @@ import {
201
218
  CircularProgress,
202
219
  Autocomplete,
203
220
  LinearProgress as LinearProgress2,
204
- InputAdornment
221
+ InputAdornment,
222
+ Checkbox,
223
+ FormControlLabel,
224
+ Tabs,
225
+ Tab
205
226
  } from "@mui/material";
206
- import { ContentCopy, Delete, Add, Edit, Autorenew, Search, Warning, Lock, LockOpen } from "@mui/icons-material";
227
+ import { ContentCopy, Delete, Add, Edit, Autorenew, Search, Warning, Lock, LockOpen, Code } from "@mui/icons-material";
207
228
  function ExpiryChip({ expiresAt }) {
208
229
  const status = expiryStatus(expiresAt);
209
230
  if (!status) return /* @__PURE__ */ React2.createElement(Typography2, { variant: "body2", color: "text.secondary" }, "-");
@@ -223,11 +244,39 @@ function fmtCost(perToken) {
223
244
  const per1k = perToken * 1e3;
224
245
  return per1k < 0.01 ? `$${(perToken * 1e6).toFixed(2)}/M` : `$${per1k.toFixed(3)}/1K`;
225
246
  }
226
- var maskKey, shortKeyId, formatDate, emptyForm, keyToEditForm, KeysTable;
247
+ function trimSlash(url) {
248
+ return url.replace(/\/+$/, "");
249
+ }
250
+ function buildSnippets(baseUrl, key, model) {
251
+ const base = trimSlash(baseUrl);
252
+ return {
253
+ curl: `curl ${base}/v1/chat/completions \\
254
+ -H "Authorization: Bearer ${key}" \\
255
+ -H "Content-Type: application/json" \\
256
+ -d '{
257
+ "model": "${model}",
258
+ "messages": [{ "role": "user", "content": "Hello!" }]
259
+ }'`,
260
+ openai: `from openai import OpenAI
261
+
262
+ client = OpenAI(
263
+ api_key="${key}",
264
+ base_url="${base}/v1",
265
+ )
266
+
267
+ response = client.chat.completions.create(
268
+ model="${model}",
269
+ messages=[{ "role": "user", "content": "Hello!" }],
270
+ )
271
+ print(response.choices[0].message.content)`
272
+ };
273
+ }
274
+ var maskKey, shortKeyId, formatDate, emptyForm, keyToEditForm, KeysTable, SnippetTabs;
227
275
  var init_KeysTable = __esm({
228
276
  "src/components/KeysTable.tsx"() {
229
277
  "use strict";
230
278
  init_api();
279
+ init_format();
231
280
  maskKey = (key) => {
232
281
  if (key.length <= 8) return "***";
233
282
  return `${key.slice(0, 4)}...${key.slice(-4)}`;
@@ -273,11 +322,15 @@ var init_KeysTable = __esm({
273
322
  onUnblockKey,
274
323
  onResetKeySpend,
275
324
  onDeleteKey,
276
- onPruneExpiredKeys
325
+ onPruneExpiredKeys,
326
+ onGetConfig
277
327
  }) => {
278
328
  const [generateModalOpen, setGenerateModalOpen] = useState(false);
279
329
  const [newKeyValue, setNewKeyValue] = useState(null);
330
+ const [newKeySnippets, setNewKeySnippets] = useState(null);
331
+ const [newKeyModel, setNewKeyModel] = useState("");
280
332
  const [formData, setFormData] = useState(emptyForm());
333
+ const [unlimitedBudget, setUnlimitedBudget] = useState(false);
281
334
  const [submitting, setSubmitting] = useState(false);
282
335
  const [editingKey, setEditingKey] = useState(null);
283
336
  const [editForm, setEditForm] = useState({});
@@ -300,12 +353,35 @@ var init_KeysTable = __esm({
300
353
  const selectedModels = models.filter((m) => (formData.models || []).includes(m.model_name));
301
354
  const selectedTeam = teams.find((t) => t.team_id === formData.team_id) ?? null;
302
355
  const editSelectedModels = models.filter((m) => (editForm.models || []).includes(m.model_name));
356
+ const aliasError = !(formData.alias || "").trim();
357
+ const budgetInvalid = !unlimitedBudget && (formData.max_budget === void 0 || formData.max_budget === null || formData.max_budget <= 0);
358
+ const canGenerate = !aliasError && !budgetInvalid && !submitting;
359
+ const budgetEstimate = useMemo(() => {
360
+ if (unlimitedBudget || budgetInvalid) return null;
361
+ const inputCosts = selectedModels.map((m) => m.input_cost_per_token).filter((c) => typeof c === "number" && c > 0);
362
+ if (inputCosts.length === 0) return null;
363
+ const pricePerToken = Math.max(...inputCosts);
364
+ return estimateTokensFromBudget(formData.max_budget ?? 0, pricePerToken);
365
+ }, [formData.max_budget, selectedModels, unlimitedBudget, budgetInvalid]);
303
366
  const handleGenerate = async () => {
304
367
  setSubmitting(true);
305
368
  try {
306
- const response = await onGenerateKey(formData);
369
+ const request = {
370
+ ...formData,
371
+ max_budget: unlimitedBudget ? null : formData.max_budget
372
+ };
373
+ const response = await onGenerateKey(request);
307
374
  setNewKeyValue(response.key);
375
+ setNewKeyModel(formData.models?.[0] ?? "");
376
+ setNewKeySnippets(null);
377
+ try {
378
+ const config = await onGetConfig();
379
+ const model = formData.models?.[0] ?? "";
380
+ setNewKeySnippets(buildSnippets(config.baseUrl, response.key, model));
381
+ } catch {
382
+ }
308
383
  setFormData(emptyForm());
384
+ setUnlimitedBudget(false);
309
385
  } catch (error) {
310
386
  console.error("Failed to generate key:", error);
311
387
  } finally {
@@ -315,7 +391,9 @@ var init_KeysTable = __esm({
315
391
  const handleCloseModal = () => {
316
392
  setGenerateModalOpen(false);
317
393
  setNewKeyValue(null);
394
+ setNewKeySnippets(null);
318
395
  setFormData(emptyForm());
396
+ setUnlimitedBudget(false);
319
397
  };
320
398
  const handleOpenEdit = (k) => {
321
399
  setEditingKey(k);
@@ -440,7 +518,17 @@ var init_KeysTable = __esm({
440
518
  onClick: () => setGenerateModalOpen(true)
441
519
  },
442
520
  "Generate New Key"
443
- ))), /* @__PURE__ */ React2.createElement(TableContainer, null, /* @__PURE__ */ React2.createElement(Table, null, /* @__PURE__ */ React2.createElement(TableHead, null, /* @__PURE__ */ React2.createElement(TableRow, null, /* @__PURE__ */ React2.createElement(TableCell, null, "Alias"), /* @__PURE__ */ React2.createElement(TableCell, null, "Key ID"), /* @__PURE__ */ React2.createElement(TableCell, null, "Created"), /* @__PURE__ */ React2.createElement(TableCell, null, "Expires"), /* @__PURE__ */ React2.createElement(TableCell, null, "Budget"), /* @__PURE__ */ React2.createElement(TableCell, null, "TPM / RPM"), /* @__PURE__ */ React2.createElement(TableCell, null, "Models"), /* @__PURE__ */ React2.createElement(TableCell, { align: "right" }, "Actions"))), /* @__PURE__ */ React2.createElement(TableBody, null, loading ? /* @__PURE__ */ React2.createElement(TableRow, null, /* @__PURE__ */ React2.createElement(TableCell, { colSpan: 8, align: "center" }, /* @__PURE__ */ React2.createElement(CircularProgress, { size: 24 }))) : filteredKeys.length === 0 ? /* @__PURE__ */ React2.createElement(TableRow, null, /* @__PURE__ */ React2.createElement(TableCell, { colSpan: 8, align: "center" }, /* @__PURE__ */ React2.createElement(Typography2, { color: "text.secondary" }, filterText ? "No keys match filter" : "No keys found"))) : filteredKeys.map((key) => {
521
+ ))), /* @__PURE__ */ React2.createElement(TableContainer, null, /* @__PURE__ */ React2.createElement(Table, null, /* @__PURE__ */ React2.createElement(TableHead, null, /* @__PURE__ */ React2.createElement(TableRow, null, /* @__PURE__ */ React2.createElement(TableCell, null, "Alias"), /* @__PURE__ */ React2.createElement(TableCell, null, "Key ID"), /* @__PURE__ */ React2.createElement(TableCell, null, "Created"), /* @__PURE__ */ React2.createElement(TableCell, null, "Expires"), /* @__PURE__ */ React2.createElement(TableCell, null, "Budget"), /* @__PURE__ */ React2.createElement(TableCell, null, "TPM / RPM"), /* @__PURE__ */ React2.createElement(TableCell, null, "Models"), /* @__PURE__ */ React2.createElement(TableCell, { align: "right" }, "Actions"))), /* @__PURE__ */ React2.createElement(TableBody, null, loading ? /* @__PURE__ */ React2.createElement(TableRow, null, /* @__PURE__ */ React2.createElement(TableCell, { colSpan: 8, align: "center" }, /* @__PURE__ */ React2.createElement(CircularProgress, { size: 24 }))) : filteredKeys.length === 0 ? /* @__PURE__ */ React2.createElement(TableRow, null, /* @__PURE__ */ React2.createElement(TableCell, { colSpan: 8, align: "center", sx: { py: 4 } }, filterText ? /* @__PURE__ */ React2.createElement(Typography2, { color: "text.secondary" }, "No keys match filter") : /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Typography2, { color: "text.secondary", gutterBottom: true }, "No keys yet \u2014 generate your first key to start calling models."), /* @__PURE__ */ React2.createElement(
522
+ Button,
523
+ {
524
+ variant: "contained",
525
+ color: "primary",
526
+ size: "small",
527
+ startIcon: /* @__PURE__ */ React2.createElement(Add, null),
528
+ onClick: () => setGenerateModalOpen(true)
529
+ },
530
+ "Generate Your First Key"
531
+ )))) : filteredKeys.map((key) => {
444
532
  const keyId = key.token ?? key.key;
445
533
  const isRotating = rotatingKeyId === keyId;
446
534
  const isBlocking = blockingKeyId === keyId;
@@ -511,12 +599,14 @@ var init_KeysTable = __esm({
511
599
  newKeyValue
512
600
  ),
513
601
  /* @__PURE__ */ React2.createElement(IconButton, { onClick: () => copyToClipboard(newKeyValue) }, /* @__PURE__ */ React2.createElement(ContentCopy, null))
514
- )) : /* @__PURE__ */ React2.createElement(Box2, { display: "flex", flexDirection: "column", gap: 2, mt: 1 }, /* @__PURE__ */ React2.createElement(
602
+ ), newKeySnippets && /* @__PURE__ */ React2.createElement(Box2, { mt: 3 }, /* @__PURE__ */ React2.createElement(Box2, { display: "flex", alignItems: "center", gap: 1, mb: 1 }, /* @__PURE__ */ React2.createElement(Code, { fontSize: "small", color: "action" }), /* @__PURE__ */ React2.createElement(Typography2, { variant: "subtitle2" }, "Start calling the proxy \u2014 paste and run")), /* @__PURE__ */ React2.createElement(SnippetTabs, { snippets: newKeySnippets, model: newKeyModel, onCopy: copyToClipboard }))) : /* @__PURE__ */ React2.createElement(Box2, { display: "flex", flexDirection: "column", gap: 2, mt: 1 }, /* @__PURE__ */ React2.createElement(
515
603
  TextField,
516
604
  {
517
605
  label: "Alias",
518
606
  value: formData.alias || "",
519
607
  onChange: (e) => setFormData({ ...formData, alias: e.target.value }),
608
+ error: aliasError,
609
+ helperText: aliasError ? "Alias is required" : void 0,
520
610
  required: true,
521
611
  fullWidth: true
522
612
  }
@@ -563,13 +653,35 @@ var init_KeysTable = __esm({
563
653
  renderOption: (props, m) => /* @__PURE__ */ React2.createElement("li", { ...props }, modelOption(m)),
564
654
  renderInput: (params) => /* @__PURE__ */ React2.createElement(TextField, { ...params, label: "Models", helperText: "Leave empty to allow all models", fullWidth: true })
565
655
  }
656
+ ), /* @__PURE__ */ React2.createElement(
657
+ FormControlLabel,
658
+ {
659
+ control: /* @__PURE__ */ React2.createElement(
660
+ Checkbox,
661
+ {
662
+ checked: unlimitedBudget,
663
+ onChange: (e) => {
664
+ setUnlimitedBudget(e.target.checked);
665
+ if (e.target.checked) {
666
+ setFormData({ ...formData, max_budget: null });
667
+ } else {
668
+ setFormData({ ...formData, max_budget: 100 });
669
+ }
670
+ }
671
+ }
672
+ ),
673
+ label: "Unlimited budget"
674
+ }
566
675
  ), /* @__PURE__ */ React2.createElement(
567
676
  TextField,
568
677
  {
569
678
  label: "Max Budget (USD)",
570
679
  type: "number",
571
- value: formData.max_budget ?? "",
680
+ value: unlimitedBudget ? "" : formData.max_budget ?? "",
572
681
  onChange: (e) => setFormData({ ...formData, max_budget: e.target.value ? Number(e.target.value) : void 0 }),
682
+ error: budgetInvalid,
683
+ helperText: budgetInvalid ? 'Enter a positive budget or tick "Unlimited"' : budgetEstimate !== null ? `\u2248 ${fmtInt(budgetEstimate)} tokens at the selected model's rate` : void 0,
684
+ disabled: unlimitedBudget,
573
685
  required: true,
574
686
  fullWidth: true
575
687
  }
@@ -597,7 +709,7 @@ var init_KeysTable = __esm({
597
709
  onClick: handleGenerate,
598
710
  variant: "contained",
599
711
  color: "primary",
600
- disabled: submitting
712
+ disabled: !canGenerate
601
713
  },
602
714
  submitting ? /* @__PURE__ */ React2.createElement(CircularProgress, { size: 24 }) : "Generate"
603
715
  ), newKeyValue && /* @__PURE__ */ React2.createElement(Button, { onClick: handleCloseModal, variant: "contained", color: "success" }, "Done"))), /* @__PURE__ */ React2.createElement(Dialog, { open: !!editingKey, onClose: handleCloseEdit, maxWidth: "sm", fullWidth: true }, /* @__PURE__ */ React2.createElement(DialogTitle, null, "Edit Key"), /* @__PURE__ */ React2.createElement(DialogContent, null, editingKey && /* @__PURE__ */ React2.createElement(Box2, { display: "flex", flexDirection: "column", gap: 2, mt: 1 }, /* @__PURE__ */ React2.createElement(Typography2, { variant: "body2", color: "text.secondary" }, /* @__PURE__ */ React2.createElement("code", { style: { fontFamily: "monospace", color: "inherit" } }, maskKey(editingKey.key))), /* @__PURE__ */ React2.createElement(
@@ -705,16 +817,44 @@ var init_KeysTable = __esm({
705
817
  pruneSubmitting ? /* @__PURE__ */ React2.createElement(CircularProgress, { size: 20 }) : "Prune"
706
818
  ))));
707
819
  };
708
- }
709
- });
710
-
711
- // src/format.ts
712
- var fmtUsd, fmtInt;
713
- var init_format = __esm({
714
- "src/format.ts"() {
715
- "use strict";
716
- fmtUsd = (n) => `$${(n ?? 0).toFixed(n < 1 ? 4 : 2)}`;
717
- fmtInt = (n) => (n ?? 0).toLocaleString();
820
+ SnippetTabs = ({ snippets, model, onCopy }) => {
821
+ const [tab, setTab] = useState("curl");
822
+ const code = tab === "curl" ? snippets.curl : snippets.openai;
823
+ return /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Tabs, { value: tab, onChange: (_, v) => setTab(v), sx: { mb: 1 } }, /* @__PURE__ */ React2.createElement(Tab, { label: "curl", value: "curl" }), /* @__PURE__ */ React2.createElement(Tab, { label: "OpenAI SDK", value: "openai" })), /* @__PURE__ */ React2.createElement(
824
+ Box2,
825
+ {
826
+ position: "relative",
827
+ p: 1.5,
828
+ sx: { backgroundColor: "action.hover", border: "1px solid", borderColor: "divider", borderRadius: 1 }
829
+ },
830
+ /* @__PURE__ */ React2.createElement(
831
+ IconButton,
832
+ {
833
+ size: "small",
834
+ onClick: () => onCopy(code),
835
+ title: "Copy snippet",
836
+ sx: { position: "absolute", top: 4, right: 4 }
837
+ },
838
+ /* @__PURE__ */ React2.createElement(ContentCopy, { fontSize: "small" })
839
+ ),
840
+ /* @__PURE__ */ React2.createElement(
841
+ Typography2,
842
+ {
843
+ component: "pre",
844
+ sx: {
845
+ fontFamily: "monospace",
846
+ fontSize: 12,
847
+ whiteSpace: "pre-wrap",
848
+ wordBreak: "break-all",
849
+ mb: 0,
850
+ pr: 4
851
+ }
852
+ },
853
+ code
854
+ ),
855
+ model && /* @__PURE__ */ React2.createElement(Typography2, { variant: "caption", color: "text.secondary", display: "block", mt: 1 }, "Using model \u201C", model, "\u201D \u2014 swap it for any model you have access to.")
856
+ ));
857
+ };
718
858
  }
719
859
  });
720
860
 
@@ -729,8 +869,8 @@ import {
729
869
  Select,
730
870
  MenuItem as MenuItem2,
731
871
  Grid,
732
- Tabs,
733
- Tab,
872
+ Tabs as Tabs2,
873
+ Tab as Tab2,
734
874
  Table as Table2,
735
875
  TableBody as TableBody2,
736
876
  TableCell as TableCell2,
@@ -931,7 +1071,7 @@ var init_UsageStats = __esm({
931
1071
  value: fmtInt(usage?.total_tokens ?? 0),
932
1072
  hint: `${fmtInt(usage?.prompt_tokens ?? 0)} in \xB7 ${fmtInt(usage?.completion_tokens ?? 0)} out`
933
1073
  }
934
- ))), /* @__PURE__ */ React3.createElement(Tabs, { value: tab, onChange: (_, v) => setTab(v), sx: { mb: 2 } }, /* @__PURE__ */ React3.createElement(Tab, { value: "costs", label: "Costs" }), /* @__PURE__ */ React3.createElement(Tab, { value: "models", label: "Model Activity" }), /* @__PURE__ */ React3.createElement(Tab, { value: "keys", label: "Key Activity" })), tab === "costs" && /* @__PURE__ */ React3.createElement(Grid, { container: true, spacing: 3 }, /* @__PURE__ */ React3.createElement(Grid, { item: true, xs: 12, md: 6 }, /* @__PURE__ */ React3.createElement(Typography3, { variant: "subtitle2", color: "text.secondary", gutterBottom: true }, "Daily Spend by Model"), loading ? /* @__PURE__ */ React3.createElement(ChartSkeleton, null) : modelSpendByDate.length === 0 ? /* @__PURE__ */ React3.createElement(EmptyChart, null) : /* @__PURE__ */ React3.createElement(Box3, { height: 260 }, /* @__PURE__ */ React3.createElement(ResponsiveContainer, { width: "100%", height: "100%" }, /* @__PURE__ */ React3.createElement(AreaChart, { data: modelSpendByDate }, /* @__PURE__ */ React3.createElement(CartesianGrid, { strokeDasharray: "3 3" }), /* @__PURE__ */ React3.createElement(XAxis, { dataKey: "date", tick: { fontSize: 12 } }), /* @__PURE__ */ React3.createElement(YAxis, { tick: { fontSize: 12 }, tickFormatter: (v) => `$${v.toFixed(2)}` }), /* @__PURE__ */ React3.createElement(Tooltip, { formatter: (v) => [`$${v.toFixed(4)}`, void 0] }), /* @__PURE__ */ React3.createElement(Legend, null), topSpendModels.map((m) => /* @__PURE__ */ React3.createElement(
1074
+ ))), /* @__PURE__ */ React3.createElement(Tabs2, { value: tab, onChange: (_, v) => setTab(v), sx: { mb: 2 } }, /* @__PURE__ */ React3.createElement(Tab2, { value: "costs", label: "Costs" }), /* @__PURE__ */ React3.createElement(Tab2, { value: "models", label: "Model Activity" }), /* @__PURE__ */ React3.createElement(Tab2, { value: "keys", label: "Key Activity" })), tab === "costs" && /* @__PURE__ */ React3.createElement(Grid, { container: true, spacing: 3 }, /* @__PURE__ */ React3.createElement(Grid, { item: true, xs: 12, md: 6 }, /* @__PURE__ */ React3.createElement(Typography3, { variant: "subtitle2", color: "text.secondary", gutterBottom: true }, "Daily Spend by Model"), loading ? /* @__PURE__ */ React3.createElement(ChartSkeleton, null) : modelSpendByDate.length === 0 ? /* @__PURE__ */ React3.createElement(EmptyChart, null) : /* @__PURE__ */ React3.createElement(Box3, { height: 260 }, /* @__PURE__ */ React3.createElement(ResponsiveContainer, { width: "100%", height: "100%" }, /* @__PURE__ */ React3.createElement(AreaChart, { data: modelSpendByDate }, /* @__PURE__ */ React3.createElement(CartesianGrid, { strokeDasharray: "3 3" }), /* @__PURE__ */ React3.createElement(XAxis, { dataKey: "date", tick: { fontSize: 12 } }), /* @__PURE__ */ React3.createElement(YAxis, { tick: { fontSize: 12 }, tickFormatter: (v) => `$${v.toFixed(2)}` }), /* @__PURE__ */ React3.createElement(Tooltip, { formatter: (v) => [`$${v.toFixed(4)}`, void 0] }), /* @__PURE__ */ React3.createElement(Legend, null), topSpendModels.map((m) => /* @__PURE__ */ React3.createElement(
935
1075
  Area,
936
1076
  {
937
1077
  key: m,
@@ -1067,7 +1207,7 @@ var init_TeamUsage = __esm({
1067
1207
  return /* @__PURE__ */ React4.createElement(Paper4, { sx: { p: 2 } }, /* @__PURE__ */ React4.createElement(LinearProgress4, null));
1068
1208
  }
1069
1209
  if (!teams.length) {
1070
- return /* @__PURE__ */ React4.createElement(Paper4, { sx: { p: 2 } }, /* @__PURE__ */ React4.createElement(Box4, { display: "flex", alignItems: "center", gap: 1 }, /* @__PURE__ */ React4.createElement(Group, { color: "disabled" }), /* @__PURE__ */ React4.createElement(Typography4, { color: "text.secondary", variant: "body2" }, "No team membership found in LiteLLM for this account.")));
1210
+ return /* @__PURE__ */ React4.createElement(Paper4, { sx: { p: 2 } }, /* @__PURE__ */ React4.createElement(Box4, { display: "flex", alignItems: "flex-start", gap: 1.5 }, /* @__PURE__ */ React4.createElement(Group, { color: "disabled", sx: { mt: 0.5 } }), /* @__PURE__ */ React4.createElement(Box4, null, /* @__PURE__ */ React4.createElement(Typography4, { color: "text.secondary", variant: "body2" }, "You're not a member of any LiteLLM team yet."), /* @__PURE__ */ React4.createElement(Typography4, { color: "text.secondary", variant: "body2", mt: 0.5 }, "That's fine \u2014 your account is provisioned, so you can still generate personal keys and use models. If you need a shared budget with colleagues, ask an admin to add you to a team."))));
1071
1211
  }
1072
1212
  return /* @__PURE__ */ React4.createElement(Box4, null, /* @__PURE__ */ React4.createElement(Typography4, { variant: "h6", mb: 1 }, "Teams"), teams.map((team) => /* @__PURE__ */ React4.createElement(
1073
1213
  TeamCard,
@@ -1221,7 +1361,7 @@ __export(LiteLLMPage_exports, {
1221
1361
  LiteLLMPage: () => LiteLLMPage
1222
1362
  });
1223
1363
  import React6, { useState as useState5, useCallback as useCallback2, useMemo as useMemo3 } from "react";
1224
- 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";
1364
+ import { Box as Box6, Snackbar, Alert as Alert2, CircularProgress as CircularProgress4, Typography as Typography6, Paper as Paper6, Tabs as Tabs3, Tab as Tab3 } from "@mui/material";
1225
1365
  import { useAsync as useAsync2, useAsyncRetry } from "react-use";
1226
1366
  import { useApi } from "@backstage/core-plugin-api";
1227
1367
  function initDateRange() {
@@ -1435,16 +1575,16 @@ var init_LiteLLMPage = __esm({
1435
1575
  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.")));
1436
1576
  }
1437
1577
  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(
1438
- Tabs2,
1578
+ Tabs3,
1439
1579
  {
1440
1580
  value: activeTab,
1441
1581
  onChange: (_, v) => setActiveTab(v),
1442
1582
  sx: { mb: 2, borderBottom: 1, borderColor: "divider" }
1443
1583
  },
1444
- /* @__PURE__ */ React6.createElement(Tab2, { label: "Overview", value: "overview" }),
1445
- /* @__PURE__ */ React6.createElement(Tab2, { label: "Keys", value: "keys" }),
1446
- /* @__PURE__ */ React6.createElement(Tab2, { label: "Teams", value: "teams" }),
1447
- userInfo.can_view_audit && /* @__PURE__ */ React6.createElement(Tab2, { label: "Audit Log", value: "audit" })
1584
+ /* @__PURE__ */ React6.createElement(Tab3, { label: "Overview", value: "overview" }),
1585
+ /* @__PURE__ */ React6.createElement(Tab3, { label: "Keys", value: "keys" }),
1586
+ /* @__PURE__ */ React6.createElement(Tab3, { label: "Teams", value: "teams" }),
1587
+ userInfo.can_view_audit && /* @__PURE__ */ React6.createElement(Tab3, { label: "Audit Log", value: "audit" })
1448
1588
  ), activeTab === "overview" && /* @__PURE__ */ React6.createElement(
1449
1589
  UsageStats,
1450
1590
  {
@@ -1469,7 +1609,8 @@ var init_LiteLLMPage = __esm({
1469
1609
  onUnblockKey: handleUnblockKey,
1470
1610
  onResetKeySpend: handleResetKeySpend,
1471
1611
  onDeleteKey: handleDeleteKey,
1472
- onPruneExpiredKeys: handlePruneExpiredKeys
1612
+ onPruneExpiredKeys: handlePruneExpiredKeys,
1613
+ onGetConfig: () => api.getConfig()
1473
1614
  }
1474
1615
  ), activeTab === "teams" && /* @__PURE__ */ React6.createElement(
1475
1616
  TeamUsage,
@@ -1573,32 +1714,42 @@ var LiteLLMHomeWidget = ({
1573
1714
  const api = useApi2(liteLlmApiRef);
1574
1715
  const [period, setPeriod] = useState6(defaultPeriod);
1575
1716
  const [loading, setLoading] = useState6(true);
1576
- const [error, setError] = useState6(null);
1717
+ const [usageError, setUsageError] = useState6(null);
1718
+ const [keysError, setKeysError] = useState6(null);
1577
1719
  const [usage, setUsage] = useState6(null);
1578
1720
  const [keys, setKeys] = useState6([]);
1579
1721
  useEffect(() => {
1580
1722
  let cancelled = false;
1581
1723
  setLoading(true);
1582
- setError(null);
1724
+ setUsageError(null);
1725
+ setKeysError(null);
1583
1726
  const { start, end } = presetToDateRange(period);
1584
1727
  const startDate = start.toISOString().split("T")[0];
1585
1728
  const endDate = end.toISOString().split("T")[0];
1586
- Promise.all([api.getUsage(startDate, endDate), api.listKeys()]).then(([usageData, keysData]) => {
1587
- if (!cancelled) {
1588
- setUsage(usageData);
1589
- setKeys(keysData);
1590
- setLoading(false);
1591
- }
1592
- }).catch((err) => {
1593
- if (!cancelled) {
1594
- setError(err.message ?? "Failed to load usage data");
1729
+ Promise.allSettled([api.getUsage(startDate, endDate), api.listKeys()]).then(
1730
+ ([usageResult, keysResult]) => {
1731
+ if (cancelled) return;
1732
+ if (usageResult.status === "fulfilled") {
1733
+ setUsage(usageResult.value);
1734
+ } else {
1735
+ setUsage(null);
1736
+ setUsageError(usageResult.reason?.message ?? "Failed to load usage data");
1737
+ }
1738
+ if (keysResult.status === "fulfilled") {
1739
+ setKeys(keysResult.value);
1740
+ } else {
1741
+ setKeys([]);
1742
+ setKeysError(keysResult.reason?.message ?? "Failed to load keys");
1743
+ }
1595
1744
  setLoading(false);
1596
1745
  }
1597
- });
1746
+ );
1598
1747
  return () => {
1599
1748
  cancelled = true;
1600
1749
  };
1601
1750
  }, [api, period]);
1751
+ const partialFailure = !loading && (usageError || keysError) && (usage || keys.length > 0);
1752
+ const totalFailure = !loading && usageError && keysError;
1602
1753
  const dailyData = (usage?.daily_usage ?? []).map((d) => ({
1603
1754
  date: d.date,
1604
1755
  spend: d.spend
@@ -1614,7 +1765,7 @@ var LiteLLMHomeWidget = ({
1614
1765
  /* @__PURE__ */ React8.createElement(MenuItem4, { value: "today" }, "Today"),
1615
1766
  /* @__PURE__ */ React8.createElement(MenuItem4, { value: "7d" }, "7d"),
1616
1767
  /* @__PURE__ */ React8.createElement(MenuItem4, { value: "30d" }, "30d")
1617
- ))), 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: fmtUsd(usage?.total_spend ?? 0) })), /* @__PURE__ */ React8.createElement(Grid2, { item: true, xs: 6 }, /* @__PURE__ */ React8.createElement(Kpi, { label: "Tokens In", value: fmtInt(usage?.prompt_tokens ?? 0) })), /* @__PURE__ */ React8.createElement(Grid2, { item: true, xs: 6 }, /* @__PURE__ */ React8.createElement(Kpi, { label: "Tokens Out", value: fmtInt(usage?.completion_tokens ?? 0) })), /* @__PURE__ */ React8.createElement(Grid2, { item: true, xs: 6 }, /* @__PURE__ */ React8.createElement(Kpi, { label: "Keys", value: fmtInt(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(
1768
+ ))), loading && /* @__PURE__ */ React8.createElement(Box7, { display: "flex", justifyContent: "center", alignItems: "center", minHeight: 120 }, /* @__PURE__ */ React8.createElement(CircularProgress5, { size: 32 })), !loading && totalFailure && /* @__PURE__ */ React8.createElement(Alert3, { severity: "error", sx: { mt: 1 } }, usageError ?? "Failed to load usage data"), !loading && !totalFailure && /* @__PURE__ */ React8.createElement(React8.Fragment, null, partialFailure && /* @__PURE__ */ React8.createElement(Alert3, { severity: "warning", sx: { mt: 1, mb: 1 } }, usageError ? `Usage data unavailable (${usageError}).` : "", keysError ? ` Key list unavailable (${keysError}).` : "", " Showing what loaded."), /* @__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: fmtUsd(usage?.total_spend ?? 0) })), /* @__PURE__ */ React8.createElement(Grid2, { item: true, xs: 6 }, /* @__PURE__ */ React8.createElement(Kpi, { label: "Tokens In", value: fmtInt(usage?.prompt_tokens ?? 0) })), /* @__PURE__ */ React8.createElement(Grid2, { item: true, xs: 6 }, /* @__PURE__ */ React8.createElement(Kpi, { label: "Tokens Out", value: fmtInt(usage?.completion_tokens ?? 0) })), /* @__PURE__ */ React8.createElement(Grid2, { item: true, xs: 6 }, /* @__PURE__ */ React8.createElement(Kpi, { label: "Keys", value: fmtInt(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(
1618
1769
  Area3,
1619
1770
  {
1620
1771
  type: "monotone",