@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 +46 -0
- package/dist/client.js +136 -9
- package/dist/index.cjs.js +173 -14
- package/dist/index.cjs.js.map +3 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/provisioning.d.ts +17 -2
- package/dist/provisioning.js +168 -25
- package/dist/router.js +82 -4
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +33 -0
- package/package.json +3 -3
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
+
if (!userId)
|
|
104
|
+
return [];
|
|
59
105
|
try {
|
|
60
|
-
const response = await this.request(`/
|
|
61
|
-
|
|
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(
|
|
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
|
-
|
|
90
|
-
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
142
|
+
if (!userId) return [];
|
|
89
143
|
try {
|
|
90
|
-
const response = await this.request(
|
|
91
|
-
|
|
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(
|
|
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
|
-
|
|
119
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
...
|
|
770
|
+
...body,
|
|
771
|
+
metadata: enrichedMetadata,
|
|
613
772
|
...resolvedUserId && { user_id: resolvedUserId }
|
|
614
773
|
};
|
|
615
774
|
const result = await client.generateKey(request);
|