@acarmisc/backstage-plugin-litellm-backend 0.15.0 → 0.16.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 +115 -22
- package/dist/bridge.d.ts +23 -16
- package/dist/bridge.js +51 -11
- package/dist/client.d.ts +7 -2
- package/dist/client.js +35 -9
- package/dist/errors.d.ts +64 -0
- package/dist/errors.js +205 -0
- package/dist/http/respondTeam.d.ts +15 -0
- package/dist/http/respondTeam.js +27 -0
- package/dist/http/teamFetch.d.ts +14 -0
- package/dist/http/teamFetch.js +44 -0
- package/dist/index.cjs.js +4248 -3375
- package/dist/index.cjs.js.map +4 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +15 -13
- package/dist/openapi.js +17 -0
- package/dist/plugin.js +2 -2
- package/dist/provisioning.d.ts +17 -11
- package/dist/provisioning.js +36 -22
- package/dist/router.d.ts +3 -4
- package/dist/router.js +188 -1349
- package/dist/routes/bridge.d.ts +8 -0
- package/dist/routes/bridge.js +130 -0
- package/dist/routes/config.d.ts +7 -0
- package/dist/routes/config.js +89 -0
- package/dist/routes/context.d.ts +55 -0
- package/dist/routes/context.js +2 -0
- package/dist/routes/keys.d.ts +8 -0
- package/dist/routes/keys.js +471 -0
- package/dist/routes/middleware/withUser.d.ts +18 -0
- package/dist/routes/middleware/withUser.js +41 -0
- package/dist/routes/objectPermissions.d.ts +3 -0
- package/dist/routes/objectPermissions.js +196 -0
- package/dist/routes/opencode.d.ts +3 -0
- package/dist/routes/opencode.js +239 -0
- package/dist/routes/teamUsage.d.ts +3 -0
- package/dist/routes/teamUsage.js +73 -0
- package/dist/routes/teams.d.ts +3 -0
- package/dist/routes/teams.js +367 -0
- package/dist/services/keyService.d.ts +61 -0
- package/dist/services/keyService.js +179 -0
- package/dist/services/modelAccess.d.ts +9 -0
- package/dist/services/modelAccess.js +30 -0
- package/dist/services/userInfoCache.d.ts +65 -0
- package/dist/services/userInfoCache.js +147 -0
- package/dist/teamAdmin.d.ts +2 -2
- package/dist/teamBudgetVisibility.d.ts +2 -1
- package/dist/teamBudgetVisibility.js +5 -5
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +8 -197
- package/package.json +6 -3
- package/dist/permissions.d.ts +0 -11
- package/dist/permissions.js +0 -57
package/dist/errors.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Central error handling and sanitization for the LiteLLM backend.
|
|
4
|
+
*
|
|
5
|
+
* Ensures that:
|
|
6
|
+
* - Upstream auth failures (401/403) don't log out Backstage sessions
|
|
7
|
+
* - Upstream 5xx errors are generic and don't leak implementation details
|
|
8
|
+
* - Upstream 4xx errors are sanitized and capped
|
|
9
|
+
* - Non-JSON error bodies are not embedded in responses
|
|
10
|
+
* - All error logging happens server-side only
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.isHttpishError = isHttpishError;
|
|
14
|
+
exports.errorMessage = errorMessage;
|
|
15
|
+
exports.sanitizeUpstreamMessage = sanitizeUpstreamMessage;
|
|
16
|
+
exports.toHttpError = toHttpError;
|
|
17
|
+
exports.sendError = sendError;
|
|
18
|
+
const client_1 = require("./client");
|
|
19
|
+
/** Regex to find HTML-like tags and entities. */
|
|
20
|
+
const HTML_TAG_PATTERN = /<[^>]*>|&[a-z]+;/gi;
|
|
21
|
+
/**
|
|
22
|
+
* Type guard: narrows unknown to {status: number; body: Record<string, unknown>}.
|
|
23
|
+
* Used to detect custom errors that have status and body properties.
|
|
24
|
+
*/
|
|
25
|
+
function isHttpishError(error) {
|
|
26
|
+
return (error !== null &&
|
|
27
|
+
typeof error === 'object' &&
|
|
28
|
+
'status' in error &&
|
|
29
|
+
'body' in error &&
|
|
30
|
+
typeof error.status === 'number' && // any: property access after guard check
|
|
31
|
+
typeof error.body === 'object' // any: property access after guard check
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Safely extract a message string from any error type.
|
|
36
|
+
* Returns empty string if no message can be extracted.
|
|
37
|
+
*/
|
|
38
|
+
function errorMessage(error) {
|
|
39
|
+
if (error instanceof Error)
|
|
40
|
+
return error.message;
|
|
41
|
+
if (typeof error === 'string')
|
|
42
|
+
return error;
|
|
43
|
+
if (error && typeof error === 'object' && 'message' in error) {
|
|
44
|
+
const msg = error.message; // any: message type varies, guarded below
|
|
45
|
+
if (typeof msg === 'string')
|
|
46
|
+
return msg;
|
|
47
|
+
}
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Sanitizes a message by:
|
|
52
|
+
* - Stripping HTML tags
|
|
53
|
+
* - Collapsing consecutive whitespace
|
|
54
|
+
* - Truncating to 500 characters
|
|
55
|
+
*
|
|
56
|
+
* @param msg The raw message (may contain HTML, newlines, etc.)
|
|
57
|
+
* @returns A safe, printable message
|
|
58
|
+
*/
|
|
59
|
+
function sanitizeUpstreamMessage(msg) {
|
|
60
|
+
if (!msg || typeof msg !== 'string')
|
|
61
|
+
return '';
|
|
62
|
+
return msg
|
|
63
|
+
.replace(HTML_TAG_PATTERN, '') // Strip HTML tags and entities
|
|
64
|
+
.replace(/\s+/g, ' ') // Collapse whitespace
|
|
65
|
+
.trim()
|
|
66
|
+
.slice(0, 500);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Maps any error type to an HTTP response shape {status, body}.
|
|
70
|
+
*
|
|
71
|
+
* Behaviour:
|
|
72
|
+
* - LiteLLMUpstreamError 401/403 → 502 generic (prevent session logout)
|
|
73
|
+
* - LiteLLMUpstreamError 5xx or network → 502 generic
|
|
74
|
+
* - LiteLLMUpstreamError 4xx (other) → 400 with sanitized message + param if present
|
|
75
|
+
* - KeyServiceError / ProvisioningError / objects with status+body → pass through
|
|
76
|
+
* - Backstage-style errors (name: InputError/NotAllowedError/NotFoundError/ConflictError) → map to 400/403/404/409
|
|
77
|
+
* - Anything else → 500 (message logged server-side, not returned)
|
|
78
|
+
*
|
|
79
|
+
* @returns {status, body, logLevel} for HTTP response; undefined _logMessage is only present for 500s
|
|
80
|
+
*/
|
|
81
|
+
function toHttpError(error) {
|
|
82
|
+
// KeyServiceError and ProvisioningError have status + body fields — pass through.
|
|
83
|
+
if (isHttpishError(error)) {
|
|
84
|
+
return {
|
|
85
|
+
status: error.status,
|
|
86
|
+
body: error.body,
|
|
87
|
+
logLevel: 'warn',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// LiteLLMUpstreamError
|
|
91
|
+
if (error instanceof client_1.LiteLLMUpstreamError) {
|
|
92
|
+
// 401/403 from upstream → don't expose; map to 502 so Backstage session isn't logged out
|
|
93
|
+
if (error.status === 401 || error.status === 403) {
|
|
94
|
+
return {
|
|
95
|
+
status: 502,
|
|
96
|
+
body: { error: 'LiteLLM rejected the request' },
|
|
97
|
+
logLevel: 'warn',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// 5xx or other network errors → generic 502
|
|
101
|
+
if (error.status >= 500 || error.status < 100) {
|
|
102
|
+
return {
|
|
103
|
+
status: 502,
|
|
104
|
+
body: { error: 'LiteLLM is unavailable' },
|
|
105
|
+
logLevel: 'error',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// 4xx (other than 401/403) → sanitized message and param. Not-found and
|
|
109
|
+
// conflict keep their meaning; everything else collapses to 400.
|
|
110
|
+
return {
|
|
111
|
+
status: error.status === 404 || error.status === 409 ? error.status : 400,
|
|
112
|
+
body: {
|
|
113
|
+
error: sanitizeUpstreamMessage(error.message),
|
|
114
|
+
...(error.param ? { param: error.param } : {}),
|
|
115
|
+
},
|
|
116
|
+
logLevel: 'warn',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// Backstage-style errors (name field)
|
|
120
|
+
if (error &&
|
|
121
|
+
typeof error === 'object' &&
|
|
122
|
+
'name' in error &&
|
|
123
|
+
'message' in error) {
|
|
124
|
+
const name = error.name; // any: error name type varies, guarded by 'in' checks
|
|
125
|
+
const message = String(error.message); // any: error message type varies, guarded by 'in' checks
|
|
126
|
+
if (name === 'InputError') {
|
|
127
|
+
return {
|
|
128
|
+
status: 400,
|
|
129
|
+
body: { error: message },
|
|
130
|
+
logLevel: 'warn',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (name === 'NotAllowedError') {
|
|
134
|
+
return {
|
|
135
|
+
status: 403,
|
|
136
|
+
body: { error: message },
|
|
137
|
+
logLevel: 'warn',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (name === 'NotFoundError') {
|
|
141
|
+
return {
|
|
142
|
+
status: 404,
|
|
143
|
+
body: { error: message },
|
|
144
|
+
logLevel: 'warn',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (name === 'ConflictError') {
|
|
148
|
+
return {
|
|
149
|
+
status: 409,
|
|
150
|
+
body: { error: message },
|
|
151
|
+
logLevel: 'warn',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Network-level failures reaching LiteLLM (DNS, refused, reset, timeout/abort):
|
|
156
|
+
// an upstream problem, not an internal one.
|
|
157
|
+
if (error instanceof Error) {
|
|
158
|
+
const code = error.code;
|
|
159
|
+
const cause = error.cause;
|
|
160
|
+
const networkCodes = ['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'];
|
|
161
|
+
if (error.name === 'AbortError' ||
|
|
162
|
+
error.name === 'TimeoutError' ||
|
|
163
|
+
(error.name === 'TypeError' && error.message === 'fetch failed') ||
|
|
164
|
+
(typeof code === 'string' && networkCodes.includes(code)) ||
|
|
165
|
+
(typeof cause?.code === 'string' && networkCodes.includes(cause.code))) {
|
|
166
|
+
return {
|
|
167
|
+
status: 502,
|
|
168
|
+
body: { error: 'LiteLLM is unavailable' },
|
|
169
|
+
logLevel: 'error',
|
|
170
|
+
_logMessage: error.message,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Fallback: 500 with no message in response
|
|
175
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
176
|
+
return {
|
|
177
|
+
status: 500,
|
|
178
|
+
body: { error: 'Internal error' },
|
|
179
|
+
logLevel: 'error',
|
|
180
|
+
_logMessage: message,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Express error handler helper. Logs the real error server-side and responds
|
|
185
|
+
* via toHttpError.
|
|
186
|
+
*
|
|
187
|
+
* @param res Express Response object
|
|
188
|
+
* @param error The caught error (any type)
|
|
189
|
+
* @param logger Logger instance with error/warn/info/debug methods
|
|
190
|
+
* @param context Contextual label for the log entry (e.g. 'fetch user info')
|
|
191
|
+
*/
|
|
192
|
+
function sendError(res, error, logger, context) {
|
|
193
|
+
const { status, body, logLevel, _logMessage } = toHttpError(error);
|
|
194
|
+
const realMessage = _logMessage || (error instanceof Error ? error.message : String(error));
|
|
195
|
+
if (logLevel === 'error') {
|
|
196
|
+
logger.error(`Failed to ${context}`, error instanceof Error ? error : new Error(String(error)));
|
|
197
|
+
}
|
|
198
|
+
else if (logLevel === 'warn') {
|
|
199
|
+
logger.warn(`${context}: ${realMessage}`);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
logger.info(`${context}: ${realMessage}`);
|
|
203
|
+
}
|
|
204
|
+
res.status(status).json(body);
|
|
205
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Response } from 'express';
|
|
2
|
+
import { TeamInfo } from '../types';
|
|
3
|
+
import type { RouterContext } from '../routes/context';
|
|
4
|
+
/**
|
|
5
|
+
* Helper to respond with a team record, conditionally redacting its budget.
|
|
6
|
+
*
|
|
7
|
+
* `caller` indicates the role of the requester:
|
|
8
|
+
* - 'member': person viewing from GET /teams (hides if hideTeamBudgetForMembers)
|
|
9
|
+
* - 'manager': team admin viewing from GET /teams/managed or a write response (hides if hideTeamBudgetForManagers)
|
|
10
|
+
*/
|
|
11
|
+
export declare function respondTeam(res: Response, team: TeamInfo, ctx: RouterContext, caller: 'member' | 'manager'): void;
|
|
12
|
+
/**
|
|
13
|
+
* Helper to respond with a list of teams, conditionally redacting budgets.
|
|
14
|
+
*/
|
|
15
|
+
export declare function respondTeamList(res: Response, teams: TeamInfo[], ctx: RouterContext, caller: 'member' | 'manager'): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.respondTeam = respondTeam;
|
|
4
|
+
exports.respondTeamList = respondTeamList;
|
|
5
|
+
const teamBudgetVisibility_1 = require("../teamBudgetVisibility");
|
|
6
|
+
/**
|
|
7
|
+
* Helper to respond with a team record, conditionally redacting its budget.
|
|
8
|
+
*
|
|
9
|
+
* `caller` indicates the role of the requester:
|
|
10
|
+
* - 'member': person viewing from GET /teams (hides if hideTeamBudgetForMembers)
|
|
11
|
+
* - 'manager': team admin viewing from GET /teams/managed or a write response (hides if hideTeamBudgetForManagers)
|
|
12
|
+
*/
|
|
13
|
+
function respondTeam(res, team, ctx, caller) {
|
|
14
|
+
const shouldHide = caller === 'member'
|
|
15
|
+
? ctx.teamBudgetVisibility.hideTeamBudgetForMembers
|
|
16
|
+
: ctx.teamBudgetVisibility.hideTeamBudgetForManagers;
|
|
17
|
+
res.json(shouldHide ? (0, teamBudgetVisibility_1.redactTeamBudget)(team) : team);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Helper to respond with a list of teams, conditionally redacting budgets.
|
|
21
|
+
*/
|
|
22
|
+
function respondTeamList(res, teams, ctx, caller) {
|
|
23
|
+
const shouldHide = caller === 'member'
|
|
24
|
+
? ctx.teamBudgetVisibility.hideTeamBudgetForMembers
|
|
25
|
+
: ctx.teamBudgetVisibility.hideTeamBudgetForManagers;
|
|
26
|
+
res.json(shouldHide ? teams.map(teamBudgetVisibility_1.redactTeamBudget) : teams);
|
|
27
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const teamCreateInFlight: Map<string, Promise<unknown>>;
|
|
2
|
+
/**
|
|
3
|
+
* Retries a LiteLLM team-info fetch with the given backoff schedule.
|
|
4
|
+
*
|
|
5
|
+
* LiteLLM proxies are commonly run behind multiple replicas; a request can
|
|
6
|
+
* land on a replica that hasn't yet caught up with a very recent write
|
|
7
|
+
* (team create/update, membership change), or on one that is transiently
|
|
8
|
+
* unhealthy. Both surface as a failed `getTeamInfo` call that would
|
|
9
|
+
* otherwise be swallowed (e.g. GET /teams silently drops the team from the
|
|
10
|
+
* response) or bubble up as a spurious 500/404 right after a write this
|
|
11
|
+
* same request just made. A short retry smooths over that window without
|
|
12
|
+
* masking a genuine, persistent failure.
|
|
13
|
+
*/
|
|
14
|
+
export declare function withTeamFetchRetry<T>(fn: () => Promise<T>, delaysMs?: number[]): Promise<T>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.teamCreateInFlight = void 0;
|
|
4
|
+
exports.withTeamFetchRetry = withTeamFetchRetry;
|
|
5
|
+
const client_1 = require("../client");
|
|
6
|
+
exports.teamCreateInFlight = new Map();
|
|
7
|
+
/** Backoff schedule for `withTeamFetchRetry` — 3 retries over ~1.3s total. */
|
|
8
|
+
const TEAM_FETCH_RETRY_DELAYS_MS = [100, 300, 900];
|
|
9
|
+
/**
|
|
10
|
+
* Only 5xx (and network/timeout) failures are treated as transient — a 4xx
|
|
11
|
+
* like "team not found" or "forbidden" is a deterministic answer from the
|
|
12
|
+
* upstream and retrying it would just add latency for the same result.
|
|
13
|
+
*/
|
|
14
|
+
function isRetryableTeamFetchError(err) {
|
|
15
|
+
if (err instanceof client_1.LiteLLMUpstreamError) {
|
|
16
|
+
return err.status >= 500;
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Retries a LiteLLM team-info fetch with the given backoff schedule.
|
|
22
|
+
*
|
|
23
|
+
* LiteLLM proxies are commonly run behind multiple replicas; a request can
|
|
24
|
+
* land on a replica that hasn't yet caught up with a very recent write
|
|
25
|
+
* (team create/update, membership change), or on one that is transiently
|
|
26
|
+
* unhealthy. Both surface as a failed `getTeamInfo` call that would
|
|
27
|
+
* otherwise be swallowed (e.g. GET /teams silently drops the team from the
|
|
28
|
+
* response) or bubble up as a spurious 500/404 right after a write this
|
|
29
|
+
* same request just made. A short retry smooths over that window without
|
|
30
|
+
* masking a genuine, persistent failure.
|
|
31
|
+
*/
|
|
32
|
+
async function withTeamFetchRetry(fn, delaysMs = TEAM_FETCH_RETRY_DELAYS_MS) {
|
|
33
|
+
for (let attempt = 0;; attempt++) {
|
|
34
|
+
try {
|
|
35
|
+
return await fn();
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (attempt >= delaysMs.length || !isRetryableTeamFetchError(err)) {
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
await new Promise(resolve => setTimeout(resolve, delaysMs[attempt]));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|