@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.
- package/config.d.ts +115 -22
- package/dist/bridge.d.ts +23 -16
- package/dist/bridge.js +51 -11
- package/dist/client.d.ts +7 -2
- package/dist/client.js +36 -9
- package/dist/errors.d.ts +64 -0
- package/dist/errors.js +205 -0
- package/dist/http/respondTeam.d.ts +15 -0
- package/dist/http/respondTeam.js +27 -0
- package/dist/http/teamFetch.d.ts +14 -0
- package/dist/http/teamFetch.js +44 -0
- package/dist/index.cjs.js +4249 -3375
- package/dist/index.cjs.js.map +4 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +15 -13
- package/dist/openapi.js +17 -0
- package/dist/plugin.js +2 -2
- package/dist/provisioning.d.ts +17 -11
- package/dist/provisioning.js +36 -22
- package/dist/router.d.ts +3 -4
- package/dist/router.js +188 -1349
- package/dist/routes/bridge.d.ts +8 -0
- package/dist/routes/bridge.js +130 -0
- package/dist/routes/config.d.ts +7 -0
- package/dist/routes/config.js +89 -0
- package/dist/routes/context.d.ts +55 -0
- package/dist/routes/context.js +2 -0
- package/dist/routes/keys.d.ts +8 -0
- package/dist/routes/keys.js +471 -0
- package/dist/routes/middleware/withUser.d.ts +18 -0
- package/dist/routes/middleware/withUser.js +41 -0
- package/dist/routes/objectPermissions.d.ts +3 -0
- package/dist/routes/objectPermissions.js +196 -0
- package/dist/routes/opencode.d.ts +3 -0
- package/dist/routes/opencode.js +239 -0
- package/dist/routes/teamUsage.d.ts +3 -0
- package/dist/routes/teamUsage.js +73 -0
- package/dist/routes/teams.d.ts +3 -0
- package/dist/routes/teams.js +367 -0
- package/dist/services/keyService.d.ts +61 -0
- package/dist/services/keyService.js +179 -0
- package/dist/services/modelAccess.d.ts +9 -0
- package/dist/services/modelAccess.js +30 -0
- package/dist/services/userInfoCache.d.ts +65 -0
- package/dist/services/userInfoCache.js +147 -0
- package/dist/teamAdmin.d.ts +2 -2
- package/dist/teamBudgetVisibility.d.ts +2 -1
- package/dist/teamBudgetVisibility.js +5 -5
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +8 -195
- package/package.json +6 -3
- package/dist/permissions.d.ts +0 -11
- 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
|
-
*
|
|
13
|
-
*
|
|
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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|
package/dist/errors.d.ts
ADDED
|
@@ -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;
|