@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.
Files changed (53) hide show
  1. package/config.d.ts +124 -22
  2. package/dist/bridge.d.ts +43 -17
  3. package/dist/bridge.js +69 -13
  4. package/dist/client.d.ts +7 -2
  5. package/dist/client.js +35 -9
  6. package/dist/errors.d.ts +64 -0
  7. package/dist/errors.js +205 -0
  8. package/dist/http/respondTeam.d.ts +15 -0
  9. package/dist/http/respondTeam.js +27 -0
  10. package/dist/http/teamFetch.d.ts +14 -0
  11. package/dist/http/teamFetch.js +44 -0
  12. package/dist/index.cjs.js +4264 -3373
  13. package/dist/index.cjs.js.map +4 -4
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +15 -13
  16. package/dist/openapi.js +17 -0
  17. package/dist/plugin.js +2 -2
  18. package/dist/provisioning.d.ts +17 -11
  19. package/dist/provisioning.js +36 -22
  20. package/dist/router.d.ts +3 -4
  21. package/dist/router.js +188 -1349
  22. package/dist/routes/bridge.d.ts +8 -0
  23. package/dist/routes/bridge.js +135 -0
  24. package/dist/routes/config.d.ts +7 -0
  25. package/dist/routes/config.js +89 -0
  26. package/dist/routes/context.d.ts +55 -0
  27. package/dist/routes/context.js +2 -0
  28. package/dist/routes/keys.d.ts +8 -0
  29. package/dist/routes/keys.js +471 -0
  30. package/dist/routes/middleware/withUser.d.ts +18 -0
  31. package/dist/routes/middleware/withUser.js +41 -0
  32. package/dist/routes/objectPermissions.d.ts +3 -0
  33. package/dist/routes/objectPermissions.js +196 -0
  34. package/dist/routes/opencode.d.ts +3 -0
  35. package/dist/routes/opencode.js +239 -0
  36. package/dist/routes/teamUsage.d.ts +3 -0
  37. package/dist/routes/teamUsage.js +73 -0
  38. package/dist/routes/teams.d.ts +3 -0
  39. package/dist/routes/teams.js +367 -0
  40. package/dist/services/keyService.d.ts +61 -0
  41. package/dist/services/keyService.js +179 -0
  42. package/dist/services/modelAccess.d.ts +9 -0
  43. package/dist/services/modelAccess.js +30 -0
  44. package/dist/services/userInfoCache.d.ts +65 -0
  45. package/dist/services/userInfoCache.js +147 -0
  46. package/dist/teamAdmin.d.ts +2 -2
  47. package/dist/teamBudgetVisibility.d.ts +2 -1
  48. package/dist/teamBudgetVisibility.js +5 -5
  49. package/dist/types.cjs.js.map +1 -1
  50. package/dist/types.d.ts +8 -197
  51. package/package.json +6 -3
  52. package/dist/permissions.d.ts +0 -11
  53. 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
+ }