@acarmisc/backstage-plugin-litellm-backend 0.15.0 → 0.16.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 +115 -22
- package/dist/bridge.d.ts +23 -16
- package/dist/bridge.js +51 -11
- package/dist/client.d.ts +7 -2
- package/dist/client.js +35 -9
- package/dist/errors.d.ts +64 -0
- package/dist/errors.js +205 -0
- package/dist/http/respondTeam.d.ts +15 -0
- package/dist/http/respondTeam.js +27 -0
- package/dist/http/teamFetch.d.ts +14 -0
- package/dist/http/teamFetch.js +44 -0
- package/dist/index.cjs.js +4248 -3375
- package/dist/index.cjs.js.map +4 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +15 -13
- package/dist/openapi.js +17 -0
- package/dist/plugin.js +2 -2
- package/dist/provisioning.d.ts +17 -11
- package/dist/provisioning.js +36 -22
- package/dist/router.d.ts +3 -4
- package/dist/router.js +188 -1349
- package/dist/routes/bridge.d.ts +8 -0
- package/dist/routes/bridge.js +130 -0
- package/dist/routes/config.d.ts +7 -0
- package/dist/routes/config.js +89 -0
- package/dist/routes/context.d.ts +55 -0
- package/dist/routes/context.js +2 -0
- package/dist/routes/keys.d.ts +8 -0
- package/dist/routes/keys.js +471 -0
- package/dist/routes/middleware/withUser.d.ts +18 -0
- package/dist/routes/middleware/withUser.js +41 -0
- package/dist/routes/objectPermissions.d.ts +3 -0
- package/dist/routes/objectPermissions.js +196 -0
- package/dist/routes/opencode.d.ts +3 -0
- package/dist/routes/opencode.js +239 -0
- package/dist/routes/teamUsage.d.ts +3 -0
- package/dist/routes/teamUsage.js +73 -0
- package/dist/routes/teams.d.ts +3 -0
- package/dist/routes/teams.js +367 -0
- package/dist/services/keyService.d.ts +61 -0
- package/dist/services/keyService.js +179 -0
- package/dist/services/modelAccess.d.ts +9 -0
- package/dist/services/modelAccess.js +30 -0
- package/dist/services/userInfoCache.d.ts +65 -0
- package/dist/services/userInfoCache.js +147 -0
- package/dist/teamAdmin.d.ts +2 -2
- package/dist/teamBudgetVisibility.d.ts +2 -1
- package/dist/teamBudgetVisibility.js +5 -5
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +8 -197
- package/package.json +6 -3
- package/dist/permissions.d.ts +0 -11
- package/dist/permissions.js +0 -57
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerKeysRoutes = registerKeysRoutes;
|
|
4
|
+
const provisioning_1 = require("../provisioning");
|
|
5
|
+
const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
|
|
6
|
+
const keyService_1 = require("../services/keyService");
|
|
7
|
+
const errors_1 = require("../errors");
|
|
8
|
+
const modelAccess_1 = require("../services/modelAccess");
|
|
9
|
+
const withUser_1 = require("./middleware/withUser");
|
|
10
|
+
/**
|
|
11
|
+
* Register key and user routes: /user/info, /keys*, key mutations, /audit,
|
|
12
|
+
* /models and /usage. Handlers are unchanged from router.ts; only their
|
|
13
|
+
* location and access to shared state (via ctx) differ.
|
|
14
|
+
*/
|
|
15
|
+
function registerKeysRoutes(router, ctx) {
|
|
16
|
+
const { client, catalogClient, auth, logger, userIdDomain, provisioningEnabled, provisioningDefaults, roleConfigs, auditGroup, allowUnlimitedBudget, teamRequired, allowOwnerResetSpend, keyValidationConfig, authorizeKeyAction, sendOwnershipError, assertPermission, sendPermissionDenied, } = ctx;
|
|
17
|
+
const generateKeyInputSchema = (0, backstage_plugin_litellm_common_1.createGenerateKeyInputSchema)(keyValidationConfig);
|
|
18
|
+
const updateKeyInputSchema = (0, backstage_plugin_litellm_common_1.createUpdateKeyInputSchema)(keyValidationConfig);
|
|
19
|
+
router.get('/user/info', async (_req, res) => {
|
|
20
|
+
try {
|
|
21
|
+
const tokenEntityRef = res.locals.tokenEntityRef;
|
|
22
|
+
const userInfo = await (0, withUser_1.getProvisionedUser)(ctx, res);
|
|
23
|
+
const canViewAudit = auditGroup && tokenEntityRef
|
|
24
|
+
? await (0, provisioning_1.isUserMemberOfGroup)(tokenEntityRef, auditGroup, catalogClient, auth, logger)
|
|
25
|
+
: false;
|
|
26
|
+
res.json({ ...userInfo, can_view_audit: canViewAudit });
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error instanceof provisioning_1.ProvisioningError) {
|
|
30
|
+
res.status(error.status).json(error.body);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
(0, errors_1.sendError)(res, error, logger, 'fetch user info');
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
router.get('/keys', async (_req, res) => {
|
|
37
|
+
try {
|
|
38
|
+
const userId = res.locals.userId;
|
|
39
|
+
await (0, withUser_1.getProvisionedUser)(ctx, res);
|
|
40
|
+
const keys = await client.listKeys(userId);
|
|
41
|
+
res.json(keys);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof provisioning_1.ProvisioningError) {
|
|
45
|
+
res.status(error.status).json(error.body);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
(0, errors_1.sendError)(res, error, logger, 'list keys');
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
router.post('/keys/prune-expired', async (_req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
// Permission check
|
|
54
|
+
if (!(await assertPermission(_req, backstage_plugin_litellm_common_1.litellmKeyRevokePermission))) {
|
|
55
|
+
sendPermissionDenied(res, backstage_plugin_litellm_common_1.litellmKeyRevokePermission);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const userId = res.locals.userId;
|
|
59
|
+
// Ensure the user is provisioned
|
|
60
|
+
await (0, withUser_1.getProvisionedUser)(ctx, res);
|
|
61
|
+
// Load the caller's keys
|
|
62
|
+
const keys = await client.listKeys(userId);
|
|
63
|
+
// Identify expired keys (expires_at in the past)
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
const expiredKeys = keys.filter(k => {
|
|
66
|
+
if (!k.expires_at)
|
|
67
|
+
return false;
|
|
68
|
+
return new Date(k.expires_at).getTime() < now;
|
|
69
|
+
});
|
|
70
|
+
if (expiredKeys.length === 0) {
|
|
71
|
+
res.json({ pruned: 0, failed: 0 });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Delete expired keys, tracking failures
|
|
75
|
+
let pruned = 0;
|
|
76
|
+
let failed = 0;
|
|
77
|
+
const failures = [];
|
|
78
|
+
for (const key of expiredKeys) {
|
|
79
|
+
try {
|
|
80
|
+
const keyId = key.token ?? key.key;
|
|
81
|
+
await client.deleteKeys({ keys: [keyId] });
|
|
82
|
+
pruned++;
|
|
83
|
+
logger.info('key.prune-expired', { userId, keyId });
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
failed++;
|
|
87
|
+
const keyId = key.token ?? key.key;
|
|
88
|
+
let errorMsg = 'Unknown error';
|
|
89
|
+
if (err instanceof Error) {
|
|
90
|
+
errorMsg = (0, errors_1.sanitizeUpstreamMessage)(err.message) || 'Unknown error';
|
|
91
|
+
}
|
|
92
|
+
failures.push({ keyId, error: errorMsg });
|
|
93
|
+
logger.warn('key.prune-expired failed', {
|
|
94
|
+
userId,
|
|
95
|
+
keyId,
|
|
96
|
+
error: errorMsg,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const response = { pruned, failed };
|
|
101
|
+
if (failures.length > 0) {
|
|
102
|
+
response.failures = failures;
|
|
103
|
+
}
|
|
104
|
+
res.json(response);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error instanceof provisioning_1.ProvisioningError) {
|
|
108
|
+
res.status(error.status).json(error.body);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
(0, errors_1.sendError)(res, error, logger, 'prune expired keys');
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
router.post('/keys/generate', async (req, res) => {
|
|
115
|
+
try {
|
|
116
|
+
// ── Parse & validate input with strict schema ────────────────────────
|
|
117
|
+
const parseResult = generateKeyInputSchema.safeParse(req.body);
|
|
118
|
+
if (!parseResult.success) {
|
|
119
|
+
const errorMessages = parseResult.error.errors
|
|
120
|
+
.map(e => `${e.path.join('.')}: ${e.message}`)
|
|
121
|
+
.join('; ');
|
|
122
|
+
res.status(400).json({
|
|
123
|
+
error: 'Invalid request body',
|
|
124
|
+
details: errorMessages,
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const input = parseResult.data;
|
|
129
|
+
// ── Permission check ────────────────────────────────────────────────
|
|
130
|
+
if (!(await assertPermission(req, backstage_plugin_litellm_common_1.litellmKeyCreatePermission))) {
|
|
131
|
+
sendPermissionDenied(res, backstage_plugin_litellm_common_1.litellmKeyCreatePermission);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const tokenEntityRef = res.locals.tokenEntityRef;
|
|
135
|
+
const resolvedUserId = res.locals.userId;
|
|
136
|
+
// ── Create key via unified service ──────────────────────────────────
|
|
137
|
+
const keyCreateCtx = {
|
|
138
|
+
client,
|
|
139
|
+
config: {
|
|
140
|
+
allowUnlimitedBudget,
|
|
141
|
+
teamRequired,
|
|
142
|
+
},
|
|
143
|
+
catalogClient,
|
|
144
|
+
auth,
|
|
145
|
+
logger,
|
|
146
|
+
keyValidationConfig,
|
|
147
|
+
provisioningEnabled,
|
|
148
|
+
provisioningDefaults,
|
|
149
|
+
roleConfigs,
|
|
150
|
+
userIdDomain,
|
|
151
|
+
};
|
|
152
|
+
const result = await (0, keyService_1.createKeyForUser)({ tokenEntityRef, userId: resolvedUserId }, input, keyCreateCtx);
|
|
153
|
+
res.json(result);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error instanceof keyService_1.KeyServiceError) {
|
|
157
|
+
res.status(error.status).json(error.body);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (error instanceof provisioning_1.ProvisioningError) {
|
|
161
|
+
res.status(error.status).json(error.body);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// LiteLLM's enterprise team-key hook silently overrides the requested
|
|
165
|
+
// `duration` with the team's `metadata.team_member_key_duration` when a
|
|
166
|
+
// team_id is set, before parsing it. A malformed value there 500s with
|
|
167
|
+
// this exact message regardless of what we sent — surface that instead
|
|
168
|
+
// of the opaque passthrough so it's actionable from the LiteLLM side.
|
|
169
|
+
const teamId = req.body?.team_id;
|
|
170
|
+
if (error instanceof Error &&
|
|
171
|
+
error.message.includes('Invalid duration format') &&
|
|
172
|
+
teamId) {
|
|
173
|
+
res.status(502).json({
|
|
174
|
+
error: 'LiteLLM rejected the key duration for this team. The team has a ' +
|
|
175
|
+
'"Team Member Key Duration" set in LiteLLM that is not in a valid ' +
|
|
176
|
+
'<number><unit> format (e.g. "30d") and overrides whatever duration ' +
|
|
177
|
+
'is requested. Fix or clear it in LiteLLM under Teams → this team → ' +
|
|
178
|
+
'Team Settings, then retry.',
|
|
179
|
+
teamId,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
(0, errors_1.sendError)(res, error, logger, 'generate key');
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
router.post('/keys/:keyId/update', async (req, res) => {
|
|
187
|
+
try {
|
|
188
|
+
const { keyId } = req.params;
|
|
189
|
+
if (!keyId) {
|
|
190
|
+
res.status(400).json({ error: 'keyId is required' });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (!(await assertPermission(req, backstage_plugin_litellm_common_1.litellmKeyManagePermission))) {
|
|
194
|
+
sendPermissionDenied(res, backstage_plugin_litellm_common_1.litellmKeyManagePermission);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const { tokenEntityRef, userId, key } = await authorizeKeyAction(req, keyId);
|
|
198
|
+
// ── Parse and validate request with strict schema ──────────────────────
|
|
199
|
+
// This will reject unknown fields including team_id, user_id, spend, blocked, key, budget_duration.
|
|
200
|
+
let input;
|
|
201
|
+
try {
|
|
202
|
+
input = updateKeyInputSchema.parse(req.body);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
const errorMessage = error && typeof error === 'object' && 'errors' in error
|
|
206
|
+
? error.errors?.[0]?.message || 'Invalid request'
|
|
207
|
+
: 'Invalid request';
|
|
208
|
+
const details = error && typeof error === 'object' && 'errors' in error
|
|
209
|
+
? error.errors
|
|
210
|
+
: undefined;
|
|
211
|
+
res.status(400).json({
|
|
212
|
+
error: errorMessage,
|
|
213
|
+
...(details && { details }),
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// ── Enforce budget rules ──────────────────────────────────────────────
|
|
218
|
+
// allowUnlimitedBudget===false rejects max_budget:null explicitly sent
|
|
219
|
+
if (!allowUnlimitedBudget && input.max_budget === null) {
|
|
220
|
+
res.status(400).json({
|
|
221
|
+
error: 'max_budget cannot be null when unlimited budgets are not allowed',
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// ── Validate models are subset of allowed using the key's team_id ─────
|
|
226
|
+
if (input.models && input.models.length > 0) {
|
|
227
|
+
let allowedModels = [];
|
|
228
|
+
if (key.team_id) {
|
|
229
|
+
// Fetch team info to get its allowed models.
|
|
230
|
+
// Fail closed: if the team can't be fetched, the error propagates.
|
|
231
|
+
try {
|
|
232
|
+
const teamInfo = await client.getTeamInfo(key.team_id);
|
|
233
|
+
allowedModels = teamInfo?.models ?? [];
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
(0, errors_1.sendError)(res, err, logger, 'fetch team info for model validation');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
// Use user's models if no team
|
|
242
|
+
const userInfo = await client.getUserInfo(userId);
|
|
243
|
+
allowedModels = userInfo?.models ?? [];
|
|
244
|
+
}
|
|
245
|
+
const disallowed = await (0, modelAccess_1.findDisallowedModels)(client, input.models, allowedModels);
|
|
246
|
+
if (disallowed.length > 0) {
|
|
247
|
+
res.status(400).json({
|
|
248
|
+
error: 'One or more requested models are not allowed',
|
|
249
|
+
disallowed_models: disallowed,
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// ── Build upstream request EXPLICITLY from parsed fields ──────────────
|
|
255
|
+
// Never spread the raw body; only include fields we've explicitly validated.
|
|
256
|
+
const upstreamRequest = {
|
|
257
|
+
key: keyId,
|
|
258
|
+
};
|
|
259
|
+
if (input.key_alias !== undefined) {
|
|
260
|
+
upstreamRequest.key_alias = input.key_alias;
|
|
261
|
+
}
|
|
262
|
+
if (input.models !== undefined) {
|
|
263
|
+
upstreamRequest.models = input.models;
|
|
264
|
+
}
|
|
265
|
+
if (input.max_budget !== undefined) {
|
|
266
|
+
// max_budget from input can be null (unlimited) or a number
|
|
267
|
+
upstreamRequest.max_budget = input.max_budget;
|
|
268
|
+
}
|
|
269
|
+
if (input.tpm_limit !== undefined) {
|
|
270
|
+
upstreamRequest.tpm_limit = input.tpm_limit;
|
|
271
|
+
}
|
|
272
|
+
if (input.rpm_limit !== undefined) {
|
|
273
|
+
upstreamRequest.rpm_limit = input.rpm_limit;
|
|
274
|
+
}
|
|
275
|
+
const result = await client.updateKey(upstreamRequest);
|
|
276
|
+
logger.info('key.update', { userId: tokenEntityRef ?? 'unknown', keyId });
|
|
277
|
+
res.json(result);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
if (sendOwnershipError(error, res))
|
|
281
|
+
return;
|
|
282
|
+
(0, errors_1.sendError)(res, error, logger, 'update key');
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
router.delete('/keys/:keyId', async (req, res) => {
|
|
286
|
+
try {
|
|
287
|
+
const { keyId } = req.params;
|
|
288
|
+
if (!keyId) {
|
|
289
|
+
res.status(400).json({ error: 'keyId is required' });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!(await assertPermission(req, backstage_plugin_litellm_common_1.litellmKeyRevokePermission))) {
|
|
293
|
+
sendPermissionDenied(res, backstage_plugin_litellm_common_1.litellmKeyRevokePermission);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
297
|
+
await client.deleteKeys({ keys: [keyId] });
|
|
298
|
+
logger.info('key.delete', { userId: tokenEntityRef ?? 'unknown', keyId });
|
|
299
|
+
res.json({ success: true });
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
if (sendOwnershipError(error, res))
|
|
303
|
+
return;
|
|
304
|
+
(0, errors_1.sendError)(res, error, logger, 'delete key');
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
router.post('/keys/:keyId/block', async (req, res) => {
|
|
308
|
+
try {
|
|
309
|
+
const { keyId } = req.params;
|
|
310
|
+
if (!(await assertPermission(req, backstage_plugin_litellm_common_1.litellmKeyManagePermission))) {
|
|
311
|
+
sendPermissionDenied(res, backstage_plugin_litellm_common_1.litellmKeyManagePermission);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const { tokenEntityRef, key } = await authorizeKeyAction(req, keyId);
|
|
315
|
+
// An already-blocked key keeps its original `blocked_by`. Re-blocking would
|
|
316
|
+
// otherwise let an owner take over an admin's block and then unblock it.
|
|
317
|
+
if (key.blocked) {
|
|
318
|
+
res.status(409).json({ error: 'Key is already blocked' });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// Record metadata: who blocked this key and when
|
|
322
|
+
const updatedMetadata = {
|
|
323
|
+
...(key.metadata ?? {}),
|
|
324
|
+
blocked_by: tokenEntityRef,
|
|
325
|
+
blocked_at: new Date().toISOString(),
|
|
326
|
+
};
|
|
327
|
+
await client.blockKey(keyId);
|
|
328
|
+
// Also update the key's metadata to record blocked_by and blocked_at
|
|
329
|
+
await client.updateKey({
|
|
330
|
+
key: keyId,
|
|
331
|
+
metadata: updatedMetadata,
|
|
332
|
+
});
|
|
333
|
+
logger.info('key.block', { userId: tokenEntityRef ?? 'unknown', keyId });
|
|
334
|
+
res.json({ success: true });
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (sendOwnershipError(error, res))
|
|
338
|
+
return;
|
|
339
|
+
(0, errors_1.sendError)(res, error, logger, 'block key');
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
router.post('/keys/:keyId/unblock', async (req, res) => {
|
|
343
|
+
try {
|
|
344
|
+
const { keyId } = req.params;
|
|
345
|
+
const { tokenEntityRef, key } = await authorizeKeyAction(req, keyId);
|
|
346
|
+
// Check if the key can be unblocked:
|
|
347
|
+
// - Owner can unblock a self-blocked key (metadata.blocked_by === tokenEntityRef)
|
|
348
|
+
// - Otherwise require the unblock permission
|
|
349
|
+
const isOwnerUnblockingOwnBlock = key.metadata?.blocked_by === tokenEntityRef;
|
|
350
|
+
if (!isOwnerUnblockingOwnBlock) {
|
|
351
|
+
if (!(await assertPermission(req, backstage_plugin_litellm_common_1.litellmKeyUnblockPermission))) {
|
|
352
|
+
sendPermissionDenied(res, backstage_plugin_litellm_common_1.litellmKeyUnblockPermission);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
await client.unblockKey(keyId);
|
|
357
|
+
// Clear the block record on unblock. Set the fields to null rather than
|
|
358
|
+
// deleting them: whether LiteLLM merges or replaces metadata on update, a
|
|
359
|
+
// stale `blocked_by` must not survive, or a later admin block could be
|
|
360
|
+
// lifted by the same owner.
|
|
361
|
+
const updatedMetadata = {
|
|
362
|
+
...(key.metadata ?? {}),
|
|
363
|
+
blocked_by: null,
|
|
364
|
+
blocked_at: null,
|
|
365
|
+
};
|
|
366
|
+
await client.updateKey({
|
|
367
|
+
key: keyId,
|
|
368
|
+
metadata: updatedMetadata,
|
|
369
|
+
});
|
|
370
|
+
logger.info('key.unblock', { userId: tokenEntityRef ?? 'unknown', keyId });
|
|
371
|
+
res.json({ success: true });
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
if (sendOwnershipError(error, res))
|
|
375
|
+
return;
|
|
376
|
+
(0, errors_1.sendError)(res, error, logger, 'unblock key');
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
router.post('/keys/:keyId/reset_spend', async (req, res) => {
|
|
380
|
+
try {
|
|
381
|
+
const { keyId } = req.params;
|
|
382
|
+
// Fail closed: check the config flag first — if false, always deny even with an allow-all permission
|
|
383
|
+
if (!allowOwnerResetSpend) {
|
|
384
|
+
res.status(403).json({
|
|
385
|
+
error: 'Reset spend is not allowed. Contact your administrator to enable this action.',
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// Then check permissions
|
|
390
|
+
if (!(await assertPermission(req, backstage_plugin_litellm_common_1.litellmKeyResetSpendPermission))) {
|
|
391
|
+
sendPermissionDenied(res, backstage_plugin_litellm_common_1.litellmKeyResetSpendPermission);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
395
|
+
await client.resetKeySpend(keyId);
|
|
396
|
+
logger.info('key.reset_spend', { userId: tokenEntityRef ?? 'unknown', keyId });
|
|
397
|
+
res.json({ success: true });
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
if (sendOwnershipError(error, res))
|
|
401
|
+
return;
|
|
402
|
+
(0, errors_1.sendError)(res, error, logger, 'reset key spend');
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
router.get('/audit', async (req, res) => {
|
|
406
|
+
if (!auditGroup) {
|
|
407
|
+
res.status(403).json({ error: 'Audit log is not configured (litellm.audit.group not set)' });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
|
|
411
|
+
if (!tokenEntityRef) {
|
|
412
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const allowed = await (0, provisioning_1.isUserMemberOfGroup)(tokenEntityRef, auditGroup, catalogClient, auth, logger);
|
|
416
|
+
if (!allowed) {
|
|
417
|
+
res.status(403).json({ error: 'Access denied: not a member of the audit group' });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (!(await assertPermission(req, backstage_plugin_litellm_common_1.litellmAuditReadPermission))) {
|
|
421
|
+
sendPermissionDenied(res, backstage_plugin_litellm_common_1.litellmAuditReadPermission);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
const { page, page_size, start_date, end_date, action, table_name, changed_by } = req.query;
|
|
426
|
+
const result = await client.getAuditLogs({
|
|
427
|
+
page: page ? Number(page) : undefined,
|
|
428
|
+
page_size: page_size ? Number(page_size) : 25,
|
|
429
|
+
start_date,
|
|
430
|
+
end_date,
|
|
431
|
+
action,
|
|
432
|
+
table_name,
|
|
433
|
+
changed_by,
|
|
434
|
+
});
|
|
435
|
+
res.json(result);
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
(0, errors_1.sendError)(res, error, logger, 'fetch audit logs');
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
router.get('/models', async (_req, res) => {
|
|
442
|
+
try {
|
|
443
|
+
const models = await client.listModels();
|
|
444
|
+
res.json(models);
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
(0, errors_1.sendError)(res, error, logger, 'list models');
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
router.get('/usage', async (req, res) => {
|
|
451
|
+
try {
|
|
452
|
+
const { start_date, end_date } = req.query;
|
|
453
|
+
if (!start_date || !end_date) {
|
|
454
|
+
res.status(400).json({ error: 'start_date and end_date are required' });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const tokenEntityRef = res.locals.tokenEntityRef;
|
|
458
|
+
const userId = res.locals.userId;
|
|
459
|
+
await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
|
|
460
|
+
const usage = await client.getUsage(start_date, end_date, userId);
|
|
461
|
+
res.json(usage);
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
if (error instanceof provisioning_1.ProvisioningError) {
|
|
465
|
+
res.status(error.status).json(error.body);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
(0, errors_1.sendError)(res, error, logger, 'fetch usage');
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import type { RouterContext } from '../context';
|
|
3
|
+
/**
|
|
4
|
+
* Express middleware that resolves a Backstage user principal to a LiteLLM user ID.
|
|
5
|
+
* Stores both `tokenEntityRef` and `userId` in res.locals for downstream handlers.
|
|
6
|
+
*
|
|
7
|
+
* Returns 401 if the request lacks a valid Backstage user credential.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createRequireUser(ctx: RouterContext): (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Helper to fetch and provision a user from res.locals (populated by requireUser middleware).
|
|
12
|
+
* Returns the user info or throws ProvisioningError (which the caller must handle and respond to).
|
|
13
|
+
*
|
|
14
|
+
* This deduplicates the repeated pattern:
|
|
15
|
+
* const userInfo = await getOrProvisionUser(...);
|
|
16
|
+
* if (error instanceof ProvisioningError) { res.status(...).json(...); return; }
|
|
17
|
+
*/
|
|
18
|
+
export declare function getProvisionedUser(ctx: RouterContext, res: Response): Promise<any>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createRequireUser = createRequireUser;
|
|
4
|
+
exports.getProvisionedUser = getProvisionedUser;
|
|
5
|
+
const provisioning_1 = require("../../provisioning");
|
|
6
|
+
/**
|
|
7
|
+
* Express middleware that resolves a Backstage user principal to a LiteLLM user ID.
|
|
8
|
+
* Stores both `tokenEntityRef` and `userId` in res.locals for downstream handlers.
|
|
9
|
+
*
|
|
10
|
+
* Returns 401 if the request lacks a valid Backstage user credential.
|
|
11
|
+
*/
|
|
12
|
+
function createRequireUser(ctx) {
|
|
13
|
+
return async (req, res, next) => {
|
|
14
|
+
try {
|
|
15
|
+
const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, ctx.auth);
|
|
16
|
+
if (!tokenEntityRef) {
|
|
17
|
+
res.status(401).json({ error: 'A Backstage user credential is required' });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
res.locals.tokenEntityRef = tokenEntityRef;
|
|
21
|
+
res.locals.userId = (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, ctx.userIdDomain);
|
|
22
|
+
next();
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
res.status(401).json({ error: 'A Backstage user credential is required' });
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Helper to fetch and provision a user from res.locals (populated by requireUser middleware).
|
|
31
|
+
* Returns the user info or throws ProvisioningError (which the caller must handle and respond to).
|
|
32
|
+
*
|
|
33
|
+
* This deduplicates the repeated pattern:
|
|
34
|
+
* const userInfo = await getOrProvisionUser(...);
|
|
35
|
+
* if (error instanceof ProvisioningError) { res.status(...).json(...); return; }
|
|
36
|
+
*/
|
|
37
|
+
async function getProvisionedUser(ctx, res) {
|
|
38
|
+
const tokenEntityRef = res.locals.tokenEntityRef;
|
|
39
|
+
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
|
+
}
|