@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 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,