@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 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 = Array.isArray(raw)
728
- ? raw
729
- : Array.isArray(raw?.data)
730
- ? raw.data
731
- : Array.isArray(raw?.results)
732
- ? raw.results
733
- : Array.isArray(raw?.logs)
734
- ? raw.logs
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
- const n = typeof v === 'number'
738
- ? v
739
- : typeof v === 'string' && v.trim() !== ''
740
- ? Number(v)
741
- : NaN;
742
- return Number.isFinite(n) ? n : fallback;
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 = Array.isArray(raw) ? raw : Array.isArray(raw?.data) ? raw.data : Array.isArray(raw?.results) ? raw.results : Array.isArray(raw?.logs) ? raw.logs : [];
8695
- const toNum = (v, fallback = 0) => {
8696
- const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
8697
- return Number.isFinite(n) ? n : fallback;
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 = toNum(
8708
+ const promptTokens = toNum2(
8701
8709
  r?.prompt_tokens ?? r?.promptTokens ?? r?.input_tokens
8702
8710
  );
8703
- const completionTokens = toNum(
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: toNum(r?.spend ?? r?.cost),
8717
+ spend: toNum2(r?.spend ?? r?.cost),
8710
8718
  promptTokens,
8711
8719
  completionTokens,
8712
- totalTokens: toNum(
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: toNum(raw?.total ?? raw?.total_count, data.length),
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) {