@acarmisc/backstage-plugin-litellm-backend 0.14.0 → 0.16.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.
Files changed (53) hide show
  1. package/config.d.ts +115 -22
  2. package/dist/bridge.d.ts +23 -16
  3. package/dist/bridge.js +51 -11
  4. package/dist/client.d.ts +7 -2
  5. package/dist/client.js +36 -9
  6. package/dist/errors.d.ts +64 -0
  7. package/dist/errors.js +205 -0
  8. package/dist/http/respondTeam.d.ts +15 -0
  9. package/dist/http/respondTeam.js +27 -0
  10. package/dist/http/teamFetch.d.ts +14 -0
  11. package/dist/http/teamFetch.js +44 -0
  12. package/dist/index.cjs.js +4249 -3375
  13. package/dist/index.cjs.js.map +4 -4
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +15 -13
  16. package/dist/openapi.js +17 -0
  17. package/dist/plugin.js +2 -2
  18. package/dist/provisioning.d.ts +17 -11
  19. package/dist/provisioning.js +36 -22
  20. package/dist/router.d.ts +3 -4
  21. package/dist/router.js +188 -1349
  22. package/dist/routes/bridge.d.ts +8 -0
  23. package/dist/routes/bridge.js +130 -0
  24. package/dist/routes/config.d.ts +7 -0
  25. package/dist/routes/config.js +89 -0
  26. package/dist/routes/context.d.ts +55 -0
  27. package/dist/routes/context.js +2 -0
  28. package/dist/routes/keys.d.ts +8 -0
  29. package/dist/routes/keys.js +471 -0
  30. package/dist/routes/middleware/withUser.d.ts +18 -0
  31. package/dist/routes/middleware/withUser.js +41 -0
  32. package/dist/routes/objectPermissions.d.ts +3 -0
  33. package/dist/routes/objectPermissions.js +196 -0
  34. package/dist/routes/opencode.d.ts +3 -0
  35. package/dist/routes/opencode.js +239 -0
  36. package/dist/routes/teamUsage.d.ts +3 -0
  37. package/dist/routes/teamUsage.js +73 -0
  38. package/dist/routes/teams.d.ts +3 -0
  39. package/dist/routes/teams.js +367 -0
  40. package/dist/services/keyService.d.ts +61 -0
  41. package/dist/services/keyService.js +179 -0
  42. package/dist/services/modelAccess.d.ts +9 -0
  43. package/dist/services/modelAccess.js +30 -0
  44. package/dist/services/userInfoCache.d.ts +65 -0
  45. package/dist/services/userInfoCache.js +147 -0
  46. package/dist/teamAdmin.d.ts +2 -2
  47. package/dist/teamBudgetVisibility.d.ts +2 -1
  48. package/dist/teamBudgetVisibility.js +5 -5
  49. package/dist/types.cjs.js.map +1 -1
  50. package/dist/types.d.ts +8 -195
  51. package/package.json +6 -3
  52. package/dist/permissions.d.ts +0 -11
  53. package/dist/permissions.js +0 -57
package/config.d.ts CHANGED
@@ -2,18 +2,25 @@ export interface Config {
2
2
  litellm: {
3
3
  /**
4
4
  * Base URL of the LiteLLM proxy instance.
5
- * @visibility backend
6
5
  */
7
6
  baseUrl: string;
8
7
 
9
8
  /**
10
9
  * Publicly reachable LiteLLM proxy URL. Used to build ready-to-paste
11
10
  * curl / OpenAI-SDK snippets in the frontend ("Key Generated" dialog).
12
- * When omitted, the internal baseUrl is used instead.
13
- * @visibility backend
11
+ * The value is served to the frontend via GET /config. When omitted the
12
+ * frontend shows "Endpoint not configured" — the internal baseUrl is
13
+ * never exposed.
14
14
  */
15
15
  publicBaseUrl?: string;
16
16
 
17
+ /**
18
+ * Who end users should contact when their LiteLLM account isn't set up
19
+ * (an email address, chat channel or URL). Served to the frontend via
20
+ * GET /config and shown in the "account isn't set up" panel.
21
+ */
22
+ supportContact?: string;
23
+
17
24
  /**
18
25
  * LiteLLM master key for admin operations. Never exposed to the frontend.
19
26
  * @visibility secret
@@ -24,7 +31,6 @@ export interface Config {
24
31
  * Email domain appended to the Backstage user entity name to form the
25
32
  * LiteLLM user_id. When set, a user named "john.doe" maps to
26
33
  * "john.doe@<userIdDomain>" in LiteLLM. Omit to use the bare entity name.
27
- * @visibility backend
28
34
  */
29
35
  userIdDomain?: string;
30
36
 
@@ -78,6 +84,12 @@ export interface Config {
78
84
  */
79
85
  rpmLimit?: number;
80
86
 
87
+ /**
88
+ * LiteLLM user role applied on /user/new (e.g. "internal_user").
89
+ * @default "internal_user"
90
+ */
91
+ userRole?: string;
92
+
81
93
  /**
82
94
  * Arbitrary key-value metadata stored on the LiteLLM user record.
83
95
  * Useful for tracking source, cost centre, department, etc.
@@ -102,16 +114,12 @@ export interface Config {
102
114
  teams?: string[];
103
115
  tpmLimit?: number;
104
116
  rpmLimit?: number;
117
+ /** LiteLLM user role for members of this group; falls back to defaults.userRole. */
118
+ userRole?: string;
105
119
  metadata?: Record<string, string>;
106
120
  }>;
107
121
  };
108
122
 
109
- /**
110
- * CLI bridge — exposes /api/litellm/bridge/* for CLI clients (Abby) that
111
- * authenticate with a Keycloak access token (JWKS-verified). Lets them
112
- * list/mint virtual keys without holding the master key. Disabled by
113
- * default; enable explicitly when the CLI is in use.
114
- */
115
123
  /**
116
124
  * Audit log access control. When set, the /audit tab in the plugin is
117
125
  * only visible to members of the specified Backstage group.
@@ -127,6 +135,103 @@ export interface Config {
127
135
  group?: string;
128
136
  };
129
137
 
138
+ /**
139
+ * Governance ceilings for individual key creation.
140
+ * These are strict server-side limits; config flags (allowUnlimitedBudget,
141
+ * teamRequired) control what users are allowed to do, but these limits
142
+ * cap what they can specify.
143
+ */
144
+ keys?: {
145
+ /**
146
+ * Maximum USD budget a single key can have. Requests exceeding this
147
+ * are rejected with a 400 error.
148
+ * @default 100
149
+ */
150
+ maxBudget?: number;
151
+
152
+ /**
153
+ * Maximum tokens-per-minute a single key can request.
154
+ * Requests exceeding this are rejected with a 400 error.
155
+ * @default 100000
156
+ */
157
+ maxTpm?: number;
158
+
159
+ /**
160
+ * Maximum requests-per-minute a single key can request.
161
+ * Requests exceeding this are rejected with a 400 error.
162
+ * @default 1000
163
+ */
164
+ maxRpm?: number;
165
+
166
+ /**
167
+ * Allowed duration presets for keys (e.g. "1d", "7d", "30d", "90d").
168
+ * Requests with a duration not in this list are rejected with a 400 error.
169
+ * An empty array allows any duration matching the format /^\d+[smhdwy]$/.
170
+ * @default ["1d", "7d", "30d", "90d"]
171
+ */
172
+ allowedDurations?: string[];
173
+
174
+ /**
175
+ * When true, key owners can reset their key's spend counter via the
176
+ * "Reset Spend" button in the frontend, subject to the
177
+ * litellm.key.resetSpend permission. When false (the default), reset
178
+ * is only available to administrators, even if the permission is ALLOW.
179
+ * Fail-closed: enforced before the permission check.
180
+ * @default false
181
+ */
182
+ allowOwnerResetSpend?: boolean;
183
+ };
184
+
185
+ /**
186
+ * OpenCode SSO-connect (GET/POST /opencode/connect). Lets the OpenCode
187
+ * portal-auth plugin obtain a personal LiteLLM key after a browser
188
+ * sign-in and confirmation. Disabled by default.
189
+ */
190
+ opencode?: {
191
+ /**
192
+ * When true the /opencode/connect routes are mounted.
193
+ * @default false
194
+ */
195
+ enabled?: boolean;
196
+
197
+ /**
198
+ * Duration of freshly generated keys, in LiteLLM format (e.g. "30d").
199
+ * @default "30d"
200
+ */
201
+ keyDuration?: string;
202
+
203
+ /**
204
+ * Max budget (USD) of keys created through the connect flow.
205
+ * @default 50
206
+ */
207
+ maxBudget?: number;
208
+
209
+ /**
210
+ * When true a team must be supplied and the user must belong to it.
211
+ * @default false
212
+ */
213
+ requireTeam?: boolean;
214
+
215
+ /**
216
+ * Extra metadata stamped on keys created through this flow.
217
+ */
218
+ metadata?: { [key: string]: string };
219
+ };
220
+
221
+ /**
222
+ * Caching configuration for backend data fetches.
223
+ */
224
+ cache?: {
225
+ /**
226
+ * Time-to-live for cached getUserInfo results, in seconds.
227
+ * When 0, caching is disabled.
228
+ * Cached results are never stale within this window; mutations
229
+ * invalidate the cache immediately to ensure consistency.
230
+ * @default 10
231
+ */
232
+ userInfoTtlSeconds?: number;
233
+ };
234
+
130
235
  /**
131
236
  * Controls for the "Generate New Key" form in the frontend.
132
237
  */
@@ -161,7 +266,6 @@ export interface Config {
161
266
  * e.g. "group:default/litellm-team-admins".
162
267
  * The plugin ships a group template at catalog/litellm-team-admins.yaml.
163
268
  * When omitted the feature is disabled entirely.
164
- * @visibility backend
165
269
  */
166
270
  group?: string;
167
271
 
@@ -169,7 +273,6 @@ export interface Config {
169
273
  * LiteLLM model names an admin may assign to a team.
170
274
  * Empty array => none assignable (fail-closed).
171
275
  * @default []
172
- * @visibility backend
173
276
  */
174
277
  allowedModels?: string[];
175
278
 
@@ -178,21 +281,18 @@ export interface Config {
178
281
  * References access_groups defined in litellm.model_info configuration.
179
282
  * Empty array => none allowed.
180
283
  * @default []
181
- * @visibility backend
182
284
  */
183
285
  allowedModelAccessGroups?: string[];
184
286
 
185
287
  /**
186
288
  * Hard USD ceiling for max_budget an admin may set on a team.
187
289
  * @default 1000
188
- * @visibility backend
189
290
  */
190
291
  maxBudgetCeiling?: number;
191
292
 
192
293
  /**
193
294
  * Allow an admin to create a team with no budget cap.
194
295
  * @default false
195
- * @visibility backend
196
296
  */
197
297
  allowUnlimitedBudget?: boolean;
198
298
 
@@ -200,7 +300,6 @@ export interface Config {
200
300
  * Vector-store ids/names an admin may attach as team knowledge bases.
201
301
  * Empty array => none allowed.
202
302
  * @default []
203
- * @visibility backend
204
303
  */
205
304
  allowedVectorStores?: string[];
206
305
 
@@ -208,7 +307,6 @@ export interface Config {
208
307
  * MCP server ids/names an admin may attach to a team.
209
308
  * Empty array => none allowed.
210
309
  * @default []
211
- * @visibility backend
212
310
  */
213
311
  allowedMcpServers?: string[];
214
312
 
@@ -217,14 +315,12 @@ export interface Config {
217
315
  * References access_groups defined in litellm.mcp_info configuration.
218
316
  * Empty array => none allowed.
219
317
  * @default []
220
- * @visibility backend
221
318
  */
222
319
  allowedMcpAccessGroups?: string[];
223
320
 
224
321
  /**
225
322
  * Allow an admin to delete a team (vs. only block/deactivate).
226
323
  * @default false
227
- * @visibility backend
228
324
  */
229
325
  allowTeamDelete?: boolean;
230
326
 
@@ -242,7 +338,6 @@ export interface Config {
242
338
  * When true, mount the knowledge-base and MCP management routes.
243
339
  * Still gated by the permission framework and the allowlists.
244
340
  * @default false
245
- * @visibility backend
246
341
  */
247
342
  enabled?: boolean;
248
343
  };
@@ -262,7 +357,6 @@ export interface Config {
262
357
  * cards, TEAM section of the budget widget). Team managers still see
263
358
  * dollars unless hideTeamBudgetForManagers is also set.
264
359
  * @default false
265
- * @visibility frontend
266
360
  */
267
361
  hideTeamBudgetForMembers?: boolean;
268
362
 
@@ -271,7 +365,6 @@ export interface Config {
271
365
  * team write responses, ManageTeamDialog budget field which becomes
272
366
  * write-only). Useful when budgets are finance-sensitive.
273
367
  * @default false
274
- * @visibility frontend
275
368
  */
276
369
  hideTeamBudgetForManagers?: boolean;
277
370
  };
package/dist/bridge.d.ts CHANGED
@@ -1,15 +1,19 @@
1
1
  import { Config } from '@backstage/config';
2
+ import { LoggerService } from '@backstage/backend-plugin-api';
2
3
  import { LiteLLMClient } from './client';
3
4
  import { GenerateKeyRequest, GenerateKeyResponse, ProvisioningDefaults, UserInfo, VirtualKey } from './types';
4
5
  /** Claims extracted from a verified Keycloak access token. */
5
6
  export interface BridgeClaims {
6
7
  sub: string;
7
8
  email?: string;
9
+ email_verified?: boolean;
8
10
  preferred_username?: string;
9
11
  name?: string;
10
12
  /** Authorized party — Keycloak sets this to the client_id of the requester. */
11
13
  azp?: string;
12
14
  aud?: string | string[];
15
+ /** Keycloak payload `typ` claim: 'Bearer' for access tokens, 'ID' for ID tokens. */
16
+ typ?: string;
13
17
  }
14
18
  /** A token verifier pluggable for tests. */
15
19
  export interface TokenVerifier {
@@ -30,6 +34,10 @@ export declare class BridgeConfigError extends Error {
30
34
  export declare class BridgeAuthError extends Error {
31
35
  readonly status = 401;
32
36
  }
37
+ /** Thrown when the caller's identity fails validation (e.g. email domain mismatch) → maps to HTTP 403. */
38
+ export declare class BridgeIdentityError extends Error {
39
+ readonly status = 403;
40
+ }
33
41
  export interface KeycloakJWTVerifierOptions {
34
42
  issuer: string;
35
43
  clientId: string;
@@ -49,13 +57,18 @@ export declare class KeycloakJWTVerifier implements TokenVerifier {
49
57
  /** Builds the default verifier from config, or throws BridgeConfigError. */
50
58
  export declare function newDefaultVerifier(cfg: BridgeConfig): TokenVerifier;
51
59
  /**
52
- * Resolves the LiteLLM user_id from the verified claims, matching exactly how
53
- * the UI derives it so the bridge addresses the *same* LiteLLM user (not a
54
- * duplicate). The UI uses toLiteLLMUserId(entityRef, userIdDomain) where the
55
- * Backstage entity name has been rewritten by the Keycloak user transformer:
56
- * because the catalog rejects '@' in entity names, usernames imported as full
57
- * emails are stored as their local-part. We replicate that here — strip the
58
- * '@domain' off the username, then apply the same userIdDomain rule.
60
+ * Resolves the LiteLLM user_id from the verified claims with strict identity validation.
61
+ *
62
+ * PR-6 identity mapping:
63
+ * - Prefer `email` with `email_verified === true` whose domain equals `userIdDomain`.
64
+ * - If userIdDomain is unset, always reject unverified emails (guard against misconfig).
65
+ * - Never strip an arbitrary domain from `preferred_username`.
66
+ * - Rejects invalid/missing identity with 403.
67
+ *
68
+ * @param claims Verified JWT claims from the bridge token
69
+ * @param userIdDomain Expected email domain (e.g. "example.com"); if set, email must match
70
+ * @returns The resolved LiteLLM user_id
71
+ * @throws BridgeIdentityError (403) if identity validation fails
59
72
  */
60
73
  export declare function resolveBridgeUserId(claims: BridgeClaims, userIdDomain?: string): string;
61
74
  /**
@@ -64,14 +77,8 @@ export declare function resolveBridgeUserId(claims: BridgeClaims, userIdDomain?:
64
77
  * name); if provisioning is disabled, throws a 404 telling the caller to log
65
78
  * in to Backstage first (the UI is the primary provisioning entry point).
66
79
  */
67
- export declare function getOrProvisionUserFromClaims(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
68
- info: (...args: unknown[]) => void;
69
- }, userIdDomain?: string): Promise<UserInfo>;
80
+ export declare function getOrProvisionUserFromClaims(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string): Promise<UserInfo>;
70
81
  /** Lists the caller's virtual keys (provisioning the user first if needed). */
71
- export declare function bridgeListKeys(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
72
- info: (...args: unknown[]) => void;
73
- }, userIdDomain?: string): Promise<VirtualKey[]>;
82
+ export declare function bridgeListKeys(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string): Promise<VirtualKey[]>;
74
83
  /** Mints a new virtual key for the caller (provisioning the user first if needed). */
75
- export declare function bridgeGenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
76
- info: (...args: unknown[]) => void;
77
- }, request: Partial<GenerateKeyRequest>, userIdDomain?: string): Promise<GenerateKeyResponse>;
84
+ export declare function bridgeGenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, request: Partial<GenerateKeyRequest>, userIdDomain?: string): Promise<GenerateKeyResponse>;
package/dist/bridge.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.KeycloakJWTVerifier = exports.BridgeAuthError = exports.BridgeConfigError = void 0;
3
+ exports.KeycloakJWTVerifier = exports.BridgeIdentityError = exports.BridgeAuthError = exports.BridgeConfigError = void 0;
4
4
  exports.readBridgeConfig = readBridgeConfig;
5
5
  exports.newDefaultVerifier = newDefaultVerifier;
6
6
  exports.resolveBridgeUserId = resolveBridgeUserId;
@@ -42,6 +42,14 @@ class BridgeAuthError extends Error {
42
42
  }
43
43
  }
44
44
  exports.BridgeAuthError = BridgeAuthError;
45
+ /** Thrown when the caller's identity fails validation (e.g. email domain mismatch) → maps to HTTP 403. */
46
+ class BridgeIdentityError extends Error {
47
+ constructor() {
48
+ super(...arguments);
49
+ this.status = 403;
50
+ }
51
+ }
52
+ exports.BridgeIdentityError = BridgeIdentityError;
45
53
  /**
46
54
  * Verifies a Keycloak access token against the realm JWKS and ensures it was
47
55
  * issued for {@link clientId} (via azp, falling back to aud). Uses jose's
@@ -73,14 +81,23 @@ class KeycloakJWTVerifier {
73
81
  if (azp !== this.clientId && !audMatches) {
74
82
  throw new BridgeAuthError(`token not issued for client "${this.clientId}" (azp=${azp ?? 'none'})`);
75
83
  }
84
+ // Keycloak marks the token kind in the PAYLOAD `typ` claim ('Bearer' for
85
+ // access tokens, 'ID' for ID tokens); the JWT header `typ` is just 'JWT'.
86
+ // Reject anything that isn't an access token.
87
+ const typ = payload.typ;
88
+ if (typ && typ !== 'Bearer') {
89
+ throw new BridgeAuthError(`token type must be Bearer or absent, got: ${typ}`);
90
+ }
76
91
  return {
77
92
  sub: payload.sub ?? '',
78
93
  email: payload.email,
94
+ email_verified: payload.email_verified,
79
95
  preferred_username: payload
80
96
  .preferred_username,
81
97
  name: payload.name,
82
98
  azp,
83
99
  aud,
100
+ typ,
84
101
  };
85
102
  }
86
103
  }
@@ -94,18 +111,41 @@ function newDefaultVerifier(cfg) {
94
111
  return new KeycloakJWTVerifier({ issuer: cfg.issuer, clientId: cfg.clientId });
95
112
  }
96
113
  /**
97
- * Resolves the LiteLLM user_id from the verified claims, matching exactly how
98
- * the UI derives it so the bridge addresses the *same* LiteLLM user (not a
99
- * duplicate). The UI uses toLiteLLMUserId(entityRef, userIdDomain) where the
100
- * Backstage entity name has been rewritten by the Keycloak user transformer:
101
- * because the catalog rejects '@' in entity names, usernames imported as full
102
- * emails are stored as their local-part. We replicate that here — strip the
103
- * '@domain' off the username, then apply the same userIdDomain rule.
114
+ * Resolves the LiteLLM user_id from the verified claims with strict identity validation.
115
+ *
116
+ * PR-6 identity mapping:
117
+ * - Prefer `email` with `email_verified === true` whose domain equals `userIdDomain`.
118
+ * - If userIdDomain is unset, always reject unverified emails (guard against misconfig).
119
+ * - Never strip an arbitrary domain from `preferred_username`.
120
+ * - Rejects invalid/missing identity with 403.
121
+ *
122
+ * @param claims Verified JWT claims from the bridge token
123
+ * @param userIdDomain Expected email domain (e.g. "example.com"); if set, email must match
124
+ * @returns The resolved LiteLLM user_id
125
+ * @throws BridgeIdentityError (403) if identity validation fails
104
126
  */
105
127
  function resolveBridgeUserId(claims, userIdDomain) {
106
- const raw = claims.preferred_username ?? claims.email ?? claims.sub;
107
- const entityName = raw.includes('@') ? raw.split('@')[0] : raw;
108
- return (0, provisioning_1.toLiteLLMUserId)(entityName, userIdDomain);
128
+ // The bridge maps identities by verified email only. Without a configured
129
+ // domain we can't tell our users from another tenant's, and stripping the
130
+ // domain would merge alice@a.com and alice@b.com — fail closed.
131
+ if (!userIdDomain) {
132
+ throw new BridgeIdentityError('litellm.userIdDomain must be configured to use the CLI bridge');
133
+ }
134
+ if (!claims.email) {
135
+ throw new BridgeIdentityError('no email found in token');
136
+ }
137
+ if (claims.email_verified !== true) {
138
+ throw new BridgeIdentityError('email is not verified');
139
+ }
140
+ const parts = claims.email.split('@');
141
+ if (parts.length !== 2 || !parts[0]) {
142
+ throw new BridgeIdentityError('malformed email in token');
143
+ }
144
+ const [localPart, domain] = parts;
145
+ if (domain.toLowerCase() !== userIdDomain.toLowerCase()) {
146
+ throw new BridgeIdentityError(`email domain mismatch: "${domain}" is not allowed for this deployment`);
147
+ }
148
+ return (0, provisioning_1.toLiteLLMUserId)(localPart, userIdDomain);
109
149
  }
110
150
  /**
111
151
  * Ensures a LiteLLM user exists for the verified identity. If the user is
package/dist/client.d.ts CHANGED
@@ -10,7 +10,7 @@ export declare function normalizeRequestTags(tags: SpendLogEntry['request_tags']
10
10
  * status and the structured `param` (e.g. `key_alias`) from the upstream
11
11
  * body so the router can surface a 400 instead of collapsing everything
12
12
  * into a 500. The message is the upstream `error.message` when present,
13
- * otherwise the raw body text.
13
+ * otherwise a generic status+statusText format (never embeds raw bodies).
14
14
  */
15
15
  export declare class LiteLLMUpstreamError extends Error {
16
16
  status: number;
@@ -68,6 +68,11 @@ export declare class LiteLLMClient {
68
68
  */
69
69
  generateKey(request: GenerateKeyRequest): Promise<GenerateKeyResponse>;
70
70
  updateKey(request: UpdateKeyRequest): Promise<VirtualKey>;
71
+ /**
72
+ * Regenerates (rotates) a key by its hash, returning a new plaintext key.
73
+ * The LiteLLM version determines the endpoint: use POST /key/{key}/regenerate.
74
+ */
75
+ regenerateKey(keyHash: string): Promise<GenerateKeyResponse>;
71
76
  deleteKeys(request: DeleteKeyRequest): Promise<{
72
77
  success: boolean;
73
78
  }>;
@@ -180,6 +185,6 @@ export declare class LiteLLMClient {
180
185
  * - usage_by_key → which keys drove cost / traffic (with key_alias + team_id from metadata)
181
186
  */
182
187
  private transformDailyActivity;
183
- getUsage(startDate: string, endDate: string, userId?: string): Promise<UsageMetrics>;
188
+ getUsage(startDate: string, endDate: string, userId: string): Promise<UsageMetrics>;
184
189
  getTeamUsage(teamId: string, startDate: string, endDate: string): Promise<UsageMetrics>;
185
190
  }
package/dist/client.js CHANGED
@@ -20,11 +20,12 @@ function normalizeRequestTags(tags) {
20
20
  * status and the structured `param` (e.g. `key_alias`) from the upstream
21
21
  * body so the router can surface a 400 instead of collapsing everything
22
22
  * into a 500. The message is the upstream `error.message` when present,
23
- * otherwise the raw body text.
23
+ * otherwise a generic status+statusText format (never embeds raw bodies).
24
24
  */
25
25
  class LiteLLMUpstreamError extends Error {
26
26
  constructor(status, statusText, body) {
27
- let message = `LiteLLM API error: ${status} ${statusText} - ${body}`;
27
+ // Default message is generic status + text; only use JSON message if present.
28
+ let message = `LiteLLM API error: ${status} ${statusText}`;
28
29
  let param;
29
30
  try {
30
31
  const parsed = JSON.parse(body);
@@ -36,7 +37,8 @@ class LiteLLMUpstreamError extends Error {
36
37
  }
37
38
  }
38
39
  catch {
39
- // not JSON — keep the raw body in the message
40
+ // Not JSON — keep the generic message (no raw body embedded).
41
+ // Log the raw body at debug level if needed by the caller.
40
42
  }
41
43
  super(message);
42
44
  this.status = status;
@@ -112,6 +114,7 @@ class LiteLLMClient {
112
114
  models: inner.models ?? raw?.models,
113
115
  max_budget: inner.max_budget ?? raw?.max_budget,
114
116
  budget_duration: inner.budget_duration ?? raw?.budget_duration,
117
+ budget_reset_at: inner.budget_reset_at ?? raw?.budget_reset_at,
115
118
  spend: inner.spend ?? raw?.spend,
116
119
  current_spend: inner.current_spend ?? raw?.current_spend,
117
120
  soft_limit: inner.soft_limit ?? raw?.soft_limit,
@@ -119,7 +122,7 @@ class LiteLLMClient {
119
122
  };
120
123
  }
121
124
  catch (err) {
122
- if (err.status === 404)
125
+ if (err instanceof LiteLLMUpstreamError && err.status === 404)
123
126
  return null;
124
127
  throw err;
125
128
  }
@@ -161,7 +164,10 @@ class LiteLLMClient {
161
164
  return rawKeys.map(this.toVirtualKey);
162
165
  }
163
166
  catch (err) {
164
- if (err.status === 404 || err.message.includes('not found')) {
167
+ if (err instanceof LiteLLMUpstreamError && err.status === 404) {
168
+ return [];
169
+ }
170
+ if (err instanceof Error && err.message.includes('not found')) {
165
171
  return [];
166
172
  }
167
173
  throw err;
@@ -181,7 +187,9 @@ class LiteLLMClient {
181
187
  rpm_limit: k.rpm_limit ?? undefined,
182
188
  models: k.models ?? [],
183
189
  user_id: k.user_id ?? undefined,
190
+ team_id: k.team_id ?? undefined,
184
191
  blocked: k.blocked ?? undefined,
192
+ metadata: k.metadata ?? undefined,
185
193
  };
186
194
  }
187
195
  /**
@@ -212,6 +220,16 @@ class LiteLLMClient {
212
220
  body: JSON.stringify(request),
213
221
  });
214
222
  }
223
+ /**
224
+ * Regenerates (rotates) a key by its hash, returning a new plaintext key.
225
+ * The LiteLLM version determines the endpoint: use POST /key/{key}/regenerate.
226
+ */
227
+ async regenerateKey(keyHash) {
228
+ return this.request(`/key/${encodeURIComponent(keyHash)}/regenerate`, {
229
+ method: 'POST',
230
+ body: JSON.stringify({}),
231
+ });
232
+ }
215
233
  async deleteKeys(request) {
216
234
  return this.request('/key/delete', {
217
235
  method: 'POST',
@@ -627,19 +645,25 @@ class LiteLLMClient {
627
645
  };
628
646
  }
629
647
  async getUsage(startDate, endDate, userId) {
648
+ if (!userId) {
649
+ // An empty user_id makes LiteLLM return org-wide activity.
650
+ throw new Error('getUsage requires a user_id');
651
+ }
630
652
  const params = new URLSearchParams({
631
653
  start_date: startDate,
632
654
  end_date: endDate,
633
655
  page_size: '100',
634
656
  });
635
- if (userId)
636
- params.append('user_id', userId);
657
+ params.append('user_id', userId);
637
658
  try {
638
659
  const response = await this.request(`/user/daily/activity?${params.toString()}`);
639
660
  return this.transformDailyActivity(response);
640
661
  }
641
662
  catch (err) {
642
- if (err.status === 404 || err.message.includes('not found')) {
663
+ if (err instanceof LiteLLMUpstreamError && err.status === 404) {
664
+ return this.emptyUsage();
665
+ }
666
+ if (err instanceof Error && err.message.includes('not found')) {
643
667
  return this.emptyUsage();
644
668
  }
645
669
  throw err;
@@ -657,7 +681,10 @@ class LiteLLMClient {
657
681
  return this.transformDailyActivity(response);
658
682
  }
659
683
  catch (err) {
660
- if (err.status === 404 || err.message.includes('not found')) {
684
+ if (err instanceof LiteLLMUpstreamError && err.status === 404) {
685
+ return this.emptyUsage();
686
+ }
687
+ if (err instanceof Error && err.message.includes('not found')) {
661
688
  return this.emptyUsage();
662
689
  }
663
690
  throw err;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Central error handling and sanitization for the LiteLLM backend.
3
+ *
4
+ * Ensures that:
5
+ * - Upstream auth failures (401/403) don't log out Backstage sessions
6
+ * - Upstream 5xx errors are generic and don't leak implementation details
7
+ * - Upstream 4xx errors are sanitized and capped
8
+ * - Non-JSON error bodies are not embedded in responses
9
+ * - All error logging happens server-side only
10
+ */
11
+ import { Response } from 'express';
12
+ import { LoggerService } from '@backstage/backend-plugin-api';
13
+ /**
14
+ * Type guard: narrows unknown to {status: number; body: Record<string, unknown>}.
15
+ * Used to detect custom errors that have status and body properties.
16
+ */
17
+ export declare function isHttpishError(error: unknown): error is {
18
+ status: number;
19
+ body: Record<string, unknown>;
20
+ };
21
+ /**
22
+ * Safely extract a message string from any error type.
23
+ * Returns empty string if no message can be extracted.
24
+ */
25
+ export declare function errorMessage(error: unknown): string;
26
+ /**
27
+ * Sanitizes a message by:
28
+ * - Stripping HTML tags
29
+ * - Collapsing consecutive whitespace
30
+ * - Truncating to 500 characters
31
+ *
32
+ * @param msg The raw message (may contain HTML, newlines, etc.)
33
+ * @returns A safe, printable message
34
+ */
35
+ export declare function sanitizeUpstreamMessage(msg: string): string;
36
+ /**
37
+ * Maps any error type to an HTTP response shape {status, body}.
38
+ *
39
+ * Behaviour:
40
+ * - LiteLLMUpstreamError 401/403 → 502 generic (prevent session logout)
41
+ * - LiteLLMUpstreamError 5xx or network → 502 generic
42
+ * - LiteLLMUpstreamError 4xx (other) → 400 with sanitized message + param if present
43
+ * - KeyServiceError / ProvisioningError / objects with status+body → pass through
44
+ * - Backstage-style errors (name: InputError/NotAllowedError/NotFoundError/ConflictError) → map to 400/403/404/409
45
+ * - Anything else → 500 (message logged server-side, not returned)
46
+ *
47
+ * @returns {status, body, logLevel} for HTTP response; undefined _logMessage is only present for 500s
48
+ */
49
+ export declare function toHttpError(error: unknown): {
50
+ status: number;
51
+ body: Record<string, unknown>;
52
+ logLevel: 'debug' | 'info' | 'warn' | 'error';
53
+ _logMessage?: string;
54
+ };
55
+ /**
56
+ * Express error handler helper. Logs the real error server-side and responds
57
+ * via toHttpError.
58
+ *
59
+ * @param res Express Response object
60
+ * @param error The caught error (any type)
61
+ * @param logger Logger instance with error/warn/info/debug methods
62
+ * @param context Contextual label for the log entry (e.g. 'fetch user info')
63
+ */
64
+ export declare function sendError(res: Response, error: unknown, logger: LoggerService, context: string): void;