@acarmisc/backstage-plugin-litellm-backend 0.15.0 → 0.16.1
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 +124 -22
- package/dist/bridge.d.ts +43 -17
- package/dist/bridge.js +69 -13
- 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 +4264 -3373
- 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 +135 -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,367 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerTeamsRoutes = registerTeamsRoutes;
|
|
4
|
+
const provisioning_1 = require("../provisioning");
|
|
5
|
+
const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
|
|
6
|
+
const teamAdmin_1 = require("../teamAdmin");
|
|
7
|
+
const teamBudgetVisibility_1 = require("../teamBudgetVisibility");
|
|
8
|
+
const errors_1 = require("../errors");
|
|
9
|
+
const withUser_1 = require("./middleware/withUser");
|
|
10
|
+
const respondTeam_1 = require("../http/respondTeam");
|
|
11
|
+
const teamFetch_1 = require("../http/teamFetch");
|
|
12
|
+
function registerTeamsRoutes(router, ctx) {
|
|
13
|
+
const { client, catalogClient, auth, permissions, logger, userIdDomain, provisioningEnabled, provisioningDefaults, roleConfigs, teamAdminCfg, teamBudgetVisibility, requireTeamMgmt, sendTeamError, authorizeTeamSubresource, } = ctx;
|
|
14
|
+
const requireUser = (0, withUser_1.createRequireUser)(ctx);
|
|
15
|
+
router.get('/teams', requireUser, async (_req, res) => {
|
|
16
|
+
try {
|
|
17
|
+
const userInfo = await (0, withUser_1.getProvisionedUser)(ctx, res);
|
|
18
|
+
if (!userInfo?.teams?.length) {
|
|
19
|
+
res.json([]);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const teams = await Promise.all(userInfo.teams.map((teamId) => (0, teamFetch_1.withTeamFetchRetry)(() => client.getTeamInfo(teamId)).catch(err => {
|
|
23
|
+
logger.warn(`Failed to fetch team ${teamId} after retries: ${err.message}`);
|
|
24
|
+
return null;
|
|
25
|
+
})));
|
|
26
|
+
const list = teams.filter(Boolean);
|
|
27
|
+
// Member surface: strip dollar amounts when the operator hides them
|
|
28
|
+
// from members. Managers who need the numbers use /teams/managed.
|
|
29
|
+
(0, respondTeam_1.respondTeamList)(res, list, ctx, 'member');
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof provisioning_1.ProvisioningError) {
|
|
33
|
+
res.status(error.status).json(error.body);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
(0, errors_1.sendError)(res, error, logger, 'fetch teams');
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
router.post('/teams', async (req, res) => {
|
|
40
|
+
if (!requireTeamMgmt(res))
|
|
41
|
+
return;
|
|
42
|
+
if (!teamAdminCfg.group) {
|
|
43
|
+
res.status(500).json({ error: 'Team management is misconfigured (group is missing)' });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const check = await (0, teamAdmin_1.assertTeamAdmin)({
|
|
47
|
+
req,
|
|
48
|
+
auth,
|
|
49
|
+
permissions,
|
|
50
|
+
catalogClient,
|
|
51
|
+
teamAdminGroup: teamAdminCfg.group,
|
|
52
|
+
permission: backstage_plugin_litellm_common_1.litellmTeamCreatePermission,
|
|
53
|
+
logger,
|
|
54
|
+
});
|
|
55
|
+
if (!check.ok) {
|
|
56
|
+
res.status(check.status).json({ error: check.error });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const v = (0, teamAdmin_1.validateTeamWriteInput)(req.body ?? {}, teamAdminCfg);
|
|
60
|
+
if (!v.ok) {
|
|
61
|
+
res.status(400).json({ error: v.error });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const key = v.value.team_alias.trim().toLowerCase();
|
|
65
|
+
const pending = teamFetch_1.teamCreateInFlight.get(key);
|
|
66
|
+
if (pending) {
|
|
67
|
+
logger.info(`Team creation already in flight for ${key} — joining`);
|
|
68
|
+
try {
|
|
69
|
+
const result = await pending;
|
|
70
|
+
res.json(result);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
sendTeamError(err, res);
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const payload = {
|
|
78
|
+
team_alias: v.value.team_alias,
|
|
79
|
+
models: v.value.models,
|
|
80
|
+
...(v.value.max_budget !== undefined && { max_budget: v.value.max_budget }),
|
|
81
|
+
...(v.value.budget_duration && { budget_duration: v.value.budget_duration }),
|
|
82
|
+
...(v.value.tpm_limit !== undefined && { tpm_limit: v.value.tpm_limit }),
|
|
83
|
+
...(v.value.rpm_limit !== undefined && { rpm_limit: v.value.rpm_limit }),
|
|
84
|
+
metadata: {
|
|
85
|
+
owning_group: teamAdminCfg.group,
|
|
86
|
+
created_by_backstage_user: check.userEntityRef,
|
|
87
|
+
created_via: 'backstage',
|
|
88
|
+
created_at_iso: new Date().toISOString(),
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
const createPromise = (async () => {
|
|
92
|
+
const result = await client.createTeam(payload);
|
|
93
|
+
logger.info('team.create', {
|
|
94
|
+
actor: check.userEntityRef,
|
|
95
|
+
teamAlias: v.value.team_alias,
|
|
96
|
+
owningGroup: teamAdminCfg.group,
|
|
97
|
+
});
|
|
98
|
+
return result;
|
|
99
|
+
})();
|
|
100
|
+
teamFetch_1.teamCreateInFlight.set(key, createPromise);
|
|
101
|
+
try {
|
|
102
|
+
const result = await createPromise;
|
|
103
|
+
// Manager surface: redact dollars when hidden even from managers.
|
|
104
|
+
res.json(teamBudgetVisibility.hideTeamBudgetForManagers
|
|
105
|
+
? (0, teamBudgetVisibility_1.redactTeamBudget)(result)
|
|
106
|
+
: result);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
sendTeamError(err, res);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
teamFetch_1.teamCreateInFlight.delete(key);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
router.patch('/teams/:teamId', async (req, res) => {
|
|
116
|
+
const authz = await authorizeTeamSubresource(req, res, backstage_plugin_litellm_common_1.litellmTeamManagePermission);
|
|
117
|
+
if (!authz)
|
|
118
|
+
return;
|
|
119
|
+
const { teamId, owningGroup, actor, team: existing } = authz;
|
|
120
|
+
const v = (0, teamAdmin_1.validateTeamPatchInput)(req.body ?? {}, teamAdminCfg);
|
|
121
|
+
if (!v.ok) {
|
|
122
|
+
res.status(400).json({ error: v.error });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// Optimistic-concurrency guard (opt-in): when the client sends the
|
|
126
|
+
// `expectedUpdatedAtIso` it based its edit on, reject if the stored value
|
|
127
|
+
// has moved on — a concurrent edit landed in between. Callers that don't
|
|
128
|
+
// send it are unaffected (last-writer-wins, as before).
|
|
129
|
+
const expected = (req.body ?? {}).expectedUpdatedAtIso;
|
|
130
|
+
if (expected !== undefined &&
|
|
131
|
+
expected !== existing.metadata?.updated_at_iso) {
|
|
132
|
+
res.status(409).json({
|
|
133
|
+
error: 'This team was modified since you loaded it. Reload and re-apply your change.',
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const payload = {
|
|
138
|
+
team_id: teamId,
|
|
139
|
+
...v.value,
|
|
140
|
+
metadata: {
|
|
141
|
+
...(existing.metadata ?? {}),
|
|
142
|
+
updated_by_backstage_user: actor,
|
|
143
|
+
updated_at_iso: new Date().toISOString(),
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
try {
|
|
147
|
+
const r = await client.updateTeam(payload);
|
|
148
|
+
logger.info('team.update', {
|
|
149
|
+
actor,
|
|
150
|
+
teamId,
|
|
151
|
+
owningGroup,
|
|
152
|
+
});
|
|
153
|
+
res.json(teamBudgetVisibility.hideTeamBudgetForManagers
|
|
154
|
+
? (0, teamBudgetVisibility_1.redactTeamBudget)(r)
|
|
155
|
+
: r);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
sendTeamError(err, res);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
// Delete a team the caller's group owns. Gated by litellm.teamAdmin.allowTeamDelete
|
|
162
|
+
// (default false — deletion is destructive: it orphans keys and revokes access
|
|
163
|
+
// for every member). Refuses when the team id is referenced by provisioning
|
|
164
|
+
// config unless ?force=true. Prefer blocking a team over deleting it.
|
|
165
|
+
router.delete('/teams/:teamId', async (req, res) => {
|
|
166
|
+
const authz = await authorizeTeamSubresource(req, res, backstage_plugin_litellm_common_1.litellmTeamDeletePermission);
|
|
167
|
+
if (!authz)
|
|
168
|
+
return;
|
|
169
|
+
const { teamId, owningGroup, actor } = authz;
|
|
170
|
+
if (!teamAdminCfg.allowTeamDelete) {
|
|
171
|
+
res.status(403).json({
|
|
172
|
+
error: 'Team deletion is disabled (set litellm.teamAdmin.allowTeamDelete: true). Block the team instead.',
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const provisioningRefs = [
|
|
177
|
+
...provisioningDefaults.teams,
|
|
178
|
+
...roleConfigs.flatMap(r => r.teams ?? []),
|
|
179
|
+
];
|
|
180
|
+
const force = String(req.query.force ?? '') === 'true';
|
|
181
|
+
if (provisioningRefs.includes(teamId) && !force) {
|
|
182
|
+
res.status(409).json({
|
|
183
|
+
error: `Team ${teamId} is referenced by litellm.provisioning config; deleting it will break user provisioning. Re-send with ?force=true to override.`,
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
await client.deleteTeam(teamId);
|
|
189
|
+
logger.info('team.delete', { actor, teamId, owningGroup, force });
|
|
190
|
+
res.json({ success: true });
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
sendTeamError(err, res);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
// Scoped listing for team admins: returns ONLY the teams whose
|
|
197
|
+
// `metadata.owning_group` equals the configured admin group, so a team admin
|
|
198
|
+
// can see and edit teams their group owns even when they are not a member.
|
|
199
|
+
// This deliberately never returns a global team list. Registered before any
|
|
200
|
+
// `/teams/:param` route so the literal path is not shadowed.
|
|
201
|
+
router.get('/teams/managed', async (req, res) => {
|
|
202
|
+
if (!requireTeamMgmt(res))
|
|
203
|
+
return;
|
|
204
|
+
if (!teamAdminCfg.group) {
|
|
205
|
+
res.status(500).json({ error: 'Team management is misconfigured (group is missing)' });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const check = await (0, teamAdmin_1.assertTeamAdmin)({
|
|
209
|
+
req,
|
|
210
|
+
auth,
|
|
211
|
+
permissions,
|
|
212
|
+
catalogClient,
|
|
213
|
+
teamAdminGroup: teamAdminCfg.group,
|
|
214
|
+
permission: backstage_plugin_litellm_common_1.litellmTeamManagePermission,
|
|
215
|
+
logger,
|
|
216
|
+
});
|
|
217
|
+
if (!check.ok) {
|
|
218
|
+
res.status(check.status).json({ error: check.error });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const all = await client.listTeams();
|
|
223
|
+
// TODO(multi-group): when litellm.teamAdmin.group becomes a list, resolve
|
|
224
|
+
// the caller's group set and filter with includes().
|
|
225
|
+
const owned = all.filter(t => typeof t.metadata?.owning_group === 'string' &&
|
|
226
|
+
t.metadata.owning_group === teamAdminCfg.group);
|
|
227
|
+
// Manager surface: strip dollar amounts when hidden even from managers.
|
|
228
|
+
res.json(teamBudgetVisibility.hideTeamBudgetForManagers
|
|
229
|
+
? owned.map(teamBudgetVisibility_1.redactTeamBudget)
|
|
230
|
+
: owned);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
sendTeamError(err, res);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
// ── Team member routes ──────────────────────────────────────────────────
|
|
237
|
+
//
|
|
238
|
+
// Add a member to a team the caller's group owns. The member must (a) resolve
|
|
239
|
+
// to a real User in the Backstage catalog and (b) exist in LiteLLM — we
|
|
240
|
+
// provision them on the spot when auto-provisioning is enabled. Only the
|
|
241
|
+
// 'user' team role can be assigned from Backstage.
|
|
242
|
+
router.post('/teams/:teamId/members', async (req, res) => {
|
|
243
|
+
const authz = await authorizeTeamSubresource(req, res, backstage_plugin_litellm_common_1.litellmTeamMembersManagePermission);
|
|
244
|
+
if (!authz)
|
|
245
|
+
return;
|
|
246
|
+
const { teamId, owningGroup, actor } = authz;
|
|
247
|
+
const body = (req.body ?? {});
|
|
248
|
+
const userEntityRef = (body.userEntityRef ?? '').trim();
|
|
249
|
+
if (!userEntityRef) {
|
|
250
|
+
res.status(400).json({ error: 'userEntityRef is required' });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (body.role !== undefined && body.role !== 'user') {
|
|
254
|
+
res.status(400).json({
|
|
255
|
+
error: "only the 'user' team role can be assigned from Backstage",
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
let maxBudgetInTeam;
|
|
260
|
+
if (body.maxBudgetInTeam !== undefined) {
|
|
261
|
+
if (typeof body.maxBudgetInTeam !== 'number' ||
|
|
262
|
+
!Number.isFinite(body.maxBudgetInTeam) ||
|
|
263
|
+
body.maxBudgetInTeam <= 0) {
|
|
264
|
+
res
|
|
265
|
+
.status(400)
|
|
266
|
+
.json({ error: 'maxBudgetInTeam must be a positive number' });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
maxBudgetInTeam = body.maxBudgetInTeam;
|
|
270
|
+
}
|
|
271
|
+
// (a) the member must be a real User in the Backstage catalog
|
|
272
|
+
try {
|
|
273
|
+
const { token } = await auth.getPluginRequestToken({
|
|
274
|
+
onBehalfOf: await auth.getOwnServiceCredentials(),
|
|
275
|
+
targetPluginId: 'catalog',
|
|
276
|
+
});
|
|
277
|
+
const entity = await catalogClient.getEntityByRef(userEntityRef, {
|
|
278
|
+
token,
|
|
279
|
+
});
|
|
280
|
+
if (!entity || entity.kind !== 'User') {
|
|
281
|
+
res.status(400).json({
|
|
282
|
+
error: `${userEntityRef} is not a User in the Backstage catalog`,
|
|
283
|
+
});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
289
|
+
logger.warn(`Catalog lookup failed for ${userEntityRef}: ${message}`);
|
|
290
|
+
res.status(400).json({
|
|
291
|
+
error: `Could not verify ${userEntityRef} in the Backstage catalog`,
|
|
292
|
+
});
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
// (b) the member must exist in LiteLLM — provision on the spot if enabled
|
|
296
|
+
const litellmUserId = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
|
|
297
|
+
try {
|
|
298
|
+
await (0, provisioning_1.getOrProvisionUser)(client, userEntityRef, litellmUserId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
if (err instanceof provisioning_1.ProvisioningError) {
|
|
302
|
+
res.status(err.status).json(err.body);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
await client.teamMemberAdd({
|
|
309
|
+
team_id: teamId,
|
|
310
|
+
user_id: litellmUserId,
|
|
311
|
+
role: 'user',
|
|
312
|
+
...(maxBudgetInTeam !== undefined && {
|
|
313
|
+
max_budget_in_team: maxBudgetInTeam,
|
|
314
|
+
}),
|
|
315
|
+
});
|
|
316
|
+
logger.info('team.member.add', {
|
|
317
|
+
actor,
|
|
318
|
+
teamId,
|
|
319
|
+
member: litellmUserId,
|
|
320
|
+
owningGroup,
|
|
321
|
+
});
|
|
322
|
+
const updated = await (0, teamFetch_1.withTeamFetchRetry)(() => client.getTeamInfo(teamId));
|
|
323
|
+
res.json(teamBudgetVisibility.hideTeamBudgetForManagers
|
|
324
|
+
? (0, teamBudgetVisibility_1.redactTeamBudget)(updated)
|
|
325
|
+
: updated);
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
sendTeamError(err, res);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
// Remove a member from a team the caller's group owns. The member ref comes
|
|
332
|
+
// from the `userEntityRef` query param (entity refs contain ':' and '/', so
|
|
333
|
+
// they can't be a path segment). No catalog re-check on removal.
|
|
334
|
+
router.delete('/teams/:teamId/members', async (req, res) => {
|
|
335
|
+
const authz = await authorizeTeamSubresource(req, res, backstage_plugin_litellm_common_1.litellmTeamMembersManagePermission);
|
|
336
|
+
if (!authz)
|
|
337
|
+
return;
|
|
338
|
+
const { teamId, owningGroup, actor } = authz;
|
|
339
|
+
const userEntityRef = String(req.query.userEntityRef ?? '').trim();
|
|
340
|
+
if (!userEntityRef) {
|
|
341
|
+
res
|
|
342
|
+
.status(400)
|
|
343
|
+
.json({ error: 'userEntityRef query parameter is required' });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const litellmUserId = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
|
|
347
|
+
try {
|
|
348
|
+
await client.teamMemberDelete({
|
|
349
|
+
team_id: teamId,
|
|
350
|
+
user_id: litellmUserId,
|
|
351
|
+
});
|
|
352
|
+
logger.info('team.member.remove', {
|
|
353
|
+
actor,
|
|
354
|
+
teamId,
|
|
355
|
+
member: litellmUserId,
|
|
356
|
+
owningGroup,
|
|
357
|
+
});
|
|
358
|
+
const updated = await (0, teamFetch_1.withTeamFetchRetry)(() => client.getTeamInfo(teamId));
|
|
359
|
+
res.json(teamBudgetVisibility.hideTeamBudgetForManagers
|
|
360
|
+
? (0, teamBudgetVisibility_1.redactTeamBudget)(updated)
|
|
361
|
+
: updated);
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
sendTeamError(err, res);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key generation service — unified by PR-6.
|
|
3
|
+
*
|
|
4
|
+
* Extracts the common logic for creating API keys from both the UI (POST /keys/generate)
|
|
5
|
+
* and the bridge (POST /bridge/keys). Handles validation, provisioning, team/model checks,
|
|
6
|
+
* and explicit upstream request construction.
|
|
7
|
+
*/
|
|
8
|
+
import { AuthService, LoggerService } from '@backstage/backend-plugin-api';
|
|
9
|
+
import { CatalogClient } from '@backstage/catalog-client';
|
|
10
|
+
import { LiteLLMClient } from '../client';
|
|
11
|
+
import { GenerateKeyResponse, ProvisioningDefaults, RoleConfig } from '../types';
|
|
12
|
+
import { type KeyValidationConfig, type GenerateKeyInput } from '@acarmisc/backstage-plugin-litellm-common';
|
|
13
|
+
export interface KeyCreateUser {
|
|
14
|
+
/** Backstage user entity ref, e.g. "user:default/alice" (may be empty for bridge). */
|
|
15
|
+
tokenEntityRef?: string;
|
|
16
|
+
/** Resolved LiteLLM user_id. Required. */
|
|
17
|
+
userId: string;
|
|
18
|
+
}
|
|
19
|
+
/** Context needed to create a key — configuration, clients, and services. */
|
|
20
|
+
export interface KeyCreateContext {
|
|
21
|
+
client: LiteLLMClient;
|
|
22
|
+
config: {
|
|
23
|
+
allowUnlimitedBudget: boolean;
|
|
24
|
+
teamRequired: boolean;
|
|
25
|
+
};
|
|
26
|
+
catalogClient?: CatalogClient;
|
|
27
|
+
auth?: AuthService;
|
|
28
|
+
logger: LoggerService;
|
|
29
|
+
keyValidationConfig: KeyValidationConfig;
|
|
30
|
+
provisioningEnabled: boolean;
|
|
31
|
+
provisioningDefaults: ProvisioningDefaults;
|
|
32
|
+
roleConfigs: RoleConfig[];
|
|
33
|
+
userIdDomain?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Error thrown during key creation — maps to HTTP responses. */
|
|
36
|
+
export declare class KeyServiceError extends Error {
|
|
37
|
+
status: number;
|
|
38
|
+
body: Record<string, unknown>;
|
|
39
|
+
constructor(status: number, body: Record<string, unknown>, message?: string);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Creates a new API key for the specified user.
|
|
43
|
+
*
|
|
44
|
+
* Performs:
|
|
45
|
+
* - Schema validation of the input
|
|
46
|
+
* - Config flag enforcement (allowUnlimitedBudget, teamRequired)
|
|
47
|
+
* - User provisioning (single-flight, role configs)
|
|
48
|
+
* - Team membership validation
|
|
49
|
+
* - Model subset validation
|
|
50
|
+
* - Metadata stamping
|
|
51
|
+
* - Explicit upstream request construction and call
|
|
52
|
+
*
|
|
53
|
+
* @param user Verified identity: tokenEntityRef (optional) + userId (required)
|
|
54
|
+
* @param input Parsed GenerateKeyInput from the schema
|
|
55
|
+
* @param ctx Configuration and service clients
|
|
56
|
+
* @returns The new key response from LiteLLM
|
|
57
|
+
* @throws KeyServiceError with status and body for HTTP response
|
|
58
|
+
* @throws ProvisioningError for provisioning failures
|
|
59
|
+
* @throws LiteLLMUpstreamError for upstream LiteLLM errors
|
|
60
|
+
*/
|
|
61
|
+
export declare function createKeyForUser(user: KeyCreateUser, input: GenerateKeyInput, ctx: KeyCreateContext): Promise<GenerateKeyResponse>;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Key generation service — unified by PR-6.
|
|
4
|
+
*
|
|
5
|
+
* Extracts the common logic for creating API keys from both the UI (POST /keys/generate)
|
|
6
|
+
* and the bridge (POST /bridge/keys). Handles validation, provisioning, team/model checks,
|
|
7
|
+
* and explicit upstream request construction.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.KeyServiceError = void 0;
|
|
11
|
+
exports.createKeyForUser = createKeyForUser;
|
|
12
|
+
const provisioning_1 = require("../provisioning");
|
|
13
|
+
/** Unified identity for key creation — resolved from either Backstage or JWT claims. */
|
|
14
|
+
const modelAccess_1 = require("./modelAccess");
|
|
15
|
+
/** Error thrown during key creation — maps to HTTP responses. */
|
|
16
|
+
class KeyServiceError extends Error {
|
|
17
|
+
constructor(status, body, message) {
|
|
18
|
+
super(message || JSON.stringify(body));
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.body = body;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
exports.KeyServiceError = KeyServiceError;
|
|
24
|
+
/**
|
|
25
|
+
* Creates a new API key for the specified user.
|
|
26
|
+
*
|
|
27
|
+
* Performs:
|
|
28
|
+
* - Schema validation of the input
|
|
29
|
+
* - Config flag enforcement (allowUnlimitedBudget, teamRequired)
|
|
30
|
+
* - User provisioning (single-flight, role configs)
|
|
31
|
+
* - Team membership validation
|
|
32
|
+
* - Model subset validation
|
|
33
|
+
* - Metadata stamping
|
|
34
|
+
* - Explicit upstream request construction and call
|
|
35
|
+
*
|
|
36
|
+
* @param user Verified identity: tokenEntityRef (optional) + userId (required)
|
|
37
|
+
* @param input Parsed GenerateKeyInput from the schema
|
|
38
|
+
* @param ctx Configuration and service clients
|
|
39
|
+
* @returns The new key response from LiteLLM
|
|
40
|
+
* @throws KeyServiceError with status and body for HTTP response
|
|
41
|
+
* @throws ProvisioningError for provisioning failures
|
|
42
|
+
* @throws LiteLLMUpstreamError for upstream LiteLLM errors
|
|
43
|
+
*/
|
|
44
|
+
async function createKeyForUser(user, input, ctx) {
|
|
45
|
+
const { client, config, catalogClient, auth, logger, provisioningEnabled, provisioningDefaults, roleConfigs, } = ctx;
|
|
46
|
+
// ── Ensure user is provisioned ──────────────────────────────────────
|
|
47
|
+
// For bridge (no catalogClient/auth), use direct provisioning from JWT;
|
|
48
|
+
// for UI (with catalogClient/auth), use full provisioning with group roles.
|
|
49
|
+
let userInfo;
|
|
50
|
+
if (catalogClient !== undefined && catalogClient !== null && auth !== undefined && auth !== null) {
|
|
51
|
+
userInfo = await (0, provisioning_1.getOrProvisionUser)(client, user.tokenEntityRef || '', user.userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
// Bridge path: just check if user exists, or provision with minimal info
|
|
55
|
+
userInfo = await client.getUserInfo(user.userId);
|
|
56
|
+
if (!userInfo) {
|
|
57
|
+
if (!provisioningEnabled) {
|
|
58
|
+
throw new KeyServiceError(404, {
|
|
59
|
+
error: 'User not found in LiteLLM',
|
|
60
|
+
detail: 'No LiteLLM user for this identity. Log in to Backstage once to be provisioned, or enable litellm.provisioning.enabled.',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// Provision with minimal info (just user_id, will use defaults)
|
|
64
|
+
const created = await client.createUser({
|
|
65
|
+
user_id: user.userId,
|
|
66
|
+
user_email: user.userId,
|
|
67
|
+
max_budget: provisioningDefaults.maxBudget,
|
|
68
|
+
budget_duration: provisioningDefaults.budgetDuration,
|
|
69
|
+
models: provisioningDefaults.models,
|
|
70
|
+
teams: provisioningDefaults.teams?.length ? [provisioningDefaults.teams[0]] : [],
|
|
71
|
+
});
|
|
72
|
+
if (!created) {
|
|
73
|
+
throw new KeyServiceError(500, {
|
|
74
|
+
error: 'User provisioning failed',
|
|
75
|
+
detail: 'Failed to create user in LiteLLM',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
userInfo = await client.getUserInfo(user.userId);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// ── Enforce config flags ────────────────────────────────────────────
|
|
82
|
+
if (!config.allowUnlimitedBudget && (input.max_budget === null || input.max_budget === undefined)) {
|
|
83
|
+
throw new KeyServiceError(400, { error: 'max_budget is required when unlimited budgets are not allowed' });
|
|
84
|
+
}
|
|
85
|
+
if (config.teamRequired && !input.team_id) {
|
|
86
|
+
throw new KeyServiceError(400, { error: 'team_id is required' });
|
|
87
|
+
}
|
|
88
|
+
// ── Validate team membership ────────────────────────────────────────
|
|
89
|
+
if (input.team_id) {
|
|
90
|
+
const userTeams = userInfo?.teams ?? [];
|
|
91
|
+
if (!userTeams.includes(input.team_id)) {
|
|
92
|
+
throw new KeyServiceError(403, {
|
|
93
|
+
error: 'Access denied: team is not one of your teams',
|
|
94
|
+
team_id: input.team_id,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// ── Validate models are subset of allowed ────────────────────────────
|
|
99
|
+
if (input.models && input.models.length > 0) {
|
|
100
|
+
// When team_id is set, use team's models; otherwise use user's models.
|
|
101
|
+
// Empty/missing allowedModels list means unrestricted.
|
|
102
|
+
let allowedModels = [];
|
|
103
|
+
if (input.team_id) {
|
|
104
|
+
// Fetch team info to get its allowed models.
|
|
105
|
+
// Fail closed: if the team can't be fetched the error propagates
|
|
106
|
+
// rather than silently skipping the model check.
|
|
107
|
+
const teamInfo = await client.getTeamInfo(input.team_id);
|
|
108
|
+
allowedModels = teamInfo?.models ?? [];
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
allowedModels = userInfo?.models ?? [];
|
|
112
|
+
}
|
|
113
|
+
const disallowed = await (0, modelAccess_1.findDisallowedModels)(client, input.models, allowedModels);
|
|
114
|
+
if (disallowed.length > 0) {
|
|
115
|
+
throw new KeyServiceError(400, {
|
|
116
|
+
error: 'One or more requested models are not allowed',
|
|
117
|
+
disallowed_models: disallowed,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// ── Stamp ownership into LiteLLM key metadata ────────────────────────
|
|
122
|
+
// LiteLLM's native `created_by` column is only populated when the caller
|
|
123
|
+
// authenticates via JWT/SSO; we always call with the master key, so that
|
|
124
|
+
// column stays null. Enriching `metadata` makes the owner identity visible
|
|
125
|
+
// in LiteLLM's UI and queryable via API.
|
|
126
|
+
const profile = user.tokenEntityRef && catalogClient !== undefined && catalogClient !== null && auth !== undefined && auth !== null
|
|
127
|
+
? await (0, provisioning_1.resolveUserProfile)(user.tokenEntityRef, catalogClient, auth, logger)
|
|
128
|
+
: {};
|
|
129
|
+
const clientMetadata = input.metadata ?? {};
|
|
130
|
+
const serverMetadata = {
|
|
131
|
+
created_by_backstage_user: user.tokenEntityRef ?? 'unknown',
|
|
132
|
+
...(profile.email && { created_by_email: profile.email }),
|
|
133
|
+
...(profile.displayName && {
|
|
134
|
+
created_by_display_name: profile.displayName,
|
|
135
|
+
}),
|
|
136
|
+
created_via: 'backstage',
|
|
137
|
+
created_at_iso: new Date().toISOString(),
|
|
138
|
+
};
|
|
139
|
+
// Server-owned keys override client values
|
|
140
|
+
const enrichedMetadata = {
|
|
141
|
+
...clientMetadata,
|
|
142
|
+
...serverMetadata,
|
|
143
|
+
};
|
|
144
|
+
// ── Build upstream request EXPLICITLY from parsed fields ──────────────
|
|
145
|
+
// Never spread the raw body; only include fields we've explicitly validated.
|
|
146
|
+
const upstreamRequest = {
|
|
147
|
+
alias: input.alias,
|
|
148
|
+
user_id: user.userId,
|
|
149
|
+
};
|
|
150
|
+
if (input.models !== undefined) {
|
|
151
|
+
upstreamRequest.models = input.models;
|
|
152
|
+
}
|
|
153
|
+
// Never mint a non-expiring key by omission: default to 30d, or the first
|
|
154
|
+
// allowed duration when 30d isn't offered.
|
|
155
|
+
const durations = ctx.keyValidationConfig.allowedDurations;
|
|
156
|
+
const defaultDuration = durations.length === 0 || durations.includes('30d') ? '30d' : durations[0];
|
|
157
|
+
upstreamRequest.duration = input.duration ?? defaultDuration;
|
|
158
|
+
if (input.max_budget !== undefined) {
|
|
159
|
+
// max_budget from input can be null (unlimited) or a number
|
|
160
|
+
upstreamRequest.max_budget = input.max_budget ?? undefined;
|
|
161
|
+
}
|
|
162
|
+
if (input.tpm_limit !== undefined) {
|
|
163
|
+
upstreamRequest.tpm_limit = input.tpm_limit;
|
|
164
|
+
}
|
|
165
|
+
if (input.rpm_limit !== undefined) {
|
|
166
|
+
upstreamRequest.rpm_limit = input.rpm_limit;
|
|
167
|
+
}
|
|
168
|
+
if (input.team_id !== undefined) {
|
|
169
|
+
upstreamRequest.team_id = input.team_id;
|
|
170
|
+
}
|
|
171
|
+
if (input.key_type !== undefined) {
|
|
172
|
+
upstreamRequest.key_type = input.key_type;
|
|
173
|
+
}
|
|
174
|
+
upstreamRequest.metadata = enrichedMetadata;
|
|
175
|
+
// ── Call upstream LiteLLM ───────────────────────────────────────────
|
|
176
|
+
const result = await client.generateKey(upstreamRequest);
|
|
177
|
+
logger.info('key.generate', { userId: user.userId, keyAlias: input.alias });
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { LiteLLMClient } from '../client';
|
|
2
|
+
/**
|
|
3
|
+
* Returns the requested models that the allow-list does not permit. The list is
|
|
4
|
+
* the team's `models` (or the user's, without a team); entries may be literal
|
|
5
|
+
* model names, access-group names, or the `all-proxy-models` sentinel — the same
|
|
6
|
+
* rules the key form applies. Model metadata (for access groups) is only fetched
|
|
7
|
+
* when a literal comparison isn't enough.
|
|
8
|
+
*/
|
|
9
|
+
export declare function findDisallowedModels(client: Pick<LiteLLMClient, 'listModels'>, requested: string[], allowed: string[] | undefined): Promise<string[]>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.findDisallowedModels = findDisallowedModels;
|
|
4
|
+
const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
|
|
5
|
+
/**
|
|
6
|
+
* Returns the requested models that the allow-list does not permit. The list is
|
|
7
|
+
* the team's `models` (or the user's, without a team); entries may be literal
|
|
8
|
+
* model names, access-group names, or the `all-proxy-models` sentinel — the same
|
|
9
|
+
* rules the key form applies. Model metadata (for access groups) is only fetched
|
|
10
|
+
* when a literal comparison isn't enough.
|
|
11
|
+
*/
|
|
12
|
+
async function findDisallowedModels(client, requested, allowed) {
|
|
13
|
+
if (!allowed || allowed.length === 0 || allowed.includes(backstage_plugin_litellm_common_1.ALL_PROXY_MODELS)) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
const notLiteral = requested.filter(m => !allowed.includes(m));
|
|
17
|
+
if (notLiteral.length === 0)
|
|
18
|
+
return [];
|
|
19
|
+
// Fail closed: if the model catalogue can't be read the error propagates.
|
|
20
|
+
const catalogue = await client.listModels();
|
|
21
|
+
// /model/info returns one row per deployment; a model name can appear several
|
|
22
|
+
// times with different access groups, so merge them per name.
|
|
23
|
+
const byName = new Map();
|
|
24
|
+
for (const m of catalogue) {
|
|
25
|
+
const entry = byName.get(m.model_name) ?? { model_name: m.model_name, access_groups: [] };
|
|
26
|
+
entry.access_groups = [...new Set([...entry.access_groups, ...(m.access_groups ?? [])])];
|
|
27
|
+
byName.set(m.model_name, entry);
|
|
28
|
+
}
|
|
29
|
+
return notLiteral.filter(name => !(0, backstage_plugin_litellm_common_1.isModelAllowed)(byName.get(name) ?? { model_name: name }, allowed));
|
|
30
|
+
}
|