@acarmisc/backstage-plugin-litellm-backend 0.18.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
@@ -7976,29 +7976,27 @@ __export(index_exports, {
7976
7976
  ProvisioningError: () => ProvisioningError,
7977
7977
  applyRoleOverrides: () => applyRoleOverrides,
7978
7978
  assertTeamAdmin: () => assertTeamAdmin,
7979
- bridgeGenerateKey: () => bridgeGenerateKey,
7980
7979
  bridgeListKeys: () => bridgeListKeys,
7981
7980
  createRouter: () => createRouter,
7982
7981
  default: () => litellmPlugin,
7983
7982
  getOrProvisionUser: () => getOrProvisionUser,
7984
7983
  getOrProvisionUserFromClaims: () => getOrProvisionUserFromClaims,
7985
7984
  isTeamManagementEnabled: () => isTeamManagementEnabled,
7986
- litellmAuditReadPermission: () => import_backstage_plugin_litellm_common11.litellmAuditReadPermission,
7987
- litellmKeyCreatePermission: () => import_backstage_plugin_litellm_common11.litellmKeyCreatePermission,
7988
- litellmKeyManagePermission: () => import_backstage_plugin_litellm_common11.litellmKeyManagePermission,
7989
- litellmKeyResetSpendPermission: () => import_backstage_plugin_litellm_common11.litellmKeyResetSpendPermission,
7990
- litellmKeyRevokePermission: () => import_backstage_plugin_litellm_common11.litellmKeyRevokePermission,
7991
- litellmKeyUnblockPermission: () => import_backstage_plugin_litellm_common11.litellmKeyUnblockPermission,
7992
- 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,
7993
7992
  litellmPlugin: () => litellmPlugin,
7994
- litellmTeamCreatePermission: () => import_backstage_plugin_litellm_common11.litellmTeamCreatePermission,
7995
- litellmTeamDeletePermission: () => import_backstage_plugin_litellm_common11.litellmTeamDeletePermission,
7996
- litellmTeamKnowledgebaseManagePermission: () => import_backstage_plugin_litellm_common11.litellmTeamKnowledgebaseManagePermission,
7997
- litellmTeamManagePermission: () => import_backstage_plugin_litellm_common11.litellmTeamManagePermission,
7998
- litellmTeamMcpManagePermission: () => import_backstage_plugin_litellm_common11.litellmTeamMcpManagePermission,
7999
- 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,
8000
7999
  newDefaultVerifier: () => newDefaultVerifier,
8001
- normalizeRequestTags: () => normalizeRequestTags,
8002
8000
  provisionUser: () => provisionUser,
8003
8001
  readBridgeConfig: () => readBridgeConfig,
8004
8002
  readProvisioningDefaults: () => readProvisioningDefaults,
@@ -8024,16 +8022,11 @@ var import_backend_plugin_api = require("@backstage/backend-plugin-api");
8024
8022
  // src/router.ts
8025
8023
  var import_express = __toESM(require("express"));
8026
8024
  var import_plugin_permission_common2 = __toESM(require_index_cjs2());
8027
- var import_errors20 = __toESM(require_index_cjs());
8025
+ var import_errors21 = __toESM(require_index_cjs());
8028
8026
  var import_catalog_client = require("@backstage/catalog-client");
8029
8027
 
8030
8028
  // src/client.ts
8031
8029
  var DEFAULT_TIMEOUT = 3e4;
8032
- function normalizeRequestTags(tags) {
8033
- if (!tags) return [];
8034
- if (Array.isArray(tags)) return tags.filter((t) => typeof t === "string");
8035
- return Object.entries(tags).map(([k, v]) => `${k}:${v}`);
8036
- }
8037
8030
  var LiteLLMUpstreamError = class extends Error {
8038
8031
  constructor(status, statusText, body) {
8039
8032
  let message2 = `LiteLLM API error: ${status} ${statusText}`;
@@ -8089,6 +8082,29 @@ var LiteLLMClient = class {
8089
8082
  clearTimeout(timeoutId);
8090
8083
  }
8091
8084
  }
8085
+ /**
8086
+ * Normalizes a raw LiteLLM user response to the UserInfo contract.
8087
+ * Handles both `/user/info` (with `user_info` wrapper) and `/user/list`
8088
+ * response shapes, and converts team arrays to team_id strings.
8089
+ */
8090
+ normalizeUserInfo(raw, fallbackUserId) {
8091
+ const inner = raw?.user_info ?? {};
8092
+ const teamIds = Array.isArray(raw?.teams) ? raw.teams.map((t) => typeof t === "string" ? t : t?.team_id).filter((t) => typeof t === "string") : [];
8093
+ return {
8094
+ user_id: raw?.user_id ?? inner.user_id ?? fallbackUserId ?? "",
8095
+ user_email: inner.user_email ?? raw?.user_email,
8096
+ email: inner.email ?? raw?.email,
8097
+ teams: teamIds,
8098
+ models: inner.models ?? raw?.models,
8099
+ max_budget: inner.max_budget ?? raw?.max_budget,
8100
+ budget_duration: inner.budget_duration ?? raw?.budget_duration,
8101
+ budget_reset_at: inner.budget_reset_at ?? raw?.budget_reset_at,
8102
+ spend: inner.spend ?? raw?.spend,
8103
+ current_spend: inner.current_spend ?? raw?.current_spend,
8104
+ soft_limit: inner.soft_limit ?? raw?.soft_limit,
8105
+ hard_limit: inner.hard_limit ?? raw?.hard_limit
8106
+ };
8107
+ }
8092
8108
  /**
8093
8109
  * Returns null when the user is not found in LiteLLM (404).
8094
8110
  * Throws on all other errors so callers know something went wrong.
@@ -8102,22 +8118,7 @@ var LiteLLMClient = class {
8102
8118
  const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
8103
8119
  try {
8104
8120
  const raw = await this.request(`/user/info${query}`);
8105
- const inner = raw?.user_info ?? {};
8106
- const teamIds = Array.isArray(raw?.teams) ? raw.teams.map((t) => typeof t === "string" ? t : t?.team_id).filter((t) => typeof t === "string") : [];
8107
- return {
8108
- user_id: raw?.user_id ?? inner.user_id ?? userId ?? "",
8109
- user_email: inner.user_email ?? raw?.user_email,
8110
- email: inner.email ?? raw?.email,
8111
- teams: teamIds,
8112
- models: inner.models ?? raw?.models,
8113
- max_budget: inner.max_budget ?? raw?.max_budget,
8114
- budget_duration: inner.budget_duration ?? raw?.budget_duration,
8115
- budget_reset_at: inner.budget_reset_at ?? raw?.budget_reset_at,
8116
- spend: inner.spend ?? raw?.spend,
8117
- current_spend: inner.current_spend ?? raw?.current_spend,
8118
- soft_limit: inner.soft_limit ?? raw?.soft_limit,
8119
- hard_limit: inner.hard_limit ?? raw?.hard_limit
8120
- };
8121
+ return this.normalizeUserInfo(raw, userId);
8121
8122
  } catch (err) {
8122
8123
  if (err instanceof LiteLLMUpstreamError && err.status === 404) return null;
8123
8124
  throw err;
@@ -8144,22 +8145,7 @@ var LiteLLMClient = class {
8144
8145
  (u) => (u.user_email ?? u.email ?? "").toLowerCase() === email.toLowerCase()
8145
8146
  );
8146
8147
  if (!found) return null;
8147
- const inner = found.user_info ?? {};
8148
- const teamIds = Array.isArray(found.teams) ? found.teams.map((t) => typeof t === "string" ? t : t?.team_id).filter((t) => typeof t === "string") : [];
8149
- return {
8150
- user_id: found.user_id ?? inner.user_id ?? "",
8151
- user_email: inner.user_email ?? found.user_email,
8152
- email: inner.email ?? found.email,
8153
- teams: teamIds,
8154
- models: inner.models ?? found.models,
8155
- max_budget: inner.max_budget ?? found.max_budget,
8156
- budget_duration: inner.budget_duration ?? found.budget_duration,
8157
- budget_reset_at: inner.budget_reset_at ?? found.budget_reset_at,
8158
- spend: inner.spend ?? found.spend,
8159
- current_spend: inner.current_spend ?? found.current_spend,
8160
- soft_limit: inner.soft_limit ?? found.soft_limit,
8161
- hard_limit: inner.hard_limit ?? found.hard_limit
8162
- };
8148
+ return this.normalizeUserInfo(found);
8163
8149
  } catch (err) {
8164
8150
  if (err instanceof LiteLLMUpstreamError && err.status === 404) return null;
8165
8151
  throw err;
@@ -8211,6 +8197,28 @@ var LiteLLMClient = class {
8211
8197
  throw err;
8212
8198
  }
8213
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
+ }
8214
8222
  toVirtualKey(k) {
8215
8223
  return {
8216
8224
  key: k.key_name ?? k.token,
@@ -8423,18 +8431,6 @@ var LiteLLMClient = class {
8423
8431
  body: JSON.stringify({ team_ids: [teamId] })
8424
8432
  });
8425
8433
  }
8426
- async blockTeam(teamId) {
8427
- return this.request("/team/block", {
8428
- method: "POST",
8429
- body: JSON.stringify({ team_id: teamId })
8430
- });
8431
- }
8432
- async unblockTeam(teamId) {
8433
- return this.request("/team/unblock", {
8434
- method: "POST",
8435
- body: JSON.stringify({ team_id: teamId })
8436
- });
8437
- }
8438
8434
  /**
8439
8435
  * Adds a member to a team. LiteLLM's `/team/member_add` nests the member
8440
8436
  * under a `member` object; `role` is 'user' or 'admin' (we only ever send
@@ -8498,43 +8494,6 @@ var LiteLLMClient = class {
8498
8494
  url: r?.url ?? void 0
8499
8495
  })).filter((r) => r.id);
8500
8496
  }
8501
- /**
8502
- * Reads per-request spend logs from LiteLLM (`GET /spend/logs`), filtered
8503
- * by date range and optionally by key/user/team. Rows are normalised so
8504
- * `request_tags` is always a `string[]` (LiteLLM returns either an array
8505
- * or an object depending on version) — callers group by tag to attribute
8506
- * spend to a conversation or caller.
8507
- *
8508
- * This is the admin/DB-backed endpoint: it can return a large number of
8509
- * rows, so callers should keep the date window tight and cap `page_size`.
8510
- */
8511
- async getSpendLogs(params) {
8512
- const query = new URLSearchParams({
8513
- start_date: params.start_date,
8514
- end_date: params.end_date
8515
- });
8516
- if (params.api_key) query.append("api_key", params.api_key);
8517
- if (params.user_id) query.append("user_id", params.user_id);
8518
- if (params.team_id) query.append("team_id", params.team_id);
8519
- if (params.page_size) query.append("page_size", String(params.page_size));
8520
- const raw = await this.request(`/spend/logs?${query.toString()}`);
8521
- const rows = Array.isArray(raw) ? raw : raw?.data ?? [];
8522
- return rows.map((row) => ({
8523
- request_id: row?.request_id,
8524
- startTime: row?.startTime ?? row?.start_time,
8525
- endTime: row?.endTime ?? row?.end_time,
8526
- spend: row?.spend ?? 0,
8527
- total_tokens: row?.total_tokens ?? 0,
8528
- prompt_tokens: row?.prompt_tokens ?? 0,
8529
- completion_tokens: row?.completion_tokens ?? 0,
8530
- model: row?.model,
8531
- api_key: row?.api_key,
8532
- user: row?.user,
8533
- team_id: row?.team_id,
8534
- request_tags: normalizeRequestTags(row?.request_tags),
8535
- metadata: row?.metadata
8536
- }));
8537
- }
8538
8497
  emptyUsage() {
8539
8498
  return {
8540
8499
  total_spend: 0,
@@ -8698,6 +8657,114 @@ var LiteLLMClient = class {
8698
8657
  }
8699
8658
  };
8700
8659
 
8660
+ // src/errors.ts
8661
+ var HTML_TAG_PATTERN = /<[^>]*>|&[a-z]+;/gi;
8662
+ function isHttpishError(error) {
8663
+ return error !== null && typeof error === "object" && "status" in error && "body" in error && typeof error.status === "number" && // any: property access after guard check
8664
+ typeof error.body === "object";
8665
+ }
8666
+ function sanitizeUpstreamMessage(msg) {
8667
+ if (!msg || typeof msg !== "string") return "";
8668
+ return msg.replace(/Bearer\s+[A-Za-z0-9._\-+/=]+/g, "Bearer [redacted]").replace(/sk-[A-Za-z0-9_\-]{8,}/g, "sk-[redacted]").replace(HTML_TAG_PATTERN, "").replace(/\s+/g, " ").trim().slice(0, 500);
8669
+ }
8670
+ function toHttpError(error) {
8671
+ if (isHttpishError(error)) {
8672
+ return {
8673
+ status: error.status,
8674
+ body: error.body,
8675
+ logLevel: "warn"
8676
+ };
8677
+ }
8678
+ if (error instanceof LiteLLMUpstreamError) {
8679
+ if (error.status === 401 || error.status === 403) {
8680
+ return {
8681
+ status: 502,
8682
+ body: { error: "LiteLLM rejected the request" },
8683
+ logLevel: "warn"
8684
+ };
8685
+ }
8686
+ if (error.status >= 500 || error.status < 100) {
8687
+ return {
8688
+ status: 502,
8689
+ body: { error: "LiteLLM is unavailable" },
8690
+ logLevel: "error"
8691
+ };
8692
+ }
8693
+ return {
8694
+ status: error.status === 404 || error.status === 409 ? error.status : 400,
8695
+ body: {
8696
+ error: sanitizeUpstreamMessage(error.message),
8697
+ ...error.param ? { param: error.param } : {}
8698
+ },
8699
+ logLevel: "warn"
8700
+ };
8701
+ }
8702
+ if (error && typeof error === "object" && "name" in error && "message" in error) {
8703
+ const name = error.name;
8704
+ const message3 = String(error.message);
8705
+ if (name === "InputError") {
8706
+ return {
8707
+ status: 400,
8708
+ body: { error: message3 },
8709
+ logLevel: "warn"
8710
+ };
8711
+ }
8712
+ if (name === "NotAllowedError") {
8713
+ return {
8714
+ status: 403,
8715
+ body: { error: message3 },
8716
+ logLevel: "warn"
8717
+ };
8718
+ }
8719
+ if (name === "NotFoundError") {
8720
+ return {
8721
+ status: 404,
8722
+ body: { error: message3 },
8723
+ logLevel: "warn"
8724
+ };
8725
+ }
8726
+ if (name === "ConflictError") {
8727
+ return {
8728
+ status: 409,
8729
+ body: { error: message3 },
8730
+ logLevel: "warn"
8731
+ };
8732
+ }
8733
+ }
8734
+ if (error instanceof Error) {
8735
+ const code = error.code;
8736
+ const cause = error.cause;
8737
+ const networkCodes = ["ECONNREFUSED", "ECONNRESET", "ENOTFOUND", "ETIMEDOUT", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"];
8738
+ if (error.name === "AbortError" || error.name === "TimeoutError" || error.name === "TypeError" && error.message === "fetch failed" || typeof code === "string" && networkCodes.includes(code) || typeof cause?.code === "string" && networkCodes.includes(cause.code)) {
8739
+ return {
8740
+ status: 502,
8741
+ body: { error: "LiteLLM is unavailable" },
8742
+ logLevel: "error",
8743
+ _logMessage: error.message
8744
+ };
8745
+ }
8746
+ }
8747
+ const message2 = error instanceof Error ? error.message : String(error);
8748
+ return {
8749
+ status: 500,
8750
+ body: { error: "Internal error" },
8751
+ logLevel: "error",
8752
+ _logMessage: message2
8753
+ };
8754
+ }
8755
+ function sendError(res, error, logger, context) {
8756
+ const { status, body, logLevel, _logMessage } = toHttpError(error);
8757
+ const realMessage = _logMessage || (error instanceof Error ? error.message : String(error));
8758
+ if (logLevel === "error") {
8759
+ logger.error(`Failed to ${context}`, error instanceof Error ? error : new Error(String(error)));
8760
+ } else if (logLevel === "warn") {
8761
+ logger.warn(`${context}: ${realMessage}`);
8762
+ } else {
8763
+ logger.info(`${context}: ${realMessage}`);
8764
+ }
8765
+ res.status(status).json(body);
8766
+ }
8767
+
8701
8768
  // src/provisioning.ts
8702
8769
  function toLiteLLMUserId(userEntityRef, userIdDomain) {
8703
8770
  const name = userEntityRef.split("/").pop() ?? userEntityRef;
@@ -8754,8 +8821,9 @@ function readProvisioningDefaults(config) {
8754
8821
  return { enabled, defaults };
8755
8822
  }
8756
8823
  async function resolveUserId(req, auth) {
8757
- const rawToken = req.headers.authorization?.slice(7);
8758
- if (!rawToken) return void 0;
8824
+ const authHeader = req.headers.authorization ?? "";
8825
+ if (!authHeader.startsWith("Bearer ")) return void 0;
8826
+ const rawToken = authHeader.slice(7);
8759
8827
  try {
8760
8828
  const credentials = await auth.authenticate(rawToken);
8761
8829
  const principal = credentials.principal;
@@ -8767,8 +8835,9 @@ async function resolveUserId(req, auth) {
8767
8835
  return void 0;
8768
8836
  }
8769
8837
  async function resolveCredentials(req, auth) {
8770
- const rawToken = req.headers.authorization?.slice(7);
8771
- if (!rawToken) return void 0;
8838
+ const authHeader = req.headers.authorization ?? "";
8839
+ if (!authHeader.startsWith("Bearer ")) return void 0;
8840
+ const rawToken = authHeader.slice(7);
8772
8841
  try {
8773
8842
  return await auth.authenticate(rawToken);
8774
8843
  } catch {
@@ -8847,10 +8916,6 @@ async function provisionUser(client, userId, defaults, profile, backstageEntity,
8847
8916
  }
8848
8917
  }
8849
8918
  var provisioningInFlight = /* @__PURE__ */ new Map();
8850
- function sanitizeUpstreamMessage(message2) {
8851
- if (!message2) return "unknown error";
8852
- return message2.replace(/Bearer\s+[A-Za-z0-9._\-+/=]+/g, "Bearer [redacted]").replace(/sk-[A-Za-z0-9_\-]{8,}/g, "sk-[redacted]").slice(0, 500);
8853
- }
8854
8919
  var ProvisioningError = class extends Error {
8855
8920
  constructor(message2, hint, provisioning, status = 404) {
8856
8921
  super(message2);
@@ -8858,6 +8923,38 @@ var ProvisioningError = class extends Error {
8858
8923
  this.body = { error: message2, hint, provisioning };
8859
8924
  }
8860
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
+ }
8861
8958
  async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger) {
8862
8959
  if (!userId) {
8863
8960
  throw new ProvisioningError(
@@ -8866,17 +8963,10 @@ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEn
8866
8963
  provisioningEnabled
8867
8964
  );
8868
8965
  }
8869
- const existing = await client.getUserInfo(userId);
8966
+ const existing = await getUserByIdOrAdopted(client, userId);
8870
8967
  if (existing) {
8871
8968
  return existing;
8872
8969
  }
8873
- if (!provisioningEnabled) {
8874
- throw new ProvisioningError(
8875
- "User not found in LiteLLM",
8876
- "Enable litellm.provisioning.enabled in app-config.yaml or create the user manually",
8877
- false
8878
- );
8879
- }
8880
8970
  const pending = provisioningInFlight.get(userId);
8881
8971
  if (pending) {
8882
8972
  logger.info(
@@ -8885,11 +8975,25 @@ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEn
8885
8975
  return pending;
8886
8976
  }
8887
8977
  const provisionPromise = (async () => {
8888
- const catalogRef = tokenEntityRef ?? userId;
8889
- const [matchedRole, profile] = await Promise.all([
8890
- resolveUserRole(catalogRef, roleConfigs, catalogClient, auth, logger),
8891
- tokenEntityRef ? resolveUserProfile(tokenEntityRef, catalogClient, auth, logger) : Promise.resolve({})
8892
- ]);
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
+ );
8893
8997
  const effectiveDefaults = matchedRole ? applyRoleOverrides(provisioningDefaults, matchedRole) : provisioningDefaults;
8894
8998
  if (matchedRole) {
8895
8999
  logger.info(
@@ -8918,7 +9022,7 @@ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEn
8918
9022
  logger.info(
8919
9023
  `LiteLLM user ${userId} already exists during provisioning \u2014 re-fetching`
8920
9024
  );
8921
- const refetched = await client.getUserInfo(userId);
9025
+ const refetched = await client.getUserInfo(userId) ?? await adoptUserByEmail(client, userId, profile.email, logger);
8922
9026
  if (refetched) {
8923
9027
  return refetched;
8924
9028
  }
@@ -8979,7 +9083,7 @@ async function resolveUserRole(userEntityRef, roleConfigs, catalogClient, auth,
8979
9083
  }
8980
9084
 
8981
9085
  // src/router.ts
8982
- 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");
8983
9087
 
8984
9088
  // src/teamAdmin.ts
8985
9089
  var import_plugin_permission_common = __toESM(require_index_cjs2());
@@ -9254,112 +9358,91 @@ function readOpencodeConfig(config) {
9254
9358
  };
9255
9359
  }
9256
9360
 
9257
- // src/errors.ts
9258
- var HTML_TAG_PATTERN = /<[^>]*>|&[a-z]+;/gi;
9259
- function isHttpishError(error) {
9260
- return error !== null && typeof error === "object" && "status" in error && "body" in error && typeof error.status === "number" && // any: property access after guard check
9261
- typeof error.body === "object";
9262
- }
9263
- function sanitizeUpstreamMessage2(msg) {
9264
- if (!msg || typeof msg !== "string") return "";
9265
- return msg.replace(HTML_TAG_PATTERN, "").replace(/\s+/g, " ").trim().slice(0, 500);
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
+ };
9266
9368
  }
9267
- function toHttpError(error) {
9268
- if (isHttpishError(error)) {
9269
- return {
9270
- status: error.status,
9271
- body: error.body,
9272
- logLevel: "warn"
9273
- };
9274
- }
9275
- if (error instanceof LiteLLMUpstreamError) {
9276
- if (error.status === 401 || error.status === 403) {
9277
- return {
9278
- status: 502,
9279
- body: { error: "LiteLLM rejected the request" },
9280
- logLevel: "warn"
9281
- };
9282
- }
9283
- if (error.status >= 500 || error.status < 100) {
9284
- return {
9285
- status: 502,
9286
- body: { error: "LiteLLM is unavailable" },
9287
- logLevel: "error"
9288
- };
9289
- }
9290
- return {
9291
- status: error.status === 404 || error.status === 409 ? error.status : 400,
9292
- body: {
9293
- error: sanitizeUpstreamMessage2(error.message),
9294
- ...error.param ? { param: error.param } : {}
9295
- },
9296
- logLevel: "warn"
9297
- };
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());
9298
9374
  }
9299
- if (error && typeof error === "object" && "name" in error && "message" in error) {
9300
- const name = error.name;
9301
- const message3 = String(error.message);
9302
- if (name === "InputError") {
9303
- return {
9304
- status: 400,
9305
- body: { error: message3 },
9306
- logLevel: "warn"
9307
- };
9308
- }
9309
- if (name === "NotAllowedError") {
9310
- return {
9311
- status: 403,
9312
- body: { error: message3 },
9313
- logLevel: "warn"
9314
- };
9315
- }
9316
- if (name === "NotFoundError") {
9317
- return {
9318
- status: 404,
9319
- body: { error: message3 },
9320
- logLevel: "warn"
9321
- };
9322
- }
9323
- if (name === "ConflictError") {
9324
- return {
9325
- status: 409,
9326
- body: { error: message3 },
9327
- logLevel: "warn"
9328
- };
9329
- }
9375
+ for (const k of keys) {
9376
+ if (k.user_id?.includes("@")) emails.add(k.user_id.toLowerCase());
9330
9377
  }
9331
- if (error instanceof Error) {
9332
- const code = error.code;
9333
- const cause = error.cause;
9334
- const networkCodes = ["ECONNREFUSED", "ECONNRESET", "ENOTFOUND", "ETIMEDOUT", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"];
9335
- if (error.name === "AbortError" || error.name === "TimeoutError" || error.name === "TypeError" && error.message === "fetch failed" || typeof code === "string" && networkCodes.includes(code) || typeof cause?.code === "string" && networkCodes.includes(cause.code)) {
9336
- return {
9337
- status: 502,
9338
- body: { error: "LiteLLM is unavailable" },
9339
- logLevel: "error",
9340
- _logMessage: error.message
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
9341
9397
  };
9398
+ rows.set(userId, row);
9342
9399
  }
9343
- }
9344
- const message2 = error instanceof Error ? error.message : String(error);
9345
- return {
9346
- status: 500,
9347
- body: { error: "Internal error" },
9348
- logLevel: "error",
9349
- _logMessage: message2
9400
+ return row;
9350
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 };
9351
9438
  }
9352
- function sendError(res, error, logger, context) {
9353
- const { status, body, logLevel, _logMessage } = toHttpError(error);
9354
- const realMessage = _logMessage || (error instanceof Error ? error.message : String(error));
9355
- if (logLevel === "error") {
9356
- logger.error(`Failed to ${context}`, error instanceof Error ? error : new Error(String(error)));
9357
- } else if (logLevel === "warn") {
9358
- logger.warn(`${context}: ${realMessage}`);
9359
- } else {
9360
- logger.info(`${context}: ${realMessage}`);
9361
- }
9362
- res.status(status).json(body);
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
+ };
9363
9446
  }
9364
9447
 
9365
9448
  // src/routes/middleware/withUser.ts
@@ -9382,7 +9465,7 @@ function createRequireUser(ctx) {
9382
9465
  async function getProvisionedUser(ctx, res) {
9383
9466
  const tokenEntityRef = res.locals.tokenEntityRef;
9384
9467
  const userId = res.locals.userId;
9385
- return getOrProvisionUser(
9468
+ const userInfo = await getOrProvisionUser(
9386
9469
  ctx.client,
9387
9470
  tokenEntityRef,
9388
9471
  userId,
@@ -9393,6 +9476,8 @@ async function getProvisionedUser(ctx, res) {
9393
9476
  ctx.auth,
9394
9477
  ctx.logger
9395
9478
  );
9479
+ res.locals.userId = effectiveUserId(userId);
9480
+ return userInfo;
9396
9481
  }
9397
9482
 
9398
9483
  // src/services/userInfoCache.ts
@@ -9601,6 +9686,7 @@ var openApiSpec = {
9601
9686
  team_id: { type: "string" },
9602
9687
  duration: { type: "string" },
9603
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' },
9604
9690
  tpm_limit: { type: "number" },
9605
9691
  rpm_limit: { type: "number" }
9606
9692
  } } } }
@@ -9697,6 +9783,24 @@ var openApiSpec = {
9697
9783
  responses: { "200": { description: "Team usage" }, "400": { description: "Missing date range" } }
9698
9784
  }
9699
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
+ },
9700
9804
  "/usage": {
9701
9805
  get: {
9702
9806
  tags: ["Usage"],
@@ -9802,6 +9906,9 @@ function registerConfigRoutes(router, ctx) {
9802
9906
  readOnly: ctx.teamAdminCfg.readOnly,
9803
9907
  memberManagerRoles: ctx.teamAdminCfg.memberManagerRoles
9804
9908
  },
9909
+ teamUsage: {
9910
+ memberBreakdown: ctx.memberBreakdownCfg.enabled
9911
+ },
9805
9912
  display: {
9806
9913
  hideTeamBudgetForMembers: ctx.teamBudgetVisibility.hideTeamBudgetForMembers,
9807
9914
  hideTeamBudgetForManagers: ctx.teamBudgetVisibility.hideTeamBudgetForManagers
@@ -9897,56 +10004,9 @@ async function createKeyForUser(user, input, ctx) {
9897
10004
  config,
9898
10005
  catalogClient,
9899
10006
  auth,
9900
- logger,
9901
- provisioningEnabled,
9902
- provisioningDefaults,
9903
- roleConfigs
10007
+ logger
9904
10008
  } = ctx;
9905
- let userInfo;
9906
- if (catalogClient !== void 0 && catalogClient !== null && auth !== void 0 && auth !== null) {
9907
- userInfo = await getOrProvisionUser(
9908
- client,
9909
- user.tokenEntityRef || "",
9910
- user.userId,
9911
- provisioningEnabled,
9912
- provisioningDefaults,
9913
- roleConfigs,
9914
- catalogClient,
9915
- auth,
9916
- logger
9917
- );
9918
- } else {
9919
- userInfo = await client.getUserInfo(user.userId);
9920
- if (!userInfo) {
9921
- if (!provisioningEnabled) {
9922
- throw new KeyServiceError(
9923
- 404,
9924
- {
9925
- error: "User not found in LiteLLM",
9926
- detail: "No LiteLLM user for this identity. Log in to Backstage once to be provisioned, or enable litellm.provisioning.enabled."
9927
- }
9928
- );
9929
- }
9930
- const created = await client.createUser({
9931
- user_id: user.userId,
9932
- user_email: user.userId,
9933
- max_budget: provisioningDefaults.maxBudget,
9934
- budget_duration: provisioningDefaults.budgetDuration,
9935
- models: provisioningDefaults.models,
9936
- teams: provisioningDefaults.teams?.length ? [provisioningDefaults.teams[0]] : []
9937
- });
9938
- if (!created) {
9939
- throw new KeyServiceError(
9940
- 500,
9941
- {
9942
- error: "User provisioning failed",
9943
- detail: "Failed to create user in LiteLLM"
9944
- }
9945
- );
9946
- }
9947
- userInfo = await client.getUserInfo(user.userId);
9948
- }
9949
- }
10009
+ const userInfo = user.userInfo;
9950
10010
  if (!config.allowUnlimitedBudget && (input.max_budget === null || input.max_budget === void 0)) {
9951
10011
  throw new KeyServiceError(
9952
10012
  400,
@@ -9992,13 +10052,15 @@ async function createKeyForUser(user, input, ctx) {
9992
10052
  }
9993
10053
  const profile = user.tokenEntityRef && catalogClient !== void 0 && catalogClient !== null && auth !== void 0 && auth !== null ? await resolveUserProfile(user.tokenEntityRef, catalogClient, auth, logger) : {};
9994
10054
  const clientMetadata = input.metadata ?? {};
10055
+ const createdVia = user.createdVia ?? "backstage";
9995
10056
  const serverMetadata = {
9996
- created_by_backstage_user: user.tokenEntityRef ?? "unknown",
10057
+ ...user.tokenEntityRef && { created_by_backstage_user: user.tokenEntityRef },
10058
+ ...!user.tokenEntityRef && { created_by: userInfo.user_id },
9997
10059
  ...profile.email && { created_by_email: profile.email },
9998
10060
  ...profile.displayName && {
9999
10061
  created_by_display_name: profile.displayName
10000
10062
  },
10001
- created_via: "backstage",
10063
+ created_via: createdVia,
10002
10064
  created_at_iso: (/* @__PURE__ */ new Date()).toISOString()
10003
10065
  };
10004
10066
  const enrichedMetadata = {
@@ -10007,7 +10069,7 @@ async function createKeyForUser(user, input, ctx) {
10007
10069
  };
10008
10070
  const upstreamRequest = {
10009
10071
  alias: input.alias,
10010
- user_id: user.userId
10072
+ user_id: userInfo.user_id
10011
10073
  };
10012
10074
  if (input.models !== void 0) {
10013
10075
  upstreamRequest.models = input.models;
@@ -10018,6 +10080,9 @@ async function createKeyForUser(user, input, ctx) {
10018
10080
  if (input.max_budget !== void 0) {
10019
10081
  upstreamRequest.max_budget = input.max_budget ?? void 0;
10020
10082
  }
10083
+ if (input.budget_duration !== void 0) {
10084
+ upstreamRequest.budget_duration = input.budget_duration;
10085
+ }
10021
10086
  if (input.tpm_limit !== void 0) {
10022
10087
  upstreamRequest.tpm_limit = input.tpm_limit;
10023
10088
  }
@@ -10032,7 +10097,7 @@ async function createKeyForUser(user, input, ctx) {
10032
10097
  }
10033
10098
  upstreamRequest.metadata = enrichedMetadata;
10034
10099
  const result = await client.generateKey(upstreamRequest);
10035
- logger.info("key.generate", { userId: user.userId, keyAlias: input.alias });
10100
+ logger.info("key.generate", { userId: userInfo.user_id, keyAlias: input.alias });
10036
10101
  return result;
10037
10102
  }
10038
10103
 
@@ -10043,17 +10108,12 @@ function registerKeysRoutes(router, ctx) {
10043
10108
  catalogClient,
10044
10109
  auth,
10045
10110
  logger,
10046
- userIdDomain,
10047
- provisioningEnabled,
10048
- provisioningDefaults,
10049
- roleConfigs,
10050
10111
  auditGroup,
10051
10112
  allowUnlimitedBudget,
10052
10113
  teamRequired,
10053
10114
  allowOwnerResetSpend,
10054
10115
  keyValidationConfig,
10055
10116
  authorizeKeyAction,
10056
- sendOwnershipError,
10057
10117
  assertPermission,
10058
10118
  sendPermissionDenied
10059
10119
  } = ctx;
@@ -10081,8 +10141,8 @@ function registerKeysRoutes(router, ctx) {
10081
10141
  });
10082
10142
  router.get("/keys", async (_req, res) => {
10083
10143
  try {
10084
- const userId = res.locals.userId;
10085
10144
  await getProvisionedUser(ctx, res);
10145
+ const userId = res.locals.userId;
10086
10146
  const keys = await client.listKeys(userId);
10087
10147
  res.json(keys);
10088
10148
  } catch (error) {
@@ -10099,8 +10159,8 @@ function registerKeysRoutes(router, ctx) {
10099
10159
  sendPermissionDenied(res, import_backstage_plugin_litellm_common3.litellmKeyRevokePermission);
10100
10160
  return;
10101
10161
  }
10102
- const userId = res.locals.userId;
10103
10162
  await getProvisionedUser(ctx, res);
10163
+ const userId = res.locals.userId;
10104
10164
  const keys = await client.listKeys(userId);
10105
10165
  const now = Date.now();
10106
10166
  const expiredKeys = keys.filter((k) => {
@@ -10125,7 +10185,7 @@ function registerKeysRoutes(router, ctx) {
10125
10185
  const keyId = key.token ?? key.key;
10126
10186
  let errorMsg = "Unknown error";
10127
10187
  if (err instanceof Error) {
10128
- errorMsg = sanitizeUpstreamMessage2(err.message) || "Unknown error";
10188
+ errorMsg = sanitizeUpstreamMessage(err.message) || "Unknown error";
10129
10189
  }
10130
10190
  failures.push({ keyId, error: errorMsg });
10131
10191
  logger.warn("key.prune-expired failed", {
@@ -10165,7 +10225,7 @@ function registerKeysRoutes(router, ctx) {
10165
10225
  return;
10166
10226
  }
10167
10227
  const tokenEntityRef = res.locals.tokenEntityRef;
10168
- const resolvedUserId = res.locals.userId;
10228
+ const userInfo = await getProvisionedUser(ctx, res);
10169
10229
  const keyCreateCtx = {
10170
10230
  client,
10171
10231
  config: {
@@ -10175,14 +10235,10 @@ function registerKeysRoutes(router, ctx) {
10175
10235
  catalogClient,
10176
10236
  auth,
10177
10237
  logger,
10178
- keyValidationConfig,
10179
- provisioningEnabled,
10180
- provisioningDefaults,
10181
- roleConfigs,
10182
- userIdDomain
10238
+ keyValidationConfig
10183
10239
  };
10184
10240
  const result = await createKeyForUser(
10185
- { tokenEntityRef, userId: resolvedUserId },
10241
+ { tokenEntityRef, userInfo },
10186
10242
  input,
10187
10243
  keyCreateCtx
10188
10244
  );
@@ -10282,7 +10338,6 @@ function registerKeysRoutes(router, ctx) {
10282
10338
  logger.info("key.update", { userId: tokenEntityRef ?? "unknown", keyId });
10283
10339
  res.json(result);
10284
10340
  } catch (error) {
10285
- if (sendOwnershipError(error, res)) return;
10286
10341
  sendError(res, error, logger, "update key");
10287
10342
  }
10288
10343
  });
@@ -10302,7 +10357,6 @@ function registerKeysRoutes(router, ctx) {
10302
10357
  logger.info("key.delete", { userId: tokenEntityRef ?? "unknown", keyId });
10303
10358
  res.json({ success: true });
10304
10359
  } catch (error) {
10305
- if (sendOwnershipError(error, res)) return;
10306
10360
  sendError(res, error, logger, "delete key");
10307
10361
  }
10308
10362
  });
@@ -10331,7 +10385,6 @@ function registerKeysRoutes(router, ctx) {
10331
10385
  logger.info("key.block", { userId: tokenEntityRef ?? "unknown", keyId });
10332
10386
  res.json({ success: true });
10333
10387
  } catch (error) {
10334
- if (sendOwnershipError(error, res)) return;
10335
10388
  sendError(res, error, logger, "block key");
10336
10389
  }
10337
10390
  });
@@ -10356,7 +10409,6 @@ function registerKeysRoutes(router, ctx) {
10356
10409
  logger.info("key.unblock", { userId: tokenEntityRef ?? "unknown", keyId });
10357
10410
  res.json({ success: true });
10358
10411
  } catch (error) {
10359
- if (sendOwnershipError(error, res)) return;
10360
10412
  sendError(res, error, logger, "unblock key");
10361
10413
  }
10362
10414
  });
@@ -10378,7 +10430,6 @@ function registerKeysRoutes(router, ctx) {
10378
10430
  logger.info("key.reset_spend", { userId: tokenEntityRef ?? "unknown", keyId });
10379
10431
  res.json({ success: true });
10380
10432
  } catch (error) {
10381
- if (sendOwnershipError(error, res)) return;
10382
10433
  sendError(res, error, logger, "reset key spend");
10383
10434
  }
10384
10435
  });
@@ -10438,23 +10489,11 @@ function registerKeysRoutes(router, ctx) {
10438
10489
  res.status(400).json({ error: "start_date and end_date are required" });
10439
10490
  return;
10440
10491
  }
10441
- const tokenEntityRef = res.locals.tokenEntityRef;
10442
- const userId = res.locals.userId;
10443
- await getOrProvisionUser(
10444
- client,
10445
- tokenEntityRef,
10446
- userId,
10447
- provisioningEnabled,
10448
- provisioningDefaults,
10449
- roleConfigs,
10450
- catalogClient,
10451
- auth,
10452
- logger
10453
- );
10492
+ await getProvisionedUser(ctx, res);
10454
10493
  const usage = await client.getUsage(
10455
10494
  start_date,
10456
10495
  end_date,
10457
- userId
10496
+ res.locals.userId
10458
10497
  );
10459
10498
  res.json(usage);
10460
10499
  } catch (error) {
@@ -10514,11 +10553,11 @@ function registerOpencodeRoutes(router, ctx) {
10514
10553
  sendPermissionDenied(res, import_backstage_plugin_litellm_common4.litellmKeyCreatePermission);
10515
10554
  return null;
10516
10555
  }
10517
- const userId = toLiteLLMUserId(tokenEntityRef, userIdDomain);
10556
+ const computedUserId = toLiteLLMUserId(tokenEntityRef, userIdDomain);
10518
10557
  const userInfo = await getOrProvisionUser(
10519
10558
  client,
10520
10559
  tokenEntityRef,
10521
- userId,
10560
+ computedUserId,
10522
10561
  provisioningEnabled,
10523
10562
  provisioningDefaults,
10524
10563
  roleConfigs,
@@ -10540,7 +10579,7 @@ function registerOpencodeRoutes(router, ctx) {
10540
10579
  res.status(400).json({ error: "A team is required for this connection" });
10541
10580
  return null;
10542
10581
  }
10543
- return { tokenEntityRef, userId, userInfo, teamId, redirectUri };
10582
+ return { tokenEntityRef, userId: effectiveUserId(computedUserId), userInfo, teamId, redirectUri };
10544
10583
  };
10545
10584
  router.get("/opencode/connect", async (req, res) => {
10546
10585
  try {
@@ -10676,6 +10715,10 @@ function registerOpencodeRoutes(router, ctx) {
10676
10715
  var import_backstage_plugin_litellm_common5 = require("@acarmisc/backstage-plugin-litellm-common");
10677
10716
 
10678
10717
  // src/http/respondTeam.ts
10718
+ function respondTeam(res, team, ctx, caller) {
10719
+ const shouldHide = caller === "member" ? ctx.teamBudgetVisibility.hideTeamBudgetForMembers : ctx.teamBudgetVisibility.hideTeamBudgetForManagers;
10720
+ res.json(shouldHide ? redactTeamBudget(team) : team);
10721
+ }
10679
10722
  function respondTeamList(res, teams, ctx, caller) {
10680
10723
  const shouldHide = caller === "member" ? ctx.teamBudgetVisibility.hideTeamBudgetForMembers : ctx.teamBudgetVisibility.hideTeamBudgetForManagers;
10681
10724
  res.json(shouldHide ? teams.map(redactTeamBudget) : teams);
@@ -10716,7 +10759,6 @@ function registerTeamsRoutes(router, ctx) {
10716
10759
  provisioningDefaults,
10717
10760
  roleConfigs,
10718
10761
  teamAdminCfg,
10719
- teamBudgetVisibility,
10720
10762
  requireTeamMgmt,
10721
10763
  sendTeamError,
10722
10764
  authorizeTeamSubresource
@@ -10796,7 +10838,7 @@ function registerTeamsRoutes(router, ctx) {
10796
10838
  logger.info(`Team creation already in flight for ${key} \u2014 joining`);
10797
10839
  try {
10798
10840
  const result = await pending;
10799
- res.json(result);
10841
+ respondTeam(res, result, ctx, "manager");
10800
10842
  } catch (err) {
10801
10843
  sendTeamError(err, res);
10802
10844
  }
@@ -10828,9 +10870,7 @@ function registerTeamsRoutes(router, ctx) {
10828
10870
  teamCreateInFlight.set(key, createPromise);
10829
10871
  try {
10830
10872
  const result = await createPromise;
10831
- res.json(
10832
- teamBudgetVisibility.hideTeamBudgetForManagers ? redactTeamBudget(result) : result
10833
- );
10873
+ respondTeam(res, result, ctx, "manager");
10834
10874
  } catch (err) {
10835
10875
  sendTeamError(err, res);
10836
10876
  } finally {
@@ -10873,9 +10913,7 @@ function registerTeamsRoutes(router, ctx) {
10873
10913
  teamId,
10874
10914
  owningGroup
10875
10915
  });
10876
- res.json(
10877
- teamBudgetVisibility.hideTeamBudgetForManagers ? redactTeamBudget(r) : r
10878
- );
10916
+ respondTeam(res, r, ctx, "manager");
10879
10917
  } catch (err) {
10880
10918
  sendTeamError(err, res);
10881
10919
  }
@@ -10937,9 +10975,7 @@ function registerTeamsRoutes(router, ctx) {
10937
10975
  const owned = all.filter(
10938
10976
  (t) => typeof t.metadata?.owning_group === "string" && t.metadata.owning_group === teamAdminCfg.group
10939
10977
  );
10940
- res.json(
10941
- teamBudgetVisibility.hideTeamBudgetForManagers ? owned.map(redactTeamBudget) : owned
10942
- );
10978
+ respondTeamList(res, owned, ctx, "manager");
10943
10979
  } catch (err) {
10944
10980
  sendTeamError(err, res);
10945
10981
  }
@@ -11005,12 +11041,12 @@ function registerTeamsRoutes(router, ctx) {
11005
11041
  });
11006
11042
  return;
11007
11043
  }
11008
- const litellmUserId = toLiteLLMUserId(userEntityRef, userIdDomain);
11044
+ const computedUserId = toLiteLLMUserId(userEntityRef, userIdDomain);
11009
11045
  try {
11010
11046
  await getOrProvisionUser(
11011
11047
  client,
11012
11048
  userEntityRef,
11013
- litellmUserId,
11049
+ computedUserId,
11014
11050
  provisioningEnabled,
11015
11051
  provisioningDefaults,
11016
11052
  roleConfigs,
@@ -11025,6 +11061,7 @@ function registerTeamsRoutes(router, ctx) {
11025
11061
  }
11026
11062
  throw err;
11027
11063
  }
11064
+ const litellmUserId = effectiveUserId(computedUserId);
11028
11065
  try {
11029
11066
  await client.teamMemberAdd({
11030
11067
  team_id: teamId,
@@ -11042,9 +11079,7 @@ function registerTeamsRoutes(router, ctx) {
11042
11079
  via
11043
11080
  });
11044
11081
  const updated = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
11045
- res.json(
11046
- teamBudgetVisibility.hideTeamBudgetForManagers ? redactTeamBudget(updated) : updated
11047
- );
11082
+ respondTeam(res, updated, ctx, "manager");
11048
11083
  } catch (err) {
11049
11084
  sendTeamError(err, res);
11050
11085
  }
@@ -11066,9 +11101,9 @@ function registerTeamsRoutes(router, ctx) {
11066
11101
  res.status(400).json({ error: "userEntityRef query parameter is required" });
11067
11102
  return;
11068
11103
  }
11069
- const litellmUserId = toLiteLLMUserId(userEntityRef, userIdDomain);
11104
+ const litellmUserId = await ctx.resolveMemberUserId(userEntityRef);
11070
11105
  if (via === "teamRole") {
11071
- if (litellmUserId === toLiteLLMUserId(actor, userIdDomain)) {
11106
+ if (litellmUserId === await ctx.resolveMemberUserId(actor)) {
11072
11107
  res.status(400).json({ error: "You cannot remove yourself from the team" });
11073
11108
  return;
11074
11109
  }
@@ -11095,9 +11130,7 @@ function registerTeamsRoutes(router, ctx) {
11095
11130
  via
11096
11131
  });
11097
11132
  const updated = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
11098
- res.json(
11099
- teamBudgetVisibility.hideTeamBudgetForManagers ? redactTeamBudget(updated) : updated
11100
- );
11133
+ respondTeam(res, updated, ctx, "manager");
11101
11134
  } catch (err) {
11102
11135
  sendTeamError(err, res);
11103
11136
  }
@@ -11115,7 +11148,6 @@ function registerObjectPermissionsRoutes(router, ctx) {
11115
11148
  permissions,
11116
11149
  logger,
11117
11150
  teamAdminCfg,
11118
- teamBudgetVisibility,
11119
11151
  requireObjectPerms,
11120
11152
  sendTeamError,
11121
11153
  authorizeTeamSubresource
@@ -11196,9 +11228,7 @@ function registerObjectPermissionsRoutes(router, ctx) {
11196
11228
  owningGroup,
11197
11229
  vector_stores: requested
11198
11230
  });
11199
- res.json(
11200
- teamBudgetVisibility.hideTeamBudgetForManagers ? redactTeamBudget(updated) : updated
11201
- );
11231
+ respondTeam(res, updated, ctx, "manager");
11202
11232
  } catch (err) {
11203
11233
  sendTeamError(err, res);
11204
11234
  }
@@ -11282,9 +11312,7 @@ function registerObjectPermissionsRoutes(router, ctx) {
11282
11312
  before,
11283
11313
  after: requested
11284
11314
  });
11285
- res.json(
11286
- teamBudgetVisibility.hideTeamBudgetForManagers ? redactTeamBudget(updated) : updated
11287
- );
11315
+ respondTeam(res, updated, ctx, "manager");
11288
11316
  } catch (err) {
11289
11317
  sendTeamError(err, res);
11290
11318
  }
@@ -11293,15 +11321,13 @@ function registerObjectPermissionsRoutes(router, ctx) {
11293
11321
  }
11294
11322
 
11295
11323
  // src/routes/teamUsage.ts
11324
+ var import_backstage_plugin_litellm_common7 = require("@acarmisc/backstage-plugin-litellm-common");
11296
11325
  function registerTeamUsageRoutes(router, ctx) {
11297
11326
  const {
11298
11327
  client,
11299
11328
  catalogClient,
11300
11329
  auth,
11301
11330
  logger,
11302
- provisioningEnabled,
11303
- provisioningDefaults,
11304
- roleConfigs,
11305
11331
  teamBudgetVisibility
11306
11332
  } = ctx;
11307
11333
  const requireUser = createRequireUser(ctx);
@@ -11314,18 +11340,7 @@ function registerTeamUsageRoutes(router, ctx) {
11314
11340
  return;
11315
11341
  }
11316
11342
  const tokenEntityRef = res.locals.tokenEntityRef;
11317
- const userId = res.locals.userId;
11318
- const userInfo = await getOrProvisionUser(
11319
- client,
11320
- tokenEntityRef,
11321
- userId,
11322
- provisioningEnabled,
11323
- provisioningDefaults,
11324
- roleConfigs,
11325
- catalogClient,
11326
- auth,
11327
- logger
11328
- );
11343
+ const userInfo = await getProvisionedUser(ctx, res);
11329
11344
  const isMember = userInfo?.teams?.includes(teamId) ?? false;
11330
11345
  let isManager = false;
11331
11346
  const managerCheckNeeded = ctx.teamMgmtEnabled && (!isMember || teamBudgetVisibility.hideTeamBudgetForMembers !== teamBudgetVisibility.hideTeamBudgetForManagers);
@@ -11369,6 +11384,95 @@ function registerTeamUsageRoutes(router, ctx) {
11369
11384
  sendError(res, error, logger, "fetch team usage");
11370
11385
  }
11371
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
+ }
11372
11476
  }
11373
11477
 
11374
11478
  // node_modules/jose/dist/node/esm/runtime/base64url.js
@@ -12784,18 +12888,17 @@ function newDefaultVerifier(cfg) {
12784
12888
  return new KeycloakJWTVerifier({ issuer: cfg.issuer, clientId: cfg.clientId });
12785
12889
  }
12786
12890
  function normalizeIdentityOptions(opts) {
12787
- const o = typeof opts === "string" ? { userIdDomain: opts } : opts ?? {};
12788
12891
  let domains = [];
12789
- if (o.trustedEmailDomains?.length) {
12790
- domains = o.trustedEmailDomains;
12791
- } else if (o.userIdDomain) {
12792
- domains = [o.userIdDomain];
12892
+ if (opts.trustedEmailDomains?.length) {
12893
+ domains = opts.trustedEmailDomains;
12894
+ } else if (opts.userIdDomain) {
12895
+ domains = [opts.userIdDomain];
12793
12896
  }
12794
12897
  const trusted = domains.map((d) => d.toLowerCase());
12795
- return { userIdDomain: o.userIdDomain, trusted };
12898
+ return { userIdDomain: opts.userIdDomain, trusted };
12796
12899
  }
12797
12900
  function resolveBridgeUserId(claims, identity) {
12798
- const { userIdDomain, trusted } = normalizeIdentityOptions(identity);
12901
+ const { userIdDomain, trusted } = normalizeIdentityOptions(identity ?? {});
12799
12902
  if (trusted.length === 0) {
12800
12903
  throw new BridgeIdentityError(
12801
12904
  "set litellm.bridge.allowedEmailDomains (or litellm.userIdDomain) to use the CLI bridge"
@@ -12830,8 +12933,8 @@ function resolveBridgeUserId(claims, identity) {
12830
12933
  name = name || emailLocalPart;
12831
12934
  return toLiteLLMUserId(name, userIdDomain);
12832
12935
  }
12833
- async function getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain) {
12834
- const userId = resolveBridgeUserId(claims, userIdDomain);
12936
+ async function getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, identity) {
12937
+ const userId = resolveBridgeUserId(claims, identity);
12835
12938
  const existing = await client.getUserInfo(userId);
12836
12939
  if (existing) return existing;
12837
12940
  if (claims.email) {
@@ -12872,42 +12975,21 @@ async function getOrProvisionUserFromClaims(client, claims, provisioningEnabled,
12872
12975
  }
12873
12976
  return created;
12874
12977
  }
12875
- async function bridgeListKeys(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain) {
12978
+ async function bridgeListKeys(client, claims, provisioningEnabled, provisioningDefaults, logger, identity) {
12876
12979
  const user = await getOrProvisionUserFromClaims(
12877
12980
  client,
12878
12981
  claims,
12879
12982
  provisioningEnabled,
12880
12983
  provisioningDefaults,
12881
12984
  logger,
12882
- userIdDomain
12985
+ identity
12883
12986
  );
12884
12987
  return client.listKeys(user.user_id);
12885
12988
  }
12886
- async function bridgeGenerateKey(client, claims, provisioningEnabled, provisioningDefaults, logger, request, userIdDomain) {
12887
- const user = await getOrProvisionUserFromClaims(
12888
- client,
12889
- claims,
12890
- provisioningEnabled,
12891
- provisioningDefaults,
12892
- logger,
12893
- userIdDomain
12894
- );
12895
- const enriched = {
12896
- ...request,
12897
- user_id: user.user_id,
12898
- metadata: {
12899
- ...request.metadata ?? {},
12900
- created_via: "abby-cli",
12901
- created_by: user.user_id,
12902
- created_at_iso: (/* @__PURE__ */ new Date()).toISOString()
12903
- }
12904
- };
12905
- return client.generateKey(enriched);
12906
- }
12907
12989
 
12908
12990
  // src/routes/bridge.ts
12909
- var import_backstage_plugin_litellm_common7 = require("@acarmisc/backstage-plugin-litellm-common");
12910
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");
12911
12993
  function registerBridgeRoutes(router, ctx, bridgeOpts) {
12912
12994
  const {
12913
12995
  client,
@@ -12915,13 +12997,12 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
12915
12997
  userIdDomain,
12916
12998
  provisioningEnabled,
12917
12999
  provisioningDefaults,
12918
- roleConfigs,
12919
13000
  allowUnlimitedBudget,
12920
13001
  teamRequired,
12921
13002
  keyValidationConfig
12922
13003
  } = ctx;
12923
13004
  const { config } = bridgeOpts;
12924
- const generateKeyInputSchema = (0, import_backstage_plugin_litellm_common8.createGenerateKeyInputSchema)(ctx.keyValidationConfig);
13005
+ const generateKeyInputSchema = (0, import_backstage_plugin_litellm_common9.createGenerateKeyInputSchema)(ctx.keyValidationConfig);
12925
13006
  const bridgeCfg = readBridgeConfig(config);
12926
13007
  const bridgeIdentity = {
12927
13008
  userIdDomain,
@@ -13000,26 +13081,25 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
13000
13081
  if (!allowUnlimitedBudget && input.max_budget === void 0) {
13001
13082
  input.max_budget = provisioningDefaults.maxBudget;
13002
13083
  }
13003
- const userId = resolveBridgeUserId(claims, bridgeIdentity);
13084
+ const userInfo = await getOrProvisionUserFromClaims(
13085
+ client,
13086
+ claims,
13087
+ provisioningEnabled,
13088
+ provisioningDefaults,
13089
+ logger,
13090
+ bridgeIdentity
13091
+ );
13004
13092
  const keyCreateCtx = {
13005
13093
  client,
13006
13094
  config: {
13007
13095
  allowUnlimitedBudget,
13008
13096
  teamRequired
13009
13097
  },
13010
- // Bridge does not have Backstage catalogClient/auth
13011
- // so provisioning from JWT claims only (no Backstage profile enrichment)
13012
- catalogClient: void 0,
13013
- auth: void 0,
13014
13098
  logger,
13015
- keyValidationConfig,
13016
- provisioningEnabled,
13017
- provisioningDefaults,
13018
- roleConfigs,
13019
- userIdDomain
13099
+ keyValidationConfig
13020
13100
  };
13021
13101
  const result = await createKeyForUser(
13022
- { userId },
13102
+ { userInfo, createdVia: "abby-cli" },
13023
13103
  input,
13024
13104
  keyCreateCtx
13025
13105
  );
@@ -13040,20 +13120,21 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
13040
13120
  bridgeIdentity
13041
13121
  );
13042
13122
  const teamIds = user.teams ?? [];
13043
- const teamMetadata = [];
13044
- for (const teamId of teamIds) {
13045
- try {
13046
- const teamInfo = await client.getTeamInfo(teamId);
13047
- teamMetadata.push({
13048
- id: teamId,
13049
- name: teamInfo.team_alias || teamId
13050
- // Use alias as display name, fallback to ID
13051
- });
13052
- } catch (e) {
13053
- logger.debug(`Could not fetch team info for ${teamId}: ${e.message}`);
13054
- teamMetadata.push({ id: teamId });
13055
- }
13056
- }
13123
+ const teamMetadata = await Promise.all(
13124
+ teamIds.map(async (teamId) => {
13125
+ try {
13126
+ const teamInfo = await client.getTeamInfo(teamId);
13127
+ return {
13128
+ id: teamId,
13129
+ name: teamInfo.team_alias || teamId
13130
+ // Use alias as display name, fallback to ID
13131
+ };
13132
+ } catch (e) {
13133
+ logger.debug(`Could not fetch team info for ${teamId}: ${e.message}`);
13134
+ return { id: teamId };
13135
+ }
13136
+ })
13137
+ );
13057
13138
  res.json({ user_id: user.user_id, teams: teamIds, team_metadata: teamMetadata });
13058
13139
  } catch (error) {
13059
13140
  handleBridgeError(error, res);
@@ -13082,11 +13163,11 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
13082
13163
  }
13083
13164
  const team = await client.getTeamInfo(teamId);
13084
13165
  const allowed = team.models;
13085
- 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)) {
13086
13167
  res.json(models);
13087
13168
  return;
13088
13169
  }
13089
- 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)));
13090
13171
  } catch (error) {
13091
13172
  handleBridgeError(error, res);
13092
13173
  }
@@ -13118,18 +13199,20 @@ async function createRouter(options) {
13118
13199
  const keyMaxBudget = config.getOptionalNumber("litellm.keys.maxBudget") ?? 100;
13119
13200
  const keyMaxTpm = config.getOptionalNumber("litellm.keys.maxTpm") ?? 1e5;
13120
13201
  const keyMaxRpm = config.getOptionalNumber("litellm.keys.maxRpm") ?? 1e3;
13121
- 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;
13122
13203
  const keyValidationConfig = {
13123
13204
  maxBudget: keyMaxBudget,
13124
13205
  maxTpm: keyMaxTpm,
13125
13206
  maxRpm: keyMaxRpm,
13126
- allowedDurations: keyAllowedDurations
13207
+ allowedDurations: keyAllowedDurations,
13208
+ allowedBudgetDurations: config.getOptionalStringArray("litellm.keys.allowedBudgetDurations") ?? import_backstage_plugin_litellm_common10.DEFAULT_KEY_BUDGET_DURATIONS
13127
13209
  };
13128
13210
  const teamMgmtEnabled = isTeamManagementEnabled(config);
13129
13211
  const objectPermsEnabled = isObjectPermissionsEnabled(config);
13130
13212
  const teamAdminCfg = readTeamAdminConfig(config);
13131
13213
  const teamBudgetVisibility = readTeamBudgetVisibility(config);
13132
13214
  const opencodeCfg = readOpencodeConfig(config);
13215
+ const memberBreakdownCfg = readMemberBreakdownConfig(config);
13133
13216
  const catalogClient = options.catalogClient ?? new import_catalog_client.CatalogClient({ discoveryApi: discovery });
13134
13217
  if (provisioningEnabled) {
13135
13218
  logger.info(
@@ -13146,7 +13229,13 @@ async function createRouter(options) {
13146
13229
  `LiteLLM team budget hiding enabled \u2014 members: ${teamBudgetVisibility.hideTeamBudgetForMembers}, managers: ${teamBudgetVisibility.hideTeamBudgetForManagers}`
13147
13230
  );
13148
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
+ };
13149
13237
  const ctx = {
13238
+ resolveMemberUserId,
13150
13239
  client,
13151
13240
  catalogClient,
13152
13241
  auth,
@@ -13169,34 +13258,21 @@ async function createRouter(options) {
13169
13258
  teamAdminCfg,
13170
13259
  teamBudgetVisibility,
13171
13260
  opencodeCfg,
13261
+ memberBreakdownCfg,
13172
13262
  // Helper functions - defined inline below this object
13173
13263
  authorizeKeyAction: async (req, keyId) => {
13174
13264
  if (!req.res || !req.res.locals.tokenEntityRef || !req.res.locals.userId) {
13175
- throw new import_errors20.NotAllowedError("User identity not found in request");
13265
+ throw new import_errors21.NotAllowedError("User identity not found in request");
13176
13266
  }
13177
13267
  const tokenEntityRef = req.res.locals.tokenEntityRef;
13178
- const userId = req.res.locals.userId;
13268
+ const userId = await resolveMemberUserId(tokenEntityRef);
13179
13269
  const ownKeys = await client.listKeys(userId);
13180
13270
  const key = ownKeys.find((k) => (k.token ?? k.key) === keyId);
13181
13271
  if (!key) {
13182
- throw new import_errors20.NotAllowedError("Access denied: key does not belong to the caller");
13272
+ throw new import_errors21.NotAllowedError("Access denied: key does not belong to the caller");
13183
13273
  }
13184
13274
  return { tokenEntityRef, userId, key };
13185
13275
  },
13186
- sendOwnershipError: (err, res) => {
13187
- if (err instanceof import_errors20.NotAllowedError) {
13188
- res.status(403).json({ error: err.message });
13189
- return true;
13190
- }
13191
- if (err && typeof err === "object" && "status" in err && "body" in err) {
13192
- const errObj = err;
13193
- if (typeof errObj.status === "number" && errObj.body) {
13194
- res.status(errObj.status).json(errObj.body);
13195
- return true;
13196
- }
13197
- }
13198
- return false;
13199
- },
13200
13276
  assertPermission: async (req, permission) => {
13201
13277
  const credentials = await resolveCredentials(req, auth);
13202
13278
  if (!credentials) return false;
@@ -13305,7 +13381,7 @@ async function createRouter(options) {
13305
13381
  }
13306
13382
  if (teamRoleEligible) {
13307
13383
  const actor = await resolveUserId(req, auth);
13308
- const litellmUserId = actor && toLiteLLMUserId(actor, userIdDomain);
13384
+ const litellmUserId = actor && await resolveMemberUserId(actor);
13309
13385
  const role = existing.members_with_roles?.find(
13310
13386
  (m) => m.user_id === litellmUserId
13311
13387
  )?.role;
@@ -13339,7 +13415,7 @@ async function createRouter(options) {
13339
13415
  }
13340
13416
 
13341
13417
  // src/plugin.ts
13342
- 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");
13343
13419
  var BRIDGE_UNAUTHENTICATED_PATHS = [
13344
13420
  "/bridge/health",
13345
13421
  "/bridge/keys",
@@ -13368,7 +13444,7 @@ var litellmPlugin = (0, import_backend_plugin_api.createBackendPlugin)({
13368
13444
  permissions,
13369
13445
  permissionsRegistry
13370
13446
  }) {
13371
- permissionsRegistry.addPermissions(import_backstage_plugin_litellm_common10.litellmPermissions);
13447
+ permissionsRegistry.addPermissions(import_backstage_plugin_litellm_common11.litellmPermissions);
13372
13448
  const router = await createRouter({
13373
13449
  config,
13374
13450
  logger,
@@ -13388,7 +13464,7 @@ var litellmPlugin = (0, import_backend_plugin_api.createBackendPlugin)({
13388
13464
  });
13389
13465
 
13390
13466
  // src/index.ts
13391
- 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");
13392
13468
  // Annotate the CommonJS export names for ESM import in node:
13393
13469
  0 && (module.exports = {
13394
13470
  KeycloakJWTVerifier,
@@ -13396,7 +13472,6 @@ var import_backstage_plugin_litellm_common11 = require("@acarmisc/backstage-plug
13396
13472
  ProvisioningError,
13397
13473
  applyRoleOverrides,
13398
13474
  assertTeamAdmin,
13399
- bridgeGenerateKey,
13400
13475
  bridgeListKeys,
13401
13476
  createRouter,
13402
13477
  getOrProvisionUser,
@@ -13417,7 +13492,6 @@ var import_backstage_plugin_litellm_common11 = require("@acarmisc/backstage-plug
13417
13492
  litellmTeamMcpManagePermission,
13418
13493
  litellmTeamMembersManagePermission,
13419
13494
  newDefaultVerifier,
13420
- normalizeRequestTags,
13421
13495
  provisionUser,
13422
13496
  readBridgeConfig,
13423
13497
  readProvisioningDefaults,