@acarmisc/backstage-plugin-litellm-backend 0.23.0 → 0.25.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 +325 -69
- 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/plugin.js +1 -0
- package/dist/router.js +3 -0
- package/dist/routes/bridge.js +24 -0
- package/dist/routes/context.d.ts +2 -0
- package/dist/routes/insights.js +37 -0
- package/dist/services/keyService.js +27 -1
- package/dist/services/mcpAccess.d.ts +20 -0
- package/dist/services/mcpAccess.js +28 -0
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Config } from '@backstage/config';
|
|
2
|
+
/**
|
|
3
|
+
* Generic Langfuse Metrics API v2 integration (litellm.langfuse.*).
|
|
4
|
+
*
|
|
5
|
+
* Powers the "Quality scores" card on the Insights tab with independent
|
|
6
|
+
* answer-quality data. Nothing here is bound to a specific deployment:
|
|
7
|
+
* the server version and the trace↔user join key vary, so a misconfigured
|
|
8
|
+
* or v3 server degrades to `{configured:false}` instead of erroring.
|
|
9
|
+
*/
|
|
10
|
+
export interface LangfuseConfig {
|
|
11
|
+
/** Mount GET /insights/quality and query Langfuse. Default false. */
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
/** Langfuse host, e.g. https://langfuse.example.com. */
|
|
14
|
+
baseUrl: string | null;
|
|
15
|
+
/** Langfuse public key (Basic-auth username). */
|
|
16
|
+
publicKey: string | null;
|
|
17
|
+
/** Langfuse secret key (Basic-auth password). */
|
|
18
|
+
secretKey: string | null;
|
|
19
|
+
/**
|
|
20
|
+
* The Langfuse observation field holding the LiteLLM user id, e.g.
|
|
21
|
+
* 'userId'. Deployments whose agents tag traces differently change this
|
|
22
|
+
* one key. Default 'userId'.
|
|
23
|
+
*/
|
|
24
|
+
userIdField: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function readLangfuseConfig(config: Config): LangfuseConfig;
|
|
27
|
+
/** True only when the integration is enabled AND fully credentialed. */
|
|
28
|
+
export declare function isLangfuseConfigured(cfg: LangfuseConfig): cfg is LangfuseConfig & {
|
|
29
|
+
baseUrl: string;
|
|
30
|
+
publicKey: string;
|
|
31
|
+
secretKey: string;
|
|
32
|
+
};
|
package/dist/langfuse.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readLangfuseConfig = readLangfuseConfig;
|
|
4
|
+
exports.isLangfuseConfigured = isLangfuseConfigured;
|
|
5
|
+
function readLangfuseConfig(config) {
|
|
6
|
+
const enabled = config.getOptionalBoolean('litellm.langfuse.enabled') ?? false;
|
|
7
|
+
return {
|
|
8
|
+
enabled,
|
|
9
|
+
baseUrl: config.getOptionalString('litellm.langfuse.baseUrl') ?? null,
|
|
10
|
+
publicKey: config.getOptionalString('litellm.langfuse.publicKey') ?? null,
|
|
11
|
+
secretKey: config.getOptionalString('litellm.langfuse.secretKey') ?? null,
|
|
12
|
+
userIdField: config.getOptionalString('litellm.langfuse.userIdField') ?? 'userId',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** True only when the integration is enabled AND fully credentialed. */
|
|
16
|
+
function isLangfuseConfigured(cfg) {
|
|
17
|
+
return (cfg.enabled && !!cfg.baseUrl && !!cfg.publicKey && !!cfg.secretKey);
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { QualitySummary } from './types';
|
|
2
|
+
import { type LangfuseConfig } from './langfuse';
|
|
3
|
+
/** Typed error for failed Langfuse Metrics API responses. */
|
|
4
|
+
export declare class LangfuseUpstreamError extends Error {
|
|
5
|
+
status: number;
|
|
6
|
+
constructor(status: number, statusText: string);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Runs one Langfuse Metrics API v2 query:
|
|
10
|
+
* GET {baseUrl}/api/public/v2/metrics with Basic auth (publicKey:secretKey)
|
|
11
|
+
* and `query` = the JSON query object.
|
|
12
|
+
*/
|
|
13
|
+
export declare function queryMetrics(baseUrl: string, publicKey: string, secretKey: string, queryObj: unknown, timeout?: number): Promise<any>;
|
|
14
|
+
/**
|
|
15
|
+
* Returns the caller's quality summary, or null when Langfuse is disabled,
|
|
16
|
+
* unconfigured, unreachable, or a v3 server without the v2 metrics endpoint
|
|
17
|
+
* (non-2xx degrades to null — never throws).
|
|
18
|
+
*/
|
|
19
|
+
export declare function getQualitySummary(cfg: LangfuseConfig, opts: {
|
|
20
|
+
from: string;
|
|
21
|
+
to: string;
|
|
22
|
+
userId: string;
|
|
23
|
+
}): Promise<QualitySummary | null>;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LangfuseUpstreamError = void 0;
|
|
4
|
+
exports.queryMetrics = queryMetrics;
|
|
5
|
+
exports.getQualitySummary = getQualitySummary;
|
|
6
|
+
const langfuse_1 = require("./langfuse");
|
|
7
|
+
const DEFAULT_TIMEOUT = 15000;
|
|
8
|
+
/** Typed error for failed Langfuse Metrics API responses. */
|
|
9
|
+
class LangfuseUpstreamError extends Error {
|
|
10
|
+
constructor(status, statusText) {
|
|
11
|
+
super(`Langfuse API error: ${status} ${statusText}`);
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
exports.LangfuseUpstreamError = LangfuseUpstreamError;
|
|
16
|
+
/**
|
|
17
|
+
* Runs one Langfuse Metrics API v2 query:
|
|
18
|
+
* GET {baseUrl}/api/public/v2/metrics with Basic auth (publicKey:secretKey)
|
|
19
|
+
* and `query` = the JSON query object.
|
|
20
|
+
*/
|
|
21
|
+
async function queryMetrics(baseUrl, publicKey, secretKey, queryObj, timeout = DEFAULT_TIMEOUT) {
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
24
|
+
try {
|
|
25
|
+
const url = `${baseUrl.replace(/\/$/, '')}/api/public/v2/metrics?query=${encodeURIComponent(JSON.stringify(queryObj))}`;
|
|
26
|
+
const response = await fetch(url, {
|
|
27
|
+
signal: controller.signal,
|
|
28
|
+
headers: {
|
|
29
|
+
Authorization: `Basic ${Buffer.from(`${publicKey}:${secretKey}`).toString('base64')}`,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
throw new LangfuseUpstreamError(response.status, response.statusText);
|
|
34
|
+
}
|
|
35
|
+
return response.json();
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
clearTimeout(timeoutId);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function toNum(v) {
|
|
42
|
+
if (typeof v === 'number')
|
|
43
|
+
return Number.isFinite(v) ? v : null;
|
|
44
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
45
|
+
const n = Number(v);
|
|
46
|
+
return Number.isFinite(n) ? n : null;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/** First finite number found across the row, its metrics nest, and its dimensions nest. */
|
|
51
|
+
function pick(row, names) {
|
|
52
|
+
const scopes = [row, row?.metrics, row?.dimensions];
|
|
53
|
+
for (const scope of scopes) {
|
|
54
|
+
if (!scope || typeof scope !== 'object')
|
|
55
|
+
continue;
|
|
56
|
+
for (const name of names) {
|
|
57
|
+
const n = toNum(scope[name]);
|
|
58
|
+
if (n !== null)
|
|
59
|
+
return n;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
function pickText(row, names) {
|
|
65
|
+
const scopes = [row, row?.metrics, row?.dimensions];
|
|
66
|
+
for (const scope of scopes) {
|
|
67
|
+
if (!scope || typeof scope !== 'object')
|
|
68
|
+
continue;
|
|
69
|
+
for (const name of names) {
|
|
70
|
+
const v = scope[name];
|
|
71
|
+
if (typeof v === 'string' && v)
|
|
72
|
+
return v;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function metricRows(body) {
|
|
78
|
+
if (Array.isArray(body))
|
|
79
|
+
return body;
|
|
80
|
+
if (Array.isArray(body?.data))
|
|
81
|
+
return body.data;
|
|
82
|
+
if (Array.isArray(body?.results))
|
|
83
|
+
return body.results;
|
|
84
|
+
if (Array.isArray(body?.rows))
|
|
85
|
+
return body.rows;
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
function parseObservations(body) {
|
|
89
|
+
const byModel = metricRows(body)
|
|
90
|
+
.map(row => ({
|
|
91
|
+
model: pickText(row, ['providedModelName', 'model', 'modelName']) ?? '',
|
|
92
|
+
cost: pick(row, ['sum_totalCost', 'totalCost', 'sum_total_cost', 'cost', 'sum_cost']) ?? 0,
|
|
93
|
+
tokens: pick(row, ['sum_totalTokens', 'totalTokens', 'sum_total_tokens', 'tokens']) ?? 0,
|
|
94
|
+
avgLatencyMs: pick(row, ['avg_latency', 'latency', 'avg_latency_ms', 'latencyMs']),
|
|
95
|
+
requests: pick(row, ['count_id', 'count', 'id_count', 'requests', 'observations']) ?? 0,
|
|
96
|
+
}))
|
|
97
|
+
.filter((r) => r.model !== '');
|
|
98
|
+
byModel.sort((a, b) => b.cost - a.cost);
|
|
99
|
+
return byModel;
|
|
100
|
+
}
|
|
101
|
+
function parseScores(body) {
|
|
102
|
+
const scores = metricRows(body)
|
|
103
|
+
.map(row => ({
|
|
104
|
+
name: pickText(row, ['name', 'scoreName']) ?? '',
|
|
105
|
+
avg: pick(row, ['avg_value', 'value', 'avg']) ?? NaN,
|
|
106
|
+
count: pick(row, ['count_id', 'count', 'id_count', 'scores']) ?? 0,
|
|
107
|
+
}))
|
|
108
|
+
.filter(s => s.name !== '' && Number.isFinite(s.avg));
|
|
109
|
+
scores.sort((a, b) => a.name.localeCompare(b.name));
|
|
110
|
+
return scores;
|
|
111
|
+
}
|
|
112
|
+
function toTimestamp(day, endOfDay) {
|
|
113
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(day)) {
|
|
114
|
+
return endOfDay ? `${day}T23:59:59.999Z` : `${day}T00:00:00.000Z`;
|
|
115
|
+
}
|
|
116
|
+
return day;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Returns the caller's quality summary, or null when Langfuse is disabled,
|
|
120
|
+
* unconfigured, unreachable, or a v3 server without the v2 metrics endpoint
|
|
121
|
+
* (non-2xx degrades to null — never throws).
|
|
122
|
+
*/
|
|
123
|
+
async function getQualitySummary(cfg, opts) {
|
|
124
|
+
if (!(0, langfuse_1.isLangfuseConfigured)(cfg))
|
|
125
|
+
return null;
|
|
126
|
+
const { baseUrl, publicKey, secretKey, userIdField } = cfg;
|
|
127
|
+
const fromTimestamp = toTimestamp(opts.from, false);
|
|
128
|
+
const toTimestampValue = toTimestamp(opts.to, true);
|
|
129
|
+
try {
|
|
130
|
+
const observations = await queryMetrics(baseUrl, publicKey, secretKey, {
|
|
131
|
+
view: 'observations',
|
|
132
|
+
metrics: [
|
|
133
|
+
{ measure: 'totalCost', aggregation: 'SUM' },
|
|
134
|
+
{ measure: 'totalTokens', aggregation: 'SUM' },
|
|
135
|
+
{ measure: 'latency', aggregation: 'AVG' },
|
|
136
|
+
{ measure: 'id', aggregation: 'COUNT' },
|
|
137
|
+
],
|
|
138
|
+
dimensions: [{ field: 'providedModelName' }],
|
|
139
|
+
filters: [
|
|
140
|
+
{ column: userIdField, operator: '=', value: opts.userId, type: 'string' },
|
|
141
|
+
],
|
|
142
|
+
fromTimestamp,
|
|
143
|
+
toTimestamp: toTimestampValue,
|
|
144
|
+
config: { row_limit: 50 },
|
|
145
|
+
});
|
|
146
|
+
// Scores are deployment-wide: the scores view does not reliably carry the
|
|
147
|
+
// trace user join key, so filtering it would silently drop every row.
|
|
148
|
+
let scores = [];
|
|
149
|
+
try {
|
|
150
|
+
const scoresBody = await queryMetrics(baseUrl, publicKey, secretKey, {
|
|
151
|
+
view: 'scores-numeric',
|
|
152
|
+
metrics: [
|
|
153
|
+
{ measure: 'value', aggregation: 'AVG' },
|
|
154
|
+
{ measure: 'id', aggregation: 'COUNT' },
|
|
155
|
+
],
|
|
156
|
+
dimensions: [{ field: 'name' }],
|
|
157
|
+
fromTimestamp,
|
|
158
|
+
toTimestamp: toTimestampValue,
|
|
159
|
+
config: { row_limit: 50 },
|
|
160
|
+
});
|
|
161
|
+
scores = parseScores(scoresBody);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
scores = [];
|
|
165
|
+
}
|
|
166
|
+
return { configured: true, byModel: parseObservations(observations), scores };
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
package/dist/plugin.js
CHANGED
package/dist/router.js
CHANGED
|
@@ -47,6 +47,7 @@ const teamAdmin_1 = require("./teamAdmin");
|
|
|
47
47
|
const teamBudgetVisibility_1 = require("./teamBudgetVisibility");
|
|
48
48
|
const opencode_1 = require("./opencode");
|
|
49
49
|
const teamMemberUsage_1 = require("./teamMemberUsage");
|
|
50
|
+
const langfuse_1 = require("./langfuse");
|
|
50
51
|
const errors_2 = require("./errors");
|
|
51
52
|
const withUser_1 = require("./routes/middleware/withUser");
|
|
52
53
|
const userInfoCache_1 = require("./services/userInfoCache");
|
|
@@ -101,6 +102,7 @@ async function createRouter(options) {
|
|
|
101
102
|
const teamBudgetVisibility = (0, teamBudgetVisibility_1.readTeamBudgetVisibility)(config);
|
|
102
103
|
const opencodeCfg = (0, opencode_1.readOpencodeConfig)(config);
|
|
103
104
|
const memberBreakdownCfg = (0, teamMemberUsage_1.readMemberBreakdownConfig)(config);
|
|
105
|
+
const langfuseCfg = (0, langfuse_1.readLangfuseConfig)(config);
|
|
104
106
|
const catalogClient = options.catalogClient ?? new catalog_client_1.CatalogClient({ discoveryApi: discovery });
|
|
105
107
|
if (provisioningEnabled) {
|
|
106
108
|
logger.info(`LiteLLM auto-provisioning enabled — defaults: budget=$${provisioningDefaults.maxBudget}/${provisioningDefaults.budgetDuration}, models=${provisioningDefaults.models.length
|
|
@@ -147,6 +149,7 @@ async function createRouter(options) {
|
|
|
147
149
|
teamBudgetVisibility,
|
|
148
150
|
opencodeCfg,
|
|
149
151
|
memberBreakdownCfg,
|
|
152
|
+
langfuseCfg,
|
|
150
153
|
// Helper functions - defined inline below this object
|
|
151
154
|
authorizeKeyAction: async (req, keyId) => {
|
|
152
155
|
// Express middleware should have populated req.res.locals with user info
|
package/dist/routes/bridge.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.registerBridgeRoutes = registerBridgeRoutes;
|
|
|
4
4
|
const provisioning_1 = require("../provisioning");
|
|
5
5
|
const bridge_1 = require("../bridge");
|
|
6
6
|
const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
|
|
7
|
+
const mcpAccess_1 = require("../services/mcpAccess");
|
|
7
8
|
const keyService_1 = require("../services/keyService");
|
|
8
9
|
const errors_1 = require("../errors");
|
|
9
10
|
const backstage_plugin_litellm_common_2 = require("@acarmisc/backstage-plugin-litellm-common");
|
|
@@ -175,6 +176,29 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
|
|
|
175
176
|
handleBridgeError(error, res);
|
|
176
177
|
}
|
|
177
178
|
});
|
|
179
|
+
// MCP servers the caller's team may use, for CLI clients that let the user
|
|
180
|
+
// pick which to attach to a new key. team_id is required: unlike models
|
|
181
|
+
// there is no meaningful unscoped catalogue to hand out.
|
|
182
|
+
router.get('/bridge/mcp', async (req, res) => {
|
|
183
|
+
try {
|
|
184
|
+
const claims = await requireClaims(req);
|
|
185
|
+
const teamId = typeof req.query.team_id === 'string' ? req.query.team_id : '';
|
|
186
|
+
if (!teamId) {
|
|
187
|
+
res.status(400).json({ error: 'team_id is required' });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const user = await (0, bridge_1.getOrProvisionUserFromClaims)(client, claims, provisioningEnabled, provisioningDefaults, logger, bridgeIdentity);
|
|
191
|
+
if (!(user.teams ?? []).includes(teamId)) {
|
|
192
|
+
res.status(403).json({ error: 'Access denied: team is not one of your teams', team_id: teamId });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const names = (0, mcpAccess_1.teamMcpServers)(await client.getTeamInfo(teamId));
|
|
196
|
+
res.json(names.map(server_name => ({ server_name })));
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
handleBridgeError(error, res);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
178
202
|
}
|
|
179
203
|
else if (bridgeCfg.enabled) {
|
|
180
204
|
logger.warn('litellm.bridge.enabled is true but no verifier could be built — bridge endpoints not mounted');
|
package/dist/routes/context.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { KeyValidationConfig } from '@acarmisc/backstage-plugin-litellm-com
|
|
|
9
9
|
import { TeamBudgetVisibility } from '../teamBudgetVisibility';
|
|
10
10
|
import { OpencodeConfig } from '../opencode';
|
|
11
11
|
import { MemberBreakdownConfig } from '../teamMemberUsage';
|
|
12
|
+
import { LangfuseConfig } from '../langfuse';
|
|
12
13
|
/**
|
|
13
14
|
* Shared runtime context for all route handlers. Holds config-derived
|
|
14
15
|
* constants, service clients, and helper functions needed by routes.
|
|
@@ -37,6 +38,7 @@ export interface RouterContext {
|
|
|
37
38
|
teamBudgetVisibility: TeamBudgetVisibility;
|
|
38
39
|
opencodeCfg: OpencodeConfig;
|
|
39
40
|
memberBreakdownCfg: MemberBreakdownConfig;
|
|
41
|
+
langfuseCfg: LangfuseConfig;
|
|
40
42
|
/**
|
|
41
43
|
* LiteLLM user id of a Backstage user: the computed id, or the id of an
|
|
42
44
|
* existing LiteLLM user with the same catalog email.
|
package/dist/routes/insights.js
CHANGED
|
@@ -4,6 +4,8 @@ exports.registerInsightsRoutes = registerInsightsRoutes;
|
|
|
4
4
|
const provisioning_1 = require("../provisioning");
|
|
5
5
|
const errors_1 = require("../errors");
|
|
6
6
|
const withUser_1 = require("./middleware/withUser");
|
|
7
|
+
const langfuse_1 = require("../langfuse");
|
|
8
|
+
const langfuseClient_1 = require("../langfuseClient");
|
|
7
9
|
function registerInsightsRoutes(router, ctx) {
|
|
8
10
|
const { client, catalogClient, auth, logger, auditGroup } = ctx;
|
|
9
11
|
const requireUser = (0, withUser_1.createRequireUser)(ctx);
|
|
@@ -40,4 +42,39 @@ function registerInsightsRoutes(router, ctx) {
|
|
|
40
42
|
(0, errors_1.sendError)(res, error, logger, 'fetch spend logs');
|
|
41
43
|
}
|
|
42
44
|
});
|
|
45
|
+
router.get('/insights/quality', requireUser, async (req, res) => {
|
|
46
|
+
try {
|
|
47
|
+
const { start_date, end_date, userId } = req.query;
|
|
48
|
+
if (!start_date || !end_date) {
|
|
49
|
+
res.status(400).json({ error: 'start_date and end_date are required' });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const tokenEntityRef = res.locals.tokenEntityRef;
|
|
53
|
+
await (0, withUser_1.getProvisionedUser)(ctx, res);
|
|
54
|
+
const callerId = res.locals.userId;
|
|
55
|
+
const isAudit = auditGroup
|
|
56
|
+
? await (0, provisioning_1.isUserMemberOfGroup)(tokenEntityRef, auditGroup, catalogClient, auth, logger)
|
|
57
|
+
: false;
|
|
58
|
+
if (!(0, langfuse_1.isLangfuseConfigured)(ctx.langfuseCfg)) {
|
|
59
|
+
res.json({ configured: false });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// Server-side scoping, same as spend-logs: only audit-group members
|
|
63
|
+
// may query other users — everyone else is forced to their own user_id.
|
|
64
|
+
// Unreachable / v3 servers degrade to {configured:false}, never a 500.
|
|
65
|
+
const summary = await (0, langfuseClient_1.getQualitySummary)(ctx.langfuseCfg, {
|
|
66
|
+
from: start_date,
|
|
67
|
+
to: end_date,
|
|
68
|
+
userId: isAudit ? userId || callerId : callerId,
|
|
69
|
+
});
|
|
70
|
+
res.json(summary ?? { configured: false });
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error instanceof provisioning_1.ProvisioningError) {
|
|
74
|
+
res.status(error.status).json(error.body);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
(0, errors_1.sendError)(res, error, logger, 'fetch quality summary');
|
|
78
|
+
}
|
|
79
|
+
});
|
|
43
80
|
}
|
|
@@ -11,6 +11,7 @@ exports.KeyServiceError = void 0;
|
|
|
11
11
|
exports.createKeyForUser = createKeyForUser;
|
|
12
12
|
const provisioning_1 = require("../provisioning");
|
|
13
13
|
const modelAccess_1 = require("./modelAccess");
|
|
14
|
+
const mcpAccess_1 = require("./mcpAccess");
|
|
14
15
|
/** Error thrown during key creation — maps to HTTP responses. */
|
|
15
16
|
class KeyServiceError extends Error {
|
|
16
17
|
constructor(status, body, message) {
|
|
@@ -58,6 +59,9 @@ async function createKeyForUser(user, input, ctx) {
|
|
|
58
59
|
});
|
|
59
60
|
}
|
|
60
61
|
}
|
|
62
|
+
// The team is read at most once per mint, and only if a check needs it.
|
|
63
|
+
let teamPromise;
|
|
64
|
+
const getTeam = (teamId) => (teamPromise ?? (teamPromise = client.getTeamInfo(teamId)));
|
|
61
65
|
// ── Validate models are subset of allowed ────────────────────────────
|
|
62
66
|
if (input.models && input.models.length > 0) {
|
|
63
67
|
// When team_id is set, use team's models; otherwise use user's models.
|
|
@@ -67,7 +71,7 @@ async function createKeyForUser(user, input, ctx) {
|
|
|
67
71
|
// Fetch team info to get its allowed models.
|
|
68
72
|
// Fail closed: if the team can't be fetched the error propagates
|
|
69
73
|
// rather than silently skipping the model check.
|
|
70
|
-
const teamInfo = await
|
|
74
|
+
const teamInfo = await getTeam(input.team_id);
|
|
71
75
|
allowedModels = teamInfo?.models ?? [];
|
|
72
76
|
}
|
|
73
77
|
else {
|
|
@@ -106,6 +110,23 @@ async function createKeyForUser(user, input, ctx) {
|
|
|
106
110
|
...clientMetadata,
|
|
107
111
|
...serverMetadata,
|
|
108
112
|
};
|
|
113
|
+
// ── Validate MCP servers are a subset of the team's ──────────────────
|
|
114
|
+
// [] is "no MCP servers" and needs no team; any name needs a team to be
|
|
115
|
+
// checked against, so a client cannot grant itself servers it has no
|
|
116
|
+
// ceiling for.
|
|
117
|
+
const mcpServers = input.mcp_servers ? [...new Set(input.mcp_servers)] : undefined;
|
|
118
|
+
if (mcpServers && mcpServers.length > 0) {
|
|
119
|
+
if (!input.team_id) {
|
|
120
|
+
throw new KeyServiceError(400, { error: 'team_id is required when mcp_servers is set' });
|
|
121
|
+
}
|
|
122
|
+
const disallowed = (0, mcpAccess_1.findDisallowedMcpServers)(mcpServers, (0, mcpAccess_1.teamMcpServers)(await getTeam(input.team_id)));
|
|
123
|
+
if (disallowed.length > 0) {
|
|
124
|
+
throw new KeyServiceError(403, {
|
|
125
|
+
error: 'One or more requested MCP servers are not allowed',
|
|
126
|
+
disallowed_mcp_servers: disallowed,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
109
130
|
// ── Build upstream request EXPLICITLY from parsed fields ──────────────
|
|
110
131
|
// Never spread the raw body; only include fields we've explicitly validated.
|
|
111
132
|
const upstreamRequest = {
|
|
@@ -139,6 +160,11 @@ async function createKeyForUser(user, input, ctx) {
|
|
|
139
160
|
if (input.key_type !== undefined) {
|
|
140
161
|
upstreamRequest.key_type = input.key_type;
|
|
141
162
|
}
|
|
163
|
+
if (mcpServers) {
|
|
164
|
+
upstreamRequest.object_permission = {
|
|
165
|
+
mcp_servers: mcpServers.length > 0 ? mcpServers : [mcpAccess_1.NO_MCP_SERVERS],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
142
168
|
upstreamRequest.metadata = enrichedMetadata;
|
|
143
169
|
// ── Call upstream LiteLLM ───────────────────────────────────────────
|
|
144
170
|
const result = await client.generateKey(upstreamRequest);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** LiteLLM's documented value that denies every MCP server. */
|
|
2
|
+
export declare const NO_MCP_SERVERS = "no-mcp-servers";
|
|
3
|
+
/**
|
|
4
|
+
* MCP servers a team may bind to a key: exactly the team's own list, as stored
|
|
5
|
+
* (LiteLLM accepts a server's name or id interchangeably when enforcing). A team
|
|
6
|
+
* with no list gets nothing: "empty" means unrestricted to LiteLLM, but a key
|
|
7
|
+
* list is a grant, so an unrestricted team must not let a member name servers it
|
|
8
|
+
* was never given. Entries the mint schema would reject are dropped so the
|
|
9
|
+
* picker never offers an unselectable name.
|
|
10
|
+
*
|
|
11
|
+
* shortcut: team `mcp_access_groups` are not expanded into server names; add
|
|
12
|
+
* that when a team grants MCP access only through groups.
|
|
13
|
+
*/
|
|
14
|
+
export declare function teamMcpServers(team: {
|
|
15
|
+
object_permission?: {
|
|
16
|
+
mcp_servers?: string[];
|
|
17
|
+
};
|
|
18
|
+
} | undefined): string[];
|
|
19
|
+
/** Requested names that are not in `allowed`, de-duplicated. */
|
|
20
|
+
export declare function findDisallowedMcpServers(requested: string[], allowed: string[]): string[];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NO_MCP_SERVERS = void 0;
|
|
4
|
+
exports.teamMcpServers = teamMcpServers;
|
|
5
|
+
exports.findDisallowedMcpServers = findDisallowedMcpServers;
|
|
6
|
+
const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
|
|
7
|
+
/** LiteLLM's documented value that denies every MCP server. */
|
|
8
|
+
exports.NO_MCP_SERVERS = 'no-mcp-servers';
|
|
9
|
+
/**
|
|
10
|
+
* MCP servers a team may bind to a key: exactly the team's own list, as stored
|
|
11
|
+
* (LiteLLM accepts a server's name or id interchangeably when enforcing). A team
|
|
12
|
+
* with no list gets nothing: "empty" means unrestricted to LiteLLM, but a key
|
|
13
|
+
* list is a grant, so an unrestricted team must not let a member name servers it
|
|
14
|
+
* was never given. Entries the mint schema would reject are dropped so the
|
|
15
|
+
* picker never offers an unselectable name.
|
|
16
|
+
*
|
|
17
|
+
* shortcut: team `mcp_access_groups` are not expanded into server names; add
|
|
18
|
+
* that when a team grants MCP access only through groups.
|
|
19
|
+
*/
|
|
20
|
+
function teamMcpServers(team) {
|
|
21
|
+
const listed = team?.object_permission?.mcp_servers ?? [];
|
|
22
|
+
return [...new Set(listed)].filter(n => backstage_plugin_litellm_common_1.MCP_SERVER_NAME_RE.test(n));
|
|
23
|
+
}
|
|
24
|
+
/** Requested names that are not in `allowed`, de-duplicated. */
|
|
25
|
+
function findDisallowedMcpServers(requested, allowed) {
|
|
26
|
+
const ok = new Set(allowed);
|
|
27
|
+
return [...new Set(requested)].filter(n => !ok.has(n));
|
|
28
|
+
}
|
package/dist/types.cjs.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/types.ts"],
|
|
4
|
-
"sourcesContent": ["// Re-export common types for backward compatibility\nexport {\n type UserInfo,\n type TeamMember,\n type TeamObjectPermission,\n type TeamInfo,\n type CreateTeamResponse,\n type VirtualKey,\n type ModelInfo,\n type UsageModelBreakdown,\n type UsageKeyBreakdown,\n type UsageDailyPoint,\n type UsageDailyModelPoint,\n type UsageMetrics,\n type TeamMemberUsage,\n type TeamMemberUsageRow,\n type GenerateKeyRequest,\n type UpdateKeyRequest,\n type GenerateKeyResponse,\n type AuditLogEntry,\n type PaginatedAuditLogs,\n type SpendLogEntry,\n type SpendLogsParams,\n type PaginatedSpendLogs,\n} from '@acarmisc/backstage-plugin-litellm-common';\n\nexport interface CreateTeamRequest {\n team_alias: string;\n models?: string[];\n max_budget?: number;\n budget_duration?: string;\n tpm_limit?: number;\n rpm_limit?: number;\n metadata?: Record<string, unknown>;\n object_permission?: {\n vector_stores?: string[];\n mcp_servers?: string[];\n mcp_access_groups?: string[];\n };\n}\n\nexport interface UpdateTeamRequest extends Partial<CreateTeamRequest> {\n team_id: string;\n blocked?: boolean;\n}\n\n/**\n * Shape of a single entry inside LiteLLM's `/user/info` `keys` array.\n * Differs from VirtualKey: uses `expires` (not `expires_at`), exposes\n * both a hashed `token` and a masked `key_name`, and fields are nullable\n * rather than optional.\n */\nexport interface LiteLLMUserKey {\n token: string;\n key_name?: string;\n key_alias?: string | null;\n spend?: number;\n expires?: string | null;\n models?: string[];\n tpm_limit?: number | null;\n rpm_limit?: number | null;\n max_budget?: number | null;\n budget_duration?: string | null;\n user_id?: string | null;\n team_id?: string | null;\n created_at: string;\n blocked?: boolean | null;\n /** Arbitrary metadata stored on the key record. */\n metadata?: Record<string, unknown> | null;\n}\n\nexport interface DeleteKeyRequest {\n keys: string[];\n}\n\nexport interface LiteLLMConfig {\n baseUrl: string;\n masterKey: string;\n}\n\nexport interface ProvisioningDefaults {\n maxBudget: number;\n budgetDuration: string;\n models: string[];\n teams: string[];\n tpmLimit?: number;\n rpmLimit?: number;\n /**\n * LiteLLM user role applied on /user/new. Defaults to \"internal_user\"\n * which grants self-service Create/Delete/View on the user's own keys.\n * Valid values: proxy_admin, proxy_admin_viewer, internal_user,\n * internal_user_viewer, team.\n */\n userRole?: string;\n metadata: Record<string, string>;\n}\n\nexport interface RoleConfig {\n group: string;\n maxBudget?: number;\n budgetDuration?: string;\n models?: string[];\n teams?: string[];\n tpmLimit?: number;\n rpmLimit?: number;\n userRole?: string;\n metadata?: Record<string, string>;\n}\n\nexport interface CreateUserRequest {\n user_id: string;\n user_email?: string;\n user_alias?: string;\n user_role?: string;\n max_budget?: number;\n budget_duration?: string;\n models?: string[];\n teams?: string[];\n tpm_limit?: number;\n rpm_limit?: number;\n metadata?: Record<string, string>;\n auto_create_key?: boolean;\n}\n\nexport interface CreateUserResponse {\n user_id: string;\n user_email?: string;\n max_budget?: number;\n models?: string[];\n teams?: string[];\n}\n\n\nexport interface AuditLogsParams {\n page?: number;\n page_size?: number;\n start_date?: string;\n end_date?: string;\n action?: string;\n table_name?: string;\n changed_by?: string;\n sort_by?: string;\n sort_order?: 'asc' | 'desc';\n}\n"],
|
|
4
|
+
"sourcesContent": ["// Re-export common types for backward compatibility\nexport {\n type UserInfo,\n type TeamMember,\n type TeamObjectPermission,\n type TeamInfo,\n type CreateTeamResponse,\n type VirtualKey,\n type ModelInfo,\n type UsageModelBreakdown,\n type UsageKeyBreakdown,\n type UsageDailyPoint,\n type UsageDailyModelPoint,\n type UsageMetrics,\n type TeamMemberUsage,\n type TeamMemberUsageRow,\n type GenerateKeyRequest,\n type UpdateKeyRequest,\n type GenerateKeyResponse,\n type AuditLogEntry,\n type PaginatedAuditLogs,\n type SpendLogEntry,\n type SpendLogsParams,\n type PaginatedSpendLogs,\n type QualityByModel,\n type QualityScore,\n type QualitySummary,\n type QualityResponse,\n} from '@acarmisc/backstage-plugin-litellm-common';\n\nexport interface CreateTeamRequest {\n team_alias: string;\n models?: string[];\n max_budget?: number;\n budget_duration?: string;\n tpm_limit?: number;\n rpm_limit?: number;\n metadata?: Record<string, unknown>;\n object_permission?: {\n vector_stores?: string[];\n mcp_servers?: string[];\n mcp_access_groups?: string[];\n };\n}\n\nexport interface UpdateTeamRequest extends Partial<CreateTeamRequest> {\n team_id: string;\n blocked?: boolean;\n}\n\n/**\n * Shape of a single entry inside LiteLLM's `/user/info` `keys` array.\n * Differs from VirtualKey: uses `expires` (not `expires_at`), exposes\n * both a hashed `token` and a masked `key_name`, and fields are nullable\n * rather than optional.\n */\nexport interface LiteLLMUserKey {\n token: string;\n key_name?: string;\n key_alias?: string | null;\n spend?: number;\n expires?: string | null;\n models?: string[];\n tpm_limit?: number | null;\n rpm_limit?: number | null;\n max_budget?: number | null;\n budget_duration?: string | null;\n user_id?: string | null;\n team_id?: string | null;\n created_at: string;\n blocked?: boolean | null;\n /** Arbitrary metadata stored on the key record. */\n metadata?: Record<string, unknown> | null;\n}\n\nexport interface DeleteKeyRequest {\n keys: string[];\n}\n\nexport interface LiteLLMConfig {\n baseUrl: string;\n masterKey: string;\n}\n\nexport interface ProvisioningDefaults {\n maxBudget: number;\n budgetDuration: string;\n models: string[];\n teams: string[];\n tpmLimit?: number;\n rpmLimit?: number;\n /**\n * LiteLLM user role applied on /user/new. Defaults to \"internal_user\"\n * which grants self-service Create/Delete/View on the user's own keys.\n * Valid values: proxy_admin, proxy_admin_viewer, internal_user,\n * internal_user_viewer, team.\n */\n userRole?: string;\n metadata: Record<string, string>;\n}\n\nexport interface RoleConfig {\n group: string;\n maxBudget?: number;\n budgetDuration?: string;\n models?: string[];\n teams?: string[];\n tpmLimit?: number;\n rpmLimit?: number;\n userRole?: string;\n metadata?: Record<string, string>;\n}\n\nexport interface CreateUserRequest {\n user_id: string;\n user_email?: string;\n user_alias?: string;\n user_role?: string;\n max_budget?: number;\n budget_duration?: string;\n models?: string[];\n teams?: string[];\n tpm_limit?: number;\n rpm_limit?: number;\n metadata?: Record<string, string>;\n auto_create_key?: boolean;\n}\n\nexport interface CreateUserResponse {\n user_id: string;\n user_email?: string;\n max_budget?: number;\n models?: string[];\n teams?: string[];\n}\n\n\nexport interface AuditLogsParams {\n page?: number;\n page_size?: number;\n start_date?: string;\n end_date?: string;\n action?: string;\n table_name?: string;\n changed_by?: string;\n sort_by?: string;\n sort_order?: 'asc' | 'desc';\n}\n"],
|
|
5
5
|
"mappings": ";;;;;;;;;;;;;;;;AAAA;AAAA;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { type UserInfo, type TeamMember, type TeamObjectPermission, type TeamInfo, type CreateTeamResponse, type VirtualKey, type ModelInfo, type UsageModelBreakdown, type UsageKeyBreakdown, type UsageDailyPoint, type UsageDailyModelPoint, type UsageMetrics, type TeamMemberUsage, type TeamMemberUsageRow, type GenerateKeyRequest, type UpdateKeyRequest, type GenerateKeyResponse, type AuditLogEntry, type PaginatedAuditLogs, type SpendLogEntry, type SpendLogsParams, type PaginatedSpendLogs, } from '@acarmisc/backstage-plugin-litellm-common';
|
|
1
|
+
export { type UserInfo, type TeamMember, type TeamObjectPermission, type TeamInfo, type CreateTeamResponse, type VirtualKey, type ModelInfo, type UsageModelBreakdown, type UsageKeyBreakdown, type UsageDailyPoint, type UsageDailyModelPoint, type UsageMetrics, type TeamMemberUsage, type TeamMemberUsageRow, type GenerateKeyRequest, type UpdateKeyRequest, type GenerateKeyResponse, type AuditLogEntry, type PaginatedAuditLogs, type SpendLogEntry, type SpendLogsParams, type PaginatedSpendLogs, type QualityByModel, type QualityScore, type QualitySummary, type QualityResponse, } from '@acarmisc/backstage-plugin-litellm-common';
|
|
2
2
|
export interface CreateTeamRequest {
|
|
3
3
|
team_alias: string;
|
|
4
4
|
models?: string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acarmisc/backstage-plugin-litellm-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "The Backstage backend plugin for LiteLLM governance",
|
|
5
5
|
"backstage": {
|
|
6
6
|
"role": "backend-plugin",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"prepack": "npm run build"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@acarmisc/backstage-plugin-litellm-common": "0.
|
|
38
|
+
"@acarmisc/backstage-plugin-litellm-common": "0.5.0",
|
|
39
39
|
"@backstage/backend-plugin-api": "^1.9.1",
|
|
40
40
|
"@backstage/catalog-client": "^1.9.0",
|
|
41
41
|
"@backstage/config": "^1.3.0",
|