@acarmisc/backstage-plugin-litellm-backend 0.19.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/config.d.ts +41 -0
- package/dist/client.d.ts +5 -0
- package/dist/client.js +21 -0
- package/dist/index.cjs.js +324 -49
- package/dist/index.cjs.js.map +4 -4
- package/dist/openapi.js +22 -0
- package/dist/provisioning.d.ts +14 -1
- package/dist/provisioning.js +83 -17
- package/dist/router.js +17 -2
- package/dist/routes/config.js +3 -0
- package/dist/routes/context.d.ts +7 -0
- package/dist/routes/keys.js +2 -2
- package/dist/routes/middleware/withUser.d.ts +1 -0
- package/dist/routes/middleware/withUser.js +5 -1
- package/dist/routes/opencode.js +3 -3
- package/dist/routes/teamUsage.js +109 -0
- package/dist/routes/teams.js +6 -4
- package/dist/services/keyService.js +3 -0
- package/dist/teamMemberUsage.d.ts +34 -0
- package/dist/teamMemberUsage.js +114 -0
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +2 -2
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'],
|
package/dist/provisioning.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
*/
|
package/dist/provisioning.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
270
|
-
// This collapses the /keys + /teams + /usage
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 &&
|
package/dist/routes/config.js
CHANGED
|
@@ -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,
|
package/dist/routes/context.d.ts
CHANGED
|
@@ -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;
|
package/dist/routes/keys.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/routes/opencode.js
CHANGED
|
@@ -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
|
|
70
|
-
const userInfo = await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef,
|
|
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 {
|
package/dist/routes/teamUsage.js
CHANGED
|
@@ -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
|
}
|
package/dist/routes/teams.js
CHANGED
|
@@ -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
|
|
309
|
+
const computedUserId = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
|
|
310
310
|
try {
|
|
311
|
-
await (0, provisioning_1.getOrProvisionUser)(client, userEntityRef,
|
|
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 =
|
|
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 === (
|
|
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;
|