@acarmisc/backstage-plugin-litellm-backend 0.26.1 → 0.27.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.cjs.js CHANGED
@@ -9530,6 +9530,47 @@ function redactTeamMemberUsage(usage) {
9530
9530
  members: usage.members.map((m) => ({ ...m, spend: 0 }))
9531
9531
  };
9532
9532
  }
9533
+ function buildTeamKeyUsage(teamId, usage, keys, team, profiles = /* @__PURE__ */ new Map()) {
9534
+ const emailById = /* @__PURE__ */ new Map();
9535
+ for (const m of team.members_with_roles ?? []) {
9536
+ if (m.user_id && m.user_email) emailById.set(m.user_id, m.user_email);
9537
+ }
9538
+ const rows = keys.map((k) => {
9539
+ const u = k.token && usage.usage_by_key?.[k.token] || void 0;
9540
+ const userId = k.user_id ?? null;
9541
+ const email = userId && emailById.get(userId) || (userId?.includes("@") ? userId : void 0);
9542
+ const displayName = email && profiles.get(email.toLowerCase())?.displayName;
9543
+ return {
9544
+ key: k.key,
9545
+ ...k.key_alias && { key_alias: k.key_alias },
9546
+ user_id: userId,
9547
+ ...email && { user_email: email },
9548
+ ...displayName && { display_name: displayName },
9549
+ created_at: k.created_at,
9550
+ ...k.expires_at && { expires_at: k.expires_at },
9551
+ blocked: k.blocked === true,
9552
+ ...k.max_budget !== void 0 && { max_budget: k.max_budget },
9553
+ spend: u?.total_spend ?? 0,
9554
+ prompt_tokens: u?.prompt_tokens ?? 0,
9555
+ completion_tokens: u?.completion_tokens ?? 0,
9556
+ total_tokens: u?.total_tokens ?? 0,
9557
+ api_requests: u?.api_requests ?? 0,
9558
+ successful_requests: u?.successful_requests ?? 0,
9559
+ failed_requests: u?.failed_requests ?? 0
9560
+ };
9561
+ });
9562
+ rows.sort(
9563
+ (a, b) => b.spend - a.spend || b.total_tokens - a.total_tokens || a.key.localeCompare(b.key)
9564
+ );
9565
+ return { team_id: teamId, keys: rows };
9566
+ }
9567
+ function redactTeamKeyUsage(usage) {
9568
+ return {
9569
+ ...usage,
9570
+ budget_hidden: true,
9571
+ keys: usage.keys.map(({ max_budget: _hidden, ...k }) => ({ ...k, spend: 0 }))
9572
+ };
9573
+ }
9533
9574
 
9534
9575
  // src/langfuse.ts
9535
9576
  function readLangfuseConfig(config) {
@@ -11533,60 +11574,77 @@ function registerTeamUsageRoutes(router, ctx) {
11533
11574
  sendError(res, error, logger, "fetch team usage");
11534
11575
  }
11535
11576
  });
11536
- router.get("/teams/:teamId/usage/members", requireUser, async (req, res) => {
11577
+ async function authorizeMemberView(req, res) {
11537
11578
  const cfg = ctx.memberBreakdownCfg;
11538
11579
  if (!cfg.enabled) {
11539
11580
  res.status(404).json({ error: "Not found" });
11540
- return;
11581
+ return null;
11582
+ }
11583
+ const { teamId } = req.params;
11584
+ const { start_date, end_date } = req.query;
11585
+ if (!start_date || !end_date) {
11586
+ res.status(400).json({ error: "start_date and end_date are required" });
11587
+ return null;
11541
11588
  }
11589
+ const tokenEntityRef = res.locals.tokenEntityRef;
11590
+ const userInfo = await getProvisionedUser(ctx, res);
11591
+ const callerId = res.locals.userId;
11592
+ const isMember = userInfo?.teams?.includes(teamId) ?? false;
11593
+ let team;
11542
11594
  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) {
11595
+ team = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
11596
+ } catch (err) {
11597
+ if (err instanceof LiteLLMUpstreamError && err.status === 404) {
11566
11598
  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;
11599
+ return null;
11577
11600
  }
11601
+ throw err;
11602
+ }
11603
+ const owningGroup = typeof team.metadata?.owning_group === "string" ? team.metadata.owning_group : void 0;
11604
+ const isManager = ctx.teamMgmtEnabled && !!owningGroup && await isUserMemberOfGroup(tokenEntityRef, owningGroup, catalogClient, auth, logger);
11605
+ if (!isMember && !isManager) {
11606
+ res.status(404).json({ error: "Team not found" });
11607
+ return null;
11608
+ }
11609
+ const role = team.members_with_roles?.find((m) => m.user_id === callerId)?.role;
11610
+ const viaRole = isMember && !!role && cfg.viewerRoles.includes(role);
11611
+ const allowed = isManager || viaRole || isMember && cfg.permissionEnabled && await ctx.assertPermission(req, import_backstage_plugin_litellm_common8.litellmTeamUsageReadPermission);
11612
+ if (!allowed) {
11613
+ res.status(403).json({
11614
+ error: "Access denied: viewing per-member usage needs a team role or permission"
11615
+ });
11616
+ return null;
11617
+ }
11618
+ let via = "permission";
11619
+ if (isManager) via = "manager";
11620
+ else if (viaRole) via = "teamRole";
11621
+ return {
11622
+ teamId,
11623
+ team,
11624
+ startDate: start_date,
11625
+ endDate: end_date,
11626
+ isManager,
11627
+ via
11628
+ };
11629
+ }
11630
+ const hideFor = (isManager) => isManager ? teamBudgetVisibility.hideTeamBudgetForManagers : teamBudgetVisibility.hideTeamBudgetForMembers;
11631
+ router.get("/teams/:teamId/usage/members", requireUser, async (req, res) => {
11632
+ try {
11633
+ const gate = await authorizeMemberView(req, res);
11634
+ if (!gate) return;
11635
+ const { teamId, team, startDate, endDate, isManager, via } = gate;
11578
11636
  const [usage, keys] = await Promise.all([
11579
- client.getTeamUsage(teamId, start_date, end_date),
11637
+ client.getTeamUsage(teamId, startDate, endDate),
11580
11638
  client.listTeamKeys(teamId)
11581
11639
  ]);
11582
11640
  const profiles = await lookupProfiles(memberEmails(team, keys));
11583
11641
  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);
11642
+ logger.info("team.usage.members.read", {
11643
+ actor: res.locals.tokenEntityRef,
11644
+ teamId,
11645
+ via
11646
+ });
11647
+ res.json(hideFor(isManager) ? redactTeamMemberUsage(breakdown) : breakdown);
11590
11648
  } catch (error) {
11591
11649
  if (error instanceof ProvisioningError) {
11592
11650
  res.status(error.status).json(error.body);
@@ -11595,6 +11653,31 @@ function registerTeamUsageRoutes(router, ctx) {
11595
11653
  sendError(res, error, logger, "fetch team member usage");
11596
11654
  }
11597
11655
  });
11656
+ router.get("/teams/:teamId/usage/keys", requireUser, async (req, res) => {
11657
+ try {
11658
+ const gate = await authorizeMemberView(req, res);
11659
+ if (!gate) return;
11660
+ const { teamId, team, startDate, endDate, isManager, via } = gate;
11661
+ const [usage, keys] = await Promise.all([
11662
+ client.getTeamUsage(teamId, startDate, endDate),
11663
+ client.listTeamKeys(teamId)
11664
+ ]);
11665
+ const profiles = await lookupProfiles(memberEmails(team, keys));
11666
+ const keyUsage = buildTeamKeyUsage(teamId, usage, keys, team, profiles);
11667
+ logger.info("team.usage.keys.read", {
11668
+ actor: res.locals.tokenEntityRef,
11669
+ teamId,
11670
+ via
11671
+ });
11672
+ res.json(hideFor(isManager) ? redactTeamKeyUsage(keyUsage) : keyUsage);
11673
+ } catch (error) {
11674
+ if (error instanceof ProvisioningError) {
11675
+ res.status(error.status).json(error.body);
11676
+ return;
11677
+ }
11678
+ sendError(res, error, logger, "fetch team key usage");
11679
+ }
11680
+ });
11598
11681
  async function lookupProfiles(emails) {
11599
11682
  const profiles = /* @__PURE__ */ new Map();
11600
11683
  if (!emails.length) return profiles;