@acarmisc/backstage-plugin-litellm-backend 0.12.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/config.d.ts +28 -0
- package/dist/client.d.ts +18 -1
- package/dist/client.js +54 -0
- package/dist/index.cjs.js +298 -9
- package/dist/index.cjs.js.map +3 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.js +7 -1
- package/dist/openapi.js +9 -0
- package/dist/opencode.d.ts +30 -0
- package/dist/opencode.js +13 -0
- package/dist/router.js +196 -9
- package/dist/teamBudgetVisibility.d.ts +48 -0
- package/dist/teamBudgetVisibility.js +76 -0
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +46 -0
- package/package.json +1 -1
package/config.d.ts
CHANGED
|
@@ -248,6 +248,34 @@ export interface Config {
|
|
|
248
248
|
};
|
|
249
249
|
};
|
|
250
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Display controls for team budget amounts in the frontend.
|
|
253
|
+
* Each flag independently hides real dollar budgets while the backend
|
|
254
|
+
* still exposes the consumption level (percent of cap + ok/near/over
|
|
255
|
+
* status + reset window). Enforcement is server-side: the backend
|
|
256
|
+
* redacts max_budget/spend from the corresponding endpoints, so the
|
|
257
|
+
* dollars are not merely hidden in CSS.
|
|
258
|
+
*/
|
|
259
|
+
display?: {
|
|
260
|
+
/**
|
|
261
|
+
* Hide dollar budgets from regular team members (GET /teams, Teams
|
|
262
|
+
* cards, TEAM section of the budget widget). Team managers still see
|
|
263
|
+
* dollars unless hideTeamBudgetForManagers is also set.
|
|
264
|
+
* @default false
|
|
265
|
+
* @visibility frontend
|
|
266
|
+
*/
|
|
267
|
+
hideTeamBudgetForMembers?: boolean;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Hide dollar budgets even from team managers (GET /teams/managed,
|
|
271
|
+
* team write responses, ManageTeamDialog budget field which becomes
|
|
272
|
+
* write-only). Useful when budgets are finance-sensitive.
|
|
273
|
+
* @default false
|
|
274
|
+
* @visibility frontend
|
|
275
|
+
*/
|
|
276
|
+
hideTeamBudgetForManagers?: boolean;
|
|
277
|
+
};
|
|
278
|
+
|
|
251
279
|
bridge?: {
|
|
252
280
|
/**
|
|
253
281
|
* When true, mount the /bridge/keys, /bridge/keys (POST), /bridge/models
|
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,15 +7997,20 @@ __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,
|
|
8003
8004
|
readRoleConfigs: () => readRoleConfigs,
|
|
8004
8005
|
readTeamAdminConfig: () => readTeamAdminConfig,
|
|
8006
|
+
readTeamBudgetVisibility: () => readTeamBudgetVisibility,
|
|
8007
|
+
redactTeamBudget: () => redactTeamBudget,
|
|
8008
|
+
redactTeamUsage: () => redactTeamUsage,
|
|
8005
8009
|
resolveBridgeUserId: () => resolveBridgeUserId,
|
|
8006
8010
|
resolveUserId: () => resolveUserId,
|
|
8007
8011
|
resolveUserProfile: () => resolveUserProfile,
|
|
8008
8012
|
resolveUserRole: () => resolveUserRole,
|
|
8013
|
+
teamBudgetStatusFor: () => teamBudgetStatusFor,
|
|
8009
8014
|
toLiteLLMUserId: () => toLiteLLMUserId,
|
|
8010
8015
|
validateTeamPatchInput: () => validateTeamPatchInput,
|
|
8011
8016
|
validateTeamWriteInput: () => validateTeamWriteInput
|
|
@@ -8022,6 +8027,11 @@ var import_catalog_client = require("@backstage/catalog-client");
|
|
|
8022
8027
|
|
|
8023
8028
|
// src/client.ts
|
|
8024
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
|
+
}
|
|
8025
8035
|
var LiteLLMUpstreamError = class extends Error {
|
|
8026
8036
|
constructor(status, statusText, body) {
|
|
8027
8037
|
let message2 = `LiteLLM API error: ${status} ${statusText} - ${body}`;
|
|
@@ -8425,6 +8435,43 @@ var LiteLLMClient = class {
|
|
|
8425
8435
|
url: r?.url ?? void 0
|
|
8426
8436
|
})).filter((r) => r.id);
|
|
8427
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
|
+
}
|
|
8428
8475
|
emptyUsage() {
|
|
8429
8476
|
return {
|
|
8430
8477
|
total_spend: 0,
|
|
@@ -8629,6 +8676,10 @@ var openApiSpec = {
|
|
|
8629
8676
|
keyGeneration: { type: "object", properties: {
|
|
8630
8677
|
allowUnlimitedBudget: { type: "boolean" },
|
|
8631
8678
|
teamRequired: { type: "boolean" }
|
|
8679
|
+
} },
|
|
8680
|
+
display: { type: "object", properties: {
|
|
8681
|
+
hideTeamBudgetForMembers: { type: "boolean" },
|
|
8682
|
+
hideTeamBudgetForManagers: { type: "boolean" }
|
|
8632
8683
|
} }
|
|
8633
8684
|
} } } }
|
|
8634
8685
|
}
|
|
@@ -8733,6 +8784,7 @@ var openApiSpec = {
|
|
|
8733
8784
|
get: {
|
|
8734
8785
|
tags: ["Teams"],
|
|
8735
8786
|
summary: "List teams the current user belongs to",
|
|
8787
|
+
description: "When litellm.display.hideTeamBudgetForMembers is set, max_budget/spend are stripped and budget_pct/budget_status/budget_hidden carry the consumption level instead.",
|
|
8736
8788
|
responses: { "200": { description: "Array of teams" } }
|
|
8737
8789
|
}
|
|
8738
8790
|
},
|
|
@@ -8740,6 +8792,7 @@ var openApiSpec = {
|
|
|
8740
8792
|
get: {
|
|
8741
8793
|
tags: ["Teams"],
|
|
8742
8794
|
summary: "Usage metrics for a team",
|
|
8795
|
+
description: "Spend fields are zeroed when the caller is subject to team budget hiding (member or manager flag, whichever applies to them).",
|
|
8743
8796
|
parameters: [
|
|
8744
8797
|
{ name: "teamId", in: "path", required: true, schema: { type: "string" } },
|
|
8745
8798
|
{ name: "start_date", in: "query", required: true, schema: { type: "string" } },
|
|
@@ -10827,6 +10880,79 @@ function validateTeamPatchInput(input, cfg) {
|
|
|
10827
10880
|
return { ok: true, value: result };
|
|
10828
10881
|
}
|
|
10829
10882
|
|
|
10883
|
+
// src/teamBudgetVisibility.ts
|
|
10884
|
+
function teamBudgetStatusFor(spend, budget) {
|
|
10885
|
+
if (budget > 0 && spend >= budget) return "over";
|
|
10886
|
+
if (budget > 0 && spend >= budget * 0.8) return "near";
|
|
10887
|
+
return "ok";
|
|
10888
|
+
}
|
|
10889
|
+
function readTeamBudgetVisibility(config) {
|
|
10890
|
+
return {
|
|
10891
|
+
hideTeamBudgetForMembers: config.getOptionalBoolean(
|
|
10892
|
+
"litellm.display.hideTeamBudgetForMembers"
|
|
10893
|
+
) ?? false,
|
|
10894
|
+
hideTeamBudgetForManagers: config.getOptionalBoolean(
|
|
10895
|
+
"litellm.display.hideTeamBudgetForManagers"
|
|
10896
|
+
) ?? false
|
|
10897
|
+
};
|
|
10898
|
+
}
|
|
10899
|
+
function redactTeamBudget(team) {
|
|
10900
|
+
const budget = team.max_budget ?? 0;
|
|
10901
|
+
if (budget <= 0) return team;
|
|
10902
|
+
const spend = team.spend ?? 0;
|
|
10903
|
+
const pct = Math.min(100, Math.max(0, spend / budget * 100));
|
|
10904
|
+
const { max_budget: _dropped, ...rest } = team;
|
|
10905
|
+
return {
|
|
10906
|
+
...rest,
|
|
10907
|
+
spend: 0,
|
|
10908
|
+
budget_pct: pct,
|
|
10909
|
+
budget_status: teamBudgetStatusFor(spend, budget),
|
|
10910
|
+
budget_hidden: true
|
|
10911
|
+
};
|
|
10912
|
+
}
|
|
10913
|
+
function redactTeamUsage(usage) {
|
|
10914
|
+
const zeroSpend = (p) => ({
|
|
10915
|
+
...p,
|
|
10916
|
+
spend: 0
|
|
10917
|
+
});
|
|
10918
|
+
const zeroTotalSpend = (p) => ({
|
|
10919
|
+
...p,
|
|
10920
|
+
total_spend: 0
|
|
10921
|
+
});
|
|
10922
|
+
return {
|
|
10923
|
+
...usage,
|
|
10924
|
+
total_spend: 0,
|
|
10925
|
+
usage_by_model: Object.fromEntries(
|
|
10926
|
+
Object.entries(usage.usage_by_model ?? {}).map(([k, v]) => [
|
|
10927
|
+
k,
|
|
10928
|
+
zeroTotalSpend(v)
|
|
10929
|
+
])
|
|
10930
|
+
),
|
|
10931
|
+
usage_by_key: Object.fromEntries(
|
|
10932
|
+
Object.entries(usage.usage_by_key ?? {}).map(([k, v]) => [
|
|
10933
|
+
k,
|
|
10934
|
+
zeroTotalSpend(v)
|
|
10935
|
+
])
|
|
10936
|
+
),
|
|
10937
|
+
daily_usage: (usage.daily_usage ?? []).map(zeroSpend),
|
|
10938
|
+
daily_by_model: (usage.daily_by_model ?? []).map(zeroSpend)
|
|
10939
|
+
};
|
|
10940
|
+
}
|
|
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
|
+
|
|
10830
10956
|
// src/router.ts
|
|
10831
10957
|
var teamCreateInFlight = /* @__PURE__ */ new Map();
|
|
10832
10958
|
var TEAM_FETCH_RETRY_DELAYS_MS = [100, 300, 900];
|
|
@@ -10848,6 +10974,15 @@ async function withTeamFetchRetry(fn, delaysMs = TEAM_FETCH_RETRY_DELAYS_MS) {
|
|
|
10848
10974
|
}
|
|
10849
10975
|
}
|
|
10850
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
|
+
}
|
|
10851
10986
|
async function createRouter(options) {
|
|
10852
10987
|
const { config, logger, auth, discovery, permissions } = options;
|
|
10853
10988
|
const baseUrl = config.getString("litellm.baseUrl");
|
|
@@ -10863,6 +10998,8 @@ async function createRouter(options) {
|
|
|
10863
10998
|
const teamMgmtEnabled = isTeamManagementEnabled(config);
|
|
10864
10999
|
const objectPermsEnabled = isObjectPermissionsEnabled(config);
|
|
10865
11000
|
const teamAdminCfg = readTeamAdminConfig(config);
|
|
11001
|
+
const teamBudgetVisibility = readTeamBudgetVisibility(config);
|
|
11002
|
+
const opencodeCfg = readOpencodeConfig(config);
|
|
10866
11003
|
const catalogClient = options.catalogClient ?? new import_catalog_client.CatalogClient({ discoveryApi: discovery });
|
|
10867
11004
|
if (provisioningEnabled) {
|
|
10868
11005
|
logger.info(
|
|
@@ -10874,6 +11011,11 @@ async function createRouter(options) {
|
|
|
10874
11011
|
"litellm.teamAdmin is configured but team management is disabled \u2014 set permission.enabled: true and litellm.teamAdmin.group to enable it."
|
|
10875
11012
|
);
|
|
10876
11013
|
}
|
|
11014
|
+
if (teamBudgetVisibility.hideTeamBudgetForMembers || teamBudgetVisibility.hideTeamBudgetForManagers) {
|
|
11015
|
+
logger.info(
|
|
11016
|
+
`LiteLLM team budget hiding enabled \u2014 members: ${teamBudgetVisibility.hideTeamBudgetForMembers}, managers: ${teamBudgetVisibility.hideTeamBudgetForManagers}`
|
|
11017
|
+
);
|
|
11018
|
+
}
|
|
10877
11019
|
const router = (0, import_express.Router)();
|
|
10878
11020
|
router.use(import_express.default.json());
|
|
10879
11021
|
router.get("/health", (_req, res) => {
|
|
@@ -10883,11 +11025,16 @@ async function createRouter(options) {
|
|
|
10883
11025
|
res.json({
|
|
10884
11026
|
baseUrl: publicBaseUrl,
|
|
10885
11027
|
keyGeneration: { allowUnlimitedBudget, teamRequired },
|
|
11028
|
+
opencode: { enabled: opencodeCfg.enabled },
|
|
10886
11029
|
teamManagement: {
|
|
10887
11030
|
enabled: teamMgmtEnabled,
|
|
10888
11031
|
maxBudgetCeiling: teamAdminCfg.maxBudgetCeiling,
|
|
10889
11032
|
allowUnlimitedBudget: teamAdminCfg.allowUnlimitedBudget,
|
|
10890
11033
|
objectPermissionsEnabled: objectPermsEnabled
|
|
11034
|
+
},
|
|
11035
|
+
display: {
|
|
11036
|
+
hideTeamBudgetForMembers: teamBudgetVisibility.hideTeamBudgetForMembers,
|
|
11037
|
+
hideTeamBudgetForManagers: teamBudgetVisibility.hideTeamBudgetForManagers
|
|
10891
11038
|
}
|
|
10892
11039
|
});
|
|
10893
11040
|
});
|
|
@@ -11115,6 +11262,109 @@ async function createRouter(options) {
|
|
|
11115
11262
|
res.status(500).json({ error: error.message });
|
|
11116
11263
|
}
|
|
11117
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
|
+
}
|
|
11118
11368
|
router.post("/keys/:keyId/update", async (req, res) => {
|
|
11119
11369
|
try {
|
|
11120
11370
|
const { keyId } = req.params;
|
|
@@ -11287,7 +11537,10 @@ async function createRouter(options) {
|
|
|
11287
11537
|
})
|
|
11288
11538
|
)
|
|
11289
11539
|
);
|
|
11290
|
-
|
|
11540
|
+
const list = teams.filter(Boolean);
|
|
11541
|
+
res.json(
|
|
11542
|
+
teamBudgetVisibility.hideTeamBudgetForMembers ? list.map(redactTeamBudget) : list
|
|
11543
|
+
);
|
|
11291
11544
|
} catch (error) {
|
|
11292
11545
|
if (error instanceof ProvisioningError) {
|
|
11293
11546
|
res.status(error.status).json(error.body);
|
|
@@ -11386,7 +11639,9 @@ async function createRouter(options) {
|
|
|
11386
11639
|
teamCreateInFlight.set(key, createPromise);
|
|
11387
11640
|
try {
|
|
11388
11641
|
const result = await createPromise;
|
|
11389
|
-
res.json(
|
|
11642
|
+
res.json(
|
|
11643
|
+
teamBudgetVisibility.hideTeamBudgetForManagers ? redactTeamBudget(result) : result
|
|
11644
|
+
);
|
|
11390
11645
|
} catch (err) {
|
|
11391
11646
|
sendTeamError(err, res);
|
|
11392
11647
|
} finally {
|
|
@@ -11430,7 +11685,9 @@ async function createRouter(options) {
|
|
|
11430
11685
|
teamId,
|
|
11431
11686
|
owningGroup
|
|
11432
11687
|
});
|
|
11433
|
-
res.json(
|
|
11688
|
+
res.json(
|
|
11689
|
+
teamBudgetVisibility.hideTeamBudgetForManagers ? redactTeamBudget(r) : r
|
|
11690
|
+
);
|
|
11434
11691
|
} catch (err) {
|
|
11435
11692
|
sendTeamError(err, res);
|
|
11436
11693
|
}
|
|
@@ -11488,7 +11745,9 @@ async function createRouter(options) {
|
|
|
11488
11745
|
const owned = all.filter(
|
|
11489
11746
|
(t) => typeof t.metadata?.owning_group === "string" && t.metadata.owning_group === teamAdminCfg.group
|
|
11490
11747
|
);
|
|
11491
|
-
res.json(
|
|
11748
|
+
res.json(
|
|
11749
|
+
teamBudgetVisibility.hideTeamBudgetForManagers ? owned.map(redactTeamBudget) : owned
|
|
11750
|
+
);
|
|
11492
11751
|
} catch (err) {
|
|
11493
11752
|
sendTeamError(err, res);
|
|
11494
11753
|
}
|
|
@@ -11630,7 +11889,9 @@ async function createRouter(options) {
|
|
|
11630
11889
|
owningGroup
|
|
11631
11890
|
});
|
|
11632
11891
|
const updated = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
|
|
11633
|
-
res.json(
|
|
11892
|
+
res.json(
|
|
11893
|
+
teamBudgetVisibility.hideTeamBudgetForManagers ? redactTeamBudget(updated) : updated
|
|
11894
|
+
);
|
|
11634
11895
|
} catch (err) {
|
|
11635
11896
|
sendTeamError(err, res);
|
|
11636
11897
|
}
|
|
@@ -11661,7 +11922,9 @@ async function createRouter(options) {
|
|
|
11661
11922
|
owningGroup
|
|
11662
11923
|
});
|
|
11663
11924
|
const updated = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
|
|
11664
|
-
res.json(
|
|
11925
|
+
res.json(
|
|
11926
|
+
teamBudgetVisibility.hideTeamBudgetForManagers ? redactTeamBudget(updated) : updated
|
|
11927
|
+
);
|
|
11665
11928
|
} catch (err) {
|
|
11666
11929
|
sendTeamError(err, res);
|
|
11667
11930
|
}
|
|
@@ -11740,7 +12003,9 @@ async function createRouter(options) {
|
|
|
11740
12003
|
owningGroup,
|
|
11741
12004
|
vector_stores: requested
|
|
11742
12005
|
});
|
|
11743
|
-
res.json(
|
|
12006
|
+
res.json(
|
|
12007
|
+
teamBudgetVisibility.hideTeamBudgetForManagers ? redactTeamBudget(updated) : updated
|
|
12008
|
+
);
|
|
11744
12009
|
} catch (err) {
|
|
11745
12010
|
sendTeamError(err, res);
|
|
11746
12011
|
}
|
|
@@ -11821,7 +12086,9 @@ async function createRouter(options) {
|
|
|
11821
12086
|
before,
|
|
11822
12087
|
after: requested
|
|
11823
12088
|
});
|
|
11824
|
-
res.json(
|
|
12089
|
+
res.json(
|
|
12090
|
+
teamBudgetVisibility.hideTeamBudgetForManagers ? redactTeamBudget(updated) : updated
|
|
12091
|
+
);
|
|
11825
12092
|
} catch (err) {
|
|
11826
12093
|
sendTeamError(err, res);
|
|
11827
12094
|
}
|
|
@@ -11840,7 +12107,24 @@ async function createRouter(options) {
|
|
|
11840
12107
|
start_date,
|
|
11841
12108
|
end_date
|
|
11842
12109
|
);
|
|
11843
|
-
|
|
12110
|
+
let hide = teamBudgetVisibility.hideTeamBudgetForMembers;
|
|
12111
|
+
if (teamAdminCfg.group) {
|
|
12112
|
+
try {
|
|
12113
|
+
const tokenEntityRef = await resolveUserId(req, auth);
|
|
12114
|
+
const isManager = !!tokenEntityRef && await isUserMemberOfGroup(
|
|
12115
|
+
tokenEntityRef,
|
|
12116
|
+
teamAdminCfg.group,
|
|
12117
|
+
catalogClient,
|
|
12118
|
+
auth,
|
|
12119
|
+
logger
|
|
12120
|
+
);
|
|
12121
|
+
hide = isManager ? teamBudgetVisibility.hideTeamBudgetForManagers : teamBudgetVisibility.hideTeamBudgetForMembers;
|
|
12122
|
+
} catch (err) {
|
|
12123
|
+
logger.warn(`Team-manager check failed, hiding team spend: ${err.message}`);
|
|
12124
|
+
hide = teamBudgetVisibility.hideTeamBudgetForMembers;
|
|
12125
|
+
}
|
|
12126
|
+
}
|
|
12127
|
+
res.json(hide ? redactTeamUsage(usage) : usage);
|
|
11844
12128
|
} catch (error) {
|
|
11845
12129
|
logger.error("Failed to fetch team usage", error);
|
|
11846
12130
|
res.status(500).json({ error: error.message });
|
|
@@ -12037,15 +12321,20 @@ var litellmPlugin = (0, import_backend_plugin_api.createBackendPlugin)({
|
|
|
12037
12321
|
litellmTeamMcpManagePermission,
|
|
12038
12322
|
litellmTeamMembersManagePermission,
|
|
12039
12323
|
newDefaultVerifier,
|
|
12324
|
+
normalizeRequestTags,
|
|
12040
12325
|
provisionUser,
|
|
12041
12326
|
readBridgeConfig,
|
|
12042
12327
|
readProvisioningDefaults,
|
|
12043
12328
|
readRoleConfigs,
|
|
12044
12329
|
readTeamAdminConfig,
|
|
12330
|
+
readTeamBudgetVisibility,
|
|
12331
|
+
redactTeamBudget,
|
|
12332
|
+
redactTeamUsage,
|
|
12045
12333
|
resolveBridgeUserId,
|
|
12046
12334
|
resolveUserId,
|
|
12047
12335
|
resolveUserProfile,
|
|
12048
12336
|
resolveUserRole,
|
|
12337
|
+
teamBudgetStatusFor,
|
|
12049
12338
|
toLiteLLMUserId,
|
|
12050
12339
|
validateTeamPatchInput,
|
|
12051
12340
|
validateTeamWriteInput
|