@acarmisc/backstage-plugin-litellm-backend 0.1.16 → 0.2.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.
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/plugin.ts", "../src/router.ts", "../src/client.ts"],
4
+ "sourcesContent": ["import { coreServices, createBackendPlugin } from '@backstage/backend-plugin-api';\nimport { createRouter } from './router';\n\nexport const litellmPlugin = createBackendPlugin({\n pluginId: 'litellm',\n register(reg) {\n reg.registerInit({\n deps: {\n httpRouter: coreServices.httpRouter,\n config: coreServices.rootConfig,\n logger: coreServices.logger,\n auth: coreServices.auth,\n discovery: coreServices.discovery,\n },\n async init({ httpRouter, config, logger, auth }) {\n const router = await createRouter({ config, logger, auth });\n httpRouter.use(router);\n },\n });\n },\n});\n", "import { Router, Request, Response } from 'express';\nimport { Config } from '@backstage/config';\nimport { AuthService } from '@backstage/backend-plugin-api';\nimport { LiteLLMClient } from './client';\nimport {\n UserInfo,\n VirtualKey,\n ModelInfo,\n UsageMetrics,\n TeamInfo,\n GenerateKeyRequest,\n GenerateKeyResponse,\n ProvisioningDefaults,\n} from './types';\n\nexport interface RouterOptions {\n config: Config;\n logger: any;\n auth: AuthService;\n}\n\n/**\n * Reads the provisioning block from config, applying safe defaults for every\n * field so the feature works out-of-the-box without any YAML required.\n *\n * Safe defaults rationale:\n * maxBudget: $10 \u2014 prevents runaway spend on a forgotten test account\n * budgetDuration: 30d \u2014 monthly reset, aligns with typical billing cycles\n * models: [] \u2014 empty means all proxy models are allowed;\n * restrict here or at team level for tighter control\n * teams: [] \u2014 no automatic team assignment; add IDs to enrol users\n * tpmLimit: none \u2014 LiteLLM global / team limits still apply\n * rpmLimit: none \u2014 same\n * metadata: backstage source tag only\n */\nfunction readProvisioningDefaults(config: Config): { enabled: boolean; defaults: ProvisioningDefaults } {\n const enabled = config.getOptionalBoolean('litellm.provisioning.enabled') ?? false;\n const defaults: ProvisioningDefaults = {\n maxBudget: config.getOptionalNumber('litellm.provisioning.defaults.maxBudget') ?? 10,\n budgetDuration: config.getOptionalString('litellm.provisioning.defaults.budgetDuration') ?? '30d',\n models: config.getOptionalStringArray('litellm.provisioning.defaults.models') ?? [],\n teams: config.getOptionalStringArray('litellm.provisioning.defaults.teams') ?? [],\n tpmLimit: config.getOptionalNumber('litellm.provisioning.defaults.tpmLimit'),\n rpmLimit: config.getOptionalNumber('litellm.provisioning.defaults.rpmLimit'),\n metadata: (config.getOptional<Record<string, string>>('litellm.provisioning.defaults.metadata') ?? {}),\n };\n return { enabled, defaults };\n}\n\n/**\n * Extracts the authenticated Backstage user identity from the request token.\n * Returns the userEntityRef (e.g. \"user:default/john.doe\") or undefined when\n * the request carries no user credential (service-to-service calls).\n */\nasync function resolveUserId(req: Request, auth: AuthService): Promise<string | undefined> {\n const rawToken = req.headers.authorization?.slice(7);\n if (!rawToken) return undefined;\n try {\n const credentials = await auth.authenticate(rawToken);\n const principal = credentials.principal as any;\n if (principal?.type === 'user') {\n return principal.userEntityRef as string;\n }\n } catch {\n // invalid or service token \u2014 caller gets query-param fallback\n }\n return undefined;\n}\n\n/**\n * Creates a LiteLLM user for the given Backstage identity using the configured\n * defaults. Returns the UserInfo of the newly created account.\n */\nasync function provisionUser(\n client: LiteLLMClient,\n userId: string,\n defaults: ProvisioningDefaults,\n logger: any,\n): Promise<UserInfo | null> {\n const payload = {\n user_id: userId,\n max_budget: defaults.maxBudget,\n budget_duration: defaults.budgetDuration,\n models: defaults.models,\n teams: defaults.teams,\n ...(defaults.tpmLimit !== undefined && { tpm_limit: defaults.tpmLimit }),\n ...(defaults.rpmLimit !== undefined && { rpm_limit: defaults.rpmLimit }),\n metadata: {\n ...defaults.metadata,\n provisioned_by: 'backstage',\n provisioned_at: new Date().toISOString(),\n backstage_entity: userId,\n },\n };\n\n logger.info(`Provisioning new LiteLLM user for Backstage identity: ${userId}`);\n try {\n await client.createUser(payload);\n // Fetch the freshly-created user record to return consistent UserInfo shape\n return await client.getUserInfo(userId);\n } catch (err: any) {\n logger.error(`Failed to provision LiteLLM user ${userId}: ${err.message}`);\n return null;\n }\n}\n\nexport async function createRouter(options: RouterOptions): Promise<Router> {\n const { config, logger, auth } = options;\n\n const baseUrl = config.getString('litellm.baseUrl');\n const masterKey = config.getString('litellm.masterKey');\n const client = new LiteLLMClient({ baseUrl, masterKey });\n const { enabled: provisioningEnabled, defaults: provisioningDefaults } = readProvisioningDefaults(config);\n\n if (provisioningEnabled) {\n logger.info(\n `LiteLLM auto-provisioning enabled \u2014 defaults: budget=$${provisioningDefaults.maxBudget}/${provisioningDefaults.budgetDuration}, models=${provisioningDefaults.models.length ? provisioningDefaults.models.join(',') : 'all'}, teams=[${provisioningDefaults.teams.join(',')}]`,\n );\n }\n\n const router = Router();\n\n router.get('/health', (_req: Request, res: Response) => {\n res.json({ status: 'ok', provisioning: provisioningEnabled });\n });\n\n router.get('/user/info', async (req: Request, res: Response) => {\n try {\n const tokenUserId = await resolveUserId(req, auth);\n const userId = tokenUserId ?? (req.query.user_id as string | undefined);\n\n let userInfo: UserInfo | null = await client.getUserInfo(userId);\n\n if (!userInfo) {\n if (provisioningEnabled && userId) {\n userInfo = await provisionUser(client, userId, provisioningDefaults, logger);\n }\n\n if (!userInfo) {\n res.status(404).json({\n error: 'User not found in LiteLLM',\n provisioning: provisioningEnabled,\n hint: provisioningEnabled\n ? 'Provisioning attempted but failed \u2014 check LiteLLM logs'\n : 'Enable litellm.provisioning.enabled in app-config.yaml or create the user manually',\n });\n return;\n }\n }\n\n res.json(userInfo);\n } catch (error: any) {\n logger.error('Failed to fetch user info', error);\n res.status(500).json({ error: error.message });\n }\n });\n\n router.get('/keys', async (req: Request, res: Response) => {\n try {\n const tokenUserId = await resolveUserId(req, auth);\n const userId = tokenUserId ?? (req.query.user_id as string | undefined);\n const keys: VirtualKey[] = await client.listKeys(userId);\n res.json(keys);\n } catch (error: any) {\n logger.error('Failed to list keys', error);\n res.status(500).json({ error: error.message });\n }\n });\n\n router.post('/keys/generate', async (req: Request, res: Response) => {\n try {\n const tokenUserId = await resolveUserId(req, auth);\n const request: GenerateKeyRequest = {\n ...req.body,\n ...(tokenUserId && { user_id: tokenUserId }),\n };\n const result: GenerateKeyResponse = await client.generateKey(request);\n res.json(result);\n } catch (error: any) {\n logger.error('Failed to generate key', error);\n res.status(500).json({ error: error.message });\n }\n });\n\n router.delete('/keys/:keyId', async (req: Request, res: Response) => {\n try {\n const { keyId } = req.params;\n if (!keyId) {\n res.status(400).json({ error: 'keyId is required' });\n return;\n }\n await client.deleteKeys({ keys: [keyId] });\n res.json({ success: true });\n } catch (error: any) {\n logger.error('Failed to delete key', error);\n res.status(500).json({ error: error.message });\n }\n });\n\n router.get('/models', async (_req: Request, res: Response) => {\n try {\n const models: ModelInfo[] = await client.listModels();\n res.json(models);\n } catch (error: any) {\n logger.error('Failed to list models', error);\n res.status(500).json({ error: error.message });\n }\n });\n\n router.get('/teams', async (req: Request, res: Response) => {\n try {\n const tokenUserId = await resolveUserId(req, auth);\n const userId = tokenUserId ?? (req.query.user_id as string | undefined);\n const userInfo: UserInfo | null = await client.getUserInfo(userId);\n\n if (!userInfo?.teams?.length) {\n res.json([]);\n return;\n }\n\n const teams = await Promise.all(\n userInfo.teams.map(teamId =>\n client.getTeamInfo(teamId).catch(err => {\n logger.warn(`Failed to fetch team ${teamId}: ${err.message}`);\n return null;\n }),\n ),\n );\n res.json(teams.filter(Boolean) as TeamInfo[]);\n } catch (error: any) {\n logger.error('Failed to fetch teams', error);\n res.status(500).json({ error: error.message });\n }\n });\n\n router.get('/teams/:teamId/usage', async (req: Request, res: Response) => {\n try {\n const { teamId } = req.params;\n const { start_date, end_date } = req.query;\n if (!start_date || !end_date) {\n res.status(400).json({ error: 'start_date and end_date are required' });\n return;\n }\n const usage: UsageMetrics = await client.getTeamUsage(\n teamId,\n start_date as string,\n end_date as string,\n );\n res.json(usage);\n } catch (error: any) {\n logger.error('Failed to fetch team usage', error);\n res.status(500).json({ error: error.message });\n }\n });\n\n router.get('/usage', async (req: Request, res: Response) => {\n try {\n const { start_date, end_date, group_by } = req.query;\n if (!start_date || !end_date) {\n res.status(400).json({ error: 'start_date and end_date are required' });\n return;\n }\n const tokenUserId = await resolveUserId(req, auth);\n const userId = tokenUserId ?? (req.query.user_id as string | undefined);\n const usage: UsageMetrics = await client.getUsage(\n start_date as string,\n end_date as string,\n userId,\n group_by as string | undefined,\n );\n res.json(usage);\n } catch (error: any) {\n logger.error('Failed to fetch usage', error);\n res.status(500).json({ error: error.message });\n }\n });\n\n return router;\n}\n", "import {\n LiteLLMConfig,\n UserInfo,\n VirtualKey,\n ModelInfo,\n UsageMetrics,\n TeamInfo,\n GenerateKeyRequest,\n GenerateKeyResponse,\n DeleteKeyRequest,\n CreateUserRequest,\n CreateUserResponse,\n} from './types';\n\nconst DEFAULT_TIMEOUT = 30000;\n\nexport class LiteLLMClient {\n private baseUrl: string;\n private masterKey: string;\n private timeout: number;\n\n constructor(config: LiteLLMConfig, timeout = DEFAULT_TIMEOUT) {\n this.baseUrl = config.baseUrl.replace(/\\/$/, '');\n this.masterKey = config.masterKey;\n this.timeout = timeout;\n }\n\n private async request<T>(path: string, options: RequestInit = {}): Promise<T> {\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), this.timeout);\n\n try {\n const response = await fetch(`${this.baseUrl}${path}`, {\n ...options,\n signal: controller.signal,\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${this.masterKey}`,\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const errorBody = await response.text();\n const err = new Error(`LiteLLM API error: ${response.status} ${response.statusText} - ${errorBody}`);\n (err as any).status = response.status;\n throw err;\n }\n\n return response.json();\n } finally {\n clearTimeout(timeoutId);\n }\n }\n\n /**\n * Returns null when the user is not found in LiteLLM (404).\n * Throws on all other errors so callers know something went wrong.\n */\n async getUserInfo(userId?: string): Promise<UserInfo | null> {\n const query = userId ? `?user_id=${encodeURIComponent(userId)}` : '';\n try {\n return await this.request<UserInfo>(`/user/info${query}`);\n } catch (err: any) {\n if (err.status === 404) return null;\n throw err;\n }\n }\n\n async createUser(payload: CreateUserRequest): Promise<CreateUserResponse> {\n return this.request<CreateUserResponse>('/user/new', {\n method: 'POST',\n body: JSON.stringify(payload),\n });\n }\n\n async listKeys(userId?: string): Promise<VirtualKey[]> {\n const query = userId ? `?user_id=${encodeURIComponent(userId)}` : '';\n const response = await this.request<{ info: VirtualKey[] } | VirtualKey[]>(`/key/info${query}`);\n return Array.isArray(response) ? response : (response.info ?? []);\n }\n\n async generateKey(request: GenerateKeyRequest): Promise<GenerateKeyResponse> {\n return this.request<GenerateKeyResponse>('/key/generate', {\n method: 'POST',\n body: JSON.stringify(request),\n });\n }\n\n async deleteKeys(request: DeleteKeyRequest): Promise<{ success: boolean }> {\n return this.request<{ success: boolean }>('/key/delete', {\n method: 'POST',\n body: JSON.stringify(request),\n });\n }\n\n async listModels(): Promise<ModelInfo[]> {\n const response = await this.request<{ data: ModelInfo[] } | ModelInfo[]>('/models');\n return Array.isArray(response) ? response : (response.data ?? []);\n }\n\n async getTeamInfo(teamId: string): Promise<TeamInfo> {\n return this.request<TeamInfo>(`/team/info?team_id=${encodeURIComponent(teamId)}`);\n }\n\n async getUsage(startDate: string, endDate: string, userId?: string, groupBy?: string): Promise<UsageMetrics> {\n const params = new URLSearchParams({ start_date: startDate, end_date: endDate });\n if (userId) params.append('user_id', userId);\n if (groupBy) params.append('group_by', groupBy);\n return this.request<UsageMetrics>(`/usage/keys?${params.toString()}`);\n }\n\n async getTeamUsage(teamId: string, startDate: string, endDate: string): Promise<UsageMetrics> {\n const params = new URLSearchParams({ start_date: startDate, end_date: endDate, team_id: teamId });\n return this.request<UsageMetrics>(`/usage/keys?${params.toString()}`);\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.LiteLLMClient = exports.createRouter = exports.litellmPlugin = void 0;
18
+ var plugin_1 = require("./plugin");
19
+ Object.defineProperty(exports, "litellmPlugin", { enumerable: true, get: function () { return plugin_1.litellmPlugin; } });
20
+ var router_1 = require("./router");
21
+ Object.defineProperty(exports, "createRouter", { enumerable: true, get: function () { return router_1.createRouter; } });
22
+ __exportStar(require("./types"), exports);
23
+ var client_1 = require("./client");
24
+ Object.defineProperty(exports, "LiteLLMClient", { enumerable: true, get: function () { return client_1.LiteLLMClient; } });
@@ -0,0 +1 @@
1
+ export declare const litellmPlugin: import("@backstage/backend-plugin-api").BackendFeature;
package/dist/plugin.js ADDED
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.litellmPlugin = void 0;
4
+ const backend_plugin_api_1 = require("@backstage/backend-plugin-api");
5
+ const router_1 = require("./router");
6
+ exports.litellmPlugin = (0, backend_plugin_api_1.createBackendPlugin)({
7
+ pluginId: 'litellm',
8
+ register(reg) {
9
+ reg.registerInit({
10
+ deps: {
11
+ httpRouter: backend_plugin_api_1.coreServices.httpRouter,
12
+ config: backend_plugin_api_1.coreServices.rootConfig,
13
+ logger: backend_plugin_api_1.coreServices.logger,
14
+ auth: backend_plugin_api_1.coreServices.auth,
15
+ discovery: backend_plugin_api_1.coreServices.discovery,
16
+ },
17
+ async init({ httpRouter, config, logger, auth, discovery }) {
18
+ const router = await (0, router_1.createRouter)({ config, logger, auth, discovery });
19
+ httpRouter.use(router);
20
+ },
21
+ });
22
+ },
23
+ });
@@ -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
+ }
@@ -0,0 +1,12 @@
1
+ import { Router } from 'express';
2
+ import { Config } from '@backstage/config';
3
+ import { AuthService, DiscoveryService } from '@backstage/backend-plugin-api';
4
+ import { ProvisioningError } from './provisioning';
5
+ export { ProvisioningError };
6
+ export interface RouterOptions {
7
+ config: Config;
8
+ logger: any;
9
+ auth: AuthService;
10
+ discovery: DiscoveryService;
11
+ }
12
+ export declare function createRouter(options: RouterOptions): Promise<Router>;
package/dist/router.js ADDED
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ProvisioningError = void 0;
4
+ exports.createRouter = createRouter;
5
+ const express_1 = require("express");
6
+ const catalog_client_1 = require("@backstage/catalog-client");
7
+ const client_1 = require("./client");
8
+ const provisioning_1 = require("./provisioning");
9
+ Object.defineProperty(exports, "ProvisioningError", { enumerable: true, get: function () { return provisioning_1.ProvisioningError; } });
10
+ async function createRouter(options) {
11
+ const { config, logger, auth, discovery } = options;
12
+ const baseUrl = config.getString('litellm.baseUrl');
13
+ const masterKey = config.getString('litellm.masterKey');
14
+ const userIdDomain = config.getOptionalString('litellm.userIdDomain');
15
+ const client = new client_1.LiteLLMClient({ baseUrl, masterKey });
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 });
19
+ if (provisioningEnabled) {
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(',')}]`);
21
+ }
22
+ const router = (0, express_1.Router)();
23
+ router.get('/health', (_req, res) => {
24
+ res.json({ status: 'ok', provisioning: provisioningEnabled });
25
+ });
26
+ router.get('/user/info', async (req, res) => {
27
+ try {
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);
33
+ res.json(userInfo);
34
+ }
35
+ catch (error) {
36
+ if (error instanceof provisioning_1.ProvisioningError) {
37
+ res.status(error.status).json(error.body);
38
+ return;
39
+ }
40
+ logger.error('Failed to fetch user info', error);
41
+ res.status(500).json({ error: error.message });
42
+ }
43
+ });
44
+ router.get('/keys', async (req, res) => {
45
+ try {
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);
51
+ const keys = await client.listKeys(userId);
52
+ res.json(keys);
53
+ }
54
+ catch (error) {
55
+ if (error instanceof provisioning_1.ProvisioningError) {
56
+ res.status(error.status).json(error.body);
57
+ return;
58
+ }
59
+ logger.error('Failed to list keys', error);
60
+ res.status(500).json({ error: error.message });
61
+ }
62
+ });
63
+ router.post('/keys/generate', async (req, res) => {
64
+ try {
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
+ }
70
+ const request = {
71
+ ...req.body,
72
+ ...(resolvedUserId && { user_id: resolvedUserId }),
73
+ };
74
+ const result = await client.generateKey(request);
75
+ res.json(result);
76
+ }
77
+ catch (error) {
78
+ if (error instanceof provisioning_1.ProvisioningError) {
79
+ res.status(error.status).json(error.body);
80
+ return;
81
+ }
82
+ logger.error('Failed to generate key', error);
83
+ res.status(500).json({ error: error.message });
84
+ }
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
+ });
102
+ router.delete('/keys/:keyId', async (req, res) => {
103
+ try {
104
+ const { keyId } = req.params;
105
+ if (!keyId) {
106
+ res.status(400).json({ error: 'keyId is required' });
107
+ return;
108
+ }
109
+ await client.deleteKeys({ keys: [keyId] });
110
+ res.json({ success: true });
111
+ }
112
+ catch (error) {
113
+ logger.error('Failed to delete key', error);
114
+ res.status(500).json({ error: error.message });
115
+ }
116
+ });
117
+ router.get('/models', async (_req, res) => {
118
+ try {
119
+ const models = await client.listModels();
120
+ res.json(models);
121
+ }
122
+ catch (error) {
123
+ logger.error('Failed to list models', error);
124
+ res.status(500).json({ error: error.message });
125
+ }
126
+ });
127
+ router.get('/teams', async (req, res) => {
128
+ try {
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);
134
+ if (!userInfo?.teams?.length) {
135
+ res.json([]);
136
+ return;
137
+ }
138
+ const teams = await Promise.all(userInfo.teams.map(teamId => client.getTeamInfo(teamId).catch(err => {
139
+ logger.warn(`Failed to fetch team ${teamId}: ${err.message}`);
140
+ return null;
141
+ })));
142
+ res.json(teams.filter(Boolean));
143
+ }
144
+ catch (error) {
145
+ if (error instanceof provisioning_1.ProvisioningError) {
146
+ res.status(error.status).json(error.body);
147
+ return;
148
+ }
149
+ logger.error('Failed to fetch teams', error);
150
+ res.status(500).json({ error: error.message });
151
+ }
152
+ });
153
+ router.get('/teams/:teamId/usage', async (req, res) => {
154
+ try {
155
+ const { teamId } = req.params;
156
+ const { start_date, end_date } = req.query;
157
+ if (!start_date || !end_date) {
158
+ res.status(400).json({ error: 'start_date and end_date are required' });
159
+ return;
160
+ }
161
+ const usage = await client.getTeamUsage(teamId, start_date, end_date);
162
+ res.json(usage);
163
+ }
164
+ catch (error) {
165
+ logger.error('Failed to fetch team usage', error);
166
+ res.status(500).json({ error: error.message });
167
+ }
168
+ });
169
+ router.get('/usage', async (req, res) => {
170
+ try {
171
+ const { start_date, end_date, group_by } = req.query;
172
+ if (!start_date || !end_date) {
173
+ res.status(400).json({ error: 'start_date and end_date are required' });
174
+ return;
175
+ }
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
+ }
183
+ const usage = await client.getUsage(start_date, end_date, userId, group_by);
184
+ res.json(usage);
185
+ }
186
+ catch (error) {
187
+ if (error instanceof provisioning_1.ProvisioningError) {
188
+ res.status(error.status).json(error.body);
189
+ return;
190
+ }
191
+ logger.error('Failed to fetch usage', error);
192
+ res.status(500).json({ error: error.message });
193
+ }
194
+ });
195
+ return router;
196
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/types.ts"],
4
- "sourcesContent": ["export interface UserInfo {\n user_id: string;\n user_email?: string;\n email?: string;\n teams?: string[];\n models?: string[];\n max_budget?: number;\n spend?: number;\n current_spend?: number;\n soft_limit?: number;\n hard_limit?: number;\n}\n\nexport interface TeamMember {\n user_id: string;\n role: 'admin' | 'user';\n}\n\nexport interface TeamInfo {\n team_id: string;\n team_alias?: string;\n max_budget?: number;\n spend: number;\n members_with_roles?: TeamMember[];\n models?: string[];\n tpm_limit?: number;\n rpm_limit?: number;\n}\n\nexport interface VirtualKey {\n key: string;\n token: string;\n key_alias?: string;\n created_at: string;\n expires_at?: string;\n spend: number;\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n models?: string[];\n user_id?: string;\n}\n\n/**\n * Shape of a single entry inside LiteLLM's `/user/info` `keys` array.\n * Differs from VirtualKey: uses `expires` (not `expires_at`), exposes\n * both a hashed `token` and a masked `key_name`, and fields are nullable\n * rather than optional.\n */\nexport interface LiteLLMUserKey {\n token: string;\n key_name?: string;\n key_alias?: string | null;\n spend?: number;\n expires?: string | null;\n models?: string[];\n tpm_limit?: number | null;\n rpm_limit?: number | null;\n max_budget?: number | null;\n user_id?: string | null;\n team_id?: string | null;\n created_at: string;\n}\n\nexport interface ModelInfo {\n model_name: string;\n mode: string;\n supports_function_calling?: boolean;\n supports_vision?: boolean;\n input_cost_per_token?: number;\n output_cost_per_token?: number;\n}\n\nexport interface UsageModelBreakdown {\n total_spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageKeyBreakdown {\n key_alias?: string;\n team_id?: string | null;\n models: string[];\n total_spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageDailyPoint {\n date: string;\n spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageDailyModelPoint {\n date: string;\n model: string;\n spend: number;\n prompt_tokens: number;\n completion_tokens: number;\n total_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageMetrics {\n total_spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n usage_by_model: Record<string, UsageModelBreakdown>;\n usage_by_key: Record<string, UsageKeyBreakdown>;\n daily_usage: UsageDailyPoint[];\n daily_by_model: UsageDailyModelPoint[];\n}\n\nexport interface GenerateKeyRequest {\n alias?: string;\n models?: string[];\n duration?: string;\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n user_id?: string;\n team_id?: string;\n key_type?: string;\n metadata?: Record<string, string>;\n}\n\nexport interface UpdateKeyRequest {\n key: string;\n key_alias?: string;\n models?: string[];\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n team_id?: string;\n duration?: string;\n}\n\nexport interface GenerateKeyResponse {\n key: string;\n key_alias?: string;\n expires_at?: string;\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n models?: string[];\n}\n\nexport interface DeleteKeyRequest {\n keys: string[];\n}\n\nexport interface LiteLLMConfig {\n baseUrl: string;\n masterKey: string;\n}\n\nexport interface ProvisioningDefaults {\n maxBudget: number;\n budgetDuration: string;\n models: string[];\n teams: string[];\n tpmLimit?: number;\n rpmLimit?: number;\n /**\n * LiteLLM user role applied on /user/new. Defaults to \"internal_user\"\n * which grants self-service Create/Delete/View on the user's own keys.\n * Valid values: proxy_admin, proxy_admin_viewer, internal_user,\n * internal_user_viewer, team.\n */\n userRole?: string;\n metadata: Record<string, string>;\n}\n\nexport interface RoleConfig {\n group: string;\n maxBudget?: number;\n budgetDuration?: string;\n models?: string[];\n teams?: string[];\n tpmLimit?: number;\n rpmLimit?: number;\n userRole?: string;\n metadata?: Record<string, string>;\n}\n\nexport interface CreateUserRequest {\n user_id: string;\n user_email?: string;\n user_alias?: string;\n user_role?: string;\n max_budget?: number;\n budget_duration?: string;\n models?: string[];\n teams?: string[];\n tpm_limit?: number;\n rpm_limit?: number;\n metadata?: Record<string, string>;\n auto_create_key?: boolean;\n}\n\nexport interface CreateUserResponse {\n user_id: string;\n user_email?: string;\n max_budget?: number;\n models?: string[];\n teams?: string[];\n}\n"],
4
+ "sourcesContent": ["export interface UserInfo {\n user_id: string;\n user_email?: string;\n email?: string;\n teams?: string[];\n models?: string[];\n max_budget?: number;\n spend?: number;\n current_spend?: number;\n soft_limit?: number;\n hard_limit?: number;\n}\n\nexport interface TeamMember {\n user_id: string;\n role: 'admin' | 'user';\n}\n\nexport interface TeamInfo {\n team_id: string;\n team_alias?: string;\n max_budget?: number;\n spend: number;\n members_with_roles?: TeamMember[];\n models?: string[];\n tpm_limit?: number;\n rpm_limit?: number;\n}\n\nexport interface VirtualKey {\n key: string;\n key_alias?: string;\n created_at: string;\n expires_at?: string;\n spend: number;\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n models?: string[];\n user_id?: string;\n}\n\nexport interface ModelInfo {\n model_name: string;\n mode: string;\n supports_function_calling?: boolean;\n supports_vision?: boolean;\n input_cost_per_token?: number;\n output_cost_per_token?: number;\n}\n\nexport interface UsageModelBreakdown {\n total_spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageKeyBreakdown {\n key_alias?: string;\n team_id?: string | null;\n models: string[];\n total_spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageDailyPoint {\n date: string;\n spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageDailyModelPoint {\n date: string;\n model: string;\n spend: number;\n prompt_tokens: number;\n completion_tokens: number;\n total_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n}\n\nexport interface UsageMetrics {\n total_spend: number;\n total_tokens: number;\n prompt_tokens: number;\n completion_tokens: number;\n api_requests: number;\n successful_requests: number;\n failed_requests: number;\n usage_by_model: Record<string, UsageModelBreakdown>;\n usage_by_key: Record<string, UsageKeyBreakdown>;\n daily_usage: UsageDailyPoint[];\n daily_by_model: UsageDailyModelPoint[];\n}\n\nexport interface GenerateKeyRequest {\n alias?: string;\n models?: string[];\n duration?: string;\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n user_id?: string;\n team_id?: string;\n key_type?: string;\n}\n\nexport interface UpdateKeyRequest {\n key: string;\n key_alias?: string;\n models?: string[];\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n team_id?: string;\n duration?: string;\n}\n\nexport interface GenerateKeyResponse {\n key: string;\n key_alias?: string;\n expires_at?: string;\n max_budget?: number;\n tpm_limit?: number;\n rpm_limit?: number;\n models?: string[];\n}\n\nexport interface DeleteKeyRequest {\n keys: string[];\n}\n\nexport interface LiteLLMConfig {\n baseUrl: string;\n masterKey: string;\n}\n\nexport interface ProvisioningDefaults {\n maxBudget: number;\n budgetDuration: string;\n models: string[];\n teams: string[];\n tpmLimit?: number;\n rpmLimit?: number;\n metadata: Record<string, string>;\n}\n\nexport interface RoleConfig {\n group: string;\n maxBudget?: number;\n budgetDuration?: string;\n models?: string[];\n teams?: string[];\n tpmLimit?: number;\n rpmLimit?: number;\n metadata?: Record<string, string>;\n}\n\nexport interface CreateUserRequest {\n user_id: string;\n user_email?: string;\n max_budget?: number;\n budget_duration?: string;\n models?: string[];\n teams?: string[];\n tpm_limit?: number;\n rpm_limit?: number;\n metadata?: Record<string, string>;\n}\n\nexport interface CreateUserResponse {\n user_id: string;\n user_email?: string;\n max_budget?: number;\n models?: string[];\n teams?: string[];\n}\n"],
5
5
  "mappings": ";;;;;;;;;;;;;;;;AAAA;AAAA;",
6
6
  "names": []
7
7
  }