@acarmisc/backstage-plugin-litellm-backend 0.5.0 → 0.6.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/dist/client.d.ts CHANGED
@@ -98,6 +98,6 @@ export declare class LiteLLMClient {
98
98
  * - usage_by_key → which keys drove cost / traffic (with key_alias + team_id from metadata)
99
99
  */
100
100
  private transformDailyActivity;
101
- getUsage(startDate: string, endDate: string, userId?: string, _groupBy?: string): Promise<UsageMetrics>;
101
+ getUsage(startDate: string, endDate: string, userId?: string): Promise<UsageMetrics>;
102
102
  getTeamUsage(teamId: string, startDate: string, endDate: string): Promise<UsageMetrics>;
103
103
  }
package/dist/client.js CHANGED
@@ -12,6 +12,11 @@ class LiteLLMClient {
12
12
  const controller = new AbortController();
13
13
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
14
14
  try {
15
+ // Auth note: the LiteLLM OpenAPI spec declares the security scheme as an
16
+ // `x-litellm-api-key` header, but Bearer + master key is the deliberate,
17
+ // production-tested choice — LiteLLM's proxy accepts both, and Bearer is
18
+ // what every observed deployment actually uses. Do not "correct" this to
19
+ // match the spec literally without testing against a live gateway.
15
20
  const response = await fetch(`${this.baseUrl}${path}`, {
16
21
  ...options,
17
22
  signal: controller.signal,
@@ -237,6 +242,8 @@ class LiteLLMClient {
237
242
  supports_vision: info.supports_vision ?? m.supports_vision,
238
243
  input_cost_per_token: info.input_cost_per_token ?? params.input_cost_per_token,
239
244
  output_cost_per_token: info.output_cost_per_token ?? params.output_cost_per_token,
245
+ max_input_tokens: info.max_input_tokens ?? m.max_input_tokens,
246
+ max_output_tokens: info.max_output_tokens ?? m.max_output_tokens,
240
247
  };
241
248
  });
242
249
  const filtered = normalised.filter(m => m.model_name);
@@ -404,7 +411,7 @@ class LiteLLMClient {
404
411
  daily_by_model,
405
412
  };
406
413
  }
407
- async getUsage(startDate, endDate, userId, _groupBy) {
414
+ async getUsage(startDate, endDate, userId) {
408
415
  const params = new URLSearchParams({
409
416
  start_date: startDate,
410
417
  end_date: endDate,
package/dist/index.cjs.js CHANGED
@@ -286,7 +286,9 @@ var LiteLLMClient = class {
286
286
  supports_function_calling: info.supports_function_calling ?? m.supports_function_calling,
287
287
  supports_vision: info.supports_vision ?? m.supports_vision,
288
288
  input_cost_per_token: info.input_cost_per_token ?? params.input_cost_per_token,
289
- output_cost_per_token: info.output_cost_per_token ?? params.output_cost_per_token
289
+ output_cost_per_token: info.output_cost_per_token ?? params.output_cost_per_token,
290
+ max_input_tokens: info.max_input_tokens ?? m.max_input_tokens,
291
+ max_output_tokens: info.max_output_tokens ?? m.max_output_tokens
290
292
  };
291
293
  });
292
294
  const filtered = normalised.filter((m) => m.model_name);
@@ -440,7 +442,7 @@ var LiteLLMClient = class {
440
442
  daily_by_model
441
443
  };
442
444
  }
443
- async getUsage(startDate, endDate, userId, _groupBy) {
445
+ async getUsage(startDate, endDate, userId) {
444
446
  const params = new URLSearchParams({
445
447
  start_date: startDate,
446
448
  end_date: endDate,
@@ -480,6 +482,274 @@ var LiteLLMClient = class {
480
482
  }
481
483
  };
482
484
 
485
+ // src/openapi.ts
486
+ var openApiSpec = {
487
+ openapi: "3.1.0",
488
+ info: {
489
+ title: "LiteLLM Governance Plugin API",
490
+ version: "0.1.0",
491
+ description: "Backstage backend surface for the LiteLLM governance plugin. UI routes authenticate via the Backstage identity system; CLI bridge routes authenticate with a Keycloak access token."
492
+ },
493
+ servers: [
494
+ { url: "/api/litellm", description: "Backstage backend plugin mount point" }
495
+ ],
496
+ tags: [
497
+ { name: "System", description: "Health and configuration" },
498
+ { name: "User", description: "Current user info and provisioning" },
499
+ { name: "Keys", description: "Virtual key lifecycle" },
500
+ { name: "Models", description: "Model catalogue" },
501
+ { name: "Teams", description: "Team membership and usage" },
502
+ { name: "Usage", description: "Spend and traffic analytics" },
503
+ { name: "Audit", description: "Audit logs (RBAC-gated)" },
504
+ { name: "Provisioning", description: "Provisioning dry-run (RBAC-gated)" },
505
+ { name: "Bridge", description: "CLI bridge (Keycloak-token auth)" }
506
+ ],
507
+ paths: {
508
+ "/health": {
509
+ get: {
510
+ tags: ["System"],
511
+ summary: "Health check",
512
+ responses: {
513
+ "200": {
514
+ description: "Plugin health",
515
+ content: { "application/json": { schema: { type: "object", properties: {
516
+ status: { type: "string" },
517
+ provisioning: { type: "boolean" }
518
+ } } } }
519
+ }
520
+ }
521
+ }
522
+ },
523
+ "/config": {
524
+ get: {
525
+ tags: ["System"],
526
+ summary: "Public LiteLLM proxy base URL (for snippet generation)",
527
+ responses: {
528
+ "200": {
529
+ description: "Proxy URL",
530
+ content: { "application/json": { schema: { type: "object", properties: {
531
+ baseUrl: { type: "string" }
532
+ } } } }
533
+ }
534
+ }
535
+ }
536
+ },
537
+ "/openapi.json": {
538
+ get: {
539
+ tags: ["System"],
540
+ summary: "This OpenAPI document",
541
+ responses: { "200": { description: "OpenAPI 3.1 JSON" } }
542
+ }
543
+ },
544
+ "/user/info": {
545
+ get: {
546
+ tags: ["User"],
547
+ summary: "Get the current user's info and quotas",
548
+ responses: {
549
+ "200": { description: "User info" },
550
+ "404": { description: "User not found in LiteLLM (provisioning disabled)" }
551
+ }
552
+ }
553
+ },
554
+ "/keys": {
555
+ get: {
556
+ tags: ["Keys"],
557
+ summary: "List the caller's virtual keys",
558
+ responses: { "200": { description: "Array of keys" } }
559
+ }
560
+ },
561
+ "/keys/generate": {
562
+ post: {
563
+ tags: ["Keys"],
564
+ summary: "Generate a new virtual key",
565
+ requestBody: {
566
+ required: true,
567
+ content: { "application/json": { schema: { type: "object", properties: {
568
+ alias: { type: "string" },
569
+ models: { type: "array", items: { type: "string" } },
570
+ team_id: { type: "string" },
571
+ duration: { type: "string" },
572
+ max_budget: { type: "number", nullable: true, description: "Positive number caps spend; null = unlimited" },
573
+ tpm_limit: { type: "number" },
574
+ rpm_limit: { type: "number" },
575
+ auto_rotate: { type: "boolean" },
576
+ rotation_interval_days: { type: "number" }
577
+ } } } }
578
+ },
579
+ responses: {
580
+ "200": { description: "Generated key (includes the raw secret \u2014 shown once)" },
581
+ "400": { description: "Missing required fields" }
582
+ }
583
+ }
584
+ },
585
+ "/keys/{keyId}": {
586
+ delete: {
587
+ tags: ["Keys"],
588
+ summary: "Revoke/delete a virtual key (caller must own it)",
589
+ parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
590
+ responses: { "200": { description: "Deleted" }, "403": { description: "Not the key owner" } }
591
+ }
592
+ },
593
+ "/keys/{keyId}/regenerate": {
594
+ post: {
595
+ tags: ["Keys"],
596
+ summary: "Rotate a key in place (caller must own it)",
597
+ parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
598
+ responses: { "200": { description: "New secret" }, "403": { description: "Not the key owner" } }
599
+ }
600
+ },
601
+ "/keys/{keyId}/update": {
602
+ post: {
603
+ tags: ["Keys"],
604
+ summary: "Update alias / models / budget / limits (caller must own it)",
605
+ parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
606
+ requestBody: { content: { "application/json": { schema: { type: "object" } } } },
607
+ responses: { "200": { description: "Updated" }, "403": { description: "Not the key owner" } }
608
+ }
609
+ },
610
+ "/keys/{keyId}/block": {
611
+ post: {
612
+ tags: ["Keys"],
613
+ summary: "Suspend a key without revoking (caller must own it)",
614
+ parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
615
+ responses: { "200": { description: "Blocked" }, "403": { description: "Not the key owner" } }
616
+ }
617
+ },
618
+ "/keys/{keyId}/unblock": {
619
+ post: {
620
+ tags: ["Keys"],
621
+ summary: "Re-enable a blocked key (caller must own it)",
622
+ parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
623
+ responses: { "200": { description: "Unblocked" }, "403": { description: "Not the key owner" } }
624
+ }
625
+ },
626
+ "/keys/{keyId}/reset_spend": {
627
+ post: {
628
+ tags: ["Keys"],
629
+ summary: "Zero out a key spend counter (caller must own it)",
630
+ parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
631
+ responses: { "200": { description: "Spend reset" }, "403": { description: "Not the key owner" } }
632
+ }
633
+ },
634
+ "/models": {
635
+ get: {
636
+ tags: ["Models"],
637
+ summary: "List available LLM models",
638
+ responses: { "200": { description: "Model catalogue" } }
639
+ }
640
+ },
641
+ "/teams": {
642
+ get: {
643
+ tags: ["Teams"],
644
+ summary: "List teams the current user belongs to",
645
+ responses: { "200": { description: "Array of teams" } }
646
+ }
647
+ },
648
+ "/teams/{teamId}/usage": {
649
+ get: {
650
+ tags: ["Teams"],
651
+ summary: "Usage metrics for a team",
652
+ parameters: [
653
+ { name: "teamId", in: "path", required: true, schema: { type: "string" } },
654
+ { name: "start_date", in: "query", required: true, schema: { type: "string" } },
655
+ { name: "end_date", in: "query", required: true, schema: { type: "string" } }
656
+ ],
657
+ responses: { "200": { description: "Team usage" }, "400": { description: "Missing date range" } }
658
+ }
659
+ },
660
+ "/usage": {
661
+ get: {
662
+ tags: ["Usage"],
663
+ summary: "Usage metrics for the current user",
664
+ parameters: [
665
+ { name: "start_date", in: "query", required: true, schema: { type: "string" } },
666
+ { name: "end_date", in: "query", required: true, schema: { type: "string" } }
667
+ ],
668
+ responses: { "200": { description: "Usage metrics" }, "400": { description: "Missing date range" } }
669
+ }
670
+ },
671
+ "/audit": {
672
+ get: {
673
+ tags: ["Audit"],
674
+ summary: "Audit logs (gated by litellm.audit.group membership)",
675
+ parameters: [
676
+ { name: "page", in: "query", schema: { type: "integer" } },
677
+ { name: "page_size", in: "query", schema: { type: "integer" } },
678
+ { name: "start_date", in: "query", schema: { type: "string" } },
679
+ { name: "end_date", in: "query", schema: { type: "string" } },
680
+ { name: "action", in: "query", schema: { type: "string" } },
681
+ { name: "table_name", in: "query", schema: { type: "string" } },
682
+ { name: "changed_by", in: "query", schema: { type: "string" } }
683
+ ],
684
+ responses: { "200": { description: "Paginated audit logs" }, "403": { description: "Not configured or not authorized" } }
685
+ }
686
+ },
687
+ "/provisioning/preview": {
688
+ get: {
689
+ tags: ["Provisioning"],
690
+ summary: "Resolve which role a Backstage group maps to (dry-run)",
691
+ parameters: [
692
+ { name: "group", in: "query", required: true, schema: { type: "string" }, description: "e.g. group:default/ai-platform" }
693
+ ],
694
+ responses: {
695
+ "200": { description: "Resolved role and effective defaults" },
696
+ "400": { description: "Missing group parameter" },
697
+ "403": { description: "Not configured or not authorized" }
698
+ }
699
+ }
700
+ },
701
+ "/bridge/health": {
702
+ get: {
703
+ tags: ["Bridge"],
704
+ summary: "Bridge health + configured clientId (no auth)",
705
+ responses: { "200": { description: "Bridge health" } }
706
+ }
707
+ },
708
+ "/bridge/keys": {
709
+ get: {
710
+ tags: ["Bridge"],
711
+ summary: "List the caller virtual keys (Keycloak token auth)",
712
+ responses: { "200": { description: "Array of keys" }, "401": { description: "Invalid token" } }
713
+ },
714
+ post: {
715
+ tags: ["Bridge"],
716
+ summary: "Mint a virtual key for the caller (Keycloak token auth)",
717
+ requestBody: { content: { "application/json": { schema: { type: "object" } } } },
718
+ responses: { "200": { description: "Generated key" }, "401": { description: "Invalid token" } }
719
+ }
720
+ },
721
+ "/bridge/keys/regenerate": {
722
+ post: {
723
+ tags: ["Bridge"],
724
+ summary: "Rotate the caller key by alias (Keycloak token auth)",
725
+ requestBody: { content: { "application/json": { schema: { type: "object", properties: { alias: { type: "string" } } } } } },
726
+ responses: { "200": { description: "New secret" }, "401": { description: "Invalid token" }, "404": { description: "No key with that alias" } }
727
+ }
728
+ },
729
+ "/bridge/models": {
730
+ get: {
731
+ tags: ["Bridge"],
732
+ summary: "List available LLM models (Keycloak token auth)",
733
+ responses: { "200": { description: "Model catalogue" }, "401": { description: "Invalid token" } }
734
+ }
735
+ }
736
+ },
737
+ components: {
738
+ securitySchemes: {
739
+ backstageUserToken: {
740
+ type: "http",
741
+ scheme: "bearer",
742
+ description: "Backstage-issued user token (Authorization: Bearer <token>)."
743
+ },
744
+ keycloakAccessToken: {
745
+ type: "http",
746
+ scheme: "bearer",
747
+ description: "Raw Keycloak access token. Bridge routes only; verified against the realm JWKS."
748
+ }
749
+ }
750
+ }
751
+ };
752
+
483
753
  // src/provisioning.ts
484
754
  function toLiteLLMUserId(userEntityRef, userIdDomain) {
485
755
  const name = userEntityRef.split("/").pop() ?? userEntityRef;
@@ -2261,6 +2531,57 @@ async function createRouter(options) {
2261
2531
  router.get("/config", (_req, res) => {
2262
2532
  res.json({ baseUrl: publicBaseUrl });
2263
2533
  });
2534
+ router.get("/openapi.json", (_req, res) => {
2535
+ res.json(openApiSpec);
2536
+ });
2537
+ router.get("/provisioning/preview", async (req, res) => {
2538
+ if (!auditGroup) {
2539
+ res.status(403).json({ error: "Preview is not configured (litellm.audit.group not set)" });
2540
+ return;
2541
+ }
2542
+ const tokenEntityRef = await resolveUserId(req, auth);
2543
+ if (!tokenEntityRef) {
2544
+ res.status(401).json({ error: "Authentication required" });
2545
+ return;
2546
+ }
2547
+ const allowed = await isUserMemberOfGroup(
2548
+ tokenEntityRef,
2549
+ auditGroup,
2550
+ catalogClient,
2551
+ auth,
2552
+ logger
2553
+ );
2554
+ if (!allowed) {
2555
+ res.status(403).json({ error: "Access denied: not a member of the audit group" });
2556
+ return;
2557
+ }
2558
+ const group = req.query.group?.trim();
2559
+ if (!group) {
2560
+ res.status(400).json({ error: "group query parameter is required (e.g. group=group:default/ai-platform)" });
2561
+ return;
2562
+ }
2563
+ if (!roleConfigs.length) {
2564
+ res.json({
2565
+ group,
2566
+ matched_role: null,
2567
+ effective_defaults: provisioningDefaults,
2568
+ note: "No litellm.provisioning.roles configured \u2014 every group receives the base defaults."
2569
+ });
2570
+ return;
2571
+ }
2572
+ try {
2573
+ const matched = roleConfigs.find((rc) => rc.group === group);
2574
+ const effective = matched ? applyRoleOverrides(provisioningDefaults, matched) : provisioningDefaults;
2575
+ res.json({
2576
+ group,
2577
+ matched_role: matched?.group ?? null,
2578
+ effective_defaults: effective
2579
+ });
2580
+ } catch (error) {
2581
+ logger.error("Failed to resolve provisioning preview", error);
2582
+ res.status(500).json({ error: error.message });
2583
+ }
2584
+ });
2264
2585
  router.get("/user/info", async (req, res) => {
2265
2586
  try {
2266
2587
  const tokenEntityRef = await resolveUserId(req, auth);
@@ -2319,6 +2640,26 @@ async function createRouter(options) {
2319
2640
  res.status(500).json({ error: error.message });
2320
2641
  }
2321
2642
  });
2643
+ async function authorizeKeyAction(req, keyId) {
2644
+ const tokenEntityRef = await resolveUserId(req, auth);
2645
+ const userId = tokenEntityRef ? toLiteLLMUserId(tokenEntityRef, userIdDomain) : req.query.user_id;
2646
+ if (!userId) {
2647
+ throw { status: 403, body: { error: "Cannot verify key ownership without an authenticated user" } };
2648
+ }
2649
+ const ownKeys = await client.listKeys(userId);
2650
+ const owns = ownKeys.some((k) => (k.token ?? k.key) === keyId);
2651
+ if (!owns) {
2652
+ throw { status: 403, body: { error: "Access denied: key does not belong to the caller" } };
2653
+ }
2654
+ return { tokenEntityRef, userId };
2655
+ }
2656
+ function sendOwnershipError(err, res) {
2657
+ if (err && typeof err.status === "number" && err.body) {
2658
+ res.status(err.status).json(err.body);
2659
+ return true;
2660
+ }
2661
+ return false;
2662
+ }
2322
2663
  router.post("/keys/:keyId/regenerate", async (req, res) => {
2323
2664
  try {
2324
2665
  const { keyId } = req.params;
@@ -2326,11 +2667,12 @@ async function createRouter(options) {
2326
2667
  res.status(400).json({ error: "keyId is required" });
2327
2668
  return;
2328
2669
  }
2329
- const tokenEntityRef = await resolveUserId(req, auth);
2670
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2330
2671
  const result = await client.regenerateKey(keyId);
2331
2672
  logger.info({ action: "key.rotate", userId: tokenEntityRef ?? "unknown", keyId });
2332
2673
  res.json(result);
2333
2674
  } catch (error) {
2675
+ if (sendOwnershipError(error, res)) return;
2334
2676
  logger.error("Failed to rotate key", error);
2335
2677
  res.status(500).json({ error: error.message });
2336
2678
  }
@@ -2400,12 +2742,13 @@ async function createRouter(options) {
2400
2742
  res.status(400).json({ error: "keyId is required" });
2401
2743
  return;
2402
2744
  }
2403
- const tokenEntityRef = await resolveUserId(req, auth);
2745
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2404
2746
  const request = { ...req.body, key: keyId };
2405
2747
  const result = await client.updateKey(request);
2406
2748
  logger.info({ action: "key.update", userId: tokenEntityRef ?? "unknown", keyId });
2407
2749
  res.json(result);
2408
2750
  } catch (error) {
2751
+ if (sendOwnershipError(error, res)) return;
2409
2752
  logger.error("Failed to update key", error);
2410
2753
  res.status(500).json({ error: error.message });
2411
2754
  }
@@ -2417,11 +2760,12 @@ async function createRouter(options) {
2417
2760
  res.status(400).json({ error: "keyId is required" });
2418
2761
  return;
2419
2762
  }
2420
- const deleteEntityRef = await resolveUserId(req, auth);
2763
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2421
2764
  await client.deleteKeys({ keys: [keyId] });
2422
- logger.info({ action: "key.delete", userId: deleteEntityRef ?? "unknown", keyId });
2765
+ logger.info({ action: "key.delete", userId: tokenEntityRef ?? "unknown", keyId });
2423
2766
  res.json({ success: true });
2424
2767
  } catch (error) {
2768
+ if (sendOwnershipError(error, res)) return;
2425
2769
  logger.error("Failed to delete key", error);
2426
2770
  res.status(500).json({ error: error.message });
2427
2771
  }
@@ -2429,11 +2773,12 @@ async function createRouter(options) {
2429
2773
  router.post("/keys/:keyId/block", async (req, res) => {
2430
2774
  try {
2431
2775
  const { keyId } = req.params;
2432
- const tokenEntityRef = await resolveUserId(req, auth);
2776
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2433
2777
  await client.blockKey(keyId);
2434
2778
  logger.info({ action: "key.block", userId: tokenEntityRef ?? "unknown", keyId });
2435
2779
  res.json({ success: true });
2436
2780
  } catch (error) {
2781
+ if (sendOwnershipError(error, res)) return;
2437
2782
  logger.error("Failed to block key", error);
2438
2783
  res.status(500).json({ error: error.message });
2439
2784
  }
@@ -2441,11 +2786,12 @@ async function createRouter(options) {
2441
2786
  router.post("/keys/:keyId/unblock", async (req, res) => {
2442
2787
  try {
2443
2788
  const { keyId } = req.params;
2444
- const tokenEntityRef = await resolveUserId(req, auth);
2789
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2445
2790
  await client.unblockKey(keyId);
2446
2791
  logger.info({ action: "key.unblock", userId: tokenEntityRef ?? "unknown", keyId });
2447
2792
  res.json({ success: true });
2448
2793
  } catch (error) {
2794
+ if (sendOwnershipError(error, res)) return;
2449
2795
  logger.error("Failed to unblock key", error);
2450
2796
  res.status(500).json({ error: error.message });
2451
2797
  }
@@ -2453,11 +2799,12 @@ async function createRouter(options) {
2453
2799
  router.post("/keys/:keyId/reset_spend", async (req, res) => {
2454
2800
  try {
2455
2801
  const { keyId } = req.params;
2456
- const tokenEntityRef = await resolveUserId(req, auth);
2802
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2457
2803
  await client.resetKeySpend(keyId);
2458
2804
  logger.info({ action: "key.reset_spend", userId: tokenEntityRef ?? "unknown", keyId });
2459
2805
  res.json({ success: true });
2460
2806
  } catch (error) {
2807
+ if (sendOwnershipError(error, res)) return;
2461
2808
  logger.error("Failed to reset key spend", error);
2462
2809
  res.status(500).json({ error: error.message });
2463
2810
  }
@@ -2567,7 +2914,7 @@ async function createRouter(options) {
2567
2914
  });
2568
2915
  router.get("/usage", async (req, res) => {
2569
2916
  try {
2570
- const { start_date, end_date, group_by } = req.query;
2917
+ const { start_date, end_date } = req.query;
2571
2918
  if (!start_date || !end_date) {
2572
2919
  res.status(400).json({ error: "start_date and end_date are required" });
2573
2920
  return;
@@ -2590,8 +2937,7 @@ async function createRouter(options) {
2590
2937
  const usage = await client.getUsage(
2591
2938
  start_date,
2592
2939
  end_date,
2593
- userId,
2594
- group_by
2940
+ userId
2595
2941
  );
2596
2942
  res.json(usage);
2597
2943
  } catch (error) {