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