@acarmisc/backstage-plugin-litellm-backend 0.1.11 → 0.1.13
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/dist/client.d.ts +17 -2
- package/dist/client.js +167 -9
- package/dist/index.cjs.js +151 -5604
- package/dist/index.cjs.js.map +4 -4
- package/dist/plugin.js +2 -2
- package/dist/provisioning.d.ts +72 -0
- package/dist/provisioning.js +196 -0
- package/dist/router.d.ts +4 -1
- package/dist/router.js +73 -108
- package/dist/types.d.ts +71 -13
- package/package.json +1 -1
package/dist/plugin.js
CHANGED
|
@@ -14,8 +14,8 @@ exports.litellmPlugin = (0, backend_plugin_api_1.createBackendPlugin)({
|
|
|
14
14
|
auth: backend_plugin_api_1.coreServices.auth,
|
|
15
15
|
discovery: backend_plugin_api_1.coreServices.discovery,
|
|
16
16
|
},
|
|
17
|
-
async init({ httpRouter, config, logger, auth }) {
|
|
18
|
-
const router = await (0, router_1.createRouter)({ config, logger, auth });
|
|
17
|
+
async init({ httpRouter, config, logger, auth, discovery }) {
|
|
18
|
+
const router = await (0, router_1.createRouter)({ config, logger, auth, discovery });
|
|
19
19
|
httpRouter.use(router);
|
|
20
20
|
},
|
|
21
21
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Config } from '@backstage/config';
|
|
2
|
+
import { AuthService } from '@backstage/backend-plugin-api';
|
|
3
|
+
import { CatalogClient } from '@backstage/catalog-client';
|
|
4
|
+
import { Request } from 'express';
|
|
5
|
+
import { LiteLLMClient } from './client';
|
|
6
|
+
import { UserInfo, ProvisioningDefaults, RoleConfig } from './types';
|
|
7
|
+
/**
|
|
8
|
+
* Converts a Backstage user entity ref to a LiteLLM user_id.
|
|
9
|
+
*
|
|
10
|
+
* When userIdDomain is configured, the entity name is suffixed with the domain
|
|
11
|
+
* so that LiteLLM user_ids match the organisation's email addresses:
|
|
12
|
+
* "user:default/andrea.carmisciano" + "abstract.it"
|
|
13
|
+
* → "andrea.carmisciano@abstract.it"
|
|
14
|
+
*
|
|
15
|
+
* Without a domain the bare entity name is returned unchanged, which works for
|
|
16
|
+
* deployments where LiteLLM users were created with plain usernames.
|
|
17
|
+
*/
|
|
18
|
+
export declare function toLiteLLMUserId(userEntityRef: string, userIdDomain?: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Reads the provisioning block from config, applying safe defaults for every
|
|
21
|
+
* field so the feature works out-of-the-box without any YAML required.
|
|
22
|
+
*
|
|
23
|
+
* Safe defaults rationale:
|
|
24
|
+
* maxBudget: $10 — prevents runaway spend on a forgotten test account
|
|
25
|
+
* budgetDuration: 30d — monthly reset, aligns with typical billing cycles
|
|
26
|
+
* models: [] — empty means all proxy models are allowed;
|
|
27
|
+
* restrict here or at team level for tighter control
|
|
28
|
+
* teams: [] — no automatic team assignment; add IDs to enrol users
|
|
29
|
+
* tpmLimit: none — LiteLLM global / team limits still apply
|
|
30
|
+
* rpmLimit: none — same
|
|
31
|
+
* metadata: backstage source tag only
|
|
32
|
+
*/
|
|
33
|
+
export declare function readRoleConfigs(config: Config): RoleConfig[];
|
|
34
|
+
/**
|
|
35
|
+
* Merges role config over defaults. Role fields override defaults only when explicitly set.
|
|
36
|
+
*/
|
|
37
|
+
export declare function applyRoleOverrides(defaults: ProvisioningDefaults, role: RoleConfig): ProvisioningDefaults;
|
|
38
|
+
export declare function readProvisioningDefaults(config: Config): {
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
defaults: ProvisioningDefaults;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Extracts the authenticated Backstage user identity from the request token.
|
|
44
|
+
* Returns the userEntityRef (e.g. "user:default/john.doe") or undefined when
|
|
45
|
+
* the request carries no user credential (service-to-service calls).
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveUserId(req: Request, auth: AuthService): Promise<string | undefined>;
|
|
48
|
+
/**
|
|
49
|
+
* Creates a LiteLLM user for the given Backstage identity using the configured
|
|
50
|
+
* defaults. Returns the UserInfo of the newly created account.
|
|
51
|
+
*/
|
|
52
|
+
export declare function provisionUser(client: LiteLLMClient, userId: string, defaults: ProvisioningDefaults, logger: any): Promise<UserInfo | null>;
|
|
53
|
+
export declare class ProvisioningError extends Error {
|
|
54
|
+
status: number;
|
|
55
|
+
body: {
|
|
56
|
+
error: string;
|
|
57
|
+
hint: string;
|
|
58
|
+
provisioning: boolean;
|
|
59
|
+
};
|
|
60
|
+
constructor(message: string, hint: string, provisioning: boolean);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Ensures the LiteLLM user exists, returning its UserInfo.
|
|
64
|
+
* When the user is missing and provisioning is enabled, attempts to create it.
|
|
65
|
+
* When provisioning is disabled, throws a ProvisioningError with a clear message.
|
|
66
|
+
*/
|
|
67
|
+
export declare function getOrProvisionUser(client: LiteLLMClient, tokenEntityRef: string | undefined, userId: string | undefined, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, roleConfigs: RoleConfig[], catalogClient: CatalogClient, auth: AuthService, logger: any): Promise<UserInfo>;
|
|
68
|
+
/**
|
|
69
|
+
* Fetches the user's Backstage group memberships and returns the first matching
|
|
70
|
+
* role config (priority order), or undefined when no role matches.
|
|
71
|
+
*/
|
|
72
|
+
export declare function resolveUserRole(userEntityRef: string, roleConfigs: RoleConfig[], catalogClient: CatalogClient, auth: AuthService, logger: any): Promise<RoleConfig | undefined>;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ProvisioningError = void 0;
|
|
4
|
+
exports.toLiteLLMUserId = toLiteLLMUserId;
|
|
5
|
+
exports.readRoleConfigs = readRoleConfigs;
|
|
6
|
+
exports.applyRoleOverrides = applyRoleOverrides;
|
|
7
|
+
exports.readProvisioningDefaults = readProvisioningDefaults;
|
|
8
|
+
exports.resolveUserId = resolveUserId;
|
|
9
|
+
exports.provisionUser = provisionUser;
|
|
10
|
+
exports.getOrProvisionUser = getOrProvisionUser;
|
|
11
|
+
exports.resolveUserRole = resolveUserRole;
|
|
12
|
+
/**
|
|
13
|
+
* Converts a Backstage user entity ref to a LiteLLM user_id.
|
|
14
|
+
*
|
|
15
|
+
* When userIdDomain is configured, the entity name is suffixed with the domain
|
|
16
|
+
* so that LiteLLM user_ids match the organisation's email addresses:
|
|
17
|
+
* "user:default/andrea.carmisciano" + "abstract.it"
|
|
18
|
+
* → "andrea.carmisciano@abstract.it"
|
|
19
|
+
*
|
|
20
|
+
* Without a domain the bare entity name is returned unchanged, which works for
|
|
21
|
+
* deployments where LiteLLM users were created with plain usernames.
|
|
22
|
+
*/
|
|
23
|
+
function toLiteLLMUserId(userEntityRef, userIdDomain) {
|
|
24
|
+
const name = userEntityRef.split('/').pop() ?? userEntityRef;
|
|
25
|
+
return userIdDomain ? `${name}@${userIdDomain}` : name;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Reads the provisioning block from config, applying safe defaults for every
|
|
29
|
+
* field so the feature works out-of-the-box without any YAML required.
|
|
30
|
+
*
|
|
31
|
+
* Safe defaults rationale:
|
|
32
|
+
* maxBudget: $10 — prevents runaway spend on a forgotten test account
|
|
33
|
+
* budgetDuration: 30d — monthly reset, aligns with typical billing cycles
|
|
34
|
+
* models: [] — empty means all proxy models are allowed;
|
|
35
|
+
* restrict here or at team level for tighter control
|
|
36
|
+
* teams: [] — no automatic team assignment; add IDs to enrol users
|
|
37
|
+
* tpmLimit: none — LiteLLM global / team limits still apply
|
|
38
|
+
* rpmLimit: none — same
|
|
39
|
+
* metadata: backstage source tag only
|
|
40
|
+
*/
|
|
41
|
+
function readRoleConfigs(config) {
|
|
42
|
+
const raw = config.getOptional('litellm.provisioning.roles');
|
|
43
|
+
if (!raw?.length)
|
|
44
|
+
return [];
|
|
45
|
+
return raw.map((r) => ({
|
|
46
|
+
group: r.group,
|
|
47
|
+
maxBudget: r.maxBudget,
|
|
48
|
+
budgetDuration: r.budgetDuration,
|
|
49
|
+
models: r.models,
|
|
50
|
+
teams: r.teams,
|
|
51
|
+
tpmLimit: r.tpmLimit,
|
|
52
|
+
rpmLimit: r.rpmLimit,
|
|
53
|
+
metadata: r.metadata,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Merges role config over defaults. Role fields override defaults only when explicitly set.
|
|
58
|
+
*/
|
|
59
|
+
function applyRoleOverrides(defaults, role) {
|
|
60
|
+
return {
|
|
61
|
+
maxBudget: role.maxBudget ?? defaults.maxBudget,
|
|
62
|
+
budgetDuration: role.budgetDuration ?? defaults.budgetDuration,
|
|
63
|
+
models: role.models ?? defaults.models,
|
|
64
|
+
teams: role.teams ?? defaults.teams,
|
|
65
|
+
tpmLimit: role.tpmLimit ?? defaults.tpmLimit,
|
|
66
|
+
rpmLimit: role.rpmLimit ?? defaults.rpmLimit,
|
|
67
|
+
metadata: { ...defaults.metadata, ...(role.metadata ?? {}) },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function readProvisioningDefaults(config) {
|
|
71
|
+
const enabled = config.getOptionalBoolean('litellm.provisioning.enabled') ?? false;
|
|
72
|
+
const defaults = {
|
|
73
|
+
maxBudget: config.getOptionalNumber('litellm.provisioning.defaults.maxBudget') ?? 10,
|
|
74
|
+
budgetDuration: config.getOptionalString('litellm.provisioning.defaults.budgetDuration') ?? '30d',
|
|
75
|
+
models: config.getOptionalStringArray('litellm.provisioning.defaults.models') ?? [],
|
|
76
|
+
teams: config.getOptionalStringArray('litellm.provisioning.defaults.teams') ?? [],
|
|
77
|
+
tpmLimit: config.getOptionalNumber('litellm.provisioning.defaults.tpmLimit'),
|
|
78
|
+
rpmLimit: config.getOptionalNumber('litellm.provisioning.defaults.rpmLimit'),
|
|
79
|
+
metadata: (config.getOptional('litellm.provisioning.defaults.metadata') ?? {}),
|
|
80
|
+
};
|
|
81
|
+
return { enabled, defaults };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Extracts the authenticated Backstage user identity from the request token.
|
|
85
|
+
* Returns the userEntityRef (e.g. "user:default/john.doe") or undefined when
|
|
86
|
+
* the request carries no user credential (service-to-service calls).
|
|
87
|
+
*/
|
|
88
|
+
async function resolveUserId(req, auth) {
|
|
89
|
+
const rawToken = req.headers.authorization?.slice(7);
|
|
90
|
+
if (!rawToken)
|
|
91
|
+
return undefined;
|
|
92
|
+
try {
|
|
93
|
+
const credentials = await auth.authenticate(rawToken);
|
|
94
|
+
const principal = credentials.principal;
|
|
95
|
+
if (principal?.type === 'user') {
|
|
96
|
+
return principal.userEntityRef;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// invalid or service token — caller gets query-param fallback
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Creates a LiteLLM user for the given Backstage identity using the configured
|
|
106
|
+
* defaults. Returns the UserInfo of the newly created account.
|
|
107
|
+
*/
|
|
108
|
+
async function provisionUser(client, userId, defaults, logger) {
|
|
109
|
+
const payload = {
|
|
110
|
+
user_id: userId,
|
|
111
|
+
max_budget: defaults.maxBudget,
|
|
112
|
+
budget_duration: defaults.budgetDuration,
|
|
113
|
+
models: defaults.models,
|
|
114
|
+
teams: defaults.teams,
|
|
115
|
+
...(defaults.tpmLimit !== undefined && { tpm_limit: defaults.tpmLimit }),
|
|
116
|
+
...(defaults.rpmLimit !== undefined && { rpm_limit: defaults.rpmLimit }),
|
|
117
|
+
metadata: {
|
|
118
|
+
...defaults.metadata,
|
|
119
|
+
provisioned_by: 'backstage',
|
|
120
|
+
provisioned_at: new Date().toISOString(),
|
|
121
|
+
backstage_entity: userId,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
logger.info(`Provisioning new LiteLLM user for Backstage identity: ${userId}`);
|
|
125
|
+
try {
|
|
126
|
+
await client.createUser(payload);
|
|
127
|
+
// Fetch the freshly-created user record to return consistent UserInfo shape
|
|
128
|
+
return await client.getUserInfo(userId);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
logger.error(`Failed to provision LiteLLM user ${userId}: ${err.message}`);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
class ProvisioningError extends Error {
|
|
136
|
+
constructor(message, hint, provisioning) {
|
|
137
|
+
super(message);
|
|
138
|
+
this.status = 404;
|
|
139
|
+
this.body = { error: message, hint, provisioning };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
exports.ProvisioningError = ProvisioningError;
|
|
143
|
+
/**
|
|
144
|
+
* Ensures the LiteLLM user exists, returning its UserInfo.
|
|
145
|
+
* When the user is missing and provisioning is enabled, attempts to create it.
|
|
146
|
+
* When provisioning is disabled, throws a ProvisioningError with a clear message.
|
|
147
|
+
*/
|
|
148
|
+
async function getOrProvisionUser(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger) {
|
|
149
|
+
if (!userId) {
|
|
150
|
+
throw new ProvisioningError('User not found in LiteLLM', 'No user identity could be resolved from the request.', provisioningEnabled);
|
|
151
|
+
}
|
|
152
|
+
let userInfo = await client.getUserInfo(userId);
|
|
153
|
+
if (!userInfo) {
|
|
154
|
+
if (provisioningEnabled) {
|
|
155
|
+
const catalogRef = tokenEntityRef ?? userId;
|
|
156
|
+
const matchedRole = await resolveUserRole(catalogRef, roleConfigs, catalogClient, auth, logger);
|
|
157
|
+
const effectiveDefaults = matchedRole
|
|
158
|
+
? applyRoleOverrides(provisioningDefaults, matchedRole)
|
|
159
|
+
: provisioningDefaults;
|
|
160
|
+
if (matchedRole) {
|
|
161
|
+
logger.info(`User ${userId} matched role group ${matchedRole.group} — using role-specific provisioning`);
|
|
162
|
+
}
|
|
163
|
+
userInfo = await provisionUser(client, userId, effectiveDefaults, logger);
|
|
164
|
+
}
|
|
165
|
+
if (!userInfo) {
|
|
166
|
+
if (provisioningEnabled) {
|
|
167
|
+
throw new ProvisioningError('User not found in LiteLLM', 'Provisioning attempted but failed — check LiteLLM logs', true);
|
|
168
|
+
}
|
|
169
|
+
throw new ProvisioningError('User not found in LiteLLM', 'Enable litellm.provisioning.enabled in app-config.yaml or create the user manually', false);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return userInfo;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Fetches the user's Backstage group memberships and returns the first matching
|
|
176
|
+
* role config (priority order), or undefined when no role matches.
|
|
177
|
+
*/
|
|
178
|
+
async function resolveUserRole(userEntityRef, roleConfigs, catalogClient, auth, logger) {
|
|
179
|
+
if (!roleConfigs.length)
|
|
180
|
+
return undefined;
|
|
181
|
+
try {
|
|
182
|
+
const { token } = await auth.getPluginRequestToken({
|
|
183
|
+
onBehalfOf: await auth.getOwnServiceCredentials(),
|
|
184
|
+
targetPluginId: 'catalog',
|
|
185
|
+
});
|
|
186
|
+
const entity = await catalogClient.getEntityByRef(userEntityRef, { token });
|
|
187
|
+
const groups = (entity?.relations ?? [])
|
|
188
|
+
.filter(r => r.type === 'memberOf')
|
|
189
|
+
.map(r => r.targetRef);
|
|
190
|
+
return roleConfigs.find(rc => groups.includes(rc.group));
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
logger.warn(`Could not resolve Backstage groups for ${userEntityRef}: ${err.message}`);
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
package/dist/router.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
2
|
import { Config } from '@backstage/config';
|
|
3
|
-
import { AuthService } from '@backstage/backend-plugin-api';
|
|
3
|
+
import { AuthService, DiscoveryService } from '@backstage/backend-plugin-api';
|
|
4
|
+
import { ProvisioningError } from './provisioning';
|
|
5
|
+
export { ProvisioningError };
|
|
4
6
|
export interface RouterOptions {
|
|
5
7
|
config: Config;
|
|
6
8
|
logger: any;
|
|
7
9
|
auth: AuthService;
|
|
10
|
+
discovery: DiscoveryService;
|
|
8
11
|
}
|
|
9
12
|
export declare function createRouter(options: RouterOptions): Promise<Router>;
|
package/dist/router.js
CHANGED
|
@@ -1,93 +1,21 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ProvisioningError = void 0;
|
|
3
4
|
exports.createRouter = createRouter;
|
|
4
5
|
const express_1 = require("express");
|
|
6
|
+
const catalog_client_1 = require("@backstage/catalog-client");
|
|
5
7
|
const client_1 = require("./client");
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
* field so the feature works out-of-the-box without any YAML required.
|
|
9
|
-
*
|
|
10
|
-
* Safe defaults rationale:
|
|
11
|
-
* maxBudget: $10 — prevents runaway spend on a forgotten test account
|
|
12
|
-
* budgetDuration: 30d — monthly reset, aligns with typical billing cycles
|
|
13
|
-
* models: [] — empty means all proxy models are allowed;
|
|
14
|
-
* restrict here or at team level for tighter control
|
|
15
|
-
* teams: [] — no automatic team assignment; add IDs to enrol users
|
|
16
|
-
* tpmLimit: none — LiteLLM global / team limits still apply
|
|
17
|
-
* rpmLimit: none — same
|
|
18
|
-
* metadata: backstage source tag only
|
|
19
|
-
*/
|
|
20
|
-
function readProvisioningDefaults(config) {
|
|
21
|
-
const enabled = config.getOptionalBoolean('litellm.provisioning.enabled') ?? false;
|
|
22
|
-
const defaults = {
|
|
23
|
-
maxBudget: config.getOptionalNumber('litellm.provisioning.defaults.maxBudget') ?? 10,
|
|
24
|
-
budgetDuration: config.getOptionalString('litellm.provisioning.defaults.budgetDuration') ?? '30d',
|
|
25
|
-
models: config.getOptionalStringArray('litellm.provisioning.defaults.models') ?? [],
|
|
26
|
-
teams: config.getOptionalStringArray('litellm.provisioning.defaults.teams') ?? [],
|
|
27
|
-
tpmLimit: config.getOptionalNumber('litellm.provisioning.defaults.tpmLimit'),
|
|
28
|
-
rpmLimit: config.getOptionalNumber('litellm.provisioning.defaults.rpmLimit'),
|
|
29
|
-
metadata: (config.getOptional('litellm.provisioning.defaults.metadata') ?? {}),
|
|
30
|
-
};
|
|
31
|
-
return { enabled, defaults };
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Extracts the authenticated Backstage user identity from the request token.
|
|
35
|
-
* Returns the userEntityRef (e.g. "user:default/john.doe") or undefined when
|
|
36
|
-
* the request carries no user credential (service-to-service calls).
|
|
37
|
-
*/
|
|
38
|
-
async function resolveUserId(req, auth) {
|
|
39
|
-
const rawToken = req.headers.authorization?.slice(7);
|
|
40
|
-
if (!rawToken)
|
|
41
|
-
return undefined;
|
|
42
|
-
try {
|
|
43
|
-
const credentials = await auth.authenticate(rawToken);
|
|
44
|
-
const principal = credentials.principal;
|
|
45
|
-
if (principal?.type === 'user') {
|
|
46
|
-
return principal.userEntityRef;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
// invalid or service token — caller gets query-param fallback
|
|
51
|
-
}
|
|
52
|
-
return undefined;
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Creates a LiteLLM user for the given Backstage identity using the configured
|
|
56
|
-
* defaults. Returns the UserInfo of the newly created account.
|
|
57
|
-
*/
|
|
58
|
-
async function provisionUser(client, userId, defaults, logger) {
|
|
59
|
-
const payload = {
|
|
60
|
-
user_id: userId,
|
|
61
|
-
max_budget: defaults.maxBudget,
|
|
62
|
-
budget_duration: defaults.budgetDuration,
|
|
63
|
-
models: defaults.models,
|
|
64
|
-
teams: defaults.teams,
|
|
65
|
-
...(defaults.tpmLimit !== undefined && { tpm_limit: defaults.tpmLimit }),
|
|
66
|
-
...(defaults.rpmLimit !== undefined && { rpm_limit: defaults.rpmLimit }),
|
|
67
|
-
metadata: {
|
|
68
|
-
...defaults.metadata,
|
|
69
|
-
provisioned_by: 'backstage',
|
|
70
|
-
provisioned_at: new Date().toISOString(),
|
|
71
|
-
backstage_entity: userId,
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
logger.info(`Provisioning new LiteLLM user for Backstage identity: ${userId}`);
|
|
75
|
-
try {
|
|
76
|
-
await client.createUser(payload);
|
|
77
|
-
// Fetch the freshly-created user record to return consistent UserInfo shape
|
|
78
|
-
return await client.getUserInfo(userId);
|
|
79
|
-
}
|
|
80
|
-
catch (err) {
|
|
81
|
-
logger.error(`Failed to provision LiteLLM user ${userId}: ${err.message}`);
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
8
|
+
const provisioning_1 = require("./provisioning");
|
|
9
|
+
Object.defineProperty(exports, "ProvisioningError", { enumerable: true, get: function () { return provisioning_1.ProvisioningError; } });
|
|
85
10
|
async function createRouter(options) {
|
|
86
|
-
const { config, logger, auth } = options;
|
|
11
|
+
const { config, logger, auth, discovery } = options;
|
|
87
12
|
const baseUrl = config.getString('litellm.baseUrl');
|
|
88
13
|
const masterKey = config.getString('litellm.masterKey');
|
|
14
|
+
const userIdDomain = config.getOptionalString('litellm.userIdDomain');
|
|
89
15
|
const client = new client_1.LiteLLMClient({ baseUrl, masterKey });
|
|
90
|
-
const { enabled: provisioningEnabled, defaults: provisioningDefaults } = readProvisioningDefaults(config);
|
|
16
|
+
const { enabled: provisioningEnabled, defaults: provisioningDefaults } = (0, provisioning_1.readProvisioningDefaults)(config);
|
|
17
|
+
const roleConfigs = (0, provisioning_1.readRoleConfigs)(config);
|
|
18
|
+
const catalogClient = new catalog_client_1.CatalogClient({ discoveryApi: discovery });
|
|
91
19
|
if (provisioningEnabled) {
|
|
92
20
|
logger.info(`LiteLLM auto-provisioning enabled — defaults: budget=$${provisioningDefaults.maxBudget}/${provisioningDefaults.budgetDuration}, models=${provisioningDefaults.models.length ? provisioningDefaults.models.join(',') : 'all'}, teams=[${provisioningDefaults.teams.join(',')}]`);
|
|
93
21
|
}
|
|
@@ -97,58 +25,80 @@ async function createRouter(options) {
|
|
|
97
25
|
});
|
|
98
26
|
router.get('/user/info', async (req, res) => {
|
|
99
27
|
try {
|
|
100
|
-
const
|
|
101
|
-
const userId =
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
userInfo = await provisionUser(client, userId, provisioningDefaults, logger);
|
|
106
|
-
}
|
|
107
|
-
if (!userInfo) {
|
|
108
|
-
res.status(404).json({
|
|
109
|
-
error: 'User not found in LiteLLM',
|
|
110
|
-
provisioning: provisioningEnabled,
|
|
111
|
-
hint: provisioningEnabled
|
|
112
|
-
? 'Provisioning attempted but failed — check LiteLLM logs'
|
|
113
|
-
: 'Enable litellm.provisioning.enabled in app-config.yaml or create the user manually',
|
|
114
|
-
});
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
28
|
+
const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
|
|
29
|
+
const userId = tokenEntityRef
|
|
30
|
+
? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
|
|
31
|
+
: req.query.user_id;
|
|
32
|
+
const userInfo = await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
|
|
118
33
|
res.json(userInfo);
|
|
119
34
|
}
|
|
120
35
|
catch (error) {
|
|
36
|
+
if (error instanceof provisioning_1.ProvisioningError) {
|
|
37
|
+
res.status(error.status).json(error.body);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
121
40
|
logger.error('Failed to fetch user info', error);
|
|
122
41
|
res.status(500).json({ error: error.message });
|
|
123
42
|
}
|
|
124
43
|
});
|
|
125
44
|
router.get('/keys', async (req, res) => {
|
|
126
45
|
try {
|
|
127
|
-
const
|
|
128
|
-
const userId =
|
|
46
|
+
const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
|
|
47
|
+
const userId = tokenEntityRef
|
|
48
|
+
? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
|
|
49
|
+
: req.query.user_id;
|
|
50
|
+
await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
|
|
129
51
|
const keys = await client.listKeys(userId);
|
|
130
52
|
res.json(keys);
|
|
131
53
|
}
|
|
132
54
|
catch (error) {
|
|
55
|
+
if (error instanceof provisioning_1.ProvisioningError) {
|
|
56
|
+
res.status(error.status).json(error.body);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
133
59
|
logger.error('Failed to list keys', error);
|
|
134
60
|
res.status(500).json({ error: error.message });
|
|
135
61
|
}
|
|
136
62
|
});
|
|
137
63
|
router.post('/keys/generate', async (req, res) => {
|
|
138
64
|
try {
|
|
139
|
-
const
|
|
65
|
+
const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
|
|
66
|
+
const resolvedUserId = tokenEntityRef ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain) : undefined;
|
|
67
|
+
if (resolvedUserId) {
|
|
68
|
+
await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, resolvedUserId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
|
|
69
|
+
}
|
|
140
70
|
const request = {
|
|
141
71
|
...req.body,
|
|
142
|
-
...(
|
|
72
|
+
...(resolvedUserId && { user_id: resolvedUserId }),
|
|
143
73
|
};
|
|
144
74
|
const result = await client.generateKey(request);
|
|
145
75
|
res.json(result);
|
|
146
76
|
}
|
|
147
77
|
catch (error) {
|
|
78
|
+
if (error instanceof provisioning_1.ProvisioningError) {
|
|
79
|
+
res.status(error.status).json(error.body);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
148
82
|
logger.error('Failed to generate key', error);
|
|
149
83
|
res.status(500).json({ error: error.message });
|
|
150
84
|
}
|
|
151
85
|
});
|
|
86
|
+
router.post('/keys/:keyId/update', async (req, res) => {
|
|
87
|
+
try {
|
|
88
|
+
const { keyId } = req.params;
|
|
89
|
+
if (!keyId) {
|
|
90
|
+
res.status(400).json({ error: 'keyId is required' });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const request = { ...req.body, key: keyId };
|
|
94
|
+
const result = await client.updateKey(request);
|
|
95
|
+
res.json(result);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
logger.error('Failed to update key', error);
|
|
99
|
+
res.status(500).json({ error: error.message });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
152
102
|
router.delete('/keys/:keyId', async (req, res) => {
|
|
153
103
|
try {
|
|
154
104
|
const { keyId } = req.params;
|
|
@@ -176,9 +126,11 @@ async function createRouter(options) {
|
|
|
176
126
|
});
|
|
177
127
|
router.get('/teams', async (req, res) => {
|
|
178
128
|
try {
|
|
179
|
-
const
|
|
180
|
-
const userId =
|
|
181
|
-
|
|
129
|
+
const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
|
|
130
|
+
const userId = tokenEntityRef
|
|
131
|
+
? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
|
|
132
|
+
: req.query.user_id;
|
|
133
|
+
const userInfo = await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
|
|
182
134
|
if (!userInfo?.teams?.length) {
|
|
183
135
|
res.json([]);
|
|
184
136
|
return;
|
|
@@ -190,6 +142,10 @@ async function createRouter(options) {
|
|
|
190
142
|
res.json(teams.filter(Boolean));
|
|
191
143
|
}
|
|
192
144
|
catch (error) {
|
|
145
|
+
if (error instanceof provisioning_1.ProvisioningError) {
|
|
146
|
+
res.status(error.status).json(error.body);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
193
149
|
logger.error('Failed to fetch teams', error);
|
|
194
150
|
res.status(500).json({ error: error.message });
|
|
195
151
|
}
|
|
@@ -217,12 +173,21 @@ async function createRouter(options) {
|
|
|
217
173
|
res.status(400).json({ error: 'start_date and end_date are required' });
|
|
218
174
|
return;
|
|
219
175
|
}
|
|
220
|
-
const
|
|
221
|
-
const userId =
|
|
176
|
+
const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
|
|
177
|
+
const userId = tokenEntityRef
|
|
178
|
+
? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
|
|
179
|
+
: req.query.user_id;
|
|
180
|
+
if (userId) {
|
|
181
|
+
await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
|
|
182
|
+
}
|
|
222
183
|
const usage = await client.getUsage(start_date, end_date, userId, group_by);
|
|
223
184
|
res.json(usage);
|
|
224
185
|
}
|
|
225
186
|
catch (error) {
|
|
187
|
+
if (error instanceof provisioning_1.ProvisioningError) {
|
|
188
|
+
res.status(error.status).json(error.body);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
226
191
|
logger.error('Failed to fetch usage', error);
|
|
227
192
|
res.status(500).json({ error: error.message });
|
|
228
193
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -44,24 +44,60 @@ export interface ModelInfo {
|
|
|
44
44
|
input_cost_per_token?: number;
|
|
45
45
|
output_cost_per_token?: number;
|
|
46
46
|
}
|
|
47
|
+
export interface UsageModelBreakdown {
|
|
48
|
+
total_spend: number;
|
|
49
|
+
total_tokens: number;
|
|
50
|
+
prompt_tokens: number;
|
|
51
|
+
completion_tokens: number;
|
|
52
|
+
api_requests: number;
|
|
53
|
+
successful_requests: number;
|
|
54
|
+
failed_requests: number;
|
|
55
|
+
}
|
|
56
|
+
export interface UsageKeyBreakdown {
|
|
57
|
+
key_alias?: string;
|
|
58
|
+
team_id?: string | null;
|
|
59
|
+
models: string[];
|
|
60
|
+
total_spend: number;
|
|
61
|
+
total_tokens: number;
|
|
62
|
+
prompt_tokens: number;
|
|
63
|
+
completion_tokens: number;
|
|
64
|
+
api_requests: number;
|
|
65
|
+
successful_requests: number;
|
|
66
|
+
failed_requests: number;
|
|
67
|
+
}
|
|
68
|
+
export interface UsageDailyPoint {
|
|
69
|
+
date: string;
|
|
70
|
+
spend: number;
|
|
71
|
+
total_tokens: number;
|
|
72
|
+
prompt_tokens: number;
|
|
73
|
+
completion_tokens: number;
|
|
74
|
+
api_requests: number;
|
|
75
|
+
successful_requests: number;
|
|
76
|
+
failed_requests: number;
|
|
77
|
+
}
|
|
78
|
+
export interface UsageDailyModelPoint {
|
|
79
|
+
date: string;
|
|
80
|
+
model: string;
|
|
81
|
+
spend: number;
|
|
82
|
+
prompt_tokens: number;
|
|
83
|
+
completion_tokens: number;
|
|
84
|
+
total_tokens: number;
|
|
85
|
+
api_requests: number;
|
|
86
|
+
successful_requests: number;
|
|
87
|
+
failed_requests: number;
|
|
88
|
+
}
|
|
47
89
|
export interface UsageMetrics {
|
|
48
90
|
total_spend: number;
|
|
49
91
|
total_tokens: number;
|
|
50
92
|
prompt_tokens: number;
|
|
51
93
|
completion_tokens: number;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
date: string;
|
|
60
|
-
spend: number;
|
|
61
|
-
total_tokens: number;
|
|
62
|
-
prompt_tokens: number;
|
|
63
|
-
completion_tokens: number;
|
|
64
|
-
}>;
|
|
94
|
+
api_requests: number;
|
|
95
|
+
successful_requests: number;
|
|
96
|
+
failed_requests: number;
|
|
97
|
+
usage_by_model: Record<string, UsageModelBreakdown>;
|
|
98
|
+
usage_by_key: Record<string, UsageKeyBreakdown>;
|
|
99
|
+
daily_usage: UsageDailyPoint[];
|
|
100
|
+
daily_by_model: UsageDailyModelPoint[];
|
|
65
101
|
}
|
|
66
102
|
export interface GenerateKeyRequest {
|
|
67
103
|
alias?: string;
|
|
@@ -71,6 +107,18 @@ export interface GenerateKeyRequest {
|
|
|
71
107
|
tpm_limit?: number;
|
|
72
108
|
rpm_limit?: number;
|
|
73
109
|
user_id?: string;
|
|
110
|
+
team_id?: string;
|
|
111
|
+
key_type?: string;
|
|
112
|
+
}
|
|
113
|
+
export interface UpdateKeyRequest {
|
|
114
|
+
key: string;
|
|
115
|
+
key_alias?: string;
|
|
116
|
+
models?: string[];
|
|
117
|
+
max_budget?: number;
|
|
118
|
+
tpm_limit?: number;
|
|
119
|
+
rpm_limit?: number;
|
|
120
|
+
team_id?: string;
|
|
121
|
+
duration?: string;
|
|
74
122
|
}
|
|
75
123
|
export interface GenerateKeyResponse {
|
|
76
124
|
key: string;
|
|
@@ -97,6 +145,16 @@ export interface ProvisioningDefaults {
|
|
|
97
145
|
rpmLimit?: number;
|
|
98
146
|
metadata: Record<string, string>;
|
|
99
147
|
}
|
|
148
|
+
export interface RoleConfig {
|
|
149
|
+
group: string;
|
|
150
|
+
maxBudget?: number;
|
|
151
|
+
budgetDuration?: string;
|
|
152
|
+
models?: string[];
|
|
153
|
+
teams?: string[];
|
|
154
|
+
tpmLimit?: number;
|
|
155
|
+
rpmLimit?: number;
|
|
156
|
+
metadata?: Record<string, string>;
|
|
157
|
+
}
|
|
100
158
|
export interface CreateUserRequest {
|
|
101
159
|
user_id: string;
|
|
102
160
|
user_email?: string;
|