@acarmisc/backstage-plugin-litellm-backend 0.19.0 → 0.20.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
@@ -7982,20 +7982,20 @@ __export(index_exports, {
7982
7982
  getOrProvisionUser: () => getOrProvisionUser,
7983
7983
  getOrProvisionUserFromClaims: () => getOrProvisionUserFromClaims,
7984
7984
  isTeamManagementEnabled: () => isTeamManagementEnabled,
7985
- litellmAuditReadPermission: () => import_backstage_plugin_litellm_common11.litellmAuditReadPermission,
7986
- litellmKeyCreatePermission: () => import_backstage_plugin_litellm_common11.litellmKeyCreatePermission,
7987
- litellmKeyManagePermission: () => import_backstage_plugin_litellm_common11.litellmKeyManagePermission,
7988
- litellmKeyResetSpendPermission: () => import_backstage_plugin_litellm_common11.litellmKeyResetSpendPermission,
7989
- litellmKeyRevokePermission: () => import_backstage_plugin_litellm_common11.litellmKeyRevokePermission,
7990
- litellmKeyUnblockPermission: () => import_backstage_plugin_litellm_common11.litellmKeyUnblockPermission,
7991
- litellmPermissions: () => import_backstage_plugin_litellm_common11.litellmPermissions,
7985
+ litellmAuditReadPermission: () => import_backstage_plugin_litellm_common12.litellmAuditReadPermission,
7986
+ litellmKeyCreatePermission: () => import_backstage_plugin_litellm_common12.litellmKeyCreatePermission,
7987
+ litellmKeyManagePermission: () => import_backstage_plugin_litellm_common12.litellmKeyManagePermission,
7988
+ litellmKeyResetSpendPermission: () => import_backstage_plugin_litellm_common12.litellmKeyResetSpendPermission,
7989
+ litellmKeyRevokePermission: () => import_backstage_plugin_litellm_common12.litellmKeyRevokePermission,
7990
+ litellmKeyUnblockPermission: () => import_backstage_plugin_litellm_common12.litellmKeyUnblockPermission,
7991
+ litellmPermissions: () => import_backstage_plugin_litellm_common12.litellmPermissions,
7992
7992
  litellmPlugin: () => litellmPlugin,
7993
- litellmTeamCreatePermission: () => import_backstage_plugin_litellm_common11.litellmTeamCreatePermission,
7994
- litellmTeamDeletePermission: () => import_backstage_plugin_litellm_common11.litellmTeamDeletePermission,
7995
- litellmTeamKnowledgebaseManagePermission: () => import_backstage_plugin_litellm_common11.litellmTeamKnowledgebaseManagePermission,
7996
- litellmTeamManagePermission: () => import_backstage_plugin_litellm_common11.litellmTeamManagePermission,
7997
- litellmTeamMcpManagePermission: () => import_backstage_plugin_litellm_common11.litellmTeamMcpManagePermission,
7998
- litellmTeamMembersManagePermission: () => import_backstage_plugin_litellm_common11.litellmTeamMembersManagePermission,
7993
+ litellmTeamCreatePermission: () => import_backstage_plugin_litellm_common12.litellmTeamCreatePermission,
7994
+ litellmTeamDeletePermission: () => import_backstage_plugin_litellm_common12.litellmTeamDeletePermission,
7995
+ litellmTeamKnowledgebaseManagePermission: () => import_backstage_plugin_litellm_common12.litellmTeamKnowledgebaseManagePermission,
7996
+ litellmTeamManagePermission: () => import_backstage_plugin_litellm_common12.litellmTeamManagePermission,
7997
+ litellmTeamMcpManagePermission: () => import_backstage_plugin_litellm_common12.litellmTeamMcpManagePermission,
7998
+ litellmTeamMembersManagePermission: () => import_backstage_plugin_litellm_common12.litellmTeamMembersManagePermission,
7999
7999
  newDefaultVerifier: () => newDefaultVerifier,
8000
8000
  provisionUser: () => provisionUser,
8001
8001
  readBridgeConfig: () => readBridgeConfig,
@@ -8197,6 +8197,28 @@ var LiteLLMClient = class {
8197
8197
  throw err;
8198
8198
  }
8199
8199
  }
8200
+ /**
8201
+ * Lists every key bound to a team (any owner), following `/key/list`
8202
+ * pagination. Stops after 50 pages of 100 keys.
8203
+ */
8204
+ async listTeamKeys(teamId) {
8205
+ const keys = [];
8206
+ for (let page = 1; page <= 50; page++) {
8207
+ const params = new URLSearchParams({
8208
+ team_id: teamId,
8209
+ return_full_object: "true",
8210
+ page: String(page),
8211
+ size: "100"
8212
+ });
8213
+ const response = await this.request(`/key/list?${params.toString()}`);
8214
+ const rawKeys = (response.keys ?? []).filter(
8215
+ (k) => typeof k === "object" && k !== null
8216
+ );
8217
+ keys.push(...rawKeys.map(this.toVirtualKey));
8218
+ if (rawKeys.length === 0 || page >= (response.total_pages ?? 1)) break;
8219
+ }
8220
+ return keys;
8221
+ }
8200
8222
  toVirtualKey(k) {
8201
8223
  return {
8202
8224
  key: k.key_name ?? k.token,
@@ -8901,6 +8923,38 @@ var ProvisioningError = class extends Error {
8901
8923
  this.body = { error: message2, hint, provisioning };
8902
8924
  }
8903
8925
  };
8926
+ var adoptedUserIds = /* @__PURE__ */ new Map();
8927
+ function effectiveUserId(userId) {
8928
+ return adoptedUserIds.get(userId) ?? userId;
8929
+ }
8930
+ async function getUserByIdOrAdopted(client, userId) {
8931
+ const adopted = adoptedUserIds.get(userId);
8932
+ if (adopted) {
8933
+ const info = await client.getUserInfo(adopted);
8934
+ if (info) return info;
8935
+ adoptedUserIds.delete(userId);
8936
+ }
8937
+ return client.getUserInfo(userId);
8938
+ }
8939
+ async function adoptUserByEmail(client, userId, email, logger) {
8940
+ if (!email) return null;
8941
+ const byEmail = await client.getUserByEmail(email);
8942
+ if (!byEmail?.user_id) return null;
8943
+ if (byEmail.user_id !== userId) {
8944
+ logger.info(
8945
+ `Found existing LiteLLM user ${byEmail.user_id} by email ${email}, reusing for ${userId}`
8946
+ );
8947
+ adoptedUserIds.set(userId, byEmail.user_id);
8948
+ }
8949
+ return byEmail;
8950
+ }
8951
+ async function findLiteLLMUser(client, tokenEntityRef, userId, catalogClient, auth, logger) {
8952
+ const existing = await getUserByIdOrAdopted(client, userId);
8953
+ if (existing) return existing;
8954
+ if (!tokenEntityRef) return null;
8955
+ const profile = await resolveUserProfile(tokenEntityRef, catalogClient, auth, logger);
8956
+ return adoptUserByEmail(client, userId, profile.email, logger);
8957
+ }
8904
8958
  async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger) {
8905
8959
  if (!userId) {
8906
8960
  throw new ProvisioningError(
@@ -8909,17 +8963,10 @@ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEn
8909
8963
  provisioningEnabled
8910
8964
  );
8911
8965
  }
8912
- const existing = await client.getUserInfo(userId);
8966
+ const existing = await getUserByIdOrAdopted(client, userId);
8913
8967
  if (existing) {
8914
8968
  return existing;
8915
8969
  }
8916
- if (!provisioningEnabled) {
8917
- throw new ProvisioningError(
8918
- "User not found in LiteLLM",
8919
- "Enable litellm.provisioning.enabled in app-config.yaml or create the user manually",
8920
- false
8921
- );
8922
- }
8923
8970
  const pending = provisioningInFlight.get(userId);
8924
8971
  if (pending) {
8925
8972
  logger.info(
@@ -8928,11 +8975,25 @@ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEn
8928
8975
  return pending;
8929
8976
  }
8930
8977
  const provisionPromise = (async () => {
8931
- const catalogRef = tokenEntityRef ?? userId;
8932
- const [matchedRole, profile] = await Promise.all([
8933
- resolveUserRole(catalogRef, roleConfigs, catalogClient, auth, logger),
8934
- tokenEntityRef ? resolveUserProfile(tokenEntityRef, catalogClient, auth, logger) : Promise.resolve({})
8935
- ]);
8978
+ const profile = tokenEntityRef ? await resolveUserProfile(tokenEntityRef, catalogClient, auth, logger) : {};
8979
+ const byEmail = await adoptUserByEmail(client, userId, profile.email, logger);
8980
+ if (byEmail) {
8981
+ return byEmail;
8982
+ }
8983
+ if (!provisioningEnabled) {
8984
+ throw new ProvisioningError(
8985
+ "User not found in LiteLLM",
8986
+ "Enable litellm.provisioning.enabled in app-config.yaml or create the user manually",
8987
+ false
8988
+ );
8989
+ }
8990
+ const matchedRole = await resolveUserRole(
8991
+ tokenEntityRef ?? userId,
8992
+ roleConfigs,
8993
+ catalogClient,
8994
+ auth,
8995
+ logger
8996
+ );
8936
8997
  const effectiveDefaults = matchedRole ? applyRoleOverrides(provisioningDefaults, matchedRole) : provisioningDefaults;
8937
8998
  if (matchedRole) {
8938
8999
  logger.info(
@@ -8961,7 +9022,7 @@ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEn
8961
9022
  logger.info(
8962
9023
  `LiteLLM user ${userId} already exists during provisioning \u2014 re-fetching`
8963
9024
  );
8964
- const refetched = await client.getUserInfo(userId);
9025
+ const refetched = await client.getUserInfo(userId) ?? await adoptUserByEmail(client, userId, profile.email, logger);
8965
9026
  if (refetched) {
8966
9027
  return refetched;
8967
9028
  }
@@ -9022,7 +9083,7 @@ async function resolveUserRole(userEntityRef, roleConfigs, catalogClient, auth,
9022
9083
  }
9023
9084
 
9024
9085
  // src/router.ts
9025
- var import_backstage_plugin_litellm_common9 = require("@acarmisc/backstage-plugin-litellm-common");
9086
+ var import_backstage_plugin_litellm_common10 = require("@acarmisc/backstage-plugin-litellm-common");
9026
9087
 
9027
9088
  // src/teamAdmin.ts
9028
9089
  var import_plugin_permission_common = __toESM(require_index_cjs2());
@@ -9297,6 +9358,93 @@ function readOpencodeConfig(config) {
9297
9358
  };
9298
9359
  }
9299
9360
 
9361
+ // src/teamMemberUsage.ts
9362
+ function readMemberBreakdownConfig(config) {
9363
+ return {
9364
+ enabled: config.getOptionalBoolean("litellm.teamUsage.memberBreakdown.enabled") ?? false,
9365
+ viewerRoles: config.getOptionalStringArray("litellm.teamUsage.memberBreakdown.viewerRoles") ?? [],
9366
+ permissionEnabled: config.getOptionalBoolean("permission.enabled") ?? false
9367
+ };
9368
+ }
9369
+ function memberEmails(team, keys) {
9370
+ const emails = /* @__PURE__ */ new Set();
9371
+ for (const m of team.members_with_roles ?? []) {
9372
+ if (m.user_email) emails.add(m.user_email.toLowerCase());
9373
+ else if (m.user_id?.includes("@")) emails.add(m.user_id.toLowerCase());
9374
+ }
9375
+ for (const k of keys) {
9376
+ if (k.user_id?.includes("@")) emails.add(k.user_id.toLowerCase());
9377
+ }
9378
+ return [...emails];
9379
+ }
9380
+ function buildTeamMemberUsage(teamId, usage, keys, team, profiles = /* @__PURE__ */ new Map()) {
9381
+ const rows = /* @__PURE__ */ new Map();
9382
+ const rowFor = (userId) => {
9383
+ let row = rows.get(userId);
9384
+ if (!row) {
9385
+ row = {
9386
+ user_id: userId,
9387
+ spend: 0,
9388
+ spend_share_pct: 0,
9389
+ prompt_tokens: 0,
9390
+ completion_tokens: 0,
9391
+ total_tokens: 0,
9392
+ api_requests: 0,
9393
+ successful_requests: 0,
9394
+ failed_requests: 0,
9395
+ success_rate: null,
9396
+ key_count: 0
9397
+ };
9398
+ rows.set(userId, row);
9399
+ }
9400
+ return row;
9401
+ };
9402
+ for (const m of team.members_with_roles ?? []) {
9403
+ if (!m.user_id) continue;
9404
+ const row = rowFor(m.user_id);
9405
+ row.role = m.role;
9406
+ if (m.user_email) row.user_email = m.user_email;
9407
+ }
9408
+ const ownerByKey = /* @__PURE__ */ new Map();
9409
+ for (const k of keys) {
9410
+ if (!k.user_id || !k.token) continue;
9411
+ ownerByKey.set(k.token, k.user_id);
9412
+ rowFor(k.user_id).key_count += 1;
9413
+ }
9414
+ for (const [keyHash, k] of Object.entries(usage.usage_by_key ?? {})) {
9415
+ const row = rowFor(ownerByKey.get(keyHash) ?? null);
9416
+ row.spend += k.total_spend ?? 0;
9417
+ row.prompt_tokens += k.prompt_tokens ?? 0;
9418
+ row.completion_tokens += k.completion_tokens ?? 0;
9419
+ row.total_tokens += k.total_tokens ?? 0;
9420
+ row.api_requests += k.api_requests ?? 0;
9421
+ row.successful_requests += k.successful_requests ?? 0;
9422
+ row.failed_requests += k.failed_requests ?? 0;
9423
+ }
9424
+ const members = [...rows.values()];
9425
+ const totalSpend = members.reduce((sum, r) => sum + r.spend, 0);
9426
+ for (const row of members) {
9427
+ row.spend_share_pct = totalSpend > 0 ? row.spend / totalSpend * 100 : 0;
9428
+ const finished = row.successful_requests + row.failed_requests;
9429
+ row.success_rate = finished > 0 ? row.successful_requests / finished * 100 : null;
9430
+ if (!row.user_email && row.user_id?.includes("@")) row.user_email = row.user_id;
9431
+ const profile = row.user_email && profiles.get(row.user_email.toLowerCase());
9432
+ if (profile && profile.displayName) row.display_name = profile.displayName;
9433
+ }
9434
+ members.sort(
9435
+ (a, b) => b.spend - a.spend || b.total_tokens - a.total_tokens || String(a.user_id ?? "\uFFFF").localeCompare(String(b.user_id ?? "\uFFFF"))
9436
+ );
9437
+ return { team_id: teamId, total_spend: totalSpend, members };
9438
+ }
9439
+ function redactTeamMemberUsage(usage) {
9440
+ return {
9441
+ ...usage,
9442
+ total_spend: 0,
9443
+ budget_hidden: true,
9444
+ members: usage.members.map((m) => ({ ...m, spend: 0 }))
9445
+ };
9446
+ }
9447
+
9300
9448
  // src/routes/middleware/withUser.ts
9301
9449
  function createRequireUser(ctx) {
9302
9450
  return async (req, res, next) => {
@@ -9317,7 +9465,7 @@ function createRequireUser(ctx) {
9317
9465
  async function getProvisionedUser(ctx, res) {
9318
9466
  const tokenEntityRef = res.locals.tokenEntityRef;
9319
9467
  const userId = res.locals.userId;
9320
- return getOrProvisionUser(
9468
+ const userInfo = await getOrProvisionUser(
9321
9469
  ctx.client,
9322
9470
  tokenEntityRef,
9323
9471
  userId,
@@ -9328,6 +9476,8 @@ async function getProvisionedUser(ctx, res) {
9328
9476
  ctx.auth,
9329
9477
  ctx.logger
9330
9478
  );
9479
+ res.locals.userId = effectiveUserId(userId);
9480
+ return userInfo;
9331
9481
  }
9332
9482
 
9333
9483
  // src/services/userInfoCache.ts
@@ -9536,6 +9686,7 @@ var openApiSpec = {
9536
9686
  team_id: { type: "string" },
9537
9687
  duration: { type: "string" },
9538
9688
  max_budget: { type: "number", nullable: true, description: "Positive number caps spend; null = unlimited" },
9689
+ budget_duration: { type: "string", description: 'How often max_budget resets (e.g. "30d", "1mo"); one of litellm.keys.allowedBudgetDurations' },
9539
9690
  tpm_limit: { type: "number" },
9540
9691
  rpm_limit: { type: "number" }
9541
9692
  } } } }
@@ -9632,6 +9783,24 @@ var openApiSpec = {
9632
9783
  responses: { "200": { description: "Team usage" }, "400": { description: "Missing date range" } }
9633
9784
  }
9634
9785
  },
9786
+ "/teams/{teamId}/usage/members": {
9787
+ get: {
9788
+ tags: ["Teams"],
9789
+ summary: "Per-member usage breakdown for a team",
9790
+ description: "Needs litellm.teamUsage.memberBreakdown.enabled. Allowed for team managers, members holding one of memberBreakdown.viewerRoles, and members granted litellm.team.usage.read. Spend is zeroed (with budget_hidden: true) under team budget hiding; spend_share_pct remains.",
9791
+ parameters: [
9792
+ { name: "teamId", in: "path", required: true, schema: { type: "string" } },
9793
+ { name: "start_date", in: "query", required: true, schema: { type: "string" } },
9794
+ { name: "end_date", in: "query", required: true, schema: { type: "string" } }
9795
+ ],
9796
+ responses: {
9797
+ "200": { description: "One row per member, sorted by spend" },
9798
+ "400": { description: "Missing date range" },
9799
+ "403": { description: "Caller may see the team but not its member breakdown" },
9800
+ "404": { description: "Feature disabled, or team not found / not visible to the caller" }
9801
+ }
9802
+ }
9803
+ },
9635
9804
  "/usage": {
9636
9805
  get: {
9637
9806
  tags: ["Usage"],
@@ -9737,6 +9906,9 @@ function registerConfigRoutes(router, ctx) {
9737
9906
  readOnly: ctx.teamAdminCfg.readOnly,
9738
9907
  memberManagerRoles: ctx.teamAdminCfg.memberManagerRoles
9739
9908
  },
9909
+ teamUsage: {
9910
+ memberBreakdown: ctx.memberBreakdownCfg.enabled
9911
+ },
9740
9912
  display: {
9741
9913
  hideTeamBudgetForMembers: ctx.teamBudgetVisibility.hideTeamBudgetForMembers,
9742
9914
  hideTeamBudgetForManagers: ctx.teamBudgetVisibility.hideTeamBudgetForManagers
@@ -9908,6 +10080,9 @@ async function createKeyForUser(user, input, ctx) {
9908
10080
  if (input.max_budget !== void 0) {
9909
10081
  upstreamRequest.max_budget = input.max_budget ?? void 0;
9910
10082
  }
10083
+ if (input.budget_duration !== void 0) {
10084
+ upstreamRequest.budget_duration = input.budget_duration;
10085
+ }
9911
10086
  if (input.tpm_limit !== void 0) {
9912
10087
  upstreamRequest.tpm_limit = input.tpm_limit;
9913
10088
  }
@@ -9966,8 +10141,8 @@ function registerKeysRoutes(router, ctx) {
9966
10141
  });
9967
10142
  router.get("/keys", async (_req, res) => {
9968
10143
  try {
9969
- const userId = res.locals.userId;
9970
10144
  await getProvisionedUser(ctx, res);
10145
+ const userId = res.locals.userId;
9971
10146
  const keys = await client.listKeys(userId);
9972
10147
  res.json(keys);
9973
10148
  } catch (error) {
@@ -9984,8 +10159,8 @@ function registerKeysRoutes(router, ctx) {
9984
10159
  sendPermissionDenied(res, import_backstage_plugin_litellm_common3.litellmKeyRevokePermission);
9985
10160
  return;
9986
10161
  }
9987
- const userId = res.locals.userId;
9988
10162
  await getProvisionedUser(ctx, res);
10163
+ const userId = res.locals.userId;
9989
10164
  const keys = await client.listKeys(userId);
9990
10165
  const now = Date.now();
9991
10166
  const expiredKeys = keys.filter((k) => {
@@ -10378,11 +10553,11 @@ function registerOpencodeRoutes(router, ctx) {
10378
10553
  sendPermissionDenied(res, import_backstage_plugin_litellm_common4.litellmKeyCreatePermission);
10379
10554
  return null;
10380
10555
  }
10381
- const userId = toLiteLLMUserId(tokenEntityRef, userIdDomain);
10556
+ const computedUserId = toLiteLLMUserId(tokenEntityRef, userIdDomain);
10382
10557
  const userInfo = await getOrProvisionUser(
10383
10558
  client,
10384
10559
  tokenEntityRef,
10385
- userId,
10560
+ computedUserId,
10386
10561
  provisioningEnabled,
10387
10562
  provisioningDefaults,
10388
10563
  roleConfigs,
@@ -10404,7 +10579,7 @@ function registerOpencodeRoutes(router, ctx) {
10404
10579
  res.status(400).json({ error: "A team is required for this connection" });
10405
10580
  return null;
10406
10581
  }
10407
- return { tokenEntityRef, userId, userInfo, teamId, redirectUri };
10582
+ return { tokenEntityRef, userId: effectiveUserId(computedUserId), userInfo, teamId, redirectUri };
10408
10583
  };
10409
10584
  router.get("/opencode/connect", async (req, res) => {
10410
10585
  try {
@@ -10866,12 +11041,12 @@ function registerTeamsRoutes(router, ctx) {
10866
11041
  });
10867
11042
  return;
10868
11043
  }
10869
- const litellmUserId = toLiteLLMUserId(userEntityRef, userIdDomain);
11044
+ const computedUserId = toLiteLLMUserId(userEntityRef, userIdDomain);
10870
11045
  try {
10871
11046
  await getOrProvisionUser(
10872
11047
  client,
10873
11048
  userEntityRef,
10874
- litellmUserId,
11049
+ computedUserId,
10875
11050
  provisioningEnabled,
10876
11051
  provisioningDefaults,
10877
11052
  roleConfigs,
@@ -10886,6 +11061,7 @@ function registerTeamsRoutes(router, ctx) {
10886
11061
  }
10887
11062
  throw err;
10888
11063
  }
11064
+ const litellmUserId = effectiveUserId(computedUserId);
10889
11065
  try {
10890
11066
  await client.teamMemberAdd({
10891
11067
  team_id: teamId,
@@ -10925,9 +11101,9 @@ function registerTeamsRoutes(router, ctx) {
10925
11101
  res.status(400).json({ error: "userEntityRef query parameter is required" });
10926
11102
  return;
10927
11103
  }
10928
- const litellmUserId = toLiteLLMUserId(userEntityRef, userIdDomain);
11104
+ const litellmUserId = await ctx.resolveMemberUserId(userEntityRef);
10929
11105
  if (via === "teamRole") {
10930
- if (litellmUserId === toLiteLLMUserId(actor, userIdDomain)) {
11106
+ if (litellmUserId === await ctx.resolveMemberUserId(actor)) {
10931
11107
  res.status(400).json({ error: "You cannot remove yourself from the team" });
10932
11108
  return;
10933
11109
  }
@@ -11145,6 +11321,7 @@ function registerObjectPermissionsRoutes(router, ctx) {
11145
11321
  }
11146
11322
 
11147
11323
  // src/routes/teamUsage.ts
11324
+ var import_backstage_plugin_litellm_common7 = require("@acarmisc/backstage-plugin-litellm-common");
11148
11325
  function registerTeamUsageRoutes(router, ctx) {
11149
11326
  const {
11150
11327
  client,
@@ -11207,6 +11384,95 @@ function registerTeamUsageRoutes(router, ctx) {
11207
11384
  sendError(res, error, logger, "fetch team usage");
11208
11385
  }
11209
11386
  });
11387
+ router.get("/teams/:teamId/usage/members", requireUser, async (req, res) => {
11388
+ const cfg = ctx.memberBreakdownCfg;
11389
+ if (!cfg.enabled) {
11390
+ res.status(404).json({ error: "Not found" });
11391
+ return;
11392
+ }
11393
+ try {
11394
+ const { teamId } = req.params;
11395
+ const { start_date, end_date } = req.query;
11396
+ if (!start_date || !end_date) {
11397
+ res.status(400).json({ error: "start_date and end_date are required" });
11398
+ return;
11399
+ }
11400
+ const tokenEntityRef = res.locals.tokenEntityRef;
11401
+ const userInfo = await getProvisionedUser(ctx, res);
11402
+ const callerId = res.locals.userId;
11403
+ const isMember = userInfo?.teams?.includes(teamId) ?? false;
11404
+ let team;
11405
+ try {
11406
+ team = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
11407
+ } catch (err) {
11408
+ if (err instanceof LiteLLMUpstreamError && err.status === 404) {
11409
+ res.status(404).json({ error: "Team not found" });
11410
+ return;
11411
+ }
11412
+ throw err;
11413
+ }
11414
+ const owningGroup = typeof team.metadata?.owning_group === "string" ? team.metadata.owning_group : void 0;
11415
+ const isManager = ctx.teamMgmtEnabled && !!owningGroup && await isUserMemberOfGroup(tokenEntityRef, owningGroup, catalogClient, auth, logger);
11416
+ if (!isMember && !isManager) {
11417
+ res.status(404).json({ error: "Team not found" });
11418
+ return;
11419
+ }
11420
+ const role = team.members_with_roles?.find((m) => m.user_id === callerId)?.role;
11421
+ const viaRole = isMember && !!role && cfg.viewerRoles.includes(role);
11422
+ const allowed = isManager || viaRole || isMember && cfg.permissionEnabled && await ctx.assertPermission(req, import_backstage_plugin_litellm_common7.litellmTeamUsageReadPermission);
11423
+ if (!allowed) {
11424
+ res.status(403).json({
11425
+ error: "Access denied: viewing per-member usage needs a team role or permission"
11426
+ });
11427
+ return;
11428
+ }
11429
+ const [usage, keys] = await Promise.all([
11430
+ client.getTeamUsage(teamId, start_date, end_date),
11431
+ client.listTeamKeys(teamId)
11432
+ ]);
11433
+ const profiles = await lookupProfiles(memberEmails(team, keys));
11434
+ const breakdown = buildTeamMemberUsage(teamId, usage, keys, team, profiles);
11435
+ let via = "permission";
11436
+ if (isManager) via = "manager";
11437
+ else if (viaRole) via = "teamRole";
11438
+ logger.info("team.usage.members.read", { actor: tokenEntityRef, teamId, via });
11439
+ const hide = isManager ? teamBudgetVisibility.hideTeamBudgetForManagers : teamBudgetVisibility.hideTeamBudgetForMembers;
11440
+ res.json(hide ? redactTeamMemberUsage(breakdown) : breakdown);
11441
+ } catch (error) {
11442
+ if (error instanceof ProvisioningError) {
11443
+ res.status(error.status).json(error.body);
11444
+ return;
11445
+ }
11446
+ sendError(res, error, logger, "fetch team member usage");
11447
+ }
11448
+ });
11449
+ async function lookupProfiles(emails) {
11450
+ const profiles = /* @__PURE__ */ new Map();
11451
+ if (!emails.length) return profiles;
11452
+ try {
11453
+ const { token } = await auth.getPluginRequestToken({
11454
+ onBehalfOf: await auth.getOwnServiceCredentials(),
11455
+ targetPluginId: "catalog"
11456
+ });
11457
+ const { items } = await catalogClient.getEntities(
11458
+ {
11459
+ filter: { kind: "User", "spec.profile.email": emails },
11460
+ fields: ["spec.profile"]
11461
+ },
11462
+ { token }
11463
+ );
11464
+ for (const entity of items) {
11465
+ const profile = entity.spec?.profile ?? {};
11466
+ if (typeof profile.email === "string") {
11467
+ profiles.set(profile.email.toLowerCase(), { displayName: profile.displayName });
11468
+ }
11469
+ }
11470
+ } catch (err) {
11471
+ const message2 = err instanceof Error ? err.message : String(err);
11472
+ logger.warn(`Catalog lookup for team member names failed: ${message2}`);
11473
+ }
11474
+ return profiles;
11475
+ }
11210
11476
  }
11211
11477
 
11212
11478
  // node_modules/jose/dist/node/esm/runtime/base64url.js
@@ -12722,8 +12988,8 @@ async function bridgeListKeys(client, claims, provisioningEnabled, provisioningD
12722
12988
  }
12723
12989
 
12724
12990
  // src/routes/bridge.ts
12725
- var import_backstage_plugin_litellm_common7 = require("@acarmisc/backstage-plugin-litellm-common");
12726
12991
  var import_backstage_plugin_litellm_common8 = require("@acarmisc/backstage-plugin-litellm-common");
12992
+ var import_backstage_plugin_litellm_common9 = require("@acarmisc/backstage-plugin-litellm-common");
12727
12993
  function registerBridgeRoutes(router, ctx, bridgeOpts) {
12728
12994
  const {
12729
12995
  client,
@@ -12736,7 +13002,7 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
12736
13002
  keyValidationConfig
12737
13003
  } = ctx;
12738
13004
  const { config } = bridgeOpts;
12739
- const generateKeyInputSchema = (0, import_backstage_plugin_litellm_common8.createGenerateKeyInputSchema)(ctx.keyValidationConfig);
13005
+ const generateKeyInputSchema = (0, import_backstage_plugin_litellm_common9.createGenerateKeyInputSchema)(ctx.keyValidationConfig);
12740
13006
  const bridgeCfg = readBridgeConfig(config);
12741
13007
  const bridgeIdentity = {
12742
13008
  userIdDomain,
@@ -12897,11 +13163,11 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
12897
13163
  }
12898
13164
  const team = await client.getTeamInfo(teamId);
12899
13165
  const allowed = team.models;
12900
- if (!allowed || allowed.length === 0 || allowed.includes(import_backstage_plugin_litellm_common7.ALL_PROXY_MODELS)) {
13166
+ if (!allowed || allowed.length === 0 || allowed.includes(import_backstage_plugin_litellm_common8.ALL_PROXY_MODELS)) {
12901
13167
  res.json(models);
12902
13168
  return;
12903
13169
  }
12904
- res.json(models.filter((m) => (0, import_backstage_plugin_litellm_common7.isModelAllowed)(m, allowed)));
13170
+ res.json(models.filter((m) => (0, import_backstage_plugin_litellm_common8.isModelAllowed)(m, allowed)));
12905
13171
  } catch (error) {
12906
13172
  handleBridgeError(error, res);
12907
13173
  }
@@ -12933,18 +13199,20 @@ async function createRouter(options) {
12933
13199
  const keyMaxBudget = config.getOptionalNumber("litellm.keys.maxBudget") ?? 100;
12934
13200
  const keyMaxTpm = config.getOptionalNumber("litellm.keys.maxTpm") ?? 1e5;
12935
13201
  const keyMaxRpm = config.getOptionalNumber("litellm.keys.maxRpm") ?? 1e3;
12936
- const keyAllowedDurations = config.getOptionalStringArray("litellm.keys.allowedDurations") ?? import_backstage_plugin_litellm_common9.DEFAULT_KEY_DURATIONS;
13202
+ const keyAllowedDurations = config.getOptionalStringArray("litellm.keys.allowedDurations") ?? import_backstage_plugin_litellm_common10.DEFAULT_KEY_DURATIONS;
12937
13203
  const keyValidationConfig = {
12938
13204
  maxBudget: keyMaxBudget,
12939
13205
  maxTpm: keyMaxTpm,
12940
13206
  maxRpm: keyMaxRpm,
12941
- allowedDurations: keyAllowedDurations
13207
+ allowedDurations: keyAllowedDurations,
13208
+ allowedBudgetDurations: config.getOptionalStringArray("litellm.keys.allowedBudgetDurations") ?? import_backstage_plugin_litellm_common10.DEFAULT_KEY_BUDGET_DURATIONS
12942
13209
  };
12943
13210
  const teamMgmtEnabled = isTeamManagementEnabled(config);
12944
13211
  const objectPermsEnabled = isObjectPermissionsEnabled(config);
12945
13212
  const teamAdminCfg = readTeamAdminConfig(config);
12946
13213
  const teamBudgetVisibility = readTeamBudgetVisibility(config);
12947
13214
  const opencodeCfg = readOpencodeConfig(config);
13215
+ const memberBreakdownCfg = readMemberBreakdownConfig(config);
12948
13216
  const catalogClient = options.catalogClient ?? new import_catalog_client.CatalogClient({ discoveryApi: discovery });
12949
13217
  if (provisioningEnabled) {
12950
13218
  logger.info(
@@ -12961,7 +13229,13 @@ async function createRouter(options) {
12961
13229
  `LiteLLM team budget hiding enabled \u2014 members: ${teamBudgetVisibility.hideTeamBudgetForMembers}, managers: ${teamBudgetVisibility.hideTeamBudgetForManagers}`
12962
13230
  );
12963
13231
  }
13232
+ const resolveMemberUserId = async (userEntityRef) => {
13233
+ const computed = toLiteLLMUserId(userEntityRef, userIdDomain);
13234
+ await findLiteLLMUser(client, userEntityRef, computed, catalogClient, auth, logger);
13235
+ return effectiveUserId(computed);
13236
+ };
12964
13237
  const ctx = {
13238
+ resolveMemberUserId,
12965
13239
  client,
12966
13240
  catalogClient,
12967
13241
  auth,
@@ -12984,13 +13258,14 @@ async function createRouter(options) {
12984
13258
  teamAdminCfg,
12985
13259
  teamBudgetVisibility,
12986
13260
  opencodeCfg,
13261
+ memberBreakdownCfg,
12987
13262
  // Helper functions - defined inline below this object
12988
13263
  authorizeKeyAction: async (req, keyId) => {
12989
13264
  if (!req.res || !req.res.locals.tokenEntityRef || !req.res.locals.userId) {
12990
13265
  throw new import_errors21.NotAllowedError("User identity not found in request");
12991
13266
  }
12992
13267
  const tokenEntityRef = req.res.locals.tokenEntityRef;
12993
- const userId = req.res.locals.userId;
13268
+ const userId = await resolveMemberUserId(tokenEntityRef);
12994
13269
  const ownKeys = await client.listKeys(userId);
12995
13270
  const key = ownKeys.find((k) => (k.token ?? k.key) === keyId);
12996
13271
  if (!key) {
@@ -13106,7 +13381,7 @@ async function createRouter(options) {
13106
13381
  }
13107
13382
  if (teamRoleEligible) {
13108
13383
  const actor = await resolveUserId(req, auth);
13109
- const litellmUserId = actor && toLiteLLMUserId(actor, userIdDomain);
13384
+ const litellmUserId = actor && await resolveMemberUserId(actor);
13110
13385
  const role = existing.members_with_roles?.find(
13111
13386
  (m) => m.user_id === litellmUserId
13112
13387
  )?.role;
@@ -13140,7 +13415,7 @@ async function createRouter(options) {
13140
13415
  }
13141
13416
 
13142
13417
  // src/plugin.ts
13143
- var import_backstage_plugin_litellm_common10 = require("@acarmisc/backstage-plugin-litellm-common");
13418
+ var import_backstage_plugin_litellm_common11 = require("@acarmisc/backstage-plugin-litellm-common");
13144
13419
  var BRIDGE_UNAUTHENTICATED_PATHS = [
13145
13420
  "/bridge/health",
13146
13421
  "/bridge/keys",
@@ -13169,7 +13444,7 @@ var litellmPlugin = (0, import_backend_plugin_api.createBackendPlugin)({
13169
13444
  permissions,
13170
13445
  permissionsRegistry
13171
13446
  }) {
13172
- permissionsRegistry.addPermissions(import_backstage_plugin_litellm_common10.litellmPermissions);
13447
+ permissionsRegistry.addPermissions(import_backstage_plugin_litellm_common11.litellmPermissions);
13173
13448
  const router = await createRouter({
13174
13449
  config,
13175
13450
  logger,
@@ -13189,7 +13464,7 @@ var litellmPlugin = (0, import_backend_plugin_api.createBackendPlugin)({
13189
13464
  });
13190
13465
 
13191
13466
  // src/index.ts
13192
- var import_backstage_plugin_litellm_common11 = require("@acarmisc/backstage-plugin-litellm-common");
13467
+ var import_backstage_plugin_litellm_common12 = require("@acarmisc/backstage-plugin-litellm-common");
13193
13468
  // Annotate the CommonJS export names for ESM import in node:
13194
13469
  0 && (module.exports = {
13195
13470
  KeycloakJWTVerifier,