@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.
- package/dist/client.d.ts +69 -0
- package/dist/client.js +312 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +26 -0
- package/dist/plugin.d.ts +1 -0
- package/dist/plugin.js +23 -0
- package/dist/provisioning.d.ts +87 -0
- package/dist/provisioning.js +339 -0
- package/dist/router.d.ts +12 -0
- package/dist/router.js +274 -0
- package/dist/types.d.ts +208 -0
- package/dist/types.js +2 -0
- package/package.json +9 -9
|
@@ -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
|
+
}
|
package/dist/router.d.ts
ADDED
|
@@ -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
|
+
}
|