@acarmisc/backstage-plugin-litellm-backend 0.2.1 → 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 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
  }
@@ -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
+ }
package/dist/client.d.ts CHANGED
@@ -8,6 +8,11 @@ export declare class LiteLLMClient {
8
8
  /**
9
9
  * Returns null when the user is not found in LiteLLM (404).
10
10
  * Throws on all other errors so callers know something went wrong.
11
+ *
12
+ * LiteLLM's `/user/info` wraps the user row inside `user_info` and returns
13
+ * `teams` as an array of full team objects, not team_id strings. We flatten
14
+ * `user_info` onto the top level and reduce `teams` to a string[] of ids so
15
+ * the rest of the code can rely on the UserInfo contract.
11
16
  */
12
17
  getUserInfo(userId?: string): Promise<UserInfo | null>;
13
18
  createUser(payload: CreateUserRequest): Promise<CreateUserResponse>;
@@ -48,6 +53,16 @@ export declare class LiteLLMClient {
48
53
  deleteKeys(request: DeleteKeyRequest): Promise<{
49
54
  success: boolean;
50
55
  }>;
56
+ /**
57
+ * Returns the proxy's model catalogue normalised to the ModelInfo shape.
58
+ *
59
+ * Prefers `/model/info` which exposes `model_name`, `mode`, capability flags
60
+ * and per-token costs. Falls back to OpenAI-compatible `/models` (which only
61
+ * returns `{id}`) so the dropdown still works on installs where `/model/info`
62
+ * isn't reachable. Without this normalisation the UI saw blank labels and
63
+ * a single "other" group because `/models` doesn't populate `model_name`
64
+ * or `mode`.
65
+ */
51
66
  listModels(): Promise<ModelInfo[]>;
52
67
  getTeamInfo(teamId: string): Promise<TeamInfo>;
53
68
  private emptyUsage;
package/dist/client.js CHANGED
@@ -36,11 +36,34 @@ class LiteLLMClient {
36
36
  /**
37
37
  * Returns null when the user is not found in LiteLLM (404).
38
38
  * Throws on all other errors so callers know something went wrong.
39
+ *
40
+ * LiteLLM's `/user/info` wraps the user row inside `user_info` and returns
41
+ * `teams` as an array of full team objects, not team_id strings. We flatten
42
+ * `user_info` onto the top level and reduce `teams` to a string[] of ids so
43
+ * the rest of the code can rely on the UserInfo contract.
39
44
  */
40
45
  async getUserInfo(userId) {
41
46
  const query = userId ? `?user_id=${encodeURIComponent(userId)}` : '';
42
47
  try {
43
- return await this.request(`/user/info${query}`);
48
+ const raw = await this.request(`/user/info${query}`);
49
+ const inner = raw?.user_info ?? {};
50
+ const teamIds = Array.isArray(raw?.teams)
51
+ ? raw.teams
52
+ .map((t) => (typeof t === 'string' ? t : t?.team_id))
53
+ .filter((t) => typeof t === 'string')
54
+ : [];
55
+ return {
56
+ user_id: raw?.user_id ?? inner.user_id ?? userId ?? '',
57
+ user_email: inner.user_email ?? raw?.user_email,
58
+ email: inner.email ?? raw?.email,
59
+ teams: teamIds,
60
+ models: inner.models ?? raw?.models,
61
+ max_budget: inner.max_budget ?? raw?.max_budget,
62
+ spend: inner.spend ?? raw?.spend,
63
+ current_spend: inner.current_spend ?? raw?.current_spend,
64
+ soft_limit: inner.soft_limit ?? raw?.soft_limit,
65
+ hard_limit: inner.hard_limit ?? raw?.hard_limit,
66
+ };
44
67
  }
45
68
  catch (err) {
46
69
  if (err.status === 404)
@@ -143,9 +166,53 @@ class LiteLLMClient {
143
166
  body: JSON.stringify(request),
144
167
  });
145
168
  }
169
+ /**
170
+ * Returns the proxy's model catalogue normalised to the ModelInfo shape.
171
+ *
172
+ * Prefers `/model/info` which exposes `model_name`, `mode`, capability flags
173
+ * and per-token costs. Falls back to OpenAI-compatible `/models` (which only
174
+ * returns `{id}`) so the dropdown still works on installs where `/model/info`
175
+ * isn't reachable. Without this normalisation the UI saw blank labels and
176
+ * a single "other" group because `/models` doesn't populate `model_name`
177
+ * or `mode`.
178
+ */
146
179
  async listModels() {
147
- const response = await this.request('/models');
148
- return Array.isArray(response) ? response : response.data ?? [];
180
+ try {
181
+ const response = await this.request('/model/info');
182
+ const data = Array.isArray(response?.data) ? response.data : [];
183
+ const normalised = data.map((m) => {
184
+ const info = m.model_info ?? {};
185
+ const params = m.litellm_params ?? {};
186
+ return {
187
+ model_name: m.model_name ?? params.model ?? info.id ?? '',
188
+ mode: info.mode ?? m.mode ?? 'chat',
189
+ supports_function_calling: info.supports_function_calling ?? m.supports_function_calling,
190
+ supports_vision: info.supports_vision ?? m.supports_vision,
191
+ input_cost_per_token: info.input_cost_per_token ?? params.input_cost_per_token,
192
+ output_cost_per_token: info.output_cost_per_token ?? params.output_cost_per_token,
193
+ };
194
+ });
195
+ const filtered = normalised.filter(m => m.model_name);
196
+ if (filtered.length)
197
+ return filtered;
198
+ }
199
+ catch {
200
+ // fall through to /models
201
+ }
202
+ const fallback = await this.request('/models');
203
+ const data = Array.isArray(fallback)
204
+ ? fallback
205
+ : Array.isArray(fallback?.data)
206
+ ? fallback.data
207
+ : [];
208
+ return data
209
+ .map((m) => ({
210
+ model_name: m.model_name ?? m.id ?? '',
211
+ mode: m.mode ?? 'chat',
212
+ supports_function_calling: m.supports_function_calling,
213
+ supports_vision: m.supports_vision,
214
+ }))
215
+ .filter((m) => m.model_name);
149
216
  }
150
217
  async getTeamInfo(teamId) {
151
218
  return this.request(`/team/info?team_id=${encodeURIComponent(teamId)}`);