@acarmisc/backstage-plugin-litellm-backend 0.2.0 → 0.2.1
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 +31 -0
- package/dist/client.js +67 -7
- package/dist/index.cjs.js +119 -12
- package/dist/index.cjs.js.map +3 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/provisioning.d.ts +17 -2
- package/dist/provisioning.js +168 -25
- package/dist/router.js +82 -4
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +33 -0
- package/package.json +3 -3
- package/dist/index.esm.js +0 -323
- package/dist/index.esm.js.map +0 -7
package/dist/router.js
CHANGED
|
@@ -1,8 +1,41 @@
|
|
|
1
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 __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.ProvisioningError = void 0;
|
|
4
37
|
exports.createRouter = createRouter;
|
|
5
|
-
const express_1 = require("express");
|
|
38
|
+
const express_1 = __importStar(require("express"));
|
|
6
39
|
const catalog_client_1 = require("@backstage/catalog-client");
|
|
7
40
|
const client_1 = require("./client");
|
|
8
41
|
const provisioning_1 = require("./provisioning");
|
|
@@ -17,9 +50,15 @@ async function createRouter(options) {
|
|
|
17
50
|
const roleConfigs = (0, provisioning_1.readRoleConfigs)(config);
|
|
18
51
|
const catalogClient = new catalog_client_1.CatalogClient({ discoveryApi: discovery });
|
|
19
52
|
if (provisioningEnabled) {
|
|
20
|
-
logger.info(`LiteLLM auto-provisioning enabled — defaults: budget=$${provisioningDefaults.maxBudget}/${provisioningDefaults.budgetDuration}, models=${provisioningDefaults.models.length
|
|
53
|
+
logger.info(`LiteLLM auto-provisioning enabled — defaults: budget=$${provisioningDefaults.maxBudget}/${provisioningDefaults.budgetDuration}, models=${provisioningDefaults.models.length
|
|
54
|
+
? provisioningDefaults.models.join(',')
|
|
55
|
+
: 'all'}, teams=[${provisioningDefaults.teams.join(',')}]`);
|
|
21
56
|
}
|
|
22
57
|
const router = (0, express_1.Router)();
|
|
58
|
+
// JSON body parser. Without this, every POST/PUT endpoint sees an empty
|
|
59
|
+
// req.body. Backstage's httpRouter does not apply a body parser at the
|
|
60
|
+
// plugin-router level, so each plugin must attach its own.
|
|
61
|
+
router.use(express_1.default.json());
|
|
23
62
|
router.get('/health', (_req, res) => {
|
|
24
63
|
res.json({ status: 'ok', provisioning: provisioningEnabled });
|
|
25
64
|
});
|
|
@@ -62,13 +101,52 @@ async function createRouter(options) {
|
|
|
62
101
|
});
|
|
63
102
|
router.post('/keys/generate', async (req, res) => {
|
|
64
103
|
try {
|
|
104
|
+
// Only alias + max_budget are required. An empty models array is
|
|
105
|
+
// intentional — in LiteLLM `models: []` means "all models the user
|
|
106
|
+
// can access" which is the desired default. Forcing a selection
|
|
107
|
+
// up front is too restrictive for the common case.
|
|
108
|
+
const body = (req.body ?? {});
|
|
109
|
+
const missing = [];
|
|
110
|
+
if (!body.alias?.trim())
|
|
111
|
+
missing.push('alias');
|
|
112
|
+
if (typeof body.max_budget !== 'number' || body.max_budget <= 0) {
|
|
113
|
+
missing.push('max_budget (positive number)');
|
|
114
|
+
}
|
|
115
|
+
if (missing.length) {
|
|
116
|
+
res.status(400).json({
|
|
117
|
+
error: 'Missing required fields',
|
|
118
|
+
hint: `Required: ${missing.join(', ')}`,
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
65
122
|
const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
|
|
66
|
-
const resolvedUserId = tokenEntityRef
|
|
123
|
+
const resolvedUserId = tokenEntityRef
|
|
124
|
+
? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
|
|
125
|
+
: undefined;
|
|
67
126
|
if (resolvedUserId) {
|
|
68
127
|
await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, resolvedUserId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
|
|
69
128
|
}
|
|
129
|
+
// Stamp ownership into LiteLLM key metadata. LiteLLM's native
|
|
130
|
+
// `created_by` column is only populated when the caller authenticates
|
|
131
|
+
// via JWT/SSO; we always call with the master key, so that column
|
|
132
|
+
// stays null. Enriching `metadata` makes the owner identity visible
|
|
133
|
+
// in LiteLLM's UI and queryable via API.
|
|
134
|
+
const profile = tokenEntityRef
|
|
135
|
+
? await (0, provisioning_1.resolveUserProfile)(tokenEntityRef, catalogClient, auth, logger)
|
|
136
|
+
: {};
|
|
137
|
+
const enrichedMetadata = {
|
|
138
|
+
...(body.metadata ?? {}),
|
|
139
|
+
created_by_backstage_user: tokenEntityRef ?? 'unknown',
|
|
140
|
+
...(profile.email && { created_by_email: profile.email }),
|
|
141
|
+
...(profile.displayName && {
|
|
142
|
+
created_by_display_name: profile.displayName,
|
|
143
|
+
}),
|
|
144
|
+
created_via: 'backstage',
|
|
145
|
+
created_at_iso: new Date().toISOString(),
|
|
146
|
+
};
|
|
70
147
|
const request = {
|
|
71
|
-
...
|
|
148
|
+
...body,
|
|
149
|
+
metadata: enrichedMetadata,
|
|
72
150
|
...(resolvedUserId && { user_id: resolvedUserId }),
|
|
73
151
|
};
|
|
74
152
|
const result = await client.generateKey(request);
|
package/dist/types.cjs.js.map
CHANGED
|
@@ -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 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"],
|
|
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"],
|
|
5
5
|
"mappings": ";;;;;;;;;;;;;;;;AAAA;AAAA;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface TeamInfo {
|
|
|
26
26
|
}
|
|
27
27
|
export interface VirtualKey {
|
|
28
28
|
key: string;
|
|
29
|
+
token: string;
|
|
29
30
|
key_alias?: string;
|
|
30
31
|
created_at: string;
|
|
31
32
|
expires_at?: string;
|
|
@@ -36,6 +37,26 @@ export interface VirtualKey {
|
|
|
36
37
|
models?: string[];
|
|
37
38
|
user_id?: string;
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Shape of a single entry inside LiteLLM's `/user/info` `keys` array.
|
|
42
|
+
* Differs from VirtualKey: uses `expires` (not `expires_at`), exposes
|
|
43
|
+
* both a hashed `token` and a masked `key_name`, and fields are nullable
|
|
44
|
+
* rather than optional.
|
|
45
|
+
*/
|
|
46
|
+
export interface LiteLLMUserKey {
|
|
47
|
+
token: string;
|
|
48
|
+
key_name?: string;
|
|
49
|
+
key_alias?: string | null;
|
|
50
|
+
spend?: number;
|
|
51
|
+
expires?: string | null;
|
|
52
|
+
models?: string[];
|
|
53
|
+
tpm_limit?: number | null;
|
|
54
|
+
rpm_limit?: number | null;
|
|
55
|
+
max_budget?: number | null;
|
|
56
|
+
user_id?: string | null;
|
|
57
|
+
team_id?: string | null;
|
|
58
|
+
created_at: string;
|
|
59
|
+
}
|
|
39
60
|
export interface ModelInfo {
|
|
40
61
|
model_name: string;
|
|
41
62
|
mode: string;
|
|
@@ -109,6 +130,7 @@ export interface GenerateKeyRequest {
|
|
|
109
130
|
user_id?: string;
|
|
110
131
|
team_id?: string;
|
|
111
132
|
key_type?: string;
|
|
133
|
+
metadata?: Record<string, string>;
|
|
112
134
|
}
|
|
113
135
|
export interface UpdateKeyRequest {
|
|
114
136
|
key: string;
|
|
@@ -143,6 +165,13 @@ export interface ProvisioningDefaults {
|
|
|
143
165
|
teams: string[];
|
|
144
166
|
tpmLimit?: number;
|
|
145
167
|
rpmLimit?: number;
|
|
168
|
+
/**
|
|
169
|
+
* LiteLLM user role applied on /user/new. Defaults to "internal_user"
|
|
170
|
+
* which grants self-service Create/Delete/View on the user's own keys.
|
|
171
|
+
* Valid values: proxy_admin, proxy_admin_viewer, internal_user,
|
|
172
|
+
* internal_user_viewer, team.
|
|
173
|
+
*/
|
|
174
|
+
userRole?: string;
|
|
146
175
|
metadata: Record<string, string>;
|
|
147
176
|
}
|
|
148
177
|
export interface RoleConfig {
|
|
@@ -153,11 +182,14 @@ export interface RoleConfig {
|
|
|
153
182
|
teams?: string[];
|
|
154
183
|
tpmLimit?: number;
|
|
155
184
|
rpmLimit?: number;
|
|
185
|
+
userRole?: string;
|
|
156
186
|
metadata?: Record<string, string>;
|
|
157
187
|
}
|
|
158
188
|
export interface CreateUserRequest {
|
|
159
189
|
user_id: string;
|
|
160
190
|
user_email?: string;
|
|
191
|
+
user_alias?: string;
|
|
192
|
+
user_role?: string;
|
|
161
193
|
max_budget?: number;
|
|
162
194
|
budget_duration?: string;
|
|
163
195
|
models?: string[];
|
|
@@ -165,6 +197,7 @@ export interface CreateUserRequest {
|
|
|
165
197
|
tpm_limit?: number;
|
|
166
198
|
rpm_limit?: number;
|
|
167
199
|
metadata?: Record<string, string>;
|
|
200
|
+
auto_create_key?: boolean;
|
|
168
201
|
}
|
|
169
202
|
export interface CreateUserResponse {
|
|
170
203
|
user_id: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acarmisc/backstage-plugin-litellm-backend",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The Backstage backend plugin for LiteLLM governance",
|
|
5
5
|
"backstage": {
|
|
6
6
|
"role": "backend-plugin",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"config.d.ts"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
|
-
"build": "
|
|
27
|
-
"prepack": "
|
|
26
|
+
"build": "node build.js && tsc -p tsconfig.json",
|
|
27
|
+
"prepack": "npm run build",
|
|
28
28
|
"postpack": ""
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
package/dist/index.esm.js
DELETED
|
@@ -1,323 +0,0 @@
|
|
|
1
|
-
// src/plugin.ts
|
|
2
|
-
import { coreServices, createBackendPlugin } from "@backstage/backend-plugin-api";
|
|
3
|
-
|
|
4
|
-
// src/router.ts
|
|
5
|
-
import { Router } from "express";
|
|
6
|
-
|
|
7
|
-
// src/client.ts
|
|
8
|
-
var DEFAULT_TIMEOUT = 3e4;
|
|
9
|
-
var LiteLLMClient = class {
|
|
10
|
-
constructor(config, timeout = DEFAULT_TIMEOUT) {
|
|
11
|
-
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
12
|
-
this.masterKey = config.masterKey;
|
|
13
|
-
this.timeout = timeout;
|
|
14
|
-
}
|
|
15
|
-
async request(path, options = {}) {
|
|
16
|
-
const controller = new AbortController();
|
|
17
|
-
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
18
|
-
try {
|
|
19
|
-
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
20
|
-
...options,
|
|
21
|
-
signal: controller.signal,
|
|
22
|
-
headers: {
|
|
23
|
-
"Content-Type": "application/json",
|
|
24
|
-
"Authorization": `Bearer ${this.masterKey}`,
|
|
25
|
-
...options.headers
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
if (!response.ok) {
|
|
29
|
-
const errorBody = await response.text();
|
|
30
|
-
const err = new Error(`LiteLLM API error: ${response.status} ${response.statusText} - ${errorBody}`);
|
|
31
|
-
err.status = response.status;
|
|
32
|
-
throw err;
|
|
33
|
-
}
|
|
34
|
-
return response.json();
|
|
35
|
-
} finally {
|
|
36
|
-
clearTimeout(timeoutId);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Returns null when the user is not found in LiteLLM (404).
|
|
41
|
-
* Throws on all other errors so callers know something went wrong.
|
|
42
|
-
*/
|
|
43
|
-
async getUserInfo(userId) {
|
|
44
|
-
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
|
|
45
|
-
try {
|
|
46
|
-
return await this.request(`/user/info${query}`);
|
|
47
|
-
} catch (err) {
|
|
48
|
-
if (err.status === 404) return null;
|
|
49
|
-
throw err;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
async createUser(payload) {
|
|
53
|
-
return this.request("/user/new", {
|
|
54
|
-
method: "POST",
|
|
55
|
-
body: JSON.stringify(payload)
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
async listKeys(userId) {
|
|
59
|
-
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
|
|
60
|
-
const response = await this.request(`/key/info${query}`);
|
|
61
|
-
return Array.isArray(response) ? response : response.info ?? [];
|
|
62
|
-
}
|
|
63
|
-
async generateKey(request) {
|
|
64
|
-
return this.request("/key/generate", {
|
|
65
|
-
method: "POST",
|
|
66
|
-
body: JSON.stringify(request)
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
async deleteKeys(request) {
|
|
70
|
-
return this.request("/key/delete", {
|
|
71
|
-
method: "POST",
|
|
72
|
-
body: JSON.stringify(request)
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
async listModels() {
|
|
76
|
-
const response = await this.request("/models");
|
|
77
|
-
return Array.isArray(response) ? response : response.data ?? [];
|
|
78
|
-
}
|
|
79
|
-
async getTeamInfo(teamId) {
|
|
80
|
-
return this.request(`/team/info?team_id=${encodeURIComponent(teamId)}`);
|
|
81
|
-
}
|
|
82
|
-
async getUsage(startDate, endDate, userId, groupBy) {
|
|
83
|
-
const params = new URLSearchParams({ start_date: startDate, end_date: endDate });
|
|
84
|
-
if (userId) params.append("user_id", userId);
|
|
85
|
-
if (groupBy) params.append("group_by", groupBy);
|
|
86
|
-
return this.request(`/usage/keys?${params.toString()}`);
|
|
87
|
-
}
|
|
88
|
-
async getTeamUsage(teamId, startDate, endDate) {
|
|
89
|
-
const params = new URLSearchParams({ start_date: startDate, end_date: endDate, team_id: teamId });
|
|
90
|
-
return this.request(`/usage/keys?${params.toString()}`);
|
|
91
|
-
}
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
// src/router.ts
|
|
95
|
-
function readProvisioningDefaults(config) {
|
|
96
|
-
const enabled = config.getOptionalBoolean("litellm.provisioning.enabled") ?? false;
|
|
97
|
-
const defaults = {
|
|
98
|
-
maxBudget: config.getOptionalNumber("litellm.provisioning.defaults.maxBudget") ?? 10,
|
|
99
|
-
budgetDuration: config.getOptionalString("litellm.provisioning.defaults.budgetDuration") ?? "30d",
|
|
100
|
-
models: config.getOptionalStringArray("litellm.provisioning.defaults.models") ?? [],
|
|
101
|
-
teams: config.getOptionalStringArray("litellm.provisioning.defaults.teams") ?? [],
|
|
102
|
-
tpmLimit: config.getOptionalNumber("litellm.provisioning.defaults.tpmLimit"),
|
|
103
|
-
rpmLimit: config.getOptionalNumber("litellm.provisioning.defaults.rpmLimit"),
|
|
104
|
-
metadata: config.getOptional("litellm.provisioning.defaults.metadata") ?? {}
|
|
105
|
-
};
|
|
106
|
-
return { enabled, defaults };
|
|
107
|
-
}
|
|
108
|
-
async function resolveUserId(req, auth) {
|
|
109
|
-
const rawToken = req.headers.authorization?.slice(7);
|
|
110
|
-
if (!rawToken) return void 0;
|
|
111
|
-
try {
|
|
112
|
-
const credentials = await auth.authenticate(rawToken);
|
|
113
|
-
const principal = credentials.principal;
|
|
114
|
-
if (principal?.type === "user") {
|
|
115
|
-
return principal.userEntityRef;
|
|
116
|
-
}
|
|
117
|
-
} catch {
|
|
118
|
-
}
|
|
119
|
-
return void 0;
|
|
120
|
-
}
|
|
121
|
-
async function provisionUser(client, userId, defaults, logger) {
|
|
122
|
-
const payload = {
|
|
123
|
-
user_id: userId,
|
|
124
|
-
max_budget: defaults.maxBudget,
|
|
125
|
-
budget_duration: defaults.budgetDuration,
|
|
126
|
-
models: defaults.models,
|
|
127
|
-
teams: defaults.teams,
|
|
128
|
-
...defaults.tpmLimit !== void 0 && { tpm_limit: defaults.tpmLimit },
|
|
129
|
-
...defaults.rpmLimit !== void 0 && { rpm_limit: defaults.rpmLimit },
|
|
130
|
-
metadata: {
|
|
131
|
-
...defaults.metadata,
|
|
132
|
-
provisioned_by: "backstage",
|
|
133
|
-
provisioned_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
134
|
-
backstage_entity: userId
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
logger.info(`Provisioning new LiteLLM user for Backstage identity: ${userId}`);
|
|
138
|
-
try {
|
|
139
|
-
await client.createUser(payload);
|
|
140
|
-
return await client.getUserInfo(userId);
|
|
141
|
-
} catch (err) {
|
|
142
|
-
logger.error(`Failed to provision LiteLLM user ${userId}: ${err.message}`);
|
|
143
|
-
return null;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
async function createRouter(options) {
|
|
147
|
-
const { config, logger, auth } = options;
|
|
148
|
-
const baseUrl = config.getString("litellm.baseUrl");
|
|
149
|
-
const masterKey = config.getString("litellm.masterKey");
|
|
150
|
-
const client = new LiteLLMClient({ baseUrl, masterKey });
|
|
151
|
-
const { enabled: provisioningEnabled, defaults: provisioningDefaults } = readProvisioningDefaults(config);
|
|
152
|
-
if (provisioningEnabled) {
|
|
153
|
-
logger.info(
|
|
154
|
-
`LiteLLM auto-provisioning enabled \u2014 defaults: budget=$${provisioningDefaults.maxBudget}/${provisioningDefaults.budgetDuration}, models=${provisioningDefaults.models.length ? provisioningDefaults.models.join(",") : "all"}, teams=[${provisioningDefaults.teams.join(",")}]`
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
const router = Router();
|
|
158
|
-
router.get("/health", (_req, res) => {
|
|
159
|
-
res.json({ status: "ok", provisioning: provisioningEnabled });
|
|
160
|
-
});
|
|
161
|
-
router.get("/user/info", async (req, res) => {
|
|
162
|
-
try {
|
|
163
|
-
const tokenUserId = await resolveUserId(req, auth);
|
|
164
|
-
const userId = tokenUserId ?? req.query.user_id;
|
|
165
|
-
let userInfo = await client.getUserInfo(userId);
|
|
166
|
-
if (!userInfo) {
|
|
167
|
-
if (provisioningEnabled && userId) {
|
|
168
|
-
userInfo = await provisionUser(client, userId, provisioningDefaults, logger);
|
|
169
|
-
}
|
|
170
|
-
if (!userInfo) {
|
|
171
|
-
res.status(404).json({
|
|
172
|
-
error: "User not found in LiteLLM",
|
|
173
|
-
provisioning: provisioningEnabled,
|
|
174
|
-
hint: provisioningEnabled ? "Provisioning attempted but failed \u2014 check LiteLLM logs" : "Enable litellm.provisioning.enabled in app-config.yaml or create the user manually"
|
|
175
|
-
});
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
res.json(userInfo);
|
|
180
|
-
} catch (error) {
|
|
181
|
-
logger.error("Failed to fetch user info", error);
|
|
182
|
-
res.status(500).json({ error: error.message });
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
router.get("/keys", async (req, res) => {
|
|
186
|
-
try {
|
|
187
|
-
const tokenUserId = await resolveUserId(req, auth);
|
|
188
|
-
const userId = tokenUserId ?? req.query.user_id;
|
|
189
|
-
const keys = await client.listKeys(userId);
|
|
190
|
-
res.json(keys);
|
|
191
|
-
} catch (error) {
|
|
192
|
-
logger.error("Failed to list keys", error);
|
|
193
|
-
res.status(500).json({ error: error.message });
|
|
194
|
-
}
|
|
195
|
-
});
|
|
196
|
-
router.post("/keys/generate", async (req, res) => {
|
|
197
|
-
try {
|
|
198
|
-
const tokenUserId = await resolveUserId(req, auth);
|
|
199
|
-
const request = {
|
|
200
|
-
...req.body,
|
|
201
|
-
...tokenUserId && { user_id: tokenUserId }
|
|
202
|
-
};
|
|
203
|
-
const result = await client.generateKey(request);
|
|
204
|
-
res.json(result);
|
|
205
|
-
} catch (error) {
|
|
206
|
-
logger.error("Failed to generate key", error);
|
|
207
|
-
res.status(500).json({ error: error.message });
|
|
208
|
-
}
|
|
209
|
-
});
|
|
210
|
-
router.delete("/keys/:keyId", async (req, res) => {
|
|
211
|
-
try {
|
|
212
|
-
const { keyId } = req.params;
|
|
213
|
-
if (!keyId) {
|
|
214
|
-
res.status(400).json({ error: "keyId is required" });
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
await client.deleteKeys({ keys: [keyId] });
|
|
218
|
-
res.json({ success: true });
|
|
219
|
-
} catch (error) {
|
|
220
|
-
logger.error("Failed to delete key", error);
|
|
221
|
-
res.status(500).json({ error: error.message });
|
|
222
|
-
}
|
|
223
|
-
});
|
|
224
|
-
router.get("/models", async (_req, res) => {
|
|
225
|
-
try {
|
|
226
|
-
const models = await client.listModels();
|
|
227
|
-
res.json(models);
|
|
228
|
-
} catch (error) {
|
|
229
|
-
logger.error("Failed to list models", error);
|
|
230
|
-
res.status(500).json({ error: error.message });
|
|
231
|
-
}
|
|
232
|
-
});
|
|
233
|
-
router.get("/teams", async (req, res) => {
|
|
234
|
-
try {
|
|
235
|
-
const tokenUserId = await resolveUserId(req, auth);
|
|
236
|
-
const userId = tokenUserId ?? req.query.user_id;
|
|
237
|
-
const userInfo = await client.getUserInfo(userId);
|
|
238
|
-
if (!userInfo?.teams?.length) {
|
|
239
|
-
res.json([]);
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
const teams = await Promise.all(
|
|
243
|
-
userInfo.teams.map(
|
|
244
|
-
(teamId) => client.getTeamInfo(teamId).catch((err) => {
|
|
245
|
-
logger.warn(`Failed to fetch team ${teamId}: ${err.message}`);
|
|
246
|
-
return null;
|
|
247
|
-
})
|
|
248
|
-
)
|
|
249
|
-
);
|
|
250
|
-
res.json(teams.filter(Boolean));
|
|
251
|
-
} catch (error) {
|
|
252
|
-
logger.error("Failed to fetch teams", error);
|
|
253
|
-
res.status(500).json({ error: error.message });
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
router.get("/teams/:teamId/usage", async (req, res) => {
|
|
257
|
-
try {
|
|
258
|
-
const { teamId } = req.params;
|
|
259
|
-
const { start_date, end_date } = req.query;
|
|
260
|
-
if (!start_date || !end_date) {
|
|
261
|
-
res.status(400).json({ error: "start_date and end_date are required" });
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
const usage = await client.getTeamUsage(
|
|
265
|
-
teamId,
|
|
266
|
-
start_date,
|
|
267
|
-
end_date
|
|
268
|
-
);
|
|
269
|
-
res.json(usage);
|
|
270
|
-
} catch (error) {
|
|
271
|
-
logger.error("Failed to fetch team usage", error);
|
|
272
|
-
res.status(500).json({ error: error.message });
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
router.get("/usage", async (req, res) => {
|
|
276
|
-
try {
|
|
277
|
-
const { start_date, end_date, group_by } = req.query;
|
|
278
|
-
if (!start_date || !end_date) {
|
|
279
|
-
res.status(400).json({ error: "start_date and end_date are required" });
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
const tokenUserId = await resolveUserId(req, auth);
|
|
283
|
-
const userId = tokenUserId ?? req.query.user_id;
|
|
284
|
-
const usage = await client.getUsage(
|
|
285
|
-
start_date,
|
|
286
|
-
end_date,
|
|
287
|
-
userId,
|
|
288
|
-
group_by
|
|
289
|
-
);
|
|
290
|
-
res.json(usage);
|
|
291
|
-
} catch (error) {
|
|
292
|
-
logger.error("Failed to fetch usage", error);
|
|
293
|
-
res.status(500).json({ error: error.message });
|
|
294
|
-
}
|
|
295
|
-
});
|
|
296
|
-
return router;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// src/plugin.ts
|
|
300
|
-
var litellmPlugin = createBackendPlugin({
|
|
301
|
-
pluginId: "litellm",
|
|
302
|
-
register(reg) {
|
|
303
|
-
reg.registerInit({
|
|
304
|
-
deps: {
|
|
305
|
-
httpRouter: coreServices.httpRouter,
|
|
306
|
-
config: coreServices.rootConfig,
|
|
307
|
-
logger: coreServices.logger,
|
|
308
|
-
auth: coreServices.auth,
|
|
309
|
-
discovery: coreServices.discovery
|
|
310
|
-
},
|
|
311
|
-
async init({ httpRouter, config, logger, auth }) {
|
|
312
|
-
const router = await createRouter({ config, logger, auth });
|
|
313
|
-
httpRouter.use(router);
|
|
314
|
-
}
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
});
|
|
318
|
-
export {
|
|
319
|
-
LiteLLMClient,
|
|
320
|
-
createRouter,
|
|
321
|
-
litellmPlugin
|
|
322
|
-
};
|
|
323
|
-
//# sourceMappingURL=index.esm.js.map
|
package/dist/index.esm.js.map
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
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
|
-
}
|