@acarmisc/backstage-plugin-litellm-backend 0.19.0 → 0.21.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());
@@ -9290,6 +9351,7 @@ function readOpencodeConfig(config) {
9290
9351
  enabled,
9291
9352
  keyDuration: config.getOptionalString("litellm.opencode.keyDuration") ?? "30d",
9292
9353
  maxBudget: config.getOptionalNumber("litellm.opencode.maxBudget") ?? 50,
9354
+ budgetDuration: config.getOptionalString("litellm.opencode.budgetDuration"),
9293
9355
  requireTeam: config.getOptionalBoolean("litellm.opencode.requireTeam") ?? false,
9294
9356
  metadata: config.getOptional(
9295
9357
  "litellm.opencode.metadata"
@@ -9297,6 +9359,93 @@ function readOpencodeConfig(config) {
9297
9359
  };
9298
9360
  }
9299
9361
 
9362
+ // src/teamMemberUsage.ts
9363
+ function readMemberBreakdownConfig(config) {
9364
+ return {
9365
+ enabled: config.getOptionalBoolean("litellm.teamUsage.memberBreakdown.enabled") ?? false,
9366
+ viewerRoles: config.getOptionalStringArray("litellm.teamUsage.memberBreakdown.viewerRoles") ?? [],
9367
+ permissionEnabled: config.getOptionalBoolean("permission.enabled") ?? false
9368
+ };
9369
+ }
9370
+ function memberEmails(team, keys) {
9371
+ const emails = /* @__PURE__ */ new Set();
9372
+ for (const m of team.members_with_roles ?? []) {
9373
+ if (m.user_email) emails.add(m.user_email.toLowerCase());
9374
+ else if (m.user_id?.includes("@")) emails.add(m.user_id.toLowerCase());
9375
+ }
9376
+ for (const k of keys) {
9377
+ if (k.user_id?.includes("@")) emails.add(k.user_id.toLowerCase());
9378
+ }
9379
+ return [...emails];
9380
+ }
9381
+ function buildTeamMemberUsage(teamId, usage, keys, team, profiles = /* @__PURE__ */ new Map()) {
9382
+ const rows = /* @__PURE__ */ new Map();
9383
+ const rowFor = (userId) => {
9384
+ let row = rows.get(userId);
9385
+ if (!row) {
9386
+ row = {
9387
+ user_id: userId,
9388
+ spend: 0,
9389
+ spend_share_pct: 0,
9390
+ prompt_tokens: 0,
9391
+ completion_tokens: 0,
9392
+ total_tokens: 0,
9393
+ api_requests: 0,
9394
+ successful_requests: 0,
9395
+ failed_requests: 0,
9396
+ success_rate: null,
9397
+ key_count: 0
9398
+ };
9399
+ rows.set(userId, row);
9400
+ }
9401
+ return row;
9402
+ };
9403
+ for (const m of team.members_with_roles ?? []) {
9404
+ if (!m.user_id) continue;
9405
+ const row = rowFor(m.user_id);
9406
+ row.role = m.role;
9407
+ if (m.user_email) row.user_email = m.user_email;
9408
+ }
9409
+ const ownerByKey = /* @__PURE__ */ new Map();
9410
+ for (const k of keys) {
9411
+ if (!k.user_id || !k.token) continue;
9412
+ ownerByKey.set(k.token, k.user_id);
9413
+ rowFor(k.user_id).key_count += 1;
9414
+ }
9415
+ for (const [keyHash, k] of Object.entries(usage.usage_by_key ?? {})) {
9416
+ const row = rowFor(ownerByKey.get(keyHash) ?? null);
9417
+ row.spend += k.total_spend ?? 0;
9418
+ row.prompt_tokens += k.prompt_tokens ?? 0;
9419
+ row.completion_tokens += k.completion_tokens ?? 0;
9420
+ row.total_tokens += k.total_tokens ?? 0;
9421
+ row.api_requests += k.api_requests ?? 0;
9422
+ row.successful_requests += k.successful_requests ?? 0;
9423
+ row.failed_requests += k.failed_requests ?? 0;
9424
+ }
9425
+ const members = [...rows.values()];
9426
+ const totalSpend = members.reduce((sum, r) => sum + r.spend, 0);
9427
+ for (const row of members) {
9428
+ row.spend_share_pct = totalSpend > 0 ? row.spend / totalSpend * 100 : 0;
9429
+ const finished = row.successful_requests + row.failed_requests;
9430
+ row.success_rate = finished > 0 ? row.successful_requests / finished * 100 : null;
9431
+ if (!row.user_email && row.user_id?.includes("@")) row.user_email = row.user_id;
9432
+ const profile = row.user_email && profiles.get(row.user_email.toLowerCase());
9433
+ if (profile && profile.displayName) row.display_name = profile.displayName;
9434
+ }
9435
+ members.sort(
9436
+ (a, b) => b.spend - a.spend || b.total_tokens - a.total_tokens || String(a.user_id ?? "\uFFFF").localeCompare(String(b.user_id ?? "\uFFFF"))
9437
+ );
9438
+ return { team_id: teamId, total_spend: totalSpend, members };
9439
+ }
9440
+ function redactTeamMemberUsage(usage) {
9441
+ return {
9442
+ ...usage,
9443
+ total_spend: 0,
9444
+ budget_hidden: true,
9445
+ members: usage.members.map((m) => ({ ...m, spend: 0 }))
9446
+ };
9447
+ }
9448
+
9300
9449
  // src/routes/middleware/withUser.ts
9301
9450
  function createRequireUser(ctx) {
9302
9451
  return async (req, res, next) => {
@@ -9317,7 +9466,7 @@ function createRequireUser(ctx) {
9317
9466
  async function getProvisionedUser(ctx, res) {
9318
9467
  const tokenEntityRef = res.locals.tokenEntityRef;
9319
9468
  const userId = res.locals.userId;
9320
- return getOrProvisionUser(
9469
+ const userInfo = await getOrProvisionUser(
9321
9470
  ctx.client,
9322
9471
  tokenEntityRef,
9323
9472
  userId,
@@ -9328,6 +9477,8 @@ async function getProvisionedUser(ctx, res) {
9328
9477
  ctx.auth,
9329
9478
  ctx.logger
9330
9479
  );
9480
+ res.locals.userId = effectiveUserId(userId);
9481
+ return userInfo;
9331
9482
  }
9332
9483
 
9333
9484
  // src/services/userInfoCache.ts
@@ -9536,6 +9687,7 @@ var openApiSpec = {
9536
9687
  team_id: { type: "string" },
9537
9688
  duration: { type: "string" },
9538
9689
  max_budget: { type: "number", nullable: true, description: "Positive number caps spend; null = unlimited" },
9690
+ budget_duration: { type: "string", description: 'How often max_budget resets (e.g. "30d", "1mo"); one of litellm.keys.allowedBudgetDurations' },
9539
9691
  tpm_limit: { type: "number" },
9540
9692
  rpm_limit: { type: "number" }
9541
9693
  } } } }
@@ -9632,6 +9784,24 @@ var openApiSpec = {
9632
9784
  responses: { "200": { description: "Team usage" }, "400": { description: "Missing date range" } }
9633
9785
  }
9634
9786
  },
9787
+ "/teams/{teamId}/usage/members": {
9788
+ get: {
9789
+ tags: ["Teams"],
9790
+ summary: "Per-member usage breakdown for a team",
9791
+ 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.",
9792
+ parameters: [
9793
+ { name: "teamId", in: "path", required: true, schema: { type: "string" } },
9794
+ { name: "start_date", in: "query", required: true, schema: { type: "string" } },
9795
+ { name: "end_date", in: "query", required: true, schema: { type: "string" } }
9796
+ ],
9797
+ responses: {
9798
+ "200": { description: "One row per member, sorted by spend" },
9799
+ "400": { description: "Missing date range" },
9800
+ "403": { description: "Caller may see the team but not its member breakdown" },
9801
+ "404": { description: "Feature disabled, or team not found / not visible to the caller" }
9802
+ }
9803
+ }
9804
+ },
9635
9805
  "/usage": {
9636
9806
  get: {
9637
9807
  tags: ["Usage"],
@@ -9737,6 +9907,9 @@ function registerConfigRoutes(router, ctx) {
9737
9907
  readOnly: ctx.teamAdminCfg.readOnly,
9738
9908
  memberManagerRoles: ctx.teamAdminCfg.memberManagerRoles
9739
9909
  },
9910
+ teamUsage: {
9911
+ memberBreakdown: ctx.memberBreakdownCfg.enabled
9912
+ },
9740
9913
  display: {
9741
9914
  hideTeamBudgetForMembers: ctx.teamBudgetVisibility.hideTeamBudgetForMembers,
9742
9915
  hideTeamBudgetForManagers: ctx.teamBudgetVisibility.hideTeamBudgetForManagers
@@ -9908,6 +10081,9 @@ async function createKeyForUser(user, input, ctx) {
9908
10081
  if (input.max_budget !== void 0) {
9909
10082
  upstreamRequest.max_budget = input.max_budget ?? void 0;
9910
10083
  }
10084
+ if (input.budget_duration !== void 0) {
10085
+ upstreamRequest.budget_duration = input.budget_duration;
10086
+ }
9911
10087
  if (input.tpm_limit !== void 0) {
9912
10088
  upstreamRequest.tpm_limit = input.tpm_limit;
9913
10089
  }
@@ -9966,8 +10142,8 @@ function registerKeysRoutes(router, ctx) {
9966
10142
  });
9967
10143
  router.get("/keys", async (_req, res) => {
9968
10144
  try {
9969
- const userId = res.locals.userId;
9970
10145
  await getProvisionedUser(ctx, res);
10146
+ const userId = res.locals.userId;
9971
10147
  const keys = await client.listKeys(userId);
9972
10148
  res.json(keys);
9973
10149
  } catch (error) {
@@ -9984,8 +10160,8 @@ function registerKeysRoutes(router, ctx) {
9984
10160
  sendPermissionDenied(res, import_backstage_plugin_litellm_common3.litellmKeyRevokePermission);
9985
10161
  return;
9986
10162
  }
9987
- const userId = res.locals.userId;
9988
10163
  await getProvisionedUser(ctx, res);
10164
+ const userId = res.locals.userId;
9989
10165
  const keys = await client.listKeys(userId);
9990
10166
  const now = Date.now();
9991
10167
  const expiredKeys = keys.filter((k) => {
@@ -10378,11 +10554,11 @@ function registerOpencodeRoutes(router, ctx) {
10378
10554
  sendPermissionDenied(res, import_backstage_plugin_litellm_common4.litellmKeyCreatePermission);
10379
10555
  return null;
10380
10556
  }
10381
- const userId = toLiteLLMUserId(tokenEntityRef, userIdDomain);
10557
+ const computedUserId = toLiteLLMUserId(tokenEntityRef, userIdDomain);
10382
10558
  const userInfo = await getOrProvisionUser(
10383
10559
  client,
10384
10560
  tokenEntityRef,
10385
- userId,
10561
+ computedUserId,
10386
10562
  provisioningEnabled,
10387
10563
  provisioningDefaults,
10388
10564
  roleConfigs,
@@ -10404,7 +10580,7 @@ function registerOpencodeRoutes(router, ctx) {
10404
10580
  res.status(400).json({ error: "A team is required for this connection" });
10405
10581
  return null;
10406
10582
  }
10407
- return { tokenEntityRef, userId, userInfo, teamId, redirectUri };
10583
+ return { tokenEntityRef, userId: effectiveUserId(computedUserId), userInfo, teamId, redirectUri };
10408
10584
  };
10409
10585
  router.get("/opencode/connect", async (req, res) => {
10410
10586
  try {
@@ -10472,6 +10648,7 @@ function registerOpencodeRoutes(router, ctx) {
10472
10648
  alias,
10473
10649
  duration: opencodeCfg.keyDuration,
10474
10650
  max_budget: opencodeCfg.maxBudget,
10651
+ ...opencodeCfg.budgetDuration && { budget_duration: opencodeCfg.budgetDuration },
10475
10652
  team_id: teamId,
10476
10653
  metadata: {
10477
10654
  ...opencodeCfg.metadata,
@@ -10506,14 +10683,15 @@ function registerOpencodeRoutes(router, ctx) {
10506
10683
  res.status(502).json({ error: "LiteLLM returned no key material" });
10507
10684
  return;
10508
10685
  }
10686
+ logger.info("opencode.connect", { userId, team: teamId ?? null, rotated });
10687
+ if (req.is("application/json")) {
10688
+ res.set("Cache-Control", "no-store");
10689
+ res.json({ key, team: teamId ?? null });
10690
+ return;
10691
+ }
10509
10692
  const url = new URL(redirectUri);
10510
10693
  url.searchParams.set("key", key);
10511
10694
  if (teamId) url.searchParams.set("team", teamId);
10512
- logger.info("opencode.connect", {
10513
- userId,
10514
- team: teamId ?? null,
10515
- rotated
10516
- });
10517
10695
  res.redirect(302, url.href);
10518
10696
  } catch (error) {
10519
10697
  if (error instanceof ProvisioningError) {
@@ -10866,12 +11044,12 @@ function registerTeamsRoutes(router, ctx) {
10866
11044
  });
10867
11045
  return;
10868
11046
  }
10869
- const litellmUserId = toLiteLLMUserId(userEntityRef, userIdDomain);
11047
+ const computedUserId = toLiteLLMUserId(userEntityRef, userIdDomain);
10870
11048
  try {
10871
11049
  await getOrProvisionUser(
10872
11050
  client,
10873
11051
  userEntityRef,
10874
- litellmUserId,
11052
+ computedUserId,
10875
11053
  provisioningEnabled,
10876
11054
  provisioningDefaults,
10877
11055
  roleConfigs,
@@ -10886,6 +11064,7 @@ function registerTeamsRoutes(router, ctx) {
10886
11064
  }
10887
11065
  throw err;
10888
11066
  }
11067
+ const litellmUserId = effectiveUserId(computedUserId);
10889
11068
  try {
10890
11069
  await client.teamMemberAdd({
10891
11070
  team_id: teamId,
@@ -10925,9 +11104,9 @@ function registerTeamsRoutes(router, ctx) {
10925
11104
  res.status(400).json({ error: "userEntityRef query parameter is required" });
10926
11105
  return;
10927
11106
  }
10928
- const litellmUserId = toLiteLLMUserId(userEntityRef, userIdDomain);
11107
+ const litellmUserId = await ctx.resolveMemberUserId(userEntityRef);
10929
11108
  if (via === "teamRole") {
10930
- if (litellmUserId === toLiteLLMUserId(actor, userIdDomain)) {
11109
+ if (litellmUserId === await ctx.resolveMemberUserId(actor)) {
10931
11110
  res.status(400).json({ error: "You cannot remove yourself from the team" });
10932
11111
  return;
10933
11112
  }
@@ -11145,6 +11324,7 @@ function registerObjectPermissionsRoutes(router, ctx) {
11145
11324
  }
11146
11325
 
11147
11326
  // src/routes/teamUsage.ts
11327
+ var import_backstage_plugin_litellm_common7 = require("@acarmisc/backstage-plugin-litellm-common");
11148
11328
  function registerTeamUsageRoutes(router, ctx) {
11149
11329
  const {
11150
11330
  client,
@@ -11207,6 +11387,95 @@ function registerTeamUsageRoutes(router, ctx) {
11207
11387
  sendError(res, error, logger, "fetch team usage");
11208
11388
  }
11209
11389
  });
11390
+ router.get("/teams/:teamId/usage/members", requireUser, async (req, res) => {
11391
+ const cfg = ctx.memberBreakdownCfg;
11392
+ if (!cfg.enabled) {
11393
+ res.status(404).json({ error: "Not found" });
11394
+ return;
11395
+ }
11396
+ try {
11397
+ const { teamId } = req.params;
11398
+ const { start_date, end_date } = req.query;
11399
+ if (!start_date || !end_date) {
11400
+ res.status(400).json({ error: "start_date and end_date are required" });
11401
+ return;
11402
+ }
11403
+ const tokenEntityRef = res.locals.tokenEntityRef;
11404
+ const userInfo = await getProvisionedUser(ctx, res);
11405
+ const callerId = res.locals.userId;
11406
+ const isMember = userInfo?.teams?.includes(teamId) ?? false;
11407
+ let team;
11408
+ try {
11409
+ team = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
11410
+ } catch (err) {
11411
+ if (err instanceof LiteLLMUpstreamError && err.status === 404) {
11412
+ res.status(404).json({ error: "Team not found" });
11413
+ return;
11414
+ }
11415
+ throw err;
11416
+ }
11417
+ const owningGroup = typeof team.metadata?.owning_group === "string" ? team.metadata.owning_group : void 0;
11418
+ const isManager = ctx.teamMgmtEnabled && !!owningGroup && await isUserMemberOfGroup(tokenEntityRef, owningGroup, catalogClient, auth, logger);
11419
+ if (!isMember && !isManager) {
11420
+ res.status(404).json({ error: "Team not found" });
11421
+ return;
11422
+ }
11423
+ const role = team.members_with_roles?.find((m) => m.user_id === callerId)?.role;
11424
+ const viaRole = isMember && !!role && cfg.viewerRoles.includes(role);
11425
+ const allowed = isManager || viaRole || isMember && cfg.permissionEnabled && await ctx.assertPermission(req, import_backstage_plugin_litellm_common7.litellmTeamUsageReadPermission);
11426
+ if (!allowed) {
11427
+ res.status(403).json({
11428
+ error: "Access denied: viewing per-member usage needs a team role or permission"
11429
+ });
11430
+ return;
11431
+ }
11432
+ const [usage, keys] = await Promise.all([
11433
+ client.getTeamUsage(teamId, start_date, end_date),
11434
+ client.listTeamKeys(teamId)
11435
+ ]);
11436
+ const profiles = await lookupProfiles(memberEmails(team, keys));
11437
+ const breakdown = buildTeamMemberUsage(teamId, usage, keys, team, profiles);
11438
+ let via = "permission";
11439
+ if (isManager) via = "manager";
11440
+ else if (viaRole) via = "teamRole";
11441
+ logger.info("team.usage.members.read", { actor: tokenEntityRef, teamId, via });
11442
+ const hide = isManager ? teamBudgetVisibility.hideTeamBudgetForManagers : teamBudgetVisibility.hideTeamBudgetForMembers;
11443
+ res.json(hide ? redactTeamMemberUsage(breakdown) : breakdown);
11444
+ } catch (error) {
11445
+ if (error instanceof ProvisioningError) {
11446
+ res.status(error.status).json(error.body);
11447
+ return;
11448
+ }
11449
+ sendError(res, error, logger, "fetch team member usage");
11450
+ }
11451
+ });
11452
+ async function lookupProfiles(emails) {
11453
+ const profiles = /* @__PURE__ */ new Map();
11454
+ if (!emails.length) return profiles;
11455
+ try {
11456
+ const { token } = await auth.getPluginRequestToken({
11457
+ onBehalfOf: await auth.getOwnServiceCredentials(),
11458
+ targetPluginId: "catalog"
11459
+ });
11460
+ const { items } = await catalogClient.getEntities(
11461
+ {
11462
+ filter: { kind: "User", "spec.profile.email": emails },
11463
+ fields: ["spec.profile"]
11464
+ },
11465
+ { token }
11466
+ );
11467
+ for (const entity of items) {
11468
+ const profile = entity.spec?.profile ?? {};
11469
+ if (typeof profile.email === "string") {
11470
+ profiles.set(profile.email.toLowerCase(), { displayName: profile.displayName });
11471
+ }
11472
+ }
11473
+ } catch (err) {
11474
+ const message2 = err instanceof Error ? err.message : String(err);
11475
+ logger.warn(`Catalog lookup for team member names failed: ${message2}`);
11476
+ }
11477
+ return profiles;
11478
+ }
11210
11479
  }
11211
11480
 
11212
11481
  // node_modules/jose/dist/node/esm/runtime/base64url.js
@@ -12722,8 +12991,8 @@ async function bridgeListKeys(client, claims, provisioningEnabled, provisioningD
12722
12991
  }
12723
12992
 
12724
12993
  // src/routes/bridge.ts
12725
- var import_backstage_plugin_litellm_common7 = require("@acarmisc/backstage-plugin-litellm-common");
12726
12994
  var import_backstage_plugin_litellm_common8 = require("@acarmisc/backstage-plugin-litellm-common");
12995
+ var import_backstage_plugin_litellm_common9 = require("@acarmisc/backstage-plugin-litellm-common");
12727
12996
  function registerBridgeRoutes(router, ctx, bridgeOpts) {
12728
12997
  const {
12729
12998
  client,
@@ -12736,7 +13005,7 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
12736
13005
  keyValidationConfig
12737
13006
  } = ctx;
12738
13007
  const { config } = bridgeOpts;
12739
- const generateKeyInputSchema = (0, import_backstage_plugin_litellm_common8.createGenerateKeyInputSchema)(ctx.keyValidationConfig);
13008
+ const generateKeyInputSchema = (0, import_backstage_plugin_litellm_common9.createGenerateKeyInputSchema)(ctx.keyValidationConfig);
12740
13009
  const bridgeCfg = readBridgeConfig(config);
12741
13010
  const bridgeIdentity = {
12742
13011
  userIdDomain,
@@ -12897,11 +13166,11 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
12897
13166
  }
12898
13167
  const team = await client.getTeamInfo(teamId);
12899
13168
  const allowed = team.models;
12900
- if (!allowed || allowed.length === 0 || allowed.includes(import_backstage_plugin_litellm_common7.ALL_PROXY_MODELS)) {
13169
+ if (!allowed || allowed.length === 0 || allowed.includes(import_backstage_plugin_litellm_common8.ALL_PROXY_MODELS)) {
12901
13170
  res.json(models);
12902
13171
  return;
12903
13172
  }
12904
- res.json(models.filter((m) => (0, import_backstage_plugin_litellm_common7.isModelAllowed)(m, allowed)));
13173
+ res.json(models.filter((m) => (0, import_backstage_plugin_litellm_common8.isModelAllowed)(m, allowed)));
12905
13174
  } catch (error) {
12906
13175
  handleBridgeError(error, res);
12907
13176
  }
@@ -12933,18 +13202,20 @@ async function createRouter(options) {
12933
13202
  const keyMaxBudget = config.getOptionalNumber("litellm.keys.maxBudget") ?? 100;
12934
13203
  const keyMaxTpm = config.getOptionalNumber("litellm.keys.maxTpm") ?? 1e5;
12935
13204
  const keyMaxRpm = config.getOptionalNumber("litellm.keys.maxRpm") ?? 1e3;
12936
- const keyAllowedDurations = config.getOptionalStringArray("litellm.keys.allowedDurations") ?? import_backstage_plugin_litellm_common9.DEFAULT_KEY_DURATIONS;
13205
+ const keyAllowedDurations = config.getOptionalStringArray("litellm.keys.allowedDurations") ?? import_backstage_plugin_litellm_common10.DEFAULT_KEY_DURATIONS;
12937
13206
  const keyValidationConfig = {
12938
13207
  maxBudget: keyMaxBudget,
12939
13208
  maxTpm: keyMaxTpm,
12940
13209
  maxRpm: keyMaxRpm,
12941
- allowedDurations: keyAllowedDurations
13210
+ allowedDurations: keyAllowedDurations,
13211
+ allowedBudgetDurations: config.getOptionalStringArray("litellm.keys.allowedBudgetDurations") ?? import_backstage_plugin_litellm_common10.DEFAULT_KEY_BUDGET_DURATIONS
12942
13212
  };
12943
13213
  const teamMgmtEnabled = isTeamManagementEnabled(config);
12944
13214
  const objectPermsEnabled = isObjectPermissionsEnabled(config);
12945
13215
  const teamAdminCfg = readTeamAdminConfig(config);
12946
13216
  const teamBudgetVisibility = readTeamBudgetVisibility(config);
12947
13217
  const opencodeCfg = readOpencodeConfig(config);
13218
+ const memberBreakdownCfg = readMemberBreakdownConfig(config);
12948
13219
  const catalogClient = options.catalogClient ?? new import_catalog_client.CatalogClient({ discoveryApi: discovery });
12949
13220
  if (provisioningEnabled) {
12950
13221
  logger.info(
@@ -12961,7 +13232,13 @@ async function createRouter(options) {
12961
13232
  `LiteLLM team budget hiding enabled \u2014 members: ${teamBudgetVisibility.hideTeamBudgetForMembers}, managers: ${teamBudgetVisibility.hideTeamBudgetForManagers}`
12962
13233
  );
12963
13234
  }
13235
+ const resolveMemberUserId = async (userEntityRef) => {
13236
+ const computed = toLiteLLMUserId(userEntityRef, userIdDomain);
13237
+ await findLiteLLMUser(client, userEntityRef, computed, catalogClient, auth, logger);
13238
+ return effectiveUserId(computed);
13239
+ };
12964
13240
  const ctx = {
13241
+ resolveMemberUserId,
12965
13242
  client,
12966
13243
  catalogClient,
12967
13244
  auth,
@@ -12984,13 +13261,14 @@ async function createRouter(options) {
12984
13261
  teamAdminCfg,
12985
13262
  teamBudgetVisibility,
12986
13263
  opencodeCfg,
13264
+ memberBreakdownCfg,
12987
13265
  // Helper functions - defined inline below this object
12988
13266
  authorizeKeyAction: async (req, keyId) => {
12989
13267
  if (!req.res || !req.res.locals.tokenEntityRef || !req.res.locals.userId) {
12990
13268
  throw new import_errors21.NotAllowedError("User identity not found in request");
12991
13269
  }
12992
13270
  const tokenEntityRef = req.res.locals.tokenEntityRef;
12993
- const userId = req.res.locals.userId;
13271
+ const userId = await resolveMemberUserId(tokenEntityRef);
12994
13272
  const ownKeys = await client.listKeys(userId);
12995
13273
  const key = ownKeys.find((k) => (k.token ?? k.key) === keyId);
12996
13274
  if (!key) {
@@ -13106,7 +13384,7 @@ async function createRouter(options) {
13106
13384
  }
13107
13385
  if (teamRoleEligible) {
13108
13386
  const actor = await resolveUserId(req, auth);
13109
- const litellmUserId = actor && toLiteLLMUserId(actor, userIdDomain);
13387
+ const litellmUserId = actor && await resolveMemberUserId(actor);
13110
13388
  const role = existing.members_with_roles?.find(
13111
13389
  (m) => m.user_id === litellmUserId
13112
13390
  )?.role;
@@ -13140,7 +13418,7 @@ async function createRouter(options) {
13140
13418
  }
13141
13419
 
13142
13420
  // src/plugin.ts
13143
- var import_backstage_plugin_litellm_common10 = require("@acarmisc/backstage-plugin-litellm-common");
13421
+ var import_backstage_plugin_litellm_common11 = require("@acarmisc/backstage-plugin-litellm-common");
13144
13422
  var BRIDGE_UNAUTHENTICATED_PATHS = [
13145
13423
  "/bridge/health",
13146
13424
  "/bridge/keys",
@@ -13169,7 +13447,7 @@ var litellmPlugin = (0, import_backend_plugin_api.createBackendPlugin)({
13169
13447
  permissions,
13170
13448
  permissionsRegistry
13171
13449
  }) {
13172
- permissionsRegistry.addPermissions(import_backstage_plugin_litellm_common10.litellmPermissions);
13450
+ permissionsRegistry.addPermissions(import_backstage_plugin_litellm_common11.litellmPermissions);
13173
13451
  const router = await createRouter({
13174
13452
  config,
13175
13453
  logger,
@@ -13189,7 +13467,7 @@ var litellmPlugin = (0, import_backend_plugin_api.createBackendPlugin)({
13189
13467
  });
13190
13468
 
13191
13469
  // src/index.ts
13192
- var import_backstage_plugin_litellm_common11 = require("@acarmisc/backstage-plugin-litellm-common");
13470
+ var import_backstage_plugin_litellm_common12 = require("@acarmisc/backstage-plugin-litellm-common");
13193
13471
  // Annotate the CommonJS export names for ESM import in node:
13194
13472
  0 && (module.exports = {
13195
13473
  KeycloakJWTVerifier,