@acarmisc/backstage-plugin-litellm-backend 0.16.1 → 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 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
- const name = claims.preferred_username?.trim() || emailLocalPart;
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(resolveBridgeUserId(claims, userIdDomain));
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: userId,
234
+ user_id: user.user_id,
202
235
  metadata: {
203
236
  ...(request.metadata ?? {}),
204
237
  created_via: 'abby-cli',
205
- created_by: userId,
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',