@acarmisc/backstage-plugin-litellm-backend 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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'],
@@ -19,6 +19,11 @@ export interface OpencodeConfig {
19
19
  keyDuration: string;
20
20
  /** Default max budget (USD) for keys created via the connect flow. Default 50. */
21
21
  maxBudget: number;
22
+ /**
23
+ * Reset period of maxBudget (e.g. "30d"). Unset: maxBudget covers the key's whole life,
24
+ * which runs out early on long-lived keys.
25
+ */
26
+ budgetDuration?: string;
22
27
  /**
23
28
  * When true, a team query parameter must reference a team the user is a
24
29
  * member of (or manage); otherwise the key is personal-only.
package/dist/opencode.js CHANGED
@@ -7,6 +7,7 @@ function readOpencodeConfig(config) {
7
7
  enabled,
8
8
  keyDuration: config.getOptionalString('litellm.opencode.keyDuration') ?? '30d',
9
9
  maxBudget: config.getOptionalNumber('litellm.opencode.maxBudget') ?? 50,
10
+ budgetDuration: config.getOptionalString('litellm.opencode.budgetDuration'),
10
11
  requireTeam: config.getOptionalBoolean('litellm.opencode.requireTeam') ?? false,
11
12
  metadata: config.getOptional('litellm.opencode.metadata') ?? {},
12
13
  };
@@ -88,7 +88,20 @@ export declare class ProvisioningError extends Error {
88
88
  constructor(message: string, hint: string, provisioning: boolean, status?: number);
89
89
  }
90
90
  /**
91
- * 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.
92
105
  * When the user is missing and provisioning is enabled, attempts to create it.
93
106
  * When provisioning is disabled, throws a ProvisioningError with a clear message.
94
107
  */
@@ -9,6 +9,8 @@ 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;
@@ -250,7 +252,68 @@ class ProvisioningError extends Error {
250
252
  }
251
253
  exports.ProvisioningError = ProvisioningError;
252
254
  /**
253
- * 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.
254
317
  * When the user is missing and provisioning is enabled, attempts to create it.
255
318
  * When provisioning is disabled, throws a ProvisioningError with a clear message.
256
319
  */
@@ -258,30 +321,31 @@ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEn
258
321
  if (!userId) {
259
322
  throw new ProvisioningError('User not found in LiteLLM', 'No user identity could be resolved from the request.', provisioningEnabled);
260
323
  }
261
- const existing = await client.getUserInfo(userId);
324
+ const existing = await getUserByIdOrAdopted(client, userId);
262
325
  if (existing) {
263
326
  return existing;
264
327
  }
265
- if (!provisioningEnabled) {
266
- throw new ProvisioningError('User not found in LiteLLM', 'Enable litellm.provisioning.enabled in app-config.yaml or create the user manually', false);
267
- }
268
328
  // Single-flight: if another request for the same userId is already
269
- // provisioning, await its result instead of starting a new /user/new.
270
- // This collapses the /keys + /teams + /usage page-load thundering
271
- // 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.
272
332
  const pending = provisioningInFlight.get(userId);
273
333
  if (pending) {
274
334
  logger.info(`LiteLLM provisioning already in flight for ${userId} — joining`);
275
335
  return pending;
276
336
  }
277
337
  const provisionPromise = (async () => {
278
- const catalogRef = tokenEntityRef ?? userId;
279
- const [matchedRole, profile] = await Promise.all([
280
- resolveUserRole(catalogRef, roleConfigs, catalogClient, auth, logger),
281
- tokenEntityRef
282
- ? resolveUserProfile(tokenEntityRef, catalogClient, auth, logger)
283
- : Promise.resolve({}),
284
- ]);
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);
285
349
  const effectiveDefaults = matchedRole
286
350
  ? applyRoleOverrides(provisioningDefaults, matchedRole)
287
351
  : provisioningDefaults;
@@ -299,11 +363,13 @@ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEn
299
363
  // The single-flight cache should prevent the parallel-409 race,
300
364
  // but keep the recovery path: if /user/new still 409s (e.g.
301
365
  // multi-replica deploys where the lock is per-process), treat
302
- // 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).
303
368
  if ((err instanceof client_1.LiteLLMUpstreamError && err.status === 409) ||
304
369
  (err instanceof Error && /already exists/i.test(err.message ?? ''))) {
305
370
  logger.info(`LiteLLM user ${userId} already exists during provisioning — re-fetching`);
306
- const refetched = await client.getUserInfo(userId);
371
+ const refetched = (await client.getUserInfo(userId)) ??
372
+ (await adoptUserByEmail(client, userId, profile.email, logger));
307
373
  if (refetched) {
308
374
  return refetched;
309
375
  }
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) {
@@ -265,7 +279,8 @@ async function createRouter(options) {
265
279
  // The permission framework still has the final say.
266
280
  if (teamRoleEligible) {
267
281
  const actor = await (0, provisioning_1.resolveUserId)(req, auth);
268
- const litellmUserId = actor && (0, provisioning_1.toLiteLLMUserId)(actor, userIdDomain);
282
+ const litellmUserId = actor &&
283
+ (await resolveMemberUserId(actor));
269
284
  const role = existing.members_with_roles?.find(m => m.user_id === litellmUserId)?.role;
270
285
  if (actor &&
271
286
  role &&
@@ -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,6 +36,12 @@ 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;
@@ -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)
@@ -11,6 +11,7 @@ export declare function createRequireUser(ctx: RouterContext): (req: Request, re
11
11
  /**
12
12
  * Helper to fetch and provision a user from res.locals (populated by requireUser middleware).
13
13
  * Returns the user info or throws ProvisioningError (which the caller must handle and respond to).
14
+ * Updates `res.locals.userId` to the resolved LiteLLM user id.
14
15
  *
15
16
  * This deduplicates the repeated pattern:
16
17
  * const userInfo = await getProvisionedUser(...);
@@ -29,6 +29,7 @@ function createRequireUser(ctx) {
29
29
  /**
30
30
  * Helper to fetch and provision a user from res.locals (populated by requireUser middleware).
31
31
  * Returns the user info or throws ProvisioningError (which the caller must handle and respond to).
32
+ * Updates `res.locals.userId` to the resolved LiteLLM user id.
32
33
  *
33
34
  * This deduplicates the repeated pattern:
34
35
  * const userInfo = await getProvisionedUser(...);
@@ -37,5 +38,8 @@ function createRequireUser(ctx) {
37
38
  async function getProvisionedUser(ctx, res) {
38
39
  const tokenEntityRef = res.locals.tokenEntityRef;
39
40
  const userId = res.locals.userId;
40
- return (0, provisioning_1.getOrProvisionUser)(ctx.client, tokenEntityRef, userId, ctx.provisioningEnabled, ctx.provisioningDefaults, ctx.roleConfigs, ctx.catalogClient, ctx.auth, ctx.logger);
41
+ const userInfo = await (0, provisioning_1.getOrProvisionUser)(ctx.client, tokenEntityRef, userId, ctx.provisioningEnabled, ctx.provisioningDefaults, ctx.roleConfigs, ctx.catalogClient, ctx.auth, ctx.logger);
42
+ // The LiteLLM user may have been found by email under a different id.
43
+ res.locals.userId = (0, provisioning_1.effectiveUserId)(userId);
44
+ return userInfo;
41
45
  }
@@ -66,8 +66,8 @@ function registerOpencodeRoutes(router, ctx) {
66
66
  sendPermissionDenied(res, backstage_plugin_litellm_common_1.litellmKeyCreatePermission);
67
67
  return null;
68
68
  }
69
- const userId = (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain);
70
- const userInfo = await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
69
+ const computedUserId = (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain);
70
+ const userInfo = await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, computedUserId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
71
71
  // Optional team binding: must be one of the user's own teams.
72
72
  const teamId = (req.query.team || req.body.team);
73
73
  if (teamId) {
@@ -84,7 +84,7 @@ function registerOpencodeRoutes(router, ctx) {
84
84
  res.status(400).json({ error: 'A team is required for this connection' });
85
85
  return null;
86
86
  }
87
- return { tokenEntityRef, userId, userInfo, teamId, redirectUri };
87
+ return { tokenEntityRef, userId: (0, provisioning_1.effectiveUserId)(computedUserId), userInfo, teamId, redirectUri };
88
88
  };
89
89
  router.get('/opencode/connect', async (req, res) => {
90
90
  try {
@@ -156,6 +156,7 @@ function registerOpencodeRoutes(router, ctx) {
156
156
  alias,
157
157
  duration: opencodeCfg.keyDuration,
158
158
  max_budget: opencodeCfg.maxBudget,
159
+ ...(opencodeCfg.budgetDuration && { budget_duration: opencodeCfg.budgetDuration }),
159
160
  team_id: teamId,
160
161
  metadata: {
161
162
  ...opencodeCfg.metadata,
@@ -202,15 +203,18 @@ function registerOpencodeRoutes(router, ctx) {
202
203
  res.status(502).json({ error: 'LiteLLM returned no key material' });
203
204
  return;
204
205
  }
206
+ logger.info('opencode.connect', { userId, team: teamId ?? null, rotated });
207
+ // The Backstage connect page calls this with fetchApi (JSON + Bearer) and does the
208
+ // loopback redirect itself, so it can collect one key per team in a single trip.
209
+ if (req.is('application/json')) {
210
+ res.set('Cache-Control', 'no-store');
211
+ res.json({ key, team: teamId ?? null });
212
+ return;
213
+ }
205
214
  const url = new URL(redirectUri);
206
215
  url.searchParams.set('key', key);
207
216
  if (teamId)
208
217
  url.searchParams.set('team', teamId);
209
- logger.info('opencode.connect', {
210
- userId,
211
- team: teamId ?? null,
212
- rotated,
213
- });
214
218
  res.redirect(302, url.href);
215
219
  }
216
220
  catch (error) {
@@ -1,8 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerTeamUsageRoutes = registerTeamUsageRoutes;
4
+ const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
5
+ const client_1 = require("../client");
4
6
  const provisioning_1 = require("../provisioning");
5
7
  const teamBudgetVisibility_1 = require("../teamBudgetVisibility");
8
+ const teamMemberUsage_1 = require("../teamMemberUsage");
6
9
  const errors_1 = require("../errors");
7
10
  const teamFetch_1 = require("../http/teamFetch");
8
11
  const withUser_1 = require("./middleware/withUser");
@@ -69,4 +72,110 @@ function registerTeamUsageRoutes(router, ctx) {
69
72
  (0, errors_1.sendError)(res, error, logger, 'fetch team usage');
70
73
  }
71
74
  });
75
+ // Per-member breakdown of a team's usage. Opt-in (404 when disabled) and
76
+ // fail-closed: the caller must be a manager of the team (owning_group), or
77
+ // a member who holds one of memberBreakdown.viewerRoles in the team or is
78
+ // granted litellm.team.usage.read.
79
+ router.get('/teams/:teamId/usage/members', requireUser, async (req, res) => {
80
+ const cfg = ctx.memberBreakdownCfg;
81
+ if (!cfg.enabled) {
82
+ res.status(404).json({ error: 'Not found' });
83
+ return;
84
+ }
85
+ try {
86
+ const { teamId } = req.params;
87
+ const { start_date, end_date } = req.query;
88
+ if (!start_date || !end_date) {
89
+ res.status(400).json({ error: 'start_date and end_date are required' });
90
+ return;
91
+ }
92
+ const tokenEntityRef = res.locals.tokenEntityRef;
93
+ const userInfo = await (0, withUser_1.getProvisionedUser)(ctx, res);
94
+ const callerId = res.locals.userId;
95
+ const isMember = userInfo?.teams?.includes(teamId) ?? false;
96
+ let team;
97
+ try {
98
+ team = await (0, teamFetch_1.withTeamFetchRetry)(() => client.getTeamInfo(teamId));
99
+ }
100
+ catch (err) {
101
+ if (err instanceof client_1.LiteLLMUpstreamError && err.status === 404) {
102
+ res.status(404).json({ error: 'Team not found' });
103
+ return;
104
+ }
105
+ throw err;
106
+ }
107
+ const owningGroup = typeof team.metadata?.owning_group === 'string' ? team.metadata.owning_group : undefined;
108
+ const isManager = ctx.teamMgmtEnabled &&
109
+ !!owningGroup &&
110
+ (await (0, provisioning_1.isUserMemberOfGroup)(tokenEntityRef, owningGroup, catalogClient, auth, logger));
111
+ if (!isMember && !isManager) {
112
+ res.status(404).json({ error: 'Team not found' });
113
+ return;
114
+ }
115
+ const role = team.members_with_roles?.find(m => m.user_id === callerId)?.role;
116
+ const viaRole = isMember && !!role && cfg.viewerRoles.includes(role);
117
+ const allowed = isManager ||
118
+ viaRole ||
119
+ (isMember &&
120
+ cfg.permissionEnabled &&
121
+ (await ctx.assertPermission(req, backstage_plugin_litellm_common_1.litellmTeamUsageReadPermission)));
122
+ if (!allowed) {
123
+ res.status(403).json({
124
+ error: 'Access denied: viewing per-member usage needs a team role or permission',
125
+ });
126
+ return;
127
+ }
128
+ const [usage, keys] = await Promise.all([
129
+ client.getTeamUsage(teamId, start_date, end_date),
130
+ client.listTeamKeys(teamId),
131
+ ]);
132
+ const profiles = await lookupProfiles((0, teamMemberUsage_1.memberEmails)(team, keys));
133
+ const breakdown = (0, teamMemberUsage_1.buildTeamMemberUsage)(teamId, usage, keys, team, profiles);
134
+ let via = 'permission';
135
+ if (isManager)
136
+ via = 'manager';
137
+ else if (viaRole)
138
+ via = 'teamRole';
139
+ logger.info('team.usage.members.read', { actor: tokenEntityRef, teamId, via });
140
+ const hide = isManager
141
+ ? teamBudgetVisibility.hideTeamBudgetForManagers
142
+ : teamBudgetVisibility.hideTeamBudgetForMembers;
143
+ res.json(hide ? (0, teamMemberUsage_1.redactTeamMemberUsage)(breakdown) : breakdown);
144
+ }
145
+ catch (error) {
146
+ if (error instanceof provisioning_1.ProvisioningError) {
147
+ res.status(error.status).json(error.body);
148
+ return;
149
+ }
150
+ (0, errors_1.sendError)(res, error, logger, 'fetch team member usage');
151
+ }
152
+ });
153
+ // Display names from catalog User entities with these emails. Best effort:
154
+ // a catalog failure only drops the names.
155
+ async function lookupProfiles(emails) {
156
+ const profiles = new Map();
157
+ if (!emails.length)
158
+ return profiles;
159
+ try {
160
+ const { token } = await auth.getPluginRequestToken({
161
+ onBehalfOf: await auth.getOwnServiceCredentials(),
162
+ targetPluginId: 'catalog',
163
+ });
164
+ const { items } = await catalogClient.getEntities({
165
+ filter: { kind: 'User', 'spec.profile.email': emails },
166
+ fields: ['spec.profile'],
167
+ }, { token });
168
+ for (const entity of items) {
169
+ const profile = entity.spec?.profile ?? {};
170
+ if (typeof profile.email === 'string') {
171
+ profiles.set(profile.email.toLowerCase(), { displayName: profile.displayName });
172
+ }
173
+ }
174
+ }
175
+ catch (err) {
176
+ const message = err instanceof Error ? err.message : String(err);
177
+ logger.warn(`Catalog lookup for team member names failed: ${message}`);
178
+ }
179
+ return profiles;
180
+ }
72
181
  }
@@ -306,9 +306,9 @@ function registerTeamsRoutes(router, ctx) {
306
306
  return;
307
307
  }
308
308
  // (b) the member must exist in LiteLLM — provision on the spot if enabled
309
- const litellmUserId = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
309
+ const computedUserId = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
310
310
  try {
311
- await (0, provisioning_1.getOrProvisionUser)(client, userEntityRef, litellmUserId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
311
+ await (0, provisioning_1.getOrProvisionUser)(client, userEntityRef, computedUserId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
312
312
  }
313
313
  catch (err) {
314
314
  if (err instanceof provisioning_1.ProvisioningError) {
@@ -317,6 +317,8 @@ function registerTeamsRoutes(router, ctx) {
317
317
  }
318
318
  throw err;
319
319
  }
320
+ // The member may exist under a different id, found by email.
321
+ const litellmUserId = (0, provisioning_1.effectiveUserId)(computedUserId);
320
322
  try {
321
323
  await client.teamMemberAdd({
322
324
  team_id: teamId,
@@ -355,11 +357,11 @@ function registerTeamsRoutes(router, ctx) {
355
357
  .json({ error: 'userEntityRef query parameter is required' });
356
358
  return;
357
359
  }
358
- const litellmUserId = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
360
+ const litellmUserId = await ctx.resolveMemberUserId(userEntityRef);
359
361
  // Team-role managers change membership only: they cannot remove
360
362
  // themselves nor a peer holding a manager role.
361
363
  if (via === 'teamRole') {
362
- if (litellmUserId === (0, provisioning_1.toLiteLLMUserId)(actor, userIdDomain)) {
364
+ if (litellmUserId === (await ctx.resolveMemberUserId(actor))) {
363
365
  res.status(400).json({ error: 'You cannot remove yourself from the team' });
364
366
  return;
365
367
  }
@@ -124,6 +124,9 @@ async function createKeyForUser(user, input, ctx) {
124
124
  // max_budget from input can be null (unlimited) or a number
125
125
  upstreamRequest.max_budget = input.max_budget ?? undefined;
126
126
  }
127
+ if (input.budget_duration !== undefined) {
128
+ upstreamRequest.budget_duration = input.budget_duration;
129
+ }
127
130
  if (input.tpm_limit !== undefined) {
128
131
  upstreamRequest.tpm_limit = input.tpm_limit;
129
132
  }
@@ -0,0 +1,34 @@
1
+ import { Config } from '@backstage/config';
2
+ import { TeamInfo, TeamMemberUsage, UsageMetrics, VirtualKey } from './types';
3
+ /** `litellm.teamUsage.memberBreakdown`: who may see a team's per-member usage. */
4
+ export interface MemberBreakdownConfig {
5
+ /** Off by default: the route returns 404 and the UI hides the table. */
6
+ enabled: boolean;
7
+ /** LiteLLM team roles (e.g. "admin") whose holders may view their own team's breakdown. */
8
+ viewerRoles: string[];
9
+ /** Whether `litellm.team.usage.read` may grant access (needs `permission.enabled`). */
10
+ permissionEnabled: boolean;
11
+ }
12
+ export declare function readMemberBreakdownConfig(config: Config): MemberBreakdownConfig;
13
+ /** Catalog profile of a member, keyed by lower-cased email. */
14
+ export type MemberProfiles = Map<string, {
15
+ displayName?: string;
16
+ }>;
17
+ /**
18
+ * Emails worth looking up in the catalog for the members of a breakdown:
19
+ * the team roster's emails and email-shaped user ids.
20
+ */
21
+ export declare function memberEmails(team: TeamInfo, keys: VirtualKey[]): string[];
22
+ /**
23
+ * Attributes a team's usage to its members. Each key in the team's daily
24
+ * activity is credited to the key's owner (from `/key/list?team_id=`), so the
25
+ * rows add up to the team total. Activity of keys with no known owner
26
+ * (deleted keys, keys without a user) lands in one `user_id: null` row.
27
+ * Team members without activity get zero rows.
28
+ */
29
+ export declare function buildTeamMemberUsage(teamId: string, usage: UsageMetrics, keys: VirtualKey[], team: TeamInfo, profiles?: MemberProfiles): TeamMemberUsage;
30
+ /**
31
+ * Hides dollar amounts: spend becomes 0 and `spend_share_pct` remains the
32
+ * way to compare members. Tokens and requests are kept.
33
+ */
34
+ export declare function redactTeamMemberUsage(usage: TeamMemberUsage): TeamMemberUsage;