@acarmisc/backstage-plugin-litellm-backend 0.5.0 → 0.6.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/dist/bridge.d.ts +0 -10
- package/dist/bridge.js +0 -18
- package/dist/client.d.ts +1 -8
- package/dist/client.js +8 -10
- package/dist/index.cjs.js +337 -82
- package/dist/index.cjs.js.map +3 -3
- package/dist/openapi.d.ts +21 -0
- package/dist/openapi.js +266 -0
- package/dist/router.js +105 -39
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
package/dist/bridge.d.ts
CHANGED
|
@@ -75,13 +75,3 @@ export declare function bridgeListKeys(client: LiteLLMClient, claims: BridgeClai
|
|
|
75
75
|
export declare function bridgeGenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
|
|
76
76
|
info: (...args: unknown[]) => void;
|
|
77
77
|
}, request: Partial<GenerateKeyRequest>, userIdDomain?: string): Promise<GenerateKeyResponse>;
|
|
78
|
-
/**
|
|
79
|
-
* Rotates the caller's key identified by alias, returning a fresh secret. The
|
|
80
|
-
* bridge can't recover a usable secret from a listing (it's masked), so a CLI
|
|
81
|
-
* that lost its cached key but whose stable alias still exists uses this to
|
|
82
|
-
* rotate in place instead of minting a duplicate. Throws 404 when no key with
|
|
83
|
-
* that alias exists for the caller — the CLI then mints one.
|
|
84
|
-
*/
|
|
85
|
-
export declare function bridgeRegenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
|
|
86
|
-
info: (...args: unknown[]) => void;
|
|
87
|
-
}, alias: string, userIdDomain?: string): Promise<GenerateKeyResponse>;
|
package/dist/bridge.js
CHANGED
|
@@ -7,7 +7,6 @@ exports.resolveBridgeUserId = resolveBridgeUserId;
|
|
|
7
7
|
exports.getOrProvisionUserFromClaims = getOrProvisionUserFromClaims;
|
|
8
8
|
exports.bridgeListKeys = bridgeListKeys;
|
|
9
9
|
exports.bridgeGenerateKey = bridgeGenerateKey;
|
|
10
|
-
exports.bridgeRegenerateKey = bridgeRegenerateKey;
|
|
11
10
|
/**
|
|
12
11
|
* Bridge — lets CLI clients (Abby) list/mint LiteLLM virtual keys without ever
|
|
13
12
|
* holding the LiteLLM master key.
|
|
@@ -153,20 +152,3 @@ async function bridgeGenerateKey(client, claims, provisioningEnabled, provisioni
|
|
|
153
152
|
};
|
|
154
153
|
return client.generateKey(enriched);
|
|
155
154
|
}
|
|
156
|
-
/**
|
|
157
|
-
* Rotates the caller's key identified by alias, returning a fresh secret. The
|
|
158
|
-
* bridge can't recover a usable secret from a listing (it's masked), so a CLI
|
|
159
|
-
* that lost its cached key but whose stable alias still exists uses this to
|
|
160
|
-
* rotate in place instead of minting a duplicate. Throws 404 when no key with
|
|
161
|
-
* that alias exists for the caller — the CLI then mints one.
|
|
162
|
-
*/
|
|
163
|
-
async function bridgeRegenerateKey(client, claims, provisioningEnabled, provisioningDefaults, logger, alias, userIdDomain) {
|
|
164
|
-
await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain);
|
|
165
|
-
const userId = resolveBridgeUserId(claims, userIdDomain);
|
|
166
|
-
const keys = await client.listKeys(userId);
|
|
167
|
-
const match = keys.find(k => k.key_alias === alias);
|
|
168
|
-
if (!match) {
|
|
169
|
-
throw new provisioning_1.ProvisioningError('Key not found', `No key with alias "${alias}" for this identity; mint one first.`, false, 404);
|
|
170
|
-
}
|
|
171
|
-
return client.regenerateKey(match.token);
|
|
172
|
-
}
|
package/dist/client.d.ts
CHANGED
|
@@ -57,13 +57,6 @@ export declare class LiteLLMClient {
|
|
|
57
57
|
unblockKey(key: string): Promise<unknown>;
|
|
58
58
|
resetKeySpend(key: string): Promise<unknown>;
|
|
59
59
|
getAuditLogs(params: AuditLogsParams): Promise<PaginatedAuditLogs>;
|
|
60
|
-
/**
|
|
61
|
-
* Rotates an existing key in place, returning a fresh `sk-` secret while
|
|
62
|
-
* keeping the same alias/budget/limits. `token` is the hashed token LiteLLM
|
|
63
|
-
* stores (the same value used for delete/update) — passed in the path because
|
|
64
|
-
* it isn't an `sk-` prefixed secret, so LiteLLM matches it without re-hashing.
|
|
65
|
-
*/
|
|
66
|
-
regenerateKey(token: string): Promise<GenerateKeyResponse>;
|
|
67
60
|
/**
|
|
68
61
|
* Returns the proxy's model catalogue normalised to the ModelInfo shape.
|
|
69
62
|
*
|
|
@@ -98,6 +91,6 @@ export declare class LiteLLMClient {
|
|
|
98
91
|
* - usage_by_key → which keys drove cost / traffic (with key_alias + team_id from metadata)
|
|
99
92
|
*/
|
|
100
93
|
private transformDailyActivity;
|
|
101
|
-
getUsage(startDate: string, endDate: string, userId?: string
|
|
94
|
+
getUsage(startDate: string, endDate: string, userId?: string): Promise<UsageMetrics>;
|
|
102
95
|
getTeamUsage(teamId: string, startDate: string, endDate: string): Promise<UsageMetrics>;
|
|
103
96
|
}
|
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,
|
|
@@ -204,15 +209,6 @@ class LiteLLMClient {
|
|
|
204
209
|
query.set('sort_order', params.sort_order);
|
|
205
210
|
return this.request(`/audit?${query.toString()}`);
|
|
206
211
|
}
|
|
207
|
-
/**
|
|
208
|
-
* Rotates an existing key in place, returning a fresh `sk-` secret while
|
|
209
|
-
* keeping the same alias/budget/limits. `token` is the hashed token LiteLLM
|
|
210
|
-
* stores (the same value used for delete/update) — passed in the path because
|
|
211
|
-
* it isn't an `sk-` prefixed secret, so LiteLLM matches it without re-hashing.
|
|
212
|
-
*/
|
|
213
|
-
async regenerateKey(token) {
|
|
214
|
-
return this.request(`/key/${encodeURIComponent(token)}/regenerate`, { method: 'POST', body: JSON.stringify({}) });
|
|
215
|
-
}
|
|
216
212
|
/**
|
|
217
213
|
* Returns the proxy's model catalogue normalised to the ModelInfo shape.
|
|
218
214
|
*
|
|
@@ -237,6 +233,8 @@ class LiteLLMClient {
|
|
|
237
233
|
supports_vision: info.supports_vision ?? m.supports_vision,
|
|
238
234
|
input_cost_per_token: info.input_cost_per_token ?? params.input_cost_per_token,
|
|
239
235
|
output_cost_per_token: info.output_cost_per_token ?? params.output_cost_per_token,
|
|
236
|
+
max_input_tokens: info.max_input_tokens ?? m.max_input_tokens,
|
|
237
|
+
max_output_tokens: info.max_output_tokens ?? m.max_output_tokens,
|
|
240
238
|
};
|
|
241
239
|
});
|
|
242
240
|
const filtered = normalised.filter(m => m.model_name);
|
|
@@ -404,7 +402,7 @@ class LiteLLMClient {
|
|
|
404
402
|
daily_by_model,
|
|
405
403
|
};
|
|
406
404
|
}
|
|
407
|
-
async getUsage(startDate, endDate, userId
|
|
405
|
+
async getUsage(startDate, endDate, userId) {
|
|
408
406
|
const params = new URLSearchParams({
|
|
409
407
|
start_date: startDate,
|
|
410
408
|
end_date: endDate,
|
package/dist/index.cjs.js
CHANGED
|
@@ -251,18 +251,6 @@ var LiteLLMClient = class {
|
|
|
251
251
|
if (params.sort_order) query.set("sort_order", params.sort_order);
|
|
252
252
|
return this.request(`/audit?${query.toString()}`);
|
|
253
253
|
}
|
|
254
|
-
/**
|
|
255
|
-
* Rotates an existing key in place, returning a fresh `sk-` secret while
|
|
256
|
-
* keeping the same alias/budget/limits. `token` is the hashed token LiteLLM
|
|
257
|
-
* stores (the same value used for delete/update) — passed in the path because
|
|
258
|
-
* it isn't an `sk-` prefixed secret, so LiteLLM matches it without re-hashing.
|
|
259
|
-
*/
|
|
260
|
-
async regenerateKey(token) {
|
|
261
|
-
return this.request(
|
|
262
|
-
`/key/${encodeURIComponent(token)}/regenerate`,
|
|
263
|
-
{ method: "POST", body: JSON.stringify({}) }
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
254
|
/**
|
|
267
255
|
* Returns the proxy's model catalogue normalised to the ModelInfo shape.
|
|
268
256
|
*
|
|
@@ -286,7 +274,9 @@ var LiteLLMClient = class {
|
|
|
286
274
|
supports_function_calling: info.supports_function_calling ?? m.supports_function_calling,
|
|
287
275
|
supports_vision: info.supports_vision ?? m.supports_vision,
|
|
288
276
|
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
|
|
277
|
+
output_cost_per_token: info.output_cost_per_token ?? params.output_cost_per_token,
|
|
278
|
+
max_input_tokens: info.max_input_tokens ?? m.max_input_tokens,
|
|
279
|
+
max_output_tokens: info.max_output_tokens ?? m.max_output_tokens
|
|
290
280
|
};
|
|
291
281
|
});
|
|
292
282
|
const filtered = normalised.filter((m) => m.model_name);
|
|
@@ -440,7 +430,7 @@ var LiteLLMClient = class {
|
|
|
440
430
|
daily_by_model
|
|
441
431
|
};
|
|
442
432
|
}
|
|
443
|
-
async getUsage(startDate, endDate, userId
|
|
433
|
+
async getUsage(startDate, endDate, userId) {
|
|
444
434
|
const params = new URLSearchParams({
|
|
445
435
|
start_date: startDate,
|
|
446
436
|
end_date: endDate,
|
|
@@ -480,6 +470,256 @@ var LiteLLMClient = class {
|
|
|
480
470
|
}
|
|
481
471
|
};
|
|
482
472
|
|
|
473
|
+
// src/openapi.ts
|
|
474
|
+
var openApiSpec = {
|
|
475
|
+
openapi: "3.1.0",
|
|
476
|
+
info: {
|
|
477
|
+
title: "LiteLLM Governance Plugin API",
|
|
478
|
+
version: "0.1.0",
|
|
479
|
+
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."
|
|
480
|
+
},
|
|
481
|
+
servers: [
|
|
482
|
+
{ url: "/api/litellm", description: "Backstage backend plugin mount point" }
|
|
483
|
+
],
|
|
484
|
+
tags: [
|
|
485
|
+
{ name: "System", description: "Health and configuration" },
|
|
486
|
+
{ name: "User", description: "Current user info and provisioning" },
|
|
487
|
+
{ name: "Keys", description: "Virtual key lifecycle" },
|
|
488
|
+
{ name: "Models", description: "Model catalogue" },
|
|
489
|
+
{ name: "Teams", description: "Team membership and usage" },
|
|
490
|
+
{ name: "Usage", description: "Spend and traffic analytics" },
|
|
491
|
+
{ name: "Audit", description: "Audit logs (RBAC-gated)" },
|
|
492
|
+
{ name: "Provisioning", description: "Provisioning dry-run (RBAC-gated)" },
|
|
493
|
+
{ name: "Bridge", description: "CLI bridge (Keycloak-token auth)" }
|
|
494
|
+
],
|
|
495
|
+
paths: {
|
|
496
|
+
"/health": {
|
|
497
|
+
get: {
|
|
498
|
+
tags: ["System"],
|
|
499
|
+
summary: "Health check",
|
|
500
|
+
responses: {
|
|
501
|
+
"200": {
|
|
502
|
+
description: "Plugin health",
|
|
503
|
+
content: { "application/json": { schema: { type: "object", properties: {
|
|
504
|
+
status: { type: "string" },
|
|
505
|
+
provisioning: { type: "boolean" }
|
|
506
|
+
} } } }
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
"/config": {
|
|
512
|
+
get: {
|
|
513
|
+
tags: ["System"],
|
|
514
|
+
summary: "Public LiteLLM proxy base URL (for snippet generation)",
|
|
515
|
+
responses: {
|
|
516
|
+
"200": {
|
|
517
|
+
description: "Proxy URL",
|
|
518
|
+
content: { "application/json": { schema: { type: "object", properties: {
|
|
519
|
+
baseUrl: { type: "string" }
|
|
520
|
+
} } } }
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
"/openapi.json": {
|
|
526
|
+
get: {
|
|
527
|
+
tags: ["System"],
|
|
528
|
+
summary: "This OpenAPI document",
|
|
529
|
+
responses: { "200": { description: "OpenAPI 3.1 JSON" } }
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
"/user/info": {
|
|
533
|
+
get: {
|
|
534
|
+
tags: ["User"],
|
|
535
|
+
summary: "Get the current user's info and quotas",
|
|
536
|
+
responses: {
|
|
537
|
+
"200": { description: "User info" },
|
|
538
|
+
"404": { description: "User not found in LiteLLM (provisioning disabled)" }
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
"/keys": {
|
|
543
|
+
get: {
|
|
544
|
+
tags: ["Keys"],
|
|
545
|
+
summary: "List the caller's virtual keys",
|
|
546
|
+
responses: { "200": { description: "Array of keys" } }
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
"/keys/generate": {
|
|
550
|
+
post: {
|
|
551
|
+
tags: ["Keys"],
|
|
552
|
+
summary: "Generate a new virtual key",
|
|
553
|
+
requestBody: {
|
|
554
|
+
required: true,
|
|
555
|
+
content: { "application/json": { schema: { type: "object", properties: {
|
|
556
|
+
alias: { type: "string" },
|
|
557
|
+
models: { type: "array", items: { type: "string" } },
|
|
558
|
+
team_id: { type: "string" },
|
|
559
|
+
duration: { type: "string" },
|
|
560
|
+
max_budget: { type: "number", nullable: true, description: "Positive number caps spend; null = unlimited" },
|
|
561
|
+
tpm_limit: { type: "number" },
|
|
562
|
+
rpm_limit: { type: "number" }
|
|
563
|
+
} } } }
|
|
564
|
+
},
|
|
565
|
+
responses: {
|
|
566
|
+
"200": { description: "Generated key (includes the raw secret \u2014 shown once)" },
|
|
567
|
+
"400": { description: "Missing required fields" }
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
"/keys/{keyId}": {
|
|
572
|
+
delete: {
|
|
573
|
+
tags: ["Keys"],
|
|
574
|
+
summary: "Revoke/delete a virtual key (caller must own it)",
|
|
575
|
+
parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
|
|
576
|
+
responses: { "200": { description: "Deleted" }, "403": { description: "Not the key owner" } }
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
"/keys/{keyId}/update": {
|
|
580
|
+
post: {
|
|
581
|
+
tags: ["Keys"],
|
|
582
|
+
summary: "Update alias / models / budget / limits (caller must own it)",
|
|
583
|
+
parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
|
|
584
|
+
requestBody: { content: { "application/json": { schema: { type: "object" } } } },
|
|
585
|
+
responses: { "200": { description: "Updated" }, "403": { description: "Not the key owner" } }
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
"/keys/{keyId}/block": {
|
|
589
|
+
post: {
|
|
590
|
+
tags: ["Keys"],
|
|
591
|
+
summary: "Suspend a key without revoking (caller must own it)",
|
|
592
|
+
parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
|
|
593
|
+
responses: { "200": { description: "Blocked" }, "403": { description: "Not the key owner" } }
|
|
594
|
+
}
|
|
595
|
+
},
|
|
596
|
+
"/keys/{keyId}/unblock": {
|
|
597
|
+
post: {
|
|
598
|
+
tags: ["Keys"],
|
|
599
|
+
summary: "Re-enable a blocked key (caller must own it)",
|
|
600
|
+
parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
|
|
601
|
+
responses: { "200": { description: "Unblocked" }, "403": { description: "Not the key owner" } }
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
"/keys/{keyId}/reset_spend": {
|
|
605
|
+
post: {
|
|
606
|
+
tags: ["Keys"],
|
|
607
|
+
summary: "Zero out a key spend counter (caller must own it)",
|
|
608
|
+
parameters: [{ name: "keyId", in: "path", required: true, schema: { type: "string" } }],
|
|
609
|
+
responses: { "200": { description: "Spend reset" }, "403": { description: "Not the key owner" } }
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
"/models": {
|
|
613
|
+
get: {
|
|
614
|
+
tags: ["Models"],
|
|
615
|
+
summary: "List available LLM models",
|
|
616
|
+
responses: { "200": { description: "Model catalogue" } }
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
"/teams": {
|
|
620
|
+
get: {
|
|
621
|
+
tags: ["Teams"],
|
|
622
|
+
summary: "List teams the current user belongs to",
|
|
623
|
+
responses: { "200": { description: "Array of teams" } }
|
|
624
|
+
}
|
|
625
|
+
},
|
|
626
|
+
"/teams/{teamId}/usage": {
|
|
627
|
+
get: {
|
|
628
|
+
tags: ["Teams"],
|
|
629
|
+
summary: "Usage metrics for a team",
|
|
630
|
+
parameters: [
|
|
631
|
+
{ name: "teamId", in: "path", required: true, schema: { type: "string" } },
|
|
632
|
+
{ name: "start_date", in: "query", required: true, schema: { type: "string" } },
|
|
633
|
+
{ name: "end_date", in: "query", required: true, schema: { type: "string" } }
|
|
634
|
+
],
|
|
635
|
+
responses: { "200": { description: "Team usage" }, "400": { description: "Missing date range" } }
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
"/usage": {
|
|
639
|
+
get: {
|
|
640
|
+
tags: ["Usage"],
|
|
641
|
+
summary: "Usage metrics for the current user",
|
|
642
|
+
parameters: [
|
|
643
|
+
{ name: "start_date", in: "query", required: true, schema: { type: "string" } },
|
|
644
|
+
{ name: "end_date", in: "query", required: true, schema: { type: "string" } }
|
|
645
|
+
],
|
|
646
|
+
responses: { "200": { description: "Usage metrics" }, "400": { description: "Missing date range" } }
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
"/audit": {
|
|
650
|
+
get: {
|
|
651
|
+
tags: ["Audit"],
|
|
652
|
+
summary: "Audit logs (gated by litellm.audit.group membership)",
|
|
653
|
+
parameters: [
|
|
654
|
+
{ name: "page", in: "query", schema: { type: "integer" } },
|
|
655
|
+
{ name: "page_size", in: "query", schema: { type: "integer" } },
|
|
656
|
+
{ name: "start_date", in: "query", schema: { type: "string" } },
|
|
657
|
+
{ name: "end_date", in: "query", schema: { type: "string" } },
|
|
658
|
+
{ name: "action", in: "query", schema: { type: "string" } },
|
|
659
|
+
{ name: "table_name", in: "query", schema: { type: "string" } },
|
|
660
|
+
{ name: "changed_by", in: "query", schema: { type: "string" } }
|
|
661
|
+
],
|
|
662
|
+
responses: { "200": { description: "Paginated audit logs" }, "403": { description: "Not configured or not authorized" } }
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
"/provisioning/preview": {
|
|
666
|
+
get: {
|
|
667
|
+
tags: ["Provisioning"],
|
|
668
|
+
summary: "Resolve which role a Backstage group maps to (dry-run)",
|
|
669
|
+
parameters: [
|
|
670
|
+
{ name: "group", in: "query", required: true, schema: { type: "string" }, description: "e.g. group:default/ai-platform" }
|
|
671
|
+
],
|
|
672
|
+
responses: {
|
|
673
|
+
"200": { description: "Resolved role and effective defaults" },
|
|
674
|
+
"400": { description: "Missing group parameter" },
|
|
675
|
+
"403": { description: "Not configured or not authorized" }
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
"/bridge/health": {
|
|
680
|
+
get: {
|
|
681
|
+
tags: ["Bridge"],
|
|
682
|
+
summary: "Bridge health + configured clientId (no auth)",
|
|
683
|
+
responses: { "200": { description: "Bridge health" } }
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
"/bridge/keys": {
|
|
687
|
+
get: {
|
|
688
|
+
tags: ["Bridge"],
|
|
689
|
+
summary: "List the caller virtual keys (Keycloak token auth)",
|
|
690
|
+
responses: { "200": { description: "Array of keys" }, "401": { description: "Invalid token" } }
|
|
691
|
+
},
|
|
692
|
+
post: {
|
|
693
|
+
tags: ["Bridge"],
|
|
694
|
+
summary: "Mint a virtual key for the caller (Keycloak token auth)",
|
|
695
|
+
requestBody: { content: { "application/json": { schema: { type: "object" } } } },
|
|
696
|
+
responses: { "200": { description: "Generated key" }, "401": { description: "Invalid token" } }
|
|
697
|
+
}
|
|
698
|
+
},
|
|
699
|
+
"/bridge/models": {
|
|
700
|
+
get: {
|
|
701
|
+
tags: ["Bridge"],
|
|
702
|
+
summary: "List available LLM models (Keycloak token auth)",
|
|
703
|
+
responses: { "200": { description: "Model catalogue" }, "401": { description: "Invalid token" } }
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
},
|
|
707
|
+
components: {
|
|
708
|
+
securitySchemes: {
|
|
709
|
+
backstageUserToken: {
|
|
710
|
+
type: "http",
|
|
711
|
+
scheme: "bearer",
|
|
712
|
+
description: "Backstage-issued user token (Authorization: Bearer <token>)."
|
|
713
|
+
},
|
|
714
|
+
keycloakAccessToken: {
|
|
715
|
+
type: "http",
|
|
716
|
+
scheme: "bearer",
|
|
717
|
+
description: "Raw Keycloak access token. Bridge routes only; verified against the realm JWKS."
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
|
|
483
723
|
// src/provisioning.ts
|
|
484
724
|
function toLiteLLMUserId(userEntityRef, userIdDomain) {
|
|
485
725
|
const name = userEntityRef.split("/").pop() ?? userEntityRef;
|
|
@@ -2213,28 +2453,6 @@ async function bridgeGenerateKey(client, claims, provisioningEnabled, provisioni
|
|
|
2213
2453
|
};
|
|
2214
2454
|
return client.generateKey(enriched);
|
|
2215
2455
|
}
|
|
2216
|
-
async function bridgeRegenerateKey(client, claims, provisioningEnabled, provisioningDefaults, logger, alias, userIdDomain) {
|
|
2217
|
-
await getOrProvisionUserFromClaims(
|
|
2218
|
-
client,
|
|
2219
|
-
claims,
|
|
2220
|
-
provisioningEnabled,
|
|
2221
|
-
provisioningDefaults,
|
|
2222
|
-
logger,
|
|
2223
|
-
userIdDomain
|
|
2224
|
-
);
|
|
2225
|
-
const userId = resolveBridgeUserId(claims, userIdDomain);
|
|
2226
|
-
const keys = await client.listKeys(userId);
|
|
2227
|
-
const match = keys.find((k) => k.key_alias === alias);
|
|
2228
|
-
if (!match) {
|
|
2229
|
-
throw new ProvisioningError(
|
|
2230
|
-
"Key not found",
|
|
2231
|
-
`No key with alias "${alias}" for this identity; mint one first.`,
|
|
2232
|
-
false,
|
|
2233
|
-
404
|
|
2234
|
-
);
|
|
2235
|
-
}
|
|
2236
|
-
return client.regenerateKey(match.token);
|
|
2237
|
-
}
|
|
2238
2456
|
|
|
2239
2457
|
// src/router.ts
|
|
2240
2458
|
async function createRouter(options) {
|
|
@@ -2261,6 +2479,57 @@ async function createRouter(options) {
|
|
|
2261
2479
|
router.get("/config", (_req, res) => {
|
|
2262
2480
|
res.json({ baseUrl: publicBaseUrl });
|
|
2263
2481
|
});
|
|
2482
|
+
router.get("/openapi.json", (_req, res) => {
|
|
2483
|
+
res.json(openApiSpec);
|
|
2484
|
+
});
|
|
2485
|
+
router.get("/provisioning/preview", async (req, res) => {
|
|
2486
|
+
if (!auditGroup) {
|
|
2487
|
+
res.status(403).json({ error: "Preview is not configured (litellm.audit.group not set)" });
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
const tokenEntityRef = await resolveUserId(req, auth);
|
|
2491
|
+
if (!tokenEntityRef) {
|
|
2492
|
+
res.status(401).json({ error: "Authentication required" });
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
const allowed = await isUserMemberOfGroup(
|
|
2496
|
+
tokenEntityRef,
|
|
2497
|
+
auditGroup,
|
|
2498
|
+
catalogClient,
|
|
2499
|
+
auth,
|
|
2500
|
+
logger
|
|
2501
|
+
);
|
|
2502
|
+
if (!allowed) {
|
|
2503
|
+
res.status(403).json({ error: "Access denied: not a member of the audit group" });
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
const group = req.query.group?.trim();
|
|
2507
|
+
if (!group) {
|
|
2508
|
+
res.status(400).json({ error: "group query parameter is required (e.g. group=group:default/ai-platform)" });
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
if (!roleConfigs.length) {
|
|
2512
|
+
res.json({
|
|
2513
|
+
group,
|
|
2514
|
+
matched_role: null,
|
|
2515
|
+
effective_defaults: provisioningDefaults,
|
|
2516
|
+
note: "No litellm.provisioning.roles configured \u2014 every group receives the base defaults."
|
|
2517
|
+
});
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
try {
|
|
2521
|
+
const matched = roleConfigs.find((rc) => rc.group === group);
|
|
2522
|
+
const effective = matched ? applyRoleOverrides(provisioningDefaults, matched) : provisioningDefaults;
|
|
2523
|
+
res.json({
|
|
2524
|
+
group,
|
|
2525
|
+
matched_role: matched?.group ?? null,
|
|
2526
|
+
effective_defaults: effective
|
|
2527
|
+
});
|
|
2528
|
+
} catch (error) {
|
|
2529
|
+
logger.error("Failed to resolve provisioning preview", error);
|
|
2530
|
+
res.status(500).json({ error: error.message });
|
|
2531
|
+
}
|
|
2532
|
+
});
|
|
2264
2533
|
router.get("/user/info", async (req, res) => {
|
|
2265
2534
|
try {
|
|
2266
2535
|
const tokenEntityRef = await resolveUserId(req, auth);
|
|
@@ -2319,22 +2588,26 @@ async function createRouter(options) {
|
|
|
2319
2588
|
res.status(500).json({ error: error.message });
|
|
2320
2589
|
}
|
|
2321
2590
|
});
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
return;
|
|
2328
|
-
}
|
|
2329
|
-
const tokenEntityRef = await resolveUserId(req, auth);
|
|
2330
|
-
const result = await client.regenerateKey(keyId);
|
|
2331
|
-
logger.info({ action: "key.rotate", userId: tokenEntityRef ?? "unknown", keyId });
|
|
2332
|
-
res.json(result);
|
|
2333
|
-
} catch (error) {
|
|
2334
|
-
logger.error("Failed to rotate key", error);
|
|
2335
|
-
res.status(500).json({ error: error.message });
|
|
2591
|
+
async function authorizeKeyAction(req, keyId) {
|
|
2592
|
+
const tokenEntityRef = await resolveUserId(req, auth);
|
|
2593
|
+
const userId = tokenEntityRef ? toLiteLLMUserId(tokenEntityRef, userIdDomain) : req.query.user_id;
|
|
2594
|
+
if (!userId) {
|
|
2595
|
+
throw { status: 403, body: { error: "Cannot verify key ownership without an authenticated user" } };
|
|
2336
2596
|
}
|
|
2337
|
-
|
|
2597
|
+
const ownKeys = await client.listKeys(userId);
|
|
2598
|
+
const owns = ownKeys.some((k) => (k.token ?? k.key) === keyId);
|
|
2599
|
+
if (!owns) {
|
|
2600
|
+
throw { status: 403, body: { error: "Access denied: key does not belong to the caller" } };
|
|
2601
|
+
}
|
|
2602
|
+
return { tokenEntityRef, userId };
|
|
2603
|
+
}
|
|
2604
|
+
function sendOwnershipError(err, res) {
|
|
2605
|
+
if (err && typeof err.status === "number" && err.body) {
|
|
2606
|
+
res.status(err.status).json(err.body);
|
|
2607
|
+
return true;
|
|
2608
|
+
}
|
|
2609
|
+
return false;
|
|
2610
|
+
}
|
|
2338
2611
|
router.post("/keys/generate", async (req, res) => {
|
|
2339
2612
|
try {
|
|
2340
2613
|
const body = req.body ?? {};
|
|
@@ -2400,12 +2673,13 @@ async function createRouter(options) {
|
|
|
2400
2673
|
res.status(400).json({ error: "keyId is required" });
|
|
2401
2674
|
return;
|
|
2402
2675
|
}
|
|
2403
|
-
const tokenEntityRef = await
|
|
2676
|
+
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
2404
2677
|
const request = { ...req.body, key: keyId };
|
|
2405
2678
|
const result = await client.updateKey(request);
|
|
2406
2679
|
logger.info({ action: "key.update", userId: tokenEntityRef ?? "unknown", keyId });
|
|
2407
2680
|
res.json(result);
|
|
2408
2681
|
} catch (error) {
|
|
2682
|
+
if (sendOwnershipError(error, res)) return;
|
|
2409
2683
|
logger.error("Failed to update key", error);
|
|
2410
2684
|
res.status(500).json({ error: error.message });
|
|
2411
2685
|
}
|
|
@@ -2417,11 +2691,12 @@ async function createRouter(options) {
|
|
|
2417
2691
|
res.status(400).json({ error: "keyId is required" });
|
|
2418
2692
|
return;
|
|
2419
2693
|
}
|
|
2420
|
-
const
|
|
2694
|
+
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
2421
2695
|
await client.deleteKeys({ keys: [keyId] });
|
|
2422
|
-
logger.info({ action: "key.delete", userId:
|
|
2696
|
+
logger.info({ action: "key.delete", userId: tokenEntityRef ?? "unknown", keyId });
|
|
2423
2697
|
res.json({ success: true });
|
|
2424
2698
|
} catch (error) {
|
|
2699
|
+
if (sendOwnershipError(error, res)) return;
|
|
2425
2700
|
logger.error("Failed to delete key", error);
|
|
2426
2701
|
res.status(500).json({ error: error.message });
|
|
2427
2702
|
}
|
|
@@ -2429,11 +2704,12 @@ async function createRouter(options) {
|
|
|
2429
2704
|
router.post("/keys/:keyId/block", async (req, res) => {
|
|
2430
2705
|
try {
|
|
2431
2706
|
const { keyId } = req.params;
|
|
2432
|
-
const tokenEntityRef = await
|
|
2707
|
+
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
2433
2708
|
await client.blockKey(keyId);
|
|
2434
2709
|
logger.info({ action: "key.block", userId: tokenEntityRef ?? "unknown", keyId });
|
|
2435
2710
|
res.json({ success: true });
|
|
2436
2711
|
} catch (error) {
|
|
2712
|
+
if (sendOwnershipError(error, res)) return;
|
|
2437
2713
|
logger.error("Failed to block key", error);
|
|
2438
2714
|
res.status(500).json({ error: error.message });
|
|
2439
2715
|
}
|
|
@@ -2441,11 +2717,12 @@ async function createRouter(options) {
|
|
|
2441
2717
|
router.post("/keys/:keyId/unblock", async (req, res) => {
|
|
2442
2718
|
try {
|
|
2443
2719
|
const { keyId } = req.params;
|
|
2444
|
-
const tokenEntityRef = await
|
|
2720
|
+
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
2445
2721
|
await client.unblockKey(keyId);
|
|
2446
2722
|
logger.info({ action: "key.unblock", userId: tokenEntityRef ?? "unknown", keyId });
|
|
2447
2723
|
res.json({ success: true });
|
|
2448
2724
|
} catch (error) {
|
|
2725
|
+
if (sendOwnershipError(error, res)) return;
|
|
2449
2726
|
logger.error("Failed to unblock key", error);
|
|
2450
2727
|
res.status(500).json({ error: error.message });
|
|
2451
2728
|
}
|
|
@@ -2453,11 +2730,12 @@ async function createRouter(options) {
|
|
|
2453
2730
|
router.post("/keys/:keyId/reset_spend", async (req, res) => {
|
|
2454
2731
|
try {
|
|
2455
2732
|
const { keyId } = req.params;
|
|
2456
|
-
const tokenEntityRef = await
|
|
2733
|
+
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
2457
2734
|
await client.resetKeySpend(keyId);
|
|
2458
2735
|
logger.info({ action: "key.reset_spend", userId: tokenEntityRef ?? "unknown", keyId });
|
|
2459
2736
|
res.json({ success: true });
|
|
2460
2737
|
} catch (error) {
|
|
2738
|
+
if (sendOwnershipError(error, res)) return;
|
|
2461
2739
|
logger.error("Failed to reset key spend", error);
|
|
2462
2740
|
res.status(500).json({ error: error.message });
|
|
2463
2741
|
}
|
|
@@ -2567,7 +2845,7 @@ async function createRouter(options) {
|
|
|
2567
2845
|
});
|
|
2568
2846
|
router.get("/usage", async (req, res) => {
|
|
2569
2847
|
try {
|
|
2570
|
-
const { start_date, end_date
|
|
2848
|
+
const { start_date, end_date } = req.query;
|
|
2571
2849
|
if (!start_date || !end_date) {
|
|
2572
2850
|
res.status(400).json({ error: "start_date and end_date are required" });
|
|
2573
2851
|
return;
|
|
@@ -2590,8 +2868,7 @@ async function createRouter(options) {
|
|
|
2590
2868
|
const usage = await client.getUsage(
|
|
2591
2869
|
start_date,
|
|
2592
2870
|
end_date,
|
|
2593
|
-
userId
|
|
2594
|
-
group_by
|
|
2871
|
+
userId
|
|
2595
2872
|
);
|
|
2596
2873
|
res.json(usage);
|
|
2597
2874
|
} catch (error) {
|
|
@@ -2669,28 +2946,6 @@ async function createRouter(options) {
|
|
|
2669
2946
|
handleBridgeError(error, res);
|
|
2670
2947
|
}
|
|
2671
2948
|
});
|
|
2672
|
-
router.post("/bridge/keys/regenerate", async (req, res) => {
|
|
2673
|
-
try {
|
|
2674
|
-
const claims = await requireClaims(req);
|
|
2675
|
-
const alias = (req.body ?? {}).alias?.trim();
|
|
2676
|
-
if (!alias) {
|
|
2677
|
-
res.status(400).json({ error: "alias is required" });
|
|
2678
|
-
return;
|
|
2679
|
-
}
|
|
2680
|
-
const result = await bridgeRegenerateKey(
|
|
2681
|
-
client,
|
|
2682
|
-
claims,
|
|
2683
|
-
provisioningEnabled,
|
|
2684
|
-
provisioningDefaults,
|
|
2685
|
-
logger,
|
|
2686
|
-
alias,
|
|
2687
|
-
userIdDomain
|
|
2688
|
-
);
|
|
2689
|
-
res.json(result);
|
|
2690
|
-
} catch (error) {
|
|
2691
|
-
handleBridgeError(error, res);
|
|
2692
|
-
}
|
|
2693
|
-
});
|
|
2694
2949
|
router.get("/bridge/models", async (req, res) => {
|
|
2695
2950
|
try {
|
|
2696
2951
|
await requireClaims(req);
|