@acarmisc/backstage-plugin-litellm-backend 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +18 -1
- package/dist/client.js +55 -0
- package/dist/index.cjs.js +173 -0
- package/dist/index.cjs.js.map +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/opencode.d.ts +30 -0
- package/dist/opencode.js +13 -0
- package/dist/router.js +137 -0
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +38 -0
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import { LiteLLMConfig, UserInfo, VirtualKey, ModelInfo, UsageMetrics, TeamInfo, GenerateKeyRequest, GenerateKeyResponse, UpdateKeyRequest, DeleteKeyRequest, CreateUserRequest, CreateUserResponse, CreateTeamRequest, CreateTeamResponse, UpdateTeamRequest, AuditLogsParams, PaginatedAuditLogs } from './types';
|
|
1
|
+
import { LiteLLMConfig, UserInfo, VirtualKey, ModelInfo, UsageMetrics, TeamInfo, GenerateKeyRequest, GenerateKeyResponse, UpdateKeyRequest, DeleteKeyRequest, CreateUserRequest, CreateUserResponse, CreateTeamRequest, CreateTeamResponse, UpdateTeamRequest, AuditLogsParams, PaginatedAuditLogs, SpendLogEntry, SpendLogsParams } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* LiteLLM versions differ on `request_tags`: an array of `k:v` strings in
|
|
4
|
+
* current builds, but historically an object map. Normalise both to a flat
|
|
5
|
+
* string array so callers can `includes('session:...')` uniformly.
|
|
6
|
+
*/
|
|
7
|
+
export declare function normalizeRequestTags(tags: SpendLogEntry['request_tags']): string[];
|
|
2
8
|
/**
|
|
3
9
|
* Typed error for failed upstream LiteLLM responses. Preserves the HTTP
|
|
4
10
|
* status and the structured `param` (e.g. `key_alias`) from the upstream
|
|
@@ -149,6 +155,17 @@ export declare class LiteLLMClient {
|
|
|
149
155
|
name?: string;
|
|
150
156
|
url?: string;
|
|
151
157
|
}>>;
|
|
158
|
+
/**
|
|
159
|
+
* Reads per-request spend logs from LiteLLM (`GET /spend/logs`), filtered
|
|
160
|
+
* by date range and optionally by key/user/team. Rows are normalised so
|
|
161
|
+
* `request_tags` is always a `string[]` (LiteLLM returns either an array
|
|
162
|
+
* or an object depending on version) — callers group by tag to attribute
|
|
163
|
+
* spend to a conversation or caller.
|
|
164
|
+
*
|
|
165
|
+
* This is the admin/DB-backed endpoint: it can return a large number of
|
|
166
|
+
* rows, so callers should keep the date window tight and cap `page_size`.
|
|
167
|
+
*/
|
|
168
|
+
getSpendLogs(params: SpendLogsParams): Promise<SpendLogEntry[]>;
|
|
152
169
|
private emptyUsage;
|
|
153
170
|
/**
|
|
154
171
|
* Transforms LiteLLM's SpendAnalyticsPaginatedResponse into the flatter
|
package/dist/client.js
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.LiteLLMClient = exports.LiteLLMUpstreamError = void 0;
|
|
4
|
+
exports.normalizeRequestTags = normalizeRequestTags;
|
|
4
5
|
const DEFAULT_TIMEOUT = 30000;
|
|
6
|
+
/**
|
|
7
|
+
* LiteLLM versions differ on `request_tags`: an array of `k:v` strings in
|
|
8
|
+
* current builds, but historically an object map. Normalise both to a flat
|
|
9
|
+
* string array so callers can `includes('session:...')` uniformly.
|
|
10
|
+
*/
|
|
11
|
+
function normalizeRequestTags(tags) {
|
|
12
|
+
if (!tags)
|
|
13
|
+
return [];
|
|
14
|
+
if (Array.isArray(tags))
|
|
15
|
+
return tags.filter((t) => typeof t === 'string');
|
|
16
|
+
return Object.entries(tags).map(([k, v]) => `${k}:${v}`);
|
|
17
|
+
}
|
|
5
18
|
/**
|
|
6
19
|
* Typed error for failed upstream LiteLLM responses. Preserves the HTTP
|
|
7
20
|
* status and the structured `param` (e.g. `key_alias`) from the upstream
|
|
@@ -168,6 +181,7 @@ class LiteLLMClient {
|
|
|
168
181
|
rpm_limit: k.rpm_limit ?? undefined,
|
|
169
182
|
models: k.models ?? [],
|
|
170
183
|
user_id: k.user_id ?? undefined,
|
|
184
|
+
team_id: k.team_id ?? undefined,
|
|
171
185
|
blocked: k.blocked ?? undefined,
|
|
172
186
|
};
|
|
173
187
|
}
|
|
@@ -451,6 +465,47 @@ class LiteLLMClient {
|
|
|
451
465
|
}))
|
|
452
466
|
.filter(r => r.id);
|
|
453
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* Reads per-request spend logs from LiteLLM (`GET /spend/logs`), filtered
|
|
470
|
+
* by date range and optionally by key/user/team. Rows are normalised so
|
|
471
|
+
* `request_tags` is always a `string[]` (LiteLLM returns either an array
|
|
472
|
+
* or an object depending on version) — callers group by tag to attribute
|
|
473
|
+
* spend to a conversation or caller.
|
|
474
|
+
*
|
|
475
|
+
* This is the admin/DB-backed endpoint: it can return a large number of
|
|
476
|
+
* rows, so callers should keep the date window tight and cap `page_size`.
|
|
477
|
+
*/
|
|
478
|
+
async getSpendLogs(params) {
|
|
479
|
+
const query = new URLSearchParams({
|
|
480
|
+
start_date: params.start_date,
|
|
481
|
+
end_date: params.end_date,
|
|
482
|
+
});
|
|
483
|
+
if (params.api_key)
|
|
484
|
+
query.append('api_key', params.api_key);
|
|
485
|
+
if (params.user_id)
|
|
486
|
+
query.append('user_id', params.user_id);
|
|
487
|
+
if (params.team_id)
|
|
488
|
+
query.append('team_id', params.team_id);
|
|
489
|
+
if (params.page_size)
|
|
490
|
+
query.append('page_size', String(params.page_size));
|
|
491
|
+
const raw = await this.request(`/spend/logs?${query.toString()}`);
|
|
492
|
+
const rows = Array.isArray(raw) ? raw : raw?.data ?? [];
|
|
493
|
+
return rows.map(row => ({
|
|
494
|
+
request_id: row?.request_id,
|
|
495
|
+
startTime: row?.startTime ?? row?.start_time,
|
|
496
|
+
endTime: row?.endTime ?? row?.end_time,
|
|
497
|
+
spend: row?.spend ?? 0,
|
|
498
|
+
total_tokens: row?.total_tokens ?? 0,
|
|
499
|
+
prompt_tokens: row?.prompt_tokens ?? 0,
|
|
500
|
+
completion_tokens: row?.completion_tokens ?? 0,
|
|
501
|
+
model: row?.model,
|
|
502
|
+
api_key: row?.api_key,
|
|
503
|
+
user: row?.user,
|
|
504
|
+
team_id: row?.team_id,
|
|
505
|
+
request_tags: normalizeRequestTags(row?.request_tags),
|
|
506
|
+
metadata: row?.metadata,
|
|
507
|
+
}));
|
|
508
|
+
}
|
|
454
509
|
emptyUsage() {
|
|
455
510
|
return {
|
|
456
511
|
total_spend: 0,
|
package/dist/index.cjs.js
CHANGED
|
@@ -7997,6 +7997,7 @@ __export(index_exports, {
|
|
|
7997
7997
|
litellmTeamMcpManagePermission: () => litellmTeamMcpManagePermission,
|
|
7998
7998
|
litellmTeamMembersManagePermission: () => litellmTeamMembersManagePermission,
|
|
7999
7999
|
newDefaultVerifier: () => newDefaultVerifier,
|
|
8000
|
+
normalizeRequestTags: () => normalizeRequestTags,
|
|
8000
8001
|
provisionUser: () => provisionUser,
|
|
8001
8002
|
readBridgeConfig: () => readBridgeConfig,
|
|
8002
8003
|
readProvisioningDefaults: () => readProvisioningDefaults,
|
|
@@ -8026,6 +8027,11 @@ var import_catalog_client = require("@backstage/catalog-client");
|
|
|
8026
8027
|
|
|
8027
8028
|
// src/client.ts
|
|
8028
8029
|
var DEFAULT_TIMEOUT = 3e4;
|
|
8030
|
+
function normalizeRequestTags(tags) {
|
|
8031
|
+
if (!tags) return [];
|
|
8032
|
+
if (Array.isArray(tags)) return tags.filter((t) => typeof t === "string");
|
|
8033
|
+
return Object.entries(tags).map(([k, v]) => `${k}:${v}`);
|
|
8034
|
+
}
|
|
8029
8035
|
var LiteLLMUpstreamError = class extends Error {
|
|
8030
8036
|
constructor(status, statusText, body) {
|
|
8031
8037
|
let message2 = `LiteLLM API error: ${status} ${statusText} - ${body}`;
|
|
@@ -8171,6 +8177,7 @@ var LiteLLMClient = class {
|
|
|
8171
8177
|
rpm_limit: k.rpm_limit ?? void 0,
|
|
8172
8178
|
models: k.models ?? [],
|
|
8173
8179
|
user_id: k.user_id ?? void 0,
|
|
8180
|
+
team_id: k.team_id ?? void 0,
|
|
8174
8181
|
blocked: k.blocked ?? void 0
|
|
8175
8182
|
};
|
|
8176
8183
|
}
|
|
@@ -8429,6 +8436,43 @@ var LiteLLMClient = class {
|
|
|
8429
8436
|
url: r?.url ?? void 0
|
|
8430
8437
|
})).filter((r) => r.id);
|
|
8431
8438
|
}
|
|
8439
|
+
/**
|
|
8440
|
+
* Reads per-request spend logs from LiteLLM (`GET /spend/logs`), filtered
|
|
8441
|
+
* by date range and optionally by key/user/team. Rows are normalised so
|
|
8442
|
+
* `request_tags` is always a `string[]` (LiteLLM returns either an array
|
|
8443
|
+
* or an object depending on version) — callers group by tag to attribute
|
|
8444
|
+
* spend to a conversation or caller.
|
|
8445
|
+
*
|
|
8446
|
+
* This is the admin/DB-backed endpoint: it can return a large number of
|
|
8447
|
+
* rows, so callers should keep the date window tight and cap `page_size`.
|
|
8448
|
+
*/
|
|
8449
|
+
async getSpendLogs(params) {
|
|
8450
|
+
const query = new URLSearchParams({
|
|
8451
|
+
start_date: params.start_date,
|
|
8452
|
+
end_date: params.end_date
|
|
8453
|
+
});
|
|
8454
|
+
if (params.api_key) query.append("api_key", params.api_key);
|
|
8455
|
+
if (params.user_id) query.append("user_id", params.user_id);
|
|
8456
|
+
if (params.team_id) query.append("team_id", params.team_id);
|
|
8457
|
+
if (params.page_size) query.append("page_size", String(params.page_size));
|
|
8458
|
+
const raw = await this.request(`/spend/logs?${query.toString()}`);
|
|
8459
|
+
const rows = Array.isArray(raw) ? raw : raw?.data ?? [];
|
|
8460
|
+
return rows.map((row) => ({
|
|
8461
|
+
request_id: row?.request_id,
|
|
8462
|
+
startTime: row?.startTime ?? row?.start_time,
|
|
8463
|
+
endTime: row?.endTime ?? row?.end_time,
|
|
8464
|
+
spend: row?.spend ?? 0,
|
|
8465
|
+
total_tokens: row?.total_tokens ?? 0,
|
|
8466
|
+
prompt_tokens: row?.prompt_tokens ?? 0,
|
|
8467
|
+
completion_tokens: row?.completion_tokens ?? 0,
|
|
8468
|
+
model: row?.model,
|
|
8469
|
+
api_key: row?.api_key,
|
|
8470
|
+
user: row?.user,
|
|
8471
|
+
team_id: row?.team_id,
|
|
8472
|
+
request_tags: normalizeRequestTags(row?.request_tags),
|
|
8473
|
+
metadata: row?.metadata
|
|
8474
|
+
}));
|
|
8475
|
+
}
|
|
8432
8476
|
emptyUsage() {
|
|
8433
8477
|
return {
|
|
8434
8478
|
total_spend: 0,
|
|
@@ -10896,6 +10940,20 @@ function redactTeamUsage(usage) {
|
|
|
10896
10940
|
};
|
|
10897
10941
|
}
|
|
10898
10942
|
|
|
10943
|
+
// src/opencode.ts
|
|
10944
|
+
function readOpencodeConfig(config) {
|
|
10945
|
+
const enabled = config.getOptionalBoolean("litellm.opencode.enabled") ?? false;
|
|
10946
|
+
return {
|
|
10947
|
+
enabled,
|
|
10948
|
+
keyDuration: config.getOptionalString("litellm.opencode.keyDuration") ?? "30d",
|
|
10949
|
+
maxBudget: config.getOptionalNumber("litellm.opencode.maxBudget") ?? 50,
|
|
10950
|
+
requireTeam: config.getOptionalBoolean("litellm.opencode.requireTeam") ?? false,
|
|
10951
|
+
metadata: config.getOptional(
|
|
10952
|
+
"litellm.opencode.metadata"
|
|
10953
|
+
) ?? {}
|
|
10954
|
+
};
|
|
10955
|
+
}
|
|
10956
|
+
|
|
10899
10957
|
// src/router.ts
|
|
10900
10958
|
var teamCreateInFlight = /* @__PURE__ */ new Map();
|
|
10901
10959
|
var TEAM_FETCH_RETRY_DELAYS_MS = [100, 300, 900];
|
|
@@ -10917,6 +10975,15 @@ async function withTeamFetchRetry(fn, delaysMs = TEAM_FETCH_RETRY_DELAYS_MS) {
|
|
|
10917
10975
|
}
|
|
10918
10976
|
}
|
|
10919
10977
|
}
|
|
10978
|
+
function isValidRedirectUri(uri) {
|
|
10979
|
+
if (!uri) return false;
|
|
10980
|
+
try {
|
|
10981
|
+
const url = new URL(uri);
|
|
10982
|
+
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1") && url.pathname === "/callback";
|
|
10983
|
+
} catch {
|
|
10984
|
+
return false;
|
|
10985
|
+
}
|
|
10986
|
+
}
|
|
10920
10987
|
async function createRouter(options) {
|
|
10921
10988
|
const { config, logger, auth, discovery, permissions } = options;
|
|
10922
10989
|
const baseUrl = config.getString("litellm.baseUrl");
|
|
@@ -10933,6 +11000,7 @@ async function createRouter(options) {
|
|
|
10933
11000
|
const objectPermsEnabled = isObjectPermissionsEnabled(config);
|
|
10934
11001
|
const teamAdminCfg = readTeamAdminConfig(config);
|
|
10935
11002
|
const teamBudgetVisibility = readTeamBudgetVisibility(config);
|
|
11003
|
+
const opencodeCfg = readOpencodeConfig(config);
|
|
10936
11004
|
const catalogClient = options.catalogClient ?? new import_catalog_client.CatalogClient({ discoveryApi: discovery });
|
|
10937
11005
|
if (provisioningEnabled) {
|
|
10938
11006
|
logger.info(
|
|
@@ -10958,6 +11026,7 @@ async function createRouter(options) {
|
|
|
10958
11026
|
res.json({
|
|
10959
11027
|
baseUrl: publicBaseUrl,
|
|
10960
11028
|
keyGeneration: { allowUnlimitedBudget, teamRequired },
|
|
11029
|
+
opencode: { enabled: opencodeCfg.enabled },
|
|
10961
11030
|
teamManagement: {
|
|
10962
11031
|
enabled: teamMgmtEnabled,
|
|
10963
11032
|
maxBudgetCeiling: teamAdminCfg.maxBudgetCeiling,
|
|
@@ -11194,6 +11263,109 @@ async function createRouter(options) {
|
|
|
11194
11263
|
res.status(500).json({ error: error.message });
|
|
11195
11264
|
}
|
|
11196
11265
|
});
|
|
11266
|
+
if (opencodeCfg.enabled) {
|
|
11267
|
+
logger.info(
|
|
11268
|
+
`OpenCode connect endpoint enabled \u2014 duration=${opencodeCfg.keyDuration}, maxBudget=$${opencodeCfg.maxBudget}`
|
|
11269
|
+
);
|
|
11270
|
+
router.get("/opencode/connect", async (req, res) => {
|
|
11271
|
+
try {
|
|
11272
|
+
const tokenEntityRef = await resolveUserId(req, auth);
|
|
11273
|
+
if (!tokenEntityRef) {
|
|
11274
|
+
res.status(401).json({ error: "Authentication required" });
|
|
11275
|
+
return;
|
|
11276
|
+
}
|
|
11277
|
+
const redirectUri = req.query.redirect_uri;
|
|
11278
|
+
if (!isValidRedirectUri(redirectUri)) {
|
|
11279
|
+
res.status(400).json({
|
|
11280
|
+
error: "Invalid redirect_uri \u2014 expected http://localhost:<port>/callback"
|
|
11281
|
+
});
|
|
11282
|
+
return;
|
|
11283
|
+
}
|
|
11284
|
+
if (!await assertPermission(req, litellmKeyCreatePermission)) {
|
|
11285
|
+
sendPermissionDenied(res, litellmKeyCreatePermission);
|
|
11286
|
+
return;
|
|
11287
|
+
}
|
|
11288
|
+
const userId = toLiteLLMUserId(tokenEntityRef, userIdDomain);
|
|
11289
|
+
const userInfo = await getOrProvisionUser(
|
|
11290
|
+
client,
|
|
11291
|
+
tokenEntityRef,
|
|
11292
|
+
userId,
|
|
11293
|
+
provisioningEnabled,
|
|
11294
|
+
provisioningDefaults,
|
|
11295
|
+
roleConfigs,
|
|
11296
|
+
catalogClient,
|
|
11297
|
+
auth,
|
|
11298
|
+
logger
|
|
11299
|
+
);
|
|
11300
|
+
const teamId = req.query.team;
|
|
11301
|
+
if (teamId) {
|
|
11302
|
+
const userTeams = userInfo?.teams ?? [];
|
|
11303
|
+
if (!userTeams.includes(teamId)) {
|
|
11304
|
+
res.status(403).json({
|
|
11305
|
+
error: "Access denied: team is not one of your teams",
|
|
11306
|
+
team: teamId
|
|
11307
|
+
});
|
|
11308
|
+
return;
|
|
11309
|
+
}
|
|
11310
|
+
} else if (opencodeCfg.requireTeam) {
|
|
11311
|
+
res.status(400).json({ error: "A team is required for this connection" });
|
|
11312
|
+
return;
|
|
11313
|
+
}
|
|
11314
|
+
const alias = teamId ? `opencode-${userId}-${teamId}` : `opencode-${userId}`;
|
|
11315
|
+
const existing = await client.listKeys(userId).catch(() => []);
|
|
11316
|
+
const reusable = existing.find(
|
|
11317
|
+
(k) => k.key_alias === alias && !k.blocked && k.user_id === userId
|
|
11318
|
+
);
|
|
11319
|
+
let key;
|
|
11320
|
+
if (reusable?.token ?? reusable?.key) {
|
|
11321
|
+
key = reusable.token ?? reusable.key;
|
|
11322
|
+
} else {
|
|
11323
|
+
const profile = await resolveUserProfile(
|
|
11324
|
+
tokenEntityRef,
|
|
11325
|
+
catalogClient,
|
|
11326
|
+
auth,
|
|
11327
|
+
logger
|
|
11328
|
+
);
|
|
11329
|
+
const result = await client.generateKey({
|
|
11330
|
+
alias,
|
|
11331
|
+
duration: opencodeCfg.keyDuration,
|
|
11332
|
+
max_budget: opencodeCfg.maxBudget,
|
|
11333
|
+
team_id: teamId,
|
|
11334
|
+
metadata: {
|
|
11335
|
+
...opencodeCfg.metadata,
|
|
11336
|
+
created_via: "opencode-connect",
|
|
11337
|
+
created_by_backstage_user: tokenEntityRef,
|
|
11338
|
+
...profile.email && { created_by_email: profile.email },
|
|
11339
|
+
...teamId && { opencode_team: teamId }
|
|
11340
|
+
},
|
|
11341
|
+
user_id: userId
|
|
11342
|
+
});
|
|
11343
|
+
if (!result.key) {
|
|
11344
|
+
res.status(502).json({ error: "LiteLLM returned no key material" });
|
|
11345
|
+
return;
|
|
11346
|
+
}
|
|
11347
|
+
key = result.key;
|
|
11348
|
+
}
|
|
11349
|
+
const url = new URL(redirectUri);
|
|
11350
|
+
url.searchParams.set("key", key);
|
|
11351
|
+
if (teamId) url.searchParams.set("team", teamId);
|
|
11352
|
+
logger.info({
|
|
11353
|
+
action: "opencode.connect",
|
|
11354
|
+
userId,
|
|
11355
|
+
team: teamId ?? null,
|
|
11356
|
+
reused: Boolean(reusable?.token ?? reusable?.key)
|
|
11357
|
+
});
|
|
11358
|
+
res.redirect(302, url.href);
|
|
11359
|
+
} catch (error) {
|
|
11360
|
+
if (error instanceof ProvisioningError) {
|
|
11361
|
+
res.status(error.status).json(error.body);
|
|
11362
|
+
return;
|
|
11363
|
+
}
|
|
11364
|
+
logger.error("OpenCode connect failed", error);
|
|
11365
|
+
res.status(500).json({ error: error.message });
|
|
11366
|
+
}
|
|
11367
|
+
});
|
|
11368
|
+
}
|
|
11197
11369
|
router.post("/keys/:keyId/update", async (req, res) => {
|
|
11198
11370
|
try {
|
|
11199
11371
|
const { keyId } = req.params;
|
|
@@ -12150,6 +12322,7 @@ var litellmPlugin = (0, import_backend_plugin_api.createBackendPlugin)({
|
|
|
12150
12322
|
litellmTeamMcpManagePermission,
|
|
12151
12323
|
litellmTeamMembersManagePermission,
|
|
12152
12324
|
newDefaultVerifier,
|
|
12325
|
+
normalizeRequestTags,
|
|
12153
12326
|
provisionUser,
|
|
12154
12327
|
readBridgeConfig,
|
|
12155
12328
|
readProvisioningDefaults,
|