@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 +1 -1
- package/dist/client.js +8 -1
- package/dist/index.cjs.js +358 -12
- 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 +110 -9
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface OpenApiSpec {
|
|
2
|
+
openapi: string;
|
|
3
|
+
info: {
|
|
4
|
+
title: string;
|
|
5
|
+
version: string;
|
|
6
|
+
description: string;
|
|
7
|
+
};
|
|
8
|
+
servers: {
|
|
9
|
+
url: string;
|
|
10
|
+
description: string;
|
|
11
|
+
}[];
|
|
12
|
+
tags: {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
}[];
|
|
16
|
+
paths: Record<string, Record<string, any>>;
|
|
17
|
+
components: {
|
|
18
|
+
securitySchemes: Record<string, any>;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export declare const openApiSpec: OpenApiSpec;
|
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// OpenAPI 3.1 contract for the plugin's own backend surface.
|
|
3
|
+
//
|
|
4
|
+
// Kept as a TypeScript object so the router can serve it at
|
|
5
|
+
// GET /api/litellm/openapi.json without a YAML parser dependency, and so
|
|
6
|
+
// a future tool can regenerate the README endpoint table from it.
|
|
7
|
+
//
|
|
8
|
+
// Conventions:
|
|
9
|
+
// - Every UI route authenticates via a Backstage user bearer token
|
|
10
|
+
// (Authorization: Bearer <backstage-token>). The bridge routes use a
|
|
11
|
+
// raw Keycloak access token instead and are gated by
|
|
12
|
+
// litellm.bridge.enabled.
|
|
13
|
+
// - Key-mutation routes 403 when the target key does not belong to the
|
|
14
|
+
// caller (ownership guard; see router.ts authorizeKeyAction).
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.openApiSpec = void 0;
|
|
17
|
+
exports.openApiSpec = {
|
|
18
|
+
openapi: '3.1.0',
|
|
19
|
+
info: {
|
|
20
|
+
title: 'LiteLLM Governance Plugin API',
|
|
21
|
+
version: '0.1.0',
|
|
22
|
+
description: 'Backstage backend surface for the LiteLLM governance plugin. ' +
|
|
23
|
+
'UI routes authenticate via the Backstage identity system; CLI bridge ' +
|
|
24
|
+
'routes authenticate with a Keycloak access token.',
|
|
25
|
+
},
|
|
26
|
+
servers: [
|
|
27
|
+
{ url: '/api/litellm', description: 'Backstage backend plugin mount point' },
|
|
28
|
+
],
|
|
29
|
+
tags: [
|
|
30
|
+
{ name: 'System', description: 'Health and configuration' },
|
|
31
|
+
{ name: 'User', description: 'Current user info and provisioning' },
|
|
32
|
+
{ name: 'Keys', description: 'Virtual key lifecycle' },
|
|
33
|
+
{ name: 'Models', description: 'Model catalogue' },
|
|
34
|
+
{ name: 'Teams', description: 'Team membership and usage' },
|
|
35
|
+
{ name: 'Usage', description: 'Spend and traffic analytics' },
|
|
36
|
+
{ name: 'Audit', description: 'Audit logs (RBAC-gated)' },
|
|
37
|
+
{ name: 'Provisioning', description: 'Provisioning dry-run (RBAC-gated)' },
|
|
38
|
+
{ name: 'Bridge', description: 'CLI bridge (Keycloak-token auth)' },
|
|
39
|
+
],
|
|
40
|
+
paths: {
|
|
41
|
+
'/health': {
|
|
42
|
+
get: {
|
|
43
|
+
tags: ['System'],
|
|
44
|
+
summary: 'Health check',
|
|
45
|
+
responses: {
|
|
46
|
+
'200': {
|
|
47
|
+
description: 'Plugin health',
|
|
48
|
+
content: { 'application/json': { schema: { type: 'object', properties: {
|
|
49
|
+
status: { type: 'string' },
|
|
50
|
+
provisioning: { type: 'boolean' },
|
|
51
|
+
} } } },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
'/config': {
|
|
57
|
+
get: {
|
|
58
|
+
tags: ['System'],
|
|
59
|
+
summary: 'Public LiteLLM proxy base URL (for snippet generation)',
|
|
60
|
+
responses: {
|
|
61
|
+
'200': {
|
|
62
|
+
description: 'Proxy URL',
|
|
63
|
+
content: { 'application/json': { schema: { type: 'object', properties: {
|
|
64
|
+
baseUrl: { type: 'string' },
|
|
65
|
+
} } } },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
'/openapi.json': {
|
|
71
|
+
get: {
|
|
72
|
+
tags: ['System'],
|
|
73
|
+
summary: 'This OpenAPI document',
|
|
74
|
+
responses: { '200': { description: 'OpenAPI 3.1 JSON' } },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
'/user/info': {
|
|
78
|
+
get: {
|
|
79
|
+
tags: ['User'],
|
|
80
|
+
summary: "Get the current user's info and quotas",
|
|
81
|
+
responses: {
|
|
82
|
+
'200': { description: 'User info' },
|
|
83
|
+
'404': { description: 'User not found in LiteLLM (provisioning disabled)' },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
'/keys': {
|
|
88
|
+
get: {
|
|
89
|
+
tags: ['Keys'],
|
|
90
|
+
summary: "List the caller's virtual keys",
|
|
91
|
+
responses: { '200': { description: 'Array of keys' } },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
'/keys/generate': {
|
|
95
|
+
post: {
|
|
96
|
+
tags: ['Keys'],
|
|
97
|
+
summary: 'Generate a new virtual key',
|
|
98
|
+
requestBody: {
|
|
99
|
+
required: true,
|
|
100
|
+
content: { 'application/json': { schema: { type: 'object', properties: {
|
|
101
|
+
alias: { type: 'string' },
|
|
102
|
+
models: { type: 'array', items: { type: 'string' } },
|
|
103
|
+
team_id: { type: 'string' },
|
|
104
|
+
duration: { type: 'string' },
|
|
105
|
+
max_budget: { type: 'number', nullable: true, description: 'Positive number caps spend; null = unlimited' },
|
|
106
|
+
tpm_limit: { type: 'number' },
|
|
107
|
+
rpm_limit: { type: 'number' },
|
|
108
|
+
auto_rotate: { type: 'boolean' },
|
|
109
|
+
rotation_interval_days: { type: 'number' },
|
|
110
|
+
} } } },
|
|
111
|
+
},
|
|
112
|
+
responses: {
|
|
113
|
+
'200': { description: 'Generated key (includes the raw secret — shown once)' },
|
|
114
|
+
'400': { description: 'Missing required fields' },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
'/keys/{keyId}': {
|
|
119
|
+
delete: {
|
|
120
|
+
tags: ['Keys'],
|
|
121
|
+
summary: 'Revoke/delete a virtual key (caller must own it)',
|
|
122
|
+
parameters: [{ name: 'keyId', in: 'path', required: true, schema: { type: 'string' } }],
|
|
123
|
+
responses: { '200': { description: 'Deleted' }, '403': { description: 'Not the key owner' } },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
'/keys/{keyId}/regenerate': {
|
|
127
|
+
post: {
|
|
128
|
+
tags: ['Keys'],
|
|
129
|
+
summary: 'Rotate a key in place (caller must own it)',
|
|
130
|
+
parameters: [{ name: 'keyId', in: 'path', required: true, schema: { type: 'string' } }],
|
|
131
|
+
responses: { '200': { description: 'New secret' }, '403': { description: 'Not the key owner' } },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
'/keys/{keyId}/update': {
|
|
135
|
+
post: {
|
|
136
|
+
tags: ['Keys'],
|
|
137
|
+
summary: 'Update alias / models / budget / limits (caller must own it)',
|
|
138
|
+
parameters: [{ name: 'keyId', in: 'path', required: true, schema: { type: 'string' } }],
|
|
139
|
+
requestBody: { content: { 'application/json': { schema: { type: 'object' } } } },
|
|
140
|
+
responses: { '200': { description: 'Updated' }, '403': { description: 'Not the key owner' } },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
'/keys/{keyId}/block': {
|
|
144
|
+
post: {
|
|
145
|
+
tags: ['Keys'],
|
|
146
|
+
summary: 'Suspend a key without revoking (caller must own it)',
|
|
147
|
+
parameters: [{ name: 'keyId', in: 'path', required: true, schema: { type: 'string' } }],
|
|
148
|
+
responses: { '200': { description: 'Blocked' }, '403': { description: 'Not the key owner' } },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
'/keys/{keyId}/unblock': {
|
|
152
|
+
post: {
|
|
153
|
+
tags: ['Keys'],
|
|
154
|
+
summary: 'Re-enable a blocked key (caller must own it)',
|
|
155
|
+
parameters: [{ name: 'keyId', in: 'path', required: true, schema: { type: 'string' } }],
|
|
156
|
+
responses: { '200': { description: 'Unblocked' }, '403': { description: 'Not the key owner' } },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
'/keys/{keyId}/reset_spend': {
|
|
160
|
+
post: {
|
|
161
|
+
tags: ['Keys'],
|
|
162
|
+
summary: 'Zero out a key spend counter (caller must own it)',
|
|
163
|
+
parameters: [{ name: 'keyId', in: 'path', required: true, schema: { type: 'string' } }],
|
|
164
|
+
responses: { '200': { description: 'Spend reset' }, '403': { description: 'Not the key owner' } },
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
'/models': {
|
|
168
|
+
get: {
|
|
169
|
+
tags: ['Models'],
|
|
170
|
+
summary: 'List available LLM models',
|
|
171
|
+
responses: { '200': { description: 'Model catalogue' } },
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
'/teams': {
|
|
175
|
+
get: {
|
|
176
|
+
tags: ['Teams'],
|
|
177
|
+
summary: 'List teams the current user belongs to',
|
|
178
|
+
responses: { '200': { description: 'Array of teams' } },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
'/teams/{teamId}/usage': {
|
|
182
|
+
get: {
|
|
183
|
+
tags: ['Teams'],
|
|
184
|
+
summary: 'Usage metrics for a team',
|
|
185
|
+
parameters: [
|
|
186
|
+
{ name: 'teamId', in: 'path', required: true, schema: { type: 'string' } },
|
|
187
|
+
{ name: 'start_date', in: 'query', required: true, schema: { type: 'string' } },
|
|
188
|
+
{ name: 'end_date', in: 'query', required: true, schema: { type: 'string' } },
|
|
189
|
+
],
|
|
190
|
+
responses: { '200': { description: 'Team usage' }, '400': { description: 'Missing date range' } },
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
'/usage': {
|
|
194
|
+
get: {
|
|
195
|
+
tags: ['Usage'],
|
|
196
|
+
summary: 'Usage metrics for the current user',
|
|
197
|
+
parameters: [
|
|
198
|
+
{ name: 'start_date', in: 'query', required: true, schema: { type: 'string' } },
|
|
199
|
+
{ name: 'end_date', in: 'query', required: true, schema: { type: 'string' } },
|
|
200
|
+
],
|
|
201
|
+
responses: { '200': { description: 'Usage metrics' }, '400': { description: 'Missing date range' } },
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
'/audit': {
|
|
205
|
+
get: {
|
|
206
|
+
tags: ['Audit'],
|
|
207
|
+
summary: 'Audit logs (gated by litellm.audit.group membership)',
|
|
208
|
+
parameters: [
|
|
209
|
+
{ name: 'page', in: 'query', schema: { type: 'integer' } },
|
|
210
|
+
{ name: 'page_size', in: 'query', schema: { type: 'integer' } },
|
|
211
|
+
{ name: 'start_date', in: 'query', schema: { type: 'string' } },
|
|
212
|
+
{ name: 'end_date', in: 'query', schema: { type: 'string' } },
|
|
213
|
+
{ name: 'action', in: 'query', schema: { type: 'string' } },
|
|
214
|
+
{ name: 'table_name', in: 'query', schema: { type: 'string' } },
|
|
215
|
+
{ name: 'changed_by', in: 'query', schema: { type: 'string' } },
|
|
216
|
+
],
|
|
217
|
+
responses: { '200': { description: 'Paginated audit logs' }, '403': { description: 'Not configured or not authorized' } },
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
'/provisioning/preview': {
|
|
221
|
+
get: {
|
|
222
|
+
tags: ['Provisioning'],
|
|
223
|
+
summary: 'Resolve which role a Backstage group maps to (dry-run)',
|
|
224
|
+
parameters: [
|
|
225
|
+
{ name: 'group', in: 'query', required: true, schema: { type: 'string' }, description: 'e.g. group:default/ai-platform' },
|
|
226
|
+
],
|
|
227
|
+
responses: {
|
|
228
|
+
'200': { description: 'Resolved role and effective defaults' },
|
|
229
|
+
'400': { description: 'Missing group parameter' },
|
|
230
|
+
'403': { description: 'Not configured or not authorized' },
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
'/bridge/health': {
|
|
235
|
+
get: {
|
|
236
|
+
tags: ['Bridge'],
|
|
237
|
+
summary: 'Bridge health + configured clientId (no auth)',
|
|
238
|
+
responses: { '200': { description: 'Bridge health' } },
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
'/bridge/keys': {
|
|
242
|
+
get: {
|
|
243
|
+
tags: ['Bridge'],
|
|
244
|
+
summary: 'List the caller virtual keys (Keycloak token auth)',
|
|
245
|
+
responses: { '200': { description: 'Array of keys' }, '401': { description: 'Invalid token' } },
|
|
246
|
+
},
|
|
247
|
+
post: {
|
|
248
|
+
tags: ['Bridge'],
|
|
249
|
+
summary: 'Mint a virtual key for the caller (Keycloak token auth)',
|
|
250
|
+
requestBody: { content: { 'application/json': { schema: { type: 'object' } } } },
|
|
251
|
+
responses: { '200': { description: 'Generated key' }, '401': { description: 'Invalid token' } },
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
'/bridge/keys/regenerate': {
|
|
255
|
+
post: {
|
|
256
|
+
tags: ['Bridge'],
|
|
257
|
+
summary: 'Rotate the caller key by alias (Keycloak token auth)',
|
|
258
|
+
requestBody: { content: { 'application/json': { schema: { type: 'object', properties: { alias: { type: 'string' } } } } } },
|
|
259
|
+
responses: { '200': { description: 'New secret' }, '401': { description: 'Invalid token' }, '404': { description: 'No key with that alias' } },
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
'/bridge/models': {
|
|
263
|
+
get: {
|
|
264
|
+
tags: ['Bridge'],
|
|
265
|
+
summary: 'List available LLM models (Keycloak token auth)',
|
|
266
|
+
responses: { '200': { description: 'Model catalogue' }, '401': { description: 'Invalid token' } },
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
components: {
|
|
271
|
+
securitySchemes: {
|
|
272
|
+
backstageUserToken: {
|
|
273
|
+
type: 'http',
|
|
274
|
+
scheme: 'bearer',
|
|
275
|
+
description: 'Backstage-issued user token (Authorization: Bearer <token>).',
|
|
276
|
+
},
|
|
277
|
+
keycloakAccessToken: {
|
|
278
|
+
type: 'http',
|
|
279
|
+
scheme: 'bearer',
|
|
280
|
+
description: 'Raw Keycloak access token. Bridge routes only; verified against the realm JWKS.',
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
};
|
package/dist/router.js
CHANGED
|
@@ -38,6 +38,7 @@ exports.createRouter = createRouter;
|
|
|
38
38
|
const express_1 = __importStar(require("express"));
|
|
39
39
|
const catalog_client_1 = require("@backstage/catalog-client");
|
|
40
40
|
const client_1 = require("./client");
|
|
41
|
+
const openapi_1 = require("./openapi");
|
|
41
42
|
const provisioning_1 = require("./provisioning");
|
|
42
43
|
Object.defineProperty(exports, "ProvisioningError", { enumerable: true, get: function () { return provisioning_1.ProvisioningError; } });
|
|
43
44
|
const bridge_1 = require("./bridge");
|
|
@@ -73,6 +74,60 @@ async function createRouter(options) {
|
|
|
73
74
|
router.get('/config', (_req, res) => {
|
|
74
75
|
res.json({ baseUrl: publicBaseUrl });
|
|
75
76
|
});
|
|
77
|
+
// Self-hosted OpenAPI 3.1 contract — lets integrators read a spec instead
|
|
78
|
+
// of router.ts. No swagger-ui dependency; serve the JSON and point external
|
|
79
|
+
// renderers (Stoplight, Swagger UI hosted elsewhere) at this endpoint.
|
|
80
|
+
router.get('/openapi.json', (_req, res) => {
|
|
81
|
+
res.json(openapi_1.openApiSpec);
|
|
82
|
+
});
|
|
83
|
+
// Provisioning dry-run: resolves which role a Backstage group maps to and
|
|
84
|
+
// echoes the effective defaults, collapsing the config → deploy → test loop
|
|
85
|
+
// into one request. Admin-gated by the audit group (same RBAC as /audit).
|
|
86
|
+
router.get('/provisioning/preview', async (req, res) => {
|
|
87
|
+
if (!auditGroup) {
|
|
88
|
+
res.status(403).json({ error: 'Preview is not configured (litellm.audit.group not set)' });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
|
|
92
|
+
if (!tokenEntityRef) {
|
|
93
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const allowed = await (0, provisioning_1.isUserMemberOfGroup)(tokenEntityRef, auditGroup, catalogClient, auth, logger);
|
|
97
|
+
if (!allowed) {
|
|
98
|
+
res.status(403).json({ error: 'Access denied: not a member of the audit group' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const group = req.query.group?.trim();
|
|
102
|
+
if (!group) {
|
|
103
|
+
res.status(400).json({ error: 'group query parameter is required (e.g. group=group:default/ai-platform)' });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!roleConfigs.length) {
|
|
107
|
+
res.json({
|
|
108
|
+
group,
|
|
109
|
+
matched_role: null,
|
|
110
|
+
effective_defaults: provisioningDefaults,
|
|
111
|
+
note: 'No litellm.provisioning.roles configured — every group receives the base defaults.',
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const matched = roleConfigs.find(rc => rc.group === group);
|
|
117
|
+
const effective = matched
|
|
118
|
+
? (0, provisioning_1.applyRoleOverrides)(provisioningDefaults, matched)
|
|
119
|
+
: provisioningDefaults;
|
|
120
|
+
res.json({
|
|
121
|
+
group,
|
|
122
|
+
matched_role: matched?.group ?? null,
|
|
123
|
+
effective_defaults: effective,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
logger.error('Failed to resolve provisioning preview', error);
|
|
128
|
+
res.status(500).json({ error: error.message });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
76
131
|
router.get('/user/info', async (req, res) => {
|
|
77
132
|
try {
|
|
78
133
|
const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
|
|
@@ -113,6 +168,40 @@ async function createRouter(options) {
|
|
|
113
168
|
res.status(500).json({ error: error.message });
|
|
114
169
|
}
|
|
115
170
|
});
|
|
171
|
+
// ── Key ownership guard ──────────────────────────────────────────────────
|
|
172
|
+
//
|
|
173
|
+
// Every key-mutation route below runs under the LiteLLM master key, so
|
|
174
|
+
// without an explicit check any authenticated Backstage user could act on
|
|
175
|
+
// any key whose token they learn (audit logs expose truncated tokens, and
|
|
176
|
+
// anyone who has held the raw key knows it). We port the same pattern the
|
|
177
|
+
// bridge already uses (bridge.ts:bridgeRegenerateKey): fetch the caller's
|
|
178
|
+
// own key list and 403 if the target token isn't in it.
|
|
179
|
+
//
|
|
180
|
+
// Returns the caller's LiteLLM user_id (when resolvable) so handlers can
|
|
181
|
+
// stamp it in logs without re-deriving it.
|
|
182
|
+
async function authorizeKeyAction(req, keyId) {
|
|
183
|
+
const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
|
|
184
|
+
const userId = tokenEntityRef
|
|
185
|
+
? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
|
|
186
|
+
: req.query.user_id;
|
|
187
|
+
if (!userId) {
|
|
188
|
+
throw { status: 403, body: { error: 'Cannot verify key ownership without an authenticated user' } };
|
|
189
|
+
}
|
|
190
|
+
const ownKeys = await client.listKeys(userId);
|
|
191
|
+
const owns = ownKeys.some(k => (k.token ?? k.key) === keyId);
|
|
192
|
+
if (!owns) {
|
|
193
|
+
throw { status: 403, body: { error: 'Access denied: key does not belong to the caller' } };
|
|
194
|
+
}
|
|
195
|
+
return { tokenEntityRef, userId };
|
|
196
|
+
}
|
|
197
|
+
// Normalize the ownership-guard rejection shape into a response.
|
|
198
|
+
function sendOwnershipError(err, res) {
|
|
199
|
+
if (err && typeof err.status === 'number' && err.body) {
|
|
200
|
+
res.status(err.status).json(err.body);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
116
205
|
router.post('/keys/:keyId/regenerate', async (req, res) => {
|
|
117
206
|
try {
|
|
118
207
|
const { keyId } = req.params;
|
|
@@ -120,12 +209,14 @@ async function createRouter(options) {
|
|
|
120
209
|
res.status(400).json({ error: 'keyId is required' });
|
|
121
210
|
return;
|
|
122
211
|
}
|
|
123
|
-
const tokenEntityRef = await (
|
|
212
|
+
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
124
213
|
const result = await client.regenerateKey(keyId);
|
|
125
214
|
logger.info({ action: 'key.rotate', userId: tokenEntityRef ?? 'unknown', keyId });
|
|
126
215
|
res.json(result);
|
|
127
216
|
}
|
|
128
217
|
catch (error) {
|
|
218
|
+
if (sendOwnershipError(error, res))
|
|
219
|
+
return;
|
|
129
220
|
logger.error('Failed to rotate key', error);
|
|
130
221
|
res.status(500).json({ error: error.message });
|
|
131
222
|
}
|
|
@@ -204,13 +295,15 @@ async function createRouter(options) {
|
|
|
204
295
|
res.status(400).json({ error: 'keyId is required' });
|
|
205
296
|
return;
|
|
206
297
|
}
|
|
207
|
-
const tokenEntityRef = await (
|
|
298
|
+
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
208
299
|
const request = { ...req.body, key: keyId };
|
|
209
300
|
const result = await client.updateKey(request);
|
|
210
301
|
logger.info({ action: 'key.update', userId: tokenEntityRef ?? 'unknown', keyId });
|
|
211
302
|
res.json(result);
|
|
212
303
|
}
|
|
213
304
|
catch (error) {
|
|
305
|
+
if (sendOwnershipError(error, res))
|
|
306
|
+
return;
|
|
214
307
|
logger.error('Failed to update key', error);
|
|
215
308
|
res.status(500).json({ error: error.message });
|
|
216
309
|
}
|
|
@@ -222,12 +315,14 @@ async function createRouter(options) {
|
|
|
222
315
|
res.status(400).json({ error: 'keyId is required' });
|
|
223
316
|
return;
|
|
224
317
|
}
|
|
225
|
-
const
|
|
318
|
+
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
226
319
|
await client.deleteKeys({ keys: [keyId] });
|
|
227
|
-
logger.info({ action: 'key.delete', userId:
|
|
320
|
+
logger.info({ action: 'key.delete', userId: tokenEntityRef ?? 'unknown', keyId });
|
|
228
321
|
res.json({ success: true });
|
|
229
322
|
}
|
|
230
323
|
catch (error) {
|
|
324
|
+
if (sendOwnershipError(error, res))
|
|
325
|
+
return;
|
|
231
326
|
logger.error('Failed to delete key', error);
|
|
232
327
|
res.status(500).json({ error: error.message });
|
|
233
328
|
}
|
|
@@ -235,12 +330,14 @@ async function createRouter(options) {
|
|
|
235
330
|
router.post('/keys/:keyId/block', async (req, res) => {
|
|
236
331
|
try {
|
|
237
332
|
const { keyId } = req.params;
|
|
238
|
-
const tokenEntityRef = await (
|
|
333
|
+
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
239
334
|
await client.blockKey(keyId);
|
|
240
335
|
logger.info({ action: 'key.block', userId: tokenEntityRef ?? 'unknown', keyId });
|
|
241
336
|
res.json({ success: true });
|
|
242
337
|
}
|
|
243
338
|
catch (error) {
|
|
339
|
+
if (sendOwnershipError(error, res))
|
|
340
|
+
return;
|
|
244
341
|
logger.error('Failed to block key', error);
|
|
245
342
|
res.status(500).json({ error: error.message });
|
|
246
343
|
}
|
|
@@ -248,12 +345,14 @@ async function createRouter(options) {
|
|
|
248
345
|
router.post('/keys/:keyId/unblock', async (req, res) => {
|
|
249
346
|
try {
|
|
250
347
|
const { keyId } = req.params;
|
|
251
|
-
const tokenEntityRef = await (
|
|
348
|
+
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
252
349
|
await client.unblockKey(keyId);
|
|
253
350
|
logger.info({ action: 'key.unblock', userId: tokenEntityRef ?? 'unknown', keyId });
|
|
254
351
|
res.json({ success: true });
|
|
255
352
|
}
|
|
256
353
|
catch (error) {
|
|
354
|
+
if (sendOwnershipError(error, res))
|
|
355
|
+
return;
|
|
257
356
|
logger.error('Failed to unblock key', error);
|
|
258
357
|
res.status(500).json({ error: error.message });
|
|
259
358
|
}
|
|
@@ -261,12 +360,14 @@ async function createRouter(options) {
|
|
|
261
360
|
router.post('/keys/:keyId/reset_spend', async (req, res) => {
|
|
262
361
|
try {
|
|
263
362
|
const { keyId } = req.params;
|
|
264
|
-
const tokenEntityRef = await (
|
|
363
|
+
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
265
364
|
await client.resetKeySpend(keyId);
|
|
266
365
|
logger.info({ action: 'key.reset_spend', userId: tokenEntityRef ?? 'unknown', keyId });
|
|
267
366
|
res.json({ success: true });
|
|
268
367
|
}
|
|
269
368
|
catch (error) {
|
|
369
|
+
if (sendOwnershipError(error, res))
|
|
370
|
+
return;
|
|
270
371
|
logger.error('Failed to reset key spend', error);
|
|
271
372
|
res.status(500).json({ error: error.message });
|
|
272
373
|
}
|
|
@@ -358,7 +459,7 @@ async function createRouter(options) {
|
|
|
358
459
|
});
|
|
359
460
|
router.get('/usage', async (req, res) => {
|
|
360
461
|
try {
|
|
361
|
-
const { start_date, end_date
|
|
462
|
+
const { start_date, end_date } = req.query;
|
|
362
463
|
if (!start_date || !end_date) {
|
|
363
464
|
res.status(400).json({ error: 'start_date and end_date are required' });
|
|
364
465
|
return;
|
|
@@ -370,7 +471,7 @@ async function createRouter(options) {
|
|
|
370
471
|
if (userId) {
|
|
371
472
|
await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
|
|
372
473
|
}
|
|
373
|
-
const usage = await client.getUsage(start_date, end_date, userId
|
|
474
|
+
const usage = await client.getUsage(start_date, end_date, userId);
|
|
374
475
|
res.json(usage);
|
|
375
476
|
}
|
|
376
477
|
catch (error) {
|
package/dist/types.cjs.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/types.ts"],
|
|
4
|
-
"sourcesContent": ["export interface UserInfo {\n user_id: string;\n user_email?: string;\n email?: string;\n teams?: string[];\n models?: string[];\n max_budget?: number;\n spend?: number;\n current_spend?: number;\n soft_limit?: number;\n hard_limit?: number;\n /** Backstage-computed: true when the user is a member of litellm.audit.group */\n can_view_audit?: boolean;\n}\n\nexport interface TeamMember {\n user_id: string;\n role: 'admin' | 'user';\n}\n\nexport interface TeamInfo {\n team_id: string;\n team_alias?: string;\n max_budget?: number;\n spend: number;\n members_with_roles?: TeamMember[];\n models?: string[];\n tpm_limit?: number;\n rpm_limit?: number;\n}\n\nexport interface VirtualKey {\n key: string;\n token: string;\n key_alias?: string;\n created_at: string;\n expires_at?: string;\n spend: number;\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n models?: string[];\n user_id?: string;\n blocked?: boolean;\n}\n\n/**\n * Shape of a single entry inside LiteLLM's `/user/info` `keys` array.\n * Differs from VirtualKey: uses `expires` (not `expires_at`), exposes\n * both a hashed `token` and a masked `key_name`, and fields are nullable\n * rather than optional.\n */\nexport interface LiteLLMUserKey {\n token: string;\n key_name?: string;\n key_alias?: string | null;\n spend?: number;\n expires?: string | null;\n models?: string[];\n tpm_limit?: number | null;\n rpm_limit?: number | null;\n max_budget?: number | null;\n user_id?: string | null;\n team_id?: string | null;\n created_at: string;\n blocked?: boolean | null;\n}\n\nexport interface ModelInfo {\n model_name: string;\n mode: string;\n supports_function_calling?: boolean;\n supports_vision?: boolean;\n input_cost_per_token?: number;\n output_cost_per_token?: number;\n}\n\nexport interface UsageModelBreakdown {\n total_spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageKeyBreakdown {\n key_alias?: string;\n team_id?: string | null;\n models: string[];\n total_spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageDailyPoint {\n date: string;\n spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageDailyModelPoint {\n date: string;\n model: string;\n spend: number;\n prompt_tokens: number;\n completion_tokens: number;\n total_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageMetrics {\n total_spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n usage_by_model: Record<string, UsageModelBreakdown>;\n usage_by_key: Record<string, UsageKeyBreakdown>;\n daily_usage: UsageDailyPoint[];\n daily_by_model: UsageDailyModelPoint[];\n}\n\nexport interface GenerateKeyRequest {\n alias?: string;\n models?: string[];\n duration?: string;\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n user_id?: string;\n team_id?: string;\n key_type?: string;\n metadata?: Record<string, string>;\n}\n\nexport interface UpdateKeyRequest {\n key: string;\n key_alias?: string;\n models?: string[];\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n team_id?: string;\n duration?: string;\n}\n\nexport interface GenerateKeyResponse {\n key: string;\n key_alias?: string;\n expires_at?: string;\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n models?: string[];\n}\n\nexport interface DeleteKeyRequest {\n keys: string[];\n}\n\nexport interface LiteLLMConfig {\n baseUrl: string;\n masterKey: string;\n}\n\nexport interface ProvisioningDefaults {\n maxBudget: number;\n budgetDuration: string;\n models: string[];\n teams: string[];\n tpmLimit?: number;\n rpmLimit?: number;\n /**\n * LiteLLM user role applied on /user/new. Defaults to \"internal_user\"\n * which grants self-service Create/Delete/View on the user's own keys.\n * Valid values: proxy_admin, proxy_admin_viewer, internal_user,\n * internal_user_viewer, team.\n */\n userRole?: string;\n metadata: Record<string, string>;\n}\n\nexport interface RoleConfig {\n group: string;\n maxBudget?: number;\n budgetDuration?: string;\n models?: string[];\n teams?: string[];\n tpmLimit?: number;\n rpmLimit?: number;\n userRole?: string;\n metadata?: Record<string, string>;\n}\n\nexport interface CreateUserRequest {\n user_id: string;\n user_email?: string;\n user_alias?: string;\n user_role?: string;\n max_budget?: number;\n budget_duration?: string;\n models?: string[];\n teams?: string[];\n tpm_limit?: number;\n rpm_limit?: number;\n metadata?: Record<string, string>;\n auto_create_key?: boolean;\n}\n\nexport interface CreateUserResponse {\n user_id: string;\n user_email?: string;\n max_budget?: number;\n models?: string[];\n teams?: string[];\n}\n\nexport interface AuditLogEntry {\n id: string;\n updated_at: string;\n changed_by?: string;\n changed_by_api_key?: string;\n action?: string;\n table_name?: string;\n object_id?: string;\n before_value?: Record<string, unknown> | null;\n updated_values?: Record<string, unknown> | null;\n}\n\nexport interface PaginatedAuditLogs {\n audit_logs: AuditLogEntry[];\n total: number;\n page: number;\n page_size: number;\n total_pages: number;\n}\n\nexport interface AuditLogsParams {\n page?: number;\n page_size?: number;\n start_date?: string;\n end_date?: string;\n action?: string;\n table_name?: string;\n changed_by?: string;\n sort_by?: string;\n sort_order?: 'asc' | 'desc';\n}\n"],
|
|
4
|
+
"sourcesContent": ["export interface UserInfo {\n user_id: string;\n user_email?: string;\n email?: string;\n teams?: string[];\n models?: string[];\n max_budget?: number;\n spend?: number;\n current_spend?: number;\n soft_limit?: number;\n hard_limit?: number;\n /** Backstage-computed: true when the user is a member of litellm.audit.group */\n can_view_audit?: boolean;\n}\n\nexport interface TeamMember {\n user_id: string;\n role: 'admin' | 'user';\n}\n\nexport interface TeamInfo {\n team_id: string;\n team_alias?: string;\n max_budget?: number;\n spend: number;\n members_with_roles?: TeamMember[];\n models?: string[];\n tpm_limit?: number;\n rpm_limit?: number;\n}\n\nexport interface VirtualKey {\n key: string;\n token: string;\n key_alias?: string;\n created_at: string;\n expires_at?: string;\n spend: number;\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n models?: string[];\n user_id?: string;\n blocked?: boolean;\n}\n\n/**\n * Shape of a single entry inside LiteLLM's `/user/info` `keys` array.\n * Differs from VirtualKey: uses `expires` (not `expires_at`), exposes\n * both a hashed `token` and a masked `key_name`, and fields are nullable\n * rather than optional.\n */\nexport interface LiteLLMUserKey {\n token: string;\n key_name?: string;\n key_alias?: string | null;\n spend?: number;\n expires?: string | null;\n models?: string[];\n tpm_limit?: number | null;\n rpm_limit?: number | null;\n max_budget?: number | null;\n user_id?: string | null;\n team_id?: string | null;\n created_at: string;\n blocked?: boolean | null;\n}\n\nexport interface ModelInfo {\n model_name: string;\n mode: string;\n supports_function_calling?: boolean;\n supports_vision?: boolean;\n input_cost_per_token?: number;\n output_cost_per_token?: number;\n max_input_tokens?: number;\n max_output_tokens?: number;\n}\n\nexport interface UsageModelBreakdown {\n total_spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageKeyBreakdown {\n key_alias?: string;\n team_id?: string | null;\n models: string[];\n total_spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageDailyPoint {\n date: string;\n spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageDailyModelPoint {\n date: string;\n model: string;\n spend: number;\n prompt_tokens: number;\n completion_tokens: number;\n total_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageMetrics {\n total_spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n usage_by_model: Record<string, UsageModelBreakdown>;\n usage_by_key: Record<string, UsageKeyBreakdown>;\n daily_usage: UsageDailyPoint[];\n daily_by_model: UsageDailyModelPoint[];\n}\n\nexport interface GenerateKeyRequest {\n alias?: string;\n models?: string[];\n duration?: string;\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n user_id?: string;\n team_id?: string;\n key_type?: string;\n metadata?: Record<string, string>;\n /** When true, LiteLLM rotates the key on a schedule. */\n auto_rotate?: boolean;\n /** Rotation interval in days (LiteLLM-enforced when auto_rotate is true). */\n rotation_interval_days?: number;\n}\n\nexport interface UpdateKeyRequest {\n key: string;\n key_alias?: string;\n models?: string[];\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n team_id?: string;\n duration?: string;\n}\n\nexport interface GenerateKeyResponse {\n key: string;\n key_alias?: string;\n expires_at?: string;\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n models?: string[];\n}\n\nexport interface DeleteKeyRequest {\n keys: string[];\n}\n\nexport interface LiteLLMConfig {\n baseUrl: string;\n masterKey: string;\n}\n\nexport interface ProvisioningDefaults {\n maxBudget: number;\n budgetDuration: string;\n models: string[];\n teams: string[];\n tpmLimit?: number;\n rpmLimit?: number;\n /**\n * LiteLLM user role applied on /user/new. Defaults to \"internal_user\"\n * which grants self-service Create/Delete/View on the user's own keys.\n * Valid values: proxy_admin, proxy_admin_viewer, internal_user,\n * internal_user_viewer, team.\n */\n userRole?: string;\n metadata: Record<string, string>;\n}\n\nexport interface RoleConfig {\n group: string;\n maxBudget?: number;\n budgetDuration?: string;\n models?: string[];\n teams?: string[];\n tpmLimit?: number;\n rpmLimit?: number;\n userRole?: string;\n metadata?: Record<string, string>;\n}\n\nexport interface CreateUserRequest {\n user_id: string;\n user_email?: string;\n user_alias?: string;\n user_role?: string;\n max_budget?: number;\n budget_duration?: string;\n models?: string[];\n teams?: string[];\n tpm_limit?: number;\n rpm_limit?: number;\n metadata?: Record<string, string>;\n auto_create_key?: boolean;\n}\n\nexport interface CreateUserResponse {\n user_id: string;\n user_email?: string;\n max_budget?: number;\n models?: string[];\n teams?: string[];\n}\n\nexport interface AuditLogEntry {\n id: string;\n updated_at: string;\n changed_by?: string;\n changed_by_api_key?: string;\n action?: string;\n table_name?: string;\n object_id?: string;\n before_value?: Record<string, unknown> | null;\n updated_values?: Record<string, unknown> | null;\n}\n\nexport interface PaginatedAuditLogs {\n audit_logs: AuditLogEntry[];\n total: number;\n page: number;\n page_size: number;\n total_pages: number;\n}\n\nexport interface AuditLogsParams {\n page?: number;\n page_size?: number;\n start_date?: string;\n end_date?: string;\n action?: string;\n table_name?: string;\n changed_by?: string;\n sort_by?: string;\n sort_order?: 'asc' | 'desc';\n}\n"],
|
|
5
5
|
"mappings": ";;;;;;;;;;;;;;;;AAAA;AAAA;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -68,6 +68,8 @@ export interface ModelInfo {
|
|
|
68
68
|
supports_vision?: boolean;
|
|
69
69
|
input_cost_per_token?: number;
|
|
70
70
|
output_cost_per_token?: number;
|
|
71
|
+
max_input_tokens?: number;
|
|
72
|
+
max_output_tokens?: number;
|
|
71
73
|
}
|
|
72
74
|
export interface UsageModelBreakdown {
|
|
73
75
|
total_spend: number;
|
|
@@ -135,6 +137,10 @@ export interface GenerateKeyRequest {
|
|
|
135
137
|
team_id?: string;
|
|
136
138
|
key_type?: string;
|
|
137
139
|
metadata?: Record<string, string>;
|
|
140
|
+
/** When true, LiteLLM rotates the key on a schedule. */
|
|
141
|
+
auto_rotate?: boolean;
|
|
142
|
+
/** Rotation interval in days (LiteLLM-enforced when auto_rotate is true). */
|
|
143
|
+
rotation_interval_days?: number;
|
|
138
144
|
}
|
|
139
145
|
export interface UpdateKeyRequest {
|
|
140
146
|
key: string;
|