@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.d.ts CHANGED
@@ -2,13 +2,13 @@ export { litellmPlugin } from './plugin';
2
2
  export { litellmPlugin as default } from './plugin';
3
3
  export { createRouter } from './router';
4
4
  export * from './types';
5
- export { LiteLLMClient, normalizeRequestTags } from './client';
5
+ export { LiteLLMClient } from './client';
6
6
  export { resolveUserId, resolveUserProfile, toLiteLLMUserId, getOrProvisionUser, provisionUser, readProvisioningDefaults, readRoleConfigs, applyRoleOverrides, resolveUserRole, ProvisioningError, } from './provisioning';
7
7
  export type { BackstageUserProfile } from './provisioning';
8
8
  export { readTeamAdminConfig, isTeamManagementEnabled, assertTeamAdmin, validateTeamWriteInput, validateTeamPatchInput, } from './teamAdmin';
9
9
  export type { TeamAdminConfig, TeamAdminCheck, TeamWriteInput, TeamWriteValidation, TeamPatchValidation } from './teamAdmin';
10
10
  export { readTeamBudgetVisibility, redactTeamBudget, redactTeamUsage, teamBudgetStatusFor, } from './teamBudgetVisibility';
11
11
  export type { TeamBudgetVisibility, TeamBudgetStatus } from './teamBudgetVisibility';
12
- export { KeycloakJWTVerifier, newDefaultVerifier, readBridgeConfig, resolveBridgeUserId, getOrProvisionUserFromClaims, bridgeListKeys, bridgeGenerateKey, } from './bridge';
12
+ export { KeycloakJWTVerifier, newDefaultVerifier, readBridgeConfig, resolveBridgeUserId, getOrProvisionUserFromClaims, bridgeListKeys, } from './bridge';
13
13
  export type { BridgeClaims, TokenVerifier, BridgeConfig, KeycloakJWTVerifierOptions, } from './bridge';
14
14
  export { litellmPermissions, litellmKeyCreatePermission, litellmKeyRevokePermission, litellmKeyManagePermission, litellmKeyResetSpendPermission, litellmKeyUnblockPermission, litellmAuditReadPermission, litellmTeamCreatePermission, litellmTeamManagePermission, litellmTeamMembersManagePermission, litellmTeamKnowledgebaseManagePermission, litellmTeamMcpManagePermission, litellmTeamDeletePermission, } from '@acarmisc/backstage-plugin-litellm-common';
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.litellmTeamDeletePermission = exports.litellmTeamMcpManagePermission = exports.litellmTeamKnowledgebaseManagePermission = exports.litellmTeamMembersManagePermission = exports.litellmTeamManagePermission = exports.litellmTeamCreatePermission = exports.litellmAuditReadPermission = exports.litellmKeyUnblockPermission = exports.litellmKeyResetSpendPermission = exports.litellmKeyManagePermission = exports.litellmKeyRevokePermission = exports.litellmKeyCreatePermission = exports.litellmPermissions = exports.bridgeGenerateKey = exports.bridgeListKeys = exports.getOrProvisionUserFromClaims = exports.resolveBridgeUserId = exports.readBridgeConfig = exports.newDefaultVerifier = exports.KeycloakJWTVerifier = exports.teamBudgetStatusFor = exports.redactTeamUsage = exports.redactTeamBudget = exports.readTeamBudgetVisibility = exports.validateTeamPatchInput = exports.validateTeamWriteInput = exports.assertTeamAdmin = exports.isTeamManagementEnabled = exports.readTeamAdminConfig = exports.ProvisioningError = exports.resolveUserRole = exports.applyRoleOverrides = exports.readRoleConfigs = exports.readProvisioningDefaults = exports.provisionUser = exports.getOrProvisionUser = exports.toLiteLLMUserId = exports.resolveUserProfile = exports.resolveUserId = exports.normalizeRequestTags = exports.LiteLLMClient = exports.createRouter = exports.default = exports.litellmPlugin = void 0;
17
+ exports.litellmTeamDeletePermission = exports.litellmTeamMcpManagePermission = exports.litellmTeamKnowledgebaseManagePermission = exports.litellmTeamMembersManagePermission = exports.litellmTeamManagePermission = exports.litellmTeamCreatePermission = exports.litellmAuditReadPermission = exports.litellmKeyUnblockPermission = exports.litellmKeyResetSpendPermission = exports.litellmKeyManagePermission = exports.litellmKeyRevokePermission = exports.litellmKeyCreatePermission = exports.litellmPermissions = exports.bridgeListKeys = exports.getOrProvisionUserFromClaims = exports.resolveBridgeUserId = exports.readBridgeConfig = exports.newDefaultVerifier = exports.KeycloakJWTVerifier = exports.teamBudgetStatusFor = exports.redactTeamUsage = exports.redactTeamBudget = exports.readTeamBudgetVisibility = exports.validateTeamPatchInput = exports.validateTeamWriteInput = exports.assertTeamAdmin = exports.isTeamManagementEnabled = exports.readTeamAdminConfig = exports.ProvisioningError = exports.resolveUserRole = exports.applyRoleOverrides = exports.readRoleConfigs = exports.readProvisioningDefaults = exports.provisionUser = exports.getOrProvisionUser = exports.toLiteLLMUserId = exports.resolveUserProfile = exports.resolveUserId = exports.LiteLLMClient = exports.createRouter = exports.default = exports.litellmPlugin = void 0;
18
18
  var plugin_1 = require("./plugin");
19
19
  Object.defineProperty(exports, "litellmPlugin", { enumerable: true, get: function () { return plugin_1.litellmPlugin; } });
20
20
  var plugin_2 = require("./plugin");
@@ -24,7 +24,6 @@ Object.defineProperty(exports, "createRouter", { enumerable: true, get: function
24
24
  __exportStar(require("./types"), exports);
25
25
  var client_1 = require("./client");
26
26
  Object.defineProperty(exports, "LiteLLMClient", { enumerable: true, get: function () { return client_1.LiteLLMClient; } });
27
- Object.defineProperty(exports, "normalizeRequestTags", { enumerable: true, get: function () { return client_1.normalizeRequestTags; } });
28
27
  var provisioning_1 = require("./provisioning");
29
28
  Object.defineProperty(exports, "resolveUserId", { enumerable: true, get: function () { return provisioning_1.resolveUserId; } });
30
29
  Object.defineProperty(exports, "resolveUserProfile", { enumerable: true, get: function () { return provisioning_1.resolveUserProfile; } });
@@ -54,7 +53,6 @@ Object.defineProperty(exports, "readBridgeConfig", { enumerable: true, get: func
54
53
  Object.defineProperty(exports, "resolveBridgeUserId", { enumerable: true, get: function () { return bridge_1.resolveBridgeUserId; } });
55
54
  Object.defineProperty(exports, "getOrProvisionUserFromClaims", { enumerable: true, get: function () { return bridge_1.getOrProvisionUserFromClaims; } });
56
55
  Object.defineProperty(exports, "bridgeListKeys", { enumerable: true, get: function () { return bridge_1.bridgeListKeys; } });
57
- Object.defineProperty(exports, "bridgeGenerateKey", { enumerable: true, get: function () { return bridge_1.bridgeGenerateKey; } });
58
56
  var backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
59
57
  Object.defineProperty(exports, "litellmPermissions", { enumerable: true, get: function () { return backstage_plugin_litellm_common_1.litellmPermissions; } });
60
58
  Object.defineProperty(exports, "litellmKeyCreatePermission", { enumerable: true, get: function () { return backstage_plugin_litellm_common_1.litellmKeyCreatePermission; } });
package/dist/openapi.js CHANGED
@@ -111,6 +111,7 @@ exports.openApiSpec = {
111
111
  team_id: { type: 'string' },
112
112
  duration: { type: 'string' },
113
113
  max_budget: { type: 'number', nullable: true, description: 'Positive number caps spend; null = unlimited' },
114
+ budget_duration: { type: 'string', description: 'How often max_budget resets (e.g. "30d", "1mo"); one of litellm.keys.allowedBudgetDurations' },
114
115
  tpm_limit: { type: 'number' },
115
116
  rpm_limit: { type: 'number' },
116
117
  } } } },
@@ -210,6 +211,27 @@ exports.openApiSpec = {
210
211
  responses: { '200': { description: 'Team usage' }, '400': { description: 'Missing date range' } },
211
212
  },
212
213
  },
214
+ '/teams/{teamId}/usage/members': {
215
+ get: {
216
+ tags: ['Teams'],
217
+ summary: 'Per-member usage breakdown for a team',
218
+ description: 'Needs litellm.teamUsage.memberBreakdown.enabled. Allowed for team ' +
219
+ 'managers, members holding one of memberBreakdown.viewerRoles, and ' +
220
+ 'members granted litellm.team.usage.read. Spend is zeroed (with ' +
221
+ 'budget_hidden: true) under team budget hiding; spend_share_pct remains.',
222
+ parameters: [
223
+ { name: 'teamId', in: 'path', required: true, schema: { type: 'string' } },
224
+ { name: 'start_date', in: 'query', required: true, schema: { type: 'string' } },
225
+ { name: 'end_date', in: 'query', required: true, schema: { type: 'string' } },
226
+ ],
227
+ responses: {
228
+ '200': { description: 'One row per member, sorted by spend' },
229
+ '400': { description: 'Missing date range' },
230
+ '403': { description: 'Caller may see the team but not its member breakdown' },
231
+ '404': { description: 'Feature disabled, or team not found / not visible to the caller' },
232
+ },
233
+ },
234
+ },
213
235
  '/usage': {
214
236
  get: {
215
237
  tags: ['Usage'],
@@ -49,11 +49,13 @@ export declare function readProvisioningDefaults(config: Config): {
49
49
  * Extracts the authenticated Backstage user identity from the request token.
50
50
  * Returns the userEntityRef (e.g. "user:default/john.doe") or undefined when
51
51
  * the request carries no user credential (service-to-service calls).
52
+ * Only accepts headers starting with 'Bearer ' (case-sensitive as Backstage sends it).
52
53
  */
53
54
  export declare function resolveUserId(req: Request, auth: AuthService): Promise<string | undefined>;
54
55
  /**
55
56
  * Like resolveUserId, but returns the raw BackstageCredentials object
56
57
  * (rather than just the entity ref) for passing into PermissionsService.authorize().
58
+ * Only accepts headers starting with 'Bearer ' (case-sensitive as Backstage sends it).
57
59
  */
58
60
  export declare function resolveCredentials(req: Request, auth: AuthService): Promise<BackstageCredentials | undefined>;
59
61
  /**
@@ -86,7 +88,20 @@ export declare class ProvisioningError extends Error {
86
88
  constructor(message: string, hint: string, provisioning: boolean, status?: number);
87
89
  }
88
90
  /**
89
- * Ensures the LiteLLM user exists, returning its UserInfo.
91
+ * The LiteLLM user id to act on for a computed id: the id of the user adopted
92
+ * by email for it, if any, else the computed id itself. Accurate once
93
+ * getOrProvisionUser or findLiteLLMUser has run for that id.
94
+ */
95
+ export declare function effectiveUserId(userId: string): string;
96
+ /**
97
+ * Finds the LiteLLM user for a Backstage identity without creating it: by
98
+ * the computed id first, then by the catalog email (see adoptUserByEmail).
99
+ * Returns null when neither matches.
100
+ */
101
+ export declare function findLiteLLMUser(client: LiteLLMClient, tokenEntityRef: string | undefined, userId: string, catalogClient: CatalogClient, auth: AuthService, logger: LoggerService): Promise<UserInfo | null>;
102
+ /**
103
+ * Ensures the LiteLLM user exists, returning its UserInfo. The user may have
104
+ * been found by email under a different id: see effectiveUserId.
90
105
  * When the user is missing and provisioning is enabled, attempts to create it.
91
106
  * When provisioning is disabled, throws a ProvisioningError with a clear message.
92
107
  */
@@ -9,10 +9,13 @@ exports.resolveUserId = resolveUserId;
9
9
  exports.resolveCredentials = resolveCredentials;
10
10
  exports.resolveUserProfile = resolveUserProfile;
11
11
  exports.provisionUser = provisionUser;
12
+ exports.effectiveUserId = effectiveUserId;
13
+ exports.findLiteLLMUser = findLiteLLMUser;
12
14
  exports.getOrProvisionUser = getOrProvisionUser;
13
15
  exports.isUserMemberOfGroup = isUserMemberOfGroup;
14
16
  exports.resolveUserRole = resolveUserRole;
15
17
  const client_1 = require("./client");
18
+ const errors_1 = require("./errors");
16
19
  /**
17
20
  * Converts a Backstage user entity ref to a LiteLLM user_id.
18
21
  *
@@ -107,11 +110,13 @@ function readProvisioningDefaults(config) {
107
110
  * Extracts the authenticated Backstage user identity from the request token.
108
111
  * Returns the userEntityRef (e.g. "user:default/john.doe") or undefined when
109
112
  * the request carries no user credential (service-to-service calls).
113
+ * Only accepts headers starting with 'Bearer ' (case-sensitive as Backstage sends it).
110
114
  */
111
115
  async function resolveUserId(req, auth) {
112
- const rawToken = req.headers.authorization?.slice(7);
113
- if (!rawToken)
116
+ const authHeader = req.headers.authorization ?? '';
117
+ if (!authHeader.startsWith('Bearer '))
114
118
  return undefined;
119
+ const rawToken = authHeader.slice(7);
115
120
  try {
116
121
  const credentials = await auth.authenticate(rawToken);
117
122
  const principal = credentials.principal;
@@ -120,18 +125,20 @@ async function resolveUserId(req, auth) {
120
125
  }
121
126
  }
122
127
  catch {
123
- // invalid or service token — caller gets query-param fallback
128
+ // invalid or service token
124
129
  }
125
130
  return undefined;
126
131
  }
127
132
  /**
128
133
  * Like resolveUserId, but returns the raw BackstageCredentials object
129
134
  * (rather than just the entity ref) for passing into PermissionsService.authorize().
135
+ * Only accepts headers starting with 'Bearer ' (case-sensitive as Backstage sends it).
130
136
  */
131
137
  async function resolveCredentials(req, auth) {
132
- const rawToken = req.headers.authorization?.slice(7);
133
- if (!rawToken)
138
+ const authHeader = req.headers.authorization ?? '';
139
+ if (!authHeader.startsWith('Bearer '))
134
140
  return undefined;
141
+ const rawToken = authHeader.slice(7);
135
142
  try {
136
143
  return await auth.authenticate(rawToken);
137
144
  }
@@ -236,20 +243,6 @@ async function provisionUser(client, userId, defaults, profile, backstageEntity,
236
243
  * requests for a re-deleted user can still trigger fresh provisioning.
237
244
  */
238
245
  const provisioningInFlight = new Map();
239
- /**
240
- * Strips any echoed Authorization bearer token from upstream LiteLLM error
241
- * messages before they're shipped back to the browser. LiteLLM normally does
242
- * not echo the master key, but defense in depth: never let a `Bearer …`
243
- * substring travel out in a response body.
244
- */
245
- function sanitizeUpstreamMessage(message) {
246
- if (!message)
247
- return 'unknown error';
248
- return message
249
- .replace(/Bearer\s+[A-Za-z0-9._\-+/=]+/g, 'Bearer [redacted]')
250
- .replace(/sk-[A-Za-z0-9_\-]{8,}/g, 'sk-[redacted]')
251
- .slice(0, 500);
252
- }
253
246
  class ProvisioningError extends Error {
254
247
  constructor(message, hint, provisioning, status = 404) {
255
248
  super(message);
@@ -259,7 +252,68 @@ class ProvisioningError extends Error {
259
252
  }
260
253
  exports.ProvisioningError = ProvisioningError;
261
254
  /**
262
- * Ensures the LiteLLM user exists, returning its UserInfo.
255
+ * Computed user id → id of an existing LiteLLM user adopted by email. Saves
256
+ * the catalog and `/user/list` round-trips on later requests; an entry is
257
+ * dropped when the adopted user disappears.
258
+ */
259
+ const adoptedUserIds = new Map();
260
+ /**
261
+ * The LiteLLM user id to act on for a computed id: the id of the user adopted
262
+ * by email for it, if any, else the computed id itself. Accurate once
263
+ * getOrProvisionUser or findLiteLLMUser has run for that id.
264
+ */
265
+ function effectiveUserId(userId) {
266
+ return adoptedUserIds.get(userId) ?? userId;
267
+ }
268
+ /**
269
+ * Looks the user up by the computed id, or by an id previously adopted for it.
270
+ */
271
+ async function getUserByIdOrAdopted(client, userId) {
272
+ const adopted = adoptedUserIds.get(userId);
273
+ if (adopted) {
274
+ const info = await client.getUserInfo(adopted);
275
+ if (info)
276
+ return info;
277
+ adoptedUserIds.delete(userId);
278
+ }
279
+ return client.getUserInfo(userId);
280
+ }
281
+ /**
282
+ * Finds an existing LiteLLM user with the given email and remembers it for
283
+ * the computed id. Users created outside Backstage (or before
284
+ * `userIdDomain` was set) often have the email as their id; LiteLLM then
285
+ * refuses a second user with the same email, so the record must be adopted
286
+ * rather than created. `getUserByEmail` only returns exact email matches.
287
+ */
288
+ async function adoptUserByEmail(client, userId, email, logger) {
289
+ if (!email)
290
+ return null;
291
+ const byEmail = await client.getUserByEmail(email);
292
+ if (!byEmail?.user_id)
293
+ return null;
294
+ if (byEmail.user_id !== userId) {
295
+ logger.info(`Found existing LiteLLM user ${byEmail.user_id} by email ${email}, reusing for ${userId}`);
296
+ adoptedUserIds.set(userId, byEmail.user_id);
297
+ }
298
+ return byEmail;
299
+ }
300
+ /**
301
+ * Finds the LiteLLM user for a Backstage identity without creating it: by
302
+ * the computed id first, then by the catalog email (see adoptUserByEmail).
303
+ * Returns null when neither matches.
304
+ */
305
+ async function findLiteLLMUser(client, tokenEntityRef, userId, catalogClient, auth, logger) {
306
+ const existing = await getUserByIdOrAdopted(client, userId);
307
+ if (existing)
308
+ return existing;
309
+ if (!tokenEntityRef)
310
+ return null;
311
+ const profile = await resolveUserProfile(tokenEntityRef, catalogClient, auth, logger);
312
+ return adoptUserByEmail(client, userId, profile.email, logger);
313
+ }
314
+ /**
315
+ * Ensures the LiteLLM user exists, returning its UserInfo. The user may have
316
+ * been found by email under a different id: see effectiveUserId.
263
317
  * When the user is missing and provisioning is enabled, attempts to create it.
264
318
  * When provisioning is disabled, throws a ProvisioningError with a clear message.
265
319
  */
@@ -267,30 +321,31 @@ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEn
267
321
  if (!userId) {
268
322
  throw new ProvisioningError('User not found in LiteLLM', 'No user identity could be resolved from the request.', provisioningEnabled);
269
323
  }
270
- const existing = await client.getUserInfo(userId);
324
+ const existing = await getUserByIdOrAdopted(client, userId);
271
325
  if (existing) {
272
326
  return existing;
273
327
  }
274
- if (!provisioningEnabled) {
275
- throw new ProvisioningError('User not found in LiteLLM', 'Enable litellm.provisioning.enabled in app-config.yaml or create the user manually', false);
276
- }
277
328
  // Single-flight: if another request for the same userId is already
278
- // provisioning, await its result instead of starting a new /user/new.
279
- // This collapses the /keys + /teams + /usage page-load thundering
280
- // herd into a single LiteLLM round-trip.
329
+ // resolving or provisioning, await its result instead of starting a new
330
+ // lookup and /user/new. This collapses the /keys + /teams + /usage
331
+ // page-load thundering herd into a single LiteLLM round-trip.
281
332
  const pending = provisioningInFlight.get(userId);
282
333
  if (pending) {
283
334
  logger.info(`LiteLLM provisioning already in flight for ${userId} — joining`);
284
335
  return pending;
285
336
  }
286
337
  const provisionPromise = (async () => {
287
- const catalogRef = tokenEntityRef ?? userId;
288
- const [matchedRole, profile] = await Promise.all([
289
- resolveUserRole(catalogRef, roleConfigs, catalogClient, auth, logger),
290
- tokenEntityRef
291
- ? resolveUserProfile(tokenEntityRef, catalogClient, auth, logger)
292
- : Promise.resolve({}),
293
- ]);
338
+ const profile = tokenEntityRef
339
+ ? await resolveUserProfile(tokenEntityRef, catalogClient, auth, logger)
340
+ : {};
341
+ const byEmail = await adoptUserByEmail(client, userId, profile.email, logger);
342
+ if (byEmail) {
343
+ return byEmail;
344
+ }
345
+ if (!provisioningEnabled) {
346
+ throw new ProvisioningError('User not found in LiteLLM', 'Enable litellm.provisioning.enabled in app-config.yaml or create the user manually', false);
347
+ }
348
+ const matchedRole = await resolveUserRole(tokenEntityRef ?? userId, roleConfigs, catalogClient, auth, logger);
294
349
  const effectiveDefaults = matchedRole
295
350
  ? applyRoleOverrides(provisioningDefaults, matchedRole)
296
351
  : provisioningDefaults;
@@ -308,11 +363,13 @@ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEn
308
363
  // The single-flight cache should prevent the parallel-409 race,
309
364
  // but keep the recovery path: if /user/new still 409s (e.g.
310
365
  // multi-replica deploys where the lock is per-process), treat
311
- // it as "user exists" and re-fetch.
366
+ // it as "user exists" and re-fetch, by id and then by email
367
+ // (LiteLLM also refuses a second user with the same email).
312
368
  if ((err instanceof client_1.LiteLLMUpstreamError && err.status === 409) ||
313
369
  (err instanceof Error && /already exists/i.test(err.message ?? ''))) {
314
370
  logger.info(`LiteLLM user ${userId} already exists during provisioning — re-fetching`);
315
- const refetched = await client.getUserInfo(userId);
371
+ const refetched = (await client.getUserInfo(userId)) ??
372
+ (await adoptUserByEmail(client, userId, profile.email, logger));
316
373
  if (refetched) {
317
374
  return refetched;
318
375
  }
@@ -331,7 +388,7 @@ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEn
331
388
  ? upstreamStatus
332
389
  : 502;
333
390
  const errMessage = err instanceof Error ? err.message : String(err);
334
- throw new ProvisioningError('LiteLLM auto-provisioning failed', `LiteLLM upstream ${upstreamStatus ?? 'error'}: ${sanitizeUpstreamMessage(errMessage)}`, true, passThrough);
391
+ throw new ProvisioningError('LiteLLM auto-provisioning failed', `LiteLLM upstream ${upstreamStatus ?? 'error'}: ${(0, errors_1.sanitizeUpstreamMessage)(errMessage)}`, true, passThrough);
335
392
  }
336
393
  })();
337
394
  provisioningInFlight.set(userId, provisionPromise);
package/dist/router.js CHANGED
@@ -46,6 +46,7 @@ const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-li
46
46
  const teamAdmin_1 = require("./teamAdmin");
47
47
  const teamBudgetVisibility_1 = require("./teamBudgetVisibility");
48
48
  const opencode_1 = require("./opencode");
49
+ const teamMemberUsage_1 = require("./teamMemberUsage");
49
50
  const errors_2 = require("./errors");
50
51
  const withUser_1 = require("./routes/middleware/withUser");
51
52
  const userInfoCache_1 = require("./services/userInfoCache");
@@ -90,12 +91,15 @@ async function createRouter(options) {
90
91
  maxTpm: keyMaxTpm,
91
92
  maxRpm: keyMaxRpm,
92
93
  allowedDurations: keyAllowedDurations,
94
+ allowedBudgetDurations: config.getOptionalStringArray('litellm.keys.allowedBudgetDurations') ??
95
+ backstage_plugin_litellm_common_1.DEFAULT_KEY_BUDGET_DURATIONS,
93
96
  };
94
97
  const teamMgmtEnabled = (0, teamAdmin_1.isTeamManagementEnabled)(config);
95
98
  const objectPermsEnabled = (0, teamAdmin_1.isObjectPermissionsEnabled)(config);
96
99
  const teamAdminCfg = (0, teamAdmin_1.readTeamAdminConfig)(config);
97
100
  const teamBudgetVisibility = (0, teamBudgetVisibility_1.readTeamBudgetVisibility)(config);
98
101
  const opencodeCfg = (0, opencode_1.readOpencodeConfig)(config);
102
+ const memberBreakdownCfg = (0, teamMemberUsage_1.readMemberBreakdownConfig)(config);
99
103
  const catalogClient = options.catalogClient ?? new catalog_client_1.CatalogClient({ discoveryApi: discovery });
100
104
  if (provisioningEnabled) {
101
105
  logger.info(`LiteLLM auto-provisioning enabled — defaults: budget=$${provisioningDefaults.maxBudget}/${provisioningDefaults.budgetDuration}, models=${provisioningDefaults.models.length
@@ -109,8 +113,16 @@ async function createRouter(options) {
109
113
  teamBudgetVisibility.hideTeamBudgetForManagers) {
110
114
  logger.info(`LiteLLM team budget hiding enabled — members: ${teamBudgetVisibility.hideTeamBudgetForMembers}, managers: ${teamBudgetVisibility.hideTeamBudgetForManagers}`);
111
115
  }
116
+ // LiteLLM user id of a Backstage user: the computed id, or the id of an
117
+ // existing user with the same catalog email.
118
+ const resolveMemberUserId = async (userEntityRef) => {
119
+ const computed = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
120
+ await (0, provisioning_1.findLiteLLMUser)(client, userEntityRef, computed, catalogClient, auth, logger);
121
+ return (0, provisioning_1.effectiveUserId)(computed);
122
+ };
112
123
  // Build shared RouterContext with config and helpers
113
124
  const ctx = {
125
+ resolveMemberUserId,
114
126
  client,
115
127
  catalogClient,
116
128
  auth,
@@ -133,6 +145,7 @@ async function createRouter(options) {
133
145
  teamAdminCfg,
134
146
  teamBudgetVisibility,
135
147
  opencodeCfg,
148
+ memberBreakdownCfg,
136
149
  // Helper functions - defined inline below this object
137
150
  authorizeKeyAction: async (req, keyId) => {
138
151
  // Express middleware should have populated req.res.locals with user info
@@ -140,7 +153,8 @@ async function createRouter(options) {
140
153
  throw new errors_1.NotAllowedError('User identity not found in request');
141
154
  }
142
155
  const tokenEntityRef = req.res.locals.tokenEntityRef;
143
- const userId = req.res.locals.userId;
156
+ // The LiteLLM user may exist under a different id, found by email.
157
+ const userId = await resolveMemberUserId(tokenEntityRef);
144
158
  const ownKeys = await client.listKeys(userId);
145
159
  const key = ownKeys.find(k => (k.token ?? k.key) === keyId);
146
160
  if (!key) {
@@ -148,22 +162,6 @@ async function createRouter(options) {
148
162
  }
149
163
  return { tokenEntityRef, userId, key };
150
164
  },
151
- sendOwnershipError: (err, res) => {
152
- // Handle NotAllowedError from authorizeKeyAction
153
- if (err instanceof errors_1.NotAllowedError) {
154
- res.status(403).json({ error: err.message });
155
- return true;
156
- }
157
- // Fallback for legacy errors with status and body
158
- if (err && typeof err === 'object' && 'status' in err && 'body' in err) {
159
- const errObj = err;
160
- if (typeof errObj.status === 'number' && errObj.body) {
161
- res.status(errObj.status).json(errObj.body);
162
- return true;
163
- }
164
- }
165
- return false;
166
- },
167
165
  assertPermission: async (req, permission) => {
168
166
  const credentials = await (0, provisioning_1.resolveCredentials)(req, auth);
169
167
  if (!credentials)
@@ -281,7 +279,8 @@ async function createRouter(options) {
281
279
  // The permission framework still has the final say.
282
280
  if (teamRoleEligible) {
283
281
  const actor = await (0, provisioning_1.resolveUserId)(req, auth);
284
- const litellmUserId = actor && (0, provisioning_1.toLiteLLMUserId)(actor, userIdDomain);
282
+ const litellmUserId = actor &&
283
+ (await resolveMemberUserId(actor));
285
284
  const role = existing.members_with_roles?.find(m => m.user_id === litellmUserId)?.role;
286
285
  if (actor &&
287
286
  role &&
@@ -8,7 +8,7 @@ const keyService_1 = require("../services/keyService");
8
8
  const errors_1 = require("../errors");
9
9
  const backstage_plugin_litellm_common_2 = require("@acarmisc/backstage-plugin-litellm-common");
10
10
  function registerBridgeRoutes(router, ctx, bridgeOpts) {
11
- const { client, logger, userIdDomain, provisioningEnabled, provisioningDefaults, roleConfigs, allowUnlimitedBudget, teamRequired, keyValidationConfig, } = ctx;
11
+ const { client, logger, userIdDomain, provisioningEnabled, provisioningDefaults, allowUnlimitedBudget, teamRequired, keyValidationConfig, } = ctx;
12
12
  const { config } = bridgeOpts;
13
13
  const generateKeyInputSchema = (0, backstage_plugin_litellm_common_2.createGenerateKeyInputSchema)(ctx.keyValidationConfig);
14
14
  // ---------------------------------------------------------------------------
@@ -99,8 +99,8 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
99
99
  if (!allowUnlimitedBudget && input.max_budget === undefined) {
100
100
  input.max_budget = provisioningDefaults.maxBudget;
101
101
  }
102
- // ── Resolve user identity from verified claims ───────────────────────
103
- const userId = (0, bridge_1.resolveBridgeUserId)(claims, bridgeIdentity);
102
+ // ── Resolve and provision user from verified claims ──────────────────
103
+ const userInfo = await (0, bridge_1.getOrProvisionUserFromClaims)(client, claims, provisioningEnabled, provisioningDefaults, logger, bridgeIdentity);
104
104
  // ── Create key via unified service ──────────────────────────────────
105
105
  const keyCreateCtx = {
106
106
  client,
@@ -108,18 +108,10 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
108
108
  allowUnlimitedBudget,
109
109
  teamRequired,
110
110
  },
111
- // Bridge does not have Backstage catalogClient/auth
112
- // so provisioning from JWT claims only (no Backstage profile enrichment)
113
- catalogClient: undefined,
114
- auth: undefined,
115
111
  logger,
116
112
  keyValidationConfig,
117
- provisioningEnabled,
118
- provisioningDefaults,
119
- roleConfigs,
120
- userIdDomain,
121
113
  };
122
- const result = await (0, keyService_1.createKeyForUser)({ userId }, input, keyCreateCtx);
114
+ const result = await (0, keyService_1.createKeyForUser)({ userInfo, createdVia: 'abby-cli' }, input, keyCreateCtx);
123
115
  res.json(result);
124
116
  }
125
117
  catch (error) {
@@ -132,23 +124,22 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
132
124
  try {
133
125
  const claims = await requireClaims(req);
134
126
  const user = await (0, bridge_1.getOrProvisionUserFromClaims)(client, claims, provisioningEnabled, provisioningDefaults, logger, bridgeIdentity);
135
- // Enrich team list with metadata (display names, etc.)
127
+ // Enrich team list with metadata (display names, etc.) in parallel
136
128
  const teamIds = user.teams ?? [];
137
- const teamMetadata = [];
138
- for (const teamId of teamIds) {
129
+ const teamMetadata = await Promise.all(teamIds.map(async (teamId) => {
139
130
  try {
140
131
  const teamInfo = await client.getTeamInfo(teamId);
141
- teamMetadata.push({
132
+ return {
142
133
  id: teamId,
143
134
  name: teamInfo.team_alias || teamId, // Use alias as display name, fallback to ID
144
- });
135
+ };
145
136
  }
146
137
  catch (e) {
147
138
  // If we can't fetch team info, just use the ID
148
139
  logger.debug(`Could not fetch team info for ${teamId}: ${e.message}`);
149
- teamMetadata.push({ id: teamId });
140
+ return { id: teamId };
150
141
  }
151
- }
142
+ }));
152
143
  res.json({ user_id: user.user_id, teams: teamIds, team_metadata: teamMetadata });
153
144
  }
154
145
  catch (error) {
@@ -29,6 +29,9 @@ function registerConfigRoutes(router, ctx) {
29
29
  readOnly: ctx.teamAdminCfg.readOnly,
30
30
  memberManagerRoles: ctx.teamAdminCfg.memberManagerRoles,
31
31
  },
32
+ teamUsage: {
33
+ memberBreakdown: ctx.memberBreakdownCfg.enabled,
34
+ },
32
35
  display: {
33
36
  hideTeamBudgetForMembers: ctx.teamBudgetVisibility.hideTeamBudgetForMembers,
34
37
  hideTeamBudgetForManagers: ctx.teamBudgetVisibility.hideTeamBudgetForManagers,
@@ -8,6 +8,7 @@ import { TeamAdminConfig } from '../teamAdmin';
8
8
  import type { KeyValidationConfig } from '@acarmisc/backstage-plugin-litellm-common';
9
9
  import { TeamBudgetVisibility } from '../teamBudgetVisibility';
10
10
  import { OpencodeConfig } from '../opencode';
11
+ import { MemberBreakdownConfig } from '../teamMemberUsage';
11
12
  /**
12
13
  * Shared runtime context for all route handlers. Holds config-derived
13
14
  * constants, service clients, and helper functions needed by routes.
@@ -35,12 +36,17 @@ export interface RouterContext {
35
36
  teamAdminCfg: TeamAdminConfig;
36
37
  teamBudgetVisibility: TeamBudgetVisibility;
37
38
  opencodeCfg: OpencodeConfig;
39
+ memberBreakdownCfg: MemberBreakdownConfig;
40
+ /**
41
+ * LiteLLM user id of a Backstage user: the computed id, or the id of an
42
+ * existing LiteLLM user with the same catalog email.
43
+ */
44
+ resolveMemberUserId(userEntityRef: string): Promise<string>;
38
45
  authorizeKeyAction(req: Request, keyId: string): Promise<{
39
46
  tokenEntityRef: string;
40
47
  userId: string;
41
48
  key: VirtualKey;
42
49
  }>;
43
- sendOwnershipError(err: unknown, res: Response): boolean;
44
50
  assertPermission(req: Request, permission: BasicPermission): Promise<boolean>;
45
51
  sendPermissionDenied(res: Response, permission: BasicPermission): void;
46
52
  requireTeamMgmt(res: Response): boolean;
@@ -13,7 +13,7 @@ const withUser_1 = require("./middleware/withUser");
13
13
  * location and access to shared state (via ctx) differ.
14
14
  */
15
15
  function registerKeysRoutes(router, ctx) {
16
- const { client, catalogClient, auth, logger, userIdDomain, provisioningEnabled, provisioningDefaults, roleConfigs, auditGroup, allowUnlimitedBudget, teamRequired, allowOwnerResetSpend, keyValidationConfig, authorizeKeyAction, sendOwnershipError, assertPermission, sendPermissionDenied, } = ctx;
16
+ const { client, catalogClient, auth, logger, auditGroup, allowUnlimitedBudget, teamRequired, allowOwnerResetSpend, keyValidationConfig, authorizeKeyAction, assertPermission, sendPermissionDenied, } = ctx;
17
17
  const generateKeyInputSchema = (0, backstage_plugin_litellm_common_1.createGenerateKeyInputSchema)(keyValidationConfig);
18
18
  const updateKeyInputSchema = (0, backstage_plugin_litellm_common_1.createUpdateKeyInputSchema)(keyValidationConfig);
19
19
  router.get('/user/info', async (_req, res) => {
@@ -35,8 +35,8 @@ function registerKeysRoutes(router, ctx) {
35
35
  });
36
36
  router.get('/keys', async (_req, res) => {
37
37
  try {
38
- const userId = res.locals.userId;
39
38
  await (0, withUser_1.getProvisionedUser)(ctx, res);
39
+ const userId = res.locals.userId;
40
40
  const keys = await client.listKeys(userId);
41
41
  res.json(keys);
42
42
  }
@@ -55,9 +55,9 @@ function registerKeysRoutes(router, ctx) {
55
55
  sendPermissionDenied(res, backstage_plugin_litellm_common_1.litellmKeyRevokePermission);
56
56
  return;
57
57
  }
58
- const userId = res.locals.userId;
59
58
  // Ensure the user is provisioned
60
59
  await (0, withUser_1.getProvisionedUser)(ctx, res);
60
+ const userId = res.locals.userId;
61
61
  // Load the caller's keys
62
62
  const keys = await client.listKeys(userId);
63
63
  // Identify expired keys (expires_at in the past)
@@ -132,7 +132,8 @@ function registerKeysRoutes(router, ctx) {
132
132
  return;
133
133
  }
134
134
  const tokenEntityRef = res.locals.tokenEntityRef;
135
- const resolvedUserId = res.locals.userId;
135
+ // ── Resolve and provision user ──────────────────────────────────────
136
+ const userInfo = await (0, withUser_1.getProvisionedUser)(ctx, res);
136
137
  // ── Create key via unified service ──────────────────────────────────
137
138
  const keyCreateCtx = {
138
139
  client,
@@ -144,12 +145,8 @@ function registerKeysRoutes(router, ctx) {
144
145
  auth,
145
146
  logger,
146
147
  keyValidationConfig,
147
- provisioningEnabled,
148
- provisioningDefaults,
149
- roleConfigs,
150
- userIdDomain,
151
148
  };
152
- const result = await (0, keyService_1.createKeyForUser)({ tokenEntityRef, userId: resolvedUserId }, input, keyCreateCtx);
149
+ const result = await (0, keyService_1.createKeyForUser)({ tokenEntityRef, userInfo }, input, keyCreateCtx);
153
150
  res.json(result);
154
151
  }
155
152
  catch (error) {
@@ -277,8 +274,6 @@ function registerKeysRoutes(router, ctx) {
277
274
  res.json(result);
278
275
  }
279
276
  catch (error) {
280
- if (sendOwnershipError(error, res))
281
- return;
282
277
  (0, errors_1.sendError)(res, error, logger, 'update key');
283
278
  }
284
279
  });
@@ -299,8 +294,6 @@ function registerKeysRoutes(router, ctx) {
299
294
  res.json({ success: true });
300
295
  }
301
296
  catch (error) {
302
- if (sendOwnershipError(error, res))
303
- return;
304
297
  (0, errors_1.sendError)(res, error, logger, 'delete key');
305
298
  }
306
299
  });
@@ -334,8 +327,6 @@ function registerKeysRoutes(router, ctx) {
334
327
  res.json({ success: true });
335
328
  }
336
329
  catch (error) {
337
- if (sendOwnershipError(error, res))
338
- return;
339
330
  (0, errors_1.sendError)(res, error, logger, 'block key');
340
331
  }
341
332
  });
@@ -371,8 +362,6 @@ function registerKeysRoutes(router, ctx) {
371
362
  res.json({ success: true });
372
363
  }
373
364
  catch (error) {
374
- if (sendOwnershipError(error, res))
375
- return;
376
365
  (0, errors_1.sendError)(res, error, logger, 'unblock key');
377
366
  }
378
367
  });
@@ -397,8 +386,6 @@ function registerKeysRoutes(router, ctx) {
397
386
  res.json({ success: true });
398
387
  }
399
388
  catch (error) {
400
- if (sendOwnershipError(error, res))
401
- return;
402
389
  (0, errors_1.sendError)(res, error, logger, 'reset key spend');
403
390
  }
404
391
  });
@@ -454,10 +441,10 @@ function registerKeysRoutes(router, ctx) {
454
441
  res.status(400).json({ error: 'start_date and end_date are required' });
455
442
  return;
456
443
  }
457
- const tokenEntityRef = res.locals.tokenEntityRef;
458
- const userId = res.locals.userId;
459
- await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
460
- const usage = await client.getUsage(start_date, end_date, userId);
444
+ await (0, withUser_1.getProvisionedUser)(ctx, res);
445
+ // Always filter by the caller's id: an empty user_id makes LiteLLM
446
+ // return org-wide activity.
447
+ const usage = await client.getUsage(start_date, end_date, res.locals.userId);
461
448
  res.json(usage);
462
449
  }
463
450
  catch (error) {