@acarmisc/backstage-plugin-litellm-backend 0.3.4 → 0.4.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.
@@ -80,6 +80,11 @@ export declare class ProvisioningError extends Error {
80
80
  * When provisioning is disabled, throws a ProvisioningError with a clear message.
81
81
  */
82
82
  export declare function getOrProvisionUser(client: LiteLLMClient, tokenEntityRef: string | undefined, userId: string | undefined, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, roleConfigs: RoleConfig[], catalogClient: CatalogClient, auth: AuthService, logger: any): Promise<UserInfo>;
83
+ /**
84
+ * Returns true when the user is a member of the given Backstage group entity ref.
85
+ * Used for RBAC gates (e.g. audit log visibility) without the role-config machinery.
86
+ */
87
+ export declare function isUserMemberOfGroup(userEntityRef: string, group: string, catalogClient: CatalogClient, auth: AuthService, logger: any): Promise<boolean>;
83
88
  /**
84
89
  * Fetches the user's Backstage group memberships and returns the first matching
85
90
  * role config (priority order), or undefined when no role matches.
@@ -9,6 +9,7 @@ exports.resolveUserId = resolveUserId;
9
9
  exports.resolveUserProfile = resolveUserProfile;
10
10
  exports.provisionUser = provisionUser;
11
11
  exports.getOrProvisionUser = getOrProvisionUser;
12
+ exports.isUserMemberOfGroup = isUserMemberOfGroup;
12
13
  exports.resolveUserRole = resolveUserRole;
13
14
  /**
14
15
  * Converts a Backstage user entity ref to a LiteLLM user_id.
@@ -314,6 +315,27 @@ async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEn
314
315
  provisioningInFlight.delete(userId);
315
316
  }
316
317
  }
318
+ /**
319
+ * Returns true when the user is a member of the given Backstage group entity ref.
320
+ * Used for RBAC gates (e.g. audit log visibility) without the role-config machinery.
321
+ */
322
+ async function isUserMemberOfGroup(userEntityRef, group, catalogClient, auth, logger) {
323
+ try {
324
+ const { token } = await auth.getPluginRequestToken({
325
+ onBehalfOf: await auth.getOwnServiceCredentials(),
326
+ targetPluginId: 'catalog',
327
+ });
328
+ const entity = await catalogClient.getEntityByRef(userEntityRef, { token });
329
+ const groups = (entity?.relations ?? [])
330
+ .filter(r => r.type === 'memberOf')
331
+ .map(r => r.targetRef);
332
+ return groups.includes(group);
333
+ }
334
+ catch (err) {
335
+ logger.warn(`Could not check group membership for ${userEntityRef}: ${err.message}`);
336
+ return false;
337
+ }
338
+ }
317
339
  /**
318
340
  * Fetches the user's Backstage group memberships and returns the first matching
319
341
  * role config (priority order), or undefined when no role matches.
package/dist/router.js CHANGED
@@ -49,6 +49,7 @@ async function createRouter(options) {
49
49
  const client = options.client ?? new client_1.LiteLLMClient({ baseUrl, masterKey });
50
50
  const { enabled: provisioningEnabled, defaults: provisioningDefaults } = (0, provisioning_1.readProvisioningDefaults)(config);
51
51
  const roleConfigs = (0, provisioning_1.readRoleConfigs)(config);
52
+ const auditGroup = config.getOptionalString('litellm.audit.group');
52
53
  const catalogClient = new catalog_client_1.CatalogClient({ discoveryApi: discovery });
53
54
  if (provisioningEnabled) {
54
55
  logger.info(`LiteLLM auto-provisioning enabled — defaults: budget=$${provisioningDefaults.maxBudget}/${provisioningDefaults.budgetDuration}, models=${provisioningDefaults.models.length
@@ -70,7 +71,10 @@ async function createRouter(options) {
70
71
  ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
71
72
  : req.query.user_id;
72
73
  const userInfo = await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
73
- res.json(userInfo);
74
+ const canViewAudit = auditGroup && tokenEntityRef
75
+ ? await (0, provisioning_1.isUserMemberOfGroup)(tokenEntityRef, auditGroup, catalogClient, auth, logger)
76
+ : false;
77
+ res.json({ ...userInfo, can_view_audit: canViewAudit });
74
78
  }
75
79
  catch (error) {
76
80
  if (error instanceof provisioning_1.ProvisioningError) {
@@ -100,6 +104,23 @@ async function createRouter(options) {
100
104
  res.status(500).json({ error: error.message });
101
105
  }
102
106
  });
107
+ router.post('/keys/:keyId/regenerate', async (req, res) => {
108
+ try {
109
+ const { keyId } = req.params;
110
+ if (!keyId) {
111
+ res.status(400).json({ error: 'keyId is required' });
112
+ return;
113
+ }
114
+ const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
115
+ const result = await client.regenerateKey(keyId);
116
+ logger.info({ action: 'key.rotate', userId: tokenEntityRef ?? 'unknown', keyId });
117
+ res.json(result);
118
+ }
119
+ catch (error) {
120
+ logger.error('Failed to rotate key', error);
121
+ res.status(500).json({ error: error.message });
122
+ }
123
+ });
103
124
  router.post('/keys/generate', async (req, res) => {
104
125
  try {
105
126
  // Only alias + max_budget are required. An empty models array is
@@ -151,6 +172,7 @@ async function createRouter(options) {
151
172
  ...(resolvedUserId && { user_id: resolvedUserId }),
152
173
  };
153
174
  const result = await client.generateKey(request);
175
+ logger.info({ action: 'key.generate', userId: resolvedUserId ?? 'unknown', keyAlias: body.alias });
154
176
  res.json(result);
155
177
  }
156
178
  catch (error) {
@@ -169,8 +191,10 @@ async function createRouter(options) {
169
191
  res.status(400).json({ error: 'keyId is required' });
170
192
  return;
171
193
  }
194
+ const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
172
195
  const request = { ...req.body, key: keyId };
173
196
  const result = await client.updateKey(request);
197
+ logger.info({ action: 'key.update', userId: tokenEntityRef ?? 'unknown', keyId });
174
198
  res.json(result);
175
199
  }
176
200
  catch (error) {
@@ -185,7 +209,9 @@ async function createRouter(options) {
185
209
  res.status(400).json({ error: 'keyId is required' });
186
210
  return;
187
211
  }
212
+ const deleteEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
188
213
  await client.deleteKeys({ keys: [keyId] });
214
+ logger.info({ action: 'key.delete', userId: deleteEntityRef ?? 'unknown', keyId });
189
215
  res.json({ success: true });
190
216
  }
191
217
  catch (error) {
@@ -193,6 +219,78 @@ async function createRouter(options) {
193
219
  res.status(500).json({ error: error.message });
194
220
  }
195
221
  });
222
+ router.post('/keys/:keyId/block', async (req, res) => {
223
+ try {
224
+ const { keyId } = req.params;
225
+ const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
226
+ await client.blockKey(keyId);
227
+ logger.info({ action: 'key.block', userId: tokenEntityRef ?? 'unknown', keyId });
228
+ res.json({ success: true });
229
+ }
230
+ catch (error) {
231
+ logger.error('Failed to block key', error);
232
+ res.status(500).json({ error: error.message });
233
+ }
234
+ });
235
+ router.post('/keys/:keyId/unblock', async (req, res) => {
236
+ try {
237
+ const { keyId } = req.params;
238
+ const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
239
+ await client.unblockKey(keyId);
240
+ logger.info({ action: 'key.unblock', userId: tokenEntityRef ?? 'unknown', keyId });
241
+ res.json({ success: true });
242
+ }
243
+ catch (error) {
244
+ logger.error('Failed to unblock key', error);
245
+ res.status(500).json({ error: error.message });
246
+ }
247
+ });
248
+ router.post('/keys/:keyId/reset_spend', async (req, res) => {
249
+ try {
250
+ const { keyId } = req.params;
251
+ const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
252
+ await client.resetKeySpend(keyId);
253
+ logger.info({ action: 'key.reset_spend', userId: tokenEntityRef ?? 'unknown', keyId });
254
+ res.json({ success: true });
255
+ }
256
+ catch (error) {
257
+ logger.error('Failed to reset key spend', error);
258
+ res.status(500).json({ error: error.message });
259
+ }
260
+ });
261
+ router.get('/audit', async (req, res) => {
262
+ if (!auditGroup) {
263
+ res.status(403).json({ error: 'Audit log is not configured (litellm.audit.group not set)' });
264
+ return;
265
+ }
266
+ const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
267
+ if (!tokenEntityRef) {
268
+ res.status(401).json({ error: 'Authentication required' });
269
+ return;
270
+ }
271
+ const allowed = await (0, provisioning_1.isUserMemberOfGroup)(tokenEntityRef, auditGroup, catalogClient, auth, logger);
272
+ if (!allowed) {
273
+ res.status(403).json({ error: 'Access denied: not a member of the audit group' });
274
+ return;
275
+ }
276
+ try {
277
+ const { page, page_size, start_date, end_date, action, table_name, changed_by } = req.query;
278
+ const result = await client.getAuditLogs({
279
+ page: page ? Number(page) : undefined,
280
+ page_size: page_size ? Number(page_size) : 25,
281
+ start_date,
282
+ end_date,
283
+ action,
284
+ table_name,
285
+ changed_by,
286
+ });
287
+ res.json(result);
288
+ }
289
+ catch (error) {
290
+ logger.error('Failed to fetch audit logs', error);
291
+ res.status(500).json({ error: error.message });
292
+ }
293
+ });
196
294
  router.get('/models', async (_req, res) => {
197
295
  try {
198
296
  const models = await client.listModels();
@@ -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}\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}\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}\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"],
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"],
5
5
  "mappings": ";;;;;;;;;;;;;;;;AAAA;AAAA;",
6
6
  "names": []
7
7
  }
package/dist/types.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface UserInfo {
9
9
  current_spend?: number;
10
10
  soft_limit?: number;
11
11
  hard_limit?: number;
12
+ /** Backstage-computed: true when the user is a member of litellm.audit.group */
13
+ can_view_audit?: boolean;
12
14
  }
13
15
  export interface TeamMember {
14
16
  user_id: string;
@@ -36,6 +38,7 @@ export interface VirtualKey {
36
38
  rpm_limit?: number;
37
39
  models?: string[];
38
40
  user_id?: string;
41
+ blocked?: boolean;
39
42
  }
40
43
  /**
41
44
  * Shape of a single entry inside LiteLLM's `/user/info` `keys` array.
@@ -56,6 +59,7 @@ export interface LiteLLMUserKey {
56
59
  user_id?: string | null;
57
60
  team_id?: string | null;
58
61
  created_at: string;
62
+ blocked?: boolean | null;
59
63
  }
60
64
  export interface ModelInfo {
61
65
  model_name: string;
@@ -206,3 +210,32 @@ export interface CreateUserResponse {
206
210
  models?: string[];
207
211
  teams?: string[];
208
212
  }
213
+ export interface AuditLogEntry {
214
+ id: string;
215
+ updated_at: string;
216
+ changed_by?: string;
217
+ changed_by_api_key?: string;
218
+ action?: string;
219
+ table_name?: string;
220
+ object_id?: string;
221
+ before_value?: Record<string, unknown> | null;
222
+ updated_values?: Record<string, unknown> | null;
223
+ }
224
+ export interface PaginatedAuditLogs {
225
+ audit_logs: AuditLogEntry[];
226
+ total: number;
227
+ page: number;
228
+ page_size: number;
229
+ total_pages: number;
230
+ }
231
+ export interface AuditLogsParams {
232
+ page?: number;
233
+ page_size?: number;
234
+ start_date?: string;
235
+ end_date?: string;
236
+ action?: string;
237
+ table_name?: string;
238
+ changed_by?: string;
239
+ sort_by?: string;
240
+ sort_order?: 'asc' | 'desc';
241
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acarmisc/backstage-plugin-litellm-backend",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "The Backstage backend plugin for LiteLLM governance",
5
5
  "backstage": {
6
6
  "role": "backend-plugin",
@@ -44,4 +44,4 @@
44
44
  "@types/node": "^20.0.0"
45
45
  },
46
46
  "configSchema": "config.d.ts"
47
- }
47
+ }