@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.
Files changed (53) hide show
  1. package/config.d.ts +115 -22
  2. package/dist/bridge.d.ts +23 -16
  3. package/dist/bridge.js +51 -11
  4. package/dist/client.d.ts +7 -2
  5. package/dist/client.js +35 -9
  6. package/dist/errors.d.ts +64 -0
  7. package/dist/errors.js +205 -0
  8. package/dist/http/respondTeam.d.ts +15 -0
  9. package/dist/http/respondTeam.js +27 -0
  10. package/dist/http/teamFetch.d.ts +14 -0
  11. package/dist/http/teamFetch.js +44 -0
  12. package/dist/index.cjs.js +4248 -3375
  13. package/dist/index.cjs.js.map +4 -4
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +15 -13
  16. package/dist/openapi.js +17 -0
  17. package/dist/plugin.js +2 -2
  18. package/dist/provisioning.d.ts +17 -11
  19. package/dist/provisioning.js +36 -22
  20. package/dist/router.d.ts +3 -4
  21. package/dist/router.js +188 -1349
  22. package/dist/routes/bridge.d.ts +8 -0
  23. package/dist/routes/bridge.js +130 -0
  24. package/dist/routes/config.d.ts +7 -0
  25. package/dist/routes/config.js +89 -0
  26. package/dist/routes/context.d.ts +55 -0
  27. package/dist/routes/context.js +2 -0
  28. package/dist/routes/keys.d.ts +8 -0
  29. package/dist/routes/keys.js +471 -0
  30. package/dist/routes/middleware/withUser.d.ts +18 -0
  31. package/dist/routes/middleware/withUser.js +41 -0
  32. package/dist/routes/objectPermissions.d.ts +3 -0
  33. package/dist/routes/objectPermissions.js +196 -0
  34. package/dist/routes/opencode.d.ts +3 -0
  35. package/dist/routes/opencode.js +239 -0
  36. package/dist/routes/teamUsage.d.ts +3 -0
  37. package/dist/routes/teamUsage.js +73 -0
  38. package/dist/routes/teams.d.ts +3 -0
  39. package/dist/routes/teams.js +367 -0
  40. package/dist/services/keyService.d.ts +61 -0
  41. package/dist/services/keyService.js +179 -0
  42. package/dist/services/modelAccess.d.ts +9 -0
  43. package/dist/services/modelAccess.js +30 -0
  44. package/dist/services/userInfoCache.d.ts +65 -0
  45. package/dist/services/userInfoCache.js +147 -0
  46. package/dist/teamAdmin.d.ts +2 -2
  47. package/dist/teamBudgetVisibility.d.ts +2 -1
  48. package/dist/teamBudgetVisibility.js +5 -5
  49. package/dist/types.cjs.js.map +1 -1
  50. package/dist/types.d.ts +8 -197
  51. package/package.json +6 -3
  52. package/dist/permissions.d.ts +0 -11
  53. 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
+ }