@acarmisc/backstage-plugin-litellm-backend 0.2.0 → 0.2.2

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
@@ -8,15 +8,61 @@ export declare class LiteLLMClient {
8
8
  /**
9
9
  * Returns null when the user is not found in LiteLLM (404).
10
10
  * Throws on all other errors so callers know something went wrong.
11
+ *
12
+ * LiteLLM's `/user/info` wraps the user row inside `user_info` and returns
13
+ * `teams` as an array of full team objects, not team_id strings. We flatten
14
+ * `user_info` onto the top level and reduce `teams` to a string[] of ids so
15
+ * the rest of the code can rely on the UserInfo contract.
11
16
  */
12
17
  getUserInfo(userId?: string): Promise<UserInfo | null>;
13
18
  createUser(payload: CreateUserRequest): Promise<CreateUserResponse>;
19
+ /**
20
+ * Updates an existing LiteLLM user record. Used as a defensive follow-up
21
+ * after /user/new because the upsert path of /user/new has been observed
22
+ * to silently drop fields like user_role under concurrent inserts.
23
+ */
24
+ updateUser(payload: Partial<CreateUserRequest> & {
25
+ user_id: string;
26
+ }): Promise<unknown>;
27
+ /**
28
+ * Returns the keys belonging to a user.
29
+ *
30
+ * Implementation note: LiteLLM's `/key/info` endpoint requires a `key`
31
+ * hash and returns 404 when only `user_id` is passed. The correct way
32
+ * to enumerate a user's keys is `/user/info?user_id=X`, which embeds
33
+ * a `keys` array with per-key metadata. We unwrap that array and
34
+ * normalise field names to match the frontend VirtualKey shape
35
+ * (LiteLLM exposes `key_name` for the masked display value and
36
+ * `expires` instead of `expires_at`).
37
+ */
14
38
  listKeys(userId?: string): Promise<VirtualKey[]>;
39
+ private toVirtualKey;
40
+ /**
41
+ * Creates a new virtual key on the LiteLLM proxy.
42
+ *
43
+ * Implementation notes — both required to avoid silently-empty keys:
44
+ * 1. The body must be the plain payload. An earlier version wrapped
45
+ * it as `{ json: request }`; LiteLLM doesn't unwrap that envelope
46
+ * and treats the request as having no fields, returning a key
47
+ * with null alias / models / budget / limits.
48
+ * 2. LiteLLM expects `key_alias`, not `alias`. Without the rename,
49
+ * the alias the user typed is dropped on the floor.
50
+ */
15
51
  generateKey(request: GenerateKeyRequest): Promise<GenerateKeyResponse>;
16
52
  updateKey(request: UpdateKeyRequest): Promise<VirtualKey>;
17
53
  deleteKeys(request: DeleteKeyRequest): Promise<{
18
54
  success: boolean;
19
55
  }>;
56
+ /**
57
+ * Returns the proxy's model catalogue normalised to the ModelInfo shape.
58
+ *
59
+ * Prefers `/model/info` which exposes `model_name`, `mode`, capability flags
60
+ * and per-token costs. Falls back to OpenAI-compatible `/models` (which only
61
+ * returns `{id}`) so the dropdown still works on installs where `/model/info`
62
+ * isn't reachable. Without this normalisation the UI saw blank labels and
63
+ * a single "other" group because `/models` doesn't populate `model_name`
64
+ * or `mode`.
65
+ */
20
66
  listModels(): Promise<ModelInfo[]>;
21
67
  getTeamInfo(teamId: string): Promise<TeamInfo>;
22
68
  private emptyUsage;
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
  });
@@ -36,11 +36,34 @@ class LiteLLMClient {
36
36
  /**
37
37
  * Returns null when the user is not found in LiteLLM (404).
38
38
  * Throws on all other errors so callers know something went wrong.
39
+ *
40
+ * LiteLLM's `/user/info` wraps the user row inside `user_info` and returns
41
+ * `teams` as an array of full team objects, not team_id strings. We flatten
42
+ * `user_info` onto the top level and reduce `teams` to a string[] of ids so
43
+ * the rest of the code can rely on the UserInfo contract.
39
44
  */
40
45
  async getUserInfo(userId) {
41
46
  const query = userId ? `?user_id=${encodeURIComponent(userId)}` : '';
42
47
  try {
43
- return await this.request(`/user/info${query}`);
48
+ const raw = await this.request(`/user/info${query}`);
49
+ const inner = raw?.user_info ?? {};
50
+ const teamIds = Array.isArray(raw?.teams)
51
+ ? raw.teams
52
+ .map((t) => (typeof t === 'string' ? t : t?.team_id))
53
+ .filter((t) => typeof t === 'string')
54
+ : [];
55
+ return {
56
+ user_id: raw?.user_id ?? inner.user_id ?? userId ?? '',
57
+ user_email: inner.user_email ?? raw?.user_email,
58
+ email: inner.email ?? raw?.email,
59
+ teams: teamIds,
60
+ models: inner.models ?? raw?.models,
61
+ max_budget: inner.max_budget ?? raw?.max_budget,
62
+ spend: inner.spend ?? raw?.spend,
63
+ current_spend: inner.current_spend ?? raw?.current_spend,
64
+ soft_limit: inner.soft_limit ?? raw?.soft_limit,
65
+ hard_limit: inner.hard_limit ?? raw?.hard_limit,
66
+ };
44
67
  }
45
68
  catch (err) {
46
69
  if (err.status === 404)
@@ -54,11 +77,35 @@ class LiteLLMClient {
54
77
  body: JSON.stringify(payload),
55
78
  });
56
79
  }
80
+ /**
81
+ * Updates an existing LiteLLM user record. Used as a defensive follow-up
82
+ * after /user/new because the upsert path of /user/new has been observed
83
+ * to silently drop fields like user_role under concurrent inserts.
84
+ */
85
+ async updateUser(payload) {
86
+ return this.request('/user/update', {
87
+ method: 'POST',
88
+ body: JSON.stringify(payload),
89
+ });
90
+ }
91
+ /**
92
+ * Returns the keys belonging to a user.
93
+ *
94
+ * Implementation note: LiteLLM's `/key/info` endpoint requires a `key`
95
+ * hash and returns 404 when only `user_id` is passed. The correct way
96
+ * to enumerate a user's keys is `/user/info?user_id=X`, which embeds
97
+ * a `keys` array with per-key metadata. We unwrap that array and
98
+ * normalise field names to match the frontend VirtualKey shape
99
+ * (LiteLLM exposes `key_name` for the masked display value and
100
+ * `expires` instead of `expires_at`).
101
+ */
57
102
  async listKeys(userId) {
58
- const query = userId ? `?user_id=${encodeURIComponent(userId)}` : '';
103
+ if (!userId)
104
+ return [];
59
105
  try {
60
- const response = await this.request(`/key/info${query}`);
61
- return Array.isArray(response) ? response : (response.info ?? []);
106
+ const response = await this.request(`/user/info?user_id=${encodeURIComponent(userId)}`);
107
+ const rawKeys = response.keys ?? [];
108
+ return rawKeys.map(this.toVirtualKey);
62
109
  }
63
110
  catch (err) {
64
111
  if (err.status === 404 || err.message.includes('not found')) {
@@ -67,10 +114,44 @@ class LiteLLMClient {
67
114
  throw err;
68
115
  }
69
116
  }
117
+ toVirtualKey(k) {
118
+ return {
119
+ // The hashed `token` never leaves LiteLLM in a usable form; the
120
+ // masked `key_name` ("sk-...XXXX") is what the UI displays. Fall
121
+ // back to `token` only when `key_name` is missing.
122
+ key: k.key_name ?? k.token,
123
+ token: k.token,
124
+ key_alias: k.key_alias ?? undefined,
125
+ created_at: k.created_at,
126
+ expires_at: k.expires ?? undefined,
127
+ spend: k.spend ?? 0,
128
+ max_budget: k.max_budget ?? undefined,
129
+ tpm_limit: k.tpm_limit ?? undefined,
130
+ rpm_limit: k.rpm_limit ?? undefined,
131
+ models: k.models ?? [],
132
+ user_id: k.user_id ?? undefined,
133
+ };
134
+ }
135
+ /**
136
+ * Creates a new virtual key on the LiteLLM proxy.
137
+ *
138
+ * Implementation notes — both required to avoid silently-empty keys:
139
+ * 1. The body must be the plain payload. An earlier version wrapped
140
+ * it as `{ json: request }`; LiteLLM doesn't unwrap that envelope
141
+ * and treats the request as having no fields, returning a key
142
+ * with null alias / models / budget / limits.
143
+ * 2. LiteLLM expects `key_alias`, not `alias`. Without the rename,
144
+ * the alias the user typed is dropped on the floor.
145
+ */
70
146
  async generateKey(request) {
147
+ const { alias, ...rest } = request;
148
+ const payload = {
149
+ ...rest,
150
+ ...(alias && { key_alias: alias }),
151
+ };
71
152
  return this.request('/key/generate', {
72
153
  method: 'POST',
73
- body: JSON.stringify({ json: request }),
154
+ body: JSON.stringify(payload),
74
155
  });
75
156
  }
76
157
  async updateKey(request) {
@@ -85,9 +166,53 @@ class LiteLLMClient {
85
166
  body: JSON.stringify(request),
86
167
  });
87
168
  }
169
+ /**
170
+ * Returns the proxy's model catalogue normalised to the ModelInfo shape.
171
+ *
172
+ * Prefers `/model/info` which exposes `model_name`, `mode`, capability flags
173
+ * and per-token costs. Falls back to OpenAI-compatible `/models` (which only
174
+ * returns `{id}`) so the dropdown still works on installs where `/model/info`
175
+ * isn't reachable. Without this normalisation the UI saw blank labels and
176
+ * a single "other" group because `/models` doesn't populate `model_name`
177
+ * or `mode`.
178
+ */
88
179
  async listModels() {
89
- const response = await this.request('/models');
90
- return Array.isArray(response) ? response : (response.data ?? []);
180
+ try {
181
+ const response = await this.request('/model/info');
182
+ const data = Array.isArray(response?.data) ? response.data : [];
183
+ const normalised = data.map((m) => {
184
+ const info = m.model_info ?? {};
185
+ const params = m.litellm_params ?? {};
186
+ return {
187
+ model_name: m.model_name ?? params.model ?? info.id ?? '',
188
+ mode: info.mode ?? m.mode ?? 'chat',
189
+ supports_function_calling: info.supports_function_calling ?? m.supports_function_calling,
190
+ supports_vision: info.supports_vision ?? m.supports_vision,
191
+ input_cost_per_token: info.input_cost_per_token ?? params.input_cost_per_token,
192
+ output_cost_per_token: info.output_cost_per_token ?? params.output_cost_per_token,
193
+ };
194
+ });
195
+ const filtered = normalised.filter(m => m.model_name);
196
+ if (filtered.length)
197
+ return filtered;
198
+ }
199
+ catch {
200
+ // fall through to /models
201
+ }
202
+ const fallback = await this.request('/models');
203
+ const data = Array.isArray(fallback)
204
+ ? fallback
205
+ : Array.isArray(fallback?.data)
206
+ ? fallback.data
207
+ : [];
208
+ return data
209
+ .map((m) => ({
210
+ model_name: m.model_name ?? m.id ?? '',
211
+ mode: m.mode ?? 'chat',
212
+ supports_function_calling: m.supports_function_calling,
213
+ supports_vision: m.supports_vision,
214
+ }))
215
+ .filter((m) => m.model_name);
91
216
  }
92
217
  async getTeamInfo(teamId) {
93
218
  return this.request(`/team/info?team_id=${encodeURIComponent(teamId)}`);
@@ -120,7 +245,9 @@ class LiteLLMClient {
120
245
  * - usage_by_key → which keys drove cost / traffic (with key_alias + team_id from metadata)
121
246
  */
122
247
  transformDailyActivity(response) {
123
- const results = Array.isArray(response?.results) ? response.results : [];
248
+ const results = Array.isArray(response?.results)
249
+ ? response.results
250
+ : [];
124
251
  const meta = response?.metadata ?? {};
125
252
  const daily_usage = results
126
253
  .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
  }
@@ -68,11 +81,30 @@ var LiteLLMClient = class {
68
81
  /**
69
82
  * Returns null when the user is not found in LiteLLM (404).
70
83
  * Throws on all other errors so callers know something went wrong.
84
+ *
85
+ * LiteLLM's `/user/info` wraps the user row inside `user_info` and returns
86
+ * `teams` as an array of full team objects, not team_id strings. We flatten
87
+ * `user_info` onto the top level and reduce `teams` to a string[] of ids so
88
+ * the rest of the code can rely on the UserInfo contract.
71
89
  */
72
90
  async getUserInfo(userId) {
73
91
  const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
74
92
  try {
75
- return await this.request(`/user/info${query}`);
93
+ const raw = await this.request(`/user/info${query}`);
94
+ const inner = raw?.user_info ?? {};
95
+ const teamIds = Array.isArray(raw?.teams) ? raw.teams.map((t) => typeof t === "string" ? t : t?.team_id).filter((t) => typeof t === "string") : [];
96
+ return {
97
+ user_id: raw?.user_id ?? inner.user_id ?? userId ?? "",
98
+ user_email: inner.user_email ?? raw?.user_email,
99
+ email: inner.email ?? raw?.email,
100
+ teams: teamIds,
101
+ models: inner.models ?? raw?.models,
102
+ max_budget: inner.max_budget ?? raw?.max_budget,
103
+ spend: inner.spend ?? raw?.spend,
104
+ current_spend: inner.current_spend ?? raw?.current_spend,
105
+ soft_limit: inner.soft_limit ?? raw?.soft_limit,
106
+ hard_limit: inner.hard_limit ?? raw?.hard_limit
107
+ };
76
108
  } catch (err) {
77
109
  if (err.status === 404) return null;
78
110
  throw err;
@@ -84,11 +116,36 @@ var LiteLLMClient = class {
84
116
  body: JSON.stringify(payload)
85
117
  });
86
118
  }
119
+ /**
120
+ * Updates an existing LiteLLM user record. Used as a defensive follow-up
121
+ * after /user/new because the upsert path of /user/new has been observed
122
+ * to silently drop fields like user_role under concurrent inserts.
123
+ */
124
+ async updateUser(payload) {
125
+ return this.request("/user/update", {
126
+ method: "POST",
127
+ body: JSON.stringify(payload)
128
+ });
129
+ }
130
+ /**
131
+ * Returns the keys belonging to a user.
132
+ *
133
+ * Implementation note: LiteLLM's `/key/info` endpoint requires a `key`
134
+ * hash and returns 404 when only `user_id` is passed. The correct way
135
+ * to enumerate a user's keys is `/user/info?user_id=X`, which embeds
136
+ * a `keys` array with per-key metadata. We unwrap that array and
137
+ * normalise field names to match the frontend VirtualKey shape
138
+ * (LiteLLM exposes `key_name` for the masked display value and
139
+ * `expires` instead of `expires_at`).
140
+ */
87
141
  async listKeys(userId) {
88
- const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
142
+ if (!userId) return [];
89
143
  try {
90
- const response = await this.request(`/key/info${query}`);
91
- return Array.isArray(response) ? response : response.info ?? [];
144
+ const response = await this.request(
145
+ `/user/info?user_id=${encodeURIComponent(userId)}`
146
+ );
147
+ const rawKeys = response.keys ?? [];
148
+ return rawKeys.map(this.toVirtualKey);
92
149
  } catch (err) {
93
150
  if (err.status === 404 || err.message.includes("not found")) {
94
151
  return [];
@@ -96,10 +153,44 @@ var LiteLLMClient = class {
96
153
  throw err;
97
154
  }
98
155
  }
156
+ toVirtualKey(k) {
157
+ return {
158
+ // The hashed `token` never leaves LiteLLM in a usable form; the
159
+ // masked `key_name` ("sk-...XXXX") is what the UI displays. Fall
160
+ // back to `token` only when `key_name` is missing.
161
+ key: k.key_name ?? k.token,
162
+ token: k.token,
163
+ key_alias: k.key_alias ?? void 0,
164
+ created_at: k.created_at,
165
+ expires_at: k.expires ?? void 0,
166
+ spend: k.spend ?? 0,
167
+ max_budget: k.max_budget ?? void 0,
168
+ tpm_limit: k.tpm_limit ?? void 0,
169
+ rpm_limit: k.rpm_limit ?? void 0,
170
+ models: k.models ?? [],
171
+ user_id: k.user_id ?? void 0
172
+ };
173
+ }
174
+ /**
175
+ * Creates a new virtual key on the LiteLLM proxy.
176
+ *
177
+ * Implementation notes — both required to avoid silently-empty keys:
178
+ * 1. The body must be the plain payload. An earlier version wrapped
179
+ * it as `{ json: request }`; LiteLLM doesn't unwrap that envelope
180
+ * and treats the request as having no fields, returning a key
181
+ * with null alias / models / budget / limits.
182
+ * 2. LiteLLM expects `key_alias`, not `alias`. Without the rename,
183
+ * the alias the user typed is dropped on the floor.
184
+ */
99
185
  async generateKey(request) {
186
+ const { alias, ...rest } = request;
187
+ const payload = {
188
+ ...rest,
189
+ ...alias && { key_alias: alias }
190
+ };
100
191
  return this.request("/key/generate", {
101
192
  method: "POST",
102
- body: JSON.stringify({ json: request })
193
+ body: JSON.stringify(payload)
103
194
  });
104
195
  }
105
196
  async updateKey(request) {
@@ -114,12 +205,49 @@ var LiteLLMClient = class {
114
205
  body: JSON.stringify(request)
115
206
  });
116
207
  }
208
+ /**
209
+ * Returns the proxy's model catalogue normalised to the ModelInfo shape.
210
+ *
211
+ * Prefers `/model/info` which exposes `model_name`, `mode`, capability flags
212
+ * and per-token costs. Falls back to OpenAI-compatible `/models` (which only
213
+ * returns `{id}`) so the dropdown still works on installs where `/model/info`
214
+ * isn't reachable. Without this normalisation the UI saw blank labels and
215
+ * a single "other" group because `/models` doesn't populate `model_name`
216
+ * or `mode`.
217
+ */
117
218
  async listModels() {
118
- const response = await this.request("/models");
119
- return Array.isArray(response) ? response : response.data ?? [];
219
+ try {
220
+ const response = await this.request("/model/info");
221
+ const data2 = Array.isArray(response?.data) ? response.data : [];
222
+ const normalised = data2.map((m) => {
223
+ const info = m.model_info ?? {};
224
+ const params = m.litellm_params ?? {};
225
+ return {
226
+ model_name: m.model_name ?? params.model ?? info.id ?? "",
227
+ mode: info.mode ?? m.mode ?? "chat",
228
+ supports_function_calling: info.supports_function_calling ?? m.supports_function_calling,
229
+ supports_vision: info.supports_vision ?? m.supports_vision,
230
+ input_cost_per_token: info.input_cost_per_token ?? params.input_cost_per_token,
231
+ output_cost_per_token: info.output_cost_per_token ?? params.output_cost_per_token
232
+ };
233
+ });
234
+ const filtered = normalised.filter((m) => m.model_name);
235
+ if (filtered.length) return filtered;
236
+ } catch {
237
+ }
238
+ const fallback = await this.request("/models");
239
+ const data = Array.isArray(fallback) ? fallback : Array.isArray(fallback?.data) ? fallback.data : [];
240
+ return data.map((m) => ({
241
+ model_name: m.model_name ?? m.id ?? "",
242
+ mode: m.mode ?? "chat",
243
+ supports_function_calling: m.supports_function_calling,
244
+ supports_vision: m.supports_vision
245
+ })).filter((m) => m.model_name);
120
246
  }
121
247
  async getTeamInfo(teamId) {
122
- return this.request(`/team/info?team_id=${encodeURIComponent(teamId)}`);
248
+ return this.request(
249
+ `/team/info?team_id=${encodeURIComponent(teamId)}`
250
+ );
123
251
  }
124
252
  emptyUsage() {
125
253
  return {
@@ -243,7 +371,9 @@ var LiteLLMClient = class {
243
371
  });
244
372
  if (userId) params.append("user_id", userId);
245
373
  try {
246
- const response = await this.request(`/user/daily/activity?${params.toString()}`);
374
+ const response = await this.request(
375
+ `/user/daily/activity?${params.toString()}`
376
+ );
247
377
  return this.transformDailyActivity(response);
248
378
  } catch (err) {
249
379
  if (err.status === 404 || err.message.includes("not found")) {
@@ -260,7 +390,9 @@ var LiteLLMClient = class {
260
390
  page_size: "100"
261
391
  });
262
392
  try {
263
- const response = await this.request(`/team/daily/activity?${params.toString()}`);
393
+ const response = await this.request(
394
+ `/team/daily/activity?${params.toString()}`
395
+ );
264
396
  return this.transformDailyActivity(response);
265
397
  } catch (err) {
266
398
  if (err.status === 404 || err.message.includes("not found")) {
@@ -370,6 +502,7 @@ async function provisionUser(client, userId, defaults, profile, backstageEntity,
370
502
  ...defaults.tpmLimit !== void 0 && { tpm_limit: defaults.tpmLimit },
371
503
  ...defaults.rpmLimit !== void 0 && { rpm_limit: defaults.rpmLimit },
372
504
  ...defaults.userRole && { user_role: defaults.userRole },
505
+ auto_create_key: false,
373
506
  metadata: {
374
507
  ...defaults.metadata,
375
508
  provisioned_by: "backstage",
@@ -537,6 +670,7 @@ async function createRouter(options) {
537
670
  );
538
671
  }
539
672
  const router = (0, import_express.Router)();
673
+ router.use(import_express.default.json());
540
674
  router.get("/health", (_req, res) => {
541
675
  res.json({ status: "ok", provisioning: provisioningEnabled });
542
676
  });
@@ -593,6 +727,19 @@ async function createRouter(options) {
593
727
  });
594
728
  router.post("/keys/generate", async (req, res) => {
595
729
  try {
730
+ const body = req.body ?? {};
731
+ const missing = [];
732
+ if (!body.alias?.trim()) missing.push("alias");
733
+ if (typeof body.max_budget !== "number" || body.max_budget <= 0) {
734
+ missing.push("max_budget (positive number)");
735
+ }
736
+ if (missing.length) {
737
+ res.status(400).json({
738
+ error: "Missing required fields",
739
+ hint: `Required: ${missing.join(", ")}`
740
+ });
741
+ return;
742
+ }
596
743
  const tokenEntityRef = await resolveUserId(req, auth);
597
744
  const resolvedUserId = tokenEntityRef ? toLiteLLMUserId(tokenEntityRef, userIdDomain) : void 0;
598
745
  if (resolvedUserId) {
@@ -608,8 +755,20 @@ async function createRouter(options) {
608
755
  logger
609
756
  );
610
757
  }
758
+ const profile = tokenEntityRef ? await resolveUserProfile(tokenEntityRef, catalogClient, auth, logger) : {};
759
+ const enrichedMetadata = {
760
+ ...body.metadata ?? {},
761
+ created_by_backstage_user: tokenEntityRef ?? "unknown",
762
+ ...profile.email && { created_by_email: profile.email },
763
+ ...profile.displayName && {
764
+ created_by_display_name: profile.displayName
765
+ },
766
+ created_via: "backstage",
767
+ created_at_iso: (/* @__PURE__ */ new Date()).toISOString()
768
+ };
611
769
  const request = {
612
- ...req.body,
770
+ ...body,
771
+ metadata: enrichedMetadata,
613
772
  ...resolvedUserId && { user_id: resolvedUserId }
614
773
  };
615
774
  const result = await client.generateKey(request);