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

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.
@@ -0,0 +1,38 @@
1
+ import { LiteLLMConfig, UserInfo, VirtualKey, ModelInfo, UsageMetrics, TeamInfo, GenerateKeyRequest, GenerateKeyResponse, UpdateKeyRequest, DeleteKeyRequest, CreateUserRequest, CreateUserResponse } from './types';
2
+ export declare class LiteLLMClient {
3
+ private baseUrl;
4
+ private masterKey;
5
+ private timeout;
6
+ constructor(config: LiteLLMConfig, timeout?: number);
7
+ private request;
8
+ /**
9
+ * Returns null when the user is not found in LiteLLM (404).
10
+ * Throws on all other errors so callers know something went wrong.
11
+ */
12
+ getUserInfo(userId?: string): Promise<UserInfo | null>;
13
+ createUser(payload: CreateUserRequest): Promise<CreateUserResponse>;
14
+ listKeys(userId?: string): Promise<VirtualKey[]>;
15
+ generateKey(request: GenerateKeyRequest): Promise<GenerateKeyResponse>;
16
+ updateKey(request: UpdateKeyRequest): Promise<VirtualKey>;
17
+ deleteKeys(request: DeleteKeyRequest): Promise<{
18
+ success: boolean;
19
+ }>;
20
+ listModels(): Promise<ModelInfo[]>;
21
+ getTeamInfo(teamId: string): Promise<TeamInfo>;
22
+ private emptyUsage;
23
+ /**
24
+ * Transforms LiteLLM's SpendAnalyticsPaginatedResponse into the flatter
25
+ * UsageMetrics shape consumed by the frontend charts.
26
+ *
27
+ * Source shape (per result row):
28
+ * { date, metrics, breakdown: { models: { [name]: { metrics, api_key_breakdown: { [keyHash]: { metrics, metadata } } } } } }
29
+ *
30
+ * We fan that out into three views the UI consumes:
31
+ * - daily_usage → spend + request trends over time
32
+ * - usage_by_model → which models drove cost / traffic
33
+ * - usage_by_key → which keys drove cost / traffic (with key_alias + team_id from metadata)
34
+ */
35
+ private transformDailyActivity;
36
+ getUsage(startDate: string, endDate: string, userId?: string, _groupBy?: string): Promise<UsageMetrics>;
37
+ getTeamUsage(teamId: string, startDate: string, endDate: string): Promise<UsageMetrics>;
38
+ }
package/dist/client.js ADDED
@@ -0,0 +1,252 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LiteLLMClient = void 0;
4
+ const DEFAULT_TIMEOUT = 30000;
5
+ class LiteLLMClient {
6
+ constructor(config, timeout = DEFAULT_TIMEOUT) {
7
+ this.baseUrl = config.baseUrl.replace(/\/$/, '');
8
+ this.masterKey = config.masterKey;
9
+ this.timeout = timeout;
10
+ }
11
+ async request(path, options = {}) {
12
+ const controller = new AbortController();
13
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
14
+ try {
15
+ const response = await fetch(`${this.baseUrl}${path}`, {
16
+ ...options,
17
+ signal: controller.signal,
18
+ headers: {
19
+ 'Content-Type': 'application/json',
20
+ 'Authorization': `Bearer ${this.masterKey}`,
21
+ ...options.headers,
22
+ },
23
+ });
24
+ if (!response.ok) {
25
+ const errorBody = await response.text();
26
+ const err = new Error(`LiteLLM API error: ${response.status} ${response.statusText} - ${errorBody}`);
27
+ err.status = response.status;
28
+ throw err;
29
+ }
30
+ return response.json();
31
+ }
32
+ finally {
33
+ clearTimeout(timeoutId);
34
+ }
35
+ }
36
+ /**
37
+ * Returns null when the user is not found in LiteLLM (404).
38
+ * Throws on all other errors so callers know something went wrong.
39
+ */
40
+ async getUserInfo(userId) {
41
+ const query = userId ? `?user_id=${encodeURIComponent(userId)}` : '';
42
+ try {
43
+ return await this.request(`/user/info${query}`);
44
+ }
45
+ catch (err) {
46
+ if (err.status === 404)
47
+ return null;
48
+ throw err;
49
+ }
50
+ }
51
+ async createUser(payload) {
52
+ return this.request('/user/new', {
53
+ method: 'POST',
54
+ body: JSON.stringify(payload),
55
+ });
56
+ }
57
+ async listKeys(userId) {
58
+ const query = userId ? `?user_id=${encodeURIComponent(userId)}` : '';
59
+ try {
60
+ const response = await this.request(`/key/info${query}`);
61
+ return Array.isArray(response) ? response : (response.info ?? []);
62
+ }
63
+ catch (err) {
64
+ if (err.status === 404 || err.message.includes('not found')) {
65
+ return [];
66
+ }
67
+ throw err;
68
+ }
69
+ }
70
+ async generateKey(request) {
71
+ return this.request('/key/generate', {
72
+ method: 'POST',
73
+ body: JSON.stringify({ json: request }),
74
+ });
75
+ }
76
+ async updateKey(request) {
77
+ return this.request('/key/update', {
78
+ method: 'POST',
79
+ body: JSON.stringify(request),
80
+ });
81
+ }
82
+ async deleteKeys(request) {
83
+ return this.request('/key/delete', {
84
+ method: 'POST',
85
+ body: JSON.stringify(request),
86
+ });
87
+ }
88
+ async listModels() {
89
+ const response = await this.request('/models');
90
+ return Array.isArray(response) ? response : (response.data ?? []);
91
+ }
92
+ async getTeamInfo(teamId) {
93
+ return this.request(`/team/info?team_id=${encodeURIComponent(teamId)}`);
94
+ }
95
+ emptyUsage() {
96
+ return {
97
+ total_spend: 0,
98
+ total_tokens: 0,
99
+ prompt_tokens: 0,
100
+ completion_tokens: 0,
101
+ api_requests: 0,
102
+ successful_requests: 0,
103
+ failed_requests: 0,
104
+ usage_by_model: {},
105
+ usage_by_key: {},
106
+ daily_usage: [],
107
+ daily_by_model: [],
108
+ };
109
+ }
110
+ /**
111
+ * Transforms LiteLLM's SpendAnalyticsPaginatedResponse into the flatter
112
+ * UsageMetrics shape consumed by the frontend charts.
113
+ *
114
+ * Source shape (per result row):
115
+ * { date, metrics, breakdown: { models: { [name]: { metrics, api_key_breakdown: { [keyHash]: { metrics, metadata } } } } } }
116
+ *
117
+ * We fan that out into three views the UI consumes:
118
+ * - daily_usage → spend + request trends over time
119
+ * - usage_by_model → which models drove cost / traffic
120
+ * - usage_by_key → which keys drove cost / traffic (with key_alias + team_id from metadata)
121
+ */
122
+ transformDailyActivity(response) {
123
+ const results = Array.isArray(response?.results) ? response.results : [];
124
+ const meta = response?.metadata ?? {};
125
+ const daily_usage = results
126
+ .map(r => ({
127
+ date: r.date,
128
+ spend: r.metrics?.spend ?? 0,
129
+ total_tokens: r.metrics?.total_tokens ?? 0,
130
+ prompt_tokens: r.metrics?.prompt_tokens ?? 0,
131
+ completion_tokens: r.metrics?.completion_tokens ?? 0,
132
+ api_requests: r.metrics?.api_requests ?? 0,
133
+ successful_requests: r.metrics?.successful_requests ?? 0,
134
+ failed_requests: r.metrics?.failed_requests ?? 0,
135
+ }))
136
+ .sort((a, b) => a.date.localeCompare(b.date));
137
+ const usage_by_model = {};
138
+ const usage_by_key = {};
139
+ const daily_by_model = [];
140
+ const emptyModelBucket = () => ({
141
+ total_spend: 0,
142
+ total_tokens: 0,
143
+ prompt_tokens: 0,
144
+ completion_tokens: 0,
145
+ api_requests: 0,
146
+ successful_requests: 0,
147
+ failed_requests: 0,
148
+ });
149
+ for (const r of results) {
150
+ const models = r.breakdown?.models ?? {};
151
+ for (const [name, entry] of Object.entries(models)) {
152
+ const m = entry?.metrics ?? {};
153
+ const bucket = usage_by_model[name] ?? emptyModelBucket();
154
+ bucket.total_spend += m.spend ?? 0;
155
+ bucket.total_tokens += m.total_tokens ?? 0;
156
+ bucket.prompt_tokens += m.prompt_tokens ?? 0;
157
+ bucket.completion_tokens += m.completion_tokens ?? 0;
158
+ bucket.api_requests += m.api_requests ?? 0;
159
+ bucket.successful_requests += m.successful_requests ?? 0;
160
+ bucket.failed_requests += m.failed_requests ?? 0;
161
+ usage_by_model[name] = bucket;
162
+ daily_by_model.push({
163
+ date: r.date,
164
+ model: name,
165
+ spend: m.spend ?? 0,
166
+ prompt_tokens: m.prompt_tokens ?? 0,
167
+ completion_tokens: m.completion_tokens ?? 0,
168
+ total_tokens: m.total_tokens ?? 0,
169
+ api_requests: m.api_requests ?? 0,
170
+ successful_requests: m.successful_requests ?? 0,
171
+ failed_requests: m.failed_requests ?? 0,
172
+ });
173
+ const keyMap = entry?.api_key_breakdown ?? {};
174
+ for (const [keyHash, keyEntry] of Object.entries(keyMap)) {
175
+ const km = keyEntry?.metrics ?? {};
176
+ const kmeta = keyEntry?.metadata ?? {};
177
+ const kb = usage_by_key[keyHash] ?? {
178
+ key_alias: kmeta.key_alias,
179
+ team_id: kmeta.team_id ?? null,
180
+ models: [],
181
+ ...emptyModelBucket(),
182
+ };
183
+ if (!kb.key_alias && kmeta.key_alias)
184
+ kb.key_alias = kmeta.key_alias;
185
+ if (kb.team_id == null && kmeta.team_id)
186
+ kb.team_id = kmeta.team_id;
187
+ if (!kb.models.includes(name))
188
+ kb.models.push(name);
189
+ kb.total_spend += km.spend ?? 0;
190
+ kb.total_tokens += km.total_tokens ?? 0;
191
+ kb.prompt_tokens += km.prompt_tokens ?? 0;
192
+ kb.completion_tokens += km.completion_tokens ?? 0;
193
+ kb.api_requests += km.api_requests ?? 0;
194
+ kb.successful_requests += km.successful_requests ?? 0;
195
+ kb.failed_requests += km.failed_requests ?? 0;
196
+ usage_by_key[keyHash] = kb;
197
+ }
198
+ }
199
+ }
200
+ return {
201
+ total_spend: meta.total_spend ?? 0,
202
+ total_tokens: meta.total_tokens ?? 0,
203
+ prompt_tokens: meta.total_prompt_tokens ?? 0,
204
+ completion_tokens: meta.total_completion_tokens ?? 0,
205
+ api_requests: meta.total_api_requests ?? 0,
206
+ successful_requests: meta.total_successful_requests ?? 0,
207
+ failed_requests: meta.total_failed_requests ?? 0,
208
+ usage_by_model,
209
+ usage_by_key,
210
+ daily_usage,
211
+ daily_by_model,
212
+ };
213
+ }
214
+ async getUsage(startDate, endDate, userId, _groupBy) {
215
+ const params = new URLSearchParams({
216
+ start_date: startDate,
217
+ end_date: endDate,
218
+ page_size: '100',
219
+ });
220
+ if (userId)
221
+ params.append('user_id', userId);
222
+ try {
223
+ const response = await this.request(`/user/daily/activity?${params.toString()}`);
224
+ return this.transformDailyActivity(response);
225
+ }
226
+ catch (err) {
227
+ if (err.status === 404 || err.message.includes('not found')) {
228
+ return this.emptyUsage();
229
+ }
230
+ throw err;
231
+ }
232
+ }
233
+ async getTeamUsage(teamId, startDate, endDate) {
234
+ const params = new URLSearchParams({
235
+ start_date: startDate,
236
+ end_date: endDate,
237
+ team_ids: teamId,
238
+ page_size: '100',
239
+ });
240
+ try {
241
+ const response = await this.request(`/team/daily/activity?${params.toString()}`);
242
+ return this.transformDailyActivity(response);
243
+ }
244
+ catch (err) {
245
+ if (err.status === 404 || err.message.includes('not found')) {
246
+ return this.emptyUsage();
247
+ }
248
+ throw err;
249
+ }
250
+ }
251
+ }
252
+ exports.LiteLLMClient = LiteLLMClient;
package/dist/index.cjs.js CHANGED
@@ -1,9 +1,7 @@
1
1
  "use strict";
2
- var __create = Object.create;
3
2
  var __defProp = Object.defineProperty;
4
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
- var __getProtoOf = Object.getPrototypeOf;
7
5
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
6
  var __export = (target, all) => {
9
7
  for (var name in all)
@@ -17,14 +15,6 @@ var __copyProps = (to, from, except, desc) => {
17
15
  }
18
16
  return to;
19
17
  };
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
- ));
28
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
19
 
30
20
  // src/index.ts
@@ -32,7 +22,6 @@ var index_exports = {};
32
22
  __export(index_exports, {
33
23
  LiteLLMClient: () => LiteLLMClient,
34
24
  createRouter: () => createRouter,
35
- default: () => litellmPlugin,
36
25
  litellmPlugin: () => litellmPlugin
37
26
  });
38
27
  module.exports = __toCommonJS(index_exports);
@@ -41,7 +30,7 @@ module.exports = __toCommonJS(index_exports);
41
30
  var import_backend_plugin_api = require("@backstage/backend-plugin-api");
42
31
 
43
32
  // src/router.ts
44
- var import_express = __toESM(require("express"));
33
+ var import_express = require("express");
45
34
  var import_catalog_client = require("@backstage/catalog-client");
46
35
 
47
36
  // src/client.ts
@@ -61,15 +50,13 @@ var LiteLLMClient = class {
61
50
  signal: controller.signal,
62
51
  headers: {
63
52
  "Content-Type": "application/json",
64
- Authorization: `Bearer ${this.masterKey}`,
53
+ "Authorization": `Bearer ${this.masterKey}`,
65
54
  ...options.headers
66
55
  }
67
56
  });
68
57
  if (!response.ok) {
69
58
  const errorBody = await response.text();
70
- const err = new Error(
71
- `LiteLLM API error: ${response.status} ${response.statusText} - ${errorBody}`
72
- );
59
+ const err = new Error(`LiteLLM API error: ${response.status} ${response.statusText} - ${errorBody}`);
73
60
  err.status = response.status;
74
61
  throw err;
75
62
  }
@@ -97,36 +84,11 @@ var LiteLLMClient = class {
97
84
  body: JSON.stringify(payload)
98
85
  });
99
86
  }
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
- */
122
87
  async listKeys(userId) {
123
- if (!userId) return [];
88
+ const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
124
89
  try {
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);
90
+ const response = await this.request(`/key/info${query}`);
91
+ return Array.isArray(response) ? response : response.info ?? [];
130
92
  } catch (err) {
131
93
  if (err.status === 404 || err.message.includes("not found")) {
132
94
  return [];
@@ -134,44 +96,10 @@ var LiteLLMClient = class {
134
96
  throw err;
135
97
  }
136
98
  }
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
- */
166
99
  async generateKey(request) {
167
- const { alias, ...rest } = request;
168
- const payload = {
169
- ...rest,
170
- ...alias && { key_alias: alias }
171
- };
172
100
  return this.request("/key/generate", {
173
101
  method: "POST",
174
- body: JSON.stringify(payload)
102
+ body: JSON.stringify({ json: request })
175
103
  });
176
104
  }
177
105
  async updateKey(request) {
@@ -187,15 +115,11 @@ var LiteLLMClient = class {
187
115
  });
188
116
  }
189
117
  async listModels() {
190
- const response = await this.request(
191
- "/models"
192
- );
118
+ const response = await this.request("/models");
193
119
  return Array.isArray(response) ? response : response.data ?? [];
194
120
  }
195
121
  async getTeamInfo(teamId) {
196
- return this.request(
197
- `/team/info?team_id=${encodeURIComponent(teamId)}`
198
- );
122
+ return this.request(`/team/info?team_id=${encodeURIComponent(teamId)}`);
199
123
  }
200
124
  emptyUsage() {
201
125
  return {
@@ -319,9 +243,7 @@ var LiteLLMClient = class {
319
243
  });
320
244
  if (userId) params.append("user_id", userId);
321
245
  try {
322
- const response = await this.request(
323
- `/user/daily/activity?${params.toString()}`
324
- );
246
+ const response = await this.request(`/user/daily/activity?${params.toString()}`);
325
247
  return this.transformDailyActivity(response);
326
248
  } catch (err) {
327
249
  if (err.status === 404 || err.message.includes("not found")) {
@@ -338,9 +260,7 @@ var LiteLLMClient = class {
338
260
  page_size: "100"
339
261
  });
340
262
  try {
341
- const response = await this.request(
342
- `/team/daily/activity?${params.toString()}`
343
- );
263
+ const response = await this.request(`/team/daily/activity?${params.toString()}`);
344
264
  return this.transformDailyActivity(response);
345
265
  } catch (err) {
346
266
  if (err.status === 404 || err.message.includes("not found")) {
@@ -450,7 +370,6 @@ async function provisionUser(client, userId, defaults, profile, backstageEntity,
450
370
  ...defaults.tpmLimit !== void 0 && { tpm_limit: defaults.tpmLimit },
451
371
  ...defaults.rpmLimit !== void 0 && { rpm_limit: defaults.rpmLimit },
452
372
  ...defaults.userRole && { user_role: defaults.userRole },
453
- auto_create_key: false,
454
373
  metadata: {
455
374
  ...defaults.metadata,
456
375
  provisioned_by: "backstage",
@@ -618,7 +537,6 @@ async function createRouter(options) {
618
537
  );
619
538
  }
620
539
  const router = (0, import_express.Router)();
621
- router.use(import_express.default.json());
622
540
  router.get("/health", (_req, res) => {
623
541
  res.json({ status: "ok", provisioning: provisioningEnabled });
624
542
  });
@@ -675,19 +593,6 @@ async function createRouter(options) {
675
593
  });
676
594
  router.post("/keys/generate", async (req, res) => {
677
595
  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
- }
691
596
  const tokenEntityRef = await resolveUserId(req, auth);
692
597
  const resolvedUserId = tokenEntityRef ? toLiteLLMUserId(tokenEntityRef, userIdDomain) : void 0;
693
598
  if (resolvedUserId) {
@@ -703,20 +608,8 @@ async function createRouter(options) {
703
608
  logger
704
609
  );
705
610
  }
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
- };
717
611
  const request = {
718
- ...body,
719
- metadata: enrichedMetadata,
612
+ ...req.body,
720
613
  ...resolvedUserId && { user_id: resolvedUserId }
721
614
  };
722
615
  const result = await client.generateKey(request);