@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 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
- res.json(userInfo);
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();