@acarmisc/backstage-plugin-litellm-backend 0.13.0 → 0.14.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 +54 -0
- package/dist/index.cjs.js +172 -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 +36 -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
|
|
@@ -451,6 +464,47 @@ class LiteLLMClient {
|
|
|
451
464
|
}))
|
|
452
465
|
.filter(r => r.id);
|
|
453
466
|
}
|
|
467
|
+
/**
|
|
468
|
+
* Reads per-request spend logs from LiteLLM (`GET /spend/logs`), filtered
|
|
469
|
+
* by date range and optionally by key/user/team. Rows are normalised so
|
|
470
|
+
* `request_tags` is always a `string[]` (LiteLLM returns either an array
|
|
471
|
+
* or an object depending on version) — callers group by tag to attribute
|
|
472
|
+
* spend to a conversation or caller.
|
|
473
|
+
*
|
|
474
|
+
* This is the admin/DB-backed endpoint: it can return a large number of
|
|
475
|
+
* rows, so callers should keep the date window tight and cap `page_size`.
|
|
476
|
+
*/
|
|
477
|
+
async getSpendLogs(params) {
|
|
478
|
+
const query = new URLSearchParams({
|
|
479
|
+
start_date: params.start_date,
|
|
480
|
+
end_date: params.end_date,
|
|
481
|
+
});
|
|
482
|
+
if (params.api_key)
|
|
483
|
+
query.append('api_key', params.api_key);
|
|
484
|
+
if (params.user_id)
|
|
485
|
+
query.append('user_id', params.user_id);
|
|
486
|
+
if (params.team_id)
|
|
487
|
+
query.append('team_id', params.team_id);
|
|
488
|
+
if (params.page_size)
|
|
489
|
+
query.append('page_size', String(params.page_size));
|
|
490
|
+
const raw = await this.request(`/spend/logs?${query.toString()}`);
|
|
491
|
+
const rows = Array.isArray(raw) ? raw : raw?.data ?? [];
|
|
492
|
+
return rows.map(row => ({
|
|
493
|
+
request_id: row?.request_id,
|
|
494
|
+
startTime: row?.startTime ?? row?.start_time,
|
|
495
|
+
endTime: row?.endTime ?? row?.end_time,
|
|
496
|
+
spend: row?.spend ?? 0,
|
|
497
|
+
total_tokens: row?.total_tokens ?? 0,
|
|
498
|
+
prompt_tokens: row?.prompt_tokens ?? 0,
|
|
499
|
+
completion_tokens: row?.completion_tokens ?? 0,
|
|
500
|
+
model: row?.model,
|
|
501
|
+
api_key: row?.api_key,
|
|
502
|
+
user: row?.user,
|
|
503
|
+
team_id: row?.team_id,
|
|
504
|
+
request_tags: normalizeRequestTags(row?.request_tags),
|
|
505
|
+
metadata: row?.metadata,
|
|
506
|
+
}));
|
|
507
|
+
}
|
|
454
508
|
emptyUsage() {
|
|
455
509
|
return {
|
|
456
510
|
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}`;
|
|
@@ -8429,6 +8435,43 @@ var LiteLLMClient = class {
|
|
|
8429
8435
|
url: r?.url ?? void 0
|
|
8430
8436
|
})).filter((r) => r.id);
|
|
8431
8437
|
}
|
|
8438
|
+
/**
|
|
8439
|
+
* Reads per-request spend logs from LiteLLM (`GET /spend/logs`), filtered
|
|
8440
|
+
* by date range and optionally by key/user/team. Rows are normalised so
|
|
8441
|
+
* `request_tags` is always a `string[]` (LiteLLM returns either an array
|
|
8442
|
+
* or an object depending on version) — callers group by tag to attribute
|
|
8443
|
+
* spend to a conversation or caller.
|
|
8444
|
+
*
|
|
8445
|
+
* This is the admin/DB-backed endpoint: it can return a large number of
|
|
8446
|
+
* rows, so callers should keep the date window tight and cap `page_size`.
|
|
8447
|
+
*/
|
|
8448
|
+
async getSpendLogs(params) {
|
|
8449
|
+
const query = new URLSearchParams({
|
|
8450
|
+
start_date: params.start_date,
|
|
8451
|
+
end_date: params.end_date
|
|
8452
|
+
});
|
|
8453
|
+
if (params.api_key) query.append("api_key", params.api_key);
|
|
8454
|
+
if (params.user_id) query.append("user_id", params.user_id);
|
|
8455
|
+
if (params.team_id) query.append("team_id", params.team_id);
|
|
8456
|
+
if (params.page_size) query.append("page_size", String(params.page_size));
|
|
8457
|
+
const raw = await this.request(`/spend/logs?${query.toString()}`);
|
|
8458
|
+
const rows = Array.isArray(raw) ? raw : raw?.data ?? [];
|
|
8459
|
+
return rows.map((row) => ({
|
|
8460
|
+
request_id: row?.request_id,
|
|
8461
|
+
startTime: row?.startTime ?? row?.start_time,
|
|
8462
|
+
endTime: row?.endTime ?? row?.end_time,
|
|
8463
|
+
spend: row?.spend ?? 0,
|
|
8464
|
+
total_tokens: row?.total_tokens ?? 0,
|
|
8465
|
+
prompt_tokens: row?.prompt_tokens ?? 0,
|
|
8466
|
+
completion_tokens: row?.completion_tokens ?? 0,
|
|
8467
|
+
model: row?.model,
|
|
8468
|
+
api_key: row?.api_key,
|
|
8469
|
+
user: row?.user,
|
|
8470
|
+
team_id: row?.team_id,
|
|
8471
|
+
request_tags: normalizeRequestTags(row?.request_tags),
|
|
8472
|
+
metadata: row?.metadata
|
|
8473
|
+
}));
|
|
8474
|
+
}
|
|
8432
8475
|
emptyUsage() {
|
|
8433
8476
|
return {
|
|
8434
8477
|
total_spend: 0,
|
|
@@ -10896,6 +10939,20 @@ function redactTeamUsage(usage) {
|
|
|
10896
10939
|
};
|
|
10897
10940
|
}
|
|
10898
10941
|
|
|
10942
|
+
// src/opencode.ts
|
|
10943
|
+
function readOpencodeConfig(config) {
|
|
10944
|
+
const enabled = config.getOptionalBoolean("litellm.opencode.enabled") ?? false;
|
|
10945
|
+
return {
|
|
10946
|
+
enabled,
|
|
10947
|
+
keyDuration: config.getOptionalString("litellm.opencode.keyDuration") ?? "30d",
|
|
10948
|
+
maxBudget: config.getOptionalNumber("litellm.opencode.maxBudget") ?? 50,
|
|
10949
|
+
requireTeam: config.getOptionalBoolean("litellm.opencode.requireTeam") ?? false,
|
|
10950
|
+
metadata: config.getOptional(
|
|
10951
|
+
"litellm.opencode.metadata"
|
|
10952
|
+
) ?? {}
|
|
10953
|
+
};
|
|
10954
|
+
}
|
|
10955
|
+
|
|
10899
10956
|
// src/router.ts
|
|
10900
10957
|
var teamCreateInFlight = /* @__PURE__ */ new Map();
|
|
10901
10958
|
var TEAM_FETCH_RETRY_DELAYS_MS = [100, 300, 900];
|
|
@@ -10917,6 +10974,15 @@ async function withTeamFetchRetry(fn, delaysMs = TEAM_FETCH_RETRY_DELAYS_MS) {
|
|
|
10917
10974
|
}
|
|
10918
10975
|
}
|
|
10919
10976
|
}
|
|
10977
|
+
function isValidRedirectUri(uri) {
|
|
10978
|
+
if (!uri) return false;
|
|
10979
|
+
try {
|
|
10980
|
+
const url = new URL(uri);
|
|
10981
|
+
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1") && url.pathname === "/callback";
|
|
10982
|
+
} catch {
|
|
10983
|
+
return false;
|
|
10984
|
+
}
|
|
10985
|
+
}
|
|
10920
10986
|
async function createRouter(options) {
|
|
10921
10987
|
const { config, logger, auth, discovery, permissions } = options;
|
|
10922
10988
|
const baseUrl = config.getString("litellm.baseUrl");
|
|
@@ -10933,6 +10999,7 @@ async function createRouter(options) {
|
|
|
10933
10999
|
const objectPermsEnabled = isObjectPermissionsEnabled(config);
|
|
10934
11000
|
const teamAdminCfg = readTeamAdminConfig(config);
|
|
10935
11001
|
const teamBudgetVisibility = readTeamBudgetVisibility(config);
|
|
11002
|
+
const opencodeCfg = readOpencodeConfig(config);
|
|
10936
11003
|
const catalogClient = options.catalogClient ?? new import_catalog_client.CatalogClient({ discoveryApi: discovery });
|
|
10937
11004
|
if (provisioningEnabled) {
|
|
10938
11005
|
logger.info(
|
|
@@ -10958,6 +11025,7 @@ async function createRouter(options) {
|
|
|
10958
11025
|
res.json({
|
|
10959
11026
|
baseUrl: publicBaseUrl,
|
|
10960
11027
|
keyGeneration: { allowUnlimitedBudget, teamRequired },
|
|
11028
|
+
opencode: { enabled: opencodeCfg.enabled },
|
|
10961
11029
|
teamManagement: {
|
|
10962
11030
|
enabled: teamMgmtEnabled,
|
|
10963
11031
|
maxBudgetCeiling: teamAdminCfg.maxBudgetCeiling,
|
|
@@ -11194,6 +11262,109 @@ async function createRouter(options) {
|
|
|
11194
11262
|
res.status(500).json({ error: error.message });
|
|
11195
11263
|
}
|
|
11196
11264
|
});
|
|
11265
|
+
if (opencodeCfg.enabled) {
|
|
11266
|
+
logger.info(
|
|
11267
|
+
`OpenCode connect endpoint enabled \u2014 duration=${opencodeCfg.keyDuration}, maxBudget=$${opencodeCfg.maxBudget}`
|
|
11268
|
+
);
|
|
11269
|
+
router.get("/opencode/connect", async (req, res) => {
|
|
11270
|
+
try {
|
|
11271
|
+
const tokenEntityRef = await resolveUserId(req, auth);
|
|
11272
|
+
if (!tokenEntityRef) {
|
|
11273
|
+
res.status(401).json({ error: "Authentication required" });
|
|
11274
|
+
return;
|
|
11275
|
+
}
|
|
11276
|
+
const redirectUri = req.query.redirect_uri;
|
|
11277
|
+
if (!isValidRedirectUri(redirectUri)) {
|
|
11278
|
+
res.status(400).json({
|
|
11279
|
+
error: "Invalid redirect_uri \u2014 expected http://localhost:<port>/callback"
|
|
11280
|
+
});
|
|
11281
|
+
return;
|
|
11282
|
+
}
|
|
11283
|
+
if (!await assertPermission(req, litellmKeyCreatePermission)) {
|
|
11284
|
+
sendPermissionDenied(res, litellmKeyCreatePermission);
|
|
11285
|
+
return;
|
|
11286
|
+
}
|
|
11287
|
+
const userId = toLiteLLMUserId(tokenEntityRef, userIdDomain);
|
|
11288
|
+
const userInfo = await getOrProvisionUser(
|
|
11289
|
+
client,
|
|
11290
|
+
tokenEntityRef,
|
|
11291
|
+
userId,
|
|
11292
|
+
provisioningEnabled,
|
|
11293
|
+
provisioningDefaults,
|
|
11294
|
+
roleConfigs,
|
|
11295
|
+
catalogClient,
|
|
11296
|
+
auth,
|
|
11297
|
+
logger
|
|
11298
|
+
);
|
|
11299
|
+
const teamId = req.query.team;
|
|
11300
|
+
if (teamId) {
|
|
11301
|
+
const userTeams = userInfo?.teams ?? [];
|
|
11302
|
+
if (!userTeams.includes(teamId)) {
|
|
11303
|
+
res.status(403).json({
|
|
11304
|
+
error: "Access denied: team is not one of your teams",
|
|
11305
|
+
team: teamId
|
|
11306
|
+
});
|
|
11307
|
+
return;
|
|
11308
|
+
}
|
|
11309
|
+
} else if (opencodeCfg.requireTeam) {
|
|
11310
|
+
res.status(400).json({ error: "A team is required for this connection" });
|
|
11311
|
+
return;
|
|
11312
|
+
}
|
|
11313
|
+
const alias = teamId ? `opencode-${userId}-${teamId}` : `opencode-${userId}`;
|
|
11314
|
+
const existing = await client.listKeys(userId).catch(() => []);
|
|
11315
|
+
const reusable = existing.find(
|
|
11316
|
+
(k) => k.key_alias === alias && !k.blocked && k.user_id === userId
|
|
11317
|
+
);
|
|
11318
|
+
let key;
|
|
11319
|
+
if (reusable?.token ?? reusable?.key) {
|
|
11320
|
+
key = reusable.token ?? reusable.key;
|
|
11321
|
+
} else {
|
|
11322
|
+
const profile = await resolveUserProfile(
|
|
11323
|
+
tokenEntityRef,
|
|
11324
|
+
catalogClient,
|
|
11325
|
+
auth,
|
|
11326
|
+
logger
|
|
11327
|
+
);
|
|
11328
|
+
const result = await client.generateKey({
|
|
11329
|
+
alias,
|
|
11330
|
+
duration: opencodeCfg.keyDuration,
|
|
11331
|
+
max_budget: opencodeCfg.maxBudget,
|
|
11332
|
+
team_id: teamId,
|
|
11333
|
+
metadata: {
|
|
11334
|
+
...opencodeCfg.metadata,
|
|
11335
|
+
created_via: "opencode-connect",
|
|
11336
|
+
created_by_backstage_user: tokenEntityRef,
|
|
11337
|
+
...profile.email && { created_by_email: profile.email },
|
|
11338
|
+
...teamId && { opencode_team: teamId }
|
|
11339
|
+
},
|
|
11340
|
+
user_id: userId
|
|
11341
|
+
});
|
|
11342
|
+
if (!result.key) {
|
|
11343
|
+
res.status(502).json({ error: "LiteLLM returned no key material" });
|
|
11344
|
+
return;
|
|
11345
|
+
}
|
|
11346
|
+
key = result.key;
|
|
11347
|
+
}
|
|
11348
|
+
const url = new URL(redirectUri);
|
|
11349
|
+
url.searchParams.set("key", key);
|
|
11350
|
+
if (teamId) url.searchParams.set("team", teamId);
|
|
11351
|
+
logger.info({
|
|
11352
|
+
action: "opencode.connect",
|
|
11353
|
+
userId,
|
|
11354
|
+
team: teamId ?? null,
|
|
11355
|
+
reused: Boolean(reusable?.token ?? reusable?.key)
|
|
11356
|
+
});
|
|
11357
|
+
res.redirect(302, url.href);
|
|
11358
|
+
} catch (error) {
|
|
11359
|
+
if (error instanceof ProvisioningError) {
|
|
11360
|
+
res.status(error.status).json(error.body);
|
|
11361
|
+
return;
|
|
11362
|
+
}
|
|
11363
|
+
logger.error("OpenCode connect failed", error);
|
|
11364
|
+
res.status(500).json({ error: error.message });
|
|
11365
|
+
}
|
|
11366
|
+
});
|
|
11367
|
+
}
|
|
11197
11368
|
router.post("/keys/:keyId/update", async (req, res) => {
|
|
11198
11369
|
try {
|
|
11199
11370
|
const { keyId } = req.params;
|
|
@@ -12150,6 +12321,7 @@ var litellmPlugin = (0, import_backend_plugin_api.createBackendPlugin)({
|
|
|
12150
12321
|
litellmTeamMcpManagePermission,
|
|
12151
12322
|
litellmTeamMembersManagePermission,
|
|
12152
12323
|
newDefaultVerifier,
|
|
12324
|
+
normalizeRequestTags,
|
|
12153
12325
|
provisionUser,
|
|
12154
12326
|
readBridgeConfig,
|
|
12155
12327
|
readProvisioningDefaults,
|