@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.
@@ -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;
@@ -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");
@@ -46,6 +47,10 @@ async function createRouter(options) {
46
47
  const baseUrl = config.getString('litellm.baseUrl');
47
48
  const masterKey = config.getString('litellm.masterKey');
48
49
  const userIdDomain = config.getOptionalString('litellm.userIdDomain');
50
+ // Publicly reachable LiteLLM proxy URL, used to build ready-to-paste
51
+ // snippets in the frontend. Falls back to baseUrl (the internal URL the
52
+ // backend calls) when the operator has not configured a public one.
53
+ const publicBaseUrl = config.getOptionalString('litellm.publicBaseUrl') ?? baseUrl;
49
54
  const client = options.client ?? new client_1.LiteLLMClient({ baseUrl, masterKey });
50
55
  const { enabled: provisioningEnabled, defaults: provisioningDefaults } = (0, provisioning_1.readProvisioningDefaults)(config);
51
56
  const roleConfigs = (0, provisioning_1.readRoleConfigs)(config);
@@ -64,6 +69,65 @@ async function createRouter(options) {
64
69
  router.get('/health', (_req, res) => {
65
70
  res.json({ status: 'ok', provisioning: provisioningEnabled });
66
71
  });
72
+ // Exposes the public LiteLLM proxy URL so the frontend can build
73
+ // ready-to-paste curl / OpenAI-SDK snippets for freshly generated keys.
74
+ router.get('/config', (_req, res) => {
75
+ res.json({ baseUrl: publicBaseUrl });
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
+ });
67
131
  router.get('/user/info', async (req, res) => {
68
132
  try {
69
133
  const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
@@ -104,6 +168,40 @@ async function createRouter(options) {
104
168
  res.status(500).json({ error: error.message });
105
169
  }
106
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
+ }
107
205
  router.post('/keys/:keyId/regenerate', async (req, res) => {
108
206
  try {
109
207
  const { keyId } = req.params;
@@ -111,28 +209,34 @@ async function createRouter(options) {
111
209
  res.status(400).json({ error: 'keyId is required' });
112
210
  return;
113
211
  }
114
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
212
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
115
213
  const result = await client.regenerateKey(keyId);
116
214
  logger.info({ action: 'key.rotate', userId: tokenEntityRef ?? 'unknown', keyId });
117
215
  res.json(result);
118
216
  }
119
217
  catch (error) {
218
+ if (sendOwnershipError(error, res))
219
+ return;
120
220
  logger.error('Failed to rotate key', error);
121
221
  res.status(500).json({ error: error.message });
122
222
  }
123
223
  });
124
224
  router.post('/keys/generate', async (req, res) => {
125
225
  try {
126
- // Only alias + max_budget are required. An empty models array is
127
- // intentional in LiteLLM `models: []` means "all models the user
128
- // can access" which is the desired default. Forcing a selection
129
- // up front is too restrictive for the common case.
226
+ // Only alias is hard-required. max_budget is optional: a positive
227
+ // number caps spend, while null/undefined means "unlimited" (LiteLLM
228
+ // supports null). An empty models array is intentional in LiteLLM
229
+ // `models: []` means "all models the user can access" which is the
230
+ // desired default. Forcing a selection up front is too restrictive for
231
+ // the common case.
130
232
  const body = (req.body ?? {});
131
233
  const missing = [];
132
234
  if (!body.alias?.trim())
133
235
  missing.push('alias');
134
- if (typeof body.max_budget !== 'number' || body.max_budget <= 0) {
135
- missing.push('max_budget (positive number)');
236
+ if (body.max_budget !== null &&
237
+ body.max_budget !== undefined &&
238
+ (typeof body.max_budget !== 'number' || body.max_budget <= 0)) {
239
+ missing.push('max_budget (positive number, or null for unlimited)');
136
240
  }
137
241
  if (missing.length) {
138
242
  res.status(400).json({
@@ -191,13 +295,15 @@ async function createRouter(options) {
191
295
  res.status(400).json({ error: 'keyId is required' });
192
296
  return;
193
297
  }
194
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
298
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
195
299
  const request = { ...req.body, key: keyId };
196
300
  const result = await client.updateKey(request);
197
301
  logger.info({ action: 'key.update', userId: tokenEntityRef ?? 'unknown', keyId });
198
302
  res.json(result);
199
303
  }
200
304
  catch (error) {
305
+ if (sendOwnershipError(error, res))
306
+ return;
201
307
  logger.error('Failed to update key', error);
202
308
  res.status(500).json({ error: error.message });
203
309
  }
@@ -209,12 +315,14 @@ async function createRouter(options) {
209
315
  res.status(400).json({ error: 'keyId is required' });
210
316
  return;
211
317
  }
212
- const deleteEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
318
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
213
319
  await client.deleteKeys({ keys: [keyId] });
214
- logger.info({ action: 'key.delete', userId: deleteEntityRef ?? 'unknown', keyId });
320
+ logger.info({ action: 'key.delete', userId: tokenEntityRef ?? 'unknown', keyId });
215
321
  res.json({ success: true });
216
322
  }
217
323
  catch (error) {
324
+ if (sendOwnershipError(error, res))
325
+ return;
218
326
  logger.error('Failed to delete key', error);
219
327
  res.status(500).json({ error: error.message });
220
328
  }
@@ -222,12 +330,14 @@ async function createRouter(options) {
222
330
  router.post('/keys/:keyId/block', async (req, res) => {
223
331
  try {
224
332
  const { keyId } = req.params;
225
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
333
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
226
334
  await client.blockKey(keyId);
227
335
  logger.info({ action: 'key.block', userId: tokenEntityRef ?? 'unknown', keyId });
228
336
  res.json({ success: true });
229
337
  }
230
338
  catch (error) {
339
+ if (sendOwnershipError(error, res))
340
+ return;
231
341
  logger.error('Failed to block key', error);
232
342
  res.status(500).json({ error: error.message });
233
343
  }
@@ -235,12 +345,14 @@ async function createRouter(options) {
235
345
  router.post('/keys/:keyId/unblock', async (req, res) => {
236
346
  try {
237
347
  const { keyId } = req.params;
238
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
348
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
239
349
  await client.unblockKey(keyId);
240
350
  logger.info({ action: 'key.unblock', userId: tokenEntityRef ?? 'unknown', keyId });
241
351
  res.json({ success: true });
242
352
  }
243
353
  catch (error) {
354
+ if (sendOwnershipError(error, res))
355
+ return;
244
356
  logger.error('Failed to unblock key', error);
245
357
  res.status(500).json({ error: error.message });
246
358
  }
@@ -248,12 +360,14 @@ async function createRouter(options) {
248
360
  router.post('/keys/:keyId/reset_spend', async (req, res) => {
249
361
  try {
250
362
  const { keyId } = req.params;
251
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
363
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
252
364
  await client.resetKeySpend(keyId);
253
365
  logger.info({ action: 'key.reset_spend', userId: tokenEntityRef ?? 'unknown', keyId });
254
366
  res.json({ success: true });
255
367
  }
256
368
  catch (error) {
369
+ if (sendOwnershipError(error, res))
370
+ return;
257
371
  logger.error('Failed to reset key spend', error);
258
372
  res.status(500).json({ error: error.message });
259
373
  }
@@ -345,7 +459,7 @@ async function createRouter(options) {
345
459
  });
346
460
  router.get('/usage', async (req, res) => {
347
461
  try {
348
- const { start_date, end_date, group_by } = req.query;
462
+ const { start_date, end_date } = req.query;
349
463
  if (!start_date || !end_date) {
350
464
  res.status(400).json({ error: 'start_date and end_date are required' });
351
465
  return;
@@ -357,7 +471,7 @@ async function createRouter(options) {
357
471
  if (userId) {
358
472
  await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
359
473
  }
360
- const usage = await client.getUsage(start_date, end_date, userId, group_by);
474
+ const usage = await client.getUsage(start_date, end_date, userId);
361
475
  res.json(usage);
362
476
  }
363
477
  catch (error) {
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acarmisc/backstage-plugin-litellm-backend",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "The Backstage backend plugin for LiteLLM governance",
5
5
  "backstage": {
6
6
  "role": "backend-plugin",