@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.
@@ -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,266 @@
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
+ } } } },
109
+ },
110
+ responses: {
111
+ '200': { description: 'Generated key (includes the raw secret — shown once)' },
112
+ '400': { description: 'Missing required fields' },
113
+ },
114
+ },
115
+ },
116
+ '/keys/{keyId}': {
117
+ delete: {
118
+ tags: ['Keys'],
119
+ summary: 'Revoke/delete a virtual key (caller must own it)',
120
+ parameters: [{ name: 'keyId', in: 'path', required: true, schema: { type: 'string' } }],
121
+ responses: { '200': { description: 'Deleted' }, '403': { description: 'Not the key owner' } },
122
+ },
123
+ },
124
+ '/keys/{keyId}/update': {
125
+ post: {
126
+ tags: ['Keys'],
127
+ summary: 'Update alias / models / budget / limits (caller must own it)',
128
+ parameters: [{ name: 'keyId', in: 'path', required: true, schema: { type: 'string' } }],
129
+ requestBody: { content: { 'application/json': { schema: { type: 'object' } } } },
130
+ responses: { '200': { description: 'Updated' }, '403': { description: 'Not the key owner' } },
131
+ },
132
+ },
133
+ '/keys/{keyId}/block': {
134
+ post: {
135
+ tags: ['Keys'],
136
+ summary: 'Suspend a key without revoking (caller must own it)',
137
+ parameters: [{ name: 'keyId', in: 'path', required: true, schema: { type: 'string' } }],
138
+ responses: { '200': { description: 'Blocked' }, '403': { description: 'Not the key owner' } },
139
+ },
140
+ },
141
+ '/keys/{keyId}/unblock': {
142
+ post: {
143
+ tags: ['Keys'],
144
+ summary: 'Re-enable a blocked key (caller must own it)',
145
+ parameters: [{ name: 'keyId', in: 'path', required: true, schema: { type: 'string' } }],
146
+ responses: { '200': { description: 'Unblocked' }, '403': { description: 'Not the key owner' } },
147
+ },
148
+ },
149
+ '/keys/{keyId}/reset_spend': {
150
+ post: {
151
+ tags: ['Keys'],
152
+ summary: 'Zero out a key spend counter (caller must own it)',
153
+ parameters: [{ name: 'keyId', in: 'path', required: true, schema: { type: 'string' } }],
154
+ responses: { '200': { description: 'Spend reset' }, '403': { description: 'Not the key owner' } },
155
+ },
156
+ },
157
+ '/models': {
158
+ get: {
159
+ tags: ['Models'],
160
+ summary: 'List available LLM models',
161
+ responses: { '200': { description: 'Model catalogue' } },
162
+ },
163
+ },
164
+ '/teams': {
165
+ get: {
166
+ tags: ['Teams'],
167
+ summary: 'List teams the current user belongs to',
168
+ responses: { '200': { description: 'Array of teams' } },
169
+ },
170
+ },
171
+ '/teams/{teamId}/usage': {
172
+ get: {
173
+ tags: ['Teams'],
174
+ summary: 'Usage metrics for a team',
175
+ parameters: [
176
+ { name: 'teamId', in: 'path', required: true, schema: { type: 'string' } },
177
+ { name: 'start_date', in: 'query', required: true, schema: { type: 'string' } },
178
+ { name: 'end_date', in: 'query', required: true, schema: { type: 'string' } },
179
+ ],
180
+ responses: { '200': { description: 'Team usage' }, '400': { description: 'Missing date range' } },
181
+ },
182
+ },
183
+ '/usage': {
184
+ get: {
185
+ tags: ['Usage'],
186
+ summary: 'Usage metrics for the current user',
187
+ parameters: [
188
+ { name: 'start_date', in: 'query', required: true, schema: { type: 'string' } },
189
+ { name: 'end_date', in: 'query', required: true, schema: { type: 'string' } },
190
+ ],
191
+ responses: { '200': { description: 'Usage metrics' }, '400': { description: 'Missing date range' } },
192
+ },
193
+ },
194
+ '/audit': {
195
+ get: {
196
+ tags: ['Audit'],
197
+ summary: 'Audit logs (gated by litellm.audit.group membership)',
198
+ parameters: [
199
+ { name: 'page', in: 'query', schema: { type: 'integer' } },
200
+ { name: 'page_size', in: 'query', schema: { type: 'integer' } },
201
+ { name: 'start_date', in: 'query', schema: { type: 'string' } },
202
+ { name: 'end_date', in: 'query', schema: { type: 'string' } },
203
+ { name: 'action', in: 'query', schema: { type: 'string' } },
204
+ { name: 'table_name', in: 'query', schema: { type: 'string' } },
205
+ { name: 'changed_by', in: 'query', schema: { type: 'string' } },
206
+ ],
207
+ responses: { '200': { description: 'Paginated audit logs' }, '403': { description: 'Not configured or not authorized' } },
208
+ },
209
+ },
210
+ '/provisioning/preview': {
211
+ get: {
212
+ tags: ['Provisioning'],
213
+ summary: 'Resolve which role a Backstage group maps to (dry-run)',
214
+ parameters: [
215
+ { name: 'group', in: 'query', required: true, schema: { type: 'string' }, description: 'e.g. group:default/ai-platform' },
216
+ ],
217
+ responses: {
218
+ '200': { description: 'Resolved role and effective defaults' },
219
+ '400': { description: 'Missing group parameter' },
220
+ '403': { description: 'Not configured or not authorized' },
221
+ },
222
+ },
223
+ },
224
+ '/bridge/health': {
225
+ get: {
226
+ tags: ['Bridge'],
227
+ summary: 'Bridge health + configured clientId (no auth)',
228
+ responses: { '200': { description: 'Bridge health' } },
229
+ },
230
+ },
231
+ '/bridge/keys': {
232
+ get: {
233
+ tags: ['Bridge'],
234
+ summary: 'List the caller virtual keys (Keycloak token auth)',
235
+ responses: { '200': { description: 'Array of keys' }, '401': { description: 'Invalid token' } },
236
+ },
237
+ post: {
238
+ tags: ['Bridge'],
239
+ summary: 'Mint a virtual key for the caller (Keycloak token auth)',
240
+ requestBody: { content: { 'application/json': { schema: { type: 'object' } } } },
241
+ responses: { '200': { description: 'Generated key' }, '401': { description: 'Invalid token' } },
242
+ },
243
+ },
244
+ '/bridge/models': {
245
+ get: {
246
+ tags: ['Bridge'],
247
+ summary: 'List available LLM models (Keycloak token auth)',
248
+ responses: { '200': { description: 'Model catalogue' }, '401': { description: 'Invalid token' } },
249
+ },
250
+ },
251
+ },
252
+ components: {
253
+ securitySchemes: {
254
+ backstageUserToken: {
255
+ type: 'http',
256
+ scheme: 'bearer',
257
+ description: 'Backstage-issued user token (Authorization: Bearer <token>).',
258
+ },
259
+ keycloakAccessToken: {
260
+ type: 'http',
261
+ scheme: 'bearer',
262
+ description: 'Raw Keycloak access token. Bridge routes only; verified against the realm JWKS.',
263
+ },
264
+ },
265
+ },
266
+ };
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,23 +168,39 @@ async function createRouter(options) {
113
168
  res.status(500).json({ error: error.message });
114
169
  }
115
170
  });
116
- router.post('/keys/:keyId/regenerate', async (req, res) => {
117
- try {
118
- const { keyId } = req.params;
119
- if (!keyId) {
120
- res.status(400).json({ error: 'keyId is required' });
121
- return;
122
- }
123
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
124
- const result = await client.regenerateKey(keyId);
125
- logger.info({ action: 'key.rotate', userId: tokenEntityRef ?? 'unknown', keyId });
126
- res.json(result);
127
- }
128
- catch (error) {
129
- logger.error('Failed to rotate key', error);
130
- res.status(500).json({ error: error.message });
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 fetch the caller's own key
177
+ // list and 403 if the target token isn't in it.
178
+ //
179
+ // Returns the caller's LiteLLM user_id (when resolvable) so handlers can
180
+ // stamp it in logs without re-deriving it.
181
+ async function authorizeKeyAction(req, keyId) {
182
+ const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
183
+ const userId = tokenEntityRef
184
+ ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
185
+ : req.query.user_id;
186
+ if (!userId) {
187
+ throw { status: 403, body: { error: 'Cannot verify key ownership without an authenticated user' } };
188
+ }
189
+ const ownKeys = await client.listKeys(userId);
190
+ const owns = ownKeys.some(k => (k.token ?? k.key) === keyId);
191
+ if (!owns) {
192
+ throw { status: 403, body: { error: 'Access denied: key does not belong to the caller' } };
193
+ }
194
+ return { tokenEntityRef, userId };
195
+ }
196
+ // Normalize the ownership-guard rejection shape into a response.
197
+ function sendOwnershipError(err, res) {
198
+ if (err && typeof err.status === 'number' && err.body) {
199
+ res.status(err.status).json(err.body);
200
+ return true;
131
201
  }
132
- });
202
+ return false;
203
+ }
133
204
  router.post('/keys/generate', async (req, res) => {
134
205
  try {
135
206
  // Only alias is hard-required. max_budget is optional: a positive
@@ -204,13 +275,15 @@ async function createRouter(options) {
204
275
  res.status(400).json({ error: 'keyId is required' });
205
276
  return;
206
277
  }
207
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
278
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
208
279
  const request = { ...req.body, key: keyId };
209
280
  const result = await client.updateKey(request);
210
281
  logger.info({ action: 'key.update', userId: tokenEntityRef ?? 'unknown', keyId });
211
282
  res.json(result);
212
283
  }
213
284
  catch (error) {
285
+ if (sendOwnershipError(error, res))
286
+ return;
214
287
  logger.error('Failed to update key', error);
215
288
  res.status(500).json({ error: error.message });
216
289
  }
@@ -222,12 +295,14 @@ async function createRouter(options) {
222
295
  res.status(400).json({ error: 'keyId is required' });
223
296
  return;
224
297
  }
225
- const deleteEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
298
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
226
299
  await client.deleteKeys({ keys: [keyId] });
227
- logger.info({ action: 'key.delete', userId: deleteEntityRef ?? 'unknown', keyId });
300
+ logger.info({ action: 'key.delete', userId: tokenEntityRef ?? 'unknown', keyId });
228
301
  res.json({ success: true });
229
302
  }
230
303
  catch (error) {
304
+ if (sendOwnershipError(error, res))
305
+ return;
231
306
  logger.error('Failed to delete key', error);
232
307
  res.status(500).json({ error: error.message });
233
308
  }
@@ -235,12 +310,14 @@ async function createRouter(options) {
235
310
  router.post('/keys/:keyId/block', async (req, res) => {
236
311
  try {
237
312
  const { keyId } = req.params;
238
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
313
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
239
314
  await client.blockKey(keyId);
240
315
  logger.info({ action: 'key.block', userId: tokenEntityRef ?? 'unknown', keyId });
241
316
  res.json({ success: true });
242
317
  }
243
318
  catch (error) {
319
+ if (sendOwnershipError(error, res))
320
+ return;
244
321
  logger.error('Failed to block key', error);
245
322
  res.status(500).json({ error: error.message });
246
323
  }
@@ -248,12 +325,14 @@ async function createRouter(options) {
248
325
  router.post('/keys/:keyId/unblock', async (req, res) => {
249
326
  try {
250
327
  const { keyId } = req.params;
251
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
328
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
252
329
  await client.unblockKey(keyId);
253
330
  logger.info({ action: 'key.unblock', userId: tokenEntityRef ?? 'unknown', keyId });
254
331
  res.json({ success: true });
255
332
  }
256
333
  catch (error) {
334
+ if (sendOwnershipError(error, res))
335
+ return;
257
336
  logger.error('Failed to unblock key', error);
258
337
  res.status(500).json({ error: error.message });
259
338
  }
@@ -261,12 +340,14 @@ async function createRouter(options) {
261
340
  router.post('/keys/:keyId/reset_spend', async (req, res) => {
262
341
  try {
263
342
  const { keyId } = req.params;
264
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
343
+ const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
265
344
  await client.resetKeySpend(keyId);
266
345
  logger.info({ action: 'key.reset_spend', userId: tokenEntityRef ?? 'unknown', keyId });
267
346
  res.json({ success: true });
268
347
  }
269
348
  catch (error) {
349
+ if (sendOwnershipError(error, res))
350
+ return;
270
351
  logger.error('Failed to reset key spend', error);
271
352
  res.status(500).json({ error: error.message });
272
353
  }
@@ -358,7 +439,7 @@ async function createRouter(options) {
358
439
  });
359
440
  router.get('/usage', async (req, res) => {
360
441
  try {
361
- const { start_date, end_date, group_by } = req.query;
442
+ const { start_date, end_date } = req.query;
362
443
  if (!start_date || !end_date) {
363
444
  res.status(400).json({ error: 'start_date and end_date are required' });
364
445
  return;
@@ -370,7 +451,7 @@ async function createRouter(options) {
370
451
  if (userId) {
371
452
  await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
372
453
  }
373
- const usage = await client.getUsage(start_date, end_date, userId, group_by);
454
+ const usage = await client.getUsage(start_date, end_date, userId);
374
455
  res.json(usage);
375
456
  }
376
457
  catch (error) {
@@ -441,21 +522,6 @@ async function createRouter(options) {
441
522
  handleBridgeError(error, res);
442
523
  }
443
524
  });
444
- router.post('/bridge/keys/regenerate', async (req, res) => {
445
- try {
446
- const claims = await requireClaims(req);
447
- const alias = (req.body ?? {}).alias?.trim();
448
- if (!alias) {
449
- res.status(400).json({ error: 'alias is required' });
450
- return;
451
- }
452
- const result = await (0, bridge_1.bridgeRegenerateKey)(client, claims, provisioningEnabled, provisioningDefaults, logger, alias, userIdDomain);
453
- res.json(result);
454
- }
455
- catch (error) {
456
- handleBridgeError(error, res);
457
- }
458
- });
459
525
  router.get('/bridge/models', async (req, res) => {
460
526
  try {
461
527
  await requireClaims(req); // authenticate only
@@ -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}\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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acarmisc/backstage-plugin-litellm-backend",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "The Backstage backend plugin for LiteLLM governance",
5
5
  "backstage": {
6
6
  "role": "backend-plugin",