@acarmisc/backstage-plugin-litellm-backend 0.26.1 → 0.28.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/config.d.ts CHANGED
@@ -463,6 +463,8 @@ export interface Config {
463
463
  * reaches:
464
464
  * - managers of the team (members of its metadata.owning_group, with
465
465
  * team management enabled);
466
+ * - team leads (members of the catalog group `<team_alias><leadGroupSuffix>`,
467
+ * when leadGroupSuffix is set);
466
468
  * - members of the team holding one of `viewerRoles` in it;
467
469
  * - members of the team granted `litellm.team.usage.read` (only
468
470
  * consulted when permission.enabled is true).
@@ -480,6 +482,13 @@ export interface Config {
480
482
  * @default []
481
483
  */
482
484
  viewerRoles?: string[];
485
+
486
+ /**
487
+ * Suffix of a catalog group named `<team_alias><suffix>` whose members
488
+ * are team leads (e.g. "-leads" -> group:default/abstrid-leads). For
489
+ * teams synced without metadata.owning_group. Unset: no lead access.
490
+ */
491
+ leadGroupSuffix?: string;
483
492
  };
484
493
  };
485
494
 
package/dist/index.cjs.js CHANGED
@@ -9449,7 +9449,8 @@ function readMemberBreakdownConfig(config) {
9449
9449
  return {
9450
9450
  enabled: config.getOptionalBoolean("litellm.teamUsage.memberBreakdown.enabled") ?? false,
9451
9451
  viewerRoles: config.getOptionalStringArray("litellm.teamUsage.memberBreakdown.viewerRoles") ?? [],
9452
- permissionEnabled: config.getOptionalBoolean("permission.enabled") ?? false
9452
+ permissionEnabled: config.getOptionalBoolean("permission.enabled") ?? false,
9453
+ leadGroupSuffix: config.getOptionalString("litellm.teamUsage.memberBreakdown.leadGroupSuffix") || void 0
9453
9454
  };
9454
9455
  }
9455
9456
  function memberEmails(team, keys) {
@@ -9530,6 +9531,47 @@ function redactTeamMemberUsage(usage) {
9530
9531
  members: usage.members.map((m) => ({ ...m, spend: 0 }))
9531
9532
  };
9532
9533
  }
9534
+ function buildTeamKeyUsage(teamId, usage, keys, team, profiles = /* @__PURE__ */ new Map()) {
9535
+ const emailById = /* @__PURE__ */ new Map();
9536
+ for (const m of team.members_with_roles ?? []) {
9537
+ if (m.user_id && m.user_email) emailById.set(m.user_id, m.user_email);
9538
+ }
9539
+ const rows = keys.map((k) => {
9540
+ const u = k.token && usage.usage_by_key?.[k.token] || void 0;
9541
+ const userId = k.user_id ?? null;
9542
+ const email = userId && emailById.get(userId) || (userId?.includes("@") ? userId : void 0);
9543
+ const displayName = email && profiles.get(email.toLowerCase())?.displayName;
9544
+ return {
9545
+ key: k.key,
9546
+ ...k.key_alias && { key_alias: k.key_alias },
9547
+ user_id: userId,
9548
+ ...email && { user_email: email },
9549
+ ...displayName && { display_name: displayName },
9550
+ created_at: k.created_at,
9551
+ ...k.expires_at && { expires_at: k.expires_at },
9552
+ blocked: k.blocked === true,
9553
+ ...k.max_budget !== void 0 && { max_budget: k.max_budget },
9554
+ spend: u?.total_spend ?? 0,
9555
+ prompt_tokens: u?.prompt_tokens ?? 0,
9556
+ completion_tokens: u?.completion_tokens ?? 0,
9557
+ total_tokens: u?.total_tokens ?? 0,
9558
+ api_requests: u?.api_requests ?? 0,
9559
+ successful_requests: u?.successful_requests ?? 0,
9560
+ failed_requests: u?.failed_requests ?? 0
9561
+ };
9562
+ });
9563
+ rows.sort(
9564
+ (a, b) => b.spend - a.spend || b.total_tokens - a.total_tokens || a.key.localeCompare(b.key)
9565
+ );
9566
+ return { team_id: teamId, keys: rows };
9567
+ }
9568
+ function redactTeamKeyUsage(usage) {
9569
+ return {
9570
+ ...usage,
9571
+ budget_hidden: true,
9572
+ keys: usage.keys.map(({ max_budget: _hidden, ...k }) => ({ ...k, spend: 0 }))
9573
+ };
9574
+ }
9533
9575
 
9534
9576
  // src/langfuse.ts
9535
9577
  function readLangfuseConfig(config) {
@@ -11480,6 +11522,22 @@ function registerTeamUsageRoutes(router, ctx) {
11480
11522
  teamBudgetVisibility
11481
11523
  } = ctx;
11482
11524
  const requireUser = createRequireUser(ctx);
11525
+ async function isTeamManager(team, tokenEntityRef) {
11526
+ const owningGroup = typeof team.metadata?.owning_group === "string" ? team.metadata.owning_group : void 0;
11527
+ if (ctx.teamMgmtEnabled && owningGroup && await isUserMemberOfGroup(tokenEntityRef, owningGroup, catalogClient, auth, logger)) {
11528
+ return true;
11529
+ }
11530
+ const suffix = ctx.memberBreakdownCfg.leadGroupSuffix;
11531
+ if (!suffix || !team.team_alias) return false;
11532
+ const slug = team.team_alias.toLowerCase().replace(/[^a-z0-9\-_.]/g, "-");
11533
+ return isUserMemberOfGroup(
11534
+ tokenEntityRef,
11535
+ `group:default/${slug}${suffix}`,
11536
+ catalogClient,
11537
+ auth,
11538
+ logger
11539
+ );
11540
+ }
11483
11541
  router.get("/teams/:teamId/usage", requireUser, async (req, res) => {
11484
11542
  try {
11485
11543
  const { teamId } = req.params;
@@ -11492,20 +11550,11 @@ function registerTeamUsageRoutes(router, ctx) {
11492
11550
  const userInfo = await getProvisionedUser(ctx, res);
11493
11551
  const isMember = userInfo?.teams?.includes(teamId) ?? false;
11494
11552
  let isManager = false;
11495
- const managerCheckNeeded = ctx.teamMgmtEnabled && (!isMember || teamBudgetVisibility.hideTeamBudgetForMembers !== teamBudgetVisibility.hideTeamBudgetForManagers);
11553
+ const managerCheckNeeded = (ctx.teamMgmtEnabled || !!ctx.memberBreakdownCfg.leadGroupSuffix) && (!isMember || teamBudgetVisibility.hideTeamBudgetForMembers !== teamBudgetVisibility.hideTeamBudgetForManagers);
11496
11554
  if (managerCheckNeeded) {
11497
11555
  try {
11498
11556
  const team = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
11499
- const owningGroup = typeof team.metadata?.owning_group === "string" ? team.metadata.owning_group : void 0;
11500
- if (owningGroup) {
11501
- isManager = await isUserMemberOfGroup(
11502
- tokenEntityRef,
11503
- owningGroup,
11504
- catalogClient,
11505
- auth,
11506
- logger
11507
- );
11508
- }
11557
+ isManager = await isTeamManager(team, tokenEntityRef);
11509
11558
  } catch (err) {
11510
11559
  const message2 = err instanceof Error ? err.message : String(err);
11511
11560
  logger.warn(`Team-manager check failed: ${message2}`);
@@ -11533,60 +11582,76 @@ function registerTeamUsageRoutes(router, ctx) {
11533
11582
  sendError(res, error, logger, "fetch team usage");
11534
11583
  }
11535
11584
  });
11536
- router.get("/teams/:teamId/usage/members", requireUser, async (req, res) => {
11585
+ async function authorizeMemberView(req, res) {
11537
11586
  const cfg = ctx.memberBreakdownCfg;
11538
11587
  if (!cfg.enabled) {
11539
11588
  res.status(404).json({ error: "Not found" });
11540
- return;
11589
+ return null;
11590
+ }
11591
+ const { teamId } = req.params;
11592
+ const { start_date, end_date } = req.query;
11593
+ if (!start_date || !end_date) {
11594
+ res.status(400).json({ error: "start_date and end_date are required" });
11595
+ return null;
11541
11596
  }
11597
+ const tokenEntityRef = res.locals.tokenEntityRef;
11598
+ const userInfo = await getProvisionedUser(ctx, res);
11599
+ const callerId = res.locals.userId;
11600
+ const isMember = userInfo?.teams?.includes(teamId) ?? false;
11601
+ let team;
11542
11602
  try {
11543
- const { teamId } = req.params;
11544
- const { start_date, end_date } = req.query;
11545
- if (!start_date || !end_date) {
11546
- res.status(400).json({ error: "start_date and end_date are required" });
11547
- return;
11548
- }
11549
- const tokenEntityRef = res.locals.tokenEntityRef;
11550
- const userInfo = await getProvisionedUser(ctx, res);
11551
- const callerId = res.locals.userId;
11552
- const isMember = userInfo?.teams?.includes(teamId) ?? false;
11553
- let team;
11554
- try {
11555
- team = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
11556
- } catch (err) {
11557
- if (err instanceof LiteLLMUpstreamError && err.status === 404) {
11558
- res.status(404).json({ error: "Team not found" });
11559
- return;
11560
- }
11561
- throw err;
11562
- }
11563
- const owningGroup = typeof team.metadata?.owning_group === "string" ? team.metadata.owning_group : void 0;
11564
- const isManager = ctx.teamMgmtEnabled && !!owningGroup && await isUserMemberOfGroup(tokenEntityRef, owningGroup, catalogClient, auth, logger);
11565
- if (!isMember && !isManager) {
11603
+ team = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
11604
+ } catch (err) {
11605
+ if (err instanceof LiteLLMUpstreamError && err.status === 404) {
11566
11606
  res.status(404).json({ error: "Team not found" });
11567
- return;
11568
- }
11569
- const role = team.members_with_roles?.find((m) => m.user_id === callerId)?.role;
11570
- const viaRole = isMember && !!role && cfg.viewerRoles.includes(role);
11571
- const allowed = isManager || viaRole || isMember && cfg.permissionEnabled && await ctx.assertPermission(req, import_backstage_plugin_litellm_common8.litellmTeamUsageReadPermission);
11572
- if (!allowed) {
11573
- res.status(403).json({
11574
- error: "Access denied: viewing per-member usage needs a team role or permission"
11575
- });
11576
- return;
11607
+ return null;
11577
11608
  }
11609
+ throw err;
11610
+ }
11611
+ const isManager = await isTeamManager(team, tokenEntityRef);
11612
+ if (!isMember && !isManager) {
11613
+ res.status(404).json({ error: "Team not found" });
11614
+ return null;
11615
+ }
11616
+ const role = team.members_with_roles?.find((m) => m.user_id === callerId)?.role;
11617
+ const viaRole = isMember && !!role && cfg.viewerRoles.includes(role);
11618
+ const allowed = isManager || viaRole || isMember && cfg.permissionEnabled && await ctx.assertPermission(req, import_backstage_plugin_litellm_common8.litellmTeamUsageReadPermission);
11619
+ if (!allowed) {
11620
+ res.status(403).json({
11621
+ error: "Access denied: viewing per-member usage needs a team role or permission"
11622
+ });
11623
+ return null;
11624
+ }
11625
+ let via = "permission";
11626
+ if (isManager) via = "manager";
11627
+ else if (viaRole) via = "teamRole";
11628
+ return {
11629
+ teamId,
11630
+ team,
11631
+ startDate: start_date,
11632
+ endDate: end_date,
11633
+ isManager,
11634
+ via
11635
+ };
11636
+ }
11637
+ const hideFor = (isManager) => isManager ? teamBudgetVisibility.hideTeamBudgetForManagers : teamBudgetVisibility.hideTeamBudgetForMembers;
11638
+ router.get("/teams/:teamId/usage/members", requireUser, async (req, res) => {
11639
+ try {
11640
+ const gate = await authorizeMemberView(req, res);
11641
+ if (!gate) return;
11642
+ const { teamId, team, startDate, endDate, isManager, via } = gate;
11578
11643
  const [usage, keys] = await Promise.all([
11579
- client.getTeamUsage(teamId, start_date, end_date),
11644
+ client.getTeamUsage(teamId, startDate, endDate),
11580
11645
  client.listTeamKeys(teamId)
11581
11646
  ]);
11582
11647
  const profiles = await lookupProfiles(memberEmails(team, keys));
11583
11648
  const breakdown = buildTeamMemberUsage(teamId, usage, keys, team, profiles);
11584
- let via = "permission";
11585
- if (isManager) via = "manager";
11586
- else if (viaRole) via = "teamRole";
11587
- logger.info("team.usage.members.read", { actor: tokenEntityRef, teamId, via });
11588
- const hide = isManager ? teamBudgetVisibility.hideTeamBudgetForManagers : teamBudgetVisibility.hideTeamBudgetForMembers;
11589
- res.json(hide ? redactTeamMemberUsage(breakdown) : breakdown);
11649
+ logger.info("team.usage.members.read", {
11650
+ actor: res.locals.tokenEntityRef,
11651
+ teamId,
11652
+ via
11653
+ });
11654
+ res.json(hideFor(isManager) ? redactTeamMemberUsage(breakdown) : breakdown);
11590
11655
  } catch (error) {
11591
11656
  if (error instanceof ProvisioningError) {
11592
11657
  res.status(error.status).json(error.body);
@@ -11595,6 +11660,31 @@ function registerTeamUsageRoutes(router, ctx) {
11595
11660
  sendError(res, error, logger, "fetch team member usage");
11596
11661
  }
11597
11662
  });
11663
+ router.get("/teams/:teamId/usage/keys", requireUser, async (req, res) => {
11664
+ try {
11665
+ const gate = await authorizeMemberView(req, res);
11666
+ if (!gate) return;
11667
+ const { teamId, team, startDate, endDate, isManager, via } = gate;
11668
+ const [usage, keys] = await Promise.all([
11669
+ client.getTeamUsage(teamId, startDate, endDate),
11670
+ client.listTeamKeys(teamId)
11671
+ ]);
11672
+ const profiles = await lookupProfiles(memberEmails(team, keys));
11673
+ const keyUsage = buildTeamKeyUsage(teamId, usage, keys, team, profiles);
11674
+ logger.info("team.usage.keys.read", {
11675
+ actor: res.locals.tokenEntityRef,
11676
+ teamId,
11677
+ via
11678
+ });
11679
+ res.json(hideFor(isManager) ? redactTeamKeyUsage(keyUsage) : keyUsage);
11680
+ } catch (error) {
11681
+ if (error instanceof ProvisioningError) {
11682
+ res.status(error.status).json(error.body);
11683
+ return;
11684
+ }
11685
+ sendError(res, error, logger, "fetch team key usage");
11686
+ }
11687
+ });
11598
11688
  async function lookupProfiles(emails) {
11599
11689
  const profiles = /* @__PURE__ */ new Map();
11600
11690
  if (!emails.length) return profiles;