@acarmisc/backstage-plugin-litellm-backend 0.26.1 → 0.28.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/config.d.ts +9 -0
- package/dist/index.cjs.js +144 -54
- package/dist/index.cjs.js.map +2 -2
- package/dist/routes/teamUsage.js +121 -61
- package/dist/teamMemberUsage.d.ts +11 -1
- package/dist/teamMemberUsage.js +51 -0
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +2 -2
package/config.d.ts
CHANGED
|
@@ -463,6 +463,8 @@ export interface Config {
|
|
|
463
463
|
* reaches:
|
|
464
464
|
* - managers of the team (members of its metadata.owning_group, with
|
|
465
465
|
* team management enabled);
|
|
466
|
+
* - team leads (members of the catalog group `<team_alias><leadGroupSuffix>`,
|
|
467
|
+
* when leadGroupSuffix is set);
|
|
466
468
|
* - members of the team holding one of `viewerRoles` in it;
|
|
467
469
|
* - members of the team granted `litellm.team.usage.read` (only
|
|
468
470
|
* consulted when permission.enabled is true).
|
|
@@ -480,6 +482,13 @@ export interface Config {
|
|
|
480
482
|
* @default []
|
|
481
483
|
*/
|
|
482
484
|
viewerRoles?: string[];
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Suffix of a catalog group named `<team_alias><suffix>` whose members
|
|
488
|
+
* are team leads (e.g. "-leads" -> group:default/abstrid-leads). For
|
|
489
|
+
* teams synced without metadata.owning_group. Unset: no lead access.
|
|
490
|
+
*/
|
|
491
|
+
leadGroupSuffix?: string;
|
|
483
492
|
};
|
|
484
493
|
};
|
|
485
494
|
|
package/dist/index.cjs.js
CHANGED
|
@@ -9449,7 +9449,8 @@ function readMemberBreakdownConfig(config) {
|
|
|
9449
9449
|
return {
|
|
9450
9450
|
enabled: config.getOptionalBoolean("litellm.teamUsage.memberBreakdown.enabled") ?? false,
|
|
9451
9451
|
viewerRoles: config.getOptionalStringArray("litellm.teamUsage.memberBreakdown.viewerRoles") ?? [],
|
|
9452
|
-
permissionEnabled: config.getOptionalBoolean("permission.enabled") ?? false
|
|
9452
|
+
permissionEnabled: config.getOptionalBoolean("permission.enabled") ?? false,
|
|
9453
|
+
leadGroupSuffix: config.getOptionalString("litellm.teamUsage.memberBreakdown.leadGroupSuffix") || void 0
|
|
9453
9454
|
};
|
|
9454
9455
|
}
|
|
9455
9456
|
function memberEmails(team, keys) {
|
|
@@ -9530,6 +9531,47 @@ function redactTeamMemberUsage(usage) {
|
|
|
9530
9531
|
members: usage.members.map((m) => ({ ...m, spend: 0 }))
|
|
9531
9532
|
};
|
|
9532
9533
|
}
|
|
9534
|
+
function buildTeamKeyUsage(teamId, usage, keys, team, profiles = /* @__PURE__ */ new Map()) {
|
|
9535
|
+
const emailById = /* @__PURE__ */ new Map();
|
|
9536
|
+
for (const m of team.members_with_roles ?? []) {
|
|
9537
|
+
if (m.user_id && m.user_email) emailById.set(m.user_id, m.user_email);
|
|
9538
|
+
}
|
|
9539
|
+
const rows = keys.map((k) => {
|
|
9540
|
+
const u = k.token && usage.usage_by_key?.[k.token] || void 0;
|
|
9541
|
+
const userId = k.user_id ?? null;
|
|
9542
|
+
const email = userId && emailById.get(userId) || (userId?.includes("@") ? userId : void 0);
|
|
9543
|
+
const displayName = email && profiles.get(email.toLowerCase())?.displayName;
|
|
9544
|
+
return {
|
|
9545
|
+
key: k.key,
|
|
9546
|
+
...k.key_alias && { key_alias: k.key_alias },
|
|
9547
|
+
user_id: userId,
|
|
9548
|
+
...email && { user_email: email },
|
|
9549
|
+
...displayName && { display_name: displayName },
|
|
9550
|
+
created_at: k.created_at,
|
|
9551
|
+
...k.expires_at && { expires_at: k.expires_at },
|
|
9552
|
+
blocked: k.blocked === true,
|
|
9553
|
+
...k.max_budget !== void 0 && { max_budget: k.max_budget },
|
|
9554
|
+
spend: u?.total_spend ?? 0,
|
|
9555
|
+
prompt_tokens: u?.prompt_tokens ?? 0,
|
|
9556
|
+
completion_tokens: u?.completion_tokens ?? 0,
|
|
9557
|
+
total_tokens: u?.total_tokens ?? 0,
|
|
9558
|
+
api_requests: u?.api_requests ?? 0,
|
|
9559
|
+
successful_requests: u?.successful_requests ?? 0,
|
|
9560
|
+
failed_requests: u?.failed_requests ?? 0
|
|
9561
|
+
};
|
|
9562
|
+
});
|
|
9563
|
+
rows.sort(
|
|
9564
|
+
(a, b) => b.spend - a.spend || b.total_tokens - a.total_tokens || a.key.localeCompare(b.key)
|
|
9565
|
+
);
|
|
9566
|
+
return { team_id: teamId, keys: rows };
|
|
9567
|
+
}
|
|
9568
|
+
function redactTeamKeyUsage(usage) {
|
|
9569
|
+
return {
|
|
9570
|
+
...usage,
|
|
9571
|
+
budget_hidden: true,
|
|
9572
|
+
keys: usage.keys.map(({ max_budget: _hidden, ...k }) => ({ ...k, spend: 0 }))
|
|
9573
|
+
};
|
|
9574
|
+
}
|
|
9533
9575
|
|
|
9534
9576
|
// src/langfuse.ts
|
|
9535
9577
|
function readLangfuseConfig(config) {
|
|
@@ -11480,6 +11522,22 @@ function registerTeamUsageRoutes(router, ctx) {
|
|
|
11480
11522
|
teamBudgetVisibility
|
|
11481
11523
|
} = ctx;
|
|
11482
11524
|
const requireUser = createRequireUser(ctx);
|
|
11525
|
+
async function isTeamManager(team, tokenEntityRef) {
|
|
11526
|
+
const owningGroup = typeof team.metadata?.owning_group === "string" ? team.metadata.owning_group : void 0;
|
|
11527
|
+
if (ctx.teamMgmtEnabled && owningGroup && await isUserMemberOfGroup(tokenEntityRef, owningGroup, catalogClient, auth, logger)) {
|
|
11528
|
+
return true;
|
|
11529
|
+
}
|
|
11530
|
+
const suffix = ctx.memberBreakdownCfg.leadGroupSuffix;
|
|
11531
|
+
if (!suffix || !team.team_alias) return false;
|
|
11532
|
+
const slug = team.team_alias.toLowerCase().replace(/[^a-z0-9\-_.]/g, "-");
|
|
11533
|
+
return isUserMemberOfGroup(
|
|
11534
|
+
tokenEntityRef,
|
|
11535
|
+
`group:default/${slug}${suffix}`,
|
|
11536
|
+
catalogClient,
|
|
11537
|
+
auth,
|
|
11538
|
+
logger
|
|
11539
|
+
);
|
|
11540
|
+
}
|
|
11483
11541
|
router.get("/teams/:teamId/usage", requireUser, async (req, res) => {
|
|
11484
11542
|
try {
|
|
11485
11543
|
const { teamId } = req.params;
|
|
@@ -11492,20 +11550,11 @@ function registerTeamUsageRoutes(router, ctx) {
|
|
|
11492
11550
|
const userInfo = await getProvisionedUser(ctx, res);
|
|
11493
11551
|
const isMember = userInfo?.teams?.includes(teamId) ?? false;
|
|
11494
11552
|
let isManager = false;
|
|
11495
|
-
const managerCheckNeeded = ctx.teamMgmtEnabled && (!isMember || teamBudgetVisibility.hideTeamBudgetForMembers !== teamBudgetVisibility.hideTeamBudgetForManagers);
|
|
11553
|
+
const managerCheckNeeded = (ctx.teamMgmtEnabled || !!ctx.memberBreakdownCfg.leadGroupSuffix) && (!isMember || teamBudgetVisibility.hideTeamBudgetForMembers !== teamBudgetVisibility.hideTeamBudgetForManagers);
|
|
11496
11554
|
if (managerCheckNeeded) {
|
|
11497
11555
|
try {
|
|
11498
11556
|
const team = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
|
|
11499
|
-
|
|
11500
|
-
if (owningGroup) {
|
|
11501
|
-
isManager = await isUserMemberOfGroup(
|
|
11502
|
-
tokenEntityRef,
|
|
11503
|
-
owningGroup,
|
|
11504
|
-
catalogClient,
|
|
11505
|
-
auth,
|
|
11506
|
-
logger
|
|
11507
|
-
);
|
|
11508
|
-
}
|
|
11557
|
+
isManager = await isTeamManager(team, tokenEntityRef);
|
|
11509
11558
|
} catch (err) {
|
|
11510
11559
|
const message2 = err instanceof Error ? err.message : String(err);
|
|
11511
11560
|
logger.warn(`Team-manager check failed: ${message2}`);
|
|
@@ -11533,60 +11582,76 @@ function registerTeamUsageRoutes(router, ctx) {
|
|
|
11533
11582
|
sendError(res, error, logger, "fetch team usage");
|
|
11534
11583
|
}
|
|
11535
11584
|
});
|
|
11536
|
-
|
|
11585
|
+
async function authorizeMemberView(req, res) {
|
|
11537
11586
|
const cfg = ctx.memberBreakdownCfg;
|
|
11538
11587
|
if (!cfg.enabled) {
|
|
11539
11588
|
res.status(404).json({ error: "Not found" });
|
|
11540
|
-
return;
|
|
11589
|
+
return null;
|
|
11590
|
+
}
|
|
11591
|
+
const { teamId } = req.params;
|
|
11592
|
+
const { start_date, end_date } = req.query;
|
|
11593
|
+
if (!start_date || !end_date) {
|
|
11594
|
+
res.status(400).json({ error: "start_date and end_date are required" });
|
|
11595
|
+
return null;
|
|
11541
11596
|
}
|
|
11597
|
+
const tokenEntityRef = res.locals.tokenEntityRef;
|
|
11598
|
+
const userInfo = await getProvisionedUser(ctx, res);
|
|
11599
|
+
const callerId = res.locals.userId;
|
|
11600
|
+
const isMember = userInfo?.teams?.includes(teamId) ?? false;
|
|
11601
|
+
let team;
|
|
11542
11602
|
try {
|
|
11543
|
-
|
|
11544
|
-
|
|
11545
|
-
if (
|
|
11546
|
-
res.status(400).json({ error: "start_date and end_date are required" });
|
|
11547
|
-
return;
|
|
11548
|
-
}
|
|
11549
|
-
const tokenEntityRef = res.locals.tokenEntityRef;
|
|
11550
|
-
const userInfo = await getProvisionedUser(ctx, res);
|
|
11551
|
-
const callerId = res.locals.userId;
|
|
11552
|
-
const isMember = userInfo?.teams?.includes(teamId) ?? false;
|
|
11553
|
-
let team;
|
|
11554
|
-
try {
|
|
11555
|
-
team = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
|
|
11556
|
-
} catch (err) {
|
|
11557
|
-
if (err instanceof LiteLLMUpstreamError && err.status === 404) {
|
|
11558
|
-
res.status(404).json({ error: "Team not found" });
|
|
11559
|
-
return;
|
|
11560
|
-
}
|
|
11561
|
-
throw err;
|
|
11562
|
-
}
|
|
11563
|
-
const owningGroup = typeof team.metadata?.owning_group === "string" ? team.metadata.owning_group : void 0;
|
|
11564
|
-
const isManager = ctx.teamMgmtEnabled && !!owningGroup && await isUserMemberOfGroup(tokenEntityRef, owningGroup, catalogClient, auth, logger);
|
|
11565
|
-
if (!isMember && !isManager) {
|
|
11603
|
+
team = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
|
|
11604
|
+
} catch (err) {
|
|
11605
|
+
if (err instanceof LiteLLMUpstreamError && err.status === 404) {
|
|
11566
11606
|
res.status(404).json({ error: "Team not found" });
|
|
11567
|
-
return;
|
|
11568
|
-
}
|
|
11569
|
-
const role = team.members_with_roles?.find((m) => m.user_id === callerId)?.role;
|
|
11570
|
-
const viaRole = isMember && !!role && cfg.viewerRoles.includes(role);
|
|
11571
|
-
const allowed = isManager || viaRole || isMember && cfg.permissionEnabled && await ctx.assertPermission(req, import_backstage_plugin_litellm_common8.litellmTeamUsageReadPermission);
|
|
11572
|
-
if (!allowed) {
|
|
11573
|
-
res.status(403).json({
|
|
11574
|
-
error: "Access denied: viewing per-member usage needs a team role or permission"
|
|
11575
|
-
});
|
|
11576
|
-
return;
|
|
11607
|
+
return null;
|
|
11577
11608
|
}
|
|
11609
|
+
throw err;
|
|
11610
|
+
}
|
|
11611
|
+
const isManager = await isTeamManager(team, tokenEntityRef);
|
|
11612
|
+
if (!isMember && !isManager) {
|
|
11613
|
+
res.status(404).json({ error: "Team not found" });
|
|
11614
|
+
return null;
|
|
11615
|
+
}
|
|
11616
|
+
const role = team.members_with_roles?.find((m) => m.user_id === callerId)?.role;
|
|
11617
|
+
const viaRole = isMember && !!role && cfg.viewerRoles.includes(role);
|
|
11618
|
+
const allowed = isManager || viaRole || isMember && cfg.permissionEnabled && await ctx.assertPermission(req, import_backstage_plugin_litellm_common8.litellmTeamUsageReadPermission);
|
|
11619
|
+
if (!allowed) {
|
|
11620
|
+
res.status(403).json({
|
|
11621
|
+
error: "Access denied: viewing per-member usage needs a team role or permission"
|
|
11622
|
+
});
|
|
11623
|
+
return null;
|
|
11624
|
+
}
|
|
11625
|
+
let via = "permission";
|
|
11626
|
+
if (isManager) via = "manager";
|
|
11627
|
+
else if (viaRole) via = "teamRole";
|
|
11628
|
+
return {
|
|
11629
|
+
teamId,
|
|
11630
|
+
team,
|
|
11631
|
+
startDate: start_date,
|
|
11632
|
+
endDate: end_date,
|
|
11633
|
+
isManager,
|
|
11634
|
+
via
|
|
11635
|
+
};
|
|
11636
|
+
}
|
|
11637
|
+
const hideFor = (isManager) => isManager ? teamBudgetVisibility.hideTeamBudgetForManagers : teamBudgetVisibility.hideTeamBudgetForMembers;
|
|
11638
|
+
router.get("/teams/:teamId/usage/members", requireUser, async (req, res) => {
|
|
11639
|
+
try {
|
|
11640
|
+
const gate = await authorizeMemberView(req, res);
|
|
11641
|
+
if (!gate) return;
|
|
11642
|
+
const { teamId, team, startDate, endDate, isManager, via } = gate;
|
|
11578
11643
|
const [usage, keys] = await Promise.all([
|
|
11579
|
-
client.getTeamUsage(teamId,
|
|
11644
|
+
client.getTeamUsage(teamId, startDate, endDate),
|
|
11580
11645
|
client.listTeamKeys(teamId)
|
|
11581
11646
|
]);
|
|
11582
11647
|
const profiles = await lookupProfiles(memberEmails(team, keys));
|
|
11583
11648
|
const breakdown = buildTeamMemberUsage(teamId, usage, keys, team, profiles);
|
|
11584
|
-
|
|
11585
|
-
|
|
11586
|
-
|
|
11587
|
-
|
|
11588
|
-
|
|
11589
|
-
res.json(
|
|
11649
|
+
logger.info("team.usage.members.read", {
|
|
11650
|
+
actor: res.locals.tokenEntityRef,
|
|
11651
|
+
teamId,
|
|
11652
|
+
via
|
|
11653
|
+
});
|
|
11654
|
+
res.json(hideFor(isManager) ? redactTeamMemberUsage(breakdown) : breakdown);
|
|
11590
11655
|
} catch (error) {
|
|
11591
11656
|
if (error instanceof ProvisioningError) {
|
|
11592
11657
|
res.status(error.status).json(error.body);
|
|
@@ -11595,6 +11660,31 @@ function registerTeamUsageRoutes(router, ctx) {
|
|
|
11595
11660
|
sendError(res, error, logger, "fetch team member usage");
|
|
11596
11661
|
}
|
|
11597
11662
|
});
|
|
11663
|
+
router.get("/teams/:teamId/usage/keys", requireUser, async (req, res) => {
|
|
11664
|
+
try {
|
|
11665
|
+
const gate = await authorizeMemberView(req, res);
|
|
11666
|
+
if (!gate) return;
|
|
11667
|
+
const { teamId, team, startDate, endDate, isManager, via } = gate;
|
|
11668
|
+
const [usage, keys] = await Promise.all([
|
|
11669
|
+
client.getTeamUsage(teamId, startDate, endDate),
|
|
11670
|
+
client.listTeamKeys(teamId)
|
|
11671
|
+
]);
|
|
11672
|
+
const profiles = await lookupProfiles(memberEmails(team, keys));
|
|
11673
|
+
const keyUsage = buildTeamKeyUsage(teamId, usage, keys, team, profiles);
|
|
11674
|
+
logger.info("team.usage.keys.read", {
|
|
11675
|
+
actor: res.locals.tokenEntityRef,
|
|
11676
|
+
teamId,
|
|
11677
|
+
via
|
|
11678
|
+
});
|
|
11679
|
+
res.json(hideFor(isManager) ? redactTeamKeyUsage(keyUsage) : keyUsage);
|
|
11680
|
+
} catch (error) {
|
|
11681
|
+
if (error instanceof ProvisioningError) {
|
|
11682
|
+
res.status(error.status).json(error.body);
|
|
11683
|
+
return;
|
|
11684
|
+
}
|
|
11685
|
+
sendError(res, error, logger, "fetch team key usage");
|
|
11686
|
+
}
|
|
11687
|
+
});
|
|
11598
11688
|
async function lookupProfiles(emails) {
|
|
11599
11689
|
const profiles = /* @__PURE__ */ new Map();
|
|
11600
11690
|
if (!emails.length) return profiles;
|