@acarmisc/backstage-plugin-litellm-backend 0.2.2 → 0.3.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 +29 -0
- package/dist/bridge.d.ts +69 -0
- package/dist/bridge.js +144 -0
- package/dist/index.cjs.js +1566 -9
- package/dist/index.cjs.js.map +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -1
- package/dist/router.d.ts +6 -0
- package/dist/router.js +75 -1
- package/package.json +6 -4
- package/dist/index.esm.js +0 -323
- package/dist/index.esm.js.map +0 -7
package/config.d.ts
CHANGED
|
@@ -97,5 +97,34 @@ export interface Config {
|
|
|
97
97
|
metadata?: Record<string, string>;
|
|
98
98
|
}>;
|
|
99
99
|
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* CLI bridge — exposes /api/litellm/bridge/* for CLI clients (Abby) that
|
|
103
|
+
* authenticate with a Keycloak access token (JWKS-verified). Lets them
|
|
104
|
+
* list/mint virtual keys without holding the master key. Disabled by
|
|
105
|
+
* default; enable explicitly when the CLI is in use.
|
|
106
|
+
*/
|
|
107
|
+
bridge?: {
|
|
108
|
+
/**
|
|
109
|
+
* When true, mount the /bridge/keys, /bridge/keys (POST), /bridge/models
|
|
110
|
+
* routes and verify caller JWTs against the Keycloak realm JWKS.
|
|
111
|
+
* @default false
|
|
112
|
+
*/
|
|
113
|
+
enabled?: boolean;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Keycloak realm issuer used to fetch JWKS and verify the token issuer,
|
|
117
|
+
* e.g. https://auth.ces.abssrv.it/realms/solution-innovation.
|
|
118
|
+
* Required when enabled.
|
|
119
|
+
*/
|
|
120
|
+
issuer?: string;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* OIDC public client the CLI uses (default "abby-cli"). The token's
|
|
124
|
+
* azp (or aud) must equal this.
|
|
125
|
+
* @default "abby-cli"
|
|
126
|
+
*/
|
|
127
|
+
clientId?: string;
|
|
128
|
+
};
|
|
100
129
|
};
|
|
101
130
|
}
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Config } from '@backstage/config';
|
|
2
|
+
import { LiteLLMClient } from './client';
|
|
3
|
+
import { GenerateKeyRequest, GenerateKeyResponse, ProvisioningDefaults, UserInfo, VirtualKey } from './types';
|
|
4
|
+
/** Claims extracted from a verified Keycloak access token. */
|
|
5
|
+
export interface BridgeClaims {
|
|
6
|
+
sub: string;
|
|
7
|
+
email?: string;
|
|
8
|
+
preferred_username?: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
/** Authorized party — Keycloak sets this to the client_id of the requester. */
|
|
11
|
+
azp?: string;
|
|
12
|
+
aud?: string | string[];
|
|
13
|
+
}
|
|
14
|
+
/** A token verifier pluggable for tests. */
|
|
15
|
+
export interface TokenVerifier {
|
|
16
|
+
verify(token: string): Promise<BridgeClaims>;
|
|
17
|
+
}
|
|
18
|
+
export interface BridgeConfig {
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
/** Keycloak realm issuer, e.g. https://auth.ces.abssrv.it/realms/solution-innovation. */
|
|
21
|
+
issuer?: string;
|
|
22
|
+
/** OIDC public client the CLI uses; checked against azp / aud. */
|
|
23
|
+
clientId: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function readBridgeConfig(config: Config): BridgeConfig;
|
|
26
|
+
/** Thrown when the bridge is misconfigured (e.g. enabled without an issuer). */
|
|
27
|
+
export declare class BridgeConfigError extends Error {
|
|
28
|
+
}
|
|
29
|
+
/** Thrown when a presented token fails verification → maps to HTTP 401. */
|
|
30
|
+
export declare class BridgeAuthError extends Error {
|
|
31
|
+
readonly status = 401;
|
|
32
|
+
}
|
|
33
|
+
export interface KeycloakJWTVerifierOptions {
|
|
34
|
+
issuer: string;
|
|
35
|
+
clientId: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Verifies a Keycloak access token against the realm JWKS and ensures it was
|
|
39
|
+
* issued for {@link clientId} (via azp, falling back to aud). Uses jose's
|
|
40
|
+
* remote JWKS client (cached, with cooldown on errors).
|
|
41
|
+
*/
|
|
42
|
+
export declare class KeycloakJWTVerifier implements TokenVerifier {
|
|
43
|
+
private readonly issuer;
|
|
44
|
+
private readonly clientId;
|
|
45
|
+
private readonly jwks;
|
|
46
|
+
constructor(opts: KeycloakJWTVerifierOptions);
|
|
47
|
+
verify(token: string): Promise<BridgeClaims>;
|
|
48
|
+
}
|
|
49
|
+
/** Builds the default verifier from config, or throws BridgeConfigError. */
|
|
50
|
+
export declare function newDefaultVerifier(cfg: BridgeConfig): TokenVerifier;
|
|
51
|
+
/** Picks the LiteLLM user_id from the verified claims (email → username → sub). */
|
|
52
|
+
export declare function resolveBridgeUserId(claims: BridgeClaims): string;
|
|
53
|
+
/**
|
|
54
|
+
* Ensures a LiteLLM user exists for the verified identity. If the user is
|
|
55
|
+
* missing and provisioning is enabled, creates it from the JWT claims (email +
|
|
56
|
+
* name); if provisioning is disabled, throws a 404 telling the caller to log
|
|
57
|
+
* in to Backstage first (the UI is the primary provisioning entry point).
|
|
58
|
+
*/
|
|
59
|
+
export declare function getOrProvisionUserFromClaims(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
|
|
60
|
+
info: (...args: unknown[]) => void;
|
|
61
|
+
}): Promise<UserInfo>;
|
|
62
|
+
/** Lists the caller's virtual keys (provisioning the user first if needed). */
|
|
63
|
+
export declare function bridgeListKeys(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
|
|
64
|
+
info: (...args: unknown[]) => void;
|
|
65
|
+
}): Promise<VirtualKey[]>;
|
|
66
|
+
/** Mints a new virtual key for the caller (provisioning the user first if needed). */
|
|
67
|
+
export declare function bridgeGenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: {
|
|
68
|
+
info: (...args: unknown[]) => void;
|
|
69
|
+
}, request: Partial<GenerateKeyRequest>): Promise<GenerateKeyResponse>;
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KeycloakJWTVerifier = exports.BridgeAuthError = exports.BridgeConfigError = void 0;
|
|
4
|
+
exports.readBridgeConfig = readBridgeConfig;
|
|
5
|
+
exports.newDefaultVerifier = newDefaultVerifier;
|
|
6
|
+
exports.resolveBridgeUserId = resolveBridgeUserId;
|
|
7
|
+
exports.getOrProvisionUserFromClaims = getOrProvisionUserFromClaims;
|
|
8
|
+
exports.bridgeListKeys = bridgeListKeys;
|
|
9
|
+
exports.bridgeGenerateKey = bridgeGenerateKey;
|
|
10
|
+
/**
|
|
11
|
+
* Bridge — lets CLI clients (Abby) list/mint LiteLLM virtual keys without ever
|
|
12
|
+
* holding the LiteLLM master key.
|
|
13
|
+
*
|
|
14
|
+
* Trust model: the Backstage backend holds the master key (as it already does
|
|
15
|
+
* for the UI). A CLI authenticates with its Keycloak access token (the same
|
|
16
|
+
* Keycloak realm Backstage uses). This module verifies that JWT against the
|
|
17
|
+
* realm JWKS, resolves the caller to a LiteLLM user_id, ensures that user
|
|
18
|
+
* exists (provisioning from claims if enabled), and then lists/generates keys
|
|
19
|
+
* via the existing master-key-authed {@link LiteLLMClient}.
|
|
20
|
+
*
|
|
21
|
+
* Unlike the UI endpoints in router.ts, the bridge routes do NOT call
|
|
22
|
+
* Backstage's `auth.authenticate` (which expects a Backstage-issued token);
|
|
23
|
+
* they verify the raw Keycloak JWT themselves.
|
|
24
|
+
*/
|
|
25
|
+
const jose_1 = require("jose");
|
|
26
|
+
const provisioning_1 = require("./provisioning");
|
|
27
|
+
function readBridgeConfig(config) {
|
|
28
|
+
const enabled = config.getOptionalBoolean('litellm.bridge.enabled') ?? false;
|
|
29
|
+
const issuer = config.getOptionalString('litellm.bridge.issuer');
|
|
30
|
+
const clientId = config.getOptionalString('litellm.bridge.clientId') ?? 'abby-cli';
|
|
31
|
+
return { enabled, issuer, clientId };
|
|
32
|
+
}
|
|
33
|
+
/** Thrown when the bridge is misconfigured (e.g. enabled without an issuer). */
|
|
34
|
+
class BridgeConfigError extends Error {
|
|
35
|
+
}
|
|
36
|
+
exports.BridgeConfigError = BridgeConfigError;
|
|
37
|
+
/** Thrown when a presented token fails verification → maps to HTTP 401. */
|
|
38
|
+
class BridgeAuthError extends Error {
|
|
39
|
+
constructor() {
|
|
40
|
+
super(...arguments);
|
|
41
|
+
this.status = 401;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
exports.BridgeAuthError = BridgeAuthError;
|
|
45
|
+
/**
|
|
46
|
+
* Verifies a Keycloak access token against the realm JWKS and ensures it was
|
|
47
|
+
* issued for {@link clientId} (via azp, falling back to aud). Uses jose's
|
|
48
|
+
* remote JWKS client (cached, with cooldown on errors).
|
|
49
|
+
*/
|
|
50
|
+
class KeycloakJWTVerifier {
|
|
51
|
+
constructor(opts) {
|
|
52
|
+
this.issuer = opts.issuer.replace(/\/$/, '');
|
|
53
|
+
this.clientId = opts.clientId;
|
|
54
|
+
this.jwks = (0, jose_1.createRemoteJWKSet)(new URL(`${this.issuer}/protocol/openid-connect/certs`));
|
|
55
|
+
}
|
|
56
|
+
async verify(token) {
|
|
57
|
+
let payload;
|
|
58
|
+
try {
|
|
59
|
+
const result = await (0, jose_1.jwtVerify)(token, this.jwks, {
|
|
60
|
+
issuer: this.issuer,
|
|
61
|
+
});
|
|
62
|
+
payload = result.payload;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
// Expired, bad signature, wrong issuer, malformed, JWKS unreachable, etc.
|
|
66
|
+
throw new BridgeAuthError(`invalid keycloak token: ${err.message ?? err}`);
|
|
67
|
+
}
|
|
68
|
+
const azp = payload.azp;
|
|
69
|
+
const aud = payload.aud;
|
|
70
|
+
const audMatches = Array.isArray(aud)
|
|
71
|
+
? aud.includes(this.clientId)
|
|
72
|
+
: aud === this.clientId;
|
|
73
|
+
if (azp !== this.clientId && !audMatches) {
|
|
74
|
+
throw new BridgeAuthError(`token not issued for client "${this.clientId}" (azp=${azp ?? 'none'})`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
sub: payload.sub ?? '',
|
|
78
|
+
email: payload.email,
|
|
79
|
+
preferred_username: payload
|
|
80
|
+
.preferred_username,
|
|
81
|
+
name: payload.name,
|
|
82
|
+
azp,
|
|
83
|
+
aud,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
exports.KeycloakJWTVerifier = KeycloakJWTVerifier;
|
|
88
|
+
/** Builds the default verifier from config, or throws BridgeConfigError. */
|
|
89
|
+
function newDefaultVerifier(cfg) {
|
|
90
|
+
if (!cfg.issuer) {
|
|
91
|
+
throw new BridgeConfigError('litellm.bridge.issuer is required when litellm.bridge.enabled is true ' +
|
|
92
|
+
'(e.g. https://auth.ces.abssrv.it/realms/solution-innovation)');
|
|
93
|
+
}
|
|
94
|
+
return new KeycloakJWTVerifier({ issuer: cfg.issuer, clientId: cfg.clientId });
|
|
95
|
+
}
|
|
96
|
+
/** Picks the LiteLLM user_id from the verified claims (email → username → sub). */
|
|
97
|
+
function resolveBridgeUserId(claims) {
|
|
98
|
+
return claims.email ?? claims.preferred_username ?? claims.sub;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Ensures a LiteLLM user exists for the verified identity. If the user is
|
|
102
|
+
* missing and provisioning is enabled, creates it from the JWT claims (email +
|
|
103
|
+
* name); if provisioning is disabled, throws a 404 telling the caller to log
|
|
104
|
+
* in to Backstage first (the UI is the primary provisioning entry point).
|
|
105
|
+
*/
|
|
106
|
+
async function getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger) {
|
|
107
|
+
const userId = resolveBridgeUserId(claims);
|
|
108
|
+
const existing = await client.getUserInfo(userId);
|
|
109
|
+
if (existing)
|
|
110
|
+
return existing;
|
|
111
|
+
if (!provisioningEnabled) {
|
|
112
|
+
throw new provisioning_1.ProvisioningError('User not found in LiteLLM', 'No LiteLLM user for this identity. Log in to Backstage once to be provisioned, or enable litellm.provisioning.enabled.', false);
|
|
113
|
+
}
|
|
114
|
+
const profile = {
|
|
115
|
+
email: claims.email ?? claims.preferred_username,
|
|
116
|
+
displayName: claims.name ?? claims.preferred_username,
|
|
117
|
+
};
|
|
118
|
+
const created = await (0, provisioning_1.provisionUser)(client, userId, provisioningDefaults, profile, undefined, logger);
|
|
119
|
+
if (!created) {
|
|
120
|
+
throw new provisioning_1.ProvisioningError('User not found in LiteLLM', 'Provisioning attempted but returned no user — check LiteLLM logs', true, 500);
|
|
121
|
+
}
|
|
122
|
+
return created;
|
|
123
|
+
}
|
|
124
|
+
/** Lists the caller's virtual keys (provisioning the user first if needed). */
|
|
125
|
+
async function bridgeListKeys(client, claims, provisioningEnabled, provisioningDefaults, logger) {
|
|
126
|
+
await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger);
|
|
127
|
+
return client.listKeys(resolveBridgeUserId(claims));
|
|
128
|
+
}
|
|
129
|
+
/** Mints a new virtual key for the caller (provisioning the user first if needed). */
|
|
130
|
+
async function bridgeGenerateKey(client, claims, provisioningEnabled, provisioningDefaults, logger, request) {
|
|
131
|
+
await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger);
|
|
132
|
+
const userId = resolveBridgeUserId(claims);
|
|
133
|
+
const enriched = {
|
|
134
|
+
...request,
|
|
135
|
+
user_id: userId,
|
|
136
|
+
metadata: {
|
|
137
|
+
...(request.metadata ?? {}),
|
|
138
|
+
created_via: 'abby-cli',
|
|
139
|
+
created_by: userId,
|
|
140
|
+
created_at_iso: new Date().toISOString(),
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
return client.generateKey(enriched);
|
|
144
|
+
}
|