@acarmisc/backstage-plugin-litellm-backend 0.18.0 → 0.20.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/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # @acarmisc/backstage-plugin-litellm-backend
2
+
3
+ Backend for the **Backstage LiteLLM Governance Plugin** (New Backend System).
4
+ It is the only component that talks to your [LiteLLM](https://github.com/BerriAI/litellm)
5
+ proxy: it holds the master key, resolves the caller's Backstage identity, and
6
+ enforces budgets, key limits, team membership, model access and permissions
7
+ before calling LiteLLM.
8
+
9
+ Pair it with the frontend, [`@acarmisc/backstage-plugin-litellm`](https://www.npmjs.com/package/@acarmisc/backstage-plugin-litellm).
10
+
11
+ ![How the backend handles a key request](https://raw.githubusercontent.com/acarmisc/backstage-plugin-litellm-govai/main/docs/images/architecture.png)
12
+
13
+ Full documentation — adoption guide, configuration reference, permissions,
14
+ security model and architecture — starts at the
15
+ [project README](https://github.com/acarmisc/backstage-plugin-litellm-govai#readme).
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ yarn --cwd packages/backend add @acarmisc/backstage-plugin-litellm-backend
21
+ ```
22
+
23
+ In `packages/backend/src/index.ts`:
24
+
25
+ ```ts
26
+ backend.add(import('@acarmisc/backstage-plugin-litellm-backend'));
27
+ ```
28
+
29
+ ## Minimal configuration
30
+
31
+ ```yaml
32
+ litellm:
33
+ baseUrl: http://litellm-proxy:4000 # internal URL of the LiteLLM proxy
34
+ publicBaseUrl: https://llm.example.com # optional: shown to users in snippets
35
+ masterKey: ${LITELLM_MASTER_KEY}
36
+ userIdDomain: example.com # optional: user:default/jane → jane@example.com
37
+ provisioning:
38
+ enabled: true # optional: create LiteLLM users on first visit
39
+ ```
40
+
41
+ The configuration schema ships with the package (`config.d.ts`); see the
42
+ [configuration reference](https://github.com/acarmisc/backstage-plugin-litellm-govai/blob/main/docs/configuration.md)
43
+ for every key (key ceilings, team management, budget hiding, CLI bridge,
44
+ OpenCode connect).
45
+
46
+ ## What it exposes
47
+
48
+ Routes are mounted under `/api/litellm`. A machine-readable OpenAPI 3.1
49
+ description is served at `/api/litellm/openapi.json`; the
50
+ [endpoint table](https://github.com/acarmisc/backstage-plugin-litellm-govai/blob/main/docs/api.md)
51
+ summarises them.
52
+
53
+ The plugin registers the `litellm.*` permissions with the permission
54
+ framework (see [Permissions](https://github.com/acarmisc/backstage-plugin-litellm-govai/blob/main/docs/permissions.md)).
55
+
56
+ ## License
57
+
58
+ Apache-2.0
package/config.d.ts CHANGED
@@ -171,6 +171,16 @@ export interface Config {
171
171
  */
172
172
  allowedDurations?: string[];
173
173
 
174
+ /**
175
+ * Allowed `budget_duration` values for new keys (how often a key's
176
+ * spend resets, e.g. "30d" or "1mo"). Requests with a value not in this
177
+ * list are rejected with a 400 error. An empty array allows any value
178
+ * matching /^\d+(s|m|h|d|mo)$/. Omitting the field keeps today's
179
+ * behaviour: the key's budget never resets.
180
+ * @default ["1d", "7d", "30d", "1mo"]
181
+ */
182
+ allowedBudgetDurations?: string[];
183
+
174
184
  /**
175
185
  * When true, key owners can reset their key's spend counter via the
176
186
  * "Reset Spend" button in the frontend, subject to the
@@ -397,6 +407,37 @@ export interface Config {
397
407
  hideTeamBudgetForManagers?: boolean;
398
408
  };
399
409
 
410
+ /**
411
+ * Team usage views beyond the team totals shown to every member.
412
+ */
413
+ teamUsage?: {
414
+ /**
415
+ * Per-member usage breakdown (GET /teams/:teamId/usage/members and the
416
+ * "Usage by member" table on the Teams tab). Per-person spend is
417
+ * sensitive, so the breakdown is off by default and, when on, only
418
+ * reaches:
419
+ * - managers of the team (members of its metadata.owning_group, with
420
+ * team management enabled);
421
+ * - members of the team holding one of `viewerRoles` in it;
422
+ * - members of the team granted `litellm.team.usage.read` (only
423
+ * consulted when permission.enabled is true).
424
+ */
425
+ memberBreakdown?: {
426
+ /**
427
+ * When false, the route returns 404 and the UI hides the table.
428
+ * @default false
429
+ */
430
+ enabled?: boolean;
431
+
432
+ /**
433
+ * LiteLLM team roles (e.g. ["admin"]) whose holders may view their
434
+ * own team's breakdown.
435
+ * @default []
436
+ */
437
+ viewerRoles?: string[];
438
+ };
439
+ };
440
+
400
441
  bridge?: {
401
442
  /**
402
443
  * When true, mount the /bridge/keys, /bridge/keys (POST), /bridge/models
package/dist/bridge.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Config } from '@backstage/config';
2
2
  import { LoggerService } from '@backstage/backend-plugin-api';
3
3
  import { LiteLLMClient } from './client';
4
- import { GenerateKeyRequest, GenerateKeyResponse, ProvisioningDefaults, UserInfo, VirtualKey } from './types';
4
+ import { ProvisioningDefaults, UserInfo, VirtualKey } from './types';
5
5
  /** Claims extracted from a verified Keycloak access token. */
6
6
  export interface BridgeClaims {
7
7
  sub: string;
@@ -94,7 +94,7 @@ export interface BridgeIdentityOptions {
94
94
  *
95
95
  * @throws BridgeIdentityError (403) if identity validation fails
96
96
  */
97
- export declare function resolveBridgeUserId(claims: BridgeClaims, identity?: string | BridgeIdentityOptions): string;
97
+ export declare function resolveBridgeUserId(claims: BridgeClaims, identity?: BridgeIdentityOptions): string;
98
98
  /**
99
99
  * Ensures a LiteLLM user exists for the verified identity. If the user is
100
100
  * missing and provisioning is enabled, creates it from the JWT claims (email +
@@ -107,8 +107,6 @@ export declare function resolveBridgeUserId(claims: BridgeClaims, identity?: str
107
107
  * preferred_username), and helps recover from cases where the bridge's userId
108
108
  * computation and the UI's differ.
109
109
  */
110
- export declare function getOrProvisionUserFromClaims(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string | BridgeIdentityOptions): Promise<UserInfo>;
110
+ export declare function getOrProvisionUserFromClaims(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, identity?: BridgeIdentityOptions): Promise<UserInfo>;
111
111
  /** Lists the caller's virtual keys (provisioning the user first if needed). */
112
- export declare function bridgeListKeys(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string | BridgeIdentityOptions): Promise<VirtualKey[]>;
113
- /** Mints a new virtual key for the caller (provisioning the user first if needed). */
114
- export declare function bridgeGenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, request: Partial<GenerateKeyRequest>, userIdDomain?: string | BridgeIdentityOptions): Promise<GenerateKeyResponse>;
112
+ export declare function bridgeListKeys(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, identity?: BridgeIdentityOptions): Promise<VirtualKey[]>;
package/dist/bridge.js CHANGED
@@ -6,7 +6,6 @@ exports.newDefaultVerifier = newDefaultVerifier;
6
6
  exports.resolveBridgeUserId = resolveBridgeUserId;
7
7
  exports.getOrProvisionUserFromClaims = getOrProvisionUserFromClaims;
8
8
  exports.bridgeListKeys = bridgeListKeys;
9
- exports.bridgeGenerateKey = bridgeGenerateKey;
10
9
  /**
11
10
  * Bridge — lets CLI clients (Abby) list/mint LiteLLM virtual keys without ever
12
11
  * holding the LiteLLM master key.
@@ -111,18 +110,16 @@ function newDefaultVerifier(cfg) {
111
110
  }
112
111
  return new KeycloakJWTVerifier({ issuer: cfg.issuer, clientId: cfg.clientId });
113
112
  }
114
- /** A bare string is the legacy form: `userIdDomain`, also the only trusted domain. */
115
113
  function normalizeIdentityOptions(opts) {
116
- const o = typeof opts === 'string' ? { userIdDomain: opts } : opts ?? {};
117
114
  let domains = [];
118
- if (o.trustedEmailDomains?.length) {
119
- domains = o.trustedEmailDomains;
115
+ if (opts.trustedEmailDomains?.length) {
116
+ domains = opts.trustedEmailDomains;
120
117
  }
121
- else if (o.userIdDomain) {
122
- domains = [o.userIdDomain];
118
+ else if (opts.userIdDomain) {
119
+ domains = [opts.userIdDomain];
123
120
  }
124
121
  const trusted = domains.map(d => d.toLowerCase());
125
- return { userIdDomain: o.userIdDomain, trusted };
122
+ return { userIdDomain: opts.userIdDomain, trusted };
126
123
  }
127
124
  /**
128
125
  * Resolves the LiteLLM user_id for a verified bridge token.
@@ -147,7 +144,7 @@ function normalizeIdentityOptions(opts) {
147
144
  * @throws BridgeIdentityError (403) if identity validation fails
148
145
  */
149
146
  function resolveBridgeUserId(claims, identity) {
150
- const { userIdDomain, trusted } = normalizeIdentityOptions(identity);
147
+ const { userIdDomain, trusted } = normalizeIdentityOptions(identity ?? {});
151
148
  if (trusted.length === 0) {
152
149
  throw new BridgeIdentityError('set litellm.bridge.allowedEmailDomains (or litellm.userIdDomain) to use the CLI bridge');
153
150
  }
@@ -193,8 +190,8 @@ function resolveBridgeUserId(claims, identity) {
193
190
  * preferred_username), and helps recover from cases where the bridge's userId
194
191
  * computation and the UI's differ.
195
192
  */
196
- async function getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain) {
197
- const userId = resolveBridgeUserId(claims, userIdDomain);
193
+ async function getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, identity) {
194
+ const userId = resolveBridgeUserId(claims, identity);
198
195
  const existing = await client.getUserInfo(userId);
199
196
  if (existing)
200
197
  return existing;
@@ -222,22 +219,7 @@ async function getOrProvisionUserFromClaims(client, claims, provisioningEnabled,
222
219
  return created;
223
220
  }
224
221
  /** Lists the caller's virtual keys (provisioning the user first if needed). */
225
- async function bridgeListKeys(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain) {
226
- const user = await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain);
222
+ async function bridgeListKeys(client, claims, provisioningEnabled, provisioningDefaults, logger, identity) {
223
+ const user = await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, identity);
227
224
  return client.listKeys(user.user_id);
228
225
  }
229
- /** Mints a new virtual key for the caller (provisioning the user first if needed). */
230
- async function bridgeGenerateKey(client, claims, provisioningEnabled, provisioningDefaults, logger, request, userIdDomain) {
231
- const user = await getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain);
232
- const enriched = {
233
- ...request,
234
- user_id: user.user_id,
235
- metadata: {
236
- ...(request.metadata ?? {}),
237
- created_via: 'abby-cli',
238
- created_by: user.user_id,
239
- created_at_iso: new Date().toISOString(),
240
- },
241
- };
242
- return client.generateKey(enriched);
243
- }
package/dist/client.d.ts CHANGED
@@ -1,10 +1,4 @@
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[];
1
+ import { LiteLLMConfig, UserInfo, VirtualKey, ModelInfo, UsageMetrics, TeamInfo, GenerateKeyRequest, GenerateKeyResponse, UpdateKeyRequest, DeleteKeyRequest, CreateUserRequest, CreateUserResponse, CreateTeamRequest, CreateTeamResponse, UpdateTeamRequest, AuditLogsParams, PaginatedAuditLogs } from './types';
8
2
  /**
9
3
  * Typed error for failed upstream LiteLLM responses. Preserves the HTTP
10
4
  * status and the structured `param` (e.g. `key_alias`) from the upstream
@@ -23,6 +17,12 @@ export declare class LiteLLMClient {
23
17
  private timeout;
24
18
  constructor(config: LiteLLMConfig, timeout?: number);
25
19
  private request;
20
+ /**
21
+ * Normalizes a raw LiteLLM user response to the UserInfo contract.
22
+ * Handles both `/user/info` (with `user_info` wrapper) and `/user/list`
23
+ * response shapes, and converts team arrays to team_id strings.
24
+ */
25
+ private normalizeUserInfo;
26
26
  /**
27
27
  * Returns null when the user is not found in LiteLLM (404).
28
28
  * Throws on all other errors so callers know something went wrong.
@@ -60,6 +60,11 @@ export declare class LiteLLMClient {
60
60
  * `expires` instead of `expires_at`).
61
61
  */
62
62
  listKeys(userId?: string): Promise<VirtualKey[]>;
63
+ /**
64
+ * Lists every key bound to a team (any owner), following `/key/list`
65
+ * pagination. Stops after 50 pages of 100 keys.
66
+ */
67
+ listTeamKeys(teamId: string): Promise<VirtualKey[]>;
63
68
  private toVirtualKey;
64
69
  /**
65
70
  * Creates a new virtual key on the LiteLLM proxy.
@@ -126,8 +131,6 @@ export declare class LiteLLMClient {
126
131
  createTeam(payload: CreateTeamRequest): Promise<CreateTeamResponse>;
127
132
  updateTeam(payload: UpdateTeamRequest): Promise<unknown>;
128
133
  deleteTeam(teamId: string): Promise<unknown>;
129
- blockTeam(teamId: string): Promise<unknown>;
130
- unblockTeam(teamId: string): Promise<unknown>;
131
134
  /**
132
135
  * Adds a member to a team. LiteLLM's `/team/member_add` nests the member
133
136
  * under a `member` object; `role` is 'user' or 'admin' (we only ever send
@@ -166,17 +169,6 @@ export declare class LiteLLMClient {
166
169
  name?: string;
167
170
  url?: string;
168
171
  }>>;
169
- /**
170
- * Reads per-request spend logs from LiteLLM (`GET /spend/logs`), filtered
171
- * by date range and optionally by key/user/team. Rows are normalised so
172
- * `request_tags` is always a `string[]` (LiteLLM returns either an array
173
- * or an object depending on version) — callers group by tag to attribute
174
- * spend to a conversation or caller.
175
- *
176
- * This is the admin/DB-backed endpoint: it can return a large number of
177
- * rows, so callers should keep the date window tight and cap `page_size`.
178
- */
179
- getSpendLogs(params: SpendLogsParams): Promise<SpendLogEntry[]>;
180
172
  private emptyUsage;
181
173
  /**
182
174
  * Transforms LiteLLM's SpendAnalyticsPaginatedResponse into the flatter
package/dist/client.js CHANGED
@@ -1,20 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.LiteLLMClient = exports.LiteLLMUpstreamError = void 0;
4
- exports.normalizeRequestTags = normalizeRequestTags;
5
4
  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
- }
18
5
  /**
19
6
  * Typed error for failed upstream LiteLLM responses. Preserves the HTTP
20
7
  * status and the structured `param` (e.g. `key_alias`) from the upstream
@@ -87,6 +74,33 @@ class LiteLLMClient {
87
74
  clearTimeout(timeoutId);
88
75
  }
89
76
  }
77
+ /**
78
+ * Normalizes a raw LiteLLM user response to the UserInfo contract.
79
+ * Handles both `/user/info` (with `user_info` wrapper) and `/user/list`
80
+ * response shapes, and converts team arrays to team_id strings.
81
+ */
82
+ normalizeUserInfo(raw, fallbackUserId) {
83
+ const inner = raw?.user_info ?? {};
84
+ const teamIds = Array.isArray(raw?.teams)
85
+ ? raw.teams
86
+ .map((t) => (typeof t === 'string' ? t : t?.team_id))
87
+ .filter((t) => typeof t === 'string')
88
+ : [];
89
+ return {
90
+ user_id: raw?.user_id ?? inner.user_id ?? fallbackUserId ?? '',
91
+ user_email: inner.user_email ?? raw?.user_email,
92
+ email: inner.email ?? raw?.email,
93
+ teams: teamIds,
94
+ models: inner.models ?? raw?.models,
95
+ max_budget: inner.max_budget ?? raw?.max_budget,
96
+ budget_duration: inner.budget_duration ?? raw?.budget_duration,
97
+ budget_reset_at: inner.budget_reset_at ?? raw?.budget_reset_at,
98
+ spend: inner.spend ?? raw?.spend,
99
+ current_spend: inner.current_spend ?? raw?.current_spend,
100
+ soft_limit: inner.soft_limit ?? raw?.soft_limit,
101
+ hard_limit: inner.hard_limit ?? raw?.hard_limit,
102
+ };
103
+ }
90
104
  /**
91
105
  * Returns null when the user is not found in LiteLLM (404).
92
106
  * Throws on all other errors so callers know something went wrong.
@@ -100,26 +114,7 @@ class LiteLLMClient {
100
114
  const query = userId ? `?user_id=${encodeURIComponent(userId)}` : '';
101
115
  try {
102
116
  const raw = await this.request(`/user/info${query}`);
103
- const inner = raw?.user_info ?? {};
104
- const teamIds = Array.isArray(raw?.teams)
105
- ? raw.teams
106
- .map((t) => (typeof t === 'string' ? t : t?.team_id))
107
- .filter((t) => typeof t === 'string')
108
- : [];
109
- return {
110
- user_id: raw?.user_id ?? inner.user_id ?? userId ?? '',
111
- user_email: inner.user_email ?? raw?.user_email,
112
- email: inner.email ?? raw?.email,
113
- teams: teamIds,
114
- models: inner.models ?? raw?.models,
115
- max_budget: inner.max_budget ?? raw?.max_budget,
116
- budget_duration: inner.budget_duration ?? raw?.budget_duration,
117
- budget_reset_at: inner.budget_reset_at ?? raw?.budget_reset_at,
118
- spend: inner.spend ?? raw?.spend,
119
- current_spend: inner.current_spend ?? raw?.current_spend,
120
- soft_limit: inner.soft_limit ?? raw?.soft_limit,
121
- hard_limit: inner.hard_limit ?? raw?.hard_limit,
122
- };
117
+ return this.normalizeUserInfo(raw, userId);
123
118
  }
124
119
  catch (err) {
125
120
  if (err instanceof LiteLLMUpstreamError && err.status === 404)
@@ -149,27 +144,7 @@ class LiteLLMClient {
149
144
  .toLowerCase() === email.toLowerCase());
150
145
  if (!found)
151
146
  return null;
152
- // Normalize to UserInfo shape (same as getUserInfo does)
153
- const inner = found.user_info ?? {};
154
- const teamIds = Array.isArray(found.teams)
155
- ? found.teams
156
- .map((t) => (typeof t === 'string' ? t : t?.team_id))
157
- .filter((t) => typeof t === 'string')
158
- : [];
159
- return {
160
- user_id: found.user_id ?? inner.user_id ?? '',
161
- user_email: inner.user_email ?? found.user_email,
162
- email: inner.email ?? found.email,
163
- teams: teamIds,
164
- models: inner.models ?? found.models,
165
- max_budget: inner.max_budget ?? found.max_budget,
166
- budget_duration: inner.budget_duration ?? found.budget_duration,
167
- budget_reset_at: inner.budget_reset_at ?? found.budget_reset_at,
168
- spend: inner.spend ?? found.spend,
169
- current_spend: inner.current_spend ?? found.current_spend,
170
- soft_limit: inner.soft_limit ?? found.soft_limit,
171
- hard_limit: inner.hard_limit ?? found.hard_limit,
172
- };
147
+ return this.normalizeUserInfo(found);
173
148
  }
174
149
  catch (err) {
175
150
  if (err instanceof LiteLLMUpstreamError && err.status === 404)
@@ -223,6 +198,27 @@ class LiteLLMClient {
223
198
  throw err;
224
199
  }
225
200
  }
201
+ /**
202
+ * Lists every key bound to a team (any owner), following `/key/list`
203
+ * pagination. Stops after 50 pages of 100 keys.
204
+ */
205
+ async listTeamKeys(teamId) {
206
+ const keys = [];
207
+ for (let page = 1; page <= 50; page++) {
208
+ const params = new URLSearchParams({
209
+ team_id: teamId,
210
+ return_full_object: 'true',
211
+ page: String(page),
212
+ size: '100',
213
+ });
214
+ const response = await this.request(`/key/list?${params.toString()}`);
215
+ const rawKeys = (response.keys ?? []).filter((k) => typeof k === 'object' && k !== null);
216
+ keys.push(...rawKeys.map(this.toVirtualKey));
217
+ if (rawKeys.length === 0 || page >= (response.total_pages ?? 1))
218
+ break;
219
+ }
220
+ return keys;
221
+ }
226
222
  toVirtualKey(k) {
227
223
  return {
228
224
  key: k.key_name ?? k.token,
@@ -447,18 +443,6 @@ class LiteLLMClient {
447
443
  body: JSON.stringify({ team_ids: [teamId] }),
448
444
  });
449
445
  }
450
- async blockTeam(teamId) {
451
- return this.request('/team/block', {
452
- method: 'POST',
453
- body: JSON.stringify({ team_id: teamId }),
454
- });
455
- }
456
- async unblockTeam(teamId) {
457
- return this.request('/team/unblock', {
458
- method: 'POST',
459
- body: JSON.stringify({ team_id: teamId }),
460
- });
461
- }
462
446
  /**
463
447
  * Adds a member to a team. LiteLLM's `/team/member_add` nests the member
464
448
  * under a `member` object; `role` is 'user' or 'admin' (we only ever send
@@ -532,47 +516,6 @@ class LiteLLMClient {
532
516
  }))
533
517
  .filter(r => r.id);
534
518
  }
535
- /**
536
- * Reads per-request spend logs from LiteLLM (`GET /spend/logs`), filtered
537
- * by date range and optionally by key/user/team. Rows are normalised so
538
- * `request_tags` is always a `string[]` (LiteLLM returns either an array
539
- * or an object depending on version) — callers group by tag to attribute
540
- * spend to a conversation or caller.
541
- *
542
- * This is the admin/DB-backed endpoint: it can return a large number of
543
- * rows, so callers should keep the date window tight and cap `page_size`.
544
- */
545
- async getSpendLogs(params) {
546
- const query = new URLSearchParams({
547
- start_date: params.start_date,
548
- end_date: params.end_date,
549
- });
550
- if (params.api_key)
551
- query.append('api_key', params.api_key);
552
- if (params.user_id)
553
- query.append('user_id', params.user_id);
554
- if (params.team_id)
555
- query.append('team_id', params.team_id);
556
- if (params.page_size)
557
- query.append('page_size', String(params.page_size));
558
- const raw = await this.request(`/spend/logs?${query.toString()}`);
559
- const rows = Array.isArray(raw) ? raw : raw?.data ?? [];
560
- return rows.map(row => ({
561
- request_id: row?.request_id,
562
- startTime: row?.startTime ?? row?.start_time,
563
- endTime: row?.endTime ?? row?.end_time,
564
- spend: row?.spend ?? 0,
565
- total_tokens: row?.total_tokens ?? 0,
566
- prompt_tokens: row?.prompt_tokens ?? 0,
567
- completion_tokens: row?.completion_tokens ?? 0,
568
- model: row?.model,
569
- api_key: row?.api_key,
570
- user: row?.user,
571
- team_id: row?.team_id,
572
- request_tags: normalizeRequestTags(row?.request_tags),
573
- metadata: row?.metadata,
574
- }));
575
- }
576
519
  emptyUsage() {
577
520
  return {
578
521
  total_spend: 0,
package/dist/errors.d.ts CHANGED
@@ -25,11 +25,12 @@ export declare function isHttpishError(error: unknown): error is {
25
25
  export declare function errorMessage(error: unknown): string;
26
26
  /**
27
27
  * Sanitizes a message by:
28
+ * - Redacting Bearer tokens and sk- keys
28
29
  * - Stripping HTML tags
29
30
  * - Collapsing consecutive whitespace
30
31
  * - Truncating to 500 characters
31
32
  *
32
- * @param msg The raw message (may contain HTML, newlines, etc.)
33
+ * @param msg The raw message (may contain HTML, newlines, secrets, etc.)
33
34
  * @returns A safe, printable message
34
35
  */
35
36
  export declare function sanitizeUpstreamMessage(msg: string): string;
package/dist/errors.js CHANGED
@@ -49,17 +49,20 @@ function errorMessage(error) {
49
49
  }
50
50
  /**
51
51
  * Sanitizes a message by:
52
+ * - Redacting Bearer tokens and sk- keys
52
53
  * - Stripping HTML tags
53
54
  * - Collapsing consecutive whitespace
54
55
  * - Truncating to 500 characters
55
56
  *
56
- * @param msg The raw message (may contain HTML, newlines, etc.)
57
+ * @param msg The raw message (may contain HTML, newlines, secrets, etc.)
57
58
  * @returns A safe, printable message
58
59
  */
59
60
  function sanitizeUpstreamMessage(msg) {
60
61
  if (!msg || typeof msg !== 'string')
61
62
  return '';
62
63
  return msg
64
+ .replace(/Bearer\s+[A-Za-z0-9._\-+/=]+/g, 'Bearer [redacted]') // Redact Bearer tokens
65
+ .replace(/sk-[A-Za-z0-9_\-]{8,}/g, 'sk-[redacted]') // Redact sk-* keys
63
66
  .replace(HTML_TAG_PATTERN, '') // Strip HTML tags and entities
64
67
  .replace(/\s+/g, ' ') // Collapse whitespace
65
68
  .trim()