@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.
@@ -1,4 +1,5 @@
1
1
  import { Request, Response, NextFunction } from 'express';
2
+ import { UserInfo } from '../../types';
2
3
  import type { RouterContext } from '../context';
3
4
  /**
4
5
  * Express middleware that resolves a Backstage user principal to a LiteLLM user ID.
@@ -10,9 +11,10 @@ export declare function createRequireUser(ctx: RouterContext): (req: Request, re
10
11
  /**
11
12
  * Helper to fetch and provision a user from res.locals (populated by requireUser middleware).
12
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.
13
15
  *
14
16
  * This deduplicates the repeated pattern:
15
- * const userInfo = await getOrProvisionUser(...);
17
+ * const userInfo = await getProvisionedUser(...);
16
18
  * if (error instanceof ProvisioningError) { res.status(...).json(...); return; }
17
19
  */
18
- export declare function getProvisionedUser(ctx: RouterContext, res: Response): Promise<any>;
20
+ export declare function getProvisionedUser(ctx: RouterContext, res: Response): Promise<UserInfo>;
@@ -29,13 +29,17 @@ 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
- * const userInfo = await getOrProvisionUser(...);
35
+ * const userInfo = await getProvisionedUser(...);
35
36
  * if (error instanceof ProvisioningError) { res.status(...).json(...); return; }
36
37
  */
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
  }
@@ -3,9 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerObjectPermissionsRoutes = registerObjectPermissionsRoutes;
4
4
  const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
5
5
  const teamAdmin_1 = require("../teamAdmin");
6
- const teamBudgetVisibility_1 = require("../teamBudgetVisibility");
6
+ const respondTeam_1 = require("../http/respondTeam");
7
7
  function registerObjectPermissionsRoutes(router, ctx) {
8
- const { client, catalogClient, auth, permissions, logger, teamAdminCfg, teamBudgetVisibility, requireObjectPerms, sendTeamError, authorizeTeamSubresource, } = ctx;
8
+ const { client, catalogClient, auth, permissions, logger, teamAdminCfg, requireObjectPerms, sendTeamError, authorizeTeamSubresource, } = ctx;
9
9
  // ── Knowledge-base (vector store) management ────────────────────────────
10
10
  //
11
11
  // Attaching a vector store to a team exposes its documents to every team
@@ -91,9 +91,7 @@ function registerObjectPermissionsRoutes(router, ctx) {
91
91
  owningGroup,
92
92
  vector_stores: requested,
93
93
  });
94
- res.json(teamBudgetVisibility.hideTeamBudgetForManagers
95
- ? (0, teamBudgetVisibility_1.redactTeamBudget)(updated)
96
- : updated);
94
+ (0, respondTeam_1.respondTeam)(res, updated, ctx, 'manager');
97
95
  }
98
96
  catch (err) {
99
97
  sendTeamError(err, res);
@@ -185,9 +183,7 @@ function registerObjectPermissionsRoutes(router, ctx) {
185
183
  before,
186
184
  after: requested,
187
185
  });
188
- res.json(teamBudgetVisibility.hideTeamBudgetForManagers
189
- ? (0, teamBudgetVisibility_1.redactTeamBudget)(updated)
190
- : updated);
186
+ (0, respondTeam_1.respondTeam)(res, updated, ctx, 'manager');
191
187
  }
192
188
  catch (err) {
193
189
  sendTeamError(err, res);
@@ -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 {
@@ -1,13 +1,16 @@
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");
9
12
  function registerTeamUsageRoutes(router, ctx) {
10
- const { client, catalogClient, auth, logger, provisioningEnabled, provisioningDefaults, roleConfigs, teamBudgetVisibility, } = ctx;
13
+ const { client, catalogClient, auth, logger, teamBudgetVisibility, } = ctx;
11
14
  const requireUser = (0, withUser_1.createRequireUser)(ctx);
12
15
  router.get('/teams/:teamId/usage', requireUser, async (req, res) => {
13
16
  try {
@@ -19,8 +22,7 @@ function registerTeamUsageRoutes(router, ctx) {
19
22
  }
20
23
  // Authorize: member of the team OR a team manager
21
24
  const tokenEntityRef = res.locals.tokenEntityRef;
22
- const userId = res.locals.userId;
23
- const userInfo = await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
25
+ const userInfo = await (0, withUser_1.getProvisionedUser)(ctx, res);
24
26
  // Check membership: user must be in the team OR be a team manager
25
27
  const isMember = userInfo?.teams?.includes(teamId) ?? false;
26
28
  // A "manager" administers this specific team: team management is enabled
@@ -70,4 +72,110 @@ function registerTeamUsageRoutes(router, ctx) {
70
72
  (0, errors_1.sendError)(res, error, logger, 'fetch team usage');
71
73
  }
72
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
+ }
73
181
  }
@@ -4,13 +4,12 @@ exports.registerTeamsRoutes = registerTeamsRoutes;
4
4
  const provisioning_1 = require("../provisioning");
5
5
  const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
6
6
  const teamAdmin_1 = require("../teamAdmin");
7
- const teamBudgetVisibility_1 = require("../teamBudgetVisibility");
8
7
  const errors_1 = require("../errors");
9
8
  const withUser_1 = require("./middleware/withUser");
10
9
  const respondTeam_1 = require("../http/respondTeam");
11
10
  const teamFetch_1 = require("../http/teamFetch");
12
11
  function registerTeamsRoutes(router, ctx) {
13
- const { client, catalogClient, auth, permissions, logger, userIdDomain, provisioningEnabled, provisioningDefaults, roleConfigs, teamAdminCfg, teamBudgetVisibility, requireTeamMgmt, sendTeamError, authorizeTeamSubresource, } = ctx;
12
+ const { client, catalogClient, auth, permissions, logger, userIdDomain, provisioningEnabled, provisioningDefaults, roleConfigs, teamAdminCfg, requireTeamMgmt, sendTeamError, authorizeTeamSubresource, } = ctx;
14
13
  const requireUser = (0, withUser_1.createRequireUser)(ctx);
15
14
  router.get('/teams', requireUser, async (_req, res) => {
16
15
  try {
@@ -83,7 +82,7 @@ function registerTeamsRoutes(router, ctx) {
83
82
  logger.info(`Team creation already in flight for ${key} — joining`);
84
83
  try {
85
84
  const result = await pending;
86
- res.json(result);
85
+ (0, respondTeam_1.respondTeam)(res, result, ctx, 'manager');
87
86
  }
88
87
  catch (err) {
89
88
  sendTeamError(err, res);
@@ -116,10 +115,7 @@ function registerTeamsRoutes(router, ctx) {
116
115
  teamFetch_1.teamCreateInFlight.set(key, createPromise);
117
116
  try {
118
117
  const result = await createPromise;
119
- // Manager surface: redact dollars when hidden even from managers.
120
- res.json(teamBudgetVisibility.hideTeamBudgetForManagers
121
- ? (0, teamBudgetVisibility_1.redactTeamBudget)(result)
122
- : result);
118
+ (0, respondTeam_1.respondTeam)(res, result, ctx, 'manager');
123
119
  }
124
120
  catch (err) {
125
121
  sendTeamError(err, res);
@@ -166,9 +162,7 @@ function registerTeamsRoutes(router, ctx) {
166
162
  teamId,
167
163
  owningGroup,
168
164
  });
169
- res.json(teamBudgetVisibility.hideTeamBudgetForManagers
170
- ? (0, teamBudgetVisibility_1.redactTeamBudget)(r)
171
- : r);
165
+ (0, respondTeam_1.respondTeam)(res, r, ctx, 'manager');
172
166
  }
173
167
  catch (err) {
174
168
  sendTeamError(err, res);
@@ -240,10 +234,7 @@ function registerTeamsRoutes(router, ctx) {
240
234
  // the caller's group set and filter with includes().
241
235
  const owned = all.filter(t => typeof t.metadata?.owning_group === 'string' &&
242
236
  t.metadata.owning_group === teamAdminCfg.group);
243
- // Manager surface: strip dollar amounts when hidden even from managers.
244
- res.json(teamBudgetVisibility.hideTeamBudgetForManagers
245
- ? owned.map(teamBudgetVisibility_1.redactTeamBudget)
246
- : owned);
237
+ (0, respondTeam_1.respondTeamList)(res, owned, ctx, 'manager');
247
238
  }
248
239
  catch (err) {
249
240
  sendTeamError(err, res);
@@ -315,9 +306,9 @@ function registerTeamsRoutes(router, ctx) {
315
306
  return;
316
307
  }
317
308
  // (b) the member must exist in LiteLLM — provision on the spot if enabled
318
- const litellmUserId = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
309
+ const computedUserId = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
319
310
  try {
320
- 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);
321
312
  }
322
313
  catch (err) {
323
314
  if (err instanceof provisioning_1.ProvisioningError) {
@@ -326,6 +317,8 @@ function registerTeamsRoutes(router, ctx) {
326
317
  }
327
318
  throw err;
328
319
  }
320
+ // The member may exist under a different id, found by email.
321
+ const litellmUserId = (0, provisioning_1.effectiveUserId)(computedUserId);
329
322
  try {
330
323
  await client.teamMemberAdd({
331
324
  team_id: teamId,
@@ -343,9 +336,7 @@ function registerTeamsRoutes(router, ctx) {
343
336
  via,
344
337
  });
345
338
  const updated = await (0, teamFetch_1.withTeamFetchRetry)(() => client.getTeamInfo(teamId));
346
- res.json(teamBudgetVisibility.hideTeamBudgetForManagers
347
- ? (0, teamBudgetVisibility_1.redactTeamBudget)(updated)
348
- : updated);
339
+ (0, respondTeam_1.respondTeam)(res, updated, ctx, 'manager');
349
340
  }
350
341
  catch (err) {
351
342
  sendTeamError(err, res);
@@ -366,11 +357,11 @@ function registerTeamsRoutes(router, ctx) {
366
357
  .json({ error: 'userEntityRef query parameter is required' });
367
358
  return;
368
359
  }
369
- const litellmUserId = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
360
+ const litellmUserId = await ctx.resolveMemberUserId(userEntityRef);
370
361
  // Team-role managers change membership only: they cannot remove
371
362
  // themselves nor a peer holding a manager role.
372
363
  if (via === 'teamRole') {
373
- if (litellmUserId === (0, provisioning_1.toLiteLLMUserId)(actor, userIdDomain)) {
364
+ if (litellmUserId === (await ctx.resolveMemberUserId(actor))) {
374
365
  res.status(400).json({ error: 'You cannot remove yourself from the team' });
375
366
  return;
376
367
  }
@@ -395,9 +386,7 @@ function registerTeamsRoutes(router, ctx) {
395
386
  via,
396
387
  });
397
388
  const updated = await (0, teamFetch_1.withTeamFetchRetry)(() => client.getTeamInfo(teamId));
398
- res.json(teamBudgetVisibility.hideTeamBudgetForManagers
399
- ? (0, teamBudgetVisibility_1.redactTeamBudget)(updated)
400
- : updated);
389
+ (0, respondTeam_1.respondTeam)(res, updated, ctx, 'manager');
401
390
  }
402
391
  catch (err) {
403
392
  sendTeamError(err, res);
@@ -1,20 +1,23 @@
1
1
  /**
2
- * Key generation service — unified by PR-6.
2
+ * Key generation service.
3
3
  *
4
4
  * Extracts the common logic for creating API keys from both the UI (POST /keys/generate)
5
- * and the bridge (POST /bridge/keys). Handles validation, provisioning, team/model checks,
6
- * and explicit upstream request construction.
5
+ * and the bridge (POST /bridge/keys). Handles validation, team/model checks,
6
+ * and explicit upstream request construction. Callers resolve the user beforehand.
7
7
  */
8
8
  import { AuthService, LoggerService } from '@backstage/backend-plugin-api';
9
9
  import { CatalogClient } from '@backstage/catalog-client';
10
10
  import { LiteLLMClient } from '../client';
11
- import { GenerateKeyResponse, ProvisioningDefaults, RoleConfig } from '../types';
11
+ import { GenerateKeyResponse, UserInfo } from '../types';
12
12
  import { type KeyValidationConfig, type GenerateKeyInput } from '@acarmisc/backstage-plugin-litellm-common';
13
+ /** User identity for key creation — already resolved by the caller. */
13
14
  export interface KeyCreateUser {
14
15
  /** Backstage user entity ref, e.g. "user:default/alice" (may be empty for bridge). */
15
16
  tokenEntityRef?: string;
16
- /** Resolved LiteLLM user_id. Required. */
17
- userId: string;
17
+ /** Resolved user info from LiteLLM. Required. */
18
+ userInfo: UserInfo;
19
+ /** How the key was created (for metadata stamping). Defaults to 'backstage'. */
20
+ createdVia?: string;
18
21
  }
19
22
  /** Context needed to create a key — configuration, clients, and services. */
20
23
  export interface KeyCreateContext {
@@ -27,10 +30,6 @@ export interface KeyCreateContext {
27
30
  auth?: AuthService;
28
31
  logger: LoggerService;
29
32
  keyValidationConfig: KeyValidationConfig;
30
- provisioningEnabled: boolean;
31
- provisioningDefaults: ProvisioningDefaults;
32
- roleConfigs: RoleConfig[];
33
- userIdDomain?: string;
34
33
  }
35
34
  /** Error thrown during key creation — maps to HTTP responses. */
36
35
  export declare class KeyServiceError extends Error {
@@ -44,18 +43,16 @@ export declare class KeyServiceError extends Error {
44
43
  * Performs:
45
44
  * - Schema validation of the input
46
45
  * - Config flag enforcement (allowUnlimitedBudget, teamRequired)
47
- * - User provisioning (single-flight, role configs)
48
46
  * - Team membership validation
49
47
  * - Model subset validation
50
48
  * - Metadata stamping
51
49
  * - Explicit upstream request construction and call
52
50
  *
53
- * @param user Verified identity: tokenEntityRef (optional) + userId (required)
51
+ * @param user Resolved identity: tokenEntityRef (optional) + userInfo (required, pre-provisioned)
54
52
  * @param input Parsed GenerateKeyInput from the schema
55
53
  * @param ctx Configuration and service clients
56
54
  * @returns The new key response from LiteLLM
57
55
  * @throws KeyServiceError with status and body for HTTP response
58
- * @throws ProvisioningError for provisioning failures
59
56
  * @throws LiteLLMUpstreamError for upstream LiteLLM errors
60
57
  */
61
58
  export declare function createKeyForUser(user: KeyCreateUser, input: GenerateKeyInput, ctx: KeyCreateContext): Promise<GenerateKeyResponse>;
@@ -1,16 +1,15 @@
1
1
  "use strict";
2
2
  /**
3
- * Key generation service — unified by PR-6.
3
+ * Key generation service.
4
4
  *
5
5
  * Extracts the common logic for creating API keys from both the UI (POST /keys/generate)
6
- * and the bridge (POST /bridge/keys). Handles validation, provisioning, team/model checks,
7
- * and explicit upstream request construction.
6
+ * and the bridge (POST /bridge/keys). Handles validation, team/model checks,
7
+ * and explicit upstream request construction. Callers resolve the user beforehand.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.KeyServiceError = void 0;
11
11
  exports.createKeyForUser = createKeyForUser;
12
12
  const provisioning_1 = require("../provisioning");
13
- /** Unified identity for key creation — resolved from either Backstage or JWT claims. */
14
13
  const modelAccess_1 = require("./modelAccess");
15
14
  /** Error thrown during key creation — maps to HTTP responses. */
16
15
  class KeyServiceError extends Error {
@@ -27,57 +26,21 @@ exports.KeyServiceError = KeyServiceError;
27
26
  * Performs:
28
27
  * - Schema validation of the input
29
28
  * - Config flag enforcement (allowUnlimitedBudget, teamRequired)
30
- * - User provisioning (single-flight, role configs)
31
29
  * - Team membership validation
32
30
  * - Model subset validation
33
31
  * - Metadata stamping
34
32
  * - Explicit upstream request construction and call
35
33
  *
36
- * @param user Verified identity: tokenEntityRef (optional) + userId (required)
34
+ * @param user Resolved identity: tokenEntityRef (optional) + userInfo (required, pre-provisioned)
37
35
  * @param input Parsed GenerateKeyInput from the schema
38
36
  * @param ctx Configuration and service clients
39
37
  * @returns The new key response from LiteLLM
40
38
  * @throws KeyServiceError with status and body for HTTP response
41
- * @throws ProvisioningError for provisioning failures
42
39
  * @throws LiteLLMUpstreamError for upstream LiteLLM errors
43
40
  */
44
41
  async function createKeyForUser(user, input, ctx) {
45
- const { client, config, catalogClient, auth, logger, provisioningEnabled, provisioningDefaults, roleConfigs, } = ctx;
46
- // ── Ensure user is provisioned ──────────────────────────────────────
47
- // For bridge (no catalogClient/auth), use direct provisioning from JWT;
48
- // for UI (with catalogClient/auth), use full provisioning with group roles.
49
- let userInfo;
50
- if (catalogClient !== undefined && catalogClient !== null && auth !== undefined && auth !== null) {
51
- userInfo = await (0, provisioning_1.getOrProvisionUser)(client, user.tokenEntityRef || '', user.userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
52
- }
53
- else {
54
- // Bridge path: just check if user exists, or provision with minimal info
55
- userInfo = await client.getUserInfo(user.userId);
56
- if (!userInfo) {
57
- if (!provisioningEnabled) {
58
- throw new KeyServiceError(404, {
59
- error: 'User not found in LiteLLM',
60
- detail: 'No LiteLLM user for this identity. Log in to Backstage once to be provisioned, or enable litellm.provisioning.enabled.',
61
- });
62
- }
63
- // Provision with minimal info (just user_id, will use defaults)
64
- const created = await client.createUser({
65
- user_id: user.userId,
66
- user_email: user.userId,
67
- max_budget: provisioningDefaults.maxBudget,
68
- budget_duration: provisioningDefaults.budgetDuration,
69
- models: provisioningDefaults.models,
70
- teams: provisioningDefaults.teams?.length ? [provisioningDefaults.teams[0]] : [],
71
- });
72
- if (!created) {
73
- throw new KeyServiceError(500, {
74
- error: 'User provisioning failed',
75
- detail: 'Failed to create user in LiteLLM',
76
- });
77
- }
78
- userInfo = await client.getUserInfo(user.userId);
79
- }
80
- }
42
+ const { client, config, catalogClient, auth, logger, } = ctx;
43
+ const userInfo = user.userInfo;
81
44
  // ── Enforce config flags ────────────────────────────────────────────
82
45
  if (!config.allowUnlimitedBudget && (input.max_budget === null || input.max_budget === undefined)) {
83
46
  throw new KeyServiceError(400, { error: 'max_budget is required when unlimited budgets are not allowed' });
@@ -127,13 +90,15 @@ async function createKeyForUser(user, input, ctx) {
127
90
  ? await (0, provisioning_1.resolveUserProfile)(user.tokenEntityRef, catalogClient, auth, logger)
128
91
  : {};
129
92
  const clientMetadata = input.metadata ?? {};
93
+ const createdVia = user.createdVia ?? 'backstage';
130
94
  const serverMetadata = {
131
- created_by_backstage_user: user.tokenEntityRef ?? 'unknown',
95
+ ...(user.tokenEntityRef && { created_by_backstage_user: user.tokenEntityRef }),
96
+ ...(!user.tokenEntityRef && { created_by: userInfo.user_id }),
132
97
  ...(profile.email && { created_by_email: profile.email }),
133
98
  ...(profile.displayName && {
134
99
  created_by_display_name: profile.displayName,
135
100
  }),
136
- created_via: 'backstage',
101
+ created_via: createdVia,
137
102
  created_at_iso: new Date().toISOString(),
138
103
  };
139
104
  // Server-owned keys override client values
@@ -145,7 +110,7 @@ async function createKeyForUser(user, input, ctx) {
145
110
  // Never spread the raw body; only include fields we've explicitly validated.
146
111
  const upstreamRequest = {
147
112
  alias: input.alias,
148
- user_id: user.userId,
113
+ user_id: userInfo.user_id,
149
114
  };
150
115
  if (input.models !== undefined) {
151
116
  upstreamRequest.models = input.models;
@@ -159,6 +124,9 @@ async function createKeyForUser(user, input, ctx) {
159
124
  // max_budget from input can be null (unlimited) or a number
160
125
  upstreamRequest.max_budget = input.max_budget ?? undefined;
161
126
  }
127
+ if (input.budget_duration !== undefined) {
128
+ upstreamRequest.budget_duration = input.budget_duration;
129
+ }
162
130
  if (input.tpm_limit !== undefined) {
163
131
  upstreamRequest.tpm_limit = input.tpm_limit;
164
132
  }
@@ -174,6 +142,6 @@ async function createKeyForUser(user, input, ctx) {
174
142
  upstreamRequest.metadata = enrichedMetadata;
175
143
  // ── Call upstream LiteLLM ───────────────────────────────────────────
176
144
  const result = await client.generateKey(upstreamRequest);
177
- logger.info('key.generate', { userId: user.userId, keyAlias: input.alias });
145
+ logger.info('key.generate', { userId: userInfo.user_id, keyAlias: input.alias });
178
146
  return result;
179
147
  }
@@ -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;