@acarmisc/backstage-plugin-litellm-backend 0.22.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.d.ts +12 -1
- package/dist/client.js +92 -0
- package/dist/index.cjs.js +318 -4
- 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 +6 -1
- package/dist/routes/context.d.ts +2 -0
- package/dist/routes/insights.d.ts +3 -0
- package/dist/routes/insights.js +80 -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.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LiteLLMConfig, UserInfo, VirtualKey, ModelInfo, UsageMetrics, TeamInfo, GenerateKeyRequest, GenerateKeyResponse, UpdateKeyRequest, DeleteKeyRequest, CreateUserRequest, CreateUserResponse, CreateTeamRequest, CreateTeamResponse, UpdateTeamRequest, AuditLogsParams, PaginatedAuditLogs } from './types';
|
|
1
|
+
import { LiteLLMConfig, UserInfo, VirtualKey, ModelInfo, UsageMetrics, TeamInfo, GenerateKeyRequest, GenerateKeyResponse, UpdateKeyRequest, DeleteKeyRequest, CreateUserRequest, CreateUserResponse, CreateTeamRequest, CreateTeamResponse, UpdateTeamRequest, AuditLogsParams, PaginatedAuditLogs, SpendLogsParams, PaginatedSpendLogs } from './types';
|
|
2
2
|
/**
|
|
3
3
|
* Typed error for failed upstream LiteLLM responses. Preserves the HTTP
|
|
4
4
|
* status and the structured `param` (e.g. `key_alias`) from the upstream
|
|
@@ -185,4 +185,15 @@ export declare class LiteLLMClient {
|
|
|
185
185
|
private transformDailyActivity;
|
|
186
186
|
getUsage(startDate: string, endDate: string, userId: string): Promise<UsageMetrics>;
|
|
187
187
|
getTeamUsage(teamId: string, startDate: string, endDate: string): Promise<UsageMetrics>;
|
|
188
|
+
/**
|
|
189
|
+
* Returns per-request spend rows from `/spend/logs` (retried as
|
|
190
|
+
* `/spend/logs/v2` on 404 — the path differs across LiteLLM versions).
|
|
191
|
+
* The response shape is version-dependent, so rows are parsed tolerantly:
|
|
192
|
+
* a bare array or a `{ data | results | logs }` envelope, with per-field
|
|
193
|
+
* fallbacks in normalizeSpendLogs.
|
|
194
|
+
*/
|
|
195
|
+
getSpendLogs(params: SpendLogsParams & {
|
|
196
|
+
apiKeyHash?: string;
|
|
197
|
+
}): Promise<PaginatedSpendLogs>;
|
|
198
|
+
private normalizeSpendLogs;
|
|
188
199
|
}
|
package/dist/client.js
CHANGED
|
@@ -683,5 +683,97 @@ class LiteLLMClient {
|
|
|
683
683
|
throw err;
|
|
684
684
|
}
|
|
685
685
|
}
|
|
686
|
+
/**
|
|
687
|
+
* Returns per-request spend rows from `/spend/logs` (retried as
|
|
688
|
+
* `/spend/logs/v2` on 404 — the path differs across LiteLLM versions).
|
|
689
|
+
* The response shape is version-dependent, so rows are parsed tolerantly:
|
|
690
|
+
* a bare array or a `{ data | results | logs }` envelope, with per-field
|
|
691
|
+
* fallbacks in normalizeSpendLogs.
|
|
692
|
+
*/
|
|
693
|
+
async getSpendLogs(params) {
|
|
694
|
+
const page = params.page ?? 1;
|
|
695
|
+
const pageSize = Math.min(params.pageSize ?? 25, 100);
|
|
696
|
+
const buildQuery = () => {
|
|
697
|
+
const q = new URLSearchParams({
|
|
698
|
+
summarize: 'false',
|
|
699
|
+
start_date: params.startDate,
|
|
700
|
+
end_date: params.endDate,
|
|
701
|
+
page: String(page),
|
|
702
|
+
page_size: String(pageSize),
|
|
703
|
+
});
|
|
704
|
+
if (params.userId)
|
|
705
|
+
q.set('user_id', params.userId);
|
|
706
|
+
if (params.teamId)
|
|
707
|
+
q.set('team_id', params.teamId);
|
|
708
|
+
if (params.apiKeyHash)
|
|
709
|
+
q.set('api_key', params.apiKeyHash);
|
|
710
|
+
return q.toString();
|
|
711
|
+
};
|
|
712
|
+
let raw;
|
|
713
|
+
try {
|
|
714
|
+
raw = await this.request(`/spend/logs?${buildQuery()}`);
|
|
715
|
+
}
|
|
716
|
+
catch (err) {
|
|
717
|
+
if (err instanceof LiteLLMUpstreamError && err.status === 404) {
|
|
718
|
+
raw = await this.request(`/spend/logs/v2?${buildQuery()}`);
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
throw err;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return this.normalizeSpendLogs(raw, page, pageSize);
|
|
725
|
+
}
|
|
726
|
+
normalizeSpendLogs(raw, page, pageSize) {
|
|
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
|
+
const toNum = (v, fallback = 0) => {
|
|
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;
|
|
744
|
+
};
|
|
745
|
+
const data = rows.map((r) => {
|
|
746
|
+
const promptTokens = toNum(r?.prompt_tokens ?? r?.promptTokens ?? r?.input_tokens);
|
|
747
|
+
const completionTokens = toNum(r?.completion_tokens ?? r?.completionTokens ?? r?.output_tokens);
|
|
748
|
+
return {
|
|
749
|
+
requestId: String(r?.request_id ?? r?.requestId ?? r?.id ?? ''),
|
|
750
|
+
model: String(r?.model ?? r?.model_name ?? ''),
|
|
751
|
+
spend: toNum(r?.spend ?? r?.cost),
|
|
752
|
+
promptTokens,
|
|
753
|
+
completionTokens,
|
|
754
|
+
totalTokens: toNum(r?.total_tokens ?? r?.totalTokens, promptTokens + completionTokens),
|
|
755
|
+
status: String(r?.status ?? r?.request_status ?? 'unknown'),
|
|
756
|
+
cacheHit: Boolean(r?.cache_hit ?? r?.cacheHit ?? r?.cached === true),
|
|
757
|
+
...(r?.user_id ?? r?.userId
|
|
758
|
+
? { userId: String(r.user_id ?? r.userId) }
|
|
759
|
+
: {}),
|
|
760
|
+
...(r?.team_id ?? r?.teamId
|
|
761
|
+
? { teamId: String(r.team_id ?? r.teamId) }
|
|
762
|
+
: {}),
|
|
763
|
+
...(r?.api_key ?? r?.keyHash
|
|
764
|
+
? { keyHash: String(r.api_key ?? r.keyHash) }
|
|
765
|
+
: {}),
|
|
766
|
+
timestamp: String(r?.timestamp ?? r?.startTime ?? r?.created_at ?? r?.date ?? ''),
|
|
767
|
+
};
|
|
768
|
+
});
|
|
769
|
+
// Upstream truncation is not signalled in-band, so truncated = full page.
|
|
770
|
+
return {
|
|
771
|
+
data,
|
|
772
|
+
total: toNum(raw?.total ?? raw?.total_count, data.length),
|
|
773
|
+
page,
|
|
774
|
+
pageSize,
|
|
775
|
+
truncated: data.length >= pageSize,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
686
778
|
}
|
|
687
779
|
exports.LiteLLMClient = LiteLLMClient;
|
package/dist/index.cjs.js
CHANGED
|
@@ -8022,7 +8022,7 @@ var import_backend_plugin_api = require("@backstage/backend-plugin-api");
|
|
|
8022
8022
|
// src/router.ts
|
|
8023
8023
|
var import_express = __toESM(require("express"));
|
|
8024
8024
|
var import_plugin_permission_common2 = __toESM(require_index_cjs2());
|
|
8025
|
-
var
|
|
8025
|
+
var import_errors22 = __toESM(require_index_cjs());
|
|
8026
8026
|
var import_catalog_client = require("@backstage/catalog-client");
|
|
8027
8027
|
|
|
8028
8028
|
// src/client.ts
|
|
@@ -8655,6 +8655,90 @@ var LiteLLMClient = class {
|
|
|
8655
8655
|
throw err;
|
|
8656
8656
|
}
|
|
8657
8657
|
}
|
|
8658
|
+
/**
|
|
8659
|
+
* Returns per-request spend rows from `/spend/logs` (retried as
|
|
8660
|
+
* `/spend/logs/v2` on 404 — the path differs across LiteLLM versions).
|
|
8661
|
+
* The response shape is version-dependent, so rows are parsed tolerantly:
|
|
8662
|
+
* a bare array or a `{ data | results | logs }` envelope, with per-field
|
|
8663
|
+
* fallbacks in normalizeSpendLogs.
|
|
8664
|
+
*/
|
|
8665
|
+
async getSpendLogs(params) {
|
|
8666
|
+
const page = params.page ?? 1;
|
|
8667
|
+
const pageSize = Math.min(params.pageSize ?? 25, 100);
|
|
8668
|
+
const buildQuery = () => {
|
|
8669
|
+
const q = new URLSearchParams({
|
|
8670
|
+
summarize: "false",
|
|
8671
|
+
start_date: params.startDate,
|
|
8672
|
+
end_date: params.endDate,
|
|
8673
|
+
page: String(page),
|
|
8674
|
+
page_size: String(pageSize)
|
|
8675
|
+
});
|
|
8676
|
+
if (params.userId) q.set("user_id", params.userId);
|
|
8677
|
+
if (params.teamId) q.set("team_id", params.teamId);
|
|
8678
|
+
if (params.apiKeyHash) q.set("api_key", params.apiKeyHash);
|
|
8679
|
+
return q.toString();
|
|
8680
|
+
};
|
|
8681
|
+
let raw;
|
|
8682
|
+
try {
|
|
8683
|
+
raw = await this.request(`/spend/logs?${buildQuery()}`);
|
|
8684
|
+
} catch (err) {
|
|
8685
|
+
if (err instanceof LiteLLMUpstreamError && err.status === 404) {
|
|
8686
|
+
raw = await this.request(`/spend/logs/v2?${buildQuery()}`);
|
|
8687
|
+
} else {
|
|
8688
|
+
throw err;
|
|
8689
|
+
}
|
|
8690
|
+
}
|
|
8691
|
+
return this.normalizeSpendLogs(raw, page, pageSize);
|
|
8692
|
+
}
|
|
8693
|
+
normalizeSpendLogs(raw, page, pageSize) {
|
|
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;
|
|
8706
|
+
};
|
|
8707
|
+
const data = rows.map((r) => {
|
|
8708
|
+
const promptTokens = toNum2(
|
|
8709
|
+
r?.prompt_tokens ?? r?.promptTokens ?? r?.input_tokens
|
|
8710
|
+
);
|
|
8711
|
+
const completionTokens = toNum2(
|
|
8712
|
+
r?.completion_tokens ?? r?.completionTokens ?? r?.output_tokens
|
|
8713
|
+
);
|
|
8714
|
+
return {
|
|
8715
|
+
requestId: String(r?.request_id ?? r?.requestId ?? r?.id ?? ""),
|
|
8716
|
+
model: String(r?.model ?? r?.model_name ?? ""),
|
|
8717
|
+
spend: toNum2(r?.spend ?? r?.cost),
|
|
8718
|
+
promptTokens,
|
|
8719
|
+
completionTokens,
|
|
8720
|
+
totalTokens: toNum2(
|
|
8721
|
+
r?.total_tokens ?? r?.totalTokens,
|
|
8722
|
+
promptTokens + completionTokens
|
|
8723
|
+
),
|
|
8724
|
+
status: String(r?.status ?? r?.request_status ?? "unknown"),
|
|
8725
|
+
cacheHit: Boolean(r?.cache_hit ?? r?.cacheHit ?? r?.cached === true),
|
|
8726
|
+
...r?.user_id ?? r?.userId ? { userId: String(r.user_id ?? r.userId) } : {},
|
|
8727
|
+
...r?.team_id ?? r?.teamId ? { teamId: String(r.team_id ?? r.teamId) } : {},
|
|
8728
|
+
...r?.api_key ?? r?.keyHash ? { keyHash: String(r.api_key ?? r.keyHash) } : {},
|
|
8729
|
+
timestamp: String(
|
|
8730
|
+
r?.timestamp ?? r?.startTime ?? r?.created_at ?? r?.date ?? ""
|
|
8731
|
+
)
|
|
8732
|
+
};
|
|
8733
|
+
});
|
|
8734
|
+
return {
|
|
8735
|
+
data,
|
|
8736
|
+
total: toNum2(raw?.total ?? raw?.total_count, data.length),
|
|
8737
|
+
page,
|
|
8738
|
+
pageSize,
|
|
8739
|
+
truncated: data.length >= pageSize
|
|
8740
|
+
};
|
|
8741
|
+
}
|
|
8658
8742
|
};
|
|
8659
8743
|
|
|
8660
8744
|
// src/errors.ts
|
|
@@ -9446,6 +9530,21 @@ function redactTeamMemberUsage(usage) {
|
|
|
9446
9530
|
};
|
|
9447
9531
|
}
|
|
9448
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
|
+
|
|
9449
9548
|
// src/routes/middleware/withUser.ts
|
|
9450
9549
|
function createRequireUser(ctx) {
|
|
9451
9550
|
return async (req, res, next) => {
|
|
@@ -11482,6 +11581,218 @@ function registerTeamUsageRoutes(router, ctx) {
|
|
|
11482
11581
|
}
|
|
11483
11582
|
}
|
|
11484
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
|
+
|
|
11721
|
+
// src/routes/insights.ts
|
|
11722
|
+
function registerInsightsRoutes(router, ctx) {
|
|
11723
|
+
const { client, catalogClient, auth, logger, auditGroup } = ctx;
|
|
11724
|
+
const requireUser = createRequireUser(ctx);
|
|
11725
|
+
router.get("/insights/spend-logs", requireUser, async (req, res) => {
|
|
11726
|
+
try {
|
|
11727
|
+
const { start_date, end_date, userId, teamId, page, page_size } = req.query;
|
|
11728
|
+
if (!start_date || !end_date) {
|
|
11729
|
+
res.status(400).json({ error: "start_date and end_date are required" });
|
|
11730
|
+
return;
|
|
11731
|
+
}
|
|
11732
|
+
const tokenEntityRef = res.locals.tokenEntityRef;
|
|
11733
|
+
await getProvisionedUser(ctx, res);
|
|
11734
|
+
const callerId = res.locals.userId;
|
|
11735
|
+
const isAudit = auditGroup ? await isUserMemberOfGroup(
|
|
11736
|
+
tokenEntityRef,
|
|
11737
|
+
auditGroup,
|
|
11738
|
+
catalogClient,
|
|
11739
|
+
auth,
|
|
11740
|
+
logger
|
|
11741
|
+
) : false;
|
|
11742
|
+
const result = await client.getSpendLogs({
|
|
11743
|
+
startDate: start_date,
|
|
11744
|
+
endDate: end_date,
|
|
11745
|
+
userId: isAudit ? userId || callerId : callerId,
|
|
11746
|
+
teamId: isAudit ? teamId : void 0,
|
|
11747
|
+
page: page ? Number(page) : void 0,
|
|
11748
|
+
pageSize: page_size ? Number(page_size) : void 0
|
|
11749
|
+
});
|
|
11750
|
+
res.json(result);
|
|
11751
|
+
} catch (error) {
|
|
11752
|
+
if (error instanceof ProvisioningError) {
|
|
11753
|
+
res.status(error.status).json(error.body);
|
|
11754
|
+
return;
|
|
11755
|
+
}
|
|
11756
|
+
sendError(res, error, logger, "fetch spend logs");
|
|
11757
|
+
}
|
|
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
|
+
});
|
|
11794
|
+
}
|
|
11795
|
+
|
|
11485
11796
|
// node_modules/jose/dist/node/esm/runtime/base64url.js
|
|
11486
11797
|
var import_node_buffer = require("node:buffer");
|
|
11487
11798
|
|
|
@@ -13220,6 +13531,7 @@ async function createRouter(options) {
|
|
|
13220
13531
|
const teamBudgetVisibility = readTeamBudgetVisibility(config);
|
|
13221
13532
|
const opencodeCfg = readOpencodeConfig(config);
|
|
13222
13533
|
const memberBreakdownCfg = readMemberBreakdownConfig(config);
|
|
13534
|
+
const langfuseCfg = readLangfuseConfig(config);
|
|
13223
13535
|
const catalogClient = options.catalogClient ?? new import_catalog_client.CatalogClient({ discoveryApi: discovery });
|
|
13224
13536
|
if (provisioningEnabled) {
|
|
13225
13537
|
logger.info(
|
|
@@ -13266,17 +13578,18 @@ async function createRouter(options) {
|
|
|
13266
13578
|
teamBudgetVisibility,
|
|
13267
13579
|
opencodeCfg,
|
|
13268
13580
|
memberBreakdownCfg,
|
|
13581
|
+
langfuseCfg,
|
|
13269
13582
|
// Helper functions - defined inline below this object
|
|
13270
13583
|
authorizeKeyAction: async (req, keyId) => {
|
|
13271
13584
|
if (!req.res || !req.res.locals.tokenEntityRef || !req.res.locals.userId) {
|
|
13272
|
-
throw new
|
|
13585
|
+
throw new import_errors22.NotAllowedError("User identity not found in request");
|
|
13273
13586
|
}
|
|
13274
13587
|
const tokenEntityRef = req.res.locals.tokenEntityRef;
|
|
13275
13588
|
const userId = await resolveMemberUserId(tokenEntityRef);
|
|
13276
13589
|
const ownKeys = await client.listKeys(userId);
|
|
13277
13590
|
const key = ownKeys.find((k) => (k.token ?? k.key) === keyId);
|
|
13278
13591
|
if (!key) {
|
|
13279
|
-
throw new
|
|
13592
|
+
throw new import_errors22.NotAllowedError("Access denied: key does not belong to the caller");
|
|
13280
13593
|
}
|
|
13281
13594
|
return { tokenEntityRef, userId, key };
|
|
13282
13595
|
},
|
|
@@ -13410,13 +13723,14 @@ async function createRouter(options) {
|
|
|
13410
13723
|
router.use(import_express.default.json());
|
|
13411
13724
|
router.use(import_express.default.urlencoded({ extended: false }));
|
|
13412
13725
|
const requireUser = createRequireUser(ctx);
|
|
13413
|
-
router.use(["/user/info", "/keys", "/usage"], requireUser);
|
|
13726
|
+
router.use(["/user/info", "/keys", "/usage", "/insights"], requireUser);
|
|
13414
13727
|
registerConfigRoutes(router, ctx);
|
|
13415
13728
|
registerKeysRoutes(router, ctx);
|
|
13416
13729
|
registerOpencodeRoutes(router, ctx);
|
|
13417
13730
|
registerTeamsRoutes(router, ctx);
|
|
13418
13731
|
registerObjectPermissionsRoutes(router, ctx);
|
|
13419
13732
|
registerTeamUsageRoutes(router, ctx);
|
|
13733
|
+
registerInsightsRoutes(router, ctx);
|
|
13420
13734
|
registerBridgeRoutes(router, ctx, { config, tokenVerifier: options.tokenVerifier });
|
|
13421
13735
|
return router;
|
|
13422
13736
|
}
|