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