@acarmisc/backstage-plugin-litellm 0.10.2 → 0.11.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.
@@ -4,6 +4,13 @@ interface KeysTableProps {
4
4
  keys: VirtualKey[];
5
5
  models: ModelInfo[];
6
6
  teams: TeamInfo[];
7
+ /** Current user's LiteLLM user id, used to pre-fill a default key alias. */
8
+ username?: string;
9
+ /** Controls for the "Generate New Key" form; see litellm.keyGeneration in app-config.yaml. */
10
+ keyGenerationSettings?: {
11
+ allowUnlimitedBudget: boolean;
12
+ teamRequired: boolean;
13
+ };
7
14
  loading: boolean;
8
15
  onGenerateKey: (request: GenerateKeyRequest) => Promise<GenerateKeyResponse>;
9
16
  onUpdateKey: (keyId: string, request: UpdateKeyRequest) => Promise<void>;
package/dist/index.cjs.js CHANGED
@@ -276,7 +276,7 @@ response = client.chat.completions.create(
276
276
  print(response.choices[0].message.content)`
277
277
  };
278
278
  }
279
- var import_react2, import_material2, import_icons_material, maskKey, shortKeyId, formatDate, emptyForm, keyToEditForm, KeysTable, SnippetTabs;
279
+ var import_react2, import_material2, import_icons_material, maskKey, generateDefaultAlias, shortKeyId, formatDate, emptyForm, keyToEditForm, KeysTable, SnippetTabs;
280
280
  var init_KeysTable = __esm({
281
281
  "src/components/KeysTable.tsx"() {
282
282
  "use strict";
@@ -289,6 +289,11 @@ var init_KeysTable = __esm({
289
289
  if (key.length <= 8) return "***";
290
290
  return `${key.slice(0, 4)}...${key.slice(-4)}`;
291
291
  };
292
+ generateDefaultAlias = (username) => {
293
+ const base = (username || "user").split("@")[0].toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "user";
294
+ const hash = Math.random().toString(36).slice(2, 8);
295
+ return `${base}-${hash}`;
296
+ };
292
297
  shortKeyId = (token) => {
293
298
  if (!token) return "-";
294
299
  if (token.length <= 16) return token;
@@ -322,6 +327,8 @@ var init_KeysTable = __esm({
322
327
  keys,
323
328
  models,
324
329
  teams,
330
+ username,
331
+ keyGenerationSettings,
325
332
  loading,
326
333
  onGenerateKey,
327
334
  onUpdateKey,
@@ -355,12 +362,23 @@ var init_KeysTable = __esm({
355
362
  (k) => (k.key_alias ?? "").toLowerCase().includes(q) || k.models?.some((m) => m.toLowerCase().includes(q))
356
363
  );
357
364
  }, [keys, filterText]);
358
- const selectedModels = models.filter((m) => (formData.models || []).includes(m.model_name));
365
+ const allowUnlimitedBudget = keyGenerationSettings?.allowUnlimitedBudget ?? false;
366
+ const teamRequired = keyGenerationSettings?.teamRequired ?? true;
359
367
  const selectedTeam = teams.find((t) => t.team_id === formData.team_id) ?? null;
368
+ const availableModels = (0, import_react2.useMemo)(() => {
369
+ const teamModels = selectedTeam?.models;
370
+ if (teamModels && teamModels.length > 0) {
371
+ const allowed = new Set(teamModels);
372
+ return models.filter((m) => allowed.has(m.model_name));
373
+ }
374
+ return models;
375
+ }, [models, selectedTeam]);
376
+ const selectedModels = availableModels.filter((m) => (formData.models || []).includes(m.model_name));
360
377
  const editSelectedModels = models.filter((m) => (editForm.models || []).includes(m.model_name));
361
378
  const aliasError = !(formData.alias || "").trim();
379
+ const teamError = teamRequired && !formData.team_id;
362
380
  const budgetInvalid = !unlimitedBudget && (formData.max_budget === void 0 || formData.max_budget === null || formData.max_budget <= 0);
363
- const canGenerate = !aliasError && !budgetInvalid && !submitting;
381
+ const canGenerate = !aliasError && !teamError && !budgetInvalid && !submitting;
364
382
  const budgetEstimate = (0, import_react2.useMemo)(() => {
365
383
  if (unlimitedBudget || budgetInvalid) return null;
366
384
  const inputCosts = selectedModels.map((m) => m.input_cost_per_token).filter((c) => typeof c === "number" && c > 0);
@@ -393,6 +411,10 @@ var init_KeysTable = __esm({
393
411
  setSubmitting(false);
394
412
  }
395
413
  };
414
+ const handleOpenGenerate = () => {
415
+ setFormData({ ...emptyForm(), alias: generateDefaultAlias(username) });
416
+ setGenerateModalOpen(true);
417
+ };
396
418
  const handleCloseModal = () => {
397
419
  setGenerateModalOpen(false);
398
420
  setNewKeyValue(null);
@@ -510,7 +532,7 @@ var init_KeysTable = __esm({
510
532
  variant: "contained",
511
533
  color: "primary",
512
534
  startIcon: /* @__PURE__ */ import_react2.default.createElement(import_icons_material.Add, null),
513
- onClick: () => setGenerateModalOpen(true)
535
+ onClick: handleOpenGenerate
514
536
  },
515
537
  "Generate New Key"
516
538
  ))), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableContainer, null, /* @__PURE__ */ import_react2.default.createElement(import_material2.Table, null, /* @__PURE__ */ import_react2.default.createElement(import_material2.TableHead, null, /* @__PURE__ */ import_react2.default.createElement(import_material2.TableRow, null, /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, null, "Alias"), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, null, "Key ID"), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, null, "Created"), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, null, "Expires"), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, null, "Budget"), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, null, "TPM / RPM"), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, null, "Models"), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, { align: "right" }, "Actions"))), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableBody, null, loading ? /* @__PURE__ */ import_react2.default.createElement(import_material2.TableRow, null, /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, { colSpan: 8, align: "center" }, /* @__PURE__ */ import_react2.default.createElement(import_material2.CircularProgress, { size: 24 }))) : filteredKeys.length === 0 ? /* @__PURE__ */ import_react2.default.createElement(import_material2.TableRow, null, /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, { colSpan: 8, align: "center", sx: { py: 4 } }, filterText ? /* @__PURE__ */ import_react2.default.createElement(import_material2.Typography, { color: "text.secondary" }, "No keys match filter") : /* @__PURE__ */ import_react2.default.createElement(import_material2.Box, null, /* @__PURE__ */ import_react2.default.createElement(import_material2.Typography, { color: "text.secondary", gutterBottom: true }, "No keys yet \u2014 generate your first key to start calling models."), /* @__PURE__ */ import_react2.default.createElement(
@@ -520,7 +542,7 @@ var init_KeysTable = __esm({
520
542
  color: "primary",
521
543
  size: "small",
522
544
  startIcon: /* @__PURE__ */ import_react2.default.createElement(import_icons_material.Add, null),
523
- onClick: () => setGenerateModalOpen(true)
545
+ onClick: handleOpenGenerate
524
546
  },
525
547
  "Generate Your First Key"
526
548
  )))) : filteredKeys.map((key) => {
@@ -610,36 +632,50 @@ var init_KeysTable = __esm({
610
632
  /* @__PURE__ */ import_react2.default.createElement(import_material2.MenuItem, { value: "30d" }, "30 Days"),
611
633
  /* @__PURE__ */ import_react2.default.createElement(import_material2.MenuItem, { value: "90d" }, "90 Days"),
612
634
  /* @__PURE__ */ import_react2.default.createElement(import_material2.MenuItem, { value: "1y" }, "1 Year")
613
- ), teams.length > 0 && /* @__PURE__ */ import_react2.default.createElement(
635
+ ), teams.length > 0 ? /* @__PURE__ */ import_react2.default.createElement(
614
636
  import_material2.Autocomplete,
615
637
  {
616
638
  options: teams,
617
639
  getOptionLabel: (t) => t.team_alias || t.team_id,
618
640
  value: selectedTeam,
619
- onChange: (_e, team) => setFormData({ ...formData, team_id: team?.team_id }),
641
+ onChange: (_e, team) => {
642
+ const teamModels = team?.models;
643
+ const restrictedModels = teamModels && teamModels.length > 0 ? (formData.models || []).filter((m) => teamModels.includes(m)) : formData.models;
644
+ setFormData({ ...formData, team_id: team?.team_id, models: restrictedModels });
645
+ },
620
646
  renderInput: (params) => /* @__PURE__ */ import_react2.default.createElement(
621
647
  import_material2.TextField,
622
648
  {
623
649
  ...params,
624
650
  label: "Team",
625
- helperText: "Optional: bind this key to a specific team for scoped access",
651
+ error: teamError,
652
+ helperText: teamError ? "Team is required" : teamRequired ? "Bind this key to a team for scoped access" : "Optional: bind this key to a specific team for scoped access",
653
+ required: teamRequired,
626
654
  fullWidth: true
627
655
  }
628
656
  )
629
657
  }
630
- ), models.length > 0 && /* @__PURE__ */ import_react2.default.createElement(
658
+ ) : teamRequired ? /* @__PURE__ */ import_react2.default.createElement(import_material2.Typography, { variant: "body2", color: "error" }, "Team selection is required, but you don't belong to any team yet \u2014 contact your administrator.") : null, availableModels.length > 0 && /* @__PURE__ */ import_react2.default.createElement(
631
659
  import_material2.Autocomplete,
632
660
  {
633
661
  multiple: true,
634
- options: models,
662
+ options: availableModels,
635
663
  groupBy: (m) => m.mode || "other",
636
664
  getOptionLabel: (m) => m.model_name,
637
665
  value: selectedModels,
638
666
  onChange: (_e, selected) => setFormData({ ...formData, models: selected.map((m) => m.model_name) }),
639
667
  renderOption: (props, m) => /* @__PURE__ */ import_react2.default.createElement("li", { ...props }, modelOption(m)),
640
- renderInput: (params) => /* @__PURE__ */ import_react2.default.createElement(import_material2.TextField, { ...params, label: "Models", helperText: "Leave empty to allow all models", fullWidth: true })
668
+ renderInput: (params) => /* @__PURE__ */ import_react2.default.createElement(
669
+ import_material2.TextField,
670
+ {
671
+ ...params,
672
+ label: "Models",
673
+ helperText: selectedTeam ? "Leave empty to allow all models available to this team" : "Leave empty to allow all models",
674
+ fullWidth: true
675
+ }
676
+ )
641
677
  }
642
- ), /* @__PURE__ */ import_react2.default.createElement(
678
+ ), allowUnlimitedBudget && /* @__PURE__ */ import_react2.default.createElement(
643
679
  import_material2.FormControlLabel,
644
680
  {
645
681
  control: /* @__PURE__ */ import_react2.default.createElement(
@@ -678,6 +714,7 @@ var init_KeysTable = __esm({
678
714
  type: "number",
679
715
  value: formData.tpm_limit ?? "",
680
716
  onChange: (e) => setFormData({ ...formData, tpm_limit: e.target.value ? Number(e.target.value) : void 0 }),
717
+ helperText: "Max tokens per minute this key can consume across all models. Leave blank for no limit.",
681
718
  fullWidth: true
682
719
  }
683
720
  ), /* @__PURE__ */ import_react2.default.createElement(
@@ -687,6 +724,7 @@ var init_KeysTable = __esm({
687
724
  type: "number",
688
725
  value: formData.rpm_limit ?? "",
689
726
  onChange: (e) => setFormData({ ...formData, rpm_limit: e.target.value ? Number(e.target.value) : void 0 }),
727
+ helperText: "Max requests per minute this key can make across all models. Leave blank for no limit.",
690
728
  fullWidth: true
691
729
  }
692
730
  ))), /* @__PURE__ */ import_react2.default.createElement(import_material2.DialogActions, null, newKeyValue ? /* @__PURE__ */ import_react2.default.createElement(import_material2.Button, { onClick: handleCloseModal, variant: "contained", color: "success" }, "Done") : /* @__PURE__ */ import_react2.default.createElement(import_react2.default.Fragment, null, /* @__PURE__ */ import_react2.default.createElement(import_material2.Button, { onClick: handleCloseModal }, "Cancel"), /* @__PURE__ */ import_react2.default.createElement(
@@ -734,6 +772,7 @@ var init_KeysTable = __esm({
734
772
  type: "number",
735
773
  value: editForm.tpm_limit ?? "",
736
774
  onChange: (e) => setEditForm({ ...editForm, tpm_limit: e.target.value ? Number(e.target.value) : void 0 }),
775
+ helperText: "Max tokens per minute this key can consume across all models. Leave blank for no limit.",
737
776
  fullWidth: true
738
777
  }
739
778
  ), /* @__PURE__ */ import_react2.default.createElement(
@@ -743,6 +782,7 @@ var init_KeysTable = __esm({
743
782
  type: "number",
744
783
  value: editForm.rpm_limit ?? "",
745
784
  onChange: (e) => setEditForm({ ...editForm, rpm_limit: e.target.value ? Number(e.target.value) : void 0 }),
785
+ helperText: "Max requests per minute this key can make across all models. Leave blank for no limit.",
746
786
  fullWidth: true
747
787
  }
748
788
  ), /* @__PURE__ */ import_react2.default.createElement(import_material2.Box, { mt: 1, pt: 2, borderTop: "1px solid", sx: { borderColor: "divider" } }, /* @__PURE__ */ import_react2.default.createElement(import_material2.Typography, { variant: "caption", color: "text.secondary", display: "block", mb: 1 }, "Danger Zone"), !resetSpendConfirm ? /* @__PURE__ */ import_react2.default.createElement(
@@ -1326,6 +1366,7 @@ var init_LiteLLMPage = __esm({
1326
1366
  () => api.getTeams().catch(() => []),
1327
1367
  [api]
1328
1368
  );
1369
+ const { value: liteLlmConfig } = (0, import_react_use2.useAsync)(() => api.getConfig(), [api]);
1329
1370
  const teams = (0, import_react6.useMemo)(() => {
1330
1371
  if (!allTeams?.length) return [];
1331
1372
  if (!userInfo) return allTeams;
@@ -1504,6 +1545,8 @@ var init_LiteLLMPage = __esm({
1504
1545
  keys: keys ?? [],
1505
1546
  models: allowedModels,
1506
1547
  teams: teams ?? [],
1548
+ username: userInfo.user_id,
1549
+ keyGenerationSettings: liteLlmConfig?.keyGeneration,
1507
1550
  loading: keysLoading || modelsLoading,
1508
1551
  onGenerateKey: handleGenerateKey,
1509
1552
  onUpdateKey: handleUpdateKey,