@acarmisc/backstage-plugin-litellm-backend 0.15.0 → 0.16.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.
Files changed (53) hide show
  1. package/config.d.ts +124 -22
  2. package/dist/bridge.d.ts +43 -17
  3. package/dist/bridge.js +69 -13
  4. package/dist/client.d.ts +7 -2
  5. package/dist/client.js +35 -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 +4264 -3373
  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 +135 -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 -197
  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
  };
@@ -297,6 +390,15 @@ export interface Config {
297
390
  * @default "abby-cli"
298
391
  */
299
392
  clientId?: string;
393
+
394
+ /**
395
+ * Email domains whose *verified* addresses may use the bridge. Defaults
396
+ * to `litellm.userIdDomain` when that is set. With neither configured
397
+ * the bridge rejects every caller. The LiteLLM user is then the same one
398
+ * the UI addresses (the Keycloak `preferred_username`, with
399
+ * `litellm.userIdDomain` applied when set) — the email is only the gate.
400
+ */
401
+ allowedEmailDomains?: string[];
300
402
  };
301
403
  };
302
404
  }
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 {
@@ -21,6 +25,12 @@ export interface BridgeConfig {
21
25
  issuer?: string;
22
26
  /** OIDC public client the CLI uses; checked against azp / aud. */
23
27
  clientId: string;
28
+ /**
29
+ * Email domains whose verified addresses may use the bridge. Defaults to
30
+ * `litellm.userIdDomain` when that is set; with neither, the bridge rejects
31
+ * every caller (fail closed).
32
+ */
33
+ allowedEmailDomains: string[];
24
34
  }
25
35
  export declare function readBridgeConfig(config: Config): BridgeConfig;
26
36
  /** Thrown when the bridge is misconfigured (e.g. enabled without an issuer). */
@@ -30,6 +40,10 @@ export declare class BridgeConfigError extends Error {
30
40
  export declare class BridgeAuthError extends Error {
31
41
  readonly status = 401;
32
42
  }
43
+ /** Thrown when the caller's identity fails validation (e.g. email domain mismatch) → maps to HTTP 403. */
44
+ export declare class BridgeIdentityError extends Error {
45
+ readonly status = 403;
46
+ }
33
47
  export interface KeycloakJWTVerifierOptions {
34
48
  issuer: string;
35
49
  clientId: string;
@@ -49,29 +63,41 @@ export declare class KeycloakJWTVerifier implements TokenVerifier {
49
63
  /** Builds the default verifier from config, or throws BridgeConfigError. */
50
64
  export declare function newDefaultVerifier(cfg: BridgeConfig): TokenVerifier;
51
65
  /**
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.
66
+ * How the bridge maps a verified token to a LiteLLM user.
67
+ * - `userIdDomain`: the same `litellm.userIdDomain` the UI uses to build ids.
68
+ * - `trustedEmailDomains`: email domains allowed to use the bridge; defaults to
69
+ * `[userIdDomain]` when that is set.
70
+ */
71
+ export interface BridgeIdentityOptions {
72
+ userIdDomain?: string;
73
+ trustedEmailDomains?: string[];
74
+ }
75
+ /**
76
+ * Resolves the LiteLLM user_id for a verified bridge token.
77
+ *
78
+ * Two separate questions, answered separately:
79
+ * 1. Is this person allowed in? The token must carry a *verified* email whose
80
+ * domain is in the trusted list (`litellm.bridge.allowedEmailDomains`,
81
+ * defaulting to `litellm.userIdDomain`). With no trusted domain configured
82
+ * everyone is rejected (fail closed). Failure → 403.
83
+ * 2. Which LiteLLM user are they? The same one the UI addresses: the Backstage
84
+ * user entity name (the Keycloak `preferred_username`) run through the same
85
+ * `litellm.userIdDomain` rule the UI applies. Using the email local part
86
+ * here would address a different user whenever a Keycloak username differs
87
+ * from the email (and would provision a duplicate). The realm is the same
88
+ * IdP Backstage signs users in with, so its usernames are the entity names.
89
+ *
90
+ * @throws BridgeIdentityError (403) if identity validation fails
59
91
  */
60
- export declare function resolveBridgeUserId(claims: BridgeClaims, userIdDomain?: string): string;
92
+ export declare function resolveBridgeUserId(claims: BridgeClaims, identity?: string | BridgeIdentityOptions): string;
61
93
  /**
62
94
  * Ensures a LiteLLM user exists for the verified identity. If the user is
63
95
  * missing and provisioning is enabled, creates it from the JWT claims (email +
64
96
  * name); if provisioning is disabled, throws a 404 telling the caller to log
65
97
  * in to Backstage first (the UI is the primary provisioning entry point).
66
98
  */
67
- export declare function getOrProvisionUserFromClaims(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
68
- info: (...args: unknown[]) => void;
69
- }, userIdDomain?: string): Promise<UserInfo>;
99
+ export declare function getOrProvisionUserFromClaims(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string | BridgeIdentityOptions): Promise<UserInfo>;
70
100
  /** 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[]>;
101
+ export declare function bridgeListKeys(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string | BridgeIdentityOptions): Promise<VirtualKey[]>;
74
102
  /** 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>;
103
+ export declare function bridgeGenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, request: Partial<GenerateKeyRequest>, userIdDomain?: string | BridgeIdentityOptions): 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;
@@ -28,7 +28,8 @@ function readBridgeConfig(config) {
28
28
  const enabled = config.getOptionalBoolean('litellm.bridge.enabled') ?? false;
29
29
  const issuer = config.getOptionalString('litellm.bridge.issuer');
30
30
  const clientId = config.getOptionalString('litellm.bridge.clientId') ?? 'abby-cli';
31
- return { enabled, issuer, clientId };
31
+ const allowedEmailDomains = (config.getOptionalStringArray('litellm.bridge.allowedEmailDomains') ?? []).map(d => d.trim().toLowerCase()).filter(Boolean);
32
+ return { enabled, issuer, clientId, allowedEmailDomains };
32
33
  }
33
34
  /** Thrown when the bridge is misconfigured (e.g. enabled without an issuer). */
34
35
  class BridgeConfigError extends Error {
@@ -42,6 +43,14 @@ class BridgeAuthError extends Error {
42
43
  }
43
44
  }
44
45
  exports.BridgeAuthError = BridgeAuthError;
46
+ /** Thrown when the caller's identity fails validation (e.g. email domain mismatch) → maps to HTTP 403. */
47
+ class BridgeIdentityError extends Error {
48
+ constructor() {
49
+ super(...arguments);
50
+ this.status = 403;
51
+ }
52
+ }
53
+ exports.BridgeIdentityError = BridgeIdentityError;
45
54
  /**
46
55
  * Verifies a Keycloak access token against the realm JWKS and ensures it was
47
56
  * issued for {@link clientId} (via azp, falling back to aud). Uses jose's
@@ -73,14 +82,23 @@ class KeycloakJWTVerifier {
73
82
  if (azp !== this.clientId && !audMatches) {
74
83
  throw new BridgeAuthError(`token not issued for client "${this.clientId}" (azp=${azp ?? 'none'})`);
75
84
  }
85
+ // Keycloak marks the token kind in the PAYLOAD `typ` claim ('Bearer' for
86
+ // access tokens, 'ID' for ID tokens); the JWT header `typ` is just 'JWT'.
87
+ // Reject anything that isn't an access token.
88
+ const typ = payload.typ;
89
+ if (typ && typ !== 'Bearer') {
90
+ throw new BridgeAuthError(`token type must be Bearer or absent, got: ${typ}`);
91
+ }
76
92
  return {
77
93
  sub: payload.sub ?? '',
78
94
  email: payload.email,
95
+ email_verified: payload.email_verified,
79
96
  preferred_username: payload
80
97
  .preferred_username,
81
98
  name: payload.name,
82
99
  azp,
83
100
  aud,
101
+ typ,
84
102
  };
85
103
  }
86
104
  }
@@ -93,19 +111,57 @@ function newDefaultVerifier(cfg) {
93
111
  }
94
112
  return new KeycloakJWTVerifier({ issuer: cfg.issuer, clientId: cfg.clientId });
95
113
  }
114
+ /** A bare string is the legacy form: `userIdDomain`, also the only trusted domain. */
115
+ function normalizeIdentityOptions(opts) {
116
+ const o = typeof opts === 'string' ? { userIdDomain: opts } : opts ?? {};
117
+ let domains = [];
118
+ if (o.trustedEmailDomains?.length) {
119
+ domains = o.trustedEmailDomains;
120
+ }
121
+ else if (o.userIdDomain) {
122
+ domains = [o.userIdDomain];
123
+ }
124
+ const trusted = domains.map(d => d.toLowerCase());
125
+ return { userIdDomain: o.userIdDomain, trusted };
126
+ }
96
127
  /**
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.
128
+ * Resolves the LiteLLM user_id for a verified bridge token.
129
+ *
130
+ * Two separate questions, answered separately:
131
+ * 1. Is this person allowed in? The token must carry a *verified* email whose
132
+ * domain is in the trusted list (`litellm.bridge.allowedEmailDomains`,
133
+ * defaulting to `litellm.userIdDomain`). With no trusted domain configured
134
+ * everyone is rejected (fail closed). Failure → 403.
135
+ * 2. Which LiteLLM user are they? The same one the UI addresses: the Backstage
136
+ * user entity name (the Keycloak `preferred_username`) run through the same
137
+ * `litellm.userIdDomain` rule the UI applies. Using the email local part
138
+ * here would address a different user whenever a Keycloak username differs
139
+ * from the email (and would provision a duplicate). The realm is the same
140
+ * IdP Backstage signs users in with, so its usernames are the entity names.
141
+ *
142
+ * @throws BridgeIdentityError (403) if identity validation fails
104
143
  */
105
- 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);
144
+ function resolveBridgeUserId(claims, identity) {
145
+ const { userIdDomain, trusted } = normalizeIdentityOptions(identity);
146
+ if (trusted.length === 0) {
147
+ throw new BridgeIdentityError('set litellm.bridge.allowedEmailDomains (or litellm.userIdDomain) to use the CLI bridge');
148
+ }
149
+ if (!claims.email) {
150
+ throw new BridgeIdentityError('no email found in token');
151
+ }
152
+ if (claims.email_verified !== true) {
153
+ throw new BridgeIdentityError('email is not verified');
154
+ }
155
+ const parts = claims.email.split('@');
156
+ if (parts.length !== 2 || !parts[0]) {
157
+ throw new BridgeIdentityError('malformed email in token');
158
+ }
159
+ const [emailLocalPart, domain] = parts;
160
+ if (!trusted.includes(domain.toLowerCase())) {
161
+ throw new BridgeIdentityError(`email domain mismatch: "${domain}" is not allowed for this deployment`);
162
+ }
163
+ const name = claims.preferred_username?.trim() || emailLocalPart;
164
+ return (0, provisioning_1.toLiteLLMUserId)(name, userIdDomain);
109
165
  }
110
166
  /**
111
167
  * 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;
@@ -183,6 +189,7 @@ class LiteLLMClient {
183
189
  user_id: k.user_id ?? undefined,
184
190
  team_id: k.team_id ?? undefined,
185
191
  blocked: k.blocked ?? undefined,
192
+ metadata: k.metadata ?? undefined,
186
193
  };
187
194
  }
188
195
  /**
@@ -213,6 +220,16 @@ class LiteLLMClient {
213
220
  body: JSON.stringify(request),
214
221
  });
215
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
+ }
216
233
  async deleteKeys(request) {
217
234
  return this.request('/key/delete', {
218
235
  method: 'POST',
@@ -628,19 +645,25 @@ class LiteLLMClient {
628
645
  };
629
646
  }
630
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
+ }
631
652
  const params = new URLSearchParams({
632
653
  start_date: startDate,
633
654
  end_date: endDate,
634
655
  page_size: '100',
635
656
  });
636
- if (userId)
637
- params.append('user_id', userId);
657
+ params.append('user_id', userId);
638
658
  try {
639
659
  const response = await this.request(`/user/daily/activity?${params.toString()}`);
640
660
  return this.transformDailyActivity(response);
641
661
  }
642
662
  catch (err) {
643
- 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')) {
644
667
  return this.emptyUsage();
645
668
  }
646
669
  throw err;
@@ -658,7 +681,10 @@ class LiteLLMClient {
658
681
  return this.transformDailyActivity(response);
659
682
  }
660
683
  catch (err) {
661
- 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')) {
662
688
  return this.emptyUsage();
663
689
  }
664
690
  throw err;