@acarmisc/backstage-plugin-litellm-backend 0.4.2 → 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/config.d.ts CHANGED
@@ -6,6 +6,14 @@ export interface Config {
6
6
  */
7
7
  baseUrl: string;
8
8
 
9
+ /**
10
+ * Publicly reachable LiteLLM proxy URL. Used to build ready-to-paste
11
+ * curl / OpenAI-SDK snippets in the frontend ("Key Generated" dialog).
12
+ * When omitted, the internal baseUrl is used instead.
13
+ * @visibility backend
14
+ */
15
+ publicBaseUrl?: string;
16
+
9
17
  /**
10
18
  * LiteLLM master key for admin operations. Never exposed to the frontend.
11
19
  * @visibility secret
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;
@@ -2242,6 +2512,7 @@ async function createRouter(options) {
2242
2512
  const baseUrl = config.getString("litellm.baseUrl");
2243
2513
  const masterKey = config.getString("litellm.masterKey");
2244
2514
  const userIdDomain = config.getOptionalString("litellm.userIdDomain");
2515
+ const publicBaseUrl = config.getOptionalString("litellm.publicBaseUrl") ?? baseUrl;
2245
2516
  const client = options.client ?? new LiteLLMClient({ baseUrl, masterKey });
2246
2517
  const { enabled: provisioningEnabled, defaults: provisioningDefaults } = readProvisioningDefaults(config);
2247
2518
  const roleConfigs = readRoleConfigs(config);
@@ -2257,6 +2528,60 @@ async function createRouter(options) {
2257
2528
  router.get("/health", (_req, res) => {
2258
2529
  res.json({ status: "ok", provisioning: provisioningEnabled });
2259
2530
  });
2531
+ router.get("/config", (_req, res) => {
2532
+ res.json({ baseUrl: publicBaseUrl });
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
+ });
2260
2585
  router.get("/user/info", async (req, res) => {
2261
2586
  try {
2262
2587
  const tokenEntityRef = await resolveUserId(req, auth);
@@ -2315,6 +2640,26 @@ async function createRouter(options) {
2315
2640
  res.status(500).json({ error: error.message });
2316
2641
  }
2317
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
+ }
2318
2663
  router.post("/keys/:keyId/regenerate", async (req, res) => {
2319
2664
  try {
2320
2665
  const { keyId } = req.params;
@@ -2322,11 +2667,12 @@ async function createRouter(options) {
2322
2667
  res.status(400).json({ error: "keyId is required" });
2323
2668
  return;
2324
2669
  }
2325
- const tokenEntityRef = await resolveUserId(req, auth);
2670
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2326
2671
  const result = await client.regenerateKey(keyId);
2327
2672
  logger.info({ action: "key.rotate", userId: tokenEntityRef ?? "unknown", keyId });
2328
2673
  res.json(result);
2329
2674
  } catch (error) {
2675
+ if (sendOwnershipError(error, res)) return;
2330
2676
  logger.error("Failed to rotate key", error);
2331
2677
  res.status(500).json({ error: error.message });
2332
2678
  }
@@ -2336,8 +2682,8 @@ async function createRouter(options) {
2336
2682
  const body = req.body ?? {};
2337
2683
  const missing = [];
2338
2684
  if (!body.alias?.trim()) missing.push("alias");
2339
- if (typeof body.max_budget !== "number" || body.max_budget <= 0) {
2340
- missing.push("max_budget (positive number)");
2685
+ if (body.max_budget !== null && body.max_budget !== void 0 && (typeof body.max_budget !== "number" || body.max_budget <= 0)) {
2686
+ missing.push("max_budget (positive number, or null for unlimited)");
2341
2687
  }
2342
2688
  if (missing.length) {
2343
2689
  res.status(400).json({
@@ -2396,12 +2742,13 @@ async function createRouter(options) {
2396
2742
  res.status(400).json({ error: "keyId is required" });
2397
2743
  return;
2398
2744
  }
2399
- const tokenEntityRef = await resolveUserId(req, auth);
2745
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2400
2746
  const request = { ...req.body, key: keyId };
2401
2747
  const result = await client.updateKey(request);
2402
2748
  logger.info({ action: "key.update", userId: tokenEntityRef ?? "unknown", keyId });
2403
2749
  res.json(result);
2404
2750
  } catch (error) {
2751
+ if (sendOwnershipError(error, res)) return;
2405
2752
  logger.error("Failed to update key", error);
2406
2753
  res.status(500).json({ error: error.message });
2407
2754
  }
@@ -2413,11 +2760,12 @@ async function createRouter(options) {
2413
2760
  res.status(400).json({ error: "keyId is required" });
2414
2761
  return;
2415
2762
  }
2416
- const deleteEntityRef = await resolveUserId(req, auth);
2763
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2417
2764
  await client.deleteKeys({ keys: [keyId] });
2418
- logger.info({ action: "key.delete", userId: deleteEntityRef ?? "unknown", keyId });
2765
+ logger.info({ action: "key.delete", userId: tokenEntityRef ?? "unknown", keyId });
2419
2766
  res.json({ success: true });
2420
2767
  } catch (error) {
2768
+ if (sendOwnershipError(error, res)) return;
2421
2769
  logger.error("Failed to delete key", error);
2422
2770
  res.status(500).json({ error: error.message });
2423
2771
  }
@@ -2425,11 +2773,12 @@ async function createRouter(options) {
2425
2773
  router.post("/keys/:keyId/block", async (req, res) => {
2426
2774
  try {
2427
2775
  const { keyId } = req.params;
2428
- const tokenEntityRef = await resolveUserId(req, auth);
2776
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2429
2777
  await client.blockKey(keyId);
2430
2778
  logger.info({ action: "key.block", userId: tokenEntityRef ?? "unknown", keyId });
2431
2779
  res.json({ success: true });
2432
2780
  } catch (error) {
2781
+ if (sendOwnershipError(error, res)) return;
2433
2782
  logger.error("Failed to block key", error);
2434
2783
  res.status(500).json({ error: error.message });
2435
2784
  }
@@ -2437,11 +2786,12 @@ async function createRouter(options) {
2437
2786
  router.post("/keys/:keyId/unblock", async (req, res) => {
2438
2787
  try {
2439
2788
  const { keyId } = req.params;
2440
- const tokenEntityRef = await resolveUserId(req, auth);
2789
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2441
2790
  await client.unblockKey(keyId);
2442
2791
  logger.info({ action: "key.unblock", userId: tokenEntityRef ?? "unknown", keyId });
2443
2792
  res.json({ success: true });
2444
2793
  } catch (error) {
2794
+ if (sendOwnershipError(error, res)) return;
2445
2795
  logger.error("Failed to unblock key", error);
2446
2796
  res.status(500).json({ error: error.message });
2447
2797
  }
@@ -2449,11 +2799,12 @@ async function createRouter(options) {
2449
2799
  router.post("/keys/:keyId/reset_spend", async (req, res) => {
2450
2800
  try {
2451
2801
  const { keyId } = req.params;
2452
- const tokenEntityRef = await resolveUserId(req, auth);
2802
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
2453
2803
  await client.resetKeySpend(keyId);
2454
2804
  logger.info({ action: "key.reset_spend", userId: tokenEntityRef ?? "unknown", keyId });
2455
2805
  res.json({ success: true });
2456
2806
  } catch (error) {
2807
+ if (sendOwnershipError(error, res)) return;
2457
2808
  logger.error("Failed to reset key spend", error);
2458
2809
  res.status(500).json({ error: error.message });
2459
2810
  }
@@ -2563,7 +2914,7 @@ async function createRouter(options) {
2563
2914
  });
2564
2915
  router.get("/usage", async (req, res) => {
2565
2916
  try {
2566
- const { start_date, end_date, group_by } = req.query;
2917
+ const { start_date, end_date } = req.query;
2567
2918
  if (!start_date || !end_date) {
2568
2919
  res.status(400).json({ error: "start_date and end_date are required" });
2569
2920
  return;
@@ -2586,8 +2937,7 @@ async function createRouter(options) {
2586
2937
  const usage = await client.getUsage(
2587
2938
  start_date,
2588
2939
  end_date,
2589
- userId,
2590
- group_by
2940
+ userId
2591
2941
  );
2592
2942
  res.json(usage);
2593
2943
  } catch (error) {