@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 +8 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +8 -1
- package/dist/index.cjs.js +364 -14
- package/dist/index.cjs.js.map +3 -3
- package/dist/openapi.d.ts +21 -0
- package/dist/openapi.js +284 -0
- package/dist/router.js +129 -15
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2763
|
+
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
2417
2764
|
await client.deleteKeys({ keys: [keyId] });
|
|
2418
|
-
logger.info({ action: "key.delete", userId:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) {
|