@acarmisc/backstage-plugin-litellm-backend 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.d.ts +15 -0
- package/dist/client.d.ts +5 -1
- package/dist/client.js +41 -3
- package/dist/index.cjs.js +151 -5
- package/dist/index.cjs.js.map +2 -2
- package/dist/provisioning.d.ts +5 -0
- package/dist/provisioning.js +22 -0
- package/dist/router.js +99 -1
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +33 -0
- package/package.json +2 -2
package/config.d.ts
CHANGED
|
@@ -104,6 +104,21 @@ export interface Config {
|
|
|
104
104
|
* list/mint virtual keys without holding the master key. Disabled by
|
|
105
105
|
* default; enable explicitly when the CLI is in use.
|
|
106
106
|
*/
|
|
107
|
+
/**
|
|
108
|
+
* Audit log access control. When set, the /audit tab in the plugin is
|
|
109
|
+
* only visible to members of the specified Backstage group.
|
|
110
|
+
*/
|
|
111
|
+
audit?: {
|
|
112
|
+
/**
|
|
113
|
+
* Backstage group entity ref whose members can view the audit log.
|
|
114
|
+
* The plugin ships a ready-made group at catalog/litellm-admins.yaml —
|
|
115
|
+
* register the root catalog-info.yaml and set this to
|
|
116
|
+
* "group:default/litellm-admins", then add members there.
|
|
117
|
+
* When omitted the audit tab is hidden for all users.
|
|
118
|
+
*/
|
|
119
|
+
group?: string;
|
|
120
|
+
};
|
|
121
|
+
|
|
107
122
|
bridge?: {
|
|
108
123
|
/**
|
|
109
124
|
* When true, mount the /bridge/keys, /bridge/keys (POST), /bridge/models
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LiteLLMConfig, UserInfo, VirtualKey, ModelInfo, UsageMetrics, TeamInfo, GenerateKeyRequest, GenerateKeyResponse, UpdateKeyRequest, DeleteKeyRequest, CreateUserRequest, CreateUserResponse } from './types';
|
|
1
|
+
import { LiteLLMConfig, UserInfo, VirtualKey, ModelInfo, UsageMetrics, TeamInfo, GenerateKeyRequest, GenerateKeyResponse, UpdateKeyRequest, DeleteKeyRequest, CreateUserRequest, CreateUserResponse, AuditLogsParams, PaginatedAuditLogs } from './types';
|
|
2
2
|
export declare class LiteLLMClient {
|
|
3
3
|
private baseUrl;
|
|
4
4
|
private masterKey;
|
|
@@ -53,6 +53,10 @@ export declare class LiteLLMClient {
|
|
|
53
53
|
deleteKeys(request: DeleteKeyRequest): Promise<{
|
|
54
54
|
success: boolean;
|
|
55
55
|
}>;
|
|
56
|
+
blockKey(key: string): Promise<unknown>;
|
|
57
|
+
unblockKey(key: string): Promise<unknown>;
|
|
58
|
+
resetKeySpend(key: string): Promise<unknown>;
|
|
59
|
+
getAuditLogs(params: AuditLogsParams): Promise<PaginatedAuditLogs>;
|
|
56
60
|
/**
|
|
57
61
|
* Rotates an existing key in place, returning a fresh `sk-` secret while
|
|
58
62
|
* keeping the same alias/budget/limits. `token` is the hashed token LiteLLM
|
package/dist/client.js
CHANGED
|
@@ -116,9 +116,6 @@ class LiteLLMClient {
|
|
|
116
116
|
}
|
|
117
117
|
toVirtualKey(k) {
|
|
118
118
|
return {
|
|
119
|
-
// The hashed `token` never leaves LiteLLM in a usable form; the
|
|
120
|
-
// masked `key_name` ("sk-...XXXX") is what the UI displays. Fall
|
|
121
|
-
// back to `token` only when `key_name` is missing.
|
|
122
119
|
key: k.key_name ?? k.token,
|
|
123
120
|
token: k.token,
|
|
124
121
|
key_alias: k.key_alias ?? undefined,
|
|
@@ -130,6 +127,7 @@ class LiteLLMClient {
|
|
|
130
127
|
rpm_limit: k.rpm_limit ?? undefined,
|
|
131
128
|
models: k.models ?? [],
|
|
132
129
|
user_id: k.user_id ?? undefined,
|
|
130
|
+
blocked: k.blocked ?? undefined,
|
|
133
131
|
};
|
|
134
132
|
}
|
|
135
133
|
/**
|
|
@@ -166,6 +164,46 @@ class LiteLLMClient {
|
|
|
166
164
|
body: JSON.stringify(request),
|
|
167
165
|
});
|
|
168
166
|
}
|
|
167
|
+
async blockKey(key) {
|
|
168
|
+
return this.request('/key/block', {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
body: JSON.stringify({ key }),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async unblockKey(key) {
|
|
174
|
+
return this.request('/key/unblock', {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
body: JSON.stringify({ key }),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
async resetKeySpend(key) {
|
|
180
|
+
return this.request(`/key/${encodeURIComponent(key)}/reset_spend`, {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
body: JSON.stringify({ reset_to: 0 }),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
async getAuditLogs(params) {
|
|
186
|
+
const query = new URLSearchParams();
|
|
187
|
+
if (params.page !== undefined)
|
|
188
|
+
query.set('page', String(params.page));
|
|
189
|
+
if (params.page_size !== undefined)
|
|
190
|
+
query.set('page_size', String(params.page_size));
|
|
191
|
+
if (params.start_date)
|
|
192
|
+
query.set('start_date', params.start_date);
|
|
193
|
+
if (params.end_date)
|
|
194
|
+
query.set('end_date', params.end_date);
|
|
195
|
+
if (params.action)
|
|
196
|
+
query.set('action', params.action);
|
|
197
|
+
if (params.table_name)
|
|
198
|
+
query.set('table_name', params.table_name);
|
|
199
|
+
if (params.changed_by)
|
|
200
|
+
query.set('changed_by', params.changed_by);
|
|
201
|
+
if (params.sort_by)
|
|
202
|
+
query.set('sort_by', params.sort_by);
|
|
203
|
+
if (params.sort_order)
|
|
204
|
+
query.set('sort_order', params.sort_order);
|
|
205
|
+
return this.request(`/audit?${query.toString()}`);
|
|
206
|
+
}
|
|
169
207
|
/**
|
|
170
208
|
* Rotates an existing key in place, returning a fresh `sk-` secret while
|
|
171
209
|
* keeping the same alias/budget/limits. `token` is the hashed token LiteLLM
|
package/dist/index.cjs.js
CHANGED
|
@@ -172,9 +172,6 @@ var LiteLLMClient = class {
|
|
|
172
172
|
}
|
|
173
173
|
toVirtualKey(k) {
|
|
174
174
|
return {
|
|
175
|
-
// The hashed `token` never leaves LiteLLM in a usable form; the
|
|
176
|
-
// masked `key_name` ("sk-...XXXX") is what the UI displays. Fall
|
|
177
|
-
// back to `token` only when `key_name` is missing.
|
|
178
175
|
key: k.key_name ?? k.token,
|
|
179
176
|
token: k.token,
|
|
180
177
|
key_alias: k.key_alias ?? void 0,
|
|
@@ -185,7 +182,8 @@ var LiteLLMClient = class {
|
|
|
185
182
|
tpm_limit: k.tpm_limit ?? void 0,
|
|
186
183
|
rpm_limit: k.rpm_limit ?? void 0,
|
|
187
184
|
models: k.models ?? [],
|
|
188
|
-
user_id: k.user_id ?? void 0
|
|
185
|
+
user_id: k.user_id ?? void 0,
|
|
186
|
+
blocked: k.blocked ?? void 0
|
|
189
187
|
};
|
|
190
188
|
}
|
|
191
189
|
/**
|
|
@@ -222,6 +220,37 @@ var LiteLLMClient = class {
|
|
|
222
220
|
body: JSON.stringify(request)
|
|
223
221
|
});
|
|
224
222
|
}
|
|
223
|
+
async blockKey(key) {
|
|
224
|
+
return this.request("/key/block", {
|
|
225
|
+
method: "POST",
|
|
226
|
+
body: JSON.stringify({ key })
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
async unblockKey(key) {
|
|
230
|
+
return this.request("/key/unblock", {
|
|
231
|
+
method: "POST",
|
|
232
|
+
body: JSON.stringify({ key })
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
async resetKeySpend(key) {
|
|
236
|
+
return this.request(`/key/${encodeURIComponent(key)}/reset_spend`, {
|
|
237
|
+
method: "POST",
|
|
238
|
+
body: JSON.stringify({ reset_to: 0 })
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
async getAuditLogs(params) {
|
|
242
|
+
const query = new URLSearchParams();
|
|
243
|
+
if (params.page !== void 0) query.set("page", String(params.page));
|
|
244
|
+
if (params.page_size !== void 0) query.set("page_size", String(params.page_size));
|
|
245
|
+
if (params.start_date) query.set("start_date", params.start_date);
|
|
246
|
+
if (params.end_date) query.set("end_date", params.end_date);
|
|
247
|
+
if (params.action) query.set("action", params.action);
|
|
248
|
+
if (params.table_name) query.set("table_name", params.table_name);
|
|
249
|
+
if (params.changed_by) query.set("changed_by", params.changed_by);
|
|
250
|
+
if (params.sort_by) query.set("sort_by", params.sort_by);
|
|
251
|
+
if (params.sort_order) query.set("sort_order", params.sort_order);
|
|
252
|
+
return this.request(`/audit?${query.toString()}`);
|
|
253
|
+
}
|
|
225
254
|
/**
|
|
226
255
|
* Rotates an existing key in place, returning a fresh `sk-` secret while
|
|
227
256
|
* keeping the same alias/budget/limits. `token` is the hashed token LiteLLM
|
|
@@ -665,6 +694,20 @@ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEn
|
|
|
665
694
|
provisioningInFlight.delete(userId);
|
|
666
695
|
}
|
|
667
696
|
}
|
|
697
|
+
async function isUserMemberOfGroup(userEntityRef, group, catalogClient, auth, logger) {
|
|
698
|
+
try {
|
|
699
|
+
const { token } = await auth.getPluginRequestToken({
|
|
700
|
+
onBehalfOf: await auth.getOwnServiceCredentials(),
|
|
701
|
+
targetPluginId: "catalog"
|
|
702
|
+
});
|
|
703
|
+
const entity = await catalogClient.getEntityByRef(userEntityRef, { token });
|
|
704
|
+
const groups = (entity?.relations ?? []).filter((r) => r.type === "memberOf").map((r) => r.targetRef);
|
|
705
|
+
return groups.includes(group);
|
|
706
|
+
} catch (err) {
|
|
707
|
+
logger.warn(`Could not check group membership for ${userEntityRef}: ${err.message}`);
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
668
711
|
async function resolveUserRole(userEntityRef, roleConfigs, catalogClient, auth, logger) {
|
|
669
712
|
if (!roleConfigs.length) return void 0;
|
|
670
713
|
try {
|
|
@@ -2183,6 +2226,7 @@ async function createRouter(options) {
|
|
|
2183
2226
|
const client = options.client ?? new LiteLLMClient({ baseUrl, masterKey });
|
|
2184
2227
|
const { enabled: provisioningEnabled, defaults: provisioningDefaults } = readProvisioningDefaults(config);
|
|
2185
2228
|
const roleConfigs = readRoleConfigs(config);
|
|
2229
|
+
const auditGroup = config.getOptionalString("litellm.audit.group");
|
|
2186
2230
|
const catalogClient = new import_catalog_client.CatalogClient({ discoveryApi: discovery });
|
|
2187
2231
|
if (provisioningEnabled) {
|
|
2188
2232
|
logger.info(
|
|
@@ -2209,7 +2253,14 @@ async function createRouter(options) {
|
|
|
2209
2253
|
auth,
|
|
2210
2254
|
logger
|
|
2211
2255
|
);
|
|
2212
|
-
|
|
2256
|
+
const canViewAudit = auditGroup && tokenEntityRef ? await isUserMemberOfGroup(
|
|
2257
|
+
tokenEntityRef,
|
|
2258
|
+
auditGroup,
|
|
2259
|
+
catalogClient,
|
|
2260
|
+
auth,
|
|
2261
|
+
logger
|
|
2262
|
+
) : false;
|
|
2263
|
+
res.json({ ...userInfo, can_view_audit: canViewAudit });
|
|
2213
2264
|
} catch (error) {
|
|
2214
2265
|
if (error instanceof ProvisioningError) {
|
|
2215
2266
|
res.status(error.status).json(error.body);
|
|
@@ -2245,6 +2296,22 @@ async function createRouter(options) {
|
|
|
2245
2296
|
res.status(500).json({ error: error.message });
|
|
2246
2297
|
}
|
|
2247
2298
|
});
|
|
2299
|
+
router.post("/keys/:keyId/regenerate", async (req, res) => {
|
|
2300
|
+
try {
|
|
2301
|
+
const { keyId } = req.params;
|
|
2302
|
+
if (!keyId) {
|
|
2303
|
+
res.status(400).json({ error: "keyId is required" });
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
const tokenEntityRef = await resolveUserId(req, auth);
|
|
2307
|
+
const result = await client.regenerateKey(keyId);
|
|
2308
|
+
logger.info({ action: "key.rotate", userId: tokenEntityRef ?? "unknown", keyId });
|
|
2309
|
+
res.json(result);
|
|
2310
|
+
} catch (error) {
|
|
2311
|
+
logger.error("Failed to rotate key", error);
|
|
2312
|
+
res.status(500).json({ error: error.message });
|
|
2313
|
+
}
|
|
2314
|
+
});
|
|
2248
2315
|
router.post("/keys/generate", async (req, res) => {
|
|
2249
2316
|
try {
|
|
2250
2317
|
const body = req.body ?? {};
|
|
@@ -2292,6 +2359,7 @@ async function createRouter(options) {
|
|
|
2292
2359
|
...resolvedUserId && { user_id: resolvedUserId }
|
|
2293
2360
|
};
|
|
2294
2361
|
const result = await client.generateKey(request);
|
|
2362
|
+
logger.info({ action: "key.generate", userId: resolvedUserId ?? "unknown", keyAlias: body.alias });
|
|
2295
2363
|
res.json(result);
|
|
2296
2364
|
} catch (error) {
|
|
2297
2365
|
if (error instanceof ProvisioningError) {
|
|
@@ -2309,8 +2377,10 @@ async function createRouter(options) {
|
|
|
2309
2377
|
res.status(400).json({ error: "keyId is required" });
|
|
2310
2378
|
return;
|
|
2311
2379
|
}
|
|
2380
|
+
const tokenEntityRef = await resolveUserId(req, auth);
|
|
2312
2381
|
const request = { ...req.body, key: keyId };
|
|
2313
2382
|
const result = await client.updateKey(request);
|
|
2383
|
+
logger.info({ action: "key.update", userId: tokenEntityRef ?? "unknown", keyId });
|
|
2314
2384
|
res.json(result);
|
|
2315
2385
|
} catch (error) {
|
|
2316
2386
|
logger.error("Failed to update key", error);
|
|
@@ -2324,13 +2394,89 @@ async function createRouter(options) {
|
|
|
2324
2394
|
res.status(400).json({ error: "keyId is required" });
|
|
2325
2395
|
return;
|
|
2326
2396
|
}
|
|
2397
|
+
const deleteEntityRef = await resolveUserId(req, auth);
|
|
2327
2398
|
await client.deleteKeys({ keys: [keyId] });
|
|
2399
|
+
logger.info({ action: "key.delete", userId: deleteEntityRef ?? "unknown", keyId });
|
|
2328
2400
|
res.json({ success: true });
|
|
2329
2401
|
} catch (error) {
|
|
2330
2402
|
logger.error("Failed to delete key", error);
|
|
2331
2403
|
res.status(500).json({ error: error.message });
|
|
2332
2404
|
}
|
|
2333
2405
|
});
|
|
2406
|
+
router.post("/keys/:keyId/block", async (req, res) => {
|
|
2407
|
+
try {
|
|
2408
|
+
const { keyId } = req.params;
|
|
2409
|
+
const tokenEntityRef = await resolveUserId(req, auth);
|
|
2410
|
+
await client.blockKey(keyId);
|
|
2411
|
+
logger.info({ action: "key.block", userId: tokenEntityRef ?? "unknown", keyId });
|
|
2412
|
+
res.json({ success: true });
|
|
2413
|
+
} catch (error) {
|
|
2414
|
+
logger.error("Failed to block key", error);
|
|
2415
|
+
res.status(500).json({ error: error.message });
|
|
2416
|
+
}
|
|
2417
|
+
});
|
|
2418
|
+
router.post("/keys/:keyId/unblock", async (req, res) => {
|
|
2419
|
+
try {
|
|
2420
|
+
const { keyId } = req.params;
|
|
2421
|
+
const tokenEntityRef = await resolveUserId(req, auth);
|
|
2422
|
+
await client.unblockKey(keyId);
|
|
2423
|
+
logger.info({ action: "key.unblock", userId: tokenEntityRef ?? "unknown", keyId });
|
|
2424
|
+
res.json({ success: true });
|
|
2425
|
+
} catch (error) {
|
|
2426
|
+
logger.error("Failed to unblock key", error);
|
|
2427
|
+
res.status(500).json({ error: error.message });
|
|
2428
|
+
}
|
|
2429
|
+
});
|
|
2430
|
+
router.post("/keys/:keyId/reset_spend", async (req, res) => {
|
|
2431
|
+
try {
|
|
2432
|
+
const { keyId } = req.params;
|
|
2433
|
+
const tokenEntityRef = await resolveUserId(req, auth);
|
|
2434
|
+
await client.resetKeySpend(keyId);
|
|
2435
|
+
logger.info({ action: "key.reset_spend", userId: tokenEntityRef ?? "unknown", keyId });
|
|
2436
|
+
res.json({ success: true });
|
|
2437
|
+
} catch (error) {
|
|
2438
|
+
logger.error("Failed to reset key spend", error);
|
|
2439
|
+
res.status(500).json({ error: error.message });
|
|
2440
|
+
}
|
|
2441
|
+
});
|
|
2442
|
+
router.get("/audit", async (req, res) => {
|
|
2443
|
+
if (!auditGroup) {
|
|
2444
|
+
res.status(403).json({ error: "Audit log is not configured (litellm.audit.group not set)" });
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2447
|
+
const tokenEntityRef = await resolveUserId(req, auth);
|
|
2448
|
+
if (!tokenEntityRef) {
|
|
2449
|
+
res.status(401).json({ error: "Authentication required" });
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
const allowed = await isUserMemberOfGroup(
|
|
2453
|
+
tokenEntityRef,
|
|
2454
|
+
auditGroup,
|
|
2455
|
+
catalogClient,
|
|
2456
|
+
auth,
|
|
2457
|
+
logger
|
|
2458
|
+
);
|
|
2459
|
+
if (!allowed) {
|
|
2460
|
+
res.status(403).json({ error: "Access denied: not a member of the audit group" });
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
try {
|
|
2464
|
+
const { page, page_size, start_date, end_date, action, table_name, changed_by } = req.query;
|
|
2465
|
+
const result = await client.getAuditLogs({
|
|
2466
|
+
page: page ? Number(page) : void 0,
|
|
2467
|
+
page_size: page_size ? Number(page_size) : 25,
|
|
2468
|
+
start_date,
|
|
2469
|
+
end_date,
|
|
2470
|
+
action,
|
|
2471
|
+
table_name,
|
|
2472
|
+
changed_by
|
|
2473
|
+
});
|
|
2474
|
+
res.json(result);
|
|
2475
|
+
} catch (error) {
|
|
2476
|
+
logger.error("Failed to fetch audit logs", error);
|
|
2477
|
+
res.status(500).json({ error: error.message });
|
|
2478
|
+
}
|
|
2479
|
+
});
|
|
2334
2480
|
router.get("/models", async (_req, res) => {
|
|
2335
2481
|
try {
|
|
2336
2482
|
const models = await client.listModels();
|