@acarmisc/backstage-plugin-litellm-backend 0.23.0 → 0.24.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 +39 -0
- package/dist/client.js +16 -15
- package/dist/index.cjs.js +206 -9
- package/dist/index.cjs.js.map +4 -4
- package/dist/langfuse.d.ts +32 -0
- package/dist/langfuse.js +18 -0
- package/dist/langfuseClient.d.ts +23 -0
- package/dist/langfuseClient.js +171 -0
- package/dist/router.js +3 -0
- package/dist/routes/context.d.ts +2 -0
- package/dist/routes/insights.js +37 -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
|
@@ -234,6 +234,45 @@ export interface Config {
|
|
|
234
234
|
metadata?: { [key: string]: string };
|
|
235
235
|
};
|
|
236
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Langfuse answer-quality scores for the Insights tab ("Quality scores"
|
|
239
|
+
* card, GET /insights/quality). Generic Metrics API v2 integration: any
|
|
240
|
+
* Langfuse host works, and a disabled, unconfigured, or v3 server
|
|
241
|
+
* degrades to `{configured:false}` instead of erroring.
|
|
242
|
+
*/
|
|
243
|
+
langfuse?: {
|
|
244
|
+
/**
|
|
245
|
+
* When true the /insights/quality route queries Langfuse.
|
|
246
|
+
* @default false
|
|
247
|
+
*/
|
|
248
|
+
enabled?: boolean;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Langfuse host, e.g. https://langfuse.example.com.
|
|
252
|
+
* @visibility backend
|
|
253
|
+
*/
|
|
254
|
+
baseUrl?: string;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Langfuse public key (Metrics API Basic-auth username).
|
|
258
|
+
* @visibility backend
|
|
259
|
+
*/
|
|
260
|
+
publicKey?: string;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Langfuse secret key (Metrics API Basic-auth password).
|
|
264
|
+
* @visibility secret
|
|
265
|
+
*/
|
|
266
|
+
secretKey?: string;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The Langfuse observation field holding the LiteLLM user id.
|
|
270
|
+
* Deployments whose agents tag traces differently change this one key.
|
|
271
|
+
* @default "userId"
|
|
272
|
+
*/
|
|
273
|
+
userIdField?: string;
|
|
274
|
+
};
|
|
275
|
+
|
|
237
276
|
/**
|
|
238
277
|
* Caching configuration for backend data fetches.
|
|
239
278
|
*/
|
package/dist/client.js
CHANGED
|
@@ -724,22 +724,23 @@ class LiteLLMClient {
|
|
|
724
724
|
return this.normalizeSpendLogs(raw, page, pageSize);
|
|
725
725
|
}
|
|
726
726
|
normalizeSpendLogs(raw, page, pageSize) {
|
|
727
|
-
const rows =
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
727
|
+
const rows = [];
|
|
728
|
+
if (Array.isArray(raw))
|
|
729
|
+
rows.push(...raw);
|
|
730
|
+
else if (Array.isArray(raw?.data))
|
|
731
|
+
rows.push(...raw.data);
|
|
732
|
+
else if (Array.isArray(raw?.results))
|
|
733
|
+
rows.push(...raw.results);
|
|
734
|
+
else if (Array.isArray(raw?.logs))
|
|
735
|
+
rows.push(...raw.logs);
|
|
736
736
|
const toNum = (v, fallback = 0) => {
|
|
737
|
-
|
|
738
|
-
? v
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
737
|
+
if (typeof v === 'number')
|
|
738
|
+
return Number.isFinite(v) ? v : fallback;
|
|
739
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
740
|
+
const n = Number(v);
|
|
741
|
+
return Number.isFinite(n) ? n : fallback;
|
|
742
|
+
}
|
|
743
|
+
return fallback;
|
|
743
744
|
};
|
|
744
745
|
const data = rows.map((r) => {
|
|
745
746
|
const promptTokens = toNum(r?.prompt_tokens ?? r?.promptTokens ?? r?.input_tokens);
|
package/dist/index.cjs.js
CHANGED
|
@@ -8691,25 +8691,33 @@ var LiteLLMClient = class {
|
|
|
8691
8691
|
return this.normalizeSpendLogs(raw, page, pageSize);
|
|
8692
8692
|
}
|
|
8693
8693
|
normalizeSpendLogs(raw, page, pageSize) {
|
|
8694
|
-
const rows =
|
|
8695
|
-
|
|
8696
|
-
|
|
8697
|
-
|
|
8694
|
+
const rows = [];
|
|
8695
|
+
if (Array.isArray(raw)) rows.push(...raw);
|
|
8696
|
+
else if (Array.isArray(raw?.data)) rows.push(...raw.data);
|
|
8697
|
+
else if (Array.isArray(raw?.results)) rows.push(...raw.results);
|
|
8698
|
+
else if (Array.isArray(raw?.logs)) rows.push(...raw.logs);
|
|
8699
|
+
const toNum2 = (v, fallback = 0) => {
|
|
8700
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : fallback;
|
|
8701
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
8702
|
+
const n = Number(v);
|
|
8703
|
+
return Number.isFinite(n) ? n : fallback;
|
|
8704
|
+
}
|
|
8705
|
+
return fallback;
|
|
8698
8706
|
};
|
|
8699
8707
|
const data = rows.map((r) => {
|
|
8700
|
-
const promptTokens =
|
|
8708
|
+
const promptTokens = toNum2(
|
|
8701
8709
|
r?.prompt_tokens ?? r?.promptTokens ?? r?.input_tokens
|
|
8702
8710
|
);
|
|
8703
|
-
const completionTokens =
|
|
8711
|
+
const completionTokens = toNum2(
|
|
8704
8712
|
r?.completion_tokens ?? r?.completionTokens ?? r?.output_tokens
|
|
8705
8713
|
);
|
|
8706
8714
|
return {
|
|
8707
8715
|
requestId: String(r?.request_id ?? r?.requestId ?? r?.id ?? ""),
|
|
8708
8716
|
model: String(r?.model ?? r?.model_name ?? ""),
|
|
8709
|
-
spend:
|
|
8717
|
+
spend: toNum2(r?.spend ?? r?.cost),
|
|
8710
8718
|
promptTokens,
|
|
8711
8719
|
completionTokens,
|
|
8712
|
-
totalTokens:
|
|
8720
|
+
totalTokens: toNum2(
|
|
8713
8721
|
r?.total_tokens ?? r?.totalTokens,
|
|
8714
8722
|
promptTokens + completionTokens
|
|
8715
8723
|
),
|
|
@@ -8725,7 +8733,7 @@ var LiteLLMClient = class {
|
|
|
8725
8733
|
});
|
|
8726
8734
|
return {
|
|
8727
8735
|
data,
|
|
8728
|
-
total:
|
|
8736
|
+
total: toNum2(raw?.total ?? raw?.total_count, data.length),
|
|
8729
8737
|
page,
|
|
8730
8738
|
pageSize,
|
|
8731
8739
|
truncated: data.length >= pageSize
|
|
@@ -9522,6 +9530,21 @@ function redactTeamMemberUsage(usage) {
|
|
|
9522
9530
|
};
|
|
9523
9531
|
}
|
|
9524
9532
|
|
|
9533
|
+
// src/langfuse.ts
|
|
9534
|
+
function readLangfuseConfig(config) {
|
|
9535
|
+
const enabled = config.getOptionalBoolean("litellm.langfuse.enabled") ?? false;
|
|
9536
|
+
return {
|
|
9537
|
+
enabled,
|
|
9538
|
+
baseUrl: config.getOptionalString("litellm.langfuse.baseUrl") ?? null,
|
|
9539
|
+
publicKey: config.getOptionalString("litellm.langfuse.publicKey") ?? null,
|
|
9540
|
+
secretKey: config.getOptionalString("litellm.langfuse.secretKey") ?? null,
|
|
9541
|
+
userIdField: config.getOptionalString("litellm.langfuse.userIdField") ?? "userId"
|
|
9542
|
+
};
|
|
9543
|
+
}
|
|
9544
|
+
function isLangfuseConfigured(cfg) {
|
|
9545
|
+
return cfg.enabled && !!cfg.baseUrl && !!cfg.publicKey && !!cfg.secretKey;
|
|
9546
|
+
}
|
|
9547
|
+
|
|
9525
9548
|
// src/routes/middleware/withUser.ts
|
|
9526
9549
|
function createRequireUser(ctx) {
|
|
9527
9550
|
return async (req, res, next) => {
|
|
@@ -11558,6 +11581,143 @@ function registerTeamUsageRoutes(router, ctx) {
|
|
|
11558
11581
|
}
|
|
11559
11582
|
}
|
|
11560
11583
|
|
|
11584
|
+
// src/langfuseClient.ts
|
|
11585
|
+
var DEFAULT_TIMEOUT2 = 15e3;
|
|
11586
|
+
var LangfuseUpstreamError = class extends Error {
|
|
11587
|
+
constructor(status, statusText) {
|
|
11588
|
+
super(`Langfuse API error: ${status} ${statusText}`);
|
|
11589
|
+
this.status = status;
|
|
11590
|
+
}
|
|
11591
|
+
};
|
|
11592
|
+
async function queryMetrics(baseUrl, publicKey, secretKey, queryObj, timeout = DEFAULT_TIMEOUT2) {
|
|
11593
|
+
const controller = new AbortController();
|
|
11594
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
11595
|
+
try {
|
|
11596
|
+
const url = `${baseUrl.replace(/\/$/, "")}/api/public/v2/metrics?query=${encodeURIComponent(
|
|
11597
|
+
JSON.stringify(queryObj)
|
|
11598
|
+
)}`;
|
|
11599
|
+
const response = await fetch(url, {
|
|
11600
|
+
signal: controller.signal,
|
|
11601
|
+
headers: {
|
|
11602
|
+
Authorization: `Basic ${Buffer.from(`${publicKey}:${secretKey}`).toString("base64")}`
|
|
11603
|
+
}
|
|
11604
|
+
});
|
|
11605
|
+
if (!response.ok) {
|
|
11606
|
+
throw new LangfuseUpstreamError(response.status, response.statusText);
|
|
11607
|
+
}
|
|
11608
|
+
return response.json();
|
|
11609
|
+
} finally {
|
|
11610
|
+
clearTimeout(timeoutId);
|
|
11611
|
+
}
|
|
11612
|
+
}
|
|
11613
|
+
function toNum(v) {
|
|
11614
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
11615
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
11616
|
+
const n = Number(v);
|
|
11617
|
+
return Number.isFinite(n) ? n : null;
|
|
11618
|
+
}
|
|
11619
|
+
return null;
|
|
11620
|
+
}
|
|
11621
|
+
function pick(row, names) {
|
|
11622
|
+
const scopes = [row, row?.metrics, row?.dimensions];
|
|
11623
|
+
for (const scope of scopes) {
|
|
11624
|
+
if (!scope || typeof scope !== "object") continue;
|
|
11625
|
+
for (const name of names) {
|
|
11626
|
+
const n = toNum(scope[name]);
|
|
11627
|
+
if (n !== null) return n;
|
|
11628
|
+
}
|
|
11629
|
+
}
|
|
11630
|
+
return null;
|
|
11631
|
+
}
|
|
11632
|
+
function pickText(row, names) {
|
|
11633
|
+
const scopes = [row, row?.metrics, row?.dimensions];
|
|
11634
|
+
for (const scope of scopes) {
|
|
11635
|
+
if (!scope || typeof scope !== "object") continue;
|
|
11636
|
+
for (const name of names) {
|
|
11637
|
+
const v = scope[name];
|
|
11638
|
+
if (typeof v === "string" && v) return v;
|
|
11639
|
+
}
|
|
11640
|
+
}
|
|
11641
|
+
return null;
|
|
11642
|
+
}
|
|
11643
|
+
function metricRows(body) {
|
|
11644
|
+
if (Array.isArray(body)) return body;
|
|
11645
|
+
if (Array.isArray(body?.data)) return body.data;
|
|
11646
|
+
if (Array.isArray(body?.results)) return body.results;
|
|
11647
|
+
if (Array.isArray(body?.rows)) return body.rows;
|
|
11648
|
+
return [];
|
|
11649
|
+
}
|
|
11650
|
+
function parseObservations(body) {
|
|
11651
|
+
const byModel = metricRows(body).map((row) => ({
|
|
11652
|
+
model: pickText(row, ["providedModelName", "model", "modelName"]) ?? "",
|
|
11653
|
+
cost: pick(row, ["sum_totalCost", "totalCost", "sum_total_cost", "cost", "sum_cost"]) ?? 0,
|
|
11654
|
+
tokens: pick(row, ["sum_totalTokens", "totalTokens", "sum_total_tokens", "tokens"]) ?? 0,
|
|
11655
|
+
avgLatencyMs: pick(row, ["avg_latency", "latency", "avg_latency_ms", "latencyMs"]),
|
|
11656
|
+
requests: pick(row, ["count_id", "count", "id_count", "requests", "observations"]) ?? 0
|
|
11657
|
+
})).filter((r) => r.model !== "");
|
|
11658
|
+
byModel.sort((a, b) => b.cost - a.cost);
|
|
11659
|
+
return byModel;
|
|
11660
|
+
}
|
|
11661
|
+
function parseScores(body) {
|
|
11662
|
+
const scores = metricRows(body).map((row) => ({
|
|
11663
|
+
name: pickText(row, ["name", "scoreName"]) ?? "",
|
|
11664
|
+
avg: pick(row, ["avg_value", "value", "avg"]) ?? NaN,
|
|
11665
|
+
count: pick(row, ["count_id", "count", "id_count", "scores"]) ?? 0
|
|
11666
|
+
})).filter((s) => s.name !== "" && Number.isFinite(s.avg));
|
|
11667
|
+
scores.sort((a, b) => a.name.localeCompare(b.name));
|
|
11668
|
+
return scores;
|
|
11669
|
+
}
|
|
11670
|
+
function toTimestamp(day2, endOfDay) {
|
|
11671
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(day2)) {
|
|
11672
|
+
return endOfDay ? `${day2}T23:59:59.999Z` : `${day2}T00:00:00.000Z`;
|
|
11673
|
+
}
|
|
11674
|
+
return day2;
|
|
11675
|
+
}
|
|
11676
|
+
async function getQualitySummary(cfg, opts) {
|
|
11677
|
+
if (!isLangfuseConfigured(cfg)) return null;
|
|
11678
|
+
const { baseUrl, publicKey, secretKey, userIdField } = cfg;
|
|
11679
|
+
const fromTimestamp = toTimestamp(opts.from, false);
|
|
11680
|
+
const toTimestampValue = toTimestamp(opts.to, true);
|
|
11681
|
+
try {
|
|
11682
|
+
const observations = await queryMetrics(baseUrl, publicKey, secretKey, {
|
|
11683
|
+
view: "observations",
|
|
11684
|
+
metrics: [
|
|
11685
|
+
{ measure: "totalCost", aggregation: "SUM" },
|
|
11686
|
+
{ measure: "totalTokens", aggregation: "SUM" },
|
|
11687
|
+
{ measure: "latency", aggregation: "AVG" },
|
|
11688
|
+
{ measure: "id", aggregation: "COUNT" }
|
|
11689
|
+
],
|
|
11690
|
+
dimensions: [{ field: "providedModelName" }],
|
|
11691
|
+
filters: [
|
|
11692
|
+
{ column: userIdField, operator: "=", value: opts.userId, type: "string" }
|
|
11693
|
+
],
|
|
11694
|
+
fromTimestamp,
|
|
11695
|
+
toTimestamp: toTimestampValue,
|
|
11696
|
+
config: { row_limit: 50 }
|
|
11697
|
+
});
|
|
11698
|
+
let scores = [];
|
|
11699
|
+
try {
|
|
11700
|
+
const scoresBody = await queryMetrics(baseUrl, publicKey, secretKey, {
|
|
11701
|
+
view: "scores-numeric",
|
|
11702
|
+
metrics: [
|
|
11703
|
+
{ measure: "value", aggregation: "AVG" },
|
|
11704
|
+
{ measure: "id", aggregation: "COUNT" }
|
|
11705
|
+
],
|
|
11706
|
+
dimensions: [{ field: "name" }],
|
|
11707
|
+
fromTimestamp,
|
|
11708
|
+
toTimestamp: toTimestampValue,
|
|
11709
|
+
config: { row_limit: 50 }
|
|
11710
|
+
});
|
|
11711
|
+
scores = parseScores(scoresBody);
|
|
11712
|
+
} catch {
|
|
11713
|
+
scores = [];
|
|
11714
|
+
}
|
|
11715
|
+
return { configured: true, byModel: parseObservations(observations), scores };
|
|
11716
|
+
} catch {
|
|
11717
|
+
return null;
|
|
11718
|
+
}
|
|
11719
|
+
}
|
|
11720
|
+
|
|
11561
11721
|
// src/routes/insights.ts
|
|
11562
11722
|
function registerInsightsRoutes(router, ctx) {
|
|
11563
11723
|
const { client, catalogClient, auth, logger, auditGroup } = ctx;
|
|
@@ -11596,6 +11756,41 @@ function registerInsightsRoutes(router, ctx) {
|
|
|
11596
11756
|
sendError(res, error, logger, "fetch spend logs");
|
|
11597
11757
|
}
|
|
11598
11758
|
});
|
|
11759
|
+
router.get("/insights/quality", requireUser, async (req, res) => {
|
|
11760
|
+
try {
|
|
11761
|
+
const { start_date, end_date, userId } = req.query;
|
|
11762
|
+
if (!start_date || !end_date) {
|
|
11763
|
+
res.status(400).json({ error: "start_date and end_date are required" });
|
|
11764
|
+
return;
|
|
11765
|
+
}
|
|
11766
|
+
const tokenEntityRef = res.locals.tokenEntityRef;
|
|
11767
|
+
await getProvisionedUser(ctx, res);
|
|
11768
|
+
const callerId = res.locals.userId;
|
|
11769
|
+
const isAudit = auditGroup ? await isUserMemberOfGroup(
|
|
11770
|
+
tokenEntityRef,
|
|
11771
|
+
auditGroup,
|
|
11772
|
+
catalogClient,
|
|
11773
|
+
auth,
|
|
11774
|
+
logger
|
|
11775
|
+
) : false;
|
|
11776
|
+
if (!isLangfuseConfigured(ctx.langfuseCfg)) {
|
|
11777
|
+
res.json({ configured: false });
|
|
11778
|
+
return;
|
|
11779
|
+
}
|
|
11780
|
+
const summary = await getQualitySummary(ctx.langfuseCfg, {
|
|
11781
|
+
from: start_date,
|
|
11782
|
+
to: end_date,
|
|
11783
|
+
userId: isAudit ? userId || callerId : callerId
|
|
11784
|
+
});
|
|
11785
|
+
res.json(summary ?? { configured: false });
|
|
11786
|
+
} catch (error) {
|
|
11787
|
+
if (error instanceof ProvisioningError) {
|
|
11788
|
+
res.status(error.status).json(error.body);
|
|
11789
|
+
return;
|
|
11790
|
+
}
|
|
11791
|
+
sendError(res, error, logger, "fetch quality summary");
|
|
11792
|
+
}
|
|
11793
|
+
});
|
|
11599
11794
|
}
|
|
11600
11795
|
|
|
11601
11796
|
// node_modules/jose/dist/node/esm/runtime/base64url.js
|
|
@@ -13336,6 +13531,7 @@ async function createRouter(options) {
|
|
|
13336
13531
|
const teamBudgetVisibility = readTeamBudgetVisibility(config);
|
|
13337
13532
|
const opencodeCfg = readOpencodeConfig(config);
|
|
13338
13533
|
const memberBreakdownCfg = readMemberBreakdownConfig(config);
|
|
13534
|
+
const langfuseCfg = readLangfuseConfig(config);
|
|
13339
13535
|
const catalogClient = options.catalogClient ?? new import_catalog_client.CatalogClient({ discoveryApi: discovery });
|
|
13340
13536
|
if (provisioningEnabled) {
|
|
13341
13537
|
logger.info(
|
|
@@ -13382,6 +13578,7 @@ async function createRouter(options) {
|
|
|
13382
13578
|
teamBudgetVisibility,
|
|
13383
13579
|
opencodeCfg,
|
|
13384
13580
|
memberBreakdownCfg,
|
|
13581
|
+
langfuseCfg,
|
|
13385
13582
|
// Helper functions - defined inline below this object
|
|
13386
13583
|
authorizeKeyAction: async (req, keyId) => {
|
|
13387
13584
|
if (!req.res || !req.res.locals.tokenEntityRef || !req.res.locals.userId) {
|