@acarmisc/backstage-plugin-litellm-backend 0.2.0 → 0.2.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/client.d.ts CHANGED
@@ -11,7 +11,38 @@ export declare class LiteLLMClient {
11
11
  */
12
12
  getUserInfo(userId?: string): Promise<UserInfo | null>;
13
13
  createUser(payload: CreateUserRequest): Promise<CreateUserResponse>;
14
+ /**
15
+ * Updates an existing LiteLLM user record. Used as a defensive follow-up
16
+ * after /user/new because the upsert path of /user/new has been observed
17
+ * to silently drop fields like user_role under concurrent inserts.
18
+ */
19
+ updateUser(payload: Partial<CreateUserRequest> & {
20
+ user_id: string;
21
+ }): Promise<unknown>;
22
+ /**
23
+ * Returns the keys belonging to a user.
24
+ *
25
+ * Implementation note: LiteLLM's `/key/info` endpoint requires a `key`
26
+ * hash and returns 404 when only `user_id` is passed. The correct way
27
+ * to enumerate a user's keys is `/user/info?user_id=X`, which embeds
28
+ * a `keys` array with per-key metadata. We unwrap that array and
29
+ * normalise field names to match the frontend VirtualKey shape
30
+ * (LiteLLM exposes `key_name` for the masked display value and
31
+ * `expires` instead of `expires_at`).
32
+ */
14
33
  listKeys(userId?: string): Promise<VirtualKey[]>;
34
+ private toVirtualKey;
35
+ /**
36
+ * Creates a new virtual key on the LiteLLM proxy.
37
+ *
38
+ * Implementation notes — both required to avoid silently-empty keys:
39
+ * 1. The body must be the plain payload. An earlier version wrapped
40
+ * it as `{ json: request }`; LiteLLM doesn't unwrap that envelope
41
+ * and treats the request as having no fields, returning a key
42
+ * with null alias / models / budget / limits.
43
+ * 2. LiteLLM expects `key_alias`, not `alias`. Without the rename,
44
+ * the alias the user typed is dropped on the floor.
45
+ */
15
46
  generateKey(request: GenerateKeyRequest): Promise<GenerateKeyResponse>;
16
47
  updateKey(request: UpdateKeyRequest): Promise<VirtualKey>;
17
48
  deleteKeys(request: DeleteKeyRequest): Promise<{
package/dist/client.js CHANGED
@@ -17,7 +17,7 @@ class LiteLLMClient {
17
17
  signal: controller.signal,
18
18
  headers: {
19
19
  'Content-Type': 'application/json',
20
- 'Authorization': `Bearer ${this.masterKey}`,
20
+ Authorization: `Bearer ${this.masterKey}`,
21
21
  ...options.headers,
22
22
  },
23
23
  });
@@ -54,11 +54,35 @@ class LiteLLMClient {
54
54
  body: JSON.stringify(payload),
55
55
  });
56
56
  }
57
+ /**
58
+ * Updates an existing LiteLLM user record. Used as a defensive follow-up
59
+ * after /user/new because the upsert path of /user/new has been observed
60
+ * to silently drop fields like user_role under concurrent inserts.
61
+ */
62
+ async updateUser(payload) {
63
+ return this.request('/user/update', {
64
+ method: 'POST',
65
+ body: JSON.stringify(payload),
66
+ });
67
+ }
68
+ /**
69
+ * Returns the keys belonging to a user.
70
+ *
71
+ * Implementation note: LiteLLM's `/key/info` endpoint requires a `key`
72
+ * hash and returns 404 when only `user_id` is passed. The correct way
73
+ * to enumerate a user's keys is `/user/info?user_id=X`, which embeds
74
+ * a `keys` array with per-key metadata. We unwrap that array and
75
+ * normalise field names to match the frontend VirtualKey shape
76
+ * (LiteLLM exposes `key_name` for the masked display value and
77
+ * `expires` instead of `expires_at`).
78
+ */
57
79
  async listKeys(userId) {
58
- const query = userId ? `?user_id=${encodeURIComponent(userId)}` : '';
80
+ if (!userId)
81
+ return [];
59
82
  try {
60
- const response = await this.request(`/key/info${query}`);
61
- return Array.isArray(response) ? response : (response.info ?? []);
83
+ const response = await this.request(`/user/info?user_id=${encodeURIComponent(userId)}`);
84
+ const rawKeys = response.keys ?? [];
85
+ return rawKeys.map(this.toVirtualKey);
62
86
  }
63
87
  catch (err) {
64
88
  if (err.status === 404 || err.message.includes('not found')) {
@@ -67,10 +91,44 @@ class LiteLLMClient {
67
91
  throw err;
68
92
  }
69
93
  }
94
+ toVirtualKey(k) {
95
+ return {
96
+ // The hashed `token` never leaves LiteLLM in a usable form; the
97
+ // masked `key_name` ("sk-...XXXX") is what the UI displays. Fall
98
+ // back to `token` only when `key_name` is missing.
99
+ key: k.key_name ?? k.token,
100
+ token: k.token,
101
+ key_alias: k.key_alias ?? undefined,
102
+ created_at: k.created_at,
103
+ expires_at: k.expires ?? undefined,
104
+ spend: k.spend ?? 0,
105
+ max_budget: k.max_budget ?? undefined,
106
+ tpm_limit: k.tpm_limit ?? undefined,
107
+ rpm_limit: k.rpm_limit ?? undefined,
108
+ models: k.models ?? [],
109
+ user_id: k.user_id ?? undefined,
110
+ };
111
+ }
112
+ /**
113
+ * Creates a new virtual key on the LiteLLM proxy.
114
+ *
115
+ * Implementation notes — both required to avoid silently-empty keys:
116
+ * 1. The body must be the plain payload. An earlier version wrapped
117
+ * it as `{ json: request }`; LiteLLM doesn't unwrap that envelope
118
+ * and treats the request as having no fields, returning a key
119
+ * with null alias / models / budget / limits.
120
+ * 2. LiteLLM expects `key_alias`, not `alias`. Without the rename,
121
+ * the alias the user typed is dropped on the floor.
122
+ */
70
123
  async generateKey(request) {
124
+ const { alias, ...rest } = request;
125
+ const payload = {
126
+ ...rest,
127
+ ...(alias && { key_alias: alias }),
128
+ };
71
129
  return this.request('/key/generate', {
72
130
  method: 'POST',
73
- body: JSON.stringify({ json: request }),
131
+ body: JSON.stringify(payload),
74
132
  });
75
133
  }
76
134
  async updateKey(request) {
@@ -87,7 +145,7 @@ class LiteLLMClient {
87
145
  }
88
146
  async listModels() {
89
147
  const response = await this.request('/models');
90
- return Array.isArray(response) ? response : (response.data ?? []);
148
+ return Array.isArray(response) ? response : response.data ?? [];
91
149
  }
92
150
  async getTeamInfo(teamId) {
93
151
  return this.request(`/team/info?team_id=${encodeURIComponent(teamId)}`);
@@ -120,7 +178,9 @@ class LiteLLMClient {
120
178
  * - usage_by_key → which keys drove cost / traffic (with key_alias + team_id from metadata)
121
179
  */
122
180
  transformDailyActivity(response) {
123
- const results = Array.isArray(response?.results) ? response.results : [];
181
+ const results = Array.isArray(response?.results)
182
+ ? response.results
183
+ : [];
124
184
  const meta = response?.metadata ?? {};
125
185
  const daily_usage = results
126
186
  .map(r => ({
package/dist/index.cjs.js CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -22,6 +32,7 @@ var index_exports = {};
22
32
  __export(index_exports, {
23
33
  LiteLLMClient: () => LiteLLMClient,
24
34
  createRouter: () => createRouter,
35
+ default: () => litellmPlugin,
25
36
  litellmPlugin: () => litellmPlugin
26
37
  });
27
38
  module.exports = __toCommonJS(index_exports);
@@ -30,7 +41,7 @@ module.exports = __toCommonJS(index_exports);
30
41
  var import_backend_plugin_api = require("@backstage/backend-plugin-api");
31
42
 
32
43
  // src/router.ts
33
- var import_express = require("express");
44
+ var import_express = __toESM(require("express"));
34
45
  var import_catalog_client = require("@backstage/catalog-client");
35
46
 
36
47
  // src/client.ts
@@ -50,13 +61,15 @@ var LiteLLMClient = class {
50
61
  signal: controller.signal,
51
62
  headers: {
52
63
  "Content-Type": "application/json",
53
- "Authorization": `Bearer ${this.masterKey}`,
64
+ Authorization: `Bearer ${this.masterKey}`,
54
65
  ...options.headers
55
66
  }
56
67
  });
57
68
  if (!response.ok) {
58
69
  const errorBody = await response.text();
59
- const err = new Error(`LiteLLM API error: ${response.status} ${response.statusText} - ${errorBody}`);
70
+ const err = new Error(
71
+ `LiteLLM API error: ${response.status} ${response.statusText} - ${errorBody}`
72
+ );
60
73
  err.status = response.status;
61
74
  throw err;
62
75
  }
@@ -84,11 +97,36 @@ var LiteLLMClient = class {
84
97
  body: JSON.stringify(payload)
85
98
  });
86
99
  }
100
+ /**
101
+ * Updates an existing LiteLLM user record. Used as a defensive follow-up
102
+ * after /user/new because the upsert path of /user/new has been observed
103
+ * to silently drop fields like user_role under concurrent inserts.
104
+ */
105
+ async updateUser(payload) {
106
+ return this.request("/user/update", {
107
+ method: "POST",
108
+ body: JSON.stringify(payload)
109
+ });
110
+ }
111
+ /**
112
+ * Returns the keys belonging to a user.
113
+ *
114
+ * Implementation note: LiteLLM's `/key/info` endpoint requires a `key`
115
+ * hash and returns 404 when only `user_id` is passed. The correct way
116
+ * to enumerate a user's keys is `/user/info?user_id=X`, which embeds
117
+ * a `keys` array with per-key metadata. We unwrap that array and
118
+ * normalise field names to match the frontend VirtualKey shape
119
+ * (LiteLLM exposes `key_name` for the masked display value and
120
+ * `expires` instead of `expires_at`).
121
+ */
87
122
  async listKeys(userId) {
88
- const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
123
+ if (!userId) return [];
89
124
  try {
90
- const response = await this.request(`/key/info${query}`);
91
- return Array.isArray(response) ? response : response.info ?? [];
125
+ const response = await this.request(
126
+ `/user/info?user_id=${encodeURIComponent(userId)}`
127
+ );
128
+ const rawKeys = response.keys ?? [];
129
+ return rawKeys.map(this.toVirtualKey);
92
130
  } catch (err) {
93
131
  if (err.status === 404 || err.message.includes("not found")) {
94
132
  return [];
@@ -96,10 +134,44 @@ var LiteLLMClient = class {
96
134
  throw err;
97
135
  }
98
136
  }
137
+ toVirtualKey(k) {
138
+ return {
139
+ // The hashed `token` never leaves LiteLLM in a usable form; the
140
+ // masked `key_name` ("sk-...XXXX") is what the UI displays. Fall
141
+ // back to `token` only when `key_name` is missing.
142
+ key: k.key_name ?? k.token,
143
+ token: k.token,
144
+ key_alias: k.key_alias ?? void 0,
145
+ created_at: k.created_at,
146
+ expires_at: k.expires ?? void 0,
147
+ spend: k.spend ?? 0,
148
+ max_budget: k.max_budget ?? void 0,
149
+ tpm_limit: k.tpm_limit ?? void 0,
150
+ rpm_limit: k.rpm_limit ?? void 0,
151
+ models: k.models ?? [],
152
+ user_id: k.user_id ?? void 0
153
+ };
154
+ }
155
+ /**
156
+ * Creates a new virtual key on the LiteLLM proxy.
157
+ *
158
+ * Implementation notes — both required to avoid silently-empty keys:
159
+ * 1. The body must be the plain payload. An earlier version wrapped
160
+ * it as `{ json: request }`; LiteLLM doesn't unwrap that envelope
161
+ * and treats the request as having no fields, returning a key
162
+ * with null alias / models / budget / limits.
163
+ * 2. LiteLLM expects `key_alias`, not `alias`. Without the rename,
164
+ * the alias the user typed is dropped on the floor.
165
+ */
99
166
  async generateKey(request) {
167
+ const { alias, ...rest } = request;
168
+ const payload = {
169
+ ...rest,
170
+ ...alias && { key_alias: alias }
171
+ };
100
172
  return this.request("/key/generate", {
101
173
  method: "POST",
102
- body: JSON.stringify({ json: request })
174
+ body: JSON.stringify(payload)
103
175
  });
104
176
  }
105
177
  async updateKey(request) {
@@ -115,11 +187,15 @@ var LiteLLMClient = class {
115
187
  });
116
188
  }
117
189
  async listModels() {
118
- const response = await this.request("/models");
190
+ const response = await this.request(
191
+ "/models"
192
+ );
119
193
  return Array.isArray(response) ? response : response.data ?? [];
120
194
  }
121
195
  async getTeamInfo(teamId) {
122
- return this.request(`/team/info?team_id=${encodeURIComponent(teamId)}`);
196
+ return this.request(
197
+ `/team/info?team_id=${encodeURIComponent(teamId)}`
198
+ );
123
199
  }
124
200
  emptyUsage() {
125
201
  return {
@@ -243,7 +319,9 @@ var LiteLLMClient = class {
243
319
  });
244
320
  if (userId) params.append("user_id", userId);
245
321
  try {
246
- const response = await this.request(`/user/daily/activity?${params.toString()}`);
322
+ const response = await this.request(
323
+ `/user/daily/activity?${params.toString()}`
324
+ );
247
325
  return this.transformDailyActivity(response);
248
326
  } catch (err) {
249
327
  if (err.status === 404 || err.message.includes("not found")) {
@@ -260,7 +338,9 @@ var LiteLLMClient = class {
260
338
  page_size: "100"
261
339
  });
262
340
  try {
263
- const response = await this.request(`/team/daily/activity?${params.toString()}`);
341
+ const response = await this.request(
342
+ `/team/daily/activity?${params.toString()}`
343
+ );
264
344
  return this.transformDailyActivity(response);
265
345
  } catch (err) {
266
346
  if (err.status === 404 || err.message.includes("not found")) {
@@ -370,6 +450,7 @@ async function provisionUser(client, userId, defaults, profile, backstageEntity,
370
450
  ...defaults.tpmLimit !== void 0 && { tpm_limit: defaults.tpmLimit },
371
451
  ...defaults.rpmLimit !== void 0 && { rpm_limit: defaults.rpmLimit },
372
452
  ...defaults.userRole && { user_role: defaults.userRole },
453
+ auto_create_key: false,
373
454
  metadata: {
374
455
  ...defaults.metadata,
375
456
  provisioned_by: "backstage",
@@ -537,6 +618,7 @@ async function createRouter(options) {
537
618
  );
538
619
  }
539
620
  const router = (0, import_express.Router)();
621
+ router.use(import_express.default.json());
540
622
  router.get("/health", (_req, res) => {
541
623
  res.json({ status: "ok", provisioning: provisioningEnabled });
542
624
  });
@@ -593,6 +675,19 @@ async function createRouter(options) {
593
675
  });
594
676
  router.post("/keys/generate", async (req, res) => {
595
677
  try {
678
+ const body = req.body ?? {};
679
+ const missing = [];
680
+ if (!body.alias?.trim()) missing.push("alias");
681
+ if (typeof body.max_budget !== "number" || body.max_budget <= 0) {
682
+ missing.push("max_budget (positive number)");
683
+ }
684
+ if (missing.length) {
685
+ res.status(400).json({
686
+ error: "Missing required fields",
687
+ hint: `Required: ${missing.join(", ")}`
688
+ });
689
+ return;
690
+ }
596
691
  const tokenEntityRef = await resolveUserId(req, auth);
597
692
  const resolvedUserId = tokenEntityRef ? toLiteLLMUserId(tokenEntityRef, userIdDomain) : void 0;
598
693
  if (resolvedUserId) {
@@ -608,8 +703,20 @@ async function createRouter(options) {
608
703
  logger
609
704
  );
610
705
  }
706
+ const profile = tokenEntityRef ? await resolveUserProfile(tokenEntityRef, catalogClient, auth, logger) : {};
707
+ const enrichedMetadata = {
708
+ ...body.metadata ?? {},
709
+ created_by_backstage_user: tokenEntityRef ?? "unknown",
710
+ ...profile.email && { created_by_email: profile.email },
711
+ ...profile.displayName && {
712
+ created_by_display_name: profile.displayName
713
+ },
714
+ created_via: "backstage",
715
+ created_at_iso: (/* @__PURE__ */ new Date()).toISOString()
716
+ };
611
717
  const request = {
612
- ...req.body,
718
+ ...body,
719
+ metadata: enrichedMetadata,
613
720
  ...resolvedUserId && { user_id: resolvedUserId }
614
721
  };
615
722
  const result = await client.generateKey(request);