@acarmisc/backstage-plugin-litellm-backend 0.26.0 → 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/client.d.ts CHANGED
@@ -160,9 +160,10 @@ export declare class LiteLLMClient {
160
160
  }>>;
161
161
  /**
162
162
  * Lists the MCP servers registered on the LiteLLM proxy. Path is
163
- * `/mcp/server/list`; tolerates a bare array, `{ data: [...] }` or
163
+ * `/v1/mcp/server` (`/mcp/server/list` hangs on current proxies); tolerates a bare array, `{ data: [...] }` or
164
164
  * `{ servers: [...] }`. Normalised to `{ id, name?, url? }` — `id` prefers
165
- * `server_id` then `id` then `alias`/`name`.
165
+ * `server_id` then `id` then `alias`/`server_name`/`name`; the display name is
166
+ * `alias`, else `server_name`, else `name`.
166
167
  */
167
168
  listMcpServers(): Promise<Array<{
168
169
  id: string;
package/dist/client.js CHANGED
@@ -495,12 +495,13 @@ class LiteLLMClient {
495
495
  }
496
496
  /**
497
497
  * Lists the MCP servers registered on the LiteLLM proxy. Path is
498
- * `/mcp/server/list`; tolerates a bare array, `{ data: [...] }` or
498
+ * `/v1/mcp/server` (`/mcp/server/list` hangs on current proxies); tolerates a bare array, `{ data: [...] }` or
499
499
  * `{ servers: [...] }`. Normalised to `{ id, name?, url? }` — `id` prefers
500
- * `server_id` then `id` then `alias`/`name`.
500
+ * `server_id` then `id` then `alias`/`server_name`/`name`; the display name is
501
+ * `alias`, else `server_name`, else `name`.
501
502
  */
502
503
  async listMcpServers() {
503
- const raw = await this.request('/mcp/server/list');
504
+ const raw = await this.request('/v1/mcp/server');
504
505
  let rows = [];
505
506
  if (Array.isArray(raw))
506
507
  rows = raw;
@@ -510,8 +511,8 @@ class LiteLLMClient {
510
511
  rows = raw.servers;
511
512
  return rows
512
513
  .map(r => ({
513
- id: r?.server_id ?? r?.id ?? r?.alias ?? r?.name ?? '',
514
- name: r?.alias ?? r?.name ?? undefined,
514
+ id: r?.server_id ?? r?.id ?? r?.alias ?? r?.server_name ?? r?.name ?? '',
515
+ name: r?.alias ?? r?.server_name ?? r?.name ?? undefined,
515
516
  url: r?.url ?? undefined,
516
517
  }))
517
518
  .filter(r => r.id);
package/dist/index.cjs.js CHANGED
@@ -8478,19 +8478,20 @@ var LiteLLMClient = class {
8478
8478
  }
8479
8479
  /**
8480
8480
  * Lists the MCP servers registered on the LiteLLM proxy. Path is
8481
- * `/mcp/server/list`; tolerates a bare array, `{ data: [...] }` or
8481
+ * `/v1/mcp/server` (`/mcp/server/list` hangs on current proxies); tolerates a bare array, `{ data: [...] }` or
8482
8482
  * `{ servers: [...] }`. Normalised to `{ id, name?, url? }` — `id` prefers
8483
- * `server_id` then `id` then `alias`/`name`.
8483
+ * `server_id` then `id` then `alias`/`server_name`/`name`; the display name is
8484
+ * `alias`, else `server_name`, else `name`.
8484
8485
  */
8485
8486
  async listMcpServers() {
8486
- const raw = await this.request("/mcp/server/list");
8487
+ const raw = await this.request("/v1/mcp/server");
8487
8488
  let rows = [];
8488
8489
  if (Array.isArray(raw)) rows = raw;
8489
8490
  else if (Array.isArray(raw?.data)) rows = raw.data;
8490
8491
  else if (Array.isArray(raw?.servers)) rows = raw.servers;
8491
8492
  return rows.map((r) => ({
8492
- id: r?.server_id ?? r?.id ?? r?.alias ?? r?.name ?? "",
8493
- name: r?.alias ?? r?.name ?? void 0,
8493
+ id: r?.server_id ?? r?.id ?? r?.alias ?? r?.server_name ?? r?.name ?? "",
8494
+ name: r?.alias ?? r?.server_name ?? r?.name ?? void 0,
8494
8495
  url: r?.url ?? void 0
8495
8496
  })).filter((r) => r.id);
8496
8497
  }
@@ -9529,6 +9530,47 @@ function redactTeamMemberUsage(usage) {
9529
9530
  members: usage.members.map((m) => ({ ...m, spend: 0 }))
9530
9531
  };
9531
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
+ }
9532
9574
 
9533
9575
  // src/langfuse.ts
9534
9576
  function readLangfuseConfig(config) {
@@ -11532,60 +11574,77 @@ function registerTeamUsageRoutes(router, ctx) {
11532
11574
  sendError(res, error, logger, "fetch team usage");
11533
11575
  }
11534
11576
  });
11535
- router.get("/teams/:teamId/usage/members", requireUser, async (req, res) => {
11577
+ async function authorizeMemberView(req, res) {
11536
11578
  const cfg = ctx.memberBreakdownCfg;
11537
11579
  if (!cfg.enabled) {
11538
11580
  res.status(404).json({ error: "Not found" });
11539
- 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;
11540
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;
11541
11594
  try {
11542
- const { teamId } = req.params;
11543
- const { start_date, end_date } = req.query;
11544
- if (!start_date || !end_date) {
11545
- res.status(400).json({ error: "start_date and end_date are required" });
11546
- return;
11547
- }
11548
- const tokenEntityRef = res.locals.tokenEntityRef;
11549
- const userInfo = await getProvisionedUser(ctx, res);
11550
- const callerId = res.locals.userId;
11551
- const isMember = userInfo?.teams?.includes(teamId) ?? false;
11552
- let team;
11553
- try {
11554
- team = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
11555
- } catch (err) {
11556
- if (err instanceof LiteLLMUpstreamError && err.status === 404) {
11557
- res.status(404).json({ error: "Team not found" });
11558
- return;
11559
- }
11560
- throw err;
11561
- }
11562
- const owningGroup = typeof team.metadata?.owning_group === "string" ? team.metadata.owning_group : void 0;
11563
- const isManager = ctx.teamMgmtEnabled && !!owningGroup && await isUserMemberOfGroup(tokenEntityRef, owningGroup, catalogClient, auth, logger);
11564
- if (!isMember && !isManager) {
11595
+ team = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
11596
+ } catch (err) {
11597
+ if (err instanceof LiteLLMUpstreamError && err.status === 404) {
11565
11598
  res.status(404).json({ error: "Team not found" });
11566
- return;
11567
- }
11568
- const role = team.members_with_roles?.find((m) => m.user_id === callerId)?.role;
11569
- const viaRole = isMember && !!role && cfg.viewerRoles.includes(role);
11570
- const allowed = isManager || viaRole || isMember && cfg.permissionEnabled && await ctx.assertPermission(req, import_backstage_plugin_litellm_common8.litellmTeamUsageReadPermission);
11571
- if (!allowed) {
11572
- res.status(403).json({
11573
- error: "Access denied: viewing per-member usage needs a team role or permission"
11574
- });
11575
- return;
11599
+ return null;
11576
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;
11577
11636
  const [usage, keys] = await Promise.all([
11578
- client.getTeamUsage(teamId, start_date, end_date),
11637
+ client.getTeamUsage(teamId, startDate, endDate),
11579
11638
  client.listTeamKeys(teamId)
11580
11639
  ]);
11581
11640
  const profiles = await lookupProfiles(memberEmails(team, keys));
11582
11641
  const breakdown = buildTeamMemberUsage(teamId, usage, keys, team, profiles);
11583
- let via = "permission";
11584
- if (isManager) via = "manager";
11585
- else if (viaRole) via = "teamRole";
11586
- logger.info("team.usage.members.read", { actor: tokenEntityRef, teamId, via });
11587
- const hide = isManager ? teamBudgetVisibility.hideTeamBudgetForManagers : teamBudgetVisibility.hideTeamBudgetForMembers;
11588
- 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);
11589
11648
  } catch (error) {
11590
11649
  if (error instanceof ProvisioningError) {
11591
11650
  res.status(error.status).json(error.body);
@@ -11594,6 +11653,31 @@ function registerTeamUsageRoutes(router, ctx) {
11594
11653
  sendError(res, error, logger, "fetch team member usage");
11595
11654
  }
11596
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
+ });
11597
11681
  async function lookupProfiles(emails) {
11598
11682
  const profiles = /* @__PURE__ */ new Map();
11599
11683
  if (!emails.length) return profiles;