@acarmisc/backstage-plugin-litellm-backend 0.1.16 → 0.2.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.
@@ -0,0 +1,339 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ProvisioningError = void 0;
4
+ exports.toLiteLLMUserId = toLiteLLMUserId;
5
+ exports.readRoleConfigs = readRoleConfigs;
6
+ exports.applyRoleOverrides = applyRoleOverrides;
7
+ exports.readProvisioningDefaults = readProvisioningDefaults;
8
+ exports.resolveUserId = resolveUserId;
9
+ exports.resolveUserProfile = resolveUserProfile;
10
+ exports.provisionUser = provisionUser;
11
+ exports.getOrProvisionUser = getOrProvisionUser;
12
+ exports.resolveUserRole = resolveUserRole;
13
+ /**
14
+ * Converts a Backstage user entity ref to a LiteLLM user_id.
15
+ *
16
+ * When userIdDomain is configured, the entity name is suffixed with the domain
17
+ * so that LiteLLM user_ids match the organisation's email addresses:
18
+ * "user:default/andrea.carmisciano" + "abstract.it"
19
+ * → "andrea.carmisciano@abstract.it"
20
+ *
21
+ * Without a domain the bare entity name is returned unchanged, which works for
22
+ * deployments where LiteLLM users were created with plain usernames.
23
+ */
24
+ function toLiteLLMUserId(userEntityRef, userIdDomain) {
25
+ const name = userEntityRef.split('/').pop() ?? userEntityRef;
26
+ // Defensive: if the entity name is already email-shaped (e.g. when the
27
+ // Keycloak provider imports usernames as full emails without our
28
+ // name-rewrite transformer running, or when a catalog change leaves
29
+ // an entity ref like "user:default/foo@bar.it"), do NOT append the
30
+ // userIdDomain — that produced "foo@bar.it@bar.it" in production.
31
+ if (name.includes('@'))
32
+ return name;
33
+ return userIdDomain ? `${name}@${userIdDomain}` : name;
34
+ }
35
+ /**
36
+ * Reads the provisioning block from config, applying safe defaults for every
37
+ * field so the feature works out-of-the-box without any YAML required.
38
+ *
39
+ * Safe defaults rationale:
40
+ * maxBudget: $10 — prevents runaway spend on a forgotten test account
41
+ * budgetDuration: 30d — monthly reset, aligns with typical billing cycles
42
+ * models: [] — empty means all proxy models are allowed;
43
+ * restrict here or at team level for tighter control
44
+ * teams: [] — no automatic team assignment; add IDs to enrol users
45
+ * tpmLimit: none — LiteLLM global / team limits still apply
46
+ * rpmLimit: none — same
47
+ * metadata: backstage source tag only
48
+ */
49
+ function readRoleConfigs(config) {
50
+ const raw = config.getOptional('litellm.provisioning.roles');
51
+ if (!raw?.length)
52
+ return [];
53
+ return raw.map((r) => ({
54
+ group: r.group,
55
+ maxBudget: r.maxBudget,
56
+ budgetDuration: r.budgetDuration,
57
+ models: r.models,
58
+ teams: r.teams,
59
+ tpmLimit: r.tpmLimit,
60
+ rpmLimit: r.rpmLimit,
61
+ userRole: r.userRole,
62
+ metadata: r.metadata,
63
+ }));
64
+ }
65
+ /**
66
+ * Merges role config over defaults. Role fields override defaults only when explicitly set.
67
+ */
68
+ function applyRoleOverrides(defaults, role) {
69
+ return {
70
+ maxBudget: role.maxBudget ?? defaults.maxBudget,
71
+ budgetDuration: role.budgetDuration ?? defaults.budgetDuration,
72
+ models: role.models ?? defaults.models,
73
+ teams: role.teams ?? defaults.teams,
74
+ tpmLimit: role.tpmLimit ?? defaults.tpmLimit,
75
+ rpmLimit: role.rpmLimit ?? defaults.rpmLimit,
76
+ userRole: role.userRole ?? defaults.userRole,
77
+ metadata: { ...defaults.metadata, ...(role.metadata ?? {}) },
78
+ };
79
+ }
80
+ function readProvisioningDefaults(config) {
81
+ const enabled = config.getOptionalBoolean('litellm.provisioning.enabled') ?? false;
82
+ const defaults = {
83
+ maxBudget: config.getOptionalNumber('litellm.provisioning.defaults.maxBudget') ?? 10,
84
+ budgetDuration: config.getOptionalString('litellm.provisioning.defaults.budgetDuration') ?? '30d',
85
+ models: config.getOptionalStringArray('litellm.provisioning.defaults.models') ??
86
+ [],
87
+ teams: config.getOptionalStringArray('litellm.provisioning.defaults.teams') ??
88
+ [],
89
+ tpmLimit: config.getOptionalNumber('litellm.provisioning.defaults.tpmLimit'),
90
+ rpmLimit: config.getOptionalNumber('litellm.provisioning.defaults.rpmLimit'),
91
+ userRole: config.getOptionalString('litellm.provisioning.defaults.userRole') ??
92
+ 'internal_user',
93
+ metadata: config.getOptional('litellm.provisioning.defaults.metadata') ?? {},
94
+ };
95
+ return { enabled, defaults };
96
+ }
97
+ /**
98
+ * Extracts the authenticated Backstage user identity from the request token.
99
+ * Returns the userEntityRef (e.g. "user:default/john.doe") or undefined when
100
+ * the request carries no user credential (service-to-service calls).
101
+ */
102
+ async function resolveUserId(req, auth) {
103
+ const rawToken = req.headers.authorization?.slice(7);
104
+ if (!rawToken)
105
+ return undefined;
106
+ try {
107
+ const credentials = await auth.authenticate(rawToken);
108
+ const principal = credentials.principal;
109
+ if (principal?.type === 'user') {
110
+ return principal.userEntityRef;
111
+ }
112
+ }
113
+ catch {
114
+ // invalid or service token — caller gets query-param fallback
115
+ }
116
+ return undefined;
117
+ }
118
+ /**
119
+ * Looks up the catalog User entity for the authenticated user and returns
120
+ * the profile block. Returns an empty object when the user has no catalog
121
+ * entity (e.g. dangerouslyAllowSignInWithoutUserInCatalog was used) — the
122
+ * caller falls back to deriving identity from userIdDomain.
123
+ */
124
+ async function resolveUserProfile(userEntityRef, catalogClient, auth, logger) {
125
+ try {
126
+ const { token } = await auth.getPluginRequestToken({
127
+ onBehalfOf: await auth.getOwnServiceCredentials(),
128
+ targetPluginId: 'catalog',
129
+ });
130
+ const entity = await catalogClient.getEntityByRef(userEntityRef, { token });
131
+ const profile = entity?.spec?.profile ?? {};
132
+ return {
133
+ email: profile.email,
134
+ displayName: profile.displayName,
135
+ };
136
+ }
137
+ catch (err) {
138
+ logger.warn(`Could not fetch catalog profile for ${userEntityRef}: ${err.message}`);
139
+ return {};
140
+ }
141
+ }
142
+ /**
143
+ * Creates a LiteLLM user for the given Backstage identity using the configured
144
+ * defaults. Returns the UserInfo of the newly created account.
145
+ */
146
+ async function provisionUser(client, userId, defaults, profile, backstageEntity, logger) {
147
+ const payload = {
148
+ user_id: userId,
149
+ ...(profile.email && { user_email: profile.email }),
150
+ ...(profile.displayName && { user_alias: profile.displayName }),
151
+ max_budget: defaults.maxBudget,
152
+ budget_duration: defaults.budgetDuration,
153
+ models: defaults.models,
154
+ teams: defaults.teams,
155
+ ...(defaults.tpmLimit !== undefined && { tpm_limit: defaults.tpmLimit }),
156
+ ...(defaults.rpmLimit !== undefined && { rpm_limit: defaults.rpmLimit }),
157
+ ...(defaults.userRole && { user_role: defaults.userRole }),
158
+ auto_create_key: false,
159
+ metadata: {
160
+ ...defaults.metadata,
161
+ provisioned_by: 'backstage',
162
+ provisioned_at: new Date().toISOString(),
163
+ backstage_entity: backstageEntity ?? userId,
164
+ ...(profile.email && { backstage_email: profile.email }),
165
+ ...(profile.displayName && {
166
+ backstage_display_name: profile.displayName,
167
+ }),
168
+ },
169
+ };
170
+ logger.info(`Provisioning new LiteLLM user for Backstage identity: ${userId}` +
171
+ (profile.email ? ` (email=${profile.email})` : ''));
172
+ try {
173
+ await client.createUser(payload);
174
+ // Defensive /user/update: LiteLLM's /user/new upsert path has been
175
+ // observed to drop user_role under concurrent inserts (the first
176
+ // call sets the field, a racing second call upserts and clears it).
177
+ // Re-asserting the role-bearing fields immediately after creation
178
+ // is cheap and makes the role guarantee robust.
179
+ if (defaults.userRole) {
180
+ try {
181
+ await client.updateUser({
182
+ user_id: userId,
183
+ user_role: defaults.userRole,
184
+ ...(profile.email && { user_email: profile.email }),
185
+ ...(profile.displayName && { user_alias: profile.displayName }),
186
+ });
187
+ }
188
+ catch (updateErr) {
189
+ logger.warn(`Defensive /user/update after provisioning ${userId} failed: ${updateErr.message}`);
190
+ }
191
+ }
192
+ // Fetch the freshly-created user record to return consistent UserInfo shape
193
+ return await client.getUserInfo(userId);
194
+ }
195
+ catch (err) {
196
+ logger.error(`Failed to provision LiteLLM user ${userId}: ${err.message}`);
197
+ throw err;
198
+ }
199
+ }
200
+ /**
201
+ * Module-scope single-flight cache keyed by LiteLLM user_id. Coalesces
202
+ * concurrent provisioning attempts for the same user so /user/new fires
203
+ * at most once per user across parallel requests. Without this, an
204
+ * authenticated page load that fires /keys, /teams and /usage in
205
+ * parallel triggers three concurrent /user/new calls; LiteLLM's
206
+ * upsert path then creates one default key per call (so the user lands
207
+ * with 3 unexpected keys) and may silently lose user_role.
208
+ *
209
+ * Cache entries are removed once the promise settles, so subsequent
210
+ * requests for a re-deleted user can still trigger fresh provisioning.
211
+ */
212
+ const provisioningInFlight = new Map();
213
+ /**
214
+ * Strips any echoed Authorization bearer token from upstream LiteLLM error
215
+ * messages before they're shipped back to the browser. LiteLLM normally does
216
+ * not echo the master key, but defense in depth: never let a `Bearer …`
217
+ * substring travel out in a response body.
218
+ */
219
+ function sanitizeUpstreamMessage(message) {
220
+ if (!message)
221
+ return 'unknown error';
222
+ return message
223
+ .replace(/Bearer\s+[A-Za-z0-9._\-+/=]+/g, 'Bearer [redacted]')
224
+ .replace(/sk-[A-Za-z0-9_\-]{8,}/g, 'sk-[redacted]')
225
+ .slice(0, 500);
226
+ }
227
+ class ProvisioningError extends Error {
228
+ constructor(message, hint, provisioning, status = 404) {
229
+ super(message);
230
+ this.status = status;
231
+ this.body = { error: message, hint, provisioning };
232
+ }
233
+ }
234
+ exports.ProvisioningError = ProvisioningError;
235
+ /**
236
+ * Ensures the LiteLLM user exists, returning its UserInfo.
237
+ * When the user is missing and provisioning is enabled, attempts to create it.
238
+ * When provisioning is disabled, throws a ProvisioningError with a clear message.
239
+ */
240
+ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger) {
241
+ if (!userId) {
242
+ throw new ProvisioningError('User not found in LiteLLM', 'No user identity could be resolved from the request.', provisioningEnabled);
243
+ }
244
+ const existing = await client.getUserInfo(userId);
245
+ if (existing) {
246
+ return existing;
247
+ }
248
+ if (!provisioningEnabled) {
249
+ throw new ProvisioningError('User not found in LiteLLM', 'Enable litellm.provisioning.enabled in app-config.yaml or create the user manually', false);
250
+ }
251
+ // Single-flight: if another request for the same userId is already
252
+ // provisioning, await its result instead of starting a new /user/new.
253
+ // This collapses the /keys + /teams + /usage page-load thundering
254
+ // herd into a single LiteLLM round-trip.
255
+ const pending = provisioningInFlight.get(userId);
256
+ if (pending) {
257
+ logger.info(`LiteLLM provisioning already in flight for ${userId} — joining`);
258
+ return pending;
259
+ }
260
+ const provisionPromise = (async () => {
261
+ const catalogRef = tokenEntityRef ?? userId;
262
+ const [matchedRole, profile] = await Promise.all([
263
+ resolveUserRole(catalogRef, roleConfigs, catalogClient, auth, logger),
264
+ tokenEntityRef
265
+ ? resolveUserProfile(tokenEntityRef, catalogClient, auth, logger)
266
+ : Promise.resolve({}),
267
+ ]);
268
+ const effectiveDefaults = matchedRole
269
+ ? applyRoleOverrides(provisioningDefaults, matchedRole)
270
+ : provisioningDefaults;
271
+ if (matchedRole) {
272
+ logger.info(`User ${userId} matched role group ${matchedRole.group} — using role-specific provisioning`);
273
+ }
274
+ try {
275
+ const created = await provisionUser(client, userId, effectiveDefaults, profile, tokenEntityRef, logger);
276
+ if (!created) {
277
+ throw new ProvisioningError('User not found in LiteLLM', 'Provisioning attempted but returned no user — check LiteLLM logs', true);
278
+ }
279
+ return created;
280
+ }
281
+ catch (err) {
282
+ // The single-flight cache should prevent the parallel-409 race,
283
+ // but keep the recovery path: if /user/new still 409s (e.g.
284
+ // multi-replica deploys where the lock is per-process), treat
285
+ // it as "user exists" and re-fetch.
286
+ if (err.status === 409 || /already exists/i.test(err.message ?? '')) {
287
+ logger.info(`LiteLLM user ${userId} already exists during provisioning — re-fetching`);
288
+ const refetched = await client.getUserInfo(userId);
289
+ if (refetched) {
290
+ return refetched;
291
+ }
292
+ }
293
+ if (err instanceof ProvisioningError) {
294
+ throw err;
295
+ }
296
+ // Map upstream LiteLLM status to a Backstage-safe gateway status.
297
+ // 401/403/5xx from LiteLLM mean the gateway (this plugin) cannot
298
+ // talk to LiteLLM — they MUST NOT propagate as 401/403 to the
299
+ // browser, otherwise Backstage's fetch middleware treats the
300
+ // user's Backstage session as expired and forces a re-login.
301
+ // Only safe client-semantic codes pass through.
302
+ const upstreamStatus = err.status;
303
+ const passThrough = [400, 404, 409, 422].includes(upstreamStatus)
304
+ ? upstreamStatus
305
+ : 502;
306
+ throw new ProvisioningError('LiteLLM auto-provisioning failed', `LiteLLM upstream ${upstreamStatus ?? 'error'}: ${sanitizeUpstreamMessage(err.message)}`, true, passThrough);
307
+ }
308
+ })();
309
+ provisioningInFlight.set(userId, provisionPromise);
310
+ try {
311
+ return await provisionPromise;
312
+ }
313
+ finally {
314
+ provisioningInFlight.delete(userId);
315
+ }
316
+ }
317
+ /**
318
+ * Fetches the user's Backstage group memberships and returns the first matching
319
+ * role config (priority order), or undefined when no role matches.
320
+ */
321
+ async function resolveUserRole(userEntityRef, roleConfigs, catalogClient, auth, logger) {
322
+ if (!roleConfigs.length)
323
+ return undefined;
324
+ try {
325
+ const { token } = await auth.getPluginRequestToken({
326
+ onBehalfOf: await auth.getOwnServiceCredentials(),
327
+ targetPluginId: 'catalog',
328
+ });
329
+ const entity = await catalogClient.getEntityByRef(userEntityRef, { token });
330
+ const groups = (entity?.relations ?? [])
331
+ .filter(r => r.type === 'memberOf')
332
+ .map(r => r.targetRef);
333
+ return roleConfigs.find(rc => groups.includes(rc.group));
334
+ }
335
+ catch (err) {
336
+ logger.warn(`Could not resolve Backstage groups for ${userEntityRef}: ${err.message}`);
337
+ return undefined;
338
+ }
339
+ }
@@ -0,0 +1,12 @@
1
+ import { Router } from 'express';
2
+ import { Config } from '@backstage/config';
3
+ import { AuthService, DiscoveryService } from '@backstage/backend-plugin-api';
4
+ import { ProvisioningError } from './provisioning';
5
+ export { ProvisioningError };
6
+ export interface RouterOptions {
7
+ config: Config;
8
+ logger: any;
9
+ auth: AuthService;
10
+ discovery: DiscoveryService;
11
+ }
12
+ export declare function createRouter(options: RouterOptions): Promise<Router>;
package/dist/router.js ADDED
@@ -0,0 +1,274 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ProvisioningError = void 0;
37
+ exports.createRouter = createRouter;
38
+ const express_1 = __importStar(require("express"));
39
+ const catalog_client_1 = require("@backstage/catalog-client");
40
+ const client_1 = require("./client");
41
+ const provisioning_1 = require("./provisioning");
42
+ Object.defineProperty(exports, "ProvisioningError", { enumerable: true, get: function () { return provisioning_1.ProvisioningError; } });
43
+ async function createRouter(options) {
44
+ const { config, logger, auth, discovery } = options;
45
+ const baseUrl = config.getString('litellm.baseUrl');
46
+ const masterKey = config.getString('litellm.masterKey');
47
+ const userIdDomain = config.getOptionalString('litellm.userIdDomain');
48
+ const client = new client_1.LiteLLMClient({ baseUrl, masterKey });
49
+ const { enabled: provisioningEnabled, defaults: provisioningDefaults } = (0, provisioning_1.readProvisioningDefaults)(config);
50
+ const roleConfigs = (0, provisioning_1.readRoleConfigs)(config);
51
+ const catalogClient = new catalog_client_1.CatalogClient({ discoveryApi: discovery });
52
+ if (provisioningEnabled) {
53
+ logger.info(`LiteLLM auto-provisioning enabled — defaults: budget=$${provisioningDefaults.maxBudget}/${provisioningDefaults.budgetDuration}, models=${provisioningDefaults.models.length
54
+ ? provisioningDefaults.models.join(',')
55
+ : 'all'}, teams=[${provisioningDefaults.teams.join(',')}]`);
56
+ }
57
+ const router = (0, express_1.Router)();
58
+ // JSON body parser. Without this, every POST/PUT endpoint sees an empty
59
+ // req.body. Backstage's httpRouter does not apply a body parser at the
60
+ // plugin-router level, so each plugin must attach its own.
61
+ router.use(express_1.default.json());
62
+ router.get('/health', (_req, res) => {
63
+ res.json({ status: 'ok', provisioning: provisioningEnabled });
64
+ });
65
+ router.get('/user/info', async (req, res) => {
66
+ try {
67
+ const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
68
+ const userId = tokenEntityRef
69
+ ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
70
+ : req.query.user_id;
71
+ const userInfo = await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
72
+ res.json(userInfo);
73
+ }
74
+ catch (error) {
75
+ if (error instanceof provisioning_1.ProvisioningError) {
76
+ res.status(error.status).json(error.body);
77
+ return;
78
+ }
79
+ logger.error('Failed to fetch user info', error);
80
+ res.status(500).json({ error: error.message });
81
+ }
82
+ });
83
+ router.get('/keys', async (req, res) => {
84
+ try {
85
+ const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
86
+ const userId = tokenEntityRef
87
+ ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
88
+ : req.query.user_id;
89
+ await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
90
+ const keys = await client.listKeys(userId);
91
+ res.json(keys);
92
+ }
93
+ catch (error) {
94
+ if (error instanceof provisioning_1.ProvisioningError) {
95
+ res.status(error.status).json(error.body);
96
+ return;
97
+ }
98
+ logger.error('Failed to list keys', error);
99
+ res.status(500).json({ error: error.message });
100
+ }
101
+ });
102
+ router.post('/keys/generate', async (req, res) => {
103
+ try {
104
+ // Only alias + max_budget are required. An empty models array is
105
+ // intentional — in LiteLLM `models: []` means "all models the user
106
+ // can access" which is the desired default. Forcing a selection
107
+ // up front is too restrictive for the common case.
108
+ const body = (req.body ?? {});
109
+ const missing = [];
110
+ if (!body.alias?.trim())
111
+ missing.push('alias');
112
+ if (typeof body.max_budget !== 'number' || body.max_budget <= 0) {
113
+ missing.push('max_budget (positive number)');
114
+ }
115
+ if (missing.length) {
116
+ res.status(400).json({
117
+ error: 'Missing required fields',
118
+ hint: `Required: ${missing.join(', ')}`,
119
+ });
120
+ return;
121
+ }
122
+ const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
123
+ const resolvedUserId = tokenEntityRef
124
+ ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
125
+ : undefined;
126
+ if (resolvedUserId) {
127
+ await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, resolvedUserId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
128
+ }
129
+ // Stamp ownership into LiteLLM key metadata. LiteLLM's native
130
+ // `created_by` column is only populated when the caller authenticates
131
+ // via JWT/SSO; we always call with the master key, so that column
132
+ // stays null. Enriching `metadata` makes the owner identity visible
133
+ // in LiteLLM's UI and queryable via API.
134
+ const profile = tokenEntityRef
135
+ ? await (0, provisioning_1.resolveUserProfile)(tokenEntityRef, catalogClient, auth, logger)
136
+ : {};
137
+ const enrichedMetadata = {
138
+ ...(body.metadata ?? {}),
139
+ created_by_backstage_user: tokenEntityRef ?? 'unknown',
140
+ ...(profile.email && { created_by_email: profile.email }),
141
+ ...(profile.displayName && {
142
+ created_by_display_name: profile.displayName,
143
+ }),
144
+ created_via: 'backstage',
145
+ created_at_iso: new Date().toISOString(),
146
+ };
147
+ const request = {
148
+ ...body,
149
+ metadata: enrichedMetadata,
150
+ ...(resolvedUserId && { user_id: resolvedUserId }),
151
+ };
152
+ const result = await client.generateKey(request);
153
+ res.json(result);
154
+ }
155
+ catch (error) {
156
+ if (error instanceof provisioning_1.ProvisioningError) {
157
+ res.status(error.status).json(error.body);
158
+ return;
159
+ }
160
+ logger.error('Failed to generate key', error);
161
+ res.status(500).json({ error: error.message });
162
+ }
163
+ });
164
+ router.post('/keys/:keyId/update', async (req, res) => {
165
+ try {
166
+ const { keyId } = req.params;
167
+ if (!keyId) {
168
+ res.status(400).json({ error: 'keyId is required' });
169
+ return;
170
+ }
171
+ const request = { ...req.body, key: keyId };
172
+ const result = await client.updateKey(request);
173
+ res.json(result);
174
+ }
175
+ catch (error) {
176
+ logger.error('Failed to update key', error);
177
+ res.status(500).json({ error: error.message });
178
+ }
179
+ });
180
+ router.delete('/keys/:keyId', async (req, res) => {
181
+ try {
182
+ const { keyId } = req.params;
183
+ if (!keyId) {
184
+ res.status(400).json({ error: 'keyId is required' });
185
+ return;
186
+ }
187
+ await client.deleteKeys({ keys: [keyId] });
188
+ res.json({ success: true });
189
+ }
190
+ catch (error) {
191
+ logger.error('Failed to delete key', error);
192
+ res.status(500).json({ error: error.message });
193
+ }
194
+ });
195
+ router.get('/models', async (_req, res) => {
196
+ try {
197
+ const models = await client.listModels();
198
+ res.json(models);
199
+ }
200
+ catch (error) {
201
+ logger.error('Failed to list models', error);
202
+ res.status(500).json({ error: error.message });
203
+ }
204
+ });
205
+ router.get('/teams', async (req, res) => {
206
+ try {
207
+ const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
208
+ const userId = tokenEntityRef
209
+ ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
210
+ : req.query.user_id;
211
+ const userInfo = await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
212
+ if (!userInfo?.teams?.length) {
213
+ res.json([]);
214
+ return;
215
+ }
216
+ const teams = await Promise.all(userInfo.teams.map(teamId => client.getTeamInfo(teamId).catch(err => {
217
+ logger.warn(`Failed to fetch team ${teamId}: ${err.message}`);
218
+ return null;
219
+ })));
220
+ res.json(teams.filter(Boolean));
221
+ }
222
+ catch (error) {
223
+ if (error instanceof provisioning_1.ProvisioningError) {
224
+ res.status(error.status).json(error.body);
225
+ return;
226
+ }
227
+ logger.error('Failed to fetch teams', error);
228
+ res.status(500).json({ error: error.message });
229
+ }
230
+ });
231
+ router.get('/teams/:teamId/usage', async (req, res) => {
232
+ try {
233
+ const { teamId } = req.params;
234
+ const { start_date, end_date } = req.query;
235
+ if (!start_date || !end_date) {
236
+ res.status(400).json({ error: 'start_date and end_date are required' });
237
+ return;
238
+ }
239
+ const usage = await client.getTeamUsage(teamId, start_date, end_date);
240
+ res.json(usage);
241
+ }
242
+ catch (error) {
243
+ logger.error('Failed to fetch team usage', error);
244
+ res.status(500).json({ error: error.message });
245
+ }
246
+ });
247
+ router.get('/usage', async (req, res) => {
248
+ try {
249
+ const { start_date, end_date, group_by } = req.query;
250
+ if (!start_date || !end_date) {
251
+ res.status(400).json({ error: 'start_date and end_date are required' });
252
+ return;
253
+ }
254
+ const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
255
+ const userId = tokenEntityRef
256
+ ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
257
+ : req.query.user_id;
258
+ if (userId) {
259
+ await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
260
+ }
261
+ const usage = await client.getUsage(start_date, end_date, userId, group_by);
262
+ res.json(usage);
263
+ }
264
+ catch (error) {
265
+ if (error instanceof provisioning_1.ProvisioningError) {
266
+ res.status(error.status).json(error.body);
267
+ return;
268
+ }
269
+ logger.error('Failed to fetch usage', error);
270
+ res.status(500).json({ error: error.message });
271
+ }
272
+ });
273
+ return router;
274
+ }