@acarmisc/backstage-plugin-litellm-backend 0.14.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 +36 -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 +4249 -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 -195
- package/package.json +6 -3
- package/dist/permissions.d.ts +0 -11
- package/dist/permissions.js +0 -57
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { Config } from '@backstage/config';
|
|
3
|
+
import { TokenVerifier } from '../bridge';
|
|
4
|
+
import type { RouterContext } from './context';
|
|
5
|
+
export declare function registerBridgeRoutes(router: Router, ctx: RouterContext, bridgeOpts: {
|
|
6
|
+
config: Config;
|
|
7
|
+
tokenVerifier?: TokenVerifier;
|
|
8
|
+
}): void;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerBridgeRoutes = registerBridgeRoutes;
|
|
4
|
+
const provisioning_1 = require("../provisioning");
|
|
5
|
+
const bridge_1 = require("../bridge");
|
|
6
|
+
const keyService_1 = require("../services/keyService");
|
|
7
|
+
const errors_1 = require("../errors");
|
|
8
|
+
const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
|
|
9
|
+
function registerBridgeRoutes(router, ctx, bridgeOpts) {
|
|
10
|
+
const { client, logger, userIdDomain, provisioningEnabled, provisioningDefaults, roleConfigs, allowUnlimitedBudget, teamRequired, keyValidationConfig, } = ctx;
|
|
11
|
+
const { config } = bridgeOpts;
|
|
12
|
+
const generateKeyInputSchema = (0, backstage_plugin_litellm_common_1.createGenerateKeyInputSchema)(ctx.keyValidationConfig);
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Bridge endpoints (CLI / Abby)
|
|
15
|
+
//
|
|
16
|
+
// Authenticated by a Keycloak access token (JWKS-verified), NOT by Backstage's
|
|
17
|
+
// own auth. Lets CLI clients list/mint virtual keys without holding the master
|
|
18
|
+
// key. Gated by litellm.bridge.enabled; the verifier needs litellm.bridge.issuer.
|
|
19
|
+
const bridgeCfg = (0, bridge_1.readBridgeConfig)(config);
|
|
20
|
+
let tokenVerifier = bridgeOpts.tokenVerifier;
|
|
21
|
+
if (bridgeCfg.enabled && !tokenVerifier) {
|
|
22
|
+
try {
|
|
23
|
+
tokenVerifier = (0, bridge_1.newDefaultVerifier)(bridgeCfg);
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
logger.error(`LiteLLM bridge enabled but misconfigured: ${e.message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (bridgeCfg.enabled && tokenVerifier) {
|
|
30
|
+
// Capture tokenVerifier in the closure to avoid non-null assertion
|
|
31
|
+
const verifier = tokenVerifier;
|
|
32
|
+
const requireClaims = async (req) => {
|
|
33
|
+
const header = req.headers.authorization ?? '';
|
|
34
|
+
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
|
|
35
|
+
if (!token)
|
|
36
|
+
throw new bridge_1.BridgeAuthError('missing Bearer token');
|
|
37
|
+
return verifier.verify(token);
|
|
38
|
+
};
|
|
39
|
+
const handleBridgeError = (error, res) => {
|
|
40
|
+
if (error instanceof bridge_1.BridgeAuthError) {
|
|
41
|
+
// Log the jose error details server-side, send opaque 401 to client
|
|
42
|
+
logger.warn(`Bridge auth error: ${error.message}`);
|
|
43
|
+
res.status(401).json({ error: 'unauthorized' });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (error instanceof bridge_1.BridgeIdentityError) {
|
|
47
|
+
res.status(403).json({ error: 'forbidden', detail: error.message });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (error instanceof keyService_1.KeyServiceError) {
|
|
51
|
+
res.status(error.status).json(error.body);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (error instanceof provisioning_1.ProvisioningError) {
|
|
55
|
+
res.status(error.status).json(error.body);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
(0, errors_1.sendError)(res, error, logger, 'bridge request');
|
|
59
|
+
};
|
|
60
|
+
router.get('/bridge/health', (_req, res) => {
|
|
61
|
+
res.json({ status: 'ok', bridge: true, clientId: bridgeCfg.clientId });
|
|
62
|
+
});
|
|
63
|
+
router.get('/bridge/keys', async (req, res) => {
|
|
64
|
+
try {
|
|
65
|
+
const claims = await requireClaims(req);
|
|
66
|
+
const keys = await (0, bridge_1.bridgeListKeys)(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain);
|
|
67
|
+
res.json(keys);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
handleBridgeError(error, res);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
router.post('/bridge/keys', async (req, res) => {
|
|
74
|
+
try {
|
|
75
|
+
const claims = await requireClaims(req);
|
|
76
|
+
// ── Parse & validate input with strict schema ────────────────────────
|
|
77
|
+
const parseResult = generateKeyInputSchema.safeParse(req.body);
|
|
78
|
+
if (!parseResult.success) {
|
|
79
|
+
const errorMessages = parseResult.error.errors
|
|
80
|
+
.map(e => `${e.path.join('.')}: ${e.message}`)
|
|
81
|
+
.join('; ');
|
|
82
|
+
res.status(400).json({
|
|
83
|
+
error: 'Invalid request body',
|
|
84
|
+
details: errorMessages,
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const input = parseResult.data;
|
|
89
|
+
// ── Resolve user identity from verified claims ───────────────────────
|
|
90
|
+
const userId = (0, bridge_1.resolveBridgeUserId)(claims, userIdDomain);
|
|
91
|
+
// ── Create key via unified service ──────────────────────────────────
|
|
92
|
+
const keyCreateCtx = {
|
|
93
|
+
client,
|
|
94
|
+
config: {
|
|
95
|
+
allowUnlimitedBudget,
|
|
96
|
+
teamRequired,
|
|
97
|
+
},
|
|
98
|
+
// Bridge does not have Backstage catalogClient/auth
|
|
99
|
+
// so provisioning from JWT claims only (no Backstage profile enrichment)
|
|
100
|
+
catalogClient: undefined,
|
|
101
|
+
auth: undefined,
|
|
102
|
+
logger,
|
|
103
|
+
keyValidationConfig,
|
|
104
|
+
provisioningEnabled,
|
|
105
|
+
provisioningDefaults,
|
|
106
|
+
roleConfigs,
|
|
107
|
+
userIdDomain,
|
|
108
|
+
};
|
|
109
|
+
const result = await (0, keyService_1.createKeyForUser)({ userId }, input, keyCreateCtx);
|
|
110
|
+
res.json(result);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
handleBridgeError(error, res);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
router.get('/bridge/models', async (req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
await requireClaims(req); // authenticate only
|
|
119
|
+
const models = await client.listModels();
|
|
120
|
+
res.json(models);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
handleBridgeError(error, res);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else if (bridgeCfg.enabled) {
|
|
128
|
+
logger.warn('litellm.bridge.enabled is true but no verifier could be built — bridge endpoints not mounted');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import type { RouterContext } from './context';
|
|
3
|
+
/**
|
|
4
|
+
* Register config-related routes (/health, /config, /openapi.json, /provisioning/preview).
|
|
5
|
+
* These routes expose configuration and metadata for the LiteLLM plugin.
|
|
6
|
+
*/
|
|
7
|
+
export declare function registerConfigRoutes(router: Router, ctx: RouterContext): void;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerConfigRoutes = registerConfigRoutes;
|
|
4
|
+
const provisioning_1 = require("../provisioning");
|
|
5
|
+
const openapi_1 = require("../openapi");
|
|
6
|
+
const errors_1 = require("../errors");
|
|
7
|
+
/**
|
|
8
|
+
* Register config-related routes (/health, /config, /openapi.json, /provisioning/preview).
|
|
9
|
+
* These routes expose configuration and metadata for the LiteLLM plugin.
|
|
10
|
+
*/
|
|
11
|
+
function registerConfigRoutes(router, ctx) {
|
|
12
|
+
router.get('/health', (_req, res) => {
|
|
13
|
+
res.json({ status: 'ok', provisioning: ctx.provisioningEnabled });
|
|
14
|
+
});
|
|
15
|
+
// Exposes the public LiteLLM proxy URL so the frontend can build
|
|
16
|
+
// ready-to-paste curl / OpenAI-SDK snippets for freshly generated keys.
|
|
17
|
+
router.get('/config', (_req, res) => {
|
|
18
|
+
res.json({
|
|
19
|
+
baseUrl: ctx.publicBaseUrl,
|
|
20
|
+
supportContact: ctx.supportContact,
|
|
21
|
+
keyGeneration: { allowUnlimitedBudget: ctx.allowUnlimitedBudget, teamRequired: ctx.teamRequired },
|
|
22
|
+
keyActions: { allowOwnerResetSpend: ctx.allowOwnerResetSpend },
|
|
23
|
+
opencode: { enabled: ctx.opencodeCfg.enabled },
|
|
24
|
+
teamManagement: {
|
|
25
|
+
enabled: ctx.teamMgmtEnabled,
|
|
26
|
+
maxBudgetCeiling: ctx.teamAdminCfg.maxBudgetCeiling,
|
|
27
|
+
allowUnlimitedBudget: ctx.teamAdminCfg.allowUnlimitedBudget,
|
|
28
|
+
objectPermissionsEnabled: ctx.objectPermsEnabled,
|
|
29
|
+
},
|
|
30
|
+
display: {
|
|
31
|
+
hideTeamBudgetForMembers: ctx.teamBudgetVisibility.hideTeamBudgetForMembers,
|
|
32
|
+
hideTeamBudgetForManagers: ctx.teamBudgetVisibility.hideTeamBudgetForManagers,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
// Self-hosted OpenAPI 3.1 contract — lets integrators read a spec instead
|
|
37
|
+
// of router.ts. No swagger-ui dependency; serve the JSON and point external
|
|
38
|
+
// renderers (Stoplight, Swagger UI hosted elsewhere) at this endpoint.
|
|
39
|
+
router.get('/openapi.json', (_req, res) => {
|
|
40
|
+
res.json(openapi_1.openApiSpec);
|
|
41
|
+
});
|
|
42
|
+
// Provisioning dry-run: resolves which role a Backstage group maps to and
|
|
43
|
+
// echoes the effective defaults, collapsing the config → deploy → test loop
|
|
44
|
+
// into one request. Admin-gated by the audit group (same RBAC as /audit).
|
|
45
|
+
router.get('/provisioning/preview', async (req, res) => {
|
|
46
|
+
if (!ctx.auditGroup) {
|
|
47
|
+
res.status(403).json({ error: 'Preview is not configured (litellm.audit.group not set)' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, ctx.auth);
|
|
51
|
+
if (!tokenEntityRef) {
|
|
52
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const allowed = await (0, provisioning_1.isUserMemberOfGroup)(tokenEntityRef, ctx.auditGroup, ctx.catalogClient, ctx.auth, ctx.logger);
|
|
56
|
+
if (!allowed) {
|
|
57
|
+
res.status(403).json({ error: 'Access denied: not a member of the audit group' });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const group = req.query.group?.trim();
|
|
61
|
+
if (!group) {
|
|
62
|
+
res.status(400).json({ error: 'group query parameter is required (e.g. group=group:default/ai-platform)' });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!ctx.roleConfigs.length) {
|
|
66
|
+
res.json({
|
|
67
|
+
group,
|
|
68
|
+
matched_role: null,
|
|
69
|
+
effective_defaults: ctx.provisioningDefaults,
|
|
70
|
+
note: 'No litellm.provisioning.roles configured — every group receives the base defaults.',
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const matched = ctx.roleConfigs.find(rc => rc.group === group);
|
|
76
|
+
const effective = matched
|
|
77
|
+
? (0, provisioning_1.applyRoleOverrides)(ctx.provisioningDefaults, matched)
|
|
78
|
+
: ctx.provisioningDefaults;
|
|
79
|
+
res.json({
|
|
80
|
+
group,
|
|
81
|
+
matched_role: matched?.group ?? null,
|
|
82
|
+
effective_defaults: effective,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
(0, errors_1.sendError)(res, error, ctx.logger, 'resolve provisioning preview');
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Response, Request } from 'express';
|
|
2
|
+
import { AuthService, PermissionsService, LoggerService } from '@backstage/backend-plugin-api';
|
|
3
|
+
import { BasicPermission } from '@backstage/plugin-permission-common';
|
|
4
|
+
import { CatalogClient } from '@backstage/catalog-client';
|
|
5
|
+
import { LiteLLMClient } from '../client';
|
|
6
|
+
import { ProvisioningDefaults, RoleConfig, VirtualKey, TeamInfo } from '../types';
|
|
7
|
+
import { TeamAdminConfig } from '../teamAdmin';
|
|
8
|
+
import type { KeyValidationConfig } from '@acarmisc/backstage-plugin-litellm-common';
|
|
9
|
+
import { TeamBudgetVisibility } from '../teamBudgetVisibility';
|
|
10
|
+
import { OpencodeConfig } from '../opencode';
|
|
11
|
+
/**
|
|
12
|
+
* Shared runtime context for all route handlers. Holds config-derived
|
|
13
|
+
* constants, service clients, and helper functions needed by routes.
|
|
14
|
+
*/
|
|
15
|
+
export interface RouterContext {
|
|
16
|
+
client: LiteLLMClient;
|
|
17
|
+
catalogClient: CatalogClient;
|
|
18
|
+
auth: AuthService;
|
|
19
|
+
permissions: PermissionsService;
|
|
20
|
+
logger: LoggerService;
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
publicBaseUrl: string | null;
|
|
23
|
+
supportContact: string | undefined;
|
|
24
|
+
userIdDomain: string | undefined;
|
|
25
|
+
provisioningEnabled: boolean;
|
|
26
|
+
provisioningDefaults: ProvisioningDefaults;
|
|
27
|
+
roleConfigs: RoleConfig[];
|
|
28
|
+
auditGroup: string | undefined;
|
|
29
|
+
allowUnlimitedBudget: boolean;
|
|
30
|
+
teamRequired: boolean;
|
|
31
|
+
allowOwnerResetSpend: boolean;
|
|
32
|
+
keyValidationConfig: KeyValidationConfig;
|
|
33
|
+
teamMgmtEnabled: boolean;
|
|
34
|
+
objectPermsEnabled: boolean;
|
|
35
|
+
teamAdminCfg: TeamAdminConfig;
|
|
36
|
+
teamBudgetVisibility: TeamBudgetVisibility;
|
|
37
|
+
opencodeCfg: OpencodeConfig;
|
|
38
|
+
authorizeKeyAction(req: Request, keyId: string): Promise<{
|
|
39
|
+
tokenEntityRef: string;
|
|
40
|
+
userId: string;
|
|
41
|
+
key: VirtualKey;
|
|
42
|
+
}>;
|
|
43
|
+
sendOwnershipError(err: unknown, res: Response): boolean;
|
|
44
|
+
assertPermission(req: Request, permission: BasicPermission): Promise<boolean>;
|
|
45
|
+
sendPermissionDenied(res: Response, permission: BasicPermission): void;
|
|
46
|
+
requireTeamMgmt(res: Response): boolean;
|
|
47
|
+
requireObjectPerms(res: Response): boolean;
|
|
48
|
+
sendTeamError(err: unknown, res: Response): void;
|
|
49
|
+
authorizeTeamSubresource(req: Request, res: Response, permission: BasicPermission): Promise<{
|
|
50
|
+
teamId: string;
|
|
51
|
+
owningGroup: string;
|
|
52
|
+
actor: string;
|
|
53
|
+
team: TeamInfo;
|
|
54
|
+
} | null>;
|
|
55
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import type { RouterContext } from './context';
|
|
3
|
+
/**
|
|
4
|
+
* Register key and user routes: /user/info, /keys*, key mutations, /audit,
|
|
5
|
+
* /models and /usage. Handlers are unchanged from router.ts; only their
|
|
6
|
+
* location and access to shared state (via ctx) differ.
|
|
7
|
+
*/
|
|
8
|
+
export declare function registerKeysRoutes(router: Router, ctx: RouterContext): void;
|