@acarmisc/backstage-plugin-litellm-backend 0.17.0 → 0.17.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/dist/bridge.d.ts +11 -0
- package/dist/bridge.js +40 -7
- package/dist/client.d.ts +6 -0
- package/dist/client.js +50 -0
- package/dist/index.cjs.js +116 -56
- package/dist/index.cjs.js.map +3 -3
- package/package.json +1 -1
package/dist/bridge.d.ts
CHANGED
|
@@ -87,6 +87,11 @@ export interface BridgeIdentityOptions {
|
|
|
87
87
|
* from the email (and would provision a duplicate). The realm is the same
|
|
88
88
|
* IdP Backstage signs users in with, so its usernames are the entity names.
|
|
89
89
|
*
|
|
90
|
+
* When `preferred_username` is email-shaped (contains '@') and its domain
|
|
91
|
+
* is in the trusted list, we extract the local part and use that as the
|
|
92
|
+
* username instead. This handles the Abby CLI case where `preferred_username`
|
|
93
|
+
* is the full email.
|
|
94
|
+
*
|
|
90
95
|
* @throws BridgeIdentityError (403) if identity validation fails
|
|
91
96
|
*/
|
|
92
97
|
export declare function resolveBridgeUserId(claims: BridgeClaims, identity?: string | BridgeIdentityOptions): string;
|
|
@@ -95,6 +100,12 @@ export declare function resolveBridgeUserId(claims: BridgeClaims, identity?: str
|
|
|
95
100
|
* missing and provisioning is enabled, creates it from the JWT claims (email +
|
|
96
101
|
* name); if provisioning is disabled, throws a 404 telling the caller to log
|
|
97
102
|
* in to Backstage first (the UI is the primary provisioning entry point).
|
|
103
|
+
*
|
|
104
|
+
* When the computed userId is not found, before provisioning we also search
|
|
105
|
+
* for an existing user by email. This handles the case where a UI user (created
|
|
106
|
+
* with a bare entity name) is accessed via the CLI (which uses email-shaped
|
|
107
|
+
* preferred_username), and helps recover from cases where the bridge's userId
|
|
108
|
+
* computation and the UI's differ.
|
|
98
109
|
*/
|
|
99
110
|
export declare function getOrProvisionUserFromClaims(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string | BridgeIdentityOptions): Promise<UserInfo>;
|
|
100
111
|
/** Lists the caller's virtual keys (provisioning the user first if needed). */
|
package/dist/bridge.js
CHANGED
|
@@ -139,6 +139,11 @@ function normalizeIdentityOptions(opts) {
|
|
|
139
139
|
* from the email (and would provision a duplicate). The realm is the same
|
|
140
140
|
* IdP Backstage signs users in with, so its usernames are the entity names.
|
|
141
141
|
*
|
|
142
|
+
* When `preferred_username` is email-shaped (contains '@') and its domain
|
|
143
|
+
* is in the trusted list, we extract the local part and use that as the
|
|
144
|
+
* username instead. This handles the Abby CLI case where `preferred_username`
|
|
145
|
+
* is the full email.
|
|
146
|
+
*
|
|
142
147
|
* @throws BridgeIdentityError (403) if identity validation fails
|
|
143
148
|
*/
|
|
144
149
|
function resolveBridgeUserId(claims, identity) {
|
|
@@ -160,7 +165,20 @@ function resolveBridgeUserId(claims, identity) {
|
|
|
160
165
|
if (!trusted.includes(domain.toLowerCase())) {
|
|
161
166
|
throw new BridgeIdentityError(`email domain mismatch: "${domain}" is not allowed for this deployment`);
|
|
162
167
|
}
|
|
163
|
-
|
|
168
|
+
let name = claims.preferred_username?.trim();
|
|
169
|
+
// If preferred_username is email-shaped and its domain is trusted, use the
|
|
170
|
+
// local part so the CLI (which uses full email as preferred_username) maps
|
|
171
|
+
// to the same user as the UI (which uses bare entity name).
|
|
172
|
+
if (name && name.includes('@')) {
|
|
173
|
+
const usernameParts = name.split('@');
|
|
174
|
+
if (usernameParts.length === 2 && usernameParts[0]) {
|
|
175
|
+
const [userLocal, userDomain] = usernameParts;
|
|
176
|
+
if (trusted.includes(userDomain.toLowerCase())) {
|
|
177
|
+
name = userLocal;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
name = name || emailLocalPart;
|
|
164
182
|
return (0, provisioning_1.toLiteLLMUserId)(name, userIdDomain);
|
|
165
183
|
}
|
|
166
184
|
/**
|
|
@@ -168,12 +186,28 @@ function resolveBridgeUserId(claims, identity) {
|
|
|
168
186
|
* missing and provisioning is enabled, creates it from the JWT claims (email +
|
|
169
187
|
* name); if provisioning is disabled, throws a 404 telling the caller to log
|
|
170
188
|
* in to Backstage first (the UI is the primary provisioning entry point).
|
|
189
|
+
*
|
|
190
|
+
* When the computed userId is not found, before provisioning we also search
|
|
191
|
+
* for an existing user by email. This handles the case where a UI user (created
|
|
192
|
+
* with a bare entity name) is accessed via the CLI (which uses email-shaped
|
|
193
|
+
* preferred_username), and helps recover from cases where the bridge's userId
|
|
194
|
+
* computation and the UI's differ.
|
|
171
195
|
*/
|
|
172
196
|
async function getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain) {
|
|
173
197
|
const userId = resolveBridgeUserId(claims, userIdDomain);
|
|
174
198
|
const existing = await client.getUserInfo(userId);
|
|
175
199
|
if (existing)
|
|
176
200
|
return existing;
|
|
201
|
+
// Before provisioning, try to find an existing user by email.
|
|
202
|
+
// This handles the case where the UI created a user under a different ID
|
|
203
|
+
// (e.g. bare entity name) but the same email.
|
|
204
|
+
if (claims.email) {
|
|
205
|
+
const byEmail = await client.getUserByEmail(claims.email);
|
|
206
|
+
if (byEmail) {
|
|
207
|
+
logger.info(`Found existing LiteLLM user ${byEmail.user_id} by email ${claims.email}, reusing for ${userId}`);
|
|
208
|
+
return byEmail;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
177
211
|
if (!provisioningEnabled) {
|
|
178
212
|
throw new provisioning_1.ProvisioningError('User not found in LiteLLM', 'No LiteLLM user for this identity. Log in to Backstage once to be provisioned, or enable litellm.provisioning.enabled.', false);
|
|
179
213
|
}
|
|
@@ -189,20 +223,19 @@ async function getOrProvisionUserFromClaims(client, claims, provisioningEnabled,
|
|
|
189
223
|
}
|
|
190
224
|
/** Lists the caller's virtual keys (provisioning the user first if needed). */
|
|
191
225
|
async function bridgeListKeys(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain) {
|
|
192
|
-
await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain);
|
|
193
|
-
return client.listKeys(
|
|
226
|
+
const user = await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain);
|
|
227
|
+
return client.listKeys(user.user_id);
|
|
194
228
|
}
|
|
195
229
|
/** Mints a new virtual key for the caller (provisioning the user first if needed). */
|
|
196
230
|
async function bridgeGenerateKey(client, claims, provisioningEnabled, provisioningDefaults, logger, request, userIdDomain) {
|
|
197
|
-
await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain);
|
|
198
|
-
const userId = resolveBridgeUserId(claims, userIdDomain);
|
|
231
|
+
const user = await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain);
|
|
199
232
|
const enriched = {
|
|
200
233
|
...request,
|
|
201
|
-
user_id:
|
|
234
|
+
user_id: user.user_id,
|
|
202
235
|
metadata: {
|
|
203
236
|
...(request.metadata ?? {}),
|
|
204
237
|
created_via: 'abby-cli',
|
|
205
|
-
created_by:
|
|
238
|
+
created_by: user.user_id,
|
|
206
239
|
created_at_iso: new Date().toISOString(),
|
|
207
240
|
},
|
|
208
241
|
};
|
package/dist/client.d.ts
CHANGED
|
@@ -33,6 +33,12 @@ export declare class LiteLLMClient {
|
|
|
33
33
|
* the rest of the code can rely on the UserInfo contract.
|
|
34
34
|
*/
|
|
35
35
|
getUserInfo(userId?: string): Promise<UserInfo | null>;
|
|
36
|
+
/**
|
|
37
|
+
* Searches for an existing user by email address. Returns the first matching
|
|
38
|
+
* user or null if none found. Handles both response shapes: a bare array
|
|
39
|
+
* or `{ users: [...] }`. Matching is case-insensitive on the email address.
|
|
40
|
+
*/
|
|
41
|
+
getUserByEmail(email: string): Promise<UserInfo | null>;
|
|
36
42
|
createUser(payload: CreateUserRequest): Promise<CreateUserResponse>;
|
|
37
43
|
/**
|
|
38
44
|
* Updates an existing LiteLLM user record. Used as a defensive follow-up
|
package/dist/client.js
CHANGED
|
@@ -127,6 +127,56 @@ class LiteLLMClient {
|
|
|
127
127
|
throw err;
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Searches for an existing user by email address. Returns the first matching
|
|
132
|
+
* user or null if none found. Handles both response shapes: a bare array
|
|
133
|
+
* or `{ users: [...] }`. Matching is case-insensitive on the email address.
|
|
134
|
+
*/
|
|
135
|
+
async getUserByEmail(email) {
|
|
136
|
+
try {
|
|
137
|
+
const raw = await this.request(`/user/list?user_email=${encodeURIComponent(email)}`);
|
|
138
|
+
let users = [];
|
|
139
|
+
if (Array.isArray(raw)) {
|
|
140
|
+
users = raw;
|
|
141
|
+
}
|
|
142
|
+
else if (Array.isArray(raw?.users)) {
|
|
143
|
+
users = raw.users;
|
|
144
|
+
}
|
|
145
|
+
if (!users.length)
|
|
146
|
+
return null;
|
|
147
|
+
// Match the first user (case-insensitive email comparison)
|
|
148
|
+
const found = users.find((u) => (u.user_email ?? u.email ?? '')
|
|
149
|
+
.toLowerCase() === email.toLowerCase());
|
|
150
|
+
if (!found)
|
|
151
|
+
return null;
|
|
152
|
+
// Normalize to UserInfo shape (same as getUserInfo does)
|
|
153
|
+
const inner = found.user_info ?? {};
|
|
154
|
+
const teamIds = Array.isArray(found.teams)
|
|
155
|
+
? found.teams
|
|
156
|
+
.map((t) => (typeof t === 'string' ? t : t?.team_id))
|
|
157
|
+
.filter((t) => typeof t === 'string')
|
|
158
|
+
: [];
|
|
159
|
+
return {
|
|
160
|
+
user_id: found.user_id ?? inner.user_id ?? '',
|
|
161
|
+
user_email: inner.user_email ?? found.user_email,
|
|
162
|
+
email: inner.email ?? found.email,
|
|
163
|
+
teams: teamIds,
|
|
164
|
+
models: inner.models ?? found.models,
|
|
165
|
+
max_budget: inner.max_budget ?? found.max_budget,
|
|
166
|
+
budget_duration: inner.budget_duration ?? found.budget_duration,
|
|
167
|
+
budget_reset_at: inner.budget_reset_at ?? found.budget_reset_at,
|
|
168
|
+
spend: inner.spend ?? found.spend,
|
|
169
|
+
current_spend: inner.current_spend ?? found.current_spend,
|
|
170
|
+
soft_limit: inner.soft_limit ?? found.soft_limit,
|
|
171
|
+
hard_limit: inner.hard_limit ?? found.hard_limit,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
if (err instanceof LiteLLMUpstreamError && err.status === 404)
|
|
176
|
+
return null;
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
130
180
|
async createUser(payload) {
|
|
131
181
|
return this.request('/user/new', {
|
|
132
182
|
method: 'POST',
|