@acarmisc/backstage-plugin-litellm-backend 0.26.0 → 0.27.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.
- package/dist/client.d.ts +3 -2
- package/dist/client.js +6 -5
- package/dist/index.cjs.js +131 -47
- package/dist/index.cjs.js.map +2 -2
- package/dist/routes/teamUsage.js +104 -54
- package/dist/teamMemberUsage.d.ts +9 -1
- package/dist/teamMemberUsage.js +50 -0
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +2 -2
package/dist/client.d.ts
CHANGED
|
@@ -160,9 +160,10 @@ export declare class LiteLLMClient {
|
|
|
160
160
|
}>>;
|
|
161
161
|
/**
|
|
162
162
|
* Lists the MCP servers registered on the LiteLLM proxy. Path is
|
|
163
|
-
* `/mcp/server/list
|
|
163
|
+
* `/v1/mcp/server` (`/mcp/server/list` hangs on current proxies); tolerates a bare array, `{ data: [...] }` or
|
|
164
164
|
* `{ servers: [...] }`. Normalised to `{ id, name?, url? }` — `id` prefers
|
|
165
|
-
* `server_id` then `id` then `alias`/`name
|
|
165
|
+
* `server_id` then `id` then `alias`/`server_name`/`name`; the display name is
|
|
166
|
+
* `alias`, else `server_name`, else `name`.
|
|
166
167
|
*/
|
|
167
168
|
listMcpServers(): Promise<Array<{
|
|
168
169
|
id: string;
|
package/dist/client.js
CHANGED
|
@@ -495,12 +495,13 @@ class LiteLLMClient {
|
|
|
495
495
|
}
|
|
496
496
|
/**
|
|
497
497
|
* Lists the MCP servers registered on the LiteLLM proxy. Path is
|
|
498
|
-
* `/mcp/server/list
|
|
498
|
+
* `/v1/mcp/server` (`/mcp/server/list` hangs on current proxies); tolerates a bare array, `{ data: [...] }` or
|
|
499
499
|
* `{ servers: [...] }`. Normalised to `{ id, name?, url? }` — `id` prefers
|
|
500
|
-
* `server_id` then `id` then `alias`/`name
|
|
500
|
+
* `server_id` then `id` then `alias`/`server_name`/`name`; the display name is
|
|
501
|
+
* `alias`, else `server_name`, else `name`.
|
|
501
502
|
*/
|
|
502
503
|
async listMcpServers() {
|
|
503
|
-
const raw = await this.request('/mcp/server
|
|
504
|
+
const raw = await this.request('/v1/mcp/server');
|
|
504
505
|
let rows = [];
|
|
505
506
|
if (Array.isArray(raw))
|
|
506
507
|
rows = raw;
|
|
@@ -510,8 +511,8 @@ class LiteLLMClient {
|
|
|
510
511
|
rows = raw.servers;
|
|
511
512
|
return rows
|
|
512
513
|
.map(r => ({
|
|
513
|
-
id: r?.server_id ?? r?.id ?? r?.alias ?? r?.name ?? '',
|
|
514
|
-
name: r?.alias ?? r?.name ?? undefined,
|
|
514
|
+
id: r?.server_id ?? r?.id ?? r?.alias ?? r?.server_name ?? r?.name ?? '',
|
|
515
|
+
name: r?.alias ?? r?.server_name ?? r?.name ?? undefined,
|
|
515
516
|
url: r?.url ?? undefined,
|
|
516
517
|
}))
|
|
517
518
|
.filter(r => r.id);
|
package/dist/index.cjs.js
CHANGED
|
@@ -8478,19 +8478,20 @@ var LiteLLMClient = class {
|
|
|
8478
8478
|
}
|
|
8479
8479
|
/**
|
|
8480
8480
|
* Lists the MCP servers registered on the LiteLLM proxy. Path is
|
|
8481
|
-
* `/mcp/server/list
|
|
8481
|
+
* `/v1/mcp/server` (`/mcp/server/list` hangs on current proxies); tolerates a bare array, `{ data: [...] }` or
|
|
8482
8482
|
* `{ servers: [...] }`. Normalised to `{ id, name?, url? }` — `id` prefers
|
|
8483
|
-
* `server_id` then `id` then `alias`/`name
|
|
8483
|
+
* `server_id` then `id` then `alias`/`server_name`/`name`; the display name is
|
|
8484
|
+
* `alias`, else `server_name`, else `name`.
|
|
8484
8485
|
*/
|
|
8485
8486
|
async listMcpServers() {
|
|
8486
|
-
const raw = await this.request("/mcp/server
|
|
8487
|
+
const raw = await this.request("/v1/mcp/server");
|
|
8487
8488
|
let rows = [];
|
|
8488
8489
|
if (Array.isArray(raw)) rows = raw;
|
|
8489
8490
|
else if (Array.isArray(raw?.data)) rows = raw.data;
|
|
8490
8491
|
else if (Array.isArray(raw?.servers)) rows = raw.servers;
|
|
8491
8492
|
return rows.map((r) => ({
|
|
8492
|
-
id: r?.server_id ?? r?.id ?? r?.alias ?? r?.name ?? "",
|
|
8493
|
-
name: r?.alias ?? r?.name ?? void 0,
|
|
8493
|
+
id: r?.server_id ?? r?.id ?? r?.alias ?? r?.server_name ?? r?.name ?? "",
|
|
8494
|
+
name: r?.alias ?? r?.server_name ?? r?.name ?? void 0,
|
|
8494
8495
|
url: r?.url ?? void 0
|
|
8495
8496
|
})).filter((r) => r.id);
|
|
8496
8497
|
}
|
|
@@ -9529,6 +9530,47 @@ function redactTeamMemberUsage(usage) {
|
|
|
9529
9530
|
members: usage.members.map((m) => ({ ...m, spend: 0 }))
|
|
9530
9531
|
};
|
|
9531
9532
|
}
|
|
9533
|
+
function buildTeamKeyUsage(teamId, usage, keys, team, profiles = /* @__PURE__ */ new Map()) {
|
|
9534
|
+
const emailById = /* @__PURE__ */ new Map();
|
|
9535
|
+
for (const m of team.members_with_roles ?? []) {
|
|
9536
|
+
if (m.user_id && m.user_email) emailById.set(m.user_id, m.user_email);
|
|
9537
|
+
}
|
|
9538
|
+
const rows = keys.map((k) => {
|
|
9539
|
+
const u = k.token && usage.usage_by_key?.[k.token] || void 0;
|
|
9540
|
+
const userId = k.user_id ?? null;
|
|
9541
|
+
const email = userId && emailById.get(userId) || (userId?.includes("@") ? userId : void 0);
|
|
9542
|
+
const displayName = email && profiles.get(email.toLowerCase())?.displayName;
|
|
9543
|
+
return {
|
|
9544
|
+
key: k.key,
|
|
9545
|
+
...k.key_alias && { key_alias: k.key_alias },
|
|
9546
|
+
user_id: userId,
|
|
9547
|
+
...email && { user_email: email },
|
|
9548
|
+
...displayName && { display_name: displayName },
|
|
9549
|
+
created_at: k.created_at,
|
|
9550
|
+
...k.expires_at && { expires_at: k.expires_at },
|
|
9551
|
+
blocked: k.blocked === true,
|
|
9552
|
+
...k.max_budget !== void 0 && { max_budget: k.max_budget },
|
|
9553
|
+
spend: u?.total_spend ?? 0,
|
|
9554
|
+
prompt_tokens: u?.prompt_tokens ?? 0,
|
|
9555
|
+
completion_tokens: u?.completion_tokens ?? 0,
|
|
9556
|
+
total_tokens: u?.total_tokens ?? 0,
|
|
9557
|
+
api_requests: u?.api_requests ?? 0,
|
|
9558
|
+
successful_requests: u?.successful_requests ?? 0,
|
|
9559
|
+
failed_requests: u?.failed_requests ?? 0
|
|
9560
|
+
};
|
|
9561
|
+
});
|
|
9562
|
+
rows.sort(
|
|
9563
|
+
(a, b) => b.spend - a.spend || b.total_tokens - a.total_tokens || a.key.localeCompare(b.key)
|
|
9564
|
+
);
|
|
9565
|
+
return { team_id: teamId, keys: rows };
|
|
9566
|
+
}
|
|
9567
|
+
function redactTeamKeyUsage(usage) {
|
|
9568
|
+
return {
|
|
9569
|
+
...usage,
|
|
9570
|
+
budget_hidden: true,
|
|
9571
|
+
keys: usage.keys.map(({ max_budget: _hidden, ...k }) => ({ ...k, spend: 0 }))
|
|
9572
|
+
};
|
|
9573
|
+
}
|
|
9532
9574
|
|
|
9533
9575
|
// src/langfuse.ts
|
|
9534
9576
|
function readLangfuseConfig(config) {
|
|
@@ -11532,60 +11574,77 @@ function registerTeamUsageRoutes(router, ctx) {
|
|
|
11532
11574
|
sendError(res, error, logger, "fetch team usage");
|
|
11533
11575
|
}
|
|
11534
11576
|
});
|
|
11535
|
-
|
|
11577
|
+
async function authorizeMemberView(req, res) {
|
|
11536
11578
|
const cfg = ctx.memberBreakdownCfg;
|
|
11537
11579
|
if (!cfg.enabled) {
|
|
11538
11580
|
res.status(404).json({ error: "Not found" });
|
|
11539
|
-
return;
|
|
11581
|
+
return null;
|
|
11582
|
+
}
|
|
11583
|
+
const { teamId } = req.params;
|
|
11584
|
+
const { start_date, end_date } = req.query;
|
|
11585
|
+
if (!start_date || !end_date) {
|
|
11586
|
+
res.status(400).json({ error: "start_date and end_date are required" });
|
|
11587
|
+
return null;
|
|
11540
11588
|
}
|
|
11589
|
+
const tokenEntityRef = res.locals.tokenEntityRef;
|
|
11590
|
+
const userInfo = await getProvisionedUser(ctx, res);
|
|
11591
|
+
const callerId = res.locals.userId;
|
|
11592
|
+
const isMember = userInfo?.teams?.includes(teamId) ?? false;
|
|
11593
|
+
let team;
|
|
11541
11594
|
try {
|
|
11542
|
-
|
|
11543
|
-
|
|
11544
|
-
if (
|
|
11545
|
-
res.status(400).json({ error: "start_date and end_date are required" });
|
|
11546
|
-
return;
|
|
11547
|
-
}
|
|
11548
|
-
const tokenEntityRef = res.locals.tokenEntityRef;
|
|
11549
|
-
const userInfo = await getProvisionedUser(ctx, res);
|
|
11550
|
-
const callerId = res.locals.userId;
|
|
11551
|
-
const isMember = userInfo?.teams?.includes(teamId) ?? false;
|
|
11552
|
-
let team;
|
|
11553
|
-
try {
|
|
11554
|
-
team = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
|
|
11555
|
-
} catch (err) {
|
|
11556
|
-
if (err instanceof LiteLLMUpstreamError && err.status === 404) {
|
|
11557
|
-
res.status(404).json({ error: "Team not found" });
|
|
11558
|
-
return;
|
|
11559
|
-
}
|
|
11560
|
-
throw err;
|
|
11561
|
-
}
|
|
11562
|
-
const owningGroup = typeof team.metadata?.owning_group === "string" ? team.metadata.owning_group : void 0;
|
|
11563
|
-
const isManager = ctx.teamMgmtEnabled && !!owningGroup && await isUserMemberOfGroup(tokenEntityRef, owningGroup, catalogClient, auth, logger);
|
|
11564
|
-
if (!isMember && !isManager) {
|
|
11595
|
+
team = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
|
|
11596
|
+
} catch (err) {
|
|
11597
|
+
if (err instanceof LiteLLMUpstreamError && err.status === 404) {
|
|
11565
11598
|
res.status(404).json({ error: "Team not found" });
|
|
11566
|
-
return;
|
|
11567
|
-
}
|
|
11568
|
-
const role = team.members_with_roles?.find((m) => m.user_id === callerId)?.role;
|
|
11569
|
-
const viaRole = isMember && !!role && cfg.viewerRoles.includes(role);
|
|
11570
|
-
const allowed = isManager || viaRole || isMember && cfg.permissionEnabled && await ctx.assertPermission(req, import_backstage_plugin_litellm_common8.litellmTeamUsageReadPermission);
|
|
11571
|
-
if (!allowed) {
|
|
11572
|
-
res.status(403).json({
|
|
11573
|
-
error: "Access denied: viewing per-member usage needs a team role or permission"
|
|
11574
|
-
});
|
|
11575
|
-
return;
|
|
11599
|
+
return null;
|
|
11576
11600
|
}
|
|
11601
|
+
throw err;
|
|
11602
|
+
}
|
|
11603
|
+
const owningGroup = typeof team.metadata?.owning_group === "string" ? team.metadata.owning_group : void 0;
|
|
11604
|
+
const isManager = ctx.teamMgmtEnabled && !!owningGroup && await isUserMemberOfGroup(tokenEntityRef, owningGroup, catalogClient, auth, logger);
|
|
11605
|
+
if (!isMember && !isManager) {
|
|
11606
|
+
res.status(404).json({ error: "Team not found" });
|
|
11607
|
+
return null;
|
|
11608
|
+
}
|
|
11609
|
+
const role = team.members_with_roles?.find((m) => m.user_id === callerId)?.role;
|
|
11610
|
+
const viaRole = isMember && !!role && cfg.viewerRoles.includes(role);
|
|
11611
|
+
const allowed = isManager || viaRole || isMember && cfg.permissionEnabled && await ctx.assertPermission(req, import_backstage_plugin_litellm_common8.litellmTeamUsageReadPermission);
|
|
11612
|
+
if (!allowed) {
|
|
11613
|
+
res.status(403).json({
|
|
11614
|
+
error: "Access denied: viewing per-member usage needs a team role or permission"
|
|
11615
|
+
});
|
|
11616
|
+
return null;
|
|
11617
|
+
}
|
|
11618
|
+
let via = "permission";
|
|
11619
|
+
if (isManager) via = "manager";
|
|
11620
|
+
else if (viaRole) via = "teamRole";
|
|
11621
|
+
return {
|
|
11622
|
+
teamId,
|
|
11623
|
+
team,
|
|
11624
|
+
startDate: start_date,
|
|
11625
|
+
endDate: end_date,
|
|
11626
|
+
isManager,
|
|
11627
|
+
via
|
|
11628
|
+
};
|
|
11629
|
+
}
|
|
11630
|
+
const hideFor = (isManager) => isManager ? teamBudgetVisibility.hideTeamBudgetForManagers : teamBudgetVisibility.hideTeamBudgetForMembers;
|
|
11631
|
+
router.get("/teams/:teamId/usage/members", requireUser, async (req, res) => {
|
|
11632
|
+
try {
|
|
11633
|
+
const gate = await authorizeMemberView(req, res);
|
|
11634
|
+
if (!gate) return;
|
|
11635
|
+
const { teamId, team, startDate, endDate, isManager, via } = gate;
|
|
11577
11636
|
const [usage, keys] = await Promise.all([
|
|
11578
|
-
client.getTeamUsage(teamId,
|
|
11637
|
+
client.getTeamUsage(teamId, startDate, endDate),
|
|
11579
11638
|
client.listTeamKeys(teamId)
|
|
11580
11639
|
]);
|
|
11581
11640
|
const profiles = await lookupProfiles(memberEmails(team, keys));
|
|
11582
11641
|
const breakdown = buildTeamMemberUsage(teamId, usage, keys, team, profiles);
|
|
11583
|
-
|
|
11584
|
-
|
|
11585
|
-
|
|
11586
|
-
|
|
11587
|
-
|
|
11588
|
-
res.json(
|
|
11642
|
+
logger.info("team.usage.members.read", {
|
|
11643
|
+
actor: res.locals.tokenEntityRef,
|
|
11644
|
+
teamId,
|
|
11645
|
+
via
|
|
11646
|
+
});
|
|
11647
|
+
res.json(hideFor(isManager) ? redactTeamMemberUsage(breakdown) : breakdown);
|
|
11589
11648
|
} catch (error) {
|
|
11590
11649
|
if (error instanceof ProvisioningError) {
|
|
11591
11650
|
res.status(error.status).json(error.body);
|
|
@@ -11594,6 +11653,31 @@ function registerTeamUsageRoutes(router, ctx) {
|
|
|
11594
11653
|
sendError(res, error, logger, "fetch team member usage");
|
|
11595
11654
|
}
|
|
11596
11655
|
});
|
|
11656
|
+
router.get("/teams/:teamId/usage/keys", requireUser, async (req, res) => {
|
|
11657
|
+
try {
|
|
11658
|
+
const gate = await authorizeMemberView(req, res);
|
|
11659
|
+
if (!gate) return;
|
|
11660
|
+
const { teamId, team, startDate, endDate, isManager, via } = gate;
|
|
11661
|
+
const [usage, keys] = await Promise.all([
|
|
11662
|
+
client.getTeamUsage(teamId, startDate, endDate),
|
|
11663
|
+
client.listTeamKeys(teamId)
|
|
11664
|
+
]);
|
|
11665
|
+
const profiles = await lookupProfiles(memberEmails(team, keys));
|
|
11666
|
+
const keyUsage = buildTeamKeyUsage(teamId, usage, keys, team, profiles);
|
|
11667
|
+
logger.info("team.usage.keys.read", {
|
|
11668
|
+
actor: res.locals.tokenEntityRef,
|
|
11669
|
+
teamId,
|
|
11670
|
+
via
|
|
11671
|
+
});
|
|
11672
|
+
res.json(hideFor(isManager) ? redactTeamKeyUsage(keyUsage) : keyUsage);
|
|
11673
|
+
} catch (error) {
|
|
11674
|
+
if (error instanceof ProvisioningError) {
|
|
11675
|
+
res.status(error.status).json(error.body);
|
|
11676
|
+
return;
|
|
11677
|
+
}
|
|
11678
|
+
sendError(res, error, logger, "fetch team key usage");
|
|
11679
|
+
}
|
|
11680
|
+
});
|
|
11597
11681
|
async function lookupProfiles(emails) {
|
|
11598
11682
|
const profiles = /* @__PURE__ */ new Map();
|
|
11599
11683
|
if (!emails.length) return profiles;
|