@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.
- package/config.d.ts +124 -22
- package/dist/bridge.d.ts +43 -17
- package/dist/bridge.js +69 -13
- package/dist/client.d.ts +7 -2
- package/dist/client.js +35 -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 +4264 -3373
- 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 +135 -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 -197
- 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
|
};
|
|
@@ -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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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,
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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;
|