@acarmisc/backstage-plugin-litellm-backend 0.3.5 → 0.4.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/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
@@ -71,6 +75,14 @@ export declare class LiteLLMClient {
71
75
  * or `mode`.
72
76
  */
73
77
  listModels(): Promise<ModelInfo[]>;
78
+ /**
79
+ * LiteLLM's `/team/info` wraps the team row inside `team_info` (alongside
80
+ * sibling `keys` / `team_memberships` arrays), the same shape as
81
+ * `/user/info` wrapping the user row inside `user_info`. Without unwrapping,
82
+ * `team_alias`, `members_with_roles`, `models`, budgets and limits are all
83
+ * undefined, so the UI fell back to displaying the raw team_id / "Untitled
84
+ * team".
85
+ */
74
86
  getTeamInfo(teamId: string): Promise<TeamInfo>;
75
87
  private emptyUsage;
76
88
  /**
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
@@ -223,8 +261,27 @@ class LiteLLMClient {
223
261
  }))
224
262
  .filter((m) => m.model_name);
225
263
  }
264
+ /**
265
+ * LiteLLM's `/team/info` wraps the team row inside `team_info` (alongside
266
+ * sibling `keys` / `team_memberships` arrays), the same shape as
267
+ * `/user/info` wrapping the user row inside `user_info`. Without unwrapping,
268
+ * `team_alias`, `members_with_roles`, `models`, budgets and limits are all
269
+ * undefined, so the UI fell back to displaying the raw team_id / "Untitled
270
+ * team".
271
+ */
226
272
  async getTeamInfo(teamId) {
227
- return this.request(`/team/info?team_id=${encodeURIComponent(teamId)}`);
273
+ const raw = await this.request(`/team/info?team_id=${encodeURIComponent(teamId)}`);
274
+ const inner = raw?.team_info ?? {};
275
+ return {
276
+ team_id: raw?.team_id ?? inner.team_id ?? teamId,
277
+ team_alias: inner.team_alias ?? raw?.team_alias,
278
+ max_budget: inner.max_budget ?? raw?.max_budget,
279
+ spend: inner.spend ?? raw?.spend ?? 0,
280
+ members_with_roles: inner.members_with_roles ?? raw?.members_with_roles,
281
+ models: inner.models ?? raw?.models,
282
+ tpm_limit: inner.tpm_limit ?? raw?.tpm_limit,
283
+ rpm_limit: inner.rpm_limit ?? raw?.rpm_limit,
284
+ };
228
285
  }
229
286
  emptyUsage() {
230
287
  return {
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
@@ -273,10 +302,29 @@ var LiteLLMClient = class {
273
302
  supports_vision: m.supports_vision
274
303
  })).filter((m) => m.model_name);
275
304
  }
305
+ /**
306
+ * LiteLLM's `/team/info` wraps the team row inside `team_info` (alongside
307
+ * sibling `keys` / `team_memberships` arrays), the same shape as
308
+ * `/user/info` wrapping the user row inside `user_info`. Without unwrapping,
309
+ * `team_alias`, `members_with_roles`, `models`, budgets and limits are all
310
+ * undefined, so the UI fell back to displaying the raw team_id / "Untitled
311
+ * team".
312
+ */
276
313
  async getTeamInfo(teamId) {
277
- return this.request(
314
+ const raw = await this.request(
278
315
  `/team/info?team_id=${encodeURIComponent(teamId)}`
279
316
  );
317
+ const inner = raw?.team_info ?? {};
318
+ return {
319
+ team_id: raw?.team_id ?? inner.team_id ?? teamId,
320
+ team_alias: inner.team_alias ?? raw?.team_alias,
321
+ max_budget: inner.max_budget ?? raw?.max_budget,
322
+ spend: inner.spend ?? raw?.spend ?? 0,
323
+ members_with_roles: inner.members_with_roles ?? raw?.members_with_roles,
324
+ models: inner.models ?? raw?.models,
325
+ tpm_limit: inner.tpm_limit ?? raw?.tpm_limit,
326
+ rpm_limit: inner.rpm_limit ?? raw?.rpm_limit
327
+ };
280
328
  }
281
329
  emptyUsage() {
282
330
  return {
@@ -665,6 +713,20 @@ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEn
665
713
  provisioningInFlight.delete(userId);
666
714
  }
667
715
  }
716
+ async function isUserMemberOfGroup(userEntityRef, group, catalogClient, auth, logger) {
717
+ try {
718
+ const { token } = await auth.getPluginRequestToken({
719
+ onBehalfOf: await auth.getOwnServiceCredentials(),
720
+ targetPluginId: "catalog"
721
+ });
722
+ const entity = await catalogClient.getEntityByRef(userEntityRef, { token });
723
+ const groups = (entity?.relations ?? []).filter((r) => r.type === "memberOf").map((r) => r.targetRef);
724
+ return groups.includes(group);
725
+ } catch (err) {
726
+ logger.warn(`Could not check group membership for ${userEntityRef}: ${err.message}`);
727
+ return false;
728
+ }
729
+ }
668
730
  async function resolveUserRole(userEntityRef, roleConfigs, catalogClient, auth, logger) {
669
731
  if (!roleConfigs.length) return void 0;
670
732
  try {
@@ -2183,6 +2245,7 @@ async function createRouter(options) {
2183
2245
  const client = options.client ?? new LiteLLMClient({ baseUrl, masterKey });
2184
2246
  const { enabled: provisioningEnabled, defaults: provisioningDefaults } = readProvisioningDefaults(config);
2185
2247
  const roleConfigs = readRoleConfigs(config);
2248
+ const auditGroup = config.getOptionalString("litellm.audit.group");
2186
2249
  const catalogClient = new import_catalog_client.CatalogClient({ discoveryApi: discovery });
2187
2250
  if (provisioningEnabled) {
2188
2251
  logger.info(
@@ -2209,7 +2272,14 @@ async function createRouter(options) {
2209
2272
  auth,
2210
2273
  logger
2211
2274
  );
2212
- res.json(userInfo);
2275
+ const canViewAudit = auditGroup && tokenEntityRef ? await isUserMemberOfGroup(
2276
+ tokenEntityRef,
2277
+ auditGroup,
2278
+ catalogClient,
2279
+ auth,
2280
+ logger
2281
+ ) : false;
2282
+ res.json({ ...userInfo, can_view_audit: canViewAudit });
2213
2283
  } catch (error) {
2214
2284
  if (error instanceof ProvisioningError) {
2215
2285
  res.status(error.status).json(error.body);
@@ -2245,6 +2315,22 @@ async function createRouter(options) {
2245
2315
  res.status(500).json({ error: error.message });
2246
2316
  }
2247
2317
  });
2318
+ router.post("/keys/:keyId/regenerate", async (req, res) => {
2319
+ try {
2320
+ const { keyId } = req.params;
2321
+ if (!keyId) {
2322
+ res.status(400).json({ error: "keyId is required" });
2323
+ return;
2324
+ }
2325
+ const tokenEntityRef = await resolveUserId(req, auth);
2326
+ const result = await client.regenerateKey(keyId);
2327
+ logger.info({ action: "key.rotate", userId: tokenEntityRef ?? "unknown", keyId });
2328
+ res.json(result);
2329
+ } catch (error) {
2330
+ logger.error("Failed to rotate key", error);
2331
+ res.status(500).json({ error: error.message });
2332
+ }
2333
+ });
2248
2334
  router.post("/keys/generate", async (req, res) => {
2249
2335
  try {
2250
2336
  const body = req.body ?? {};
@@ -2292,6 +2378,7 @@ async function createRouter(options) {
2292
2378
  ...resolvedUserId && { user_id: resolvedUserId }
2293
2379
  };
2294
2380
  const result = await client.generateKey(request);
2381
+ logger.info({ action: "key.generate", userId: resolvedUserId ?? "unknown", keyAlias: body.alias });
2295
2382
  res.json(result);
2296
2383
  } catch (error) {
2297
2384
  if (error instanceof ProvisioningError) {
@@ -2309,8 +2396,10 @@ async function createRouter(options) {
2309
2396
  res.status(400).json({ error: "keyId is required" });
2310
2397
  return;
2311
2398
  }
2399
+ const tokenEntityRef = await resolveUserId(req, auth);
2312
2400
  const request = { ...req.body, key: keyId };
2313
2401
  const result = await client.updateKey(request);
2402
+ logger.info({ action: "key.update", userId: tokenEntityRef ?? "unknown", keyId });
2314
2403
  res.json(result);
2315
2404
  } catch (error) {
2316
2405
  logger.error("Failed to update key", error);
@@ -2324,13 +2413,89 @@ async function createRouter(options) {
2324
2413
  res.status(400).json({ error: "keyId is required" });
2325
2414
  return;
2326
2415
  }
2416
+ const deleteEntityRef = await resolveUserId(req, auth);
2327
2417
  await client.deleteKeys({ keys: [keyId] });
2418
+ logger.info({ action: "key.delete", userId: deleteEntityRef ?? "unknown", keyId });
2328
2419
  res.json({ success: true });
2329
2420
  } catch (error) {
2330
2421
  logger.error("Failed to delete key", error);
2331
2422
  res.status(500).json({ error: error.message });
2332
2423
  }
2333
2424
  });
2425
+ router.post("/keys/:keyId/block", async (req, res) => {
2426
+ try {
2427
+ const { keyId } = req.params;
2428
+ const tokenEntityRef = await resolveUserId(req, auth);
2429
+ await client.blockKey(keyId);
2430
+ logger.info({ action: "key.block", userId: tokenEntityRef ?? "unknown", keyId });
2431
+ res.json({ success: true });
2432
+ } catch (error) {
2433
+ logger.error("Failed to block key", error);
2434
+ res.status(500).json({ error: error.message });
2435
+ }
2436
+ });
2437
+ router.post("/keys/:keyId/unblock", async (req, res) => {
2438
+ try {
2439
+ const { keyId } = req.params;
2440
+ const tokenEntityRef = await resolveUserId(req, auth);
2441
+ await client.unblockKey(keyId);
2442
+ logger.info({ action: "key.unblock", userId: tokenEntityRef ?? "unknown", keyId });
2443
+ res.json({ success: true });
2444
+ } catch (error) {
2445
+ logger.error("Failed to unblock key", error);
2446
+ res.status(500).json({ error: error.message });
2447
+ }
2448
+ });
2449
+ router.post("/keys/:keyId/reset_spend", async (req, res) => {
2450
+ try {
2451
+ const { keyId } = req.params;
2452
+ const tokenEntityRef = await resolveUserId(req, auth);
2453
+ await client.resetKeySpend(keyId);
2454
+ logger.info({ action: "key.reset_spend", userId: tokenEntityRef ?? "unknown", keyId });
2455
+ res.json({ success: true });
2456
+ } catch (error) {
2457
+ logger.error("Failed to reset key spend", error);
2458
+ res.status(500).json({ error: error.message });
2459
+ }
2460
+ });
2461
+ router.get("/audit", async (req, res) => {
2462
+ if (!auditGroup) {
2463
+ res.status(403).json({ error: "Audit log is not configured (litellm.audit.group not set)" });
2464
+ return;
2465
+ }
2466
+ const tokenEntityRef = await resolveUserId(req, auth);
2467
+ if (!tokenEntityRef) {
2468
+ res.status(401).json({ error: "Authentication required" });
2469
+ return;
2470
+ }
2471
+ const allowed = await isUserMemberOfGroup(
2472
+ tokenEntityRef,
2473
+ auditGroup,
2474
+ catalogClient,
2475
+ auth,
2476
+ logger
2477
+ );
2478
+ if (!allowed) {
2479
+ res.status(403).json({ error: "Access denied: not a member of the audit group" });
2480
+ return;
2481
+ }
2482
+ try {
2483
+ const { page, page_size, start_date, end_date, action, table_name, changed_by } = req.query;
2484
+ const result = await client.getAuditLogs({
2485
+ page: page ? Number(page) : void 0,
2486
+ page_size: page_size ? Number(page_size) : 25,
2487
+ start_date,
2488
+ end_date,
2489
+ action,
2490
+ table_name,
2491
+ changed_by
2492
+ });
2493
+ res.json(result);
2494
+ } catch (error) {
2495
+ logger.error("Failed to fetch audit logs", error);
2496
+ res.status(500).json({ error: error.message });
2497
+ }
2498
+ });
2334
2499
  router.get("/models", async (_req, res) => {
2335
2500
  try {
2336
2501
  const models = await client.listModels();